Files
PaperClipAI/packages/adapters/codex-local/src/server/codex-auth-merge-decision.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

168 lines
6.3 KiB
TypeScript

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