Skip to content

Commit 63b5929

Browse files
feat(adapter-codex-local): add device-login runner
Run the Codex device-login command through an injected SandboxLoginDriver. Surface the parsed prompt one time in memory and read the credential one time on success. Handle a timeout and a cancellation, and always dispose the driver. Parse the stream in an in-memory buffer only; never forward, retain, or log the raw text, and keep the URL, the code, and any token out of every log line, the result, and every thrown error. Co-authored-by: Paperclip <noreply@paperclip.ing>
1 parent c8d3786 commit 63b5929

2 files changed

Lines changed: 332 additions & 0 deletions

File tree

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import {
3+
CODEX_DEVICE_LOGIN_COMMAND,
4+
runDeviceLogin,
5+
type SandboxLoginDriver,
6+
} from "./device-login-runner.js";
7+
8+
const REAL_SHAPED_URL = "https://auth.openai.com/codex/device";
9+
const REAL_SHAPED_CODE = "WXYZ-12345";
10+
const TOKEN_SENTINEL = "SENTINEL_TOKEN_ABC123";
11+
const AUTH_BYTES = Buffer.from(
12+
JSON.stringify({ tokens: { account_id: "acc", refresh_token: TOKEN_SENTINEL } }),
13+
);
14+
15+
interface FakeDriverOptions {
16+
chunks?: string[];
17+
exitCode?: number;
18+
hang?: boolean;
19+
execError?: Error;
20+
readError?: Error;
21+
authBytes?: Buffer;
22+
}
23+
24+
function createFakeDriver(options: FakeDriverOptions = {}) {
25+
const disposeCalls = { count: 0 };
26+
const driver: SandboxLoginDriver = {
27+
async execStreaming(_command, onStdout) {
28+
if (options.execError) throw options.execError;
29+
for (const chunk of options.chunks ?? []) {
30+
onStdout(chunk);
31+
}
32+
if (options.hang) {
33+
// Never resolve. The runner must fall back to its timeout or signal.
34+
await new Promise<never>(() => {});
35+
}
36+
return { exitCode: options.exitCode ?? 0 };
37+
},
38+
async readFile() {
39+
if (options.readError) throw options.readError;
40+
return options.authBytes ?? AUTH_BYTES;
41+
},
42+
async dispose() {
43+
disposeCalls.count += 1;
44+
},
45+
};
46+
return { driver, disposeCalls };
47+
}
48+
49+
describe("runDeviceLogin", () => {
50+
it("runner_reports_prompt_then_success", async () => {
51+
const { driver, disposeCalls } = createFakeDriver({
52+
// The prompt spans two chunks; the runner must join them and fire once.
53+
chunks: [`1. Open this link\n${REAL_SHAPED_URL}\n`, `${REAL_SHAPED_CODE}\nDone.\n`],
54+
exitCode: 0,
55+
});
56+
const onPrompt = vi.fn();
57+
const onCredential = vi.fn();
58+
const result = await runDeviceLogin(driver, {
59+
onPrompt,
60+
onCredential,
61+
authPath: "/home/.codex/auth.json",
62+
timeoutMs: 1000,
63+
});
64+
expect(result.outcome).toBe("success");
65+
expect(result.exitCode).toBe(0);
66+
expect(result.promptSurfaced).toBe(true);
67+
expect(onPrompt).toHaveBeenCalledTimes(1);
68+
expect(onPrompt).toHaveBeenCalledWith({ url: REAL_SHAPED_URL, code: REAL_SHAPED_CODE });
69+
expect(onCredential).toHaveBeenCalledTimes(1);
70+
expect(onCredential).toHaveBeenCalledWith(AUTH_BYTES);
71+
expect(disposeCalls.count).toBe(1);
72+
});
73+
74+
it("runner_disposes_sandbox_on_timeout", async () => {
75+
const { driver, disposeCalls } = createFakeDriver({ hang: true });
76+
const onPrompt = vi.fn();
77+
const result = await runDeviceLogin(driver, { onPrompt, timeoutMs: 20 });
78+
expect(result.outcome).toBe("timeout");
79+
expect(disposeCalls.count).toBe(1);
80+
});
81+
82+
it("runner_disposes_sandbox_on_cancellation", async () => {
83+
const { driver, disposeCalls } = createFakeDriver({ hang: true });
84+
const controller = new AbortController();
85+
const onPrompt = vi.fn();
86+
const promise = runDeviceLogin(driver, {
87+
onPrompt,
88+
timeoutMs: 5000,
89+
signal: controller.signal,
90+
});
91+
controller.abort();
92+
const result = await promise;
93+
expect(result.outcome).toBe("cancelled");
94+
expect(disposeCalls.count).toBe(1);
95+
});
96+
97+
it("runner_reports_failure_on_nonzero_exit", async () => {
98+
const { driver, disposeCalls } = createFakeDriver({ exitCode: 7 });
99+
const onPrompt = vi.fn();
100+
const result = await runDeviceLogin(driver, { onPrompt, timeoutMs: 1000 });
101+
expect(result.outcome).toBe("failure");
102+
expect(result.exitCode).toBe(7);
103+
expect(disposeCalls.count).toBe(1);
104+
});
105+
106+
it("runner_logs_and_result_contain_no_url_code_or_token_sentinel", async () => {
107+
const logs: string[] = [];
108+
const { driver } = createFakeDriver({
109+
chunks: [
110+
`${REAL_SHAPED_URL}\n${REAL_SHAPED_CODE}\n`,
111+
`refresh_token=${TOKEN_SENTINEL}\n`,
112+
],
113+
exitCode: 0,
114+
});
115+
const result = await runDeviceLogin(driver, {
116+
onPrompt: () => {},
117+
timeoutMs: 1000,
118+
log: (line) => logs.push(line),
119+
});
120+
const haystack = `${logs.join("\n")}\n${JSON.stringify(result)}`;
121+
expect(haystack).not.toContain(REAL_SHAPED_URL);
122+
expect(haystack).not.toContain(REAL_SHAPED_CODE);
123+
expect(haystack).not.toContain(TOKEN_SENTINEL);
124+
});
125+
126+
it("runner_error_messages_contain_no_url_or_code", async () => {
127+
const { driver, disposeCalls } = createFakeDriver({
128+
// A driver error whose message embeds secret-bearing text. The runner must
129+
// never let that message reach its own thrown error.
130+
execError: new Error(`network failure while streaming ${REAL_SHAPED_URL} ${REAL_SHAPED_CODE}`),
131+
});
132+
let caught: unknown;
133+
try {
134+
await runDeviceLogin(driver, { onPrompt: () => {}, timeoutMs: 1000 });
135+
} catch (error) {
136+
caught = error;
137+
}
138+
expect(caught).toBeInstanceOf(Error);
139+
const message = (caught as Error).message;
140+
expect(message).not.toContain(REAL_SHAPED_URL);
141+
expect(message).not.toContain(REAL_SHAPED_CODE);
142+
// The driver is still disposed on the error path.
143+
expect(disposeCalls.count).toBe(1);
144+
});
145+
146+
it("exposes the default login command", () => {
147+
expect(CODEX_DEVICE_LOGIN_COMMAND).toContain("codex");
148+
expect(CODEX_DEVICE_LOGIN_COMMAND).toContain("--device-auth");
149+
});
150+
});
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
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

Comments
 (0)