mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 23:43:16 +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>
168 lines
6.3 KiB
TypeScript
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);
|
|
});
|
|
});
|