|
| 1 | +import { parseDeviceLoginPrompt, type DeviceLoginPrompt } from "./device-login-parse.js"; |
| 2 | + |
| 3 | +// The device-login runner. It runs the Codex device-login command through an |
| 4 | +// injected {@link SandboxLoginDriver}, surfaces the login prompt one time in |
| 5 | +// memory, and handles a timeout and a cancellation. The runner always disposes |
| 6 | +// the driver. |
| 7 | +// |
| 8 | +// Security (Control 1 — secret handling): the runner treats every byte of the |
| 9 | +// sandbox stream as secret-bearing, untrusted input. It parses the stream in an |
| 10 | +// in-memory buffer only. It drops the buffer as soon as it finds the prompt. It |
| 11 | +// never forwards the raw text to a log or an artifact, and it never stores the |
| 12 | +// raw text on the result. The runner reports only a fixed, non-secret status. It |
| 13 | +// passes the prompt one time through the in-memory `onPrompt` callback. It passes |
| 14 | +// the credential bytes one time through the in-memory `onCredential` callback. |
| 15 | +// The runner keeps the URL, the code, and any token byte out of every log line |
| 16 | +// and every thrown error. |
| 17 | + |
| 18 | +/** The default Codex device-login command. */ |
| 19 | +export const CODEX_DEVICE_LOGIN_COMMAND = "codex login --device-auth"; |
| 20 | + |
| 21 | +/** |
| 22 | + * The sandbox side of the device-login run. The runner never calls Daytona |
| 23 | + * directly; a caller injects a concrete driver. A production driver binds these |
| 24 | + * three methods to a non-persisting Daytona exec path, a file read, and a |
| 25 | + * sandbox delete. |
| 26 | + */ |
| 27 | +export interface SandboxLoginDriver { |
| 28 | + /** |
| 29 | + * Runs `command` in the sandbox and streams standard output to `onStdout` in |
| 30 | + * memory. Resolves with the command exit code when the command ends. A driver |
| 31 | + * must not persist the raw output to any durable log. |
| 32 | + */ |
| 33 | + execStreaming(command: string, onStdout: (chunk: string) => void): Promise<{ exitCode: number | null }>; |
| 34 | + /** Reads the bytes of one file from the sandbox. */ |
| 35 | + readFile(path: string): Promise<Buffer>; |
| 36 | + /** Deletes the sandbox and releases its resources. */ |
| 37 | + dispose(): Promise<void>; |
| 38 | +} |
| 39 | + |
| 40 | +export type DeviceLoginOutcome = "success" | "failure" | "timeout" | "cancelled"; |
| 41 | + |
| 42 | +/** The runner result. It never carries a URL, a code, or a token byte. */ |
| 43 | +export interface DeviceLoginResult { |
| 44 | + outcome: DeviceLoginOutcome; |
| 45 | + exitCode: number | null; |
| 46 | + promptSurfaced: boolean; |
| 47 | +} |
| 48 | + |
| 49 | +export interface RunDeviceLoginOptions { |
| 50 | + /** The login command. Defaults to {@link CODEX_DEVICE_LOGIN_COMMAND}. */ |
| 51 | + command?: string; |
| 52 | + /** Receives the parsed prompt one time in memory. The caller displays it. */ |
| 53 | + onPrompt: (prompt: DeviceLoginPrompt) => void; |
| 54 | + /** |
| 55 | + * Receives the sandbox `auth.json` bytes one time in memory on success. The |
| 56 | + * runner reads the bytes with {@link SandboxLoginDriver.readFile} before it |
| 57 | + * disposes the driver. Set together with {@link authPath}. |
| 58 | + */ |
| 59 | + onCredential?: (authBytes: Buffer) => void | Promise<void>; |
| 60 | + /** The sandbox path of the credential file to read on success. */ |
| 61 | + authPath?: string; |
| 62 | + /** The host-side timeout in milliseconds. */ |
| 63 | + timeoutMs: number; |
| 64 | + /** An optional cancellation signal. */ |
| 65 | + signal?: AbortSignal; |
| 66 | + /** A non-leaking progress sink. It receives only fixed status lines. */ |
| 67 | + log?: (line: string) => void; |
| 68 | +} |
| 69 | + |
| 70 | +type RaceResult = |
| 71 | + | { kind: "exit"; exitCode: number | null } |
| 72 | + | { kind: "timeout" } |
| 73 | + | { kind: "cancelled" }; |
| 74 | + |
| 75 | +/** |
| 76 | + * Races the streaming exec against the timeout and the cancellation signal. The |
| 77 | + * exec result resolves the race; the timeout and the signal resolve the race |
| 78 | + * with a terminal status. A driver error rejects the race, so the caller can |
| 79 | + * convert it to a fixed, non-secret error. A late exec rejection after the race |
| 80 | + * already settled is consumed here, so it never becomes an unhandled rejection. |
| 81 | + */ |
| 82 | +function raceExec( |
| 83 | + exec: Promise<{ exitCode: number | null }>, |
| 84 | + timeoutMs: number, |
| 85 | + signal: AbortSignal | undefined, |
| 86 | +): Promise<RaceResult> { |
| 87 | + return new Promise<RaceResult>((resolve, reject) => { |
| 88 | + let settled = false; |
| 89 | + const cleanup = () => { |
| 90 | + clearTimeout(timer); |
| 91 | + if (signal) signal.removeEventListener("abort", onAbort); |
| 92 | + }; |
| 93 | + const finish = (run: () => void) => { |
| 94 | + if (settled) return; |
| 95 | + settled = true; |
| 96 | + cleanup(); |
| 97 | + run(); |
| 98 | + }; |
| 99 | + const timer = setTimeout(() => finish(() => resolve({ kind: "timeout" })), timeoutMs); |
| 100 | + const onAbort = () => finish(() => resolve({ kind: "cancelled" })); |
| 101 | + if (signal) signal.addEventListener("abort", onAbort, { once: true }); |
| 102 | + exec.then( |
| 103 | + (value) => finish(() => resolve({ kind: "exit", exitCode: value.exitCode })), |
| 104 | + (error) => finish(() => reject(error)), |
| 105 | + ); |
| 106 | + }); |
| 107 | +} |
| 108 | + |
| 109 | +/** |
| 110 | + * Runs the device-login command through `driver`. Surfaces the prompt one time |
| 111 | + * through `onPrompt`. On success it reads the credential and surfaces the bytes |
| 112 | + * one time through `onCredential`. Returns a fixed status. Always disposes the |
| 113 | + * driver. Never logs the raw stream, and never puts a URL, a code, or a token |
| 114 | + * into a log line, the result, or a thrown error. |
| 115 | + */ |
| 116 | +export async function runDeviceLogin( |
| 117 | + driver: SandboxLoginDriver, |
| 118 | + options: RunDeviceLoginOptions, |
| 119 | +): Promise<DeviceLoginResult> { |
| 120 | + const { onPrompt, onCredential, authPath, timeoutMs, signal } = options; |
| 121 | + const command = options.command ?? CODEX_DEVICE_LOGIN_COMMAND; |
| 122 | + const log = options.log ?? (() => {}); |
| 123 | + |
| 124 | + let promptSurfaced = false; |
| 125 | + // The in-memory parse buffer. The runner drops it as soon as it finds the |
| 126 | + // prompt, so the secret-bearing stream never lives longer than one parse. |
| 127 | + let buffer = ""; |
| 128 | + const onStdout = (chunk: string): void => { |
| 129 | + if (promptSurfaced) return; |
| 130 | + buffer += chunk; |
| 131 | + const prompt = parseDeviceLoginPrompt(buffer); |
| 132 | + if (prompt) { |
| 133 | + promptSurfaced = true; |
| 134 | + buffer = ""; |
| 135 | + onPrompt(prompt); |
| 136 | + } |
| 137 | + }; |
| 138 | + |
| 139 | + try { |
| 140 | + if (signal?.aborted) { |
| 141 | + log("[paperclip] Device login cancelled before start."); |
| 142 | + return { outcome: "cancelled", exitCode: null, promptSurfaced }; |
| 143 | + } |
| 144 | + |
| 145 | + const exec = driver.execStreaming(command, onStdout); |
| 146 | + const raced = await raceExec(exec, timeoutMs, signal); |
| 147 | + |
| 148 | + if (raced.kind === "timeout") { |
| 149 | + log("[paperclip] Device login timed out; disposing the sandbox."); |
| 150 | + return { outcome: "timeout", exitCode: null, promptSurfaced }; |
| 151 | + } |
| 152 | + if (raced.kind === "cancelled") { |
| 153 | + log("[paperclip] Device login cancelled; disposing the sandbox."); |
| 154 | + return { outcome: "cancelled", exitCode: null, promptSurfaced }; |
| 155 | + } |
| 156 | + |
| 157 | + const exitCode = raced.exitCode; |
| 158 | + if (exitCode !== 0) { |
| 159 | + log("[paperclip] Device login command ended with a non-zero exit code."); |
| 160 | + return { outcome: "failure", exitCode, promptSurfaced }; |
| 161 | + } |
| 162 | + |
| 163 | + if (onCredential && authPath) { |
| 164 | + const authBytes = await driver.readFile(authPath); |
| 165 | + await onCredential(authBytes); |
| 166 | + } |
| 167 | + log("[paperclip] Device login command ended successfully."); |
| 168 | + return { outcome: "success", exitCode, promptSurfaced }; |
| 169 | + } catch { |
| 170 | + // Convert any driver error to a fixed, non-secret error. The original error |
| 171 | + // may embed streamed bytes, so the runner never propagates its message. |
| 172 | + throw new Error("device login failed: the sandbox login command errored."); |
| 173 | + } finally { |
| 174 | + // Always dispose the driver. A dispose error must not leak or mask the |
| 175 | + // result, so the runner swallows it and logs a fixed line. |
| 176 | + try { |
| 177 | + await driver.dispose(); |
| 178 | + } catch { |
| 179 | + log("[paperclip] Device login: the sandbox dispose step errored."); |
| 180 | + } |
| 181 | + } |
| 182 | +} |
0 commit comments