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 <noreply@paperclip.ing>
This commit is contained in:
parent
5a0985f80a
commit
459469638e
|
|
@ -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.
|
||||
|
|
@ -0,0 +1 @@
|
|||
Error logging in with device code: error sending request for url (https://auth.openai.com/api/accounts/deviceauth/<redacted-path>)
|
||||
|
|
@ -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.
|
||||
|
|
@ -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<string> {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-proof-"));
|
||||
cleanupDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function envFor(instanceHome: string, extra: Record<string, string> = {}): 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<void>;
|
||||
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<InstallDeviceLoginOutcome> {
|
||||
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<void> {
|
||||
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 });
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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<never>(() => {});
|
||||
}
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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<Buffer>;
|
||||
/** Deletes the sandbox and releases its resources. */
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
/** 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<void>;
|
||||
/** 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<RaceResult> {
|
||||
return new Promise<RaceResult>((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<DeviceLoginResult> {
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue