Files
PaperClipAI/server/src/services/local-ai-browser-login.ts
T
DottaandPaperclip ddcd746eb6 feat(runner): qualify native Codex platforms and cache hosted builds
Extract the tested runtime and installed-package prerequisite without changing agent creation defaults or experimental gates.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-12 15:15:01 -05:00

135 lines
6.6 KiB
TypeScript

import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { open } from "node:fs/promises";
import { constants } from "node:fs";
import path from "node:path";
import { runSetupTokenLogin } from "@paperclipai/adapter-claude-local/server";
import { runDeviceLogin } from "@paperclipai/adapter-codex-local/server";
import { ensureCommandResolvable, resolveCommandForLogs } from "@paperclipai/adapter-utils/server-utils";
import { resolveCodexCommand } from "../vendor/paperclip-runner/index.js";
export type LocalBrowserLoginState = {
authorizationUrl?: string;
code?: string;
outcome?: "success" | "failure";
error?: string;
submitCode?: (code: string) => void;
abort: () => void;
};
// Node has no built-in PTY API. Python's standard-library pty module supplies
// the terminal required by both CLIs. The Docker image already includes
// python3. This bridge forwards only bytes; provider output is never logged.
const PYTHON_PTY_BRIDGE = String.raw`
import errno, os, pty, select, signal, sys
pid, master = pty.fork()
if pid == 0:
os.execvpe(sys.argv[1], sys.argv[1:], os.environ)
def stop(_signal, _frame):
try: os.killpg(pid, signal.SIGKILL)
except ProcessLookupError: pass
sys.exit(1)
signal.signal(signal.SIGTERM, stop)
while True:
readable, _, _ = select.select([master, 0], [], [])
if 0 in readable:
data = os.read(0, 4096)
if data: os.write(master, data)
else: stop(None, None)
if master in readable:
try: data = os.read(master, 4096)
except OSError as error:
if error.errno == errno.EIO: break
raise
if not data: break
os.write(1, data)
_, status = os.waitpid(pid, 0)
sys.exit(os.waitstatus_to_exitcode(status))
`;
/** Run the same browser-code login used by sandbox connections on the local host. */
export function startLocalBrowserLogin(provider: "anthropic" | "openai", home: string): LocalBrowserLoginState {
const controller = new AbortController();
const state: LocalBrowserLoginState = { abort: () => controller.abort() };
const args = provider === "anthropic" ? ["setup-token"] : ["login", "--device-auth"];
let child: ChildProcessWithoutNullStreams | null = null;
const driver = {
async start(_command: string, onData: (chunk: string) => void): Promise<{ exitCode: number | null }> {
let executable = "claude";
if (provider === "openai") {
// The installed Codex CLI is independent of the runner daemon's glibc floor.
// Supported CLI hosts use owned resolution; other platforms keep PATH lookup.
const legacyPlatform = !((process.platform === "linux" || process.platform === "darwin")
&& (process.arch === "x64" || process.arch === "arm64"));
try {
if (legacyPlatform) {
await ensureCommandResolvable("codex", home, process.env);
executable = path.resolve(home, await resolveCommandForLogs("codex", home, process.env));
} else {
executable = resolveCodexCommand();
}
} catch {
state.error = legacyPlatform
? "Codex browser sign-in requires the Codex CLI on this platform. Install Codex on this execution host and ensure codex is on Paperclip's PATH, then start sign-in again."
: "Codex browser sign-in requires an installed Codex CLI. Install Codex on this execution host or restore Paperclip's runtime dependencies, then start sign-in again.";
throw new Error("Browser login runtime unavailable");
}
}
if (controller.signal.aborted) throw new Error("Local sign-in cancelled before launch");
const env = { ...process.env,
ANTHROPIC_API_KEY: "", ANTHROPIC_AUTH_TOKEN: "", CLAUDE_CODE_OAUTH_TOKEN: "",
OPENAI_API_KEY: "", CODEX_API_KEY: "",
CLAUDE_CONFIG_DIR: provider === "anthropic" ? home : process.env.CLAUDE_CONFIG_DIR,
CODEX_HOME: provider === "openai" ? home : process.env.CODEX_HOME,
BROWSER: "true",
};
return new Promise((resolve, reject) => {
child = spawn("python3", ["-u", "-c", PYTHON_PTY_BRIDGE, executable, ...args], { env, stdio: ["pipe", "pipe", "pipe"] });
child.stdout.on("data", (bytes: Buffer) => onData(bytes.toString("utf8")));
// Provider output may contain secrets. Never log or retain stderr.
child.stderr.resume();
child.once("error", () => {
state.error = "Browser sign-in requires Python 3 and a working local terminal. Install Python 3 on this execution host, then start sign-in again.";
reject(new Error("Browser login terminal unavailable"));
});
child.once("close", (exitCode) => resolve({ exitCode }));
});
},
write(input: string) { child?.stdin.write(input); },
stop() { child?.kill("SIGTERM"); },
async dispose() { child?.kill("SIGTERM"); },
async readFile(file: string) { const { readLocalAiCredentialFile } = await import("./local-ai-credential-file.js"); return Buffer.from(await readLocalAiCredentialFile(file)); },
};
if (provider === "anthropic") {
let deliverCode: ((code: string) => void) | undefined;
const codeReady = new Promise<string>((resolve) => { deliverCode = resolve; });
state.submitCode = (code) => { deliverCode?.(code); deliverCode = undefined; };
void runSetupTokenLogin(driver, {
timeoutMs: 300_000, signal: controller.signal,
onPrompt: (prompt) => { state.authorizationUrl = prompt.url; },
provideCode: (signal) => new Promise<string>((resolve, reject) => {
const abort = () => reject(new Error("Local sign-in cancelled"));
if (signal.aborted) { abort(); return; }
signal.addEventListener("abort", abort, { once: true });
codeReady.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
}),
onCredential: async (bytes) => {
const file = await open(path.join(home, ".credentials.json"),
constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
try {
await file.writeFile(JSON.stringify({ claudeAiOauth: { accessToken: bytes.toString("utf8") } }));
} finally {
await file.close();
}
},
}).then((result) => { state.outcome = result.outcome === "success" ? "success" : "failure"; })
.catch(() => { state.outcome = "failure"; });
} else {
void runDeviceLogin(driver, {
timeoutMs: 300_000, signal: controller.signal,
onPrompt: (prompt) => { state.authorizationUrl = prompt.url; state.code = prompt.code; },
}).then((result) => { state.outcome = result.outcome === "success" ? "success" : "failure"; })
.catch(() => { state.outcome = "failure"; });
}
return state;
}