mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +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>
118 lines
4.8 KiB
JavaScript
118 lines
4.8 KiB
JavaScript
const fs = require("fs");
|
|
|
|
// Co-change notice: parseAuth below mirrors hasUsableAuthPayload in
|
|
// packages/adapters/codex-local/src/server/codex-home.ts. If the auth format
|
|
// changes (new shape, renamed field), update both sites together.
|
|
function parseAuth(filePath) {
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
} catch {
|
|
return { kind: "unusable" };
|
|
}
|
|
|
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
return { kind: "unusable" };
|
|
}
|
|
|
|
if (typeof parsed.OPENAI_API_KEY === "string" && parsed.OPENAI_API_KEY.trim().length > 0) {
|
|
return { kind: "apikey" };
|
|
}
|
|
|
|
const tokens = parsed.tokens;
|
|
if (tokens === null || typeof tokens !== "object" || Array.isArray(tokens)) {
|
|
return { kind: "unusable" };
|
|
}
|
|
|
|
const accountId = typeof tokens.account_id === "string" ? tokens.account_id.trim() : "";
|
|
const hasTokenMaterial = ["id_token", "access_token", "refresh_token"].some((key) => {
|
|
const value = tokens[key];
|
|
return typeof value === "string" && value.trim().length > 0;
|
|
});
|
|
if (!accountId || !hasTokenMaterial) {
|
|
return { kind: "unusable" };
|
|
}
|
|
|
|
const lastRefresh = typeof parsed.last_refresh === "string" ? Date.parse(parsed.last_refresh) : NaN;
|
|
return {
|
|
kind: "subscription",
|
|
accountId,
|
|
lastRefresh: Number.isFinite(lastRefresh) ? lastRefresh : null,
|
|
};
|
|
}
|
|
|
|
// This predicate answers a single, direction-agnostic question: should the
|
|
// caller replace the `destination` auth.json with the `source` auth.json? The
|
|
// caller picks which copy is source and which is destination from its own frame
|
|
// of reference (an inbound restore, an outbound copy-back, …) purely by argument
|
|
// order — there is no `--direction` flag and no hard-coded sandbox/host notion:
|
|
//
|
|
// argv[0] (first positional) = source auth.json path
|
|
// argv[1] (second positional) = destination auth.json path
|
|
//
|
|
// The predicate has two modes, selected by a leading positional flag:
|
|
//
|
|
// default (no flag) — fail closed to the destination; used by the host default
|
|
// store, whose fail-closed behavior must never change. An absent or
|
|
// unusable destination keeps the destination (no seed from empty).
|
|
// --seed-if-dest-absent — the opt-in cache-slot mode; used only by the
|
|
// per-identity cache slot helper. It ADDS one behavior on top of the default:
|
|
// when the destination is unusable (absent or unparseable) AND the source is
|
|
// a usable subscription credential, use the source (fill the empty slot). It
|
|
// never relaxes the different-identity, api-key, or unusable-source guards.
|
|
//
|
|
// A leading positional flag (not an environment variable) keeps the mode
|
|
// explicit per call, so a host-default two-path call can never enter seed mode.
|
|
//
|
|
// Exit 10 = use source; exit 20 = keep destination. The predicate only ever
|
|
// reads the two files and exits with a code — it never prints token bytes.
|
|
const USE_SOURCE = 10;
|
|
const KEEP_DESTINATION = 20;
|
|
const SEED_IF_DEST_ABSENT_FLAG = "--seed-if-dest-absent";
|
|
|
|
const rawArgs = process.argv.slice(2);
|
|
const seedIfDestAbsent = rawArgs[0] === SEED_IF_DEST_ABSENT_FLAG;
|
|
const [sourceAuthPath, destinationAuthPath] = seedIfDestAbsent ? rawArgs.slice(1) : rawArgs;
|
|
const sourceAuth = parseAuth(sourceAuthPath);
|
|
const destinationAuth = parseAuth(destinationAuthPath);
|
|
|
|
// Seed mode only: fill an ABSENT (unusable) destination slot from a usable
|
|
// subscription source. A subscription-kind source is guaranteed usable and to
|
|
// carry a real account_id (parseAuth returns "subscription" only then), so this
|
|
// is never a random pick. This branch changes ONLY the destination-unusable
|
|
// case; the api-key and unusable-source guards below still keep the destination.
|
|
if (
|
|
seedIfDestAbsent &&
|
|
destinationAuth.kind === "unusable" &&
|
|
sourceAuth.kind === "subscription"
|
|
) {
|
|
process.exit(USE_SOURCE);
|
|
}
|
|
|
|
// Fail closed to the destination unless both sides are the same usable,
|
|
// subscription-kind identity — an unusable side, an api-key credential, a kind
|
|
// mismatch, or a different account_id all keep the destination copy.
|
|
if (
|
|
destinationAuth.kind === "unusable" ||
|
|
sourceAuth.kind === "unusable" ||
|
|
sourceAuth.kind !== destinationAuth.kind ||
|
|
destinationAuth.kind === "apikey" ||
|
|
sourceAuth.accountId !== destinationAuth.accountId
|
|
) {
|
|
process.exit(KEEP_DESTINATION);
|
|
}
|
|
|
|
// Use the source credential only when it is strictly fresher: both sides must
|
|
// carry a parseable last_refresh and the source one must be strictly greater.
|
|
// Ties and null/unparseable freshness keep the destination copy so a spent
|
|
// single-use refresh token is never written over a good one.
|
|
if (
|
|
sourceAuth.lastRefresh !== null &&
|
|
destinationAuth.lastRefresh !== null &&
|
|
sourceAuth.lastRefresh > destinationAuth.lastRefresh
|
|
) {
|
|
process.exit(USE_SOURCE);
|
|
}
|
|
|
|
process.exit(KEEP_DESTINATION);
|