feat(codex): add identity-keyed host credential cache (#10853)

## Thinking Path

> - Paperclip manages agent work as issues and pull requests.
> - This change lives in the Codex local adapter path.
> - The host auth flow needs a stable cache per identity.
> - The cache must not change the copy-back path or the default store
overwrite.
> - This pull request adds that cache and keeps the existing flow
intact.
> - The benefit is repeatable host auth with safer identity scoping.

## Linked Issues or Issue Description

**Subsystem affected**
- packages/adapters: Codex local adapter and host auth flow

**Problem or motivation**
- The host auth flow needs one usable credential per identity.
- The current flow does not keep that identity state in a separate
cache.

**Proposed solution**
- Add an identity-keyed host credential cache.
- Keep the cache company scoped.
- Add an opt-in seed mode for the merge decision helper.
- Write the cache at copy-back time without changing the default store
overwrite.

**Alternatives considered**
- Store the cache in the instance-global root.
- Seed the host default store through an environment flag.
- Both choices weaken isolation or caller control, so I did not use
them.

**Roadmap alignment**
- I checked `ROADMAP.md`.
- I found no direct overlap with an active roadmap item.
- The change fits the core auth and secrets direction.

**Additional context**
- Local tests passed before I opened this pull request.
- I found no direct duplicate pull request for this branch.

## What Changed

- Added `codex-auth-cache.ts` for company scoped cache storage and
identity anchored vending.
- Added `codex-auth-merge-decision.cjs` support for a leading
`--seed-if-dest-absent` flag.
- Updated `codex-auth-copyback.ts` to write the cache at teardown under
the merge lock.
- Wired `execute.ts` to use the identity anchored vend before the
managed home seed step.
- Added `CODEX-AUTH-CACHE.md` for the cache rules, directions, state
matrix, off switch, and clear action.
- Added and updated tests for cache behavior, merge decision flow, and
copy-back flow.

## Verification

- `pnpm --filter @paperclipai/adapter-codex-local exec vitest run`.
- `node_modules/.bin/vitest run --project @paperclipai/adapter-utils
workspace-restore-merge`.
- `tsc --noEmit` in `packages/adapters/codex-local`.
- `git log --oneline
origin/master..origin/feat/codex-auth-identity-cache` shows the expected
commits.

## Risks

- This change touches credential storage.
- A mistake in the cache path could break host auth for one identity.
- The tests reduce this risk, but the surface stays sensitive.

## Model Used

- OpenAI Codex, GPT-5, tool use enabled.

## 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 linked existing issues with `Fixes:` / `Closes:` /
`Refs:` or described the issue in the pull request body
- [x] I have not referenced internal or instance-local Paperclip issues
or links
- [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
- [x] All Paperclip CI gates are green
- [x] 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-04 16:33:54 -07:00 committed by GitHub
parent c647b8cc2e
commit 7e592c33fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 1469 additions and 9 deletions

View File

@ -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:
```
<instanceRoot>/companies/<companyId>/codex-auth-cache/<account_id>/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`.

View File

@ -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<string> {
const dir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-cache-"));
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 }): 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<string, string>;
}): 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);
});
});
});

View File

@ -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: `<cacheRoot>/<safeAccountId>/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
* `<safeAccountId>/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<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("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<string> {
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<string, unknown>;
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<string, unknown>;
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<number> {
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<void>;
}): Promise<WriteCodexAuthCacheEntryOutcome> {
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<void>,
): Promise<VendCodexAuthOutcome> {
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<void> {
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<void> {
const cacheDir = resolveCodexAuthCacheDir(env, companyId);
await rm(cacheDir, { recursive: true, force: true });
}

View File

@ -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<string, string> = {}): 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<ReturnType<typeof copyBackCodexAuth>>;
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<string | null> {
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<ReturnType<typeof copyBackCodexAuth>> | 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);
});
});

View File

@ -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<void>;
/**
* 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<string>;
/** Environment for the cache off-switch read. Defaults to `process.env`. */
env?: NodeJS.ProcessEnv;
}
async function decideExitCode(sourcePath: string, destinationPath: string): Promise<number> {
@ -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<CopyBackCodexAuthOutcome> {
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;
}

View File

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

View File

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

View File

@ -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<AdapterExec
const configuredHomeIsManaged =
configuredCodexHome != null &&
isManagedCodexHomePath(process.env, agent.companyId, configuredCodexHome);
// Identity-anchored cache vend (host to sandbox). Before the managed home is
// seeded from the shared source `auth.json`, refresh the shared credential with
// a strictly-newer cached copy of the SAME identity. The vend reads the host
// identity first and resolves only that identity's cache slot; when the host
// holds no credential it does nothing (no random pick). This keeps the change
// additive: the managed home still symlinks the shared `auth.json`, now at its
// freshest same-identity copy. The off-switch (default on) skips the vend.
if (isCodexAuthCacheEnabled(process.env)) {
const sharedHomeAuthPath = path.join(resolveSharedCodexHomeDir(process.env), "auth.json");
await selectVendCredential(
sharedHomeAuthPath,
(accountId) => 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<AdapterExec
readSandboxAuth: () => 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<AdapterExec
await paperclipBridge.stop();
}
if (restoreRemoteWorkspace) {
await onLog(
"stdout",
`[paperclip] Restoring workspace changes from ${describeAdapterExecutionTarget(executionTarget)}.\n`,
);
await restoreRemoteWorkspace();
// This teardown runs in a `finally`, so a throw here replaces the
// already-computed run result (`return toResult(...)`) and turns a
// successful Codex run into a failure. The workspace restore — and the
// host credential copy-back inside it — is a best-effort teardown step.
// Keep it rejection-safe: log a fault loudly and keep the pending
// result. The host copy-back installs the credential on disk before any
// diagnostic log runs, so it is already durable when this block returns.
try {
await onLog(
"stdout",
`[paperclip] Restoring workspace changes from ${describeAdapterExecutionTarget(executionTarget)}.\n`,
);
await restoreRemoteWorkspace();
} catch (error) {
await Promise.resolve(
onLog(
"stderr",
`[paperclip] Failed to restore workspace changes from ${describeAdapterExecutionTarget(
executionTarget,
)}: ${error instanceof Error ? error.message : String(error)}\n`,
),
).catch(() => undefined);
}
}
}
} finally {