mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02: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>
618 lines
25 KiB
TypeScript
618 lines
25 KiB
TypeScript
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
|
|
// runs, only with the arguments flipped: for an outbound copy-back the sandbox
|
|
// copy is the `source` and the host copy is the `destination`, so exit 10
|
|
// (use source) means "install the sandbox credential onto the host" and exit 20
|
|
// (keep destination) means "leave the host credential untouched". This suite
|
|
// drives the REAL `.cjs` through the module (no stub predicate) against a real
|
|
// host tmp filesystem, injecting only the sandbox read.
|
|
describe("copyBackCodexAuth", () => {
|
|
const cleanupDirs: string[] = [];
|
|
|
|
afterEach(async () => {
|
|
while (cleanupDirs.length > 0) {
|
|
const dir = cleanupDirs.pop();
|
|
if (!dir) continue;
|
|
// Re-open perms in case a test tightened them, so cleanup always succeeds.
|
|
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 } : {}),
|
|
},
|
|
null,
|
|
2,
|
|
);
|
|
}
|
|
|
|
function apiKeyAuth(marker: string): string {
|
|
return JSON.stringify({ OPENAI_API_KEY: `sk-${marker}` }, null, 2);
|
|
}
|
|
|
|
async function makeHostDir(): Promise<string> {
|
|
const dir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-copyback-"));
|
|
cleanupDirs.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
const NEWER = "2026-07-09T02:00:00Z";
|
|
const OLDER = "2026-07-09T01:00:00Z";
|
|
|
|
async function runCopyBack(input: {
|
|
sandboxAuth: string | (() => Promise<Buffer>);
|
|
hostAuth: string;
|
|
hostDir?: string;
|
|
}): Promise<{
|
|
outcome: Awaited<ReturnType<typeof copyBackCodexAuth>>;
|
|
finalHostAuth: string;
|
|
finalHostMode: number;
|
|
logs: string[];
|
|
leftoverEntries: string[];
|
|
}> {
|
|
const hostDir = input.hostDir ?? (await makeHostDir());
|
|
const hostAuthPath = path.join(hostDir, "auth.json");
|
|
await writeFile(hostAuthPath, input.hostAuth, { mode: 0o600 });
|
|
|
|
const readSandboxAuth =
|
|
typeof input.sandboxAuth === "function"
|
|
? input.sandboxAuth
|
|
: async () => Buffer.from(input.sandboxAuth as string, "utf8");
|
|
|
|
const logs: string[] = [];
|
|
const outcome = await copyBackCodexAuth({
|
|
readSandboxAuth,
|
|
hostAuthPath,
|
|
log: (line) => {
|
|
logs.push(line);
|
|
},
|
|
});
|
|
|
|
const finalHostAuth = await readFile(hostAuthPath, "utf8");
|
|
const finalHostMode = (await lstat(hostAuthPath)).mode & 0o777;
|
|
const leftoverEntries = (await readdir(hostDir)).filter((name) => name !== "auth.json");
|
|
return { outcome, finalHostAuth, finalHostMode, logs, leftoverEntries };
|
|
}
|
|
|
|
it("installs a strictly-newer same-account sandbox auth onto the host at 0600", async () => {
|
|
const sandboxAuth = subscriptionAuth({
|
|
accountId: "acct-same",
|
|
lastRefresh: NEWER,
|
|
marker: "sandbox-newer-SENTINEL",
|
|
});
|
|
const hostAuth = subscriptionAuth({
|
|
accountId: "acct-same",
|
|
lastRefresh: OLDER,
|
|
marker: "host-older-SENTINEL",
|
|
});
|
|
|
|
const result = await runCopyBack({ sandboxAuth, hostAuth });
|
|
|
|
expect(result.outcome).toBe("copied");
|
|
expect(result.finalHostAuth).toBe(sandboxAuth);
|
|
expect(result.finalHostMode).toBe(0o600);
|
|
// Temp staging file must be gone once the swap completes.
|
|
expect(result.leftoverEntries).toEqual([]);
|
|
// Never leak token bytes in log output.
|
|
expect(result.logs.join("\n")).not.toContain("SENTINEL");
|
|
});
|
|
|
|
it("keeps the host auth when the sandbox copy is not strictly newer", async () => {
|
|
const cases: { name: string; sandboxAuth: string; hostAuth: string }[] = [
|
|
{
|
|
name: "tie",
|
|
sandboxAuth: subscriptionAuth({ accountId: "acct-same", lastRefresh: NEWER, marker: "sandbox-tie" }),
|
|
hostAuth: subscriptionAuth({ accountId: "acct-same", lastRefresh: NEWER, marker: "host-tie" }),
|
|
},
|
|
{
|
|
name: "sandbox older",
|
|
sandboxAuth: subscriptionAuth({ accountId: "acct-same", lastRefresh: OLDER, marker: "sandbox-older" }),
|
|
hostAuth: subscriptionAuth({ accountId: "acct-same", lastRefresh: NEWER, marker: "host-newer" }),
|
|
},
|
|
{
|
|
name: "missing sandbox last_refresh",
|
|
sandboxAuth: subscriptionAuth({ accountId: "acct-same", marker: "sandbox-no-refresh" }),
|
|
hostAuth: subscriptionAuth({ accountId: "acct-same", lastRefresh: NEWER, marker: "host-refresh" }),
|
|
},
|
|
{
|
|
name: "unparseable sandbox last_refresh",
|
|
sandboxAuth: subscriptionAuth({ accountId: "acct-same", lastRefresh: "not-a-date", marker: "sandbox-bad" }),
|
|
hostAuth: subscriptionAuth({ accountId: "acct-same", lastRefresh: NEWER, marker: "host-refresh" }),
|
|
},
|
|
];
|
|
|
|
for (const entry of cases) {
|
|
const result = await runCopyBack({ sandboxAuth: entry.sandboxAuth, hostAuth: entry.hostAuth });
|
|
expect(result.outcome, entry.name).toBe("kept-host");
|
|
expect(result.finalHostAuth, entry.name).toBe(entry.hostAuth);
|
|
expect(result.finalHostMode, entry.name).toBe(0o600);
|
|
expect(result.leftoverEntries, entry.name).toEqual([]);
|
|
}
|
|
});
|
|
|
|
it("keeps the host auth on identity mismatch, kind mismatch, apikey, and unusable sandbox auth", async () => {
|
|
const hostAuth = subscriptionAuth({ accountId: "acct-host", lastRefresh: OLDER, marker: "host-keep" });
|
|
const cases: { name: string; sandboxAuth: string; hostAuth: string }[] = [
|
|
{
|
|
name: "identity mismatch (sandbox newer, different account)",
|
|
sandboxAuth: subscriptionAuth({ accountId: "acct-other", lastRefresh: NEWER, marker: "sandbox-other" }),
|
|
hostAuth,
|
|
},
|
|
{
|
|
name: "kind mismatch (sandbox subscription, host apikey)",
|
|
sandboxAuth: subscriptionAuth({ accountId: "acct-host", lastRefresh: NEWER, marker: "sandbox-sub" }),
|
|
hostAuth: apiKeyAuth("host-api-key"),
|
|
},
|
|
{
|
|
name: "sandbox apikey",
|
|
sandboxAuth: apiKeyAuth("sandbox-api-key"),
|
|
hostAuth,
|
|
},
|
|
{
|
|
name: "sandbox unusable JSON",
|
|
sandboxAuth: "{not valid json",
|
|
hostAuth,
|
|
},
|
|
{
|
|
name: "sandbox account id missing",
|
|
sandboxAuth: JSON.stringify({
|
|
tokens: { id_token: "id", access_token: "acc", refresh_token: "ref" },
|
|
last_refresh: NEWER,
|
|
}),
|
|
hostAuth,
|
|
},
|
|
{
|
|
name: "host unusable JSON (never create host auth from sandbox)",
|
|
sandboxAuth: subscriptionAuth({ accountId: "acct-host", lastRefresh: NEWER, marker: "sandbox-valid" }),
|
|
hostAuth: "{not valid json",
|
|
},
|
|
];
|
|
|
|
for (const entry of cases) {
|
|
const result = await runCopyBack({ sandboxAuth: entry.sandboxAuth, hostAuth: entry.hostAuth });
|
|
expect(result.outcome, entry.name).toBe("kept-host");
|
|
expect(result.finalHostAuth, entry.name).toBe(entry.hostAuth);
|
|
expect(result.finalHostMode, entry.name).toBe(0o600);
|
|
expect(result.leftoverEntries, entry.name).toEqual([]);
|
|
}
|
|
});
|
|
|
|
it("creates a missing shared Codex home before staging copy-back", async () => {
|
|
const rootDir = await makeHostDir();
|
|
const hostDir = path.join(rootDir, "missing-codex-home");
|
|
const hostAuthPath = path.join(hostDir, "auth.json");
|
|
const logs: string[] = [];
|
|
|
|
const outcome = await copyBackCodexAuth({
|
|
readSandboxAuth: async () => Buffer.from(apiKeyAuth("sandbox-only"), "utf8"),
|
|
hostAuthPath,
|
|
log: (line) => {
|
|
logs.push(line);
|
|
},
|
|
});
|
|
|
|
expect(outcome).toBe("kept-host");
|
|
expect(await readdir(hostDir)).toEqual([]);
|
|
expect(logs.join("\n")).not.toContain("sandbox-only");
|
|
});
|
|
|
|
it("preserves the host file atomically when the install cannot be staged (no partial write, no leaked temp)", async () => {
|
|
// Make the host directory read-only so staging the same-filesystem temp fails
|
|
// with EACCES. The host credential must be left byte-for-byte intact and no
|
|
// partial/temp file may remain — the outbound write is all-or-nothing.
|
|
const hostDir = await makeHostDir();
|
|
const hostAuth = subscriptionAuth({ accountId: "acct-same", lastRefresh: OLDER, marker: "host-intact" });
|
|
const hostAuthPath = path.join(hostDir, "auth.json");
|
|
await writeFile(hostAuthPath, hostAuth, { mode: 0o600 });
|
|
const before = await stat(hostAuthPath);
|
|
|
|
await chmod(hostDir, 0o500); // r-x: readable/traversable, not writable
|
|
try {
|
|
const sandboxAuth = subscriptionAuth({ accountId: "acct-same", lastRefresh: NEWER, marker: "sandbox-newer" });
|
|
await expect(
|
|
copyBackCodexAuth({
|
|
readSandboxAuth: async () => Buffer.from(sandboxAuth, "utf8"),
|
|
hostAuthPath,
|
|
log: () => {},
|
|
}),
|
|
).rejects.toThrow();
|
|
} finally {
|
|
await chmod(hostDir, 0o700);
|
|
}
|
|
|
|
const after = await stat(hostAuthPath);
|
|
expect(await readFile(hostAuthPath, "utf8")).toBe(hostAuth);
|
|
expect(after.mode & 0o777).toBe(0o600);
|
|
expect(after.mtimeMs).toBe(before.mtimeMs);
|
|
expect((await readdir(hostDir)).filter((name) => name !== "auth.json")).toEqual([]);
|
|
});
|
|
|
|
it("treats an absent sandbox auth.json (ENOENT) as a keep-host no-op, host untouched, no throw", async () => {
|
|
const hostAuth = subscriptionAuth({ accountId: "acct-same", lastRefresh: OLDER, marker: "host-intact" });
|
|
|
|
// The real production `readSandboxAuth` is `readFile("${assetDir}/auth.json")`;
|
|
// a genuinely absent file surfaces a node ENOENT error. That must be a benign
|
|
// "nothing to copy back" outcome, not a fail-loud teardown error.
|
|
const enoent = Object.assign(new Error("ENOENT: no such file or directory, open 'auth.json'"), {
|
|
code: "ENOENT",
|
|
});
|
|
|
|
const result = await runCopyBack({
|
|
sandboxAuth: async () => {
|
|
throw enoent;
|
|
},
|
|
hostAuth,
|
|
});
|
|
|
|
expect(result.outcome).toBe("kept-host");
|
|
expect(result.finalHostAuth).toBe(hostAuth);
|
|
expect(result.finalHostMode).toBe(0o600);
|
|
// No staging temp is ever created on the ENOENT path.
|
|
expect(result.leftoverEntries).toEqual([]);
|
|
expect(result.logs.join("\n")).toContain("no sandbox credential to copy back");
|
|
});
|
|
|
|
it("fails loud when the sandbox read errors and leaves the host untouched", async () => {
|
|
const hostDir = await makeHostDir();
|
|
const hostAuth = subscriptionAuth({ accountId: "acct-same", lastRefresh: OLDER, marker: "host-intact" });
|
|
const hostAuthPath = path.join(hostDir, "auth.json");
|
|
await writeFile(hostAuthPath, hostAuth, { mode: 0o600 });
|
|
|
|
await expect(
|
|
copyBackCodexAuth({
|
|
readSandboxAuth: async () => {
|
|
throw new Error("sandbox read boom");
|
|
},
|
|
hostAuthPath,
|
|
log: () => {},
|
|
}),
|
|
).rejects.toThrow(/sandbox read boom/);
|
|
|
|
expect(await readFile(hostAuthPath, "utf8")).toBe(hostAuth);
|
|
expect((await readdir(hostDir)).filter((name) => name !== "auth.json")).toEqual([]);
|
|
});
|
|
|
|
it("does not emit token substrings on any code path", async () => {
|
|
const sandboxAuth = subscriptionAuth({
|
|
accountId: "acct-same",
|
|
lastRefresh: NEWER,
|
|
marker: "TOKEN-SENTINEL",
|
|
});
|
|
const hostAuth = subscriptionAuth({
|
|
accountId: "acct-same",
|
|
lastRefresh: OLDER,
|
|
marker: "HOST-SENTINEL",
|
|
});
|
|
|
|
const result = await runCopyBack({ sandboxAuth, hostAuth });
|
|
expect(result.outcome).toBe("copied");
|
|
const combined = result.logs.join("\n");
|
|
expect(combined).not.toContain("SENTINEL");
|
|
expect(combined).not.toContain("id-token");
|
|
expect(combined).not.toContain("access-token");
|
|
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);
|
|
});
|
|
});
|