@tickernelz/paperclip-pro-adapter-codex-local 2026.925.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/dist/cli/format-event.d.ts +2 -0
- package/dist/cli/format-event.d.ts.map +1 -0
- package/dist/cli/format-event.js +218 -0
- package/dist/cli/format-event.js.map +1 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +2 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/quota-probe.d.ts +3 -0
- package/dist/cli/quota-probe.d.ts.map +1 -0
- package/dist/cli/quota-probe.js +104 -0
- package/dist/cli/quota-probe.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +155 -0
- package/dist/index.js.map +1 -0
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +58 -0
- package/dist/index.test.js.map +1 -0
- package/dist/server/acp.d.ts +30 -0
- package/dist/server/acp.d.ts.map +1 -0
- package/dist/server/acp.js +532 -0
- package/dist/server/acp.js.map +1 -0
- package/dist/server/acp.test.d.ts +2 -0
- package/dist/server/acp.test.d.ts.map +1 -0
- package/dist/server/acp.test.js +1087 -0
- package/dist/server/acp.test.js.map +1 -0
- package/dist/server/adapter-auth-promotion.d.ts +102 -0
- package/dist/server/adapter-auth-promotion.d.ts.map +1 -0
- package/dist/server/adapter-auth-promotion.js +210 -0
- package/dist/server/adapter-auth-promotion.js.map +1 -0
- package/dist/server/adapter-auth-promotion.test.d.ts +2 -0
- package/dist/server/adapter-auth-promotion.test.d.ts.map +1 -0
- package/dist/server/adapter-auth-promotion.test.js +658 -0
- package/dist/server/adapter-auth-promotion.test.js.map +1 -0
- package/dist/server/auth-check.d.ts +8 -0
- package/dist/server/auth-check.d.ts.map +1 -0
- package/dist/server/auth-check.js +8 -0
- package/dist/server/auth-check.js.map +1 -0
- package/dist/server/auth-precedence.d.ts +16 -0
- package/dist/server/auth-precedence.d.ts.map +1 -0
- package/dist/server/auth-precedence.js +20 -0
- package/dist/server/auth-precedence.js.map +1 -0
- package/dist/server/auth-precedence.test.d.ts +2 -0
- package/dist/server/auth-precedence.test.d.ts.map +1 -0
- package/dist/server/auth-precedence.test.js +100 -0
- package/dist/server/auth-precedence.test.js.map +1 -0
- package/dist/server/codex-args.d.ts +13 -0
- package/dist/server/codex-args.d.ts.map +1 -0
- package/dist/server/codex-args.js +76 -0
- package/dist/server/codex-args.js.map +1 -0
- package/dist/server/codex-args.test.d.ts +2 -0
- package/dist/server/codex-args.test.d.ts.map +1 -0
- package/dist/server/codex-args.test.js +243 -0
- package/dist/server/codex-args.test.js.map +1 -0
- package/dist/server/codex-auth-cache.d.ts +206 -0
- package/dist/server/codex-auth-cache.d.ts.map +1 -0
- package/dist/server/codex-auth-cache.js +501 -0
- package/dist/server/codex-auth-cache.js.map +1 -0
- package/dist/server/codex-auth-cache.test.d.ts +2 -0
- package/dist/server/codex-auth-cache.test.d.ts.map +1 -0
- package/dist/server/codex-auth-cache.test.js +497 -0
- package/dist/server/codex-auth-cache.test.js.map +1 -0
- package/dist/server/codex-auth-copyback.d.ts +53 -0
- package/dist/server/codex-auth-copyback.d.ts.map +1 -0
- package/dist/server/codex-auth-copyback.js +123 -0
- package/dist/server/codex-auth-copyback.js.map +1 -0
- package/dist/server/codex-auth-copyback.test.d.ts +2 -0
- package/dist/server/codex-auth-copyback.test.d.ts.map +1 -0
- package/dist/server/codex-auth-copyback.test.js +507 -0
- package/dist/server/codex-auth-copyback.test.js.map +1 -0
- package/dist/server/codex-auth-merge-decision.cjs +170 -0
- package/dist/server/codex-auth-merge-decision.d.ts +22 -0
- package/dist/server/codex-auth-merge-decision.d.ts.map +1 -0
- package/dist/server/codex-auth-merge-decision.js +58 -0
- package/dist/server/codex-auth-merge-decision.js.map +1 -0
- package/dist/server/codex-auth-merge-decision.test.d.ts +2 -0
- package/dist/server/codex-auth-merge-decision.test.d.ts.map +1 -0
- package/dist/server/codex-auth-merge-decision.test.js +230 -0
- package/dist/server/codex-auth-merge-decision.test.js.map +1 -0
- package/dist/server/codex-auth-merge-extract.sh +73 -0
- package/dist/server/codex-auth-merge-scripts.d.ts +21 -0
- package/dist/server/codex-auth-merge-scripts.d.ts.map +1 -0
- package/dist/server/codex-auth-merge-scripts.js +40 -0
- package/dist/server/codex-auth-merge-scripts.js.map +1 -0
- package/dist/server/codex-auth-merge.test.d.ts +2 -0
- package/dist/server/codex-auth-merge.test.d.ts.map +1 -0
- package/dist/server/codex-auth-merge.test.js +714 -0
- package/dist/server/codex-auth-merge.test.js.map +1 -0
- package/dist/server/codex-auth-seed-write.d.ts +30 -0
- package/dist/server/codex-auth-seed-write.d.ts.map +1 -0
- package/dist/server/codex-auth-seed-write.js +41 -0
- package/dist/server/codex-auth-seed-write.js.map +1 -0
- package/dist/server/codex-home.d.ts +162 -0
- package/dist/server/codex-home.d.ts.map +1 -0
- package/dist/server/codex-home.js +771 -0
- package/dist/server/codex-home.js.map +1 -0
- package/dist/server/codex-home.test.d.ts +2 -0
- package/dist/server/codex-home.test.d.ts.map +1 -0
- package/dist/server/codex-home.test.js +1183 -0
- package/dist/server/codex-home.test.js.map +1 -0
- package/dist/server/config-schema.d.ts +3 -0
- package/dist/server/config-schema.d.ts.map +1 -0
- package/dist/server/config-schema.js +67 -0
- package/dist/server/config-schema.js.map +1 -0
- package/dist/server/device-login-export.d.ts +56 -0
- package/dist/server/device-login-export.d.ts.map +1 -0
- package/dist/server/device-login-export.js +230 -0
- package/dist/server/device-login-export.js.map +1 -0
- package/dist/server/device-login-export.test.d.ts +2 -0
- package/dist/server/device-login-export.test.d.ts.map +1 -0
- package/dist/server/device-login-export.test.js +245 -0
- package/dist/server/device-login-export.test.js.map +1 -0
- package/dist/server/device-login-parse.d.ts +20 -0
- package/dist/server/device-login-parse.d.ts.map +1 -0
- package/dist/server/device-login-parse.js +155 -0
- package/dist/server/device-login-parse.js.map +1 -0
- package/dist/server/device-login-parse.test.d.ts +2 -0
- package/dist/server/device-login-parse.test.d.ts.map +1 -0
- package/dist/server/device-login-parse.test.js +269 -0
- package/dist/server/device-login-parse.test.js.map +1 -0
- package/dist/server/device-login-runner.d.ts +69 -0
- package/dist/server/device-login-runner.d.ts.map +1 -0
- package/dist/server/device-login-runner.js +109 -0
- package/dist/server/device-login-runner.js.map +1 -0
- package/dist/server/device-login-runner.test.d.ts +2 -0
- package/dist/server/device-login-runner.test.d.ts.map +1 -0
- package/dist/server/device-login-runner.test.js +201 -0
- package/dist/server/device-login-runner.test.js.map +1 -0
- package/dist/server/engine-availability.test.d.ts +2 -0
- package/dist/server/engine-availability.test.d.ts.map +1 -0
- package/dist/server/engine-availability.test.js +41 -0
- package/dist/server/engine-availability.test.js.map +1 -0
- package/dist/server/execute.acp-fallback.test.d.ts +2 -0
- package/dist/server/execute.acp-fallback.test.d.ts.map +1 -0
- package/dist/server/execute.acp-fallback.test.js +123 -0
- package/dist/server/execute.acp-fallback.test.js.map +1 -0
- package/dist/server/execute.auth-precedence.test.d.ts +2 -0
- package/dist/server/execute.auth-precedence.test.d.ts.map +1 -0
- package/dist/server/execute.auth-precedence.test.js +137 -0
- package/dist/server/execute.auth-precedence.test.js.map +1 -0
- package/dist/server/execute.auth.test.d.ts +2 -0
- package/dist/server/execute.auth.test.d.ts.map +1 -0
- package/dist/server/execute.auth.test.js +197 -0
- package/dist/server/execute.auth.test.js.map +1 -0
- package/dist/server/execute.d.ts +40 -0
- package/dist/server/execute.d.ts.map +1 -0
- package/dist/server/execute.js +1290 -0
- package/dist/server/execute.js.map +1 -0
- package/dist/server/execute.remote.test.d.ts +2 -0
- package/dist/server/execute.remote.test.d.ts.map +1 -0
- package/dist/server/execute.remote.test.js +556 -0
- package/dist/server/execute.remote.test.js.map +1 -0
- package/dist/server/execute.stderr-error.test.d.ts +2 -0
- package/dist/server/execute.stderr-error.test.d.ts.map +1 -0
- package/dist/server/execute.stderr-error.test.js +142 -0
- package/dist/server/execute.stderr-error.test.js.map +1 -0
- package/dist/server/execute.test.d.ts +2 -0
- package/dist/server/execute.test.d.ts.map +1 -0
- package/dist/server/execute.test.js +240 -0
- package/dist/server/execute.test.js.map +1 -0
- package/dist/server/index.d.ts +18 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +71 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/output-inactivity-monitor.d.ts +62 -0
- package/dist/server/output-inactivity-monitor.d.ts.map +1 -0
- package/dist/server/output-inactivity-monitor.integration.test.d.ts +2 -0
- package/dist/server/output-inactivity-monitor.integration.test.d.ts.map +1 -0
- package/dist/server/output-inactivity-monitor.integration.test.js +136 -0
- package/dist/server/output-inactivity-monitor.integration.test.js.map +1 -0
- package/dist/server/output-inactivity-monitor.js +112 -0
- package/dist/server/output-inactivity-monitor.js.map +1 -0
- package/dist/server/output-inactivity-monitor.test.d.ts +2 -0
- package/dist/server/output-inactivity-monitor.test.d.ts.map +1 -0
- package/dist/server/output-inactivity-monitor.test.js +284 -0
- package/dist/server/output-inactivity-monitor.test.js.map +1 -0
- package/dist/server/parse.d.ts +51 -0
- package/dist/server/parse.d.ts.map +1 -0
- package/dist/server/parse.js +261 -0
- package/dist/server/parse.js.map +1 -0
- package/dist/server/parse.test.d.ts +2 -0
- package/dist/server/parse.test.d.ts.map +1 -0
- package/dist/server/parse.test.js +186 -0
- package/dist/server/parse.test.js.map +1 -0
- package/dist/server/process-activity-monitor.d.ts +21 -0
- package/dist/server/process-activity-monitor.d.ts.map +1 -0
- package/dist/server/process-activity-monitor.js +103 -0
- package/dist/server/process-activity-monitor.js.map +1 -0
- package/dist/server/process-activity-monitor.test.d.ts +2 -0
- package/dist/server/process-activity-monitor.test.d.ts.map +1 -0
- package/dist/server/process-activity-monitor.test.js +82 -0
- package/dist/server/process-activity-monitor.test.js.map +1 -0
- package/dist/server/quota-spawn-error.test.d.ts +2 -0
- package/dist/server/quota-spawn-error.test.d.ts.map +1 -0
- package/dist/server/quota-spawn-error.test.js +174 -0
- package/dist/server/quota-spawn-error.test.js.map +1 -0
- package/dist/server/quota.d.ts +66 -0
- package/dist/server/quota.d.ts.map +1 -0
- package/dist/server/quota.js +511 -0
- package/dist/server/quota.js.map +1 -0
- package/dist/server/runtime-config.d.ts +11 -0
- package/dist/server/runtime-config.d.ts.map +1 -0
- package/dist/server/runtime-config.js +380 -0
- package/dist/server/runtime-config.js.map +1 -0
- package/dist/server/runtime-config.test.d.ts +2 -0
- package/dist/server/runtime-config.test.d.ts.map +1 -0
- package/dist/server/runtime-config.test.js +367 -0
- package/dist/server/runtime-config.test.js.map +1 -0
- package/dist/server/skills.d.ts +8 -0
- package/dist/server/skills.d.ts.map +1 -0
- package/dist/server/skills.js +26 -0
- package/dist/server/skills.js.map +1 -0
- package/dist/server/test.d.ts +3 -0
- package/dist/server/test.d.ts.map +1 -0
- package/dist/server/test.js +458 -0
- package/dist/server/test.js.map +1 -0
- package/dist/server/test.remote.test.d.ts +2 -0
- package/dist/server/test.remote.test.d.ts.map +1 -0
- package/dist/server/test.remote.test.js +396 -0
- package/dist/server/test.remote.test.js.map +1 -0
- package/dist/ui/build-config.d.ts +5 -0
- package/dist/ui/build-config.d.ts.map +1 -0
- package/dist/ui/build-config.js +229 -0
- package/dist/ui/build-config.js.map +1 -0
- package/dist/ui/build-config.test.d.ts +2 -0
- package/dist/ui/build-config.test.d.ts.map +1 -0
- package/dist/ui/build-config.test.js +272 -0
- package/dist/ui/build-config.test.js.map +1 -0
- package/dist/ui/index.d.ts +4 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +8 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/parse-stdout.d.ts +3 -0
- package/dist/ui/parse-stdout.d.ts.map +1 -0
- package/dist/ui/parse-stdout.js +265 -0
- package/dist/ui/parse-stdout.js.map +1 -0
- package/dist/ui/parse-stdout.test.d.ts +2 -0
- package/dist/ui/parse-stdout.test.d.ts.map +1 -0
- package/dist/ui/parse-stdout.test.js +77 -0
- package/dist/ui/parse-stdout.test.js.map +1 -0
- package/package.json +61 -0
- package/skills/agentmail/SKILL.md +70 -0
- package/skills/paperclip/SKILL.md +712 -0
- package/skills/paperclip/references/api-reference.md +1675 -0
- package/skills/paperclip/references/artifacts.md +158 -0
- package/skills/paperclip/references/cases.md +295 -0
- package/skills/paperclip/references/company-skills.md +266 -0
- package/skills/paperclip/references/issue-workspaces.md +80 -0
- package/skills/paperclip/references/routines.md +231 -0
- package/skills/paperclip/references/workflows.md +141 -0
- package/skills/paperclip/scripts/paperclip-upload-artifact.sh +592 -0
- package/skills/paperclip-board/SKILL.md +619 -0
- package/skills/paperclip-converting-plans-to-tasks/SKILL.md +60 -0
- package/skills/paperclip-create-agent/SKILL.md +179 -0
- package/skills/paperclip-create-agent/references/agent-instruction-templates.md +123 -0
- package/skills/paperclip-create-agent/references/agents/coder.md +64 -0
- package/skills/paperclip-create-agent/references/agents/qa.md +88 -0
- package/skills/paperclip-create-agent/references/agents/securityengineer.md +135 -0
- package/skills/paperclip-create-agent/references/agents/uxdesigner.md +115 -0
- package/skills/paperclip-create-agent/references/api-reference.md +110 -0
- package/skills/paperclip-create-agent/references/baseline-role-guide.md +168 -0
- package/skills/paperclip-create-agent/references/draft-review-checklist.md +95 -0
- package/skills/para-memory-files/SKILL.md +100 -0
- package/skills/para-memory-files/references/schemas.md +35 -0
- package/skills/slack/SKILL.md +65 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { mkdir, open, rename, rm } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { withDirectoryMergeLock } from "@tickernelz/paperclip-pro-adapter-utils/workspace-restore-merge";
|
|
5
|
+
import { isCodexAuthCacheEnabled, readSubscriptionAccountId, writeCodexAuthCacheEntry, } from "./codex-auth-cache.js";
|
|
6
|
+
import { IMPLAUSIBLE_LAST_REFRESH_EXIT, USE_SOURCE_EXIT, decideCodexAuthMerge, } from "./codex-auth-merge-decision.js";
|
|
7
|
+
/**
|
|
8
|
+
* Guards, locks, and atomically installs a strictly-newer sandbox Codex
|
|
9
|
+
* `auth.json` onto the shared host credential at teardown.
|
|
10
|
+
*
|
|
11
|
+
* Sequence, all under `withDirectoryMergeLock` on the host target's directory
|
|
12
|
+
* so a concurrent inbound restore or another copy-back can't interleave:
|
|
13
|
+
* 1. Read the sandbox credential bytes. A genuinely absent sandbox
|
|
14
|
+
* `auth.json` (ENOENT) means there is simply nothing to copy back, so it
|
|
15
|
+
* resolves to `kept-host` (benign no-op, host untouched); every other read
|
|
16
|
+
* error stays fail-loud.
|
|
17
|
+
* 2. Stage them to a `0600` temp file on the **same filesystem** as the host
|
|
18
|
+
* target (its directory), which doubles as the predicate `source`.
|
|
19
|
+
* 3. Run the Phase-3 decision predicate (`source` = sandbox temp, `destination`
|
|
20
|
+
* = host). Exit 10 → adopt the sandbox copy; exit 20 → keep the host copy.
|
|
21
|
+
* 4. On exit 10, `rename` the staged temp over the host target — an atomic
|
|
22
|
+
* same-directory swap that preserves mode `0600`. On exit 20, discard it.
|
|
23
|
+
* The staged temp is always removed (rename consumes it on the copy path; the
|
|
24
|
+
* finally cleans it up otherwise), so a failure never leaves a partial file.
|
|
25
|
+
* Never logs token bytes — only the decision outcome.
|
|
26
|
+
*/
|
|
27
|
+
export async function copyBackCodexAuth(input) {
|
|
28
|
+
const { readSandboxAuth, hostAuthPath, log, resolveCacheEntryPath, env } = input;
|
|
29
|
+
// Read first (outside the lock) — a read never mutates the host, so there is
|
|
30
|
+
// nothing to serialize yet. A genuinely absent sandbox `auth.json` (ENOENT —
|
|
31
|
+
// e.g. Codex removed it mid-run, or a non-provisioned edge) is a "nothing to
|
|
32
|
+
// copy back" no-op, not a teardown failure: return `kept-host` and log the
|
|
33
|
+
// benign outcome. Every other read error stays fail-loud so a real read fault
|
|
34
|
+
// is never silently mistaken for "nothing to copy back".
|
|
35
|
+
let sandboxAuthBytes;
|
|
36
|
+
try {
|
|
37
|
+
sandboxAuthBytes = await readSandboxAuth();
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error?.code === "ENOENT") {
|
|
41
|
+
await log("[paperclip] Codex auth copy-back: no sandbox credential to copy back (absent auth.json); host credential kept.");
|
|
42
|
+
return "kept-host";
|
|
43
|
+
}
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
const hostDir = path.dirname(hostAuthPath);
|
|
47
|
+
await mkdir(hostDir, { recursive: true });
|
|
48
|
+
const hostOutcome = await withDirectoryMergeLock(hostDir, async () => {
|
|
49
|
+
// Stage on the same filesystem as the host target so both the predicate read
|
|
50
|
+
// and the final rename stay device-local (rename across devices is not
|
|
51
|
+
// atomic and would fail with EXDEV).
|
|
52
|
+
const stagedTempPath = path.join(hostDir, `.auth.json.copyback-${process.pid}-${randomUUID()}.tmp`);
|
|
53
|
+
// `wx` + explicit mode create the temp private (0600) and fail if it somehow
|
|
54
|
+
// already exists, so we never write through a pre-existing symlink.
|
|
55
|
+
const handle = await open(stagedTempPath, "wx", 0o600);
|
|
56
|
+
try {
|
|
57
|
+
await handle.writeFile(sandboxAuthBytes);
|
|
58
|
+
await handle.close();
|
|
59
|
+
const decision = await decideCodexAuthMerge(stagedTempPath, hostAuthPath, {
|
|
60
|
+
errorLabel: "codex auth copy-back",
|
|
61
|
+
});
|
|
62
|
+
if (decision === USE_SOURCE_EXIT) {
|
|
63
|
+
// Atomic same-directory swap; rename preserves the temp's 0600 mode.
|
|
64
|
+
await rename(stagedTempPath, hostAuthPath);
|
|
65
|
+
await log("[paperclip] Codex auth copy-back: sandbox credential is strictly newer for the same subscription identity; installed to the host at mode 0600.");
|
|
66
|
+
return "copied";
|
|
67
|
+
}
|
|
68
|
+
// Make a clock-bound rejection visible. A host with a wrong clock would
|
|
69
|
+
// otherwise discard an honest refresh with no signal. Log only the exit
|
|
70
|
+
// code and this caller's own label — never a timestamp read from either
|
|
71
|
+
// file, and never credential bytes.
|
|
72
|
+
if (decision === IMPLAUSIBLE_LAST_REFRESH_EXIT) {
|
|
73
|
+
await log(`[paperclip] Codex auth copy-back: WARNING host credential kept (decision exit ${IMPLAUSIBLE_LAST_REFRESH_EXIT}, codex auth copy-back) — the sandbox copy's last_refresh sat further ahead of the host clock than the plausible skew allowance. Check the host clock if this is unexpected.`);
|
|
74
|
+
return "kept-host";
|
|
75
|
+
}
|
|
76
|
+
await log("[paperclip] Codex auth copy-back: host credential kept (sandbox copy is not a strictly-newer same-identity subscription credential).");
|
|
77
|
+
return "kept-host";
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
// Close is idempotent-safe to skip after an explicit close; the temp is the
|
|
81
|
+
// thing that must never linger. On the copy path rename already consumed it
|
|
82
|
+
// (force makes the removal a no-op); on every other path this deletes the
|
|
83
|
+
// staged credential bytes.
|
|
84
|
+
await handle.close().catch(() => undefined);
|
|
85
|
+
await rm(stagedTempPath, { force: true }).catch(() => undefined);
|
|
86
|
+
}
|
|
87
|
+
}, env);
|
|
88
|
+
// Additive cache write. Independent of the host default overwrite above: it
|
|
89
|
+
// runs on its own directory lock, keys the slot by the real sandbox
|
|
90
|
+
// `account_id`, and can write a slot for a different identity than the host
|
|
91
|
+
// holds. It never touches the host default store. The off-switch (default on)
|
|
92
|
+
// makes this a no-op when disabled. Only a usable subscription credential has
|
|
93
|
+
// an identity to key; an api-key or unusable sandbox credential is skipped.
|
|
94
|
+
//
|
|
95
|
+
// The cache write is best-effort. The host copy-back above already finished
|
|
96
|
+
// and set `hostOutcome`, so a failure of this additive write must not replace
|
|
97
|
+
// that successful result. Catch the error, log it, and return `hostOutcome`.
|
|
98
|
+
// The cache stays a hint: the next teardown re-attempts the write.
|
|
99
|
+
if (resolveCacheEntryPath && isCodexAuthCacheEnabled(env)) {
|
|
100
|
+
try {
|
|
101
|
+
const sandboxAccountId = readSubscriptionAccountId(sandboxAuthBytes);
|
|
102
|
+
if (sandboxAccountId) {
|
|
103
|
+
const cacheEntryPath = await resolveCacheEntryPath(sandboxAccountId);
|
|
104
|
+
await writeCodexAuthCacheEntry({ sandboxAuthBytes, cacheEntryPath, log, env });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
// Log only the errno code, never the error message. The message embeds the
|
|
109
|
+
// cache slot path, and the slot path embeds the raw `account_id`; the code
|
|
110
|
+
// (for example EACCES or ENOSPC) makes the failure diagnosable without a
|
|
111
|
+
// leak. Token bytes never reach the log.
|
|
112
|
+
const code = error?.code ?? "unknown";
|
|
113
|
+
// The host copy-back above already finished and set `hostOutcome`. This
|
|
114
|
+
// diagnostic log is the last step, so a rejecting logger must not throw
|
|
115
|
+
// and turn that successful result into a failed copy-back. Guard the log:
|
|
116
|
+
// a rejection here is swallowed, and the function still returns
|
|
117
|
+
// `hostOutcome` below.
|
|
118
|
+
await Promise.resolve(log(`[paperclip] Codex auth cache: additive cache write failed (${code}); host copy-back result kept.`)).catch(() => undefined);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return hostOutcome;
|
|
122
|
+
}
|
|
123
|
+
//# sourceMappingURL=codex-auth-copyback.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"codex-auth-copyback.js","sourceRoot":"","sources":["../../src/server/codex-auth-copyback.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,IAAI,MAAM,WAAW,CAAC;AAC7B,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,sBAAsB,EAAE,MAAM,iEAAiE,CAAC;AACzG,OAAO,EACL,uBAAuB,EACvB,yBAAyB,EACzB,wBAAwB,GACzB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EACL,6BAA6B,EAC7B,eAAe,EACf,oBAAoB,GACrB,MAAM,gCAAgC,CAAC;AA6CxC;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CAAC,KAA6B;IACnE,MAAM,EAAE,eAAe,EAAE,YAAY,EAAE,GAAG,EAAE,qBAAqB,EAAE,GAAG,EAAE,GAAG,KAAK,CAAC;IAEjF,6EAA6E;IAC7E,6EAA6E;IAC7E,6EAA6E;IAC7E,2EAA2E;IAC3E,8EAA8E;IAC9E,yDAAyD;IACzD,IAAI,gBAAwB,CAAC;IAC7B,IAAI,CAAC;QACH,gBAAgB,GAAG,MAAM,eAAe,EAAE,CAAC;IAC7C,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IAAK,KAAsC,EAAE,IAAI,KAAK,QAAQ,EAAE,CAAC;YAC/D,MAAM,GAAG,CACP,gHAAgH,CACjH,CAAC;YACF,OAAO,WAAW,CAAC;QACrB,CAAC;QACD,MAAM,KAAK,CAAC;IACd,CAAC;IAED,MAAM,OAAO,GAAG,IAAI,CAAC,OAAO,CAAC,YAAY,CAAC,CAAC;IAC3C,MAAM,KAAK,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC1C,MAAM,WAAW,GAAG,MAAM,sBAAsB,CAC9C,OAAO,EACP,KAAK,IAAI,EAAE;QACT,6EAA6E;QAC7E,uEAAuE;QACvE,qCAAqC;QACrC,MAAM,cAAc,GAAG,IAAI,CAAC,IAAI,CAAC,OAAO,EAAE,uBAAuB,OAAO,CAAC,GAAG,IAAI,UAAU,EAAE,MAAM,CAAC,CAAC;QACpG,6EAA6E;QAC7E,oEAAoE;QACpE,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,cAAc,EAAE,IAAI,EAAE,KAAK,CAAC,CAAC;QACvD,IAAI,CAAC;YACH,MAAM,MAAM,CAAC,SAAS,CAAC,gBAAgB,CAAC,CAAC;YACzC,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC;YAErB,MAAM,QAAQ,GAAG,MAAM,oBAAoB,CAAC,cAAc,EAAE,YAAY,EAAE;gBACxE,UAAU,EAAE,sBAAsB;aACnC,CAAC,CAAC;YACH,IAAI,QAAQ,KAAK,eAAe,EAAE,CAAC;gBACjC,qEAAqE;gBACrE,MAAM,MAAM,CAAC,cAAc,EAAE,YAAY,CAAC,CAAC;gBAC3C,MAAM,GAAG,CACP,gJAAgJ,CACjJ,CAAC;gBACF,OAAO,QAAQ,CAAC;YAClB,CAAC;YAED,wEAAwE;YACxE,wEAAwE;YACxE,wEAAwE;YACxE,oCAAoC;YACpC,IAAI,QAAQ,KAAK,6BAA6B,EAAE,CAAC;gBAC/C,MAAM,GAAG,CACP,iFAAiF,6BAA6B,8KAA8K,CAC7R,CAAC;gBACF,OAAO,WAAW,CAAC;YACrB,CAAC;YAED,MAAM,GAAG,CACP,sIAAsI,CACvI,CAAC;YACF,OAAO,WAAW,CAAC;QACrB,CAAC;gBAAS,CAAC;YACT,4EAA4E;YAC5E,4EAA4E;YAC5E,0EAA0E;YAC1E,2BAA2B;YAC3B,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;YAC5C,MAAM,EAAE,CAAC,cAAc,EAAE,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;QACnE,CAAC;IACH,CAAC,EACD,GAAG,CACJ,CAAC;IAEF,4EAA4E;IAC5E,oEAAoE;IACpE,4EAA4E;IAC5E,8EAA8E;IAC9E,8EAA8E;IAC9E,4EAA4E;IAC5E,EAAE;IACF,4EAA4E;IAC5E,8EAA8E;IAC9E,6EAA6E;IAC7E,mEAAmE;IACnE,IAAI,qBAAqB,IAAI,uBAAuB,CAAC,GAAG,CAAC,EAAE,CAAC;QAC1D,IAAI,CAAC;YACH,MAAM,gBAAgB,GAAG,yBAAyB,CAAC,gBAAgB,CAAC,CAAC;YACrE,IAAI,gBAAgB,EAAE,CAAC;gBACrB,MAAM,cAAc,GAAG,MAAM,qBAAqB,CAAC,gBAAgB,CAAC,CAAC;gBACrE,MAAM,wBAAwB,CAAC,EAAE,gBAAgB,EAAE,cAAc,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC,CAAC;YACjF,CAAC;QACH,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,2EAA2E;YAC3E,2EAA2E;YAC3E,yEAAyE;YACzE,yCAAyC;YACzC,MAAM,IAAI,GAAI,KAAsC,EAAE,IAAI,IAAI,SAAS,CAAC;YACxE,wEAAwE;YACxE,wEAAwE;YACxE,0EAA0E;YAC1E,gEAAgE;YAChE,uBAAuB;YACvB,MAAM,OAAO,CAAC,OAAO,CACnB,GAAG,CACD,8DAA8D,IAAI,gCAAgC,CACnG,CACF,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAC;QAC3B,CAAC;IACH,CAAC;IAED,OAAO,WAAW,CAAC;AACrB,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"codex-auth-copyback.test.d.ts","sourceRoot":"","sources":["../../src/server/codex-auth-copyback.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
import { chmod, lstat, mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { copyBackCodexAuth } from "./codex-auth-copyback.js";
|
|
6
|
+
import { ensureCodexAuthCacheEntryDir, resolveCodexAuthCacheDir, resolveCodexAuthCacheEntryPath, } from "./codex-auth-cache.js";
|
|
7
|
+
import { resolveSharedCodexHomeDir } from "./codex-home.js";
|
|
8
|
+
// The copy-back module reuses the exact same direction-agnostic decision
|
|
9
|
+
// predicate (`codex-auth-merge-decision.cjs`) that the inbound extract path
|
|
10
|
+
// runs, only with the arguments flipped: for an outbound copy-back the sandbox
|
|
11
|
+
// copy is the `source` and the host copy is the `destination`, so exit 10
|
|
12
|
+
// (use source) means "install the sandbox credential onto the host" and exit 20
|
|
13
|
+
// (keep destination) means "leave the host credential untouched". This suite
|
|
14
|
+
// drives the REAL `.cjs` through the module (no stub predicate) against a real
|
|
15
|
+
// host tmp filesystem, injecting only the sandbox read.
|
|
16
|
+
describe("copyBackCodexAuth", () => {
|
|
17
|
+
const cleanupDirs = [];
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
while (cleanupDirs.length > 0) {
|
|
20
|
+
const dir = cleanupDirs.pop();
|
|
21
|
+
if (!dir)
|
|
22
|
+
continue;
|
|
23
|
+
// Re-open perms in case a test tightened them, so cleanup always succeeds.
|
|
24
|
+
await chmod(dir, 0o700).catch(() => undefined);
|
|
25
|
+
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
function subscriptionAuth(input) {
|
|
29
|
+
return JSON.stringify({
|
|
30
|
+
tokens: {
|
|
31
|
+
id_token: `id-token-${input.marker}`,
|
|
32
|
+
access_token: `access-token-${input.marker}`,
|
|
33
|
+
refresh_token: `refresh-token-${input.marker}`,
|
|
34
|
+
account_id: input.accountId,
|
|
35
|
+
},
|
|
36
|
+
...(input.lastRefresh ? { last_refresh: input.lastRefresh } : {}),
|
|
37
|
+
}, null, 2);
|
|
38
|
+
}
|
|
39
|
+
function apiKeyAuth(marker) {
|
|
40
|
+
return JSON.stringify({ OPENAI_API_KEY: `sk-${marker}` }, null, 2);
|
|
41
|
+
}
|
|
42
|
+
async function makeHostDir() {
|
|
43
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-copyback-"));
|
|
44
|
+
cleanupDirs.push(dir);
|
|
45
|
+
return dir;
|
|
46
|
+
}
|
|
47
|
+
const NEWER = "2026-07-09T02:00:00Z";
|
|
48
|
+
const OLDER = "2026-07-09T01:00:00Z";
|
|
49
|
+
async function runCopyBack(input) {
|
|
50
|
+
const hostDir = input.hostDir ?? (await makeHostDir());
|
|
51
|
+
const hostAuthPath = path.join(hostDir, "auth.json");
|
|
52
|
+
await writeFile(hostAuthPath, input.hostAuth, { mode: 0o600 });
|
|
53
|
+
const readSandboxAuth = typeof input.sandboxAuth === "function"
|
|
54
|
+
? input.sandboxAuth
|
|
55
|
+
: async () => Buffer.from(input.sandboxAuth, "utf8");
|
|
56
|
+
const logs = [];
|
|
57
|
+
const outcome = await copyBackCodexAuth({
|
|
58
|
+
readSandboxAuth,
|
|
59
|
+
hostAuthPath,
|
|
60
|
+
log: (line) => {
|
|
61
|
+
logs.push(line);
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
const finalHostAuth = await readFile(hostAuthPath, "utf8");
|
|
65
|
+
const finalHostMode = (await lstat(hostAuthPath)).mode & 0o777;
|
|
66
|
+
const leftoverEntries = (await readdir(hostDir)).filter((name) => name !== "auth.json");
|
|
67
|
+
return { outcome, finalHostAuth, finalHostMode, logs, leftoverEntries };
|
|
68
|
+
}
|
|
69
|
+
it("installs a strictly-newer same-account sandbox auth onto the host at 0600", async () => {
|
|
70
|
+
const sandboxAuth = subscriptionAuth({
|
|
71
|
+
accountId: "acct-same",
|
|
72
|
+
lastRefresh: NEWER,
|
|
73
|
+
marker: "sandbox-newer-SENTINEL",
|
|
74
|
+
});
|
|
75
|
+
const hostAuth = subscriptionAuth({
|
|
76
|
+
accountId: "acct-same",
|
|
77
|
+
lastRefresh: OLDER,
|
|
78
|
+
marker: "host-older-SENTINEL",
|
|
79
|
+
});
|
|
80
|
+
const result = await runCopyBack({ sandboxAuth, hostAuth });
|
|
81
|
+
expect(result.outcome).toBe("copied");
|
|
82
|
+
expect(result.finalHostAuth).toBe(sandboxAuth);
|
|
83
|
+
expect(result.finalHostMode).toBe(0o600);
|
|
84
|
+
// Temp staging file must be gone once the swap completes.
|
|
85
|
+
expect(result.leftoverEntries).toEqual([]);
|
|
86
|
+
// Never leak token bytes in log output.
|
|
87
|
+
expect(result.logs.join("\n")).not.toContain("SENTINEL");
|
|
88
|
+
});
|
|
89
|
+
it("keeps the host auth when the sandbox copy is not strictly newer", async () => {
|
|
90
|
+
const cases = [
|
|
91
|
+
{
|
|
92
|
+
name: "tie",
|
|
93
|
+
sandboxAuth: subscriptionAuth({ accountId: "acct-same", lastRefresh: NEWER, marker: "sandbox-tie" }),
|
|
94
|
+
hostAuth: subscriptionAuth({ accountId: "acct-same", lastRefresh: NEWER, marker: "host-tie" }),
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: "sandbox older",
|
|
98
|
+
sandboxAuth: subscriptionAuth({ accountId: "acct-same", lastRefresh: OLDER, marker: "sandbox-older" }),
|
|
99
|
+
hostAuth: subscriptionAuth({ accountId: "acct-same", lastRefresh: NEWER, marker: "host-newer" }),
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: "missing sandbox last_refresh",
|
|
103
|
+
sandboxAuth: subscriptionAuth({ accountId: "acct-same", marker: "sandbox-no-refresh" }),
|
|
104
|
+
hostAuth: subscriptionAuth({ accountId: "acct-same", lastRefresh: NEWER, marker: "host-refresh" }),
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: "unparseable sandbox last_refresh",
|
|
108
|
+
sandboxAuth: subscriptionAuth({ accountId: "acct-same", lastRefresh: "not-a-date", marker: "sandbox-bad" }),
|
|
109
|
+
hostAuth: subscriptionAuth({ accountId: "acct-same", lastRefresh: NEWER, marker: "host-refresh" }),
|
|
110
|
+
},
|
|
111
|
+
];
|
|
112
|
+
for (const entry of cases) {
|
|
113
|
+
const result = await runCopyBack({ sandboxAuth: entry.sandboxAuth, hostAuth: entry.hostAuth });
|
|
114
|
+
expect(result.outcome, entry.name).toBe("kept-host");
|
|
115
|
+
expect(result.finalHostAuth, entry.name).toBe(entry.hostAuth);
|
|
116
|
+
expect(result.finalHostMode, entry.name).toBe(0o600);
|
|
117
|
+
expect(result.leftoverEntries, entry.name).toEqual([]);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
it("keeps the host auth on identity mismatch, kind mismatch, apikey, and unusable sandbox auth", async () => {
|
|
121
|
+
const hostAuth = subscriptionAuth({ accountId: "acct-host", lastRefresh: OLDER, marker: "host-keep" });
|
|
122
|
+
const cases = [
|
|
123
|
+
{
|
|
124
|
+
name: "identity mismatch (sandbox newer, different account)",
|
|
125
|
+
sandboxAuth: subscriptionAuth({ accountId: "acct-other", lastRefresh: NEWER, marker: "sandbox-other" }),
|
|
126
|
+
hostAuth,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: "kind mismatch (sandbox subscription, host apikey)",
|
|
130
|
+
sandboxAuth: subscriptionAuth({ accountId: "acct-host", lastRefresh: NEWER, marker: "sandbox-sub" }),
|
|
131
|
+
hostAuth: apiKeyAuth("host-api-key"),
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: "sandbox apikey",
|
|
135
|
+
sandboxAuth: apiKeyAuth("sandbox-api-key"),
|
|
136
|
+
hostAuth,
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: "sandbox unusable JSON",
|
|
140
|
+
sandboxAuth: "{not valid json",
|
|
141
|
+
hostAuth,
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: "sandbox account id missing",
|
|
145
|
+
sandboxAuth: JSON.stringify({
|
|
146
|
+
tokens: { id_token: "id", access_token: "acc", refresh_token: "ref" },
|
|
147
|
+
last_refresh: NEWER,
|
|
148
|
+
}),
|
|
149
|
+
hostAuth,
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: "host unusable JSON (never create host auth from sandbox)",
|
|
153
|
+
sandboxAuth: subscriptionAuth({ accountId: "acct-host", lastRefresh: NEWER, marker: "sandbox-valid" }),
|
|
154
|
+
hostAuth: "{not valid json",
|
|
155
|
+
},
|
|
156
|
+
];
|
|
157
|
+
for (const entry of cases) {
|
|
158
|
+
const result = await runCopyBack({ sandboxAuth: entry.sandboxAuth, hostAuth: entry.hostAuth });
|
|
159
|
+
expect(result.outcome, entry.name).toBe("kept-host");
|
|
160
|
+
expect(result.finalHostAuth, entry.name).toBe(entry.hostAuth);
|
|
161
|
+
expect(result.finalHostMode, entry.name).toBe(0o600);
|
|
162
|
+
expect(result.leftoverEntries, entry.name).toEqual([]);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
it("creates a missing shared Codex home before staging copy-back", async () => {
|
|
166
|
+
const rootDir = await makeHostDir();
|
|
167
|
+
const hostDir = path.join(rootDir, "missing-codex-home");
|
|
168
|
+
const hostAuthPath = path.join(hostDir, "auth.json");
|
|
169
|
+
const logs = [];
|
|
170
|
+
const outcome = await copyBackCodexAuth({
|
|
171
|
+
readSandboxAuth: async () => Buffer.from(apiKeyAuth("sandbox-only"), "utf8"),
|
|
172
|
+
hostAuthPath,
|
|
173
|
+
log: (line) => {
|
|
174
|
+
logs.push(line);
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
expect(outcome).toBe("kept-host");
|
|
178
|
+
expect(await readdir(hostDir)).toEqual([]);
|
|
179
|
+
expect(logs.join("\n")).not.toContain("sandbox-only");
|
|
180
|
+
});
|
|
181
|
+
it("preserves the host file atomically when the install cannot be staged (no partial write, no leaked temp)", async () => {
|
|
182
|
+
// Make the host directory read-only so staging the same-filesystem temp fails
|
|
183
|
+
// with EACCES. The host credential must be left byte-for-byte intact and no
|
|
184
|
+
// partial/temp file may remain — the outbound write is all-or-nothing.
|
|
185
|
+
const hostDir = await makeHostDir();
|
|
186
|
+
const hostAuth = subscriptionAuth({ accountId: "acct-same", lastRefresh: OLDER, marker: "host-intact" });
|
|
187
|
+
const hostAuthPath = path.join(hostDir, "auth.json");
|
|
188
|
+
await writeFile(hostAuthPath, hostAuth, { mode: 0o600 });
|
|
189
|
+
const before = await stat(hostAuthPath);
|
|
190
|
+
await chmod(hostDir, 0o500); // r-x: readable/traversable, not writable
|
|
191
|
+
try {
|
|
192
|
+
const sandboxAuth = subscriptionAuth({ accountId: "acct-same", lastRefresh: NEWER, marker: "sandbox-newer" });
|
|
193
|
+
await expect(copyBackCodexAuth({
|
|
194
|
+
readSandboxAuth: async () => Buffer.from(sandboxAuth, "utf8"),
|
|
195
|
+
hostAuthPath,
|
|
196
|
+
log: () => { },
|
|
197
|
+
})).rejects.toThrow();
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
await chmod(hostDir, 0o700);
|
|
201
|
+
}
|
|
202
|
+
const after = await stat(hostAuthPath);
|
|
203
|
+
expect(await readFile(hostAuthPath, "utf8")).toBe(hostAuth);
|
|
204
|
+
expect(after.mode & 0o777).toBe(0o600);
|
|
205
|
+
expect(after.mtimeMs).toBe(before.mtimeMs);
|
|
206
|
+
expect((await readdir(hostDir)).filter((name) => name !== "auth.json")).toEqual([]);
|
|
207
|
+
});
|
|
208
|
+
it("treats an absent sandbox auth.json (ENOENT) as a keep-host no-op, host untouched, no throw", async () => {
|
|
209
|
+
const hostAuth = subscriptionAuth({ accountId: "acct-same", lastRefresh: OLDER, marker: "host-intact" });
|
|
210
|
+
// The real production `readSandboxAuth` is `readFile("${assetDir}/auth.json")`;
|
|
211
|
+
// a genuinely absent file surfaces a node ENOENT error. That must be a benign
|
|
212
|
+
// "nothing to copy back" outcome, not a fail-loud teardown error.
|
|
213
|
+
const enoent = Object.assign(new Error("ENOENT: no such file or directory, open 'auth.json'"), {
|
|
214
|
+
code: "ENOENT",
|
|
215
|
+
});
|
|
216
|
+
const result = await runCopyBack({
|
|
217
|
+
sandboxAuth: async () => {
|
|
218
|
+
throw enoent;
|
|
219
|
+
},
|
|
220
|
+
hostAuth,
|
|
221
|
+
});
|
|
222
|
+
expect(result.outcome).toBe("kept-host");
|
|
223
|
+
expect(result.finalHostAuth).toBe(hostAuth);
|
|
224
|
+
expect(result.finalHostMode).toBe(0o600);
|
|
225
|
+
// No staging temp is ever created on the ENOENT path.
|
|
226
|
+
expect(result.leftoverEntries).toEqual([]);
|
|
227
|
+
expect(result.logs.join("\n")).toContain("no sandbox credential to copy back");
|
|
228
|
+
});
|
|
229
|
+
it("fails loud when the sandbox read errors and leaves the host untouched", async () => {
|
|
230
|
+
const hostDir = await makeHostDir();
|
|
231
|
+
const hostAuth = subscriptionAuth({ accountId: "acct-same", lastRefresh: OLDER, marker: "host-intact" });
|
|
232
|
+
const hostAuthPath = path.join(hostDir, "auth.json");
|
|
233
|
+
await writeFile(hostAuthPath, hostAuth, { mode: 0o600 });
|
|
234
|
+
await expect(copyBackCodexAuth({
|
|
235
|
+
readSandboxAuth: async () => {
|
|
236
|
+
throw new Error("sandbox read boom");
|
|
237
|
+
},
|
|
238
|
+
hostAuthPath,
|
|
239
|
+
log: () => { },
|
|
240
|
+
})).rejects.toThrow(/sandbox read boom/);
|
|
241
|
+
expect(await readFile(hostAuthPath, "utf8")).toBe(hostAuth);
|
|
242
|
+
expect((await readdir(hostDir)).filter((name) => name !== "auth.json")).toEqual([]);
|
|
243
|
+
});
|
|
244
|
+
it("does not emit token substrings on any code path", async () => {
|
|
245
|
+
const sandboxAuth = subscriptionAuth({
|
|
246
|
+
accountId: "acct-same",
|
|
247
|
+
lastRefresh: NEWER,
|
|
248
|
+
marker: "TOKEN-SENTINEL",
|
|
249
|
+
});
|
|
250
|
+
const hostAuth = subscriptionAuth({
|
|
251
|
+
accountId: "acct-same",
|
|
252
|
+
lastRefresh: OLDER,
|
|
253
|
+
marker: "HOST-SENTINEL",
|
|
254
|
+
});
|
|
255
|
+
const result = await runCopyBack({ sandboxAuth, hostAuth });
|
|
256
|
+
expect(result.outcome).toBe("copied");
|
|
257
|
+
const combined = result.logs.join("\n");
|
|
258
|
+
expect(combined).not.toContain("SENTINEL");
|
|
259
|
+
expect(combined).not.toContain("id-token");
|
|
260
|
+
expect(combined).not.toContain("access-token");
|
|
261
|
+
expect(combined).not.toContain("refresh-token");
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
// The teardown copy-back also writes the fresher, usable subscription credential
|
|
265
|
+
// into its per-identity cache slot, keyed by the real `account_id`. The cache is
|
|
266
|
+
// a SEPARATE store; the host default overwrite and the cache slot are asserted
|
|
267
|
+
// independently. This suite drives the real `.cjs` predicate (default mode for
|
|
268
|
+
// the host store, seed mode for the cache slot) against a real host tmp
|
|
269
|
+
// filesystem.
|
|
270
|
+
describe("copyBackCodexAuth identity-keyed cache write", () => {
|
|
271
|
+
const cleanupDirs = [];
|
|
272
|
+
const COMPANY_ID = "company-a";
|
|
273
|
+
afterEach(async () => {
|
|
274
|
+
while (cleanupDirs.length > 0) {
|
|
275
|
+
const dir = cleanupDirs.pop();
|
|
276
|
+
if (!dir)
|
|
277
|
+
continue;
|
|
278
|
+
await chmod(dir, 0o700).catch(() => undefined);
|
|
279
|
+
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
function subscriptionAuth(input) {
|
|
283
|
+
return JSON.stringify({
|
|
284
|
+
tokens: {
|
|
285
|
+
id_token: `id-token-${input.marker}`,
|
|
286
|
+
access_token: `access-token-${input.marker}`,
|
|
287
|
+
refresh_token: `refresh-token-${input.marker}`,
|
|
288
|
+
account_id: input.accountId,
|
|
289
|
+
},
|
|
290
|
+
...(input.lastRefresh ? { last_refresh: input.lastRefresh } : {}),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
function apiKeyAuth(marker) {
|
|
294
|
+
return JSON.stringify({ OPENAI_API_KEY: `sk-${marker}` });
|
|
295
|
+
}
|
|
296
|
+
const NEWER = "2026-07-09T02:00:00Z";
|
|
297
|
+
const OLDER = "2026-07-09T01:00:00Z";
|
|
298
|
+
async function makeEnv(extra = {}) {
|
|
299
|
+
const home = await mkdtemp(path.join(os.tmpdir(), "paperclip-codex-copyback-cache-"));
|
|
300
|
+
cleanupDirs.push(home);
|
|
301
|
+
const env = {
|
|
302
|
+
PAPERCLIP_HOME: home,
|
|
303
|
+
PAPERCLIP_INSTANCE_ID: "default",
|
|
304
|
+
CODEX_HOME: path.join(home, "shared-codex"),
|
|
305
|
+
...extra,
|
|
306
|
+
};
|
|
307
|
+
const sharedHome = resolveSharedCodexHomeDir(env);
|
|
308
|
+
await mkdir(sharedHome, { recursive: true });
|
|
309
|
+
return { env, sharedHomeAuthPath: path.join(sharedHome, "auth.json"), sharedHome };
|
|
310
|
+
}
|
|
311
|
+
async function runWithCache(input) {
|
|
312
|
+
if (input.hostAuth !== undefined) {
|
|
313
|
+
await writeFile(input.sharedHomeAuthPath, input.hostAuth, { mode: 0o600 });
|
|
314
|
+
}
|
|
315
|
+
const logs = [];
|
|
316
|
+
const outcome = await copyBackCodexAuth({
|
|
317
|
+
readSandboxAuth: async () => Buffer.from(input.sandboxAuth, "utf8"),
|
|
318
|
+
hostAuthPath: input.sharedHomeAuthPath,
|
|
319
|
+
log: (line) => {
|
|
320
|
+
logs.push(line);
|
|
321
|
+
},
|
|
322
|
+
resolveCacheEntryPath: (accountId) => ensureCodexAuthCacheEntryDir(input.env, accountId, COMPANY_ID),
|
|
323
|
+
env: input.cacheEnabledEnv ?? input.env,
|
|
324
|
+
});
|
|
325
|
+
const finalHostAuth = await readFile(input.sharedHomeAuthPath, "utf8").catch((error) => (error.code === "ENOENT" ? null : Promise.reject(error)));
|
|
326
|
+
return { outcome, finalHostAuth, logs };
|
|
327
|
+
}
|
|
328
|
+
async function readCacheSlot(env, accountId) {
|
|
329
|
+
const entryPath = resolveCodexAuthCacheEntryPath(env, accountId, COMPANY_ID);
|
|
330
|
+
return readFile(entryPath, "utf8").catch((error) => error.code === "ENOENT" ? null : Promise.reject(error));
|
|
331
|
+
}
|
|
332
|
+
it("host absent (matrix row 3): host default store stays empty, cache slot holds the credential keyed by account_id", async () => {
|
|
333
|
+
const { env, sharedHomeAuthPath } = await makeEnv();
|
|
334
|
+
const sandboxAuth = subscriptionAuth({ accountId: "acct-y", lastRefresh: NEWER, marker: "row3" });
|
|
335
|
+
const result = await runWithCache({ sandboxAuth, env, sharedHomeAuthPath });
|
|
336
|
+
expect(result.outcome).toBe("kept-host");
|
|
337
|
+
expect(result.finalHostAuth).toBeNull(); // host never seeded
|
|
338
|
+
expect(await readCacheSlot(env, "acct-y")).toBe(sandboxAuth);
|
|
339
|
+
});
|
|
340
|
+
it("host present, same identity, sandbox newer (matrix row 1a): host default overwritten AND cache slot updated", async () => {
|
|
341
|
+
const { env, sharedHomeAuthPath } = await makeEnv();
|
|
342
|
+
const sandboxAuth = subscriptionAuth({ accountId: "acct-x", lastRefresh: NEWER, marker: "sandbox" });
|
|
343
|
+
const hostAuth = subscriptionAuth({ accountId: "acct-x", lastRefresh: OLDER, marker: "host" });
|
|
344
|
+
const result = await runWithCache({ sandboxAuth, hostAuth, env, sharedHomeAuthPath });
|
|
345
|
+
expect(result.outcome).toBe("copied");
|
|
346
|
+
expect(result.finalHostAuth).toBe(sandboxAuth);
|
|
347
|
+
expect(await readCacheSlot(env, "acct-x")).toBe(sandboxAuth);
|
|
348
|
+
});
|
|
349
|
+
it("host present, different identity (matrix row 1b): host default untouched, cache slot holds the new identity only", async () => {
|
|
350
|
+
const { env, sharedHomeAuthPath } = await makeEnv();
|
|
351
|
+
const sandboxAuth = subscriptionAuth({ accountId: "acct-y", lastRefresh: NEWER, marker: "sandbox" });
|
|
352
|
+
const hostAuth = subscriptionAuth({ accountId: "acct-x", lastRefresh: OLDER, marker: "host" });
|
|
353
|
+
const result = await runWithCache({ sandboxAuth, hostAuth, env, sharedHomeAuthPath });
|
|
354
|
+
expect(result.outcome).toBe("kept-host");
|
|
355
|
+
expect(result.finalHostAuth).toBe(hostAuth); // host keeps its own identity X
|
|
356
|
+
expect(await readCacheSlot(env, "acct-y")).toBe(sandboxAuth); // slot Y written
|
|
357
|
+
expect(await readCacheSlot(env, "acct-x")).toBeNull(); // no slot for host identity
|
|
358
|
+
});
|
|
359
|
+
it("sandbox apikey or unusable: neither the host default nor the cache changes", async () => {
|
|
360
|
+
for (const sandboxAuth of [apiKeyAuth("sbx"), "{not valid json"]) {
|
|
361
|
+
const { env, sharedHomeAuthPath } = await makeEnv();
|
|
362
|
+
const hostAuth = subscriptionAuth({ accountId: "acct-x", lastRefresh: OLDER, marker: "host" });
|
|
363
|
+
const result = await runWithCache({ sandboxAuth, hostAuth, env, sharedHomeAuthPath });
|
|
364
|
+
expect(result.outcome).toBe("kept-host");
|
|
365
|
+
expect(result.finalHostAuth).toBe(hostAuth);
|
|
366
|
+
const cacheDir = resolveCodexAuthCacheDir(env, COMPANY_ID);
|
|
367
|
+
// No cache slot directory is created at all for a credential with no identity.
|
|
368
|
+
await expect(readdir(cacheDir)).rejects.toThrow();
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
it("off-switch off (matrix row 1a inputs): teardown cache write is a no-op and the host default overwrite still runs", async () => {
|
|
372
|
+
const { env, sharedHomeAuthPath } = await makeEnv();
|
|
373
|
+
const sandboxAuth = subscriptionAuth({ accountId: "acct-x", lastRefresh: NEWER, marker: "sandbox" });
|
|
374
|
+
const hostAuth = subscriptionAuth({ accountId: "acct-x", lastRefresh: OLDER, marker: "host" });
|
|
375
|
+
const result = await runWithCache({
|
|
376
|
+
sandboxAuth,
|
|
377
|
+
hostAuth,
|
|
378
|
+
env,
|
|
379
|
+
sharedHomeAuthPath,
|
|
380
|
+
cacheEnabledEnv: { ...env, PAPERCLIP_CODEX_AUTH_CACHE: "0" },
|
|
381
|
+
});
|
|
382
|
+
// Host overwrite still runs with the off-switch off.
|
|
383
|
+
expect(result.outcome).toBe("copied");
|
|
384
|
+
expect(result.finalHostAuth).toBe(sandboxAuth);
|
|
385
|
+
// No cache slot is written.
|
|
386
|
+
const cacheDir = resolveCodexAuthCacheDir(env, COMPANY_ID);
|
|
387
|
+
await expect(readdir(cacheDir)).rejects.toThrow();
|
|
388
|
+
});
|
|
389
|
+
it("two concurrent teardown writes for the same identity leave one valid cache slot (no partial file)", async () => {
|
|
390
|
+
const { env, sharedHomeAuthPath } = await makeEnv();
|
|
391
|
+
await writeFile(sharedHomeAuthPath, subscriptionAuth({ accountId: "acct-x", lastRefresh: OLDER, marker: "host" }), { mode: 0o600 });
|
|
392
|
+
const run = (marker) => copyBackCodexAuth({
|
|
393
|
+
readSandboxAuth: async () => Buffer.from(subscriptionAuth({ accountId: "acct-x", lastRefresh: NEWER, marker }), "utf8"),
|
|
394
|
+
hostAuthPath: sharedHomeAuthPath,
|
|
395
|
+
log: () => { },
|
|
396
|
+
resolveCacheEntryPath: (accountId) => ensureCodexAuthCacheEntryDir(env, accountId, COMPANY_ID),
|
|
397
|
+
env,
|
|
398
|
+
});
|
|
399
|
+
await Promise.all([run("a"), run("b")]);
|
|
400
|
+
const entryPath = resolveCodexAuthCacheEntryPath(env, "acct-x", COMPANY_ID);
|
|
401
|
+
const slotDir = path.dirname(entryPath);
|
|
402
|
+
// Exactly one valid slot file, no leftover staging temp.
|
|
403
|
+
expect(await readdir(slotDir)).toEqual(["auth.json"]);
|
|
404
|
+
const finalSlot = await readFile(entryPath, "utf8");
|
|
405
|
+
expect(JSON.parse(finalSlot).tokens.account_id).toBe("acct-x");
|
|
406
|
+
});
|
|
407
|
+
it("the cache write never emits source token bytes or a raw account_id to the log", async () => {
|
|
408
|
+
const { env, sharedHomeAuthPath } = await makeEnv();
|
|
409
|
+
const sandboxAuth = subscriptionAuth({
|
|
410
|
+
accountId: "SECRET-ACCT",
|
|
411
|
+
lastRefresh: NEWER,
|
|
412
|
+
marker: "TOKEN-SENTINEL",
|
|
413
|
+
});
|
|
414
|
+
const result = await runWithCache({ sandboxAuth, env, sharedHomeAuthPath });
|
|
415
|
+
expect(await readCacheSlot(env, "SECRET-ACCT")).toBe(sandboxAuth);
|
|
416
|
+
const combined = result.logs.join("\n");
|
|
417
|
+
expect(combined).not.toContain("SENTINEL");
|
|
418
|
+
expect(combined).not.toContain("SECRET-ACCT");
|
|
419
|
+
expect(combined).not.toContain("id-token");
|
|
420
|
+
});
|
|
421
|
+
it("a read-only cache directory does not fail the copy-back: the successful host result is kept and no partial slot remains", async () => {
|
|
422
|
+
const { env, sharedHomeAuthPath } = await makeEnv();
|
|
423
|
+
const sandboxAuth = subscriptionAuth({ accountId: "acct-x", lastRefresh: NEWER, marker: "sandbox" });
|
|
424
|
+
const hostAuth = subscriptionAuth({ accountId: "acct-x", lastRefresh: OLDER, marker: "host" });
|
|
425
|
+
await writeFile(sharedHomeAuthPath, hostAuth, { mode: 0o600 });
|
|
426
|
+
// Pre-create the entry directory, then make it read-only so the cache-slot
|
|
427
|
+
// temp create fails. The host overwrite runs first and must stay intact. The
|
|
428
|
+
// additive cache write is best-effort, so its failure must not throw.
|
|
429
|
+
const entryPath = await ensureCodexAuthCacheEntryDir(env, "acct-x", COMPANY_ID);
|
|
430
|
+
const slotDir = path.dirname(entryPath);
|
|
431
|
+
const logs = [];
|
|
432
|
+
await chmod(slotDir, 0o500);
|
|
433
|
+
let outcome;
|
|
434
|
+
try {
|
|
435
|
+
outcome = await copyBackCodexAuth({
|
|
436
|
+
readSandboxAuth: async () => Buffer.from(sandboxAuth, "utf8"),
|
|
437
|
+
hostAuthPath: sharedHomeAuthPath,
|
|
438
|
+
log: (line) => {
|
|
439
|
+
logs.push(line);
|
|
440
|
+
},
|
|
441
|
+
resolveCacheEntryPath: (accountId) => ensureCodexAuthCacheEntryDir(env, accountId, COMPANY_ID),
|
|
442
|
+
env,
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
finally {
|
|
446
|
+
await chmod(slotDir, 0o700);
|
|
447
|
+
}
|
|
448
|
+
// The host copy-back succeeded and its result is returned unchanged.
|
|
449
|
+
expect(outcome).toBe("copied");
|
|
450
|
+
// Host default overwrite ran before the cache write and stays applied.
|
|
451
|
+
expect(await readFile(sharedHomeAuthPath, "utf8")).toBe(sandboxAuth);
|
|
452
|
+
// No partial slot file; only the (empty) slot directory remains.
|
|
453
|
+
expect(await readdir(slotDir)).toEqual([]);
|
|
454
|
+
// The failure is visible in the log with only the errno code. The raw
|
|
455
|
+
// account_id (which the failing slot path embeds) never reaches the log.
|
|
456
|
+
const combined = logs.join("\n");
|
|
457
|
+
expect(combined).toContain("additive cache write failed (EACCES)");
|
|
458
|
+
expect(combined).not.toContain("acct-x");
|
|
459
|
+
});
|
|
460
|
+
it("a rejecting cache-failure log does not override the successful host copy-back result", async () => {
|
|
461
|
+
const { env, sharedHomeAuthPath } = await makeEnv();
|
|
462
|
+
const sandboxAuth = subscriptionAuth({ accountId: "acct-x", lastRefresh: NEWER, marker: "sandbox" });
|
|
463
|
+
const hostAuth = subscriptionAuth({ accountId: "acct-x", lastRefresh: OLDER, marker: "host" });
|
|
464
|
+
await writeFile(sharedHomeAuthPath, hostAuth, { mode: 0o600 });
|
|
465
|
+
// Force the additive cache write to fail: pre-create the slot directory,
|
|
466
|
+
// then make it read-only so the slot temp create fails with EACCES.
|
|
467
|
+
const entryPath = await ensureCodexAuthCacheEntryDir(env, "acct-x", COMPANY_ID);
|
|
468
|
+
const slotDir = path.dirname(entryPath);
|
|
469
|
+
await chmod(slotDir, 0o500);
|
|
470
|
+
// The logger rejects for the cache-failure diagnostic line only. The host
|
|
471
|
+
// copy-back already installed the sandbox credential on disk, so this
|
|
472
|
+
// rejection must not propagate and must not override the "copied" result.
|
|
473
|
+
const logs = [];
|
|
474
|
+
let outcome;
|
|
475
|
+
let thrown;
|
|
476
|
+
try {
|
|
477
|
+
outcome = await copyBackCodexAuth({
|
|
478
|
+
readSandboxAuth: async () => Buffer.from(sandboxAuth, "utf8"),
|
|
479
|
+
hostAuthPath: sharedHomeAuthPath,
|
|
480
|
+
log: (line) => {
|
|
481
|
+
logs.push(line);
|
|
482
|
+
if (line.includes("additive cache write failed")) {
|
|
483
|
+
return Promise.reject(new Error("log sink boom"));
|
|
484
|
+
}
|
|
485
|
+
},
|
|
486
|
+
resolveCacheEntryPath: (accountId) => ensureCodexAuthCacheEntryDir(env, accountId, COMPANY_ID),
|
|
487
|
+
env,
|
|
488
|
+
}).catch((error) => {
|
|
489
|
+
thrown = error;
|
|
490
|
+
return undefined;
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
finally {
|
|
494
|
+
await chmod(slotDir, 0o700);
|
|
495
|
+
}
|
|
496
|
+
// The rejecting cache-failure log never surfaces as a thrown error.
|
|
497
|
+
expect(thrown).toBeUndefined();
|
|
498
|
+
// The host copy-back result is kept intact.
|
|
499
|
+
expect(outcome).toBe("copied");
|
|
500
|
+
expect(await readFile(sharedHomeAuthPath, "utf8")).toBe(sandboxAuth);
|
|
501
|
+
// No partial slot file remains after the failed cache write.
|
|
502
|
+
expect(await readdir(slotDir)).toEqual([]);
|
|
503
|
+
// The cache-failure diagnostic was attempted even though the sink rejected.
|
|
504
|
+
expect(logs.some((line) => line.includes("additive cache write failed (EACCES)"))).toBe(true);
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
//# sourceMappingURL=codex-auth-copyback.test.js.map
|