diff --git a/packages/adapters/codex-local/CODEX-AUTH-CACHE.md b/packages/adapters/codex-local/CODEX-AUTH-CACHE.md new file mode 100644 index 0000000000..fcffe6957d --- /dev/null +++ b/packages/adapters/codex-local/CODEX-AUTH-CACHE.md @@ -0,0 +1,101 @@ +# Codex identity-keyed host credential cache + +This document describes the host credential cache for Codex authentication. The +cache keeps one usable subscription credential per identity (`account_id`) in a +separate host store. The cache is additive. It does not change the copy-back +path, the fail-closed decision predicate, or the host default store overwrite. + +## Where the cache lives + +The cache root is company-scoped, under the same isolation boundary as the +managed Codex home: + +``` +/companies//codex-auth-cache//auth.json +``` + +The root sits outside the shared Codex home (`resolveSharedCodexHomeDir`) and +outside the symlink allowlist. The `account_id` is sanitized to one safe path +segment. Each cache root and each identity directory is private (mode `0700`). + +## The two directions + +The board set one rule for both directions. A side that has no credential can +receive one, but only when a real credential on one side names the expected +identity. The harness never picks a credential from the cache at random. + +- **Host to sandbox (inbound).** A sandbox that starts with no Codex credential + takes the host credential. The cache does not change this. At provision the + cache also refreshes the host credential with a strictly-newer cached copy of + the same identity (the vend, below). +- **Sandbox to host (copy-back).** A host that already holds an identity keeps a + strictly-newer same-identity credential from the sandbox. The cache does not + change this. At teardown the cache also writes the sandbox credential into its + per-identity slot (the cache write, below). + +## The identity anchor rule + +The identity anchor rule is the load-bearing constraint: + +- The **cache vend** only refreshes an identity the host **already holds** in the + shared home. It replaces the staged host credential with a strictly-newer + cached credential of the **same** `account_id`. It never introduces a new + identity. +- When the host shared home holds **no** credential, the vend does **nothing**. + The harness never selects a cache entry to seed an empty host. This is the + "no random pick from the cache when the host is empty" rule. +- The **cache write** keys each entry by the real `account_id` of the credential + that flows back from the sandbox. It writes to a per-identity cache slot, never + to the host default store. The cache write is best-effort: it runs after the + host copy-back finishes, so a cache-write failure never replaces the successful + copy-back result. The failure is logged with its errno code and the next + teardown re-attempts the write. + +The host never learns an identity from the cache. The host only refreshes an +identity a real credential already states. + +## State matrix + +"Host has auth" means the shared source store `resolveSharedCodexHomeDir(env)/auth.json` +holds a usable subscription credential. "Sandbox has auth" means the run's +sandbox `auth.json` holds a usable credential at teardown. `X` and `Y` are two +different subscription identities (`account_id`). + +| # | Host store | Sandbox cred | Inbound: sandbox home gets | Copy-back: host store | Cache write | Cache vend | Identity anchor | +|---|---|---|---|---|---|---|---| +| 1a | HAS `X` | HAS `X`, newer | fresher of the two (`X`) | overwrite with newer `X` | write slot `X` | may stage a strictly-newer cached `X` | host and sandbox both name `X` | +| 1b | HAS `X` | HAS `Y` (`Y != X`) | host `X` (predicate rejects `Y`) | keep host `X` | write slot `Y` (per identity) | may stage a strictly-newer cached `X` | host names `X`; `Y` is cached, never adopted | +| 2 | HAS `X` | NONE | host `X` | keep host (sandbox absent) | no write (no source) | may stage a strictly-newer cached `X` | host names `X` | +| 3 | NONE | HAS `Y` | image-login fallback; host store **never seeded** | keep host **empty** (never seed) | write slot `Y` | **none** (host empty, no random pick) | sandbox names `Y`; host is silent | +| 4 | NONE | NONE | image-login fallback, or the run fails | keep host **empty** | no write | **none** | no side names an identity | + +The cache changes an outcome only in the "stage a strictly-newer cached copy of +an identity the host already holds" cases (rows 1a, 1b, 2, vend column). It never +changes which identity a side uses. It never seeds an empty host store (rows 3, +4). + +## Off-switch + +The cache is on by default. Set the environment flag `PAPERCLIP_CODEX_AUTH_CACHE` +to an explicit falsy value (`0`, `false`, `no`, or `off`) to turn it off. When +off, the teardown cache write and the provision vend become no-ops. The host +default overwrite is unchanged in both states. + +## Cache-clear action + +Two operator actions remove cached credentials: + +- `clearCodexAuthCacheEntry(env, accountId, companyId)` removes exactly one + identity slot. +- `clearCodexAuthCache(env, companyId)` removes every slot in the company-scoped + cache root. + +To disable the cache without a code revert, use the off-switch. To remove a +single cached identity, use `clearCodexAuthCacheEntry`. To remove every cached +credential, delete the cache root or use `clearCodexAuthCache`. Neither action +affects the host default store. + +## No secret in logs + +The cache logs the decision and the outcome only. It never logs token bytes and +never logs a raw `account_id`. diff --git a/packages/adapters/codex-local/src/server/codex-auth-cache.test.ts b/packages/adapters/codex-local/src/server/codex-auth-cache.test.ts new file mode 100644 index 0000000000..f3818ecd8a --- /dev/null +++ b/packages/adapters/codex-local/src/server/codex-auth-cache.test.ts @@ -0,0 +1,333 @@ +import { chmod, lstat, mkdir, mkdtemp, readdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + clearCodexAuthCache, + clearCodexAuthCacheEntry, + ensureCodexAuthCacheEntryDir, + isCodexAuthCacheEnabled, + resolveCodexAuthCacheDir, + resolveCodexAuthCacheEntryPath, + selectVendCredential, + toCacheKey, +} from "./codex-auth-cache.js"; +import { resolveSharedCodexHomeDir } from "./codex-home.js"; + +// This suite proves the per-identity host credential cache store. The cache is a +// separate directory outside the shared Codex home and outside the symlink +// allowlist. It keys one usable subscription credential per identity +// (`account_id`). The suite drives the real path resolver, the real directory +// guards, and the real decision predicate (`codex-auth-merge-decision.cjs`) +// against a real host tmp filesystem. +describe("codex auth cache store", () => { + const cleanupDirs: string[] = []; + + afterEach(async () => { + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop(); + if (!dir) continue; + await chmod(dir, 0o700).catch(() => undefined); + await rm(dir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + async function makeInstanceRoot(): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-cache-")); + cleanupDirs.push(dir); + return dir; + } + + function envFor(instanceHome: string, extra: Record = {}): NodeJS.ProcessEnv { + return { + PAPERCLIP_HOME: instanceHome, + PAPERCLIP_INSTANCE_ID: "default", + ...extra, + }; + } + + function subscriptionAuth(input: { accountId: string; lastRefresh?: string; marker?: string }): string { + const suffix = input.marker ?? input.accountId; + return JSON.stringify({ + tokens: { + id_token: `id-token-${suffix}`, + access_token: `access-token-${suffix}`, + refresh_token: `refresh-token-${suffix}`, + account_id: input.accountId, + }, + ...(input.lastRefresh ? { last_refresh: input.lastRefresh } : {}), + }); + } + + const NEWER = "2026-07-09T02:00:00Z"; + const OLDER = "2026-07-09T01:00:00Z"; + + describe("Phase 1: cache store location and path scheme", () => { + it("resolveCodexAuthCacheDir returns a path outside resolveSharedCodexHomeDir", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home, { CODEX_HOME: path.join(home, "shared-codex") }); + const cacheDir = resolveCodexAuthCacheDir(env, "company-a"); + const sharedHome = resolveSharedCodexHomeDir(env); + expect(cacheDir.startsWith(sharedHome + path.sep)).toBe(false); + expect(cacheDir).not.toBe(sharedHome); + }); + + it("resolveCodexAuthCacheDir returns a company-scoped path under the instance companies directory when companyId is set", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + const cacheDir = resolveCodexAuthCacheDir(env, "company-a"); + expect(cacheDir).toBe( + path.resolve(home, "instances", "default", "companies", "company-a", "codex-auth-cache"), + ); + }); + + it("resolveCodexAuthCacheEntryPath keys the entry by a sanitized account_id and ends with auth.json", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + const entryPath = resolveCodexAuthCacheEntryPath(env, "acct-42", "company-a"); + const cacheDir = resolveCodexAuthCacheDir(env, "company-a"); + expect(entryPath).toBe(path.join(cacheDir, "acct-42", "auth.json")); + }); + + it("toCacheKey rejects an empty account_id, a path separator, and ..", () => { + expect(() => toCacheKey("")).toThrow(); + expect(() => toCacheKey(" ")).toThrow(); + expect(() => toCacheKey("..")).toThrow(); + expect(() => toCacheKey(".")).toThrow(); + expect(() => toCacheKey("a/b")).toThrow(); + expect(() => toCacheKey("a\\b")).toThrow(); + expect(() => toCacheKey("../escape")).toThrow(); + expect(() => toCacheKey("a\0b")).toThrow(); + expect(toCacheKey("acct-42")).toBe("acct-42"); + }); + + it("resolveCodexAuthCacheDir rejects a traversal companyId before it builds the path", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + // A traversal companyId must never make the cache root escape the + // companies/ directory. Sanitization runs before path construction, so a + // relative segment, a path separator, an absolute path, and a NUL byte all + // fail loud. + expect(() => resolveCodexAuthCacheDir(env, "")).toThrow(); + expect(() => resolveCodexAuthCacheDir(env, " ")).toThrow(); + expect(() => resolveCodexAuthCacheDir(env, "..")).toThrow(); + expect(() => resolveCodexAuthCacheDir(env, ".")).toThrow(); + expect(() => resolveCodexAuthCacheDir(env, "../../etc")).toThrow(); + expect(() => resolveCodexAuthCacheDir(env, "/etc")).toThrow(); + expect(() => resolveCodexAuthCacheDir(env, "a/b")).toThrow(); + expect(() => resolveCodexAuthCacheDir(env, "a\\b")).toThrow(); + expect(() => resolveCodexAuthCacheDir(env, "a\0b")).toThrow(); + // The clear path inherits the same guard, so a traversal companyId can + // never reach rm() on an escaped root. + await expect(clearCodexAuthCache(env, "../../etc")).rejects.toThrow(); + const safeDir = resolveCodexAuthCacheDir(env, "company-a"); + expect(safeDir).toBe( + path.resolve(home, "instances", "default", "companies", "company-a", "codex-auth-cache"), + ); + }); + + it("resolveCodexAuthCacheEntryPath verifies the resolved path stays under the cache root", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + // A traversal account_id is rejected by toCacheKey, so the resolved entry + // path can never escape the cache root. + expect(() => resolveCodexAuthCacheEntryPath(env, "../../etc", "company-a")).toThrow(); + const entryPath = resolveCodexAuthCacheEntryPath(env, "acct-ok", "company-a"); + const cacheDir = resolveCodexAuthCacheDir(env, "company-a"); + expect(entryPath.startsWith(cacheDir + path.sep)).toBe(true); + expect(entryPath.endsWith(path.join("acct-ok", "auth.json"))).toBe(true); + }); + + it("ensureCodexAuthCacheEntryDir creates the cache root and the entry directory private (0700)", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + const entryPath = await ensureCodexAuthCacheEntryDir(env, "acct-priv", "company-a"); + const entryDir = path.dirname(entryPath); + const cacheDir = resolveCodexAuthCacheDir(env, "company-a"); + expect((await lstat(entryDir)).mode & 0o777).toBe(0o700); + expect((await lstat(cacheDir)).mode & 0o777).toBe(0o700); + }); + + it("the cache root fails closed (lstat) when it is a symlink or a non-directory", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + const cacheDir = resolveCodexAuthCacheDir(env, "company-a"); + // Make the cache root a symlink to another directory. lstat (not stat) + // must catch it and fail closed. + await mkdir(path.dirname(cacheDir), { recursive: true }); + const target = path.join(home, "elsewhere"); + await mkdir(target, { recursive: true }); + await symlink(target, cacheDir); + await expect(ensureCodexAuthCacheEntryDir(env, "acct-x", "company-a")).rejects.toThrow(); + }); + + it("the entry directory fails closed (lstat) when it is a symlink or a non-directory", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + const cacheDir = resolveCodexAuthCacheDir(env, "company-a"); + await mkdir(cacheDir, { recursive: true, mode: 0o700 }); + // Put a regular file where the entry directory must be. + await writeFile(path.join(cacheDir, "acct-file"), "not a dir", { mode: 0o600 }); + await expect(ensureCodexAuthCacheEntryDir(env, "acct-file", "company-a")).rejects.toThrow(); + }); + }); + + describe("Phase 4: identity-anchored vend", () => { + async function stageHostAndCache(input: { + hostAuth?: string; + cacheAuthByAccount?: Record; + }): Promise<{ env: NodeJS.ProcessEnv; sharedHomeAuthPath: string }> { + const home = await makeInstanceRoot(); + const env = envFor(home, { CODEX_HOME: path.join(home, "shared-codex") }); + const sharedHome = resolveSharedCodexHomeDir(env); + await mkdir(sharedHome, { recursive: true }); + const sharedHomeAuthPath = path.join(sharedHome, "auth.json"); + if (input.hostAuth !== undefined) { + await writeFile(sharedHomeAuthPath, input.hostAuth, { mode: 0o600 }); + } + for (const [accountId, auth] of Object.entries(input.cacheAuthByAccount ?? {})) { + const entryPath = await ensureCodexAuthCacheEntryDir(env, accountId, "company-a"); + await writeFile(entryPath, auth, { mode: 0o600 }); + } + return { env, sharedHomeAuthPath }; + } + + const resolveEntry = + (env: NodeJS.ProcessEnv) => (accountId: string) => + resolveCodexAuthCacheEntryPath(env, accountId, "company-a"); + + it("vend refreshes the host identity when the cached copy is strictly newer for the same identity", async () => { + const { env, sharedHomeAuthPath } = await stageHostAndCache({ + hostAuth: subscriptionAuth({ accountId: "acct-x", lastRefresh: OLDER, marker: "host" }), + cacheAuthByAccount: { + "acct-x": subscriptionAuth({ accountId: "acct-x", lastRefresh: NEWER, marker: "cache" }), + }, + }); + const outcome = await selectVendCredential(sharedHomeAuthPath, resolveEntry(env), () => undefined); + expect(outcome).toBe("vended"); + const finalHost = await readFile(sharedHomeAuthPath, "utf8"); + expect(finalHost).toContain("acct-x"); + expect(finalHost).toContain("cache"); + }); + + it("vend keeps the shared-home credential when the cache copy is older or a tie", async () => { + for (const cacheRefresh of [OLDER, NEWER]) { + const hostRefresh = cacheRefresh === OLDER ? NEWER : NEWER; // host newer or tie + const { env, sharedHomeAuthPath } = await stageHostAndCache({ + hostAuth: subscriptionAuth({ accountId: "acct-x", lastRefresh: hostRefresh, marker: "host" }), + cacheAuthByAccount: { + "acct-x": subscriptionAuth({ accountId: "acct-x", lastRefresh: cacheRefresh, marker: "cache" }), + }, + }); + const before = await readFile(sharedHomeAuthPath, "utf8"); + const outcome = await selectVendCredential(sharedHomeAuthPath, resolveEntry(env), () => undefined); + expect(outcome).toBe("kept-host"); + expect(await readFile(sharedHomeAuthPath, "utf8")).toBe(before); + } + }); + + it("vend never selects a cache slot with a different account_id than the host holds", async () => { + const { env, sharedHomeAuthPath } = await stageHostAndCache({ + hostAuth: subscriptionAuth({ accountId: "acct-x", lastRefresh: OLDER, marker: "host" }), + cacheAuthByAccount: { + "acct-y": subscriptionAuth({ accountId: "acct-y", lastRefresh: NEWER, marker: "other" }), + }, + }); + const before = await readFile(sharedHomeAuthPath, "utf8"); + const outcome = await selectVendCredential(sharedHomeAuthPath, resolveEntry(env), () => undefined); + expect(outcome).toBe("kept-host"); + expect(await readFile(sharedHomeAuthPath, "utf8")).toBe(before); + }); + + it("vend does nothing when the host shared home has no auth.json (no random pick from the cache)", async () => { + const { env, sharedHomeAuthPath } = await stageHostAndCache({ + cacheAuthByAccount: { + "acct-y": subscriptionAuth({ accountId: "acct-y", lastRefresh: NEWER, marker: "other" }), + }, + }); + const outcome = await selectVendCredential(sharedHomeAuthPath, resolveEntry(env), () => undefined); + expect(outcome).toBe("no-host-identity"); + await expect(lstat(sharedHomeAuthPath)).rejects.toThrow(); + }); + + it("vend does nothing when the host shared home holds an apikey (no subscription identity)", async () => { + const { env, sharedHomeAuthPath } = await stageHostAndCache({ + hostAuth: JSON.stringify({ OPENAI_API_KEY: "sk-host" }), + cacheAuthByAccount: { + "acct-y": subscriptionAuth({ accountId: "acct-y", lastRefresh: NEWER, marker: "other" }), + }, + }); + const before = await readFile(sharedHomeAuthPath, "utf8"); + const outcome = await selectVendCredential(sharedHomeAuthPath, resolveEntry(env), () => undefined); + expect(outcome).toBe("no-host-identity"); + expect(await readFile(sharedHomeAuthPath, "utf8")).toBe(before); + }); + + it("the vend never emits token bytes or a raw account_id to the log", async () => { + const { env, sharedHomeAuthPath } = await stageHostAndCache({ + hostAuth: subscriptionAuth({ accountId: "SECRET-ACCT", lastRefresh: OLDER, marker: "HOST-SENTINEL" }), + cacheAuthByAccount: { + "SECRET-ACCT": subscriptionAuth({ accountId: "SECRET-ACCT", lastRefresh: NEWER, marker: "CACHE-SENTINEL" }), + }, + }); + const logs: string[] = []; + await selectVendCredential(sharedHomeAuthPath, resolveEntry(env), (line) => { + logs.push(line); + }); + const combined = logs.join("\n"); + expect(combined).not.toContain("SENTINEL"); + expect(combined).not.toContain("SECRET-ACCT"); + expect(combined).not.toContain("id-token"); + }); + }); + + describe("Phase 5: cache-clear operator action and off-switch", () => { + it("clearCodexAuthCacheEntry removes one identity slot and leaves other slots intact", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + const keepPath = await ensureCodexAuthCacheEntryDir(env, "acct-keep", "company-a"); + const dropPath = await ensureCodexAuthCacheEntryDir(env, "acct-drop", "company-a"); + await writeFile(keepPath, subscriptionAuth({ accountId: "acct-keep" }), { mode: 0o600 }); + await writeFile(dropPath, subscriptionAuth({ accountId: "acct-drop" }), { mode: 0o600 }); + + await clearCodexAuthCacheEntry(env, "acct-drop", "company-a"); + await expect(lstat(path.dirname(dropPath))).rejects.toThrow(); + expect(await readFile(keepPath, "utf8")).toContain("acct-keep"); + }); + + it("clearCodexAuthCacheEntry rejects an account_id that escapes the cache root", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + await expect(clearCodexAuthCacheEntry(env, "../../etc", "company-a")).rejects.toThrow(); + await expect(clearCodexAuthCacheEntry(env, "a/b", "company-a")).rejects.toThrow(); + }); + + it("clearCodexAuthCacheEntry is a benign no-op for a missing slot", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + await expect(clearCodexAuthCacheEntry(env, "acct-absent", "company-a")).resolves.toBeUndefined(); + }); + + it("clearCodexAuthCache removes every slot", async () => { + const home = await makeInstanceRoot(); + const env = envFor(home); + await ensureCodexAuthCacheEntryDir(env, "acct-1", "company-a"); + await ensureCodexAuthCacheEntryDir(env, "acct-2", "company-a"); + const cacheDir = resolveCodexAuthCacheDir(env, "company-a"); + expect((await readdir(cacheDir)).sort()).toEqual(["acct-1", "acct-2"]); + await clearCodexAuthCache(env, "company-a"); + await expect(lstat(cacheDir)).rejects.toThrow(); + }); + + it("isCodexAuthCacheEnabled defaults to on and turns off on an explicit falsy flag", () => { + expect(isCodexAuthCacheEnabled({})).toBe(true); + expect(isCodexAuthCacheEnabled({ PAPERCLIP_CODEX_AUTH_CACHE: "1" })).toBe(true); + expect(isCodexAuthCacheEnabled({ PAPERCLIP_CODEX_AUTH_CACHE: "on" })).toBe(true); + expect(isCodexAuthCacheEnabled({ PAPERCLIP_CODEX_AUTH_CACHE: "0" })).toBe(false); + expect(isCodexAuthCacheEnabled({ PAPERCLIP_CODEX_AUTH_CACHE: "false" })).toBe(false); + expect(isCodexAuthCacheEnabled({ PAPERCLIP_CODEX_AUTH_CACHE: "off" })).toBe(false); + expect(isCodexAuthCacheEnabled({ PAPERCLIP_CODEX_AUTH_CACHE: "no" })).toBe(false); + }); + }); +}); diff --git a/packages/adapters/codex-local/src/server/codex-auth-cache.ts b/packages/adapters/codex-local/src/server/codex-auth-cache.ts new file mode 100644 index 0000000000..3283cfd680 --- /dev/null +++ b/packages/adapters/codex-local/src/server/codex-auth-cache.ts @@ -0,0 +1,415 @@ +import { execFile as execFileCallback } from "node:child_process"; +import { lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { randomUUID } from "node:crypto"; +import { resolvePaperclipInstanceRootForAdapter } from "@paperclipai/adapter-utils/server-utils"; +import { withDirectoryMergeLock } from "@paperclipai/adapter-utils/workspace-restore-merge"; + +const execFile = promisify(execFileCallback); + +// The identity-keyed host credential cache keeps one usable subscription +// credential per identity (`account_id`) in a SEPARATE host store, outside the +// shared Codex home and outside the symlink allowlist. The cache is additive: it +// never changes the copy-back path, the fail-closed decision predicate, or the +// host default store overwrite. It only refreshes an identity the host already +// holds. It never seeds an empty host store and never picks a credential at +// random. + +const CACHE_DIR_NAME = "codex-auth-cache"; +const CACHE_ENTRY_FILE = "auth.json"; +// A private directory (owner rwx only). 0o700 has no group/other bits, so a +// standard umask can never widen it. +const PRIVATE_DIR_MODE = 0o700; + +// One default-on off-switch. When the flag is an explicit falsy value the cache +// write and the cache vend become no-ops. The host default overwrite is +// unchanged in both states. +export const CODEX_AUTH_CACHE_OFF_SWITCH_ENV = "PAPERCLIP_CODEX_AUTH_CACHE"; +const FALSY_ENV_RE = /^(0|false|no|off)$/i; + +// The cache reuses the same direction-agnostic decision predicate the copy-back +// and inbound restore run. The predicate answers one question — "should the +// caller replace `destination` with `source`?" — purely by argument order (first +// = source, second = destination). Exit 10 = use source; exit 20 = keep +// destination. A leading `--seed-if-dest-absent` flag adds one opt-in behaviour: +// fill an ABSENT destination slot from a usable subscription source. The +// predicate only reads the two files and exits with a code; it never prints +// token bytes. +const DECISION_SCRIPT_PATH = fileURLToPath( + new URL("./codex-auth-merge-decision.cjs", import.meta.url), +); +const SEED_IF_DEST_ABSENT_FLAG = "--seed-if-dest-absent"; +const USE_SOURCE_EXIT = 10; +const KEEP_DESTINATION_EXIT = 20; + +function nonEmpty(value: string | undefined): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +/** + * True when the cache is enabled. The cache is on by default. It turns off only + * when {@link CODEX_AUTH_CACHE_OFF_SWITCH_ENV} is an explicit falsy value + * (`0`, `false`, `no`, or `off`). + */ +export function isCodexAuthCacheEnabled(env: NodeJS.ProcessEnv = process.env): boolean { + const raw = env[CODEX_AUTH_CACHE_OFF_SWITCH_ENV]; + if (typeof raw !== "string") return true; + return !FALSY_ENV_RE.test(raw.trim()); +} + +/** + * Sanitizes one raw value to a single safe path segment. Rejects an empty value, + * a relative segment (`.` or `..`), a path separator (`/` or `\`), and a NUL + * byte, so the value can never become a path traversal. Returns the trimmed, + * safe segment. The `label` names the value in the error message. (Security + * condition 3.) + */ +function toSafePathSegment(value: string, label: string): string { + const trimmed = typeof value === "string" ? value.trim() : ""; + if (trimmed.length === 0) { + throw new Error(`codex auth cache: ${label} is empty`); + } + if (trimmed === "." || trimmed === "..") { + throw new Error(`codex auth cache: ${label} is a relative path segment`); + } + if (trimmed.includes("/") || trimmed.includes("\\") || trimmed.includes("\0")) { + throw new Error(`codex auth cache: ${label} contains a path separator`); + } + // Defense in depth: a safe segment is exactly its own basename. Anything else + // carries a separator or a relative segment the checks above must have caught. + if (path.basename(trimmed) !== trimmed) { + throw new Error(`codex auth cache: ${label} is not a single path segment`); + } + return trimmed; +} + +/** + * Sanitizes an `account_id` to one safe path segment. Rejects an empty value, a + * relative segment (`.` or `..`), a path separator, and a NUL byte, so a raw + * `account_id` can never become a path traversal. Returns the trimmed, safe + * segment. (Security condition 3.) + */ +export function toCacheKey(accountId: string): string { + return toSafePathSegment(accountId, "account_id"); +} + +/** + * Resolves the company-scoped cache root under the same isolation boundary as + * the managed Codex home (`resolveManagedCodexHomeDir`). The root is always + * company-scoped, so a Codex credential can never cross a company boundary. + * There is no instance-global fallback root: `companyId` is required, and an + * empty value is a fail-loud error. `companyId` is sanitized to a single safe + * path segment, so a traversal value (`..`, `/etc`, `a/b`) can never make the + * cache root escape the `companies/` directory. Every downstream path (the + * entry path, the vend, and the clear) inherits this guard. (Security + * condition 1.) + */ +export function resolveCodexAuthCacheDir( + env: NodeJS.ProcessEnv = process.env, + companyId: string, +): string { + const safeCompanyId = toSafePathSegment(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, CACHE_DIR_NAME); +} + +/** + * Resolves the entry path for one identity: `//auth.json`. + * The `account_id` is sanitized by {@link toCacheKey}. After the join, this + * verifies the resolved entry path stays under the cache root and ends at exactly + * `/auth.json`. This function does no filesystem work; it is safe + * for a read path (the vend and the clear). (Security condition 3.) + */ +export function resolveCodexAuthCacheEntryPath( + env: NodeJS.ProcessEnv = process.env, + accountId: string, + companyId: string, +): string { + const resolvedRoot = resolveCodexAuthCacheDir(env, companyId); + const safeKey = toCacheKey(accountId); + const entryDir = path.resolve(resolvedRoot, safeKey); + const entryPath = path.resolve(entryDir, CACHE_ENTRY_FILE); + const expectedEntryPath = path.join(resolvedRoot, safeKey, CACHE_ENTRY_FILE); + if ( + !entryDir.startsWith(resolvedRoot + path.sep) || + path.dirname(entryDir) !== resolvedRoot || + entryPath !== expectedEntryPath + ) { + throw new Error("codex auth cache: resolved entry path escapes the cache root"); + } + return entryPath; +} + +/** + * Ensures one directory exists and is private (mode 0700). Fails closed with + * `lstat` (not `stat`) when the existing path is a symlink or a non-directory, + * so the cache never writes through a planted symlink. (Security condition 3.) + */ +async function ensurePrivateDir(dir: string): Promise { + const existing = await lstat(dir).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }); + if (existing) { + if (existing.isSymbolicLink() || !existing.isDirectory()) { + throw new Error("codex auth cache: cache path is a symlink or a non-directory"); + } + return; + } + await mkdir(dir, { recursive: true, mode: PRIVATE_DIR_MODE }); +} + +/** + * Resolves the entry path and creates the cache root and the entry directory + * private (mode 0700), each guarded by `lstat`. Use this on the write path + * before the cache slot is written. + */ +export async function ensureCodexAuthCacheEntryDir( + env: NodeJS.ProcessEnv = process.env, + accountId: string, + companyId: string, +): Promise { + const entryPath = resolveCodexAuthCacheEntryPath(env, accountId, companyId); + await ensurePrivateDir(resolveCodexAuthCacheDir(env, companyId)); + await ensurePrivateDir(path.dirname(entryPath)); + return entryPath; +} + +/** + * Reads the usable subscription `account_id` from an `auth.json` payload. Returns + * `null` for an absent, unusable, or api-key credential (no subscription + * identity). This mirrors `parseAuth` in `codex-auth-merge-decision.cjs`; keep + * the two in step when the auth format changes. + */ +export function readSubscriptionAccountId(bytes: Buffer): string | null { + let parsed: unknown; + try { + parsed = JSON.parse(bytes.toString("utf8")); + } catch { + return null; + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return null; + } + const record = parsed as Record; + const apiKey = record.OPENAI_API_KEY; + if (typeof apiKey === "string" && apiKey.trim().length > 0) { + return null; + } + const tokens = record.tokens; + if (tokens === null || typeof tokens !== "object" || Array.isArray(tokens)) { + return null; + } + const tokenRecord = tokens as Record; + const accountId = typeof tokenRecord.account_id === "string" ? tokenRecord.account_id.trim() : ""; + const hasTokenMaterial = ["id_token", "access_token", "refresh_token"].some((key) => { + const value = tokenRecord[key]; + return typeof value === "string" && value.trim().length > 0; + }); + if (!accountId || !hasTokenMaterial) { + return null; + } + return accountId; +} + +async function decideExitCode( + sourcePath: string, + destinationPath: string, + options: { seedIfDestAbsent?: boolean } = {}, +): Promise { + const args = options.seedIfDestAbsent + ? [DECISION_SCRIPT_PATH, SEED_IF_DEST_ABSENT_FLAG, sourcePath, destinationPath] + : [DECISION_SCRIPT_PATH, sourcePath, destinationPath]; + try { + await execFile("node", args); + } catch (error) { + const code = (error as { code?: unknown }).code; + if (code === USE_SOURCE_EXIT || code === KEEP_DESTINATION_EXIT) { + return code; + } + const detail = + typeof code === "string" + ? `node could not be executed (${code})` + : typeof code === "number" + ? `unexpected predicate exit code ${code}` + : error instanceof Error + ? error.message + : String(error); + throw new Error(`codex auth cache decision predicate failed: ${detail}`); + } + throw new Error("codex auth cache decision predicate exited 0 (expected 10 or 20)"); +} + +export type WriteCodexAuthCacheEntryOutcome = "written" | "kept-slot"; + +/** + * Writes `sandboxAuthBytes` into its per-identity cache slot when the slot is + * absent, or when the source is a strictly-newer same-identity subscription + * credential (Phase 2 seed mode). The mutation runs under the merge lock on the + * slot directory. Never logs token bytes or a raw `account_id`. + */ +export async function writeCodexAuthCacheEntry(input: { + sandboxAuthBytes: Buffer; + cacheEntryPath: string; + log: (line: string) => void | Promise; +}): Promise { + const { sandboxAuthBytes, cacheEntryPath, log } = input; + const cacheEntryDir = path.dirname(cacheEntryPath); + return withDirectoryMergeLock(cacheEntryDir, async () => { + const stagedTempPath = path.join( + cacheEntryDir, + `.auth.json.cache-source-${process.pid}-${randomUUID()}.tmp`, + ); + const handle = await open(stagedTempPath, "wx", 0o600); + try { + await handle.writeFile(sandboxAuthBytes); + await handle.close(); + const decision = await decideExitCode(stagedTempPath, cacheEntryPath, { + seedIfDestAbsent: true, + }); + if (decision === USE_SOURCE_EXIT) { + await rename(stagedTempPath, cacheEntryPath); + await log("[paperclip] Codex auth cache: wrote the per-identity cache slot at mode 0600."); + return "written"; + } + await log( + "[paperclip] Codex auth cache: kept the cache slot (source is not a strictly-newer same-identity subscription credential).", + ); + return "kept-slot"; + } finally { + await handle.close().catch(() => undefined); + await rm(stagedTempPath, { force: true }).catch(() => undefined); + } + }); +} + +export type VendCodexAuthOutcome = "vended" | "kept-host" | "no-host-identity"; + +/** + * Refreshes the identity the host already holds with a strictly-newer cached + * credential of the same `account_id`. Identity-anchored: + * + * 1. Read the host identity from `sharedHomeAuthPath` first. + * 2. When the host holds no usable subscription identity, do nothing. The vend + * never picks a credential from the cache at random (matrix rows 3, 4). + * 3. Resolve ONLY that exact identity's cache slot. The vend never scans the + * cache root. (Security condition 4.) + * 4. Run the DEFAULT-mode predicate (cache slot as source, shared home as + * destination). Install the cache copy only when it is strictly newer for the + * same identity. + * + * The vend never changes identity and never stages a spent single-use refresh + * token (the strictly-newer rule guards it). Never logs token bytes or a raw + * `account_id`. + */ +export async function selectVendCredential( + sharedHomeAuthPath: string, + resolveCacheEntryPath: (accountId: string) => string, + log: (line: string) => void | Promise, +): Promise { + const hostBytes = await readFile(sharedHomeAuthPath).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }); + const hostAccountId = hostBytes ? readSubscriptionAccountId(hostBytes) : null; + if (!hostAccountId) { + // No host identity: the vend does nothing (identity-anchor rule). + return "no-host-identity"; + } + + let cacheEntryPath: string; + try { + cacheEntryPath = resolveCacheEntryPath(hostAccountId); + } catch { + // A host identity that cannot form a safe cache key never vends. + return "no-host-identity"; + } + + const hostDir = path.dirname(sharedHomeAuthPath); + return withDirectoryMergeLock(hostDir, async () => { + // Read the cached bytes under the lock, then stage exactly those bytes into a + // private (0600) temp next to the host target. The predicate reads the temp, + // so the vend installs exactly the bytes the predicate approved. This closes + // the read-after-validate skew: a separate reader of `cacheEntryPath` could + // otherwise see different bytes than the ones installed. The rename over the + // host target stays device-local and atomic. + const cacheBytes = await readFile(cacheEntryPath).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }); + if (!cacheBytes) { + await log("[paperclip] Codex auth cache: no cached credential for the host identity; host credential kept."); + return "kept-host"; + } + const stagedTempPath = path.join( + hostDir, + `.auth.json.vend-${process.pid}-${randomUUID()}.tmp`, + ); + const handle = await open(stagedTempPath, "wx", 0o600); + try { + await handle.writeFile(cacheBytes); + await handle.close(); + // Default-mode predicate: install the cache copy only when it is strictly + // newer for the SAME identity. Same-identity + strictly-newer keeps the + // change additive and semantics-preserving. + const decision = await decideExitCode(stagedTempPath, sharedHomeAuthPath); + if (decision === USE_SOURCE_EXIT) { + await rename(stagedTempPath, sharedHomeAuthPath); + await log( + "[paperclip] Codex auth cache: refreshed the host credential with a strictly-newer cached copy of the same identity at mode 0600.", + ); + return "vended"; + } + await log( + "[paperclip] Codex auth cache: host credential kept (the cached copy is not strictly newer for the same identity).", + ); + return "kept-host"; + } finally { + await handle.close().catch(() => undefined); + await rm(stagedTempPath, { force: true }).catch(() => undefined); + } + }); +} + +/** + * Removes exactly one identity slot from the cache. The `account_id` is + * sanitized by {@link toCacheKey}, so the removal can never escape the cache + * root. A missing slot is a benign no-op. Any other removal error is fail-loud. + */ +export async function clearCodexAuthCacheEntry( + env: NodeJS.ProcessEnv = process.env, + accountId: string, + companyId: string, +): Promise { + const entryPath = resolveCodexAuthCacheEntryPath(env, accountId, companyId); + const entryDir = path.dirname(entryPath); + // A missing slot is a benign no-op. Pre-check before the lock: the merge lock + // sits next to the slot under the cache root, so locking a slot whose cache + // root does not exist would fail on the lock directory itself. + const existing = await lstat(entryDir).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }); + if (!existing) return; + await withDirectoryMergeLock(entryDir, async () => { + await rm(entryDir, { recursive: true, force: true }); + }); +} + +/** + * Removes every slot in the company-scoped cache root. A missing cache root is a + * benign no-op. + */ +export async function clearCodexAuthCache( + env: NodeJS.ProcessEnv = process.env, + companyId: string, +): Promise { + const cacheDir = resolveCodexAuthCacheDir(env, companyId); + await rm(cacheDir, { recursive: true, force: true }); +} diff --git a/packages/adapters/codex-local/src/server/codex-auth-copyback.test.ts b/packages/adapters/codex-local/src/server/codex-auth-copyback.test.ts index 4aff672ddb..e238eba085 100644 --- a/packages/adapters/codex-local/src/server/codex-auth-copyback.test.ts +++ b/packages/adapters/codex-local/src/server/codex-auth-copyback.test.ts @@ -1,9 +1,15 @@ -import { chmod, lstat, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { chmod, lstat, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { copyBackCodexAuth } from "./codex-auth-copyback.js"; +import { + ensureCodexAuthCacheEntryDir, + resolveCodexAuthCacheDir, + resolveCodexAuthCacheEntryPath, +} from "./codex-auth-cache.js"; +import { resolveSharedCodexHomeDir } from "./codex-home.js"; // The copy-back module reuses the exact same direction-agnostic decision // predicate (`codex-auth-merge-decision.cjs`) that the inbound extract path @@ -313,3 +319,299 @@ describe("copyBackCodexAuth", () => { expect(combined).not.toContain("refresh-token"); }); }); + +// The teardown copy-back also writes the fresher, usable subscription credential +// into its per-identity cache slot, keyed by the real `account_id`. The cache is +// a SEPARATE store; the host default overwrite and the cache slot are asserted +// independently. This suite drives the real `.cjs` predicate (default mode for +// the host store, seed mode for the cache slot) against a real host tmp +// filesystem. +describe("copyBackCodexAuth identity-keyed cache write", () => { + const cleanupDirs: string[] = []; + const COMPANY_ID = "company-a"; + + 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); + } + }); + + function subscriptionAuth(input: { accountId: string; lastRefresh?: string; marker: string }): string { + return JSON.stringify({ + tokens: { + id_token: `id-token-${input.marker}`, + access_token: `access-token-${input.marker}`, + refresh_token: `refresh-token-${input.marker}`, + account_id: input.accountId, + }, + ...(input.lastRefresh ? { last_refresh: input.lastRefresh } : {}), + }); + } + + function apiKeyAuth(marker: string): string { + return JSON.stringify({ OPENAI_API_KEY: `sk-${marker}` }); + } + + const NEWER = "2026-07-09T02:00:00Z"; + const OLDER = "2026-07-09T01:00:00Z"; + + async function makeEnv(extra: Record = {}): Promise<{ + env: NodeJS.ProcessEnv; + sharedHomeAuthPath: string; + sharedHome: string; + }> { + const home = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-copyback-cache-")); + cleanupDirs.push(home); + const env: NodeJS.ProcessEnv = { + PAPERCLIP_HOME: home, + PAPERCLIP_INSTANCE_ID: "default", + CODEX_HOME: path.join(home, "shared-codex"), + ...extra, + }; + const sharedHome = resolveSharedCodexHomeDir(env); + await mkdir(sharedHome, { recursive: true }); + return { env, sharedHomeAuthPath: path.join(sharedHome, "auth.json"), sharedHome }; + } + + async function runWithCache(input: { + sandboxAuth: string; + hostAuth?: string; + env: NodeJS.ProcessEnv; + sharedHomeAuthPath: string; + cacheEnabledEnv?: NodeJS.ProcessEnv; + }): Promise<{ + outcome: Awaited>; + finalHostAuth: string | null; + logs: string[]; + }> { + if (input.hostAuth !== undefined) { + await writeFile(input.sharedHomeAuthPath, input.hostAuth, { mode: 0o600 }); + } + const logs: string[] = []; + const outcome = await copyBackCodexAuth({ + readSandboxAuth: async () => Buffer.from(input.sandboxAuth, "utf8"), + hostAuthPath: input.sharedHomeAuthPath, + log: (line) => { + logs.push(line); + }, + resolveCacheEntryPath: (accountId) => + ensureCodexAuthCacheEntryDir(input.env, accountId, COMPANY_ID), + env: input.cacheEnabledEnv ?? input.env, + }); + const finalHostAuth = await readFile(input.sharedHomeAuthPath, "utf8").catch( + (error: NodeJS.ErrnoException) => (error.code === "ENOENT" ? null : Promise.reject(error)), + ); + return { outcome, finalHostAuth, logs }; + } + + async function readCacheSlot(env: NodeJS.ProcessEnv, accountId: string): Promise { + const entryPath = resolveCodexAuthCacheEntryPath(env, accountId, COMPANY_ID); + return readFile(entryPath, "utf8").catch((error: NodeJS.ErrnoException) => + error.code === "ENOENT" ? null : Promise.reject(error), + ); + } + + it("host absent (matrix row 3): host default store stays empty, cache slot holds the credential keyed by account_id", async () => { + const { env, sharedHomeAuthPath } = await makeEnv(); + const sandboxAuth = subscriptionAuth({ accountId: "acct-y", lastRefresh: NEWER, marker: "row3" }); + + const result = await runWithCache({ sandboxAuth, env, sharedHomeAuthPath }); + + expect(result.outcome).toBe("kept-host"); + expect(result.finalHostAuth).toBeNull(); // host never seeded + expect(await readCacheSlot(env, "acct-y")).toBe(sandboxAuth); + }); + + it("host present, same identity, sandbox newer (matrix row 1a): host default overwritten AND cache slot updated", async () => { + const { env, sharedHomeAuthPath } = await makeEnv(); + const sandboxAuth = subscriptionAuth({ accountId: "acct-x", lastRefresh: NEWER, marker: "sandbox" }); + const hostAuth = subscriptionAuth({ accountId: "acct-x", lastRefresh: OLDER, marker: "host" }); + + const result = await runWithCache({ sandboxAuth, hostAuth, env, sharedHomeAuthPath }); + + expect(result.outcome).toBe("copied"); + expect(result.finalHostAuth).toBe(sandboxAuth); + expect(await readCacheSlot(env, "acct-x")).toBe(sandboxAuth); + }); + + it("host present, different identity (matrix row 1b): host default untouched, cache slot holds the new identity only", async () => { + const { env, sharedHomeAuthPath } = await makeEnv(); + const sandboxAuth = subscriptionAuth({ accountId: "acct-y", lastRefresh: NEWER, marker: "sandbox" }); + const hostAuth = subscriptionAuth({ accountId: "acct-x", lastRefresh: OLDER, marker: "host" }); + + const result = await runWithCache({ sandboxAuth, hostAuth, env, sharedHomeAuthPath }); + + expect(result.outcome).toBe("kept-host"); + expect(result.finalHostAuth).toBe(hostAuth); // host keeps its own identity X + expect(await readCacheSlot(env, "acct-y")).toBe(sandboxAuth); // slot Y written + expect(await readCacheSlot(env, "acct-x")).toBeNull(); // no slot for host identity + }); + + it("sandbox apikey or unusable: neither the host default nor the cache changes", async () => { + for (const sandboxAuth of [apiKeyAuth("sbx"), "{not valid json"]) { + const { env, sharedHomeAuthPath } = await makeEnv(); + const hostAuth = subscriptionAuth({ accountId: "acct-x", lastRefresh: OLDER, marker: "host" }); + const result = await runWithCache({ sandboxAuth, hostAuth, env, sharedHomeAuthPath }); + expect(result.outcome).toBe("kept-host"); + expect(result.finalHostAuth).toBe(hostAuth); + const cacheDir = resolveCodexAuthCacheDir(env, COMPANY_ID); + // No cache slot directory is created at all for a credential with no identity. + await expect(readdir(cacheDir)).rejects.toThrow(); + } + }); + + it("off-switch off (matrix row 1a inputs): teardown cache write is a no-op and the host default overwrite still runs", async () => { + const { env, sharedHomeAuthPath } = await makeEnv(); + const sandboxAuth = subscriptionAuth({ accountId: "acct-x", lastRefresh: NEWER, marker: "sandbox" }); + const hostAuth = subscriptionAuth({ accountId: "acct-x", lastRefresh: OLDER, marker: "host" }); + + const result = await runWithCache({ + sandboxAuth, + hostAuth, + env, + sharedHomeAuthPath, + cacheEnabledEnv: { ...env, PAPERCLIP_CODEX_AUTH_CACHE: "0" }, + }); + + // Host overwrite still runs with the off-switch off. + expect(result.outcome).toBe("copied"); + expect(result.finalHostAuth).toBe(sandboxAuth); + // No cache slot is written. + const cacheDir = resolveCodexAuthCacheDir(env, COMPANY_ID); + await expect(readdir(cacheDir)).rejects.toThrow(); + }); + + it("two concurrent teardown writes for the same identity leave one valid cache slot (no partial file)", async () => { + const { env, sharedHomeAuthPath } = await makeEnv(); + await writeFile( + sharedHomeAuthPath, + subscriptionAuth({ accountId: "acct-x", lastRefresh: OLDER, marker: "host" }), + { mode: 0o600 }, + ); + const run = (marker: string) => + copyBackCodexAuth({ + readSandboxAuth: async () => + Buffer.from(subscriptionAuth({ accountId: "acct-x", lastRefresh: NEWER, marker }), "utf8"), + hostAuthPath: sharedHomeAuthPath, + log: () => {}, + resolveCacheEntryPath: (accountId) => ensureCodexAuthCacheEntryDir(env, accountId, COMPANY_ID), + env, + }); + await Promise.all([run("a"), run("b")]); + + const entryPath = resolveCodexAuthCacheEntryPath(env, "acct-x", COMPANY_ID); + const slotDir = path.dirname(entryPath); + // Exactly one valid slot file, no leftover staging temp. + expect(await readdir(slotDir)).toEqual(["auth.json"]); + const finalSlot = await readFile(entryPath, "utf8"); + expect(JSON.parse(finalSlot).tokens.account_id).toBe("acct-x"); + }); + + it("the cache write never emits source token bytes or a raw account_id to the log", async () => { + const { env, sharedHomeAuthPath } = await makeEnv(); + const sandboxAuth = subscriptionAuth({ + accountId: "SECRET-ACCT", + lastRefresh: NEWER, + marker: "TOKEN-SENTINEL", + }); + const result = await runWithCache({ sandboxAuth, env, sharedHomeAuthPath }); + expect(await readCacheSlot(env, "SECRET-ACCT")).toBe(sandboxAuth); + const combined = result.logs.join("\n"); + expect(combined).not.toContain("SENTINEL"); + expect(combined).not.toContain("SECRET-ACCT"); + expect(combined).not.toContain("id-token"); + }); + + it("a read-only cache directory does not fail the copy-back: the successful host result is kept and no partial slot remains", async () => { + const { env, sharedHomeAuthPath } = await makeEnv(); + const sandboxAuth = subscriptionAuth({ accountId: "acct-x", lastRefresh: NEWER, marker: "sandbox" }); + const hostAuth = subscriptionAuth({ accountId: "acct-x", lastRefresh: OLDER, marker: "host" }); + await writeFile(sharedHomeAuthPath, hostAuth, { mode: 0o600 }); + + // Pre-create the entry directory, then make it read-only so the cache-slot + // temp create fails. The host overwrite runs first and must stay intact. The + // additive cache write is best-effort, so its failure must not throw. + const entryPath = await ensureCodexAuthCacheEntryDir(env, "acct-x", COMPANY_ID); + const slotDir = path.dirname(entryPath); + const logs: string[] = []; + await chmod(slotDir, 0o500); + let outcome: string; + try { + outcome = await copyBackCodexAuth({ + readSandboxAuth: async () => Buffer.from(sandboxAuth, "utf8"), + hostAuthPath: sharedHomeAuthPath, + log: (line) => { + logs.push(line); + }, + resolveCacheEntryPath: (accountId) => ensureCodexAuthCacheEntryDir(env, accountId, COMPANY_ID), + env, + }); + } finally { + await chmod(slotDir, 0o700); + } + + // The host copy-back succeeded and its result is returned unchanged. + expect(outcome).toBe("copied"); + // Host default overwrite ran before the cache write and stays applied. + expect(await readFile(sharedHomeAuthPath, "utf8")).toBe(sandboxAuth); + // No partial slot file; only the (empty) slot directory remains. + expect(await readdir(slotDir)).toEqual([]); + // The failure is visible in the log with only the errno code. The raw + // account_id (which the failing slot path embeds) never reaches the log. + const combined = logs.join("\n"); + expect(combined).toContain("additive cache write failed (EACCES)"); + expect(combined).not.toContain("acct-x"); + }); + + it("a rejecting cache-failure log does not override the successful host copy-back result", async () => { + const { env, sharedHomeAuthPath } = await makeEnv(); + const sandboxAuth = subscriptionAuth({ accountId: "acct-x", lastRefresh: NEWER, marker: "sandbox" }); + const hostAuth = subscriptionAuth({ accountId: "acct-x", lastRefresh: OLDER, marker: "host" }); + await writeFile(sharedHomeAuthPath, hostAuth, { mode: 0o600 }); + + // Force the additive cache write to fail: pre-create the slot directory, + // then make it read-only so the slot temp create fails with EACCES. + const entryPath = await ensureCodexAuthCacheEntryDir(env, "acct-x", COMPANY_ID); + const slotDir = path.dirname(entryPath); + await chmod(slotDir, 0o500); + + // The logger rejects for the cache-failure diagnostic line only. The host + // copy-back already installed the sandbox credential on disk, so this + // rejection must not propagate and must not override the "copied" result. + const logs: string[] = []; + let outcome: Awaited> | undefined; + let thrown: unknown; + try { + outcome = await copyBackCodexAuth({ + readSandboxAuth: async () => Buffer.from(sandboxAuth, "utf8"), + hostAuthPath: sharedHomeAuthPath, + log: (line) => { + logs.push(line); + if (line.includes("additive cache write failed")) { + return Promise.reject(new Error("log sink boom")); + } + }, + resolveCacheEntryPath: (accountId) => ensureCodexAuthCacheEntryDir(env, accountId, COMPANY_ID), + env, + }).catch((error: unknown) => { + thrown = error; + return undefined; + }); + } finally { + await chmod(slotDir, 0o700); + } + + // The rejecting cache-failure log never surfaces as a thrown error. + expect(thrown).toBeUndefined(); + // The host copy-back result is kept intact. + expect(outcome).toBe("copied"); + expect(await readFile(sharedHomeAuthPath, "utf8")).toBe(sandboxAuth); + // No partial slot file remains after the failed cache write. + expect(await readdir(slotDir)).toEqual([]); + // The cache-failure diagnostic was attempted even though the sink rejected. + expect(logs.some((line) => line.includes("additive cache write failed (EACCES)"))).toBe(true); + }); +}); diff --git a/packages/adapters/codex-local/src/server/codex-auth-copyback.ts b/packages/adapters/codex-local/src/server/codex-auth-copyback.ts index bf960701b7..19028af021 100644 --- a/packages/adapters/codex-local/src/server/codex-auth-copyback.ts +++ b/packages/adapters/codex-local/src/server/codex-auth-copyback.ts @@ -5,6 +5,11 @@ import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { randomUUID } from "node:crypto"; import { withDirectoryMergeLock } from "@paperclipai/adapter-utils/workspace-restore-merge"; +import { + isCodexAuthCacheEnabled, + readSubscriptionAccountId, + writeCodexAuthCacheEntry, +} from "./codex-auth-cache.js"; const execFile = promisify(execFileCallback); @@ -41,6 +46,19 @@ export interface CopyBackCodexAuthInput { hostAuthPath: string; /** Non-leaking progress sink: receives decision/outcome lines only. */ log: (line: string) => void | Promise; + /** + * Resolves and ensures the per-identity cache slot path for a sandbox + * `account_id`. When this is provided AND the cache off-switch is on, the + * copy-back also writes the fresher, usable subscription credential into its + * per-identity cache slot as a second, additive write, keyed by the real + * `account_id`. This is independent of the host default overwrite: it can + * write a cache slot for a different identity than the host holds (matrix rows + * 1b, 3), and it never touches the host default store. When absent, no cache + * write happens. + */ + resolveCacheEntryPath?: (accountId: string) => Promise; + /** Environment for the cache off-switch read. Defaults to `process.env`. */ + env?: NodeJS.ProcessEnv; } async function decideExitCode(sourcePath: string, destinationPath: string): Promise { @@ -93,7 +111,7 @@ async function decideExitCode(sourcePath: string, destinationPath: string): Prom * Never logs token bytes — only the decision outcome. */ export async function copyBackCodexAuth(input: CopyBackCodexAuthInput): Promise { - const { readSandboxAuth, hostAuthPath, log } = input; + const { readSandboxAuth, hostAuthPath, log, resolveCacheEntryPath, env } = input; // Read first (outside the lock) — a read never mutates the host, so there is // nothing to serialize yet. A genuinely absent sandbox `auth.json` (ENOENT — @@ -116,7 +134,7 @@ export async function copyBackCodexAuth(input: CopyBackCodexAuthInput): Promise< const hostDir = path.dirname(hostAuthPath); await mkdir(hostDir, { recursive: true }); - return withDirectoryMergeLock(hostDir, async () => { + const hostOutcome = await withDirectoryMergeLock(hostDir, async () => { // Stage on the same filesystem as the host target so both the predicate read // and the final rename stay device-local (rename across devices is not // atomic and would fail with EXDEV). @@ -151,4 +169,43 @@ export async function copyBackCodexAuth(input: CopyBackCodexAuthInput): Promise< await rm(stagedTempPath, { force: true }).catch(() => undefined); } }); + + // Additive cache write. Independent of the host default overwrite above: it + // runs on its own directory lock, keys the slot by the real sandbox + // `account_id`, and can write a slot for a different identity than the host + // holds. It never touches the host default store. The off-switch (default on) + // makes this a no-op when disabled. Only a usable subscription credential has + // an identity to key; an api-key or unusable sandbox credential is skipped. + // + // The cache write is best-effort. The host copy-back above already finished + // and set `hostOutcome`, so a failure of this additive write must not replace + // that successful result. Catch the error, log it, and return `hostOutcome`. + // The cache stays a hint: the next teardown re-attempts the write. + if (resolveCacheEntryPath && isCodexAuthCacheEnabled(env)) { + try { + const sandboxAccountId = readSubscriptionAccountId(sandboxAuthBytes); + if (sandboxAccountId) { + const cacheEntryPath = await resolveCacheEntryPath(sandboxAccountId); + await writeCodexAuthCacheEntry({ sandboxAuthBytes, cacheEntryPath, log }); + } + } catch (error) { + // Log only the errno code, never the error message. The message embeds the + // cache slot path, and the slot path embeds the raw `account_id`; the code + // (for example EACCES or ENOSPC) makes the failure diagnosable without a + // leak. Token bytes never reach the log. + const code = (error as NodeJS.ErrnoException | null)?.code ?? "unknown"; + // The host copy-back above already finished and set `hostOutcome`. This + // diagnostic log is the last step, so a rejecting logger must not throw + // and turn that successful result into a failed copy-back. Guard the log: + // a rejection here is swallowed, and the function still returns + // `hostOutcome` below. + await Promise.resolve( + log( + `[paperclip] Codex auth cache: additive cache write failed (${code}); host copy-back result kept.`, + ), + ).catch(() => undefined); + } + } + + return hostOutcome; } diff --git a/packages/adapters/codex-local/src/server/codex-auth-merge-decision.cjs b/packages/adapters/codex-local/src/server/codex-auth-merge-decision.cjs index 358de3731e..fe557795a2 100644 --- a/packages/adapters/codex-local/src/server/codex-auth-merge-decision.cjs +++ b/packages/adapters/codex-local/src/server/codex-auth-merge-decision.cjs @@ -50,15 +50,45 @@ function parseAuth(filePath) { // argv[0] (first positional) = source auth.json path // argv[1] (second positional) = destination auth.json path // +// The predicate has two modes, selected by a leading positional flag: +// +// default (no flag) — fail closed to the destination; used by the host default +// store, whose fail-closed behavior must never change. An absent or +// unusable destination keeps the destination (no seed from empty). +// --seed-if-dest-absent — the opt-in cache-slot mode; used only by the +// per-identity cache slot helper. It ADDS one behavior on top of the default: +// when the destination is unusable (absent or unparseable) AND the source is +// a usable subscription credential, use the source (fill the empty slot). It +// never relaxes the different-identity, api-key, or unusable-source guards. +// +// A leading positional flag (not an environment variable) keeps the mode +// explicit per call, so a host-default two-path call can never enter seed mode. +// // Exit 10 = use source; exit 20 = keep destination. The predicate only ever // reads the two files and exits with a code — it never prints token bytes. const USE_SOURCE = 10; const KEEP_DESTINATION = 20; +const SEED_IF_DEST_ABSENT_FLAG = "--seed-if-dest-absent"; -const [sourceAuthPath, destinationAuthPath] = process.argv.slice(2); +const rawArgs = process.argv.slice(2); +const seedIfDestAbsent = rawArgs[0] === SEED_IF_DEST_ABSENT_FLAG; +const [sourceAuthPath, destinationAuthPath] = seedIfDestAbsent ? rawArgs.slice(1) : rawArgs; const sourceAuth = parseAuth(sourceAuthPath); const destinationAuth = parseAuth(destinationAuthPath); +// Seed mode only: fill an ABSENT (unusable) destination slot from a usable +// subscription source. A subscription-kind source is guaranteed usable and to +// carry a real account_id (parseAuth returns "subscription" only then), so this +// is never a random pick. This branch changes ONLY the destination-unusable +// case; the api-key and unusable-source guards below still keep the destination. +if ( + seedIfDestAbsent && + destinationAuth.kind === "unusable" && + sourceAuth.kind === "subscription" +) { + process.exit(USE_SOURCE); +} + // Fail closed to the destination unless both sides are the same usable, // subscription-kind identity — an unusable side, an api-key credential, a kind // mismatch, or a different account_id all keep the destination copy. diff --git a/packages/adapters/codex-local/src/server/codex-auth-merge-decision.test.ts b/packages/adapters/codex-local/src/server/codex-auth-merge-decision.test.ts new file mode 100644 index 0000000000..5c05352cda --- /dev/null +++ b/packages/adapters/codex-local/src/server/codex-auth-merge-decision.test.ts @@ -0,0 +1,167 @@ +import { execFile as execFileCallback } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, it } from "vitest"; + +const execFile = promisify(execFileCallback); + +// This suite pins the opt-in seed mode of the single decision predicate. The +// default (no-flag) call keeps the fail-closed host-default contract unchanged. +// The leading positional `--seed-if-dest-absent` flag adds one behaviour: fill +// an ABSENT destination slot from a usable subscription source. The flag never +// relaxes the different-identity, api-key, or unusable-source guards, and a +// default two-path call can never enter seed mode. +describe("codex-auth-merge-decision predicate seed mode", () => { + const cleanupDirs: string[] = []; + + afterEach(async () => { + while (cleanupDirs.length > 0) { + const dir = cleanupDirs.pop(); + if (!dir) continue; + await rm(dir, { recursive: true, force: true }).catch(() => undefined); + } + }); + + const decisionScriptPath = fileURLToPath( + new URL("./codex-auth-merge-decision.cjs", import.meta.url), + ); + + const USE_SOURCE = 10; + const KEEP_DESTINATION = 20; + const NEWER = "2026-07-09T02:00:00Z"; + const OLDER = "2026-07-09T01:00:00Z"; + + function subscriptionAuth(input: { accountId: string; lastRefresh?: string; marker?: string }): string { + const suffix = input.marker ?? input.accountId; + return JSON.stringify({ + tokens: { + id_token: `id-token-${suffix}`, + access_token: `access-token-${suffix}`, + refresh_token: `refresh-token-${suffix}`, + account_id: input.accountId, + }, + ...(input.lastRefresh ? { last_refresh: input.lastRefresh } : {}), + }); + } + + function apiKeyAuth(marker: string): string { + return JSON.stringify({ OPENAI_API_KEY: `sk-${marker}` }); + } + + const ABSENT = Symbol("absent"); + + async function runDecision(input: { + seed?: boolean; + sourceAuth: string; + destinationAuth: string | typeof ABSENT; + }): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-seed-decision-")); + cleanupDirs.push(dir); + const sourcePath = path.join(dir, "source-auth.json"); + const destinationPath = path.join(dir, "destination-auth.json"); + await writeFile(sourcePath, input.sourceAuth, { mode: 0o600 }); + if (input.destinationAuth !== ABSENT) { + await writeFile(destinationPath, input.destinationAuth, { mode: 0o600 }); + } + // The flag, when present, is the leading positional argument, parsed before + // the two path arguments. + const args = input.seed + ? [decisionScriptPath, "--seed-if-dest-absent", sourcePath, destinationPath] + : [decisionScriptPath, sourcePath, destinationPath]; + try { + await execFile("node", args); + return 0; + } catch (error) { + const failure = error as { code?: unknown }; + if (typeof failure.code === "number") return failure.code; + throw error; + } + } + + it("default mode keeps destination when destination is absent (host-default fail-closed)", async () => { + const code = await runDecision({ + sourceAuth: subscriptionAuth({ accountId: "acct", lastRefresh: NEWER }), + destinationAuth: ABSENT, + }); + expect(code).toBe(KEEP_DESTINATION); + }); + + it("seed mode uses source when destination is absent and source is a usable subscription credential", async () => { + const code = await runDecision({ + seed: true, + sourceAuth: subscriptionAuth({ accountId: "acct", lastRefresh: NEWER }), + destinationAuth: ABSENT, + }); + expect(code).toBe(USE_SOURCE); + }); + + it("seed mode uses source when destination is unparseable and source is a usable subscription credential", async () => { + const code = await runDecision({ + seed: true, + sourceAuth: subscriptionAuth({ accountId: "acct", lastRefresh: NEWER }), + destinationAuth: "{not valid json", + }); + expect(code).toBe(USE_SOURCE); + }); + + it("seed mode still keeps destination when source is apikey or unusable", async () => { + const apikeyCode = await runDecision({ + seed: true, + sourceAuth: apiKeyAuth("source"), + destinationAuth: ABSENT, + }); + expect(apikeyCode).toBe(KEEP_DESTINATION); + + const unusableCode = await runDecision({ + seed: true, + sourceAuth: "{not valid json", + destinationAuth: ABSENT, + }); + expect(unusableCode).toBe(KEEP_DESTINATION); + }); + + it("seed mode still keeps destination when destination holds a different account_id", async () => { + const code = await runDecision({ + seed: true, + sourceAuth: subscriptionAuth({ accountId: "acct-x", lastRefresh: NEWER }), + destinationAuth: subscriptionAuth({ accountId: "acct-y", lastRefresh: OLDER }), + }); + expect(code).toBe(KEEP_DESTINATION); + }); + + it("seed mode keeps the same-identity strictly-newer contract for a present destination", async () => { + const newerCode = await runDecision({ + seed: true, + sourceAuth: subscriptionAuth({ accountId: "acct", lastRefresh: NEWER, marker: "src" }), + destinationAuth: subscriptionAuth({ accountId: "acct", lastRefresh: OLDER, marker: "dst" }), + }); + expect(newerCode).toBe(USE_SOURCE); + + const tieCode = await runDecision({ + seed: true, + sourceAuth: subscriptionAuth({ accountId: "acct", lastRefresh: NEWER, marker: "src" }), + destinationAuth: subscriptionAuth({ accountId: "acct", lastRefresh: NEWER, marker: "dst" }), + }); + expect(tieCode).toBe(KEEP_DESTINATION); + }); + + it("the leading positional --seed-if-dest-absent flag is parsed before the two path arguments; a default two-path call never enters seed mode", async () => { + // Identical inputs; only the leading flag differs. Absent destination: + // default keeps, seed uses source. This proves a host-default two-path call + // (no flag) can never seed. + const defaultCode = await runDecision({ + sourceAuth: subscriptionAuth({ accountId: "acct", lastRefresh: NEWER }), + destinationAuth: ABSENT, + }); + const seedCode = await runDecision({ + seed: true, + sourceAuth: subscriptionAuth({ accountId: "acct", lastRefresh: NEWER }), + destinationAuth: ABSENT, + }); + expect(defaultCode).toBe(KEEP_DESTINATION); + expect(seedCode).toBe(USE_SOURCE); + }); +}); diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index 10d4e83f73..a39b746929 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -4,6 +4,12 @@ import { fileURLToPath } from "node:url"; import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils"; import { buildCodexAuthInboundProvision } from "./codex-auth-merge-scripts.js"; import { copyBackCodexAuth } from "./codex-auth-copyback.js"; +import { + ensureCodexAuthCacheEntryDir, + isCodexAuthCacheEnabled, + resolveCodexAuthCacheEntryPath, + selectVendCredential, +} from "./codex-auth-cache.js"; import { adapterExecutionTargetIsRemote, adapterExecutionTargetRemoteCwd, @@ -619,6 +625,29 @@ export async function execute(ctx: AdapterExecutionContext): Promise resolveCodexAuthCacheEntryPath(process.env, accountId, agent.companyId), + (line) => onLog("stdout", `${line}\n`), + ).catch(async (error) => { + // The vend is best-effort and additive. A vend failure must never block a + // run: log and fall through to seed from the unrefreshed shared credential. + await onLog( + "stderr", + `[paperclip] Codex auth cache: vend skipped after an error; using the shared credential as-is.\n`, + ); + void error; + }); + } if (configuredCodexHome == null) { await prepareManagedCodexHome(process.env, onLog, agent.companyId, { apiKey: configuredOpenAiApiKey, @@ -771,6 +800,14 @@ export async function execute(ctx: AdapterExecutionContext): Promise readFile(path.posix.join(assetDir, "auth.json")), hostAuthPath: path.join(resolveSharedCodexHomeDir(process.env), "auth.json"), log: (line) => onLog("stdout", `${line}\n`), + // Additive cache write (sandbox to host): also cache the + // sandbox subscription credential in its per-identity slot, + // keyed by the real `account_id`. Company-scoped root; the + // helper ensures the slot directory private and containment- + // guarded. The off-switch (default on) is read inside. + resolveCacheEntryPath: (accountId) => + ensureCodexAuthCacheEntryDir(process.env, accountId, agent.companyId), + env: process.env, })), // No `exclude` denylist: `stagedCodexHomeDir` already contains // ONLY the allowlisted files (auth/config/skills), so there is @@ -1470,11 +1507,29 @@ export async function execute(ctx: AdapterExecutionContext): Promise undefined); + } } } } finally {