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:
Nicky Leach 2026-08-10 16:12:30 -07:00 committed by GitHub
parent 5a0985f80a
commit 459469638e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1399 additions and 0 deletions

View File

@ -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.

View File

@ -0,0 +1 @@
Error logging in with device code: error sending request for url (https://auth.openai.com/api/accounts/deviceauth/<redacted-path>)

View File

@ -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.

View File

@ -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);
});
});

View File

@ -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 });
}

View File

@ -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();
});
});

View File

@ -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 };
}

View File

@ -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");
});
});

View File

@ -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.");
}
}
}