mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
## 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>
334 lines
16 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|