Files
PaperClipAI/packages/adapters/codex-local/src/server/codex-auth-cache.test.ts
T
Nicky LeachandPaperclip 7e592c33fa 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>
2026-08-04 16:33:54 -07:00

334 lines
16 KiB
TypeScript

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