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:
parent
c647b8cc2e
commit
7e592c33fa
|
|
@ -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`.
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in New Issue