From 459469638eeab4af471edf65f80ed6e27bdd6d2b Mon Sep 17 00:00:00 2001 From: Nicky Leach Date: Mon, 10 Aug 2026 16:12:30 -0700 Subject: [PATCH] feat(adapter-codex-local): add secure device-login building blocks (#11097) ## Thinking Path > - Paperclip connects AI agents to local and remote runtimes. > - The Codex local adapter needs a safe device-login flow. > - A future sandbox integration needs strict prompt validation, secret protection, cleanup, and private credential storage. > - This pull request adds tested building blocks for that flow. > - The result gives a later Daytona integration a clear security boundary. ## Linked Issues or Issue Description No public issue covers this change. **Problem or motivation** The Codex local adapter has no safe, reusable flow to prove device login inside an isolated sandbox. **Proposed solution** Add parser, runner, credential export, and proof helpers. Validate the prompt, protect login data, store credentials in a private run-scoped home, and dispose all sandbox resources. **Alternatives considered** Do not connect a production Daytona driver in this change. Use an injected sandbox driver and focused tests first. This keeps the security controls testable before live provider integration. **Roadmap alignment** The change extends the Codex local adapter. It does not add a core Paperclip route or duplicate a planned core feature. **Additional context** The flow keeps the login URL, code, and token out of logs, results, and errors. The proof home uses a company-scoped root and a run-scoped private directory. ## What Changed - Add a pure parser for the exact Codex device-login URL and one-time code shape. - Add a sandbox runner with prompt handling, timeout, cancellation, and disposal. - Add a credential export step with company scoping, path checks, payload checks, private modes, locking, and cleanup. - Add redacted device-login fixtures and focused tests for parsing, secret redaction, runner outcomes, credential export, and cleanup. ## Verification - Run `pnpm --filter @paperclipai/adapter-codex-local exec vitest run`. - Run `pnpm --filter @paperclipai/adapter-codex-local exec tsc --noEmit`. - Review tests for strict URL and code validation, timeout, cancellation, disposal, secret redaction, path safety, payload safety, file modes, and cleanup. ## Risks - This change provides building blocks, not a live Daytona proof. - A later integration must connect the runner to a concrete sandbox driver. - Credential export depends on existing Codex authentication cache helpers. - Incorrect path or payload assumptions can reject valid credentials. ## Model Used Codex, GPT-5, tool use, code execution, and repository review. The Paperclip runtime controls the exact context window and reasoning mode. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes: #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- .../src/server/__fixtures__/README.md | 38 +++ .../server/__fixtures__/device-login-edge.txt | 1 + .../__fixtures__/device-login-sample.txt | 5 + .../src/server/device-login-export.test.ts | 284 +++++++++++++++++ .../src/server/device-login-export.ts | 290 ++++++++++++++++++ .../src/server/device-login-parse.test.ts | 212 +++++++++++++ .../src/server/device-login-parse.ts | 166 ++++++++++ .../src/server/device-login-runner.test.ts | 197 ++++++++++++ .../src/server/device-login-runner.ts | 206 +++++++++++++ 9 files changed, 1399 insertions(+) create mode 100644 packages/adapters/codex-local/src/server/__fixtures__/README.md create mode 100644 packages/adapters/codex-local/src/server/__fixtures__/device-login-edge.txt create mode 100644 packages/adapters/codex-local/src/server/__fixtures__/device-login-sample.txt create mode 100644 packages/adapters/codex-local/src/server/device-login-export.test.ts create mode 100644 packages/adapters/codex-local/src/server/device-login-export.ts create mode 100644 packages/adapters/codex-local/src/server/device-login-parse.test.ts create mode 100644 packages/adapters/codex-local/src/server/device-login-parse.ts create mode 100644 packages/adapters/codex-local/src/server/device-login-runner.test.ts create mode 100644 packages/adapters/codex-local/src/server/device-login-runner.ts diff --git a/packages/adapters/codex-local/src/server/__fixtures__/README.md b/packages/adapters/codex-local/src/server/__fixtures__/README.md new file mode 100644 index 0000000000..8cbb76777b --- /dev/null +++ b/packages/adapters/codex-local/src/server/__fixtures__/README.md @@ -0,0 +1,38 @@ +# Device-login sample fixtures + +These fixtures hold redacted, real Codex device-login output. A capture step ran +`codex login --device-auth` inside a Daytona sandbox and recorded the output. The +capture step redacted every secret before it kept or posted the text. The parser +tests read these fixtures. The tests never read a live secret. + +## Source + +- Capture date (UTC): `2026-08-08`. +- Daytona image: `cr.app.daytona.io/sbox/daytona-6a8d60e245981dd72d0e647e6afae46e0fd7b8bdfdb29b08120747509a39dc33:daytona`. +- Sandbox id: `3bd22e18-2103-4227-b918-6c375fd30b49` (region `us`). +- Codex home: a new throwaway directory for each independent run. + +## Files + +| File | Row | Condition | Expected parse result | +|---|---|---|---| +| `device-login-sample.txt` | A — normal prompt | `timeout 60 codex login --device-auth` | a URL and a code | +| `device-login-edge.txt` | D — error / retry | offline run with an unreachable local proxy | `null` | + +Two more rows from the capture are not committed as fixtures. Row B (timeout +tail) printed no extra Codex line; the external `timeout` process ended the +command with exit status `124`. Row C (`codex login --help`) and Row E +(`codex --version`) are not device-login prompts. + +## Redaction + +The capture step transformed the real one-time code to question marks and kept +the shape. So `device-login-sample.txt` holds `????-?????` in the code position. +The real code alphabet is a Codex detail and the capture did not keep it. The +parser matches the grounded structure of the code — four characters, a hyphen, +then five characters — and does not invent an alphabet. The token class is bound +to alphanumerics and the redaction sentinel `?`, so the committed sample parses +to a code without a real secret in the repository. + +The capture step checked the final text for common credential field names and +for an unredacted device-code pattern. It found no match. diff --git a/packages/adapters/codex-local/src/server/__fixtures__/device-login-edge.txt b/packages/adapters/codex-local/src/server/__fixtures__/device-login-edge.txt new file mode 100644 index 0000000000..a846c11820 --- /dev/null +++ b/packages/adapters/codex-local/src/server/__fixtures__/device-login-edge.txt @@ -0,0 +1 @@ +Error logging in with device code: error sending request for url (https://auth.openai.com/api/accounts/deviceauth/) diff --git a/packages/adapters/codex-local/src/server/__fixtures__/device-login-sample.txt b/packages/adapters/codex-local/src/server/__fixtures__/device-login-sample.txt new file mode 100644 index 0000000000..9cf72aa851 --- /dev/null +++ b/packages/adapters/codex-local/src/server/__fixtures__/device-login-sample.txt @@ -0,0 +1,5 @@ +1. Open this link in your browser and sign in to your account +https://auth.openai.com/codex/device +2. Enter this one-time code (expires in 15 minutes) +????-????? +Device codes are a common phishing target. Never share this code. diff --git a/packages/adapters/codex-local/src/server/device-login-export.test.ts b/packages/adapters/codex-local/src/server/device-login-export.test.ts new file mode 100644 index 0000000000..616b23b135 --- /dev/null +++ b/packages/adapters/codex-local/src/server/device-login-export.test.ts @@ -0,0 +1,284 @@ +import { chmod, lstat, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + MAX_AUTH_JSON_BYTES, + deriveProofHome, + installDeviceLoginCredential, + removeProofHome, + resolveProofHomeRoot, +} from "./device-login-export.js"; +import { resolveManagedCodexHomeDir, resolveSharedCodexHomeDir } from "./codex-home.js"; + +const COMPANY = "company-a"; +const RUN = "run-1234"; +const NEWER = "2026-07-09T02:00:00Z"; +const OLDER = "2026-07-09T01:00:00Z"; +const ACCOUNT = "acct-42"; +const OTHER_ACCOUNT = "acct-99"; +const TOKEN_SENTINEL = "SENTINEL_TOKEN_XYZ"; + +describe("device-login credential export", () => { + const cleanupDirs: string[] = []; + + afterEach(async () => { + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop(); + if (!dir) continue; + await chmod(dir, 0o700).catch(() => undefined); + await rm(dir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + async function makeInstanceRoot(): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-proof-")); + cleanupDirs.push(dir); + return dir; + } + + function envFor(instanceHome: string, extra: Record = {}): NodeJS.ProcessEnv { + return { PAPERCLIP_HOME: instanceHome, PAPERCLIP_INSTANCE_ID: "default", ...extra }; + } + + function subscriptionAuth(input: { accountId: string; lastRefresh?: string; marker?: string }): Buffer { + const suffix = input.marker ?? input.accountId; + return Buffer.from( + JSON.stringify({ + tokens: { + id_token: `id-token-${suffix}`, + access_token: `access-token-${suffix}`, + refresh_token: `${TOKEN_SENTINEL}-${suffix}`, + account_id: input.accountId, + }, + ...(input.lastRefresh ? { last_refresh: input.lastRefresh } : {}), + }), + ); + } + + const noopLog = (_line: string): void => {}; + + it("deriveProofHome returns a unique, run-scoped path under a company-scoped root", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + const root = resolveProofHomeRoot(env, COMPANY); + expect(root).toBe( + path.resolve(home, "instances", "default", "companies", COMPANY, "codex-device-login-proof"), + ); + const a = deriveProofHome({ env, companyId: COMPANY, runId: RUN }); + const b = deriveProofHome({ env, companyId: COMPANY, runId: RUN }); + expect(a.startsWith(root + path.sep)).toBe(true); + expect(a).toContain(RUN); + expect(a).not.toBe(b); // unique per call + // Never the shared or the managed home. + expect(a).not.toBe(resolveSharedCodexHomeDir(env)); + expect(a).not.toBe(resolveManagedCodexHomeDir(env, COMPANY)); + }); + + it("export_creates_root_and_home_at_mode_0700", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + const proofHome = deriveProofHome({ env, companyId: COMPANY, runId: RUN }); + await installDeviceLoginCredential({ + sandboxAuthBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }), + proofHome, + env, + companyId: COMPANY, + log: noopLog, + }); + const root = resolveProofHomeRoot(env, COMPANY); + expect((await stat(root)).mode & 0o777).toBe(0o700); + expect((await stat(proofHome)).mode & 0o777).toBe(0o700); + }); + + it("export_seeds_empty_proof_home_with_mode_0600", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + const proofHome = deriveProofHome({ env, companyId: COMPANY, runId: RUN }); + const outcome = await installDeviceLoginCredential({ + sandboxAuthBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }), + proofHome, + env, + companyId: COMPANY, + log: noopLog, + }); + expect(outcome).toBe("seeded"); + const authPath = path.join(proofHome, "auth.json"); + expect((await stat(authPath)).mode & 0o777).toBe(0o600); + const written = JSON.parse(await readFile(authPath, "utf8")); + expect(written.tokens.account_id).toBe(ACCOUNT); + }); + + it("export_rejects_default_shared_and_managed_home", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home, { CODEX_HOME: path.join(home, "shared-codex") }); + const bytes = subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }); + const shared = resolveSharedCodexHomeDir(env); + const managed = resolveManagedCodexHomeDir(env, COMPANY); + const agentManaged = path.resolve(managed, "..", "agents", "agent-1", "codex-home"); + for (const target of [shared, managed, agentManaged]) { + await expect( + installDeviceLoginCredential({ sandboxAuthBytes: bytes, proofHome: target, env, companyId: COMPANY, log: noopLog }), + ).rejects.toThrow(); + } + }); + + it("export_rejects_symlink_or_non_regular_path", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + const root = resolveProofHomeRoot(env, COMPANY); + await mkdir(root, { recursive: true, mode: 0o700 }); + const bytes = subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }); + + // A symlinked proof home is rejected. + const realDir = path.join(home, "elsewhere"); + await mkdir(realDir, { recursive: true, mode: 0o700 }); + const linkedHome = path.join(root, `${RUN}-linked`); + await symlink(realDir, linkedHome); + await expect( + installDeviceLoginCredential({ sandboxAuthBytes: bytes, proofHome: linkedHome, env, companyId: COMPANY, log: noopLog }), + ).rejects.toThrow(); + + // A proof home whose auth.json is a symlink is rejected. + const symAuthHome = path.join(root, `${RUN}-symauth`); + await mkdir(symAuthHome, { recursive: true, mode: 0o700 }); + await symlink(path.join(home, "target.json"), path.join(symAuthHome, "auth.json")); + await expect( + installDeviceLoginCredential({ sandboxAuthBytes: bytes, proofHome: symAuthHome, env, companyId: COMPANY, log: noopLog }), + ).rejects.toThrow(); + }); + + it("export_rejects_api_key_malformed_or_oversized_payload", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + const apiKey = Buffer.from(JSON.stringify({ OPENAI_API_KEY: "sk-secret-key" })); + const malformed = Buffer.from("this is not json {"); + const oversized = Buffer.concat([ + subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }), + Buffer.alloc(MAX_AUTH_JSON_BYTES + 10, 0x20), + ]); + for (const bytes of [apiKey, malformed, oversized]) { + const proofHome = deriveProofHome({ env, companyId: COMPANY, runId: RUN }); + await expect( + installDeviceLoginCredential({ sandboxAuthBytes: bytes, proofHome, env, companyId: COMPANY, log: noopLog }), + ).rejects.toThrow(); + // No file was written on a rejected payload. + await expect(stat(path.join(proofHome, "auth.json"))).rejects.toThrow(); + } + }); + + it("export_updates_home_with_strictly_newer_same_identity", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + const proofHome = deriveProofHome({ env, companyId: COMPANY, runId: RUN }); + const authPath = path.join(proofHome, "auth.json"); + await installDeviceLoginCredential({ + sandboxAuthBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: OLDER, marker: "old" }), + proofHome, + env, + companyId: COMPANY, + log: noopLog, + }); + const outcome = await installDeviceLoginCredential({ + sandboxAuthBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER, marker: "new" }), + proofHome, + env, + companyId: COMPANY, + log: noopLog, + }); + expect(outcome).toBe("updated"); + const written = JSON.parse(await readFile(authPath, "utf8")); + expect(written.last_refresh).toBe(NEWER); + expect(written.tokens.refresh_token).toContain("new"); + }); + + it("export_keeps_home_on_older_or_different_identity", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + const proofHome = deriveProofHome({ env, companyId: COMPANY, runId: RUN }); + const authPath = path.join(proofHome, "auth.json"); + await installDeviceLoginCredential({ + sandboxAuthBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER, marker: "keep" }), + proofHome, + env, + companyId: COMPANY, + log: noopLog, + }); + // Older same identity: kept. + const olderOutcome = await installDeviceLoginCredential({ + sandboxAuthBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: OLDER, marker: "older" }), + proofHome, + env, + companyId: COMPANY, + log: noopLog, + }); + expect(olderOutcome).toBe("kept"); + // Different identity, even newer: kept. + const otherOutcome = await installDeviceLoginCredential({ + sandboxAuthBytes: subscriptionAuth({ accountId: OTHER_ACCOUNT, lastRefresh: NEWER, marker: "other" }), + proofHome, + env, + companyId: COMPANY, + log: noopLog, + }); + expect(otherOutcome).toBe("kept"); + const written = JSON.parse(await readFile(authPath, "utf8")); + expect(written.tokens.account_id).toBe(ACCOUNT); + expect(written.tokens.refresh_token).toContain("keep"); + }); + + it("export_logs_contain_no_token_bytes", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + const proofHome = deriveProofHome({ env, companyId: COMPANY, runId: RUN }); + const logs: string[] = []; + await installDeviceLoginCredential({ + sandboxAuthBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }), + proofHome, + env, + companyId: COMPANY, + log: (line) => { + logs.push(line); + }, + }); + const haystack = logs.join("\n"); + expect(haystack).not.toContain(TOKEN_SENTINEL); + expect(haystack).not.toContain(ACCOUNT); + }); + + it("cleanup_removes_proof_home_and_leaves_default_home_unchanged", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home, { CODEX_HOME: path.join(home, "shared-codex") }); + // A sentinel in a fake shared/default home must survive the cleanup. + const shared = resolveSharedCodexHomeDir(env); + await mkdir(shared, { recursive: true, mode: 0o700 }); + const sentinel = path.join(shared, "auth.json"); + await writeFile(sentinel, JSON.stringify({ tokens: { account_id: "host", refresh_token: "host" } }), { mode: 0o600 }); + + const proofHome = deriveProofHome({ env, companyId: COMPANY, runId: RUN }); + await installDeviceLoginCredential({ + sandboxAuthBytes: subscriptionAuth({ accountId: ACCOUNT, lastRefresh: NEWER }), + proofHome, + env, + companyId: COMPANY, + log: noopLog, + }); + expect((await lstat(proofHome)).isDirectory()).toBe(true); + + await removeProofHome(proofHome, { env, companyId: COMPANY }); + await expect(lstat(proofHome)).rejects.toThrow(); + // The fake default home is untouched. + expect(await readFile(sentinel, "utf8")).toContain("host"); + }); + + it("removeProofHome refuses a path outside the proof root", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + const outside = path.join(home, "not-a-proof-home"); + await mkdir(outside, { recursive: true }); + await expect(removeProofHome(outside, { env, companyId: COMPANY })).rejects.toThrow(); + // The outside directory is untouched. + expect((await lstat(outside)).isDirectory()).toBe(true); + }); +}); diff --git a/packages/adapters/codex-local/src/server/device-login-export.ts b/packages/adapters/codex-local/src/server/device-login-export.ts new file mode 100644 index 0000000000..b72f16749e --- /dev/null +++ b/packages/adapters/codex-local/src/server/device-login-export.ts @@ -0,0 +1,290 @@ +import { chmod, lstat, mkdir, rm } from "node:fs/promises"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { resolvePaperclipInstanceRootForAdapter } from "@paperclipai/adapter-utils/server-utils"; +import { readSubscriptionAccountId, writeCodexAuthCacheEntry } from "./codex-auth-cache.js"; +import { copyBackCodexAuth } from "./codex-auth-copyback.js"; +import { + codexHomeHasUsableAuth, + resolveManagedCodexHomeDir, + resolveSharedCodexHomeDir, +} from "./codex-home.js"; + +// The credential export. It installs a device-login credential into a unique, +// run-scoped, private proof home. It handles the empty-home first-login case and +// a later strictly-newer, same-identity update. It removes the proof home on +// every terminal path. +// +// Security (Control 2): the export never accepts an arbitrary host-home +// argument. It derives a unique, run-scoped proof home under a dedicated +// company-scoped root. It rejects the default, the shared, and every managed +// Codex home. It calls `lstat` on the proof-home path and rejects a symlink or a +// non-regular file. It creates the root and the proof home at mode 0700. It +// stages and renames `auth.json` at mode 0600 under a directory lock (through the +// reused seed writer and copy-back helpers). It enforces a bounded-size, +// subscription-only auth shape before any write, so it rejects an API-key, a +// malformed, and an oversized payload. It never logs token bytes. + +const PROOF_ROOT_DIR_NAME = "codex-device-login-proof"; +// A private directory (owner rwx only). 0o700 has no group or other bits. +const PRIVATE_DIR_MODE = 0o700; +// The managed Codex home directory always uses this name (see +// `resolveManagedCodexHomeDir`). The export rejects any target with this name, +// so a shared company home and a per-agent home are both refused. +const MANAGED_HOME_DIR_NAME = "codex-home"; +const AUTH_FILE_NAME = "auth.json"; + +// A bounded size for the credential payload. A real subscription `auth.json` is +// a few kilobytes. The export refuses a larger payload before any parse or write. +export const MAX_AUTH_JSON_BYTES = 64 * 1024; + +export type InstallDeviceLoginOutcome = "seeded" | "updated" | "kept"; + +function nonEmpty(value: string | undefined): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +/** + * Sanitizes one raw value to a single safe path segment. Rejects an empty value, + * a relative segment, a path separator, and a NUL byte, so the value can never + * become a path traversal. Returns the trimmed, safe segment. + */ +function requireSafeSegment(value: string, label: string): string { + const trimmed = typeof value === "string" ? value.trim() : ""; + if (trimmed.length === 0) throw new Error(`device-login export: ${label} is empty`); + if (trimmed === "." || trimmed === "..") { + throw new Error(`device-login export: ${label} is a relative path segment`); + } + if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("\0")) { + throw new Error(`device-login export: ${label} contains a path separator`); + } + if (path.basename(trimmed) !== trimmed) { + throw new Error(`device-login export: ${label} is not a single path segment`); + } + return trimmed; +} + +/** Reduces a run id to a safe, bounded path segment. Never throws. */ +function toSafeRunSegment(value: string | null): string { + const cleaned = (value ?? "").trim().replace(/[^A-Za-z0-9_-]+/g, "-").slice(0, 80); + return cleaned.length > 0 ? cleaned : "run"; +} + +/** + * Resolves the company-scoped proof-home root under the same isolation boundary + * as the managed Codex home. The root is always company-scoped, so a proof home + * can never cross a company boundary. `companyId` is required and is sanitized to + * a single safe path segment. + */ +export function resolveProofHomeRoot(env: NodeJS.ProcessEnv = process.env, companyId: string): string { + const safeCompanyId = requireSafeSegment(companyId, "companyId"); + const instanceRoot = resolvePaperclipInstanceRootForAdapter({ + homeDir: nonEmpty(env.PAPERCLIP_HOME) ?? undefined, + instanceId: nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? undefined, + env, + }); + return path.resolve(instanceRoot, "companies", safeCompanyId, PROOF_ROOT_DIR_NAME); +} + +export interface DeriveProofHomeInput { + env?: NodeJS.ProcessEnv; + companyId?: string; + runId?: string; +} + +/** + * Returns a unique, run-scoped proof-home path under the company-scoped root. + * The path carries the run id and a fresh random suffix, so two calls never + * collide and every proof home is tied to its run. + */ +export function deriveProofHome(input: DeriveProofHomeInput = {}): string { + const env = input.env ?? process.env; + const companyId = requireSafeSegment( + input.companyId ?? nonEmpty(env.PAPERCLIP_COMPANY_ID) ?? "", + "companyId", + ); + const runSegment = toSafeRunSegment(input.runId ?? nonEmpty(env.PAPERCLIP_RUN_ID)); + const root = resolveProofHomeRoot(env, companyId); + return path.resolve(root, `${runSegment}-${randomUUID()}`); +} + +/** + * Rejects a target that is the shared, the default, or a managed Codex home, or + * that is not strictly under the company-scoped proof root. This guard runs + * before any filesystem write, so the export never writes outside its own root. + */ +function assertProofHomeIsSafeTarget( + resolved: string, + env: NodeJS.ProcessEnv, + companyId: string, +): void { + const shared = path.resolve(resolveSharedCodexHomeDir(env)); + if (resolved === shared) { + throw new Error("device-login export: refused the shared or default Codex home"); + } + const managed = path.resolve(resolveManagedCodexHomeDir(env, companyId)); + if (resolved === managed) { + throw new Error("device-login export: refused the managed company Codex home"); + } + if (path.basename(resolved) === MANAGED_HOME_DIR_NAME) { + throw new Error("device-login export: refused a managed Codex home"); + } + const root = resolveProofHomeRoot(env, companyId); + if (!resolved.startsWith(root + path.sep)) { + throw new Error("device-login export: the proof home must be under the company-scoped proof root"); + } +} + +/** + * Enforces the bounded-size, subscription-only auth shape. Returns the + * subscription `account_id` on success. Rejects an empty, an oversized, an + * API-key, and a malformed payload. Never puts token bytes into the error. + */ +function assertUsableSubscriptionShape(bytes: Buffer): void { + if (bytes.length === 0) { + throw new Error("device-login export: refused an empty auth payload"); + } + if (bytes.length > MAX_AUTH_JSON_BYTES) { + throw new Error("device-login export: refused an oversized auth payload"); + } + const accountId = readSubscriptionAccountId(bytes); + if (!accountId) { + // Covers an API-key payload, a malformed payload, and an unusable payload. + throw new Error("device-login export: refused a non-subscription auth payload"); + } +} + +/** + * Ensures one directory exists and is private (mode 0700). Uses `lstat` (not + * `stat`), so the export never writes through a planted symlink. Fails closed + * when the existing path is a symlink or a non-directory. + */ +async function ensurePrivateDir(dir: string): Promise { + const existing = await lstat(dir).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }); + if (existing) { + if (existing.isSymbolicLink() || !existing.isDirectory()) { + throw new Error("device-login export: the proof-home path is a symlink or a non-directory"); + } + await chmod(dir, PRIVATE_DIR_MODE); + return; + } + await mkdir(dir, { mode: PRIVATE_DIR_MODE }); + await chmod(dir, PRIVATE_DIR_MODE); +} + +/** + * Rejects an existing `auth.json` that is a symlink or a non-regular file. An + * absent file is the normal first-install case. + */ +async function assertAuthPathIsRegularOrAbsent(authPath: string): Promise { + const existing = await lstat(authPath).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }); + if (existing && (existing.isSymbolicLink() || !existing.isFile())) { + throw new Error("device-login export: refused a symlink or a non-regular auth.json"); + } +} + +export interface InstallDeviceLoginCredentialInput { + /** The sandbox `auth.json` bytes read back from the login sandbox. */ + sandboxAuthBytes: Buffer; + /** The run-scoped proof home from {@link deriveProofHome}. */ + proofHome: string; + /** A non-leaking progress sink. It receives only fixed status lines. */ + log: (line: string) => void | Promise; + env?: NodeJS.ProcessEnv; + companyId?: string; +} + +/** + * Installs the sandbox credential into the run-scoped proof home. Seeds an empty + * home. Applies a strictly-newer, same-identity update to a non-empty home. + * Keeps the home otherwise. Enforces Control 2 before any write: the target + * safety guard, the auth-shape gate, and the path-safety guard all run first. + * Never logs token bytes. + */ +export async function installDeviceLoginCredential( + input: InstallDeviceLoginCredentialInput, +): Promise { + const { sandboxAuthBytes, log } = input; + const env = input.env ?? process.env; + const companyId = requireSafeSegment( + input.companyId ?? nonEmpty(env.PAPERCLIP_COMPANY_ID) ?? "", + "companyId", + ); + const proofHome = path.resolve(input.proofHome); + + // 1. Target safety (pure path logic). Reject a shared, default, or managed + // home, or a path outside the proof root, before any filesystem work. + assertProofHomeIsSafeTarget(proofHome, env, companyId); + + // 2. Auth-shape gate. Reject an empty, oversized, API-key, or malformed + // payload before any directory is created. + assertUsableSubscriptionShape(sandboxAuthBytes); + + // 3. Path safety. Create the root and the proof home at mode 0700, each + // guarded by `lstat`. + const root = resolveProofHomeRoot(env, companyId); + await mkdir(path.dirname(root), { recursive: true }); + await ensurePrivateDir(root); + await ensurePrivateDir(proofHome); + + const authPath = path.join(proofHome, AUTH_FILE_NAME); + await assertAuthPathIsRegularOrAbsent(authPath); + + // 4. Decide first install versus update. + const hadUsableAuth = await codexHomeHasUsableAuth(proofHome); + if (!hadUsableAuth) { + // First install: the copy-back predicate fails closed on an absent + // destination, so the seed writer fills the empty home. The seed writer + // stages and renames `auth.json` at mode 0600 under a directory lock. + const outcome = await writeCodexAuthCacheEntry({ + sandboxAuthBytes, + cacheEntryPath: authPath, + log, + }); + return outcome === "written" ? "seeded" : "kept"; + } + + // Update: install the credential only when it is strictly newer for the same + // subscription identity. `copyBackCodexAuth` runs the same decision predicate + // and the same 0600 staged rename under the directory lock. + const outcome = await copyBackCodexAuth({ + readSandboxAuth: async () => sandboxAuthBytes, + hostAuthPath: authPath, + log, + env, + }); + return outcome === "copied" ? "updated" : "kept"; +} + +export interface RemoveProofHomeInput { + env?: NodeJS.ProcessEnv; + companyId?: string; +} + +/** + * Removes the proof home. Refuses a path outside the company-scoped proof root, + * so a cleanup can never delete the shared, the default, or a managed home. A + * missing proof home is a benign no-op. + */ +export async function removeProofHome( + proofHome: string, + input: RemoveProofHomeInput = {}, +): Promise { + const env = input.env ?? process.env; + const companyId = requireSafeSegment( + input.companyId ?? nonEmpty(env.PAPERCLIP_COMPANY_ID) ?? "", + "companyId", + ); + const resolved = path.resolve(proofHome); + const root = resolveProofHomeRoot(env, companyId); + if (!resolved.startsWith(root + path.sep)) { + throw new Error("device-login export: refused to remove a path outside the proof root"); + } + await rm(resolved, { recursive: true, force: true }); +} diff --git a/packages/adapters/codex-local/src/server/device-login-parse.test.ts b/packages/adapters/codex-local/src/server/device-login-parse.test.ts new file mode 100644 index 0000000000..cfd6fa9b98 --- /dev/null +++ b/packages/adapters/codex-local/src/server/device-login-parse.test.ts @@ -0,0 +1,212 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { parseDeviceLoginPrompt } from "./device-login-parse.js"; + +const fixturesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "__fixtures__"); + +function readFixture(name: string): string { + return readFileSync(path.join(fixturesDir, name), "utf8"); +} + +const EXACT_URL = "https://auth.openai.com/codex/device"; + +describe("parseDeviceLoginPrompt", () => { + it("parse_returns_url_and_code_from_sample", () => { + const result = parseDeviceLoginPrompt(readFixture("device-login-sample.txt")); + expect(result).not.toBeNull(); + expect(result?.url).toBe(EXACT_URL); + // The committed sample keeps the capture-time redaction of the code. The + // parser extracts the four-hyphen-five structure without a real secret. + expect(result?.code).toBe("????-?????"); + }); + + it("parse_returns_url_and_code_from_ansi_colored_output", () => { + // Codex CLI 0.128.0 and later add ANSI color (SGR) sequences around the URL + // and the code. A live sandbox run confirmed this format. The parser removes + // the Control Sequence Introducer sequences and reads the tokens the same as + // plain output. + const cyan = "\x1b[36m"; + const bold = "\x1b[1m"; + const reset = "\x1b[0m"; + const text = [ + "1. Open this link in your browser and sign in to your account", + `${cyan}${EXACT_URL}${reset}`, + "2. Enter this one-time code (expires in 15 minutes)", + `${bold}ABCD-EFGHJ${reset}`, + ].join("\n"); + const result = parseDeviceLoginPrompt(text); + expect(result).not.toBeNull(); + expect(result?.url).toBe(EXACT_URL); + expect(result?.code).toBe("ABCD-EFGHJ"); + }); + + it("parse_returns_null_when_prompt_absent", () => { + const text = "Some unrelated log line\nNothing to see here\n"; + expect(parseDeviceLoginPrompt(text)).toBeNull(); + }); + + it("parse_returns_null_for_url_with_query_or_fragment", () => { + const withQuery = [ + "Open this link", + "https://auth.openai.com/codex/device?foo=bar", + "ABCD-EFGHJ", + ].join("\n"); + const withFragment = [ + "Open this link", + "https://auth.openai.com/codex/device#section", + "ABCD-EFGHJ", + ].join("\n"); + expect(parseDeviceLoginPrompt(withQuery)).toBeNull(); + expect(parseDeviceLoginPrompt(withFragment)).toBeNull(); + }); + + it("parse_returns_null_for_wrong_origin_or_path", () => { + const wrongOrigin = [ + "Open this link", + "https://auth.example.com/codex/device", + "ABCD-EFGHJ", + ].join("\n"); + const wrongPath = [ + "Open this link", + "https://auth.openai.com/codex/device/extra", + "ABCD-EFGHJ", + ].join("\n"); + const httpScheme = [ + "Open this link", + "http://auth.openai.com/codex/device", + "ABCD-EFGHJ", + ].join("\n"); + expect(parseDeviceLoginPrompt(wrongOrigin)).toBeNull(); + expect(parseDeviceLoginPrompt(wrongPath)).toBeNull(); + expect(parseDeviceLoginPrompt(httpScheme)).toBeNull(); + }); + + it("parse_returns_null_for_malformed_short_code", () => { + // Each input carries the code preamble after the URL, so the test exercises + // the code-structure check and not the preamble check. + const preamble = "2. Enter this one-time code (expires in 15 minutes)"; + const shortCode = [EXACT_URL, preamble, "ABC-EFGHJ"].join("\n"); // 3 then 5 + const longCode = [EXACT_URL, preamble, "ABCDE-EFGHJ"].join("\n"); // 5 then 5 + const noHyphen = [EXACT_URL, preamble, "ABCDEFGHJ"].join("\n"); + const noCode = [EXACT_URL, preamble, "no code on this line"].join("\n"); + expect(parseDeviceLoginPrompt(shortCode)).toBeNull(); + expect(parseDeviceLoginPrompt(longCode)).toBeNull(); + expect(parseDeviceLoginPrompt(noHyphen)).toBeNull(); + expect(parseDeviceLoginPrompt(noCode)).toBeNull(); + }); + + it("parse_binds_code_to_url_and_ignores_a_code_before_the_url", () => { + // A code-shaped token appears before the URL. The parser reads the code only + // after the URL, so it uses the code that follows the URL. + const text = [ + "WXYZ-98765", + "1. Open this link in your browser and sign in to your account", + EXACT_URL, + "2. Enter this one-time code (expires in 15 minutes)", + "ABCD-EFGHJ", + ].join("\n"); + const result = parseDeviceLoginPrompt(text); + expect(result).not.toBeNull(); + expect(result?.code).toBe("ABCD-EFGHJ"); + }); + + it("parse_returns_null_when_only_code_is_before_the_url", () => { + // The only code-shaped token sits before the URL. No code follows the URL, + // so the parser rejects the output instead of binding the earlier token. + const text = ["WXYZ-98765", "Open this link", EXACT_URL].join("\n"); + expect(parseDeviceLoginPrompt(text)).toBeNull(); + }); + + it("parse_returns_null_when_code_is_far_after_the_url", () => { + // A code-shaped token sits far below the URL, past the proximity window. The + // parser does not bind a distant token to the URL. + const filler = "unrelated log line\n".repeat(40); + const text = [EXACT_URL, filler, "ABCD-EFGHJ"].join("\n"); + expect(parseDeviceLoginPrompt(text)).toBeNull(); + }); + + it("parse_ignores_a_code_between_the_url_and_the_code_preamble", () => { + // A code-shaped token sits after the URL but before the "one-time code" + // preamble line. The parser anchors the code on the preamble, so it ignores + // the earlier token and returns the real code after the preamble. + const text = [ + "1. Open this link in your browser and sign in to your account", + EXACT_URL, + "session id WXYZ-98765 for this attempt", + "2. Enter this one-time code (expires in 15 minutes)", + "ABCD-EFGHJ", + ].join("\n"); + const result = parseDeviceLoginPrompt(text); + expect(result).not.toBeNull(); + expect(result?.code).toBe("ABCD-EFGHJ"); + }); + + it("parse_ignores_a_code_that_shares_the_preamble_line", () => { + // A code-shaped token shares the preamble line with the "one-time code" + // phrase. The parser advances past the whole preamble line, so it ignores the + // token on that line and returns the code from the dedicated code line below. + const text = [ + EXACT_URL, + "2. Enter this one-time code (ref WXYZ-98765, expires in 15 minutes)", + "ABCD-EFGHJ", + ].join("\n"); + const result = parseDeviceLoginPrompt(text); + expect(result).not.toBeNull(); + expect(result?.code).toBe("ABCD-EFGHJ"); + }); + + it("parse_returns_null_when_a_noise_line_follows_the_preamble", () => { + // A line that mixes a code-shaped token with other text sits right after the + // preamble line. The parser reads the code only from a dedicated code line, so + // it never surfaces the embedded token. It rejects the output instead. This + // proves the parser binds the code to the prompt-code line and not to any + // nearby code-shaped token. + const text = [ + EXACT_URL, + "2. Enter this one-time code (expires in 15 minutes)", + "your session token is WXYZ-98765 here", + "ABCD-EFGHJ", + ].join("\n"); + expect(parseDeviceLoginPrompt(text)).toBeNull(); + }); + + it("parse_returns_null_when_the_code_preamble_is_absent", () => { + // A valid code follows the URL, but the "one-time code" preamble is absent. + // The parser requires the preamble, so it rejects the output. This binds the + // code to the prompt structure and not to a bare code-shaped token. + const text = [ + "1. Open this link in your browser and sign in to your account", + EXACT_URL, + "ABCD-EFGHJ", + ].join("\n"); + expect(parseDeviceLoginPrompt(text)).toBeNull(); + }); + + it("parse_ignores_token_like_text", () => { + // Token-like noise without the exact device URL must not yield a prompt. + const text = [ + "tokens received", + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature", + "sk-proj-ABCD1234ABCD1234ABCD1234", + "ABCD-EFGHJ", + ].join("\n"); + expect(parseDeviceLoginPrompt(text)).toBeNull(); + }); + + it("parse_returns_null_for_edge_sample", () => { + // The grounded edge row carries a URL, but a wrong path and a wrong origin + // segment, so the parser rejects it. + expect(parseDeviceLoginPrompt(readFixture("device-login-edge.txt"))).toBeNull(); + }); + + it("keeps the url and the code out of a thrown error", () => { + // A non-string input is a programming error, but the message must never + // carry secret-bearing input. The parser returns null instead of throwing. + // @ts-expect-error deliberate wrong type + expect(parseDeviceLoginPrompt(undefined)).toBeNull(); + // @ts-expect-error deliberate wrong type + expect(parseDeviceLoginPrompt(12345)).toBeNull(); + }); +}); diff --git a/packages/adapters/codex-local/src/server/device-login-parse.ts b/packages/adapters/codex-local/src/server/device-login-parse.ts new file mode 100644 index 0000000000..86dc3fb83e --- /dev/null +++ b/packages/adapters/codex-local/src/server/device-login-parse.ts @@ -0,0 +1,166 @@ +// The device-login output parser. It reads the Codex `login --device-auth` +// output and returns the login URL and the one-time code, or null. +// +// Security (Control 1 — strict validation): the parser accepts only the exact +// origin and path of the device-login URL. It rejects any query, any fragment, a +// different origin, and a different path. It accepts only the short-code +// structure `XXXX-XXXXX` (four characters, a hyphen, then five characters). The +// parser never logs the URL, the code, or any input byte, and it keeps them out +// of every thrown error. The parser is a pure function. + +export interface DeviceLoginPrompt { + url: string; + code: string; +} + +// The one and only accepted device-login URL. The parser returns this exact +// constant string on a match, so the output is never a caller-controlled value. +export const DEVICE_LOGIN_URL = "https://auth.openai.com/codex/device"; + +// Codex CLI 0.128.0 and later wrap the login URL and the one-time code in ANSI +// color sequences. A color sequence is a Control Sequence Introducer (CSI): the +// ESC control byte, a `[`, zero or more parameter bytes (0x30-0x3F), zero or +// more intermediate bytes (0x20-0x2F), and one final byte (0x40-0x7E). The +// parser removes every CSI sequence first, so a colored URL or code reads the +// same as a plain one. The strip only normalizes the input; the URL and the +// code still pass the strict validation below. +// eslint-disable-next-line no-control-regex +const ANSI_CSI_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g; + +// A candidate URL token is a run of non-space characters that starts with an +// http or https scheme. The parser validates each candidate with the `URL` +// class; the regular expression only splits tokens out of the text. +const URL_TOKEN_RE = /https?:\/\/\S+/g; + +// Trailing punctuation that prose commonly puts right after a URL. The parser +// strips only these characters. It never strips `?` or `#`, so a URL with a +// query or a fragment stays malformed and the parser rejects it. +const TRAILING_PUNCTUATION_RE = /[)\].,;:!]+$/; + +// The one-time code structure on a dedicated line: four characters, a hyphen, +// then five characters, and nothing else on the line. The Codex prompt prints +// the code alone on the line that comes right after the preamble line. So the +// pattern matches the whole line. A code-shaped token that shares a line with +// other text cannot match. The real code alphabet is a Codex detail that the +// grounded capture did not keep (the capture redacted the code to `?`). So the +// parser binds the token class to alphanumerics and the redaction sentinel `?`. +const CODE_LINE_RE = /^([A-Za-z0-9?]{4}-[A-Za-z0-9?]{5})$/; + +// The prompt line that introduces the one-time code. The Codex prompt prints +// the phrase "one-time code" on the line right before the code. The parser +// anchors the code search on this phrase. So the code binds to the prompt line +// that introduces it. A code-shaped token that sits between the URL and this +// phrase cannot bind, because the parser reads the code only after the phrase. +const CODE_PREAMBLE_RE = /one-time code/i; + +// The maximum number of characters between the end of the URL and the start of +// the code preamble. The Codex prompt prints the preamble about one line after +// the URL. The parser looks for the preamble only inside this window after the +// URL. So a preamble far away in the output cannot bind to the URL. +const MAX_URL_TO_PREAMBLE_GAP = 256; + +// The maximum number of characters after the end of the preamble line that the +// parser reads for the code. The Codex prompt prints the code on the line right +// after the preamble line. The parser looks for the code only inside this window +// after the preamble line. The window is large enough for the real code line and +// small enough to reject distant noise. +const MAX_PREAMBLE_TO_CODE_GAP = 128; + +// The result of a URL search: the canonical URL and the index of the first +// character after the matched URL token. The code search starts at this index. +interface DeviceUrlMatch { + url: string; + end: number; +} + +/** + * Returns the exact device-login URL and its end index when `text` holds it as + * a standalone token with the exact origin `https://auth.openai.com` and the + * exact path `/codex/device` and no query, fragment, or credentials. Returns + * null otherwise. Returns the canonical {@link DEVICE_LOGIN_URL} constant on a + * match. The `end` index is the position of the first character after the + * matched token in `text`. + */ +function findExactDeviceUrl(text: string): DeviceUrlMatch | null { + for (const match of text.matchAll(URL_TOKEN_RE)) { + const token = match[0]; + const cleaned = token.replace(TRAILING_PUNCTUATION_RE, ""); + let parsed: URL; + try { + parsed = new URL(cleaned); + } catch { + continue; + } + if ( + parsed.protocol === "https:" && + parsed.host === "auth.openai.com" && + parsed.pathname === "/codex/device" && + parsed.search === "" && + parsed.hash === "" && + parsed.username === "" && + parsed.password === "" + ) { + return { url: DEVICE_LOGIN_URL, end: (match.index ?? 0) + token.length }; + } + } + return null; +} + +/** + * Returns the one-time code when `text` holds the code preamble after + * `fromIndex` and a dedicated code line after that preamble line. The parser + * first finds the "one-time code" preamble in a window after the URL. It then + * advances past the end of the preamble line and reads the first non-blank line + * after it. That line must hold only the `XXXX-XXXXX` code. So the code binds to + * the dedicated code line that the prompt prints right after the preamble line. + * A code-shaped token between the URL and the preamble cannot bind. A code-shaped + * token that shares the preamble line cannot bind. A code-shaped token inside a + * line that also holds other text cannot bind. Returns null when the preamble is + * absent, when the preamble has no line break after it, or when the first + * non-blank line after the preamble line is not a code line. + */ +function findShortCode(text: string, fromIndex: number): string | null { + const preambleWindow = text.slice(fromIndex, fromIndex + MAX_URL_TO_PREAMBLE_GAP); + const preambleMatch = CODE_PREAMBLE_RE.exec(preambleWindow); + if (!preambleMatch) return null; + // Advance to the end of the preamble line. The code sits on the next line, so + // the parser reads the code only after the preamble line ends. A code-shaped + // token on the preamble line cannot bind. + const preambleEnd = fromIndex + preambleMatch.index + preambleMatch[0].length; + const lineBreak = text.indexOf("\n", preambleEnd); + if (lineBreak === -1) return null; + const codeWindow = text.slice(lineBreak + 1, lineBreak + 1 + MAX_PREAMBLE_TO_CODE_GAP); + // Read the first non-blank line after the preamble line. It must hold only the + // code. So the parser binds the code to the dedicated code line, and it rejects + // a line that mixes the code with other text. + for (const line of codeWindow.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + const match = CODE_LINE_RE.exec(trimmed); + return match ? match[1] : null; + } + return null; +} + +/** + * Parses Codex device-login output. Removes ANSI color sequences first, so + * colored output from Codex CLI 0.128.0 and later reads the same as plain + * output. The parser reads the URL first, then finds the "one-time code" + * preamble after the URL, then reads the code from the dedicated code line right + * after the preamble line. So the code binds to the prompt line that introduces + * it, and a code-shaped token between the URL and the preamble, on the preamble + * line, or mixed with other text on a later line, cannot form a prompt. Returns + * the login URL and the one-time code when both are present and valid. Returns + * null for any other input, including a non-string input, an absent prompt, a URL + * with a query or a fragment, a wrong origin or path, and a malformed short code. + * Never throws on input, and never puts the URL or the code into a log or an error. + */ +export function parseDeviceLoginPrompt(text: string): DeviceLoginPrompt | null { + if (typeof text !== "string" || text.length === 0) return null; + const clean = text.replace(ANSI_CSI_RE, ""); + const urlMatch = findExactDeviceUrl(clean); + if (!urlMatch) return null; + const code = findShortCode(clean, urlMatch.end); + if (!code) return null; + return { url: urlMatch.url, code }; +} diff --git a/packages/adapters/codex-local/src/server/device-login-runner.test.ts b/packages/adapters/codex-local/src/server/device-login-runner.test.ts new file mode 100644 index 0000000000..7a14958149 --- /dev/null +++ b/packages/adapters/codex-local/src/server/device-login-runner.test.ts @@ -0,0 +1,197 @@ +import { describe, expect, it, vi } from "vitest"; +import { + CODEX_DEVICE_LOGIN_COMMAND, + runDeviceLogin, + type SandboxLoginDriver, +} from "./device-login-runner.js"; + +const REAL_SHAPED_URL = "https://auth.openai.com/codex/device"; +const REAL_SHAPED_CODE = "WXYZ-12345"; +const TOKEN_SENTINEL = "SENTINEL_TOKEN_ABC123"; +const AUTH_BYTES = Buffer.from( + JSON.stringify({ tokens: { account_id: "acc", refresh_token: TOKEN_SENTINEL } }), +); + +interface FakeDriverOptions { + chunks?: string[]; + exitCode?: number; + hang?: boolean; + execError?: Error; + readError?: Error; + authBytes?: Buffer; +} + +function createFakeDriver(options: FakeDriverOptions = {}) { + const disposeCalls = { count: 0 }; + const driver: SandboxLoginDriver = { + async execStreaming(_command, onStdout) { + if (options.execError) throw options.execError; + for (const chunk of options.chunks ?? []) { + onStdout(chunk); + } + if (options.hang) { + // Never resolve. The runner must fall back to its timeout or signal. + await new Promise(() => {}); + } + return { exitCode: options.exitCode ?? 0 }; + }, + async readFile() { + if (options.readError) throw options.readError; + return options.authBytes ?? AUTH_BYTES; + }, + async dispose() { + disposeCalls.count += 1; + }, + }; + return { driver, disposeCalls }; +} + +describe("runDeviceLogin", () => { + it("runner_reports_prompt_then_success", async () => { + const { driver, disposeCalls } = createFakeDriver({ + // The prompt spans two chunks; the runner must join them and fire once. + // The chunk boundary falls inside the prompt, after the code preamble. + chunks: [ + `1. Open this link\n${REAL_SHAPED_URL}\n2. Enter this one-time code (expires in 15 minutes)\n`, + `${REAL_SHAPED_CODE}\nDone.\n`, + ], + exitCode: 0, + }); + const onPrompt = vi.fn(); + const onCredential = vi.fn(); + const result = await runDeviceLogin(driver, { + onPrompt, + onCredential, + authPath: "/home/.codex/auth.json", + timeoutMs: 1000, + }); + expect(result.outcome).toBe("success"); + expect(result.exitCode).toBe(0); + expect(result.promptSurfaced).toBe(true); + expect(onPrompt).toHaveBeenCalledTimes(1); + expect(onPrompt).toHaveBeenCalledWith({ url: REAL_SHAPED_URL, code: REAL_SHAPED_CODE }); + expect(onCredential).toHaveBeenCalledTimes(1); + expect(onCredential).toHaveBeenCalledWith(AUTH_BYTES); + expect(disposeCalls.count).toBe(1); + }); + + it("runner_surfaces_prompt_after_a_large_pre_prompt_stream", async () => { + // The sandbox streams a large volume of output before the prompt. The runner + // bounds the parse buffer, so the memory stays limited. The prompt arrives at + // the end of the stream, so the trailing window still holds it and the runner + // surfaces it. + const noise = "unrelated sandbox log line\n".repeat(20000); + const { driver } = createFakeDriver({ + chunks: [ + noise, + `1. Open this link\n${REAL_SHAPED_URL}\n2. Enter this one-time code (expires in 15 minutes)\n${REAL_SHAPED_CODE}\nDone.\n`, + ], + exitCode: 0, + }); + const onPrompt = vi.fn(); + const result = await runDeviceLogin(driver, { onPrompt, timeoutMs: 1000 }); + expect(result.outcome).toBe("success"); + expect(result.promptSurfaced).toBe(true); + expect(onPrompt).toHaveBeenCalledTimes(1); + expect(onPrompt).toHaveBeenCalledWith({ url: REAL_SHAPED_URL, code: REAL_SHAPED_CODE }); + }); + + it("runner_surfaces_prompt_at_the_start_of_one_large_chunk", async () => { + // One stdout callback supplies more than the retained-buffer limit. The + // prompt sits near the start, and a large volume of output follows it. The + // runner parses the whole chunk before it trims the retained window, so it + // still finds the early prompt and never drops it. + const trailingNoise = "unrelated sandbox log line\n".repeat(20000); + const { driver } = createFakeDriver({ + chunks: [ + `1. Open this link\n${REAL_SHAPED_URL}\n2. Enter this one-time code (expires in 15 minutes)\n${REAL_SHAPED_CODE}\nDone.\n${trailingNoise}`, + ], + exitCode: 0, + }); + const onPrompt = vi.fn(); + const result = await runDeviceLogin(driver, { onPrompt, timeoutMs: 1000 }); + expect(result.outcome).toBe("success"); + expect(result.promptSurfaced).toBe(true); + expect(onPrompt).toHaveBeenCalledTimes(1); + expect(onPrompt).toHaveBeenCalledWith({ url: REAL_SHAPED_URL, code: REAL_SHAPED_CODE }); + }); + + it("runner_disposes_sandbox_on_timeout", async () => { + const { driver, disposeCalls } = createFakeDriver({ hang: true }); + const onPrompt = vi.fn(); + const result = await runDeviceLogin(driver, { onPrompt, timeoutMs: 20 }); + expect(result.outcome).toBe("timeout"); + expect(disposeCalls.count).toBe(1); + }); + + it("runner_disposes_sandbox_on_cancellation", async () => { + const { driver, disposeCalls } = createFakeDriver({ hang: true }); + const controller = new AbortController(); + const onPrompt = vi.fn(); + const promise = runDeviceLogin(driver, { + onPrompt, + timeoutMs: 5000, + signal: controller.signal, + }); + controller.abort(); + const result = await promise; + expect(result.outcome).toBe("cancelled"); + expect(disposeCalls.count).toBe(1); + }); + + it("runner_reports_failure_on_nonzero_exit", async () => { + const { driver, disposeCalls } = createFakeDriver({ exitCode: 7 }); + const onPrompt = vi.fn(); + const result = await runDeviceLogin(driver, { onPrompt, timeoutMs: 1000 }); + expect(result.outcome).toBe("failure"); + expect(result.exitCode).toBe(7); + expect(disposeCalls.count).toBe(1); + }); + + it("runner_logs_and_result_contain_no_url_code_or_token_sentinel", async () => { + const logs: string[] = []; + const { driver } = createFakeDriver({ + chunks: [ + `${REAL_SHAPED_URL}\n${REAL_SHAPED_CODE}\n`, + `refresh_token=${TOKEN_SENTINEL}\n`, + ], + exitCode: 0, + }); + const result = await runDeviceLogin(driver, { + onPrompt: () => {}, + timeoutMs: 1000, + log: (line) => { + logs.push(line); + }, + }); + const haystack = `${logs.join("\n")}\n${JSON.stringify(result)}`; + expect(haystack).not.toContain(REAL_SHAPED_URL); + expect(haystack).not.toContain(REAL_SHAPED_CODE); + expect(haystack).not.toContain(TOKEN_SENTINEL); + }); + + it("runner_error_messages_contain_no_url_or_code", async () => { + const { driver, disposeCalls } = createFakeDriver({ + // A driver error whose message embeds secret-bearing text. The runner must + // never let that message reach its own thrown error. + execError: new Error(`network failure while streaming ${REAL_SHAPED_URL} ${REAL_SHAPED_CODE}`), + }); + let caught: unknown; + try { + await runDeviceLogin(driver, { onPrompt: () => {}, timeoutMs: 1000 }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(Error); + const message = (caught as Error).message; + expect(message).not.toContain(REAL_SHAPED_URL); + expect(message).not.toContain(REAL_SHAPED_CODE); + // The driver is still disposed on the error path. + expect(disposeCalls.count).toBe(1); + }); + + it("exposes the default login command", () => { + expect(CODEX_DEVICE_LOGIN_COMMAND).toContain("codex"); + expect(CODEX_DEVICE_LOGIN_COMMAND).toContain("--device-auth"); + }); +}); diff --git a/packages/adapters/codex-local/src/server/device-login-runner.ts b/packages/adapters/codex-local/src/server/device-login-runner.ts new file mode 100644 index 0000000000..2eccde880b --- /dev/null +++ b/packages/adapters/codex-local/src/server/device-login-runner.ts @@ -0,0 +1,206 @@ +import { parseDeviceLoginPrompt, type DeviceLoginPrompt } from "./device-login-parse.js"; + +// The device-login runner. It runs the Codex device-login command through an +// injected {@link SandboxLoginDriver}, surfaces the login prompt one time in +// memory, and handles a timeout and a cancellation. The runner always disposes +// the driver. +// +// Security (Control 1 — secret handling): the runner treats every byte of the +// sandbox stream as secret-bearing, untrusted input. It parses the stream in an +// in-memory buffer only. It drops the buffer as soon as it finds the prompt. It +// never forwards the raw text to a log or an artifact, and it never stores the +// raw text on the result. The runner reports only a fixed, non-secret status. It +// passes the prompt one time through the in-memory `onPrompt` callback. It passes +// the credential bytes one time through the in-memory `onCredential` callback. +// The runner keeps the URL, the code, and any token byte out of every log line +// and every thrown error. + +/** The default Codex device-login command. */ +export const CODEX_DEVICE_LOGIN_COMMAND = "codex login --device-auth"; + +/** + * The maximum number of characters the runner keeps for the next chunk. The + * Codex prompt is small and puts the URL and the code close together. A sandbox + * can stream a large volume of output before the prompt. So after each parse the + * runner keeps only the most recent characters up to this limit. The retained + * buffer cannot grow without a bound across many chunks. The limit is far larger + * than the prompt, so the trailing window never drops a real prompt that spans a + * chunk boundary. + */ +const MAX_PARSE_BUFFER_CHARS = 64 * 1024; + +/** + * The sandbox side of the device-login run. The runner never calls Daytona + * directly; a caller injects a concrete driver. A production driver binds these + * three methods to a non-persisting Daytona exec path, a file read, and a + * sandbox delete. + */ +export interface SandboxLoginDriver { + /** + * Runs `command` in the sandbox and streams standard output to `onStdout` in + * memory. Resolves with the command exit code when the command ends. A driver + * must not persist the raw output to any durable log. + */ + execStreaming(command: string, onStdout: (chunk: string) => void): Promise<{ exitCode: number | null }>; + /** Reads the bytes of one file from the sandbox. */ + readFile(path: string): Promise; + /** Deletes the sandbox and releases its resources. */ + dispose(): Promise; +} + +/** Receives the parsed prompt one time in memory. The caller displays it. */ +export type DeviceLoginPromptSink = (prompt: DeviceLoginPrompt) => void; + +export type DeviceLoginOutcome = "success" | "failure" | "timeout" | "cancelled"; + +/** The runner result. It never carries a URL, a code, or a token byte. */ +export interface DeviceLoginResult { + outcome: DeviceLoginOutcome; + exitCode: number | null; + promptSurfaced: boolean; +} + +export interface RunDeviceLoginOptions { + /** The login command. Defaults to {@link CODEX_DEVICE_LOGIN_COMMAND}. */ + command?: string; + /** Receives the parsed prompt one time in memory. The caller displays it. */ + onPrompt: DeviceLoginPromptSink; + /** + * Receives the sandbox `auth.json` bytes one time in memory on success. The + * runner reads the bytes with {@link SandboxLoginDriver.readFile} before it + * disposes the driver. Set together with {@link authPath}. + */ + onCredential?: (authBytes: Buffer) => void | Promise; + /** The sandbox path of the credential file to read on success. */ + authPath?: string; + /** The host-side timeout in milliseconds. */ + timeoutMs: number; + /** An optional cancellation signal. */ + signal?: AbortSignal; + /** A non-leaking progress sink. It receives only fixed status lines. */ + log?: (line: string) => void; +} + +type RaceResult = + | { kind: "exit"; exitCode: number | null } + | { kind: "timeout" } + | { kind: "cancelled" }; + +/** + * Races the streaming exec against the timeout and the cancellation signal. The + * exec result resolves the race; the timeout and the signal resolve the race + * with a terminal status. A driver error rejects the race, so the caller can + * convert it to a fixed, non-secret error. A late exec rejection after the race + * already settled is consumed here, so it never becomes an unhandled rejection. + */ +function raceExec( + exec: Promise<{ exitCode: number | null }>, + timeoutMs: number, + signal: AbortSignal | undefined, +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + clearTimeout(timer); + if (signal) signal.removeEventListener("abort", onAbort); + }; + const finish = (run: () => void) => { + if (settled) return; + settled = true; + cleanup(); + run(); + }; + const timer = setTimeout(() => finish(() => resolve({ kind: "timeout" })), timeoutMs); + const onAbort = () => finish(() => resolve({ kind: "cancelled" })); + if (signal) signal.addEventListener("abort", onAbort, { once: true }); + exec.then( + (value) => finish(() => resolve({ kind: "exit", exitCode: value.exitCode })), + (error) => finish(() => reject(error)), + ); + }); +} + +/** + * Runs the device-login command through `driver`. Surfaces the prompt one time + * through `onPrompt`. On success it reads the credential and surfaces the bytes + * one time through `onCredential`. Returns a fixed status. Always disposes the + * driver. Never logs the raw stream, and never puts a URL, a code, or a token + * into a log line, the result, or a thrown error. + */ +export async function runDeviceLogin( + driver: SandboxLoginDriver, + options: RunDeviceLoginOptions, +): Promise { + const { onPrompt, onCredential, authPath, timeoutMs, signal } = options; + const command = options.command ?? CODEX_DEVICE_LOGIN_COMMAND; + const log = options.log ?? (() => {}); + + let promptSurfaced = false; + // The in-memory parse buffer. The runner drops it as soon as it finds the + // prompt, so the secret-bearing stream never lives longer than one parse. The + // runner parses the full buffer, and it includes the whole new chunk. So a + // prompt at the start of one large chunk still parses. The runner bounds only + // the buffer that it keeps for the next chunk to {@link MAX_PARSE_BUFFER_CHARS}. + // The runner keeps the trailing window and drops the oldest characters. The + // prompt puts the URL and the code close together, so the trailing window + // always holds a real prompt that spans a chunk boundary. + let buffer = ""; + const onStdout = (chunk: string): void => { + if (promptSurfaced) return; + buffer += chunk; + const prompt = parseDeviceLoginPrompt(buffer); + if (prompt) { + promptSurfaced = true; + buffer = ""; + onPrompt(prompt); + return; + } + if (buffer.length > MAX_PARSE_BUFFER_CHARS) { + buffer = buffer.slice(buffer.length - MAX_PARSE_BUFFER_CHARS); + } + }; + + try { + if (signal?.aborted) { + log("[paperclip] Device login cancelled before start."); + return { outcome: "cancelled", exitCode: null, promptSurfaced }; + } + + const exec = driver.execStreaming(command, onStdout); + const raced = await raceExec(exec, timeoutMs, signal); + + if (raced.kind === "timeout") { + log("[paperclip] Device login timed out; disposing the sandbox."); + return { outcome: "timeout", exitCode: null, promptSurfaced }; + } + if (raced.kind === "cancelled") { + log("[paperclip] Device login cancelled; disposing the sandbox."); + return { outcome: "cancelled", exitCode: null, promptSurfaced }; + } + + const exitCode = raced.exitCode; + if (exitCode !== 0) { + log("[paperclip] Device login command ended with a non-zero exit code."); + return { outcome: "failure", exitCode, promptSurfaced }; + } + + if (onCredential && authPath) { + const authBytes = await driver.readFile(authPath); + await onCredential(authBytes); + } + log("[paperclip] Device login command ended successfully."); + return { outcome: "success", exitCode, promptSurfaced }; + } catch { + // Convert any driver error to a fixed, non-secret error. The original error + // may embed streamed bytes, so the runner never propagates its message. + throw new Error("device login failed: the sandbox login command errored."); + } finally { + // Always dispose the driver. A dispose error must not leak or mask the + // result, so the runner swallows it and logs a fixed line. + try { + await driver.dispose(); + } catch { + log("[paperclip] Device login: the sandbox dispose step errored."); + } + } +}