@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.
Files changed (268) hide show
  1. package/LICENSE +22 -0
  2. package/dist/cli/format-event.d.ts +2 -0
  3. package/dist/cli/format-event.d.ts.map +1 -0
  4. package/dist/cli/format-event.js +218 -0
  5. package/dist/cli/format-event.js.map +1 -0
  6. package/dist/cli/index.d.ts +2 -0
  7. package/dist/cli/index.d.ts.map +1 -0
  8. package/dist/cli/index.js +2 -0
  9. package/dist/cli/index.js.map +1 -0
  10. package/dist/cli/quota-probe.d.ts +3 -0
  11. package/dist/cli/quota-probe.d.ts.map +1 -0
  12. package/dist/cli/quota-probe.js +104 -0
  13. package/dist/cli/quota-probe.js.map +1 -0
  14. package/dist/index.d.ts +21 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +155 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/index.test.d.ts +2 -0
  19. package/dist/index.test.d.ts.map +1 -0
  20. package/dist/index.test.js +58 -0
  21. package/dist/index.test.js.map +1 -0
  22. package/dist/server/acp.d.ts +30 -0
  23. package/dist/server/acp.d.ts.map +1 -0
  24. package/dist/server/acp.js +532 -0
  25. package/dist/server/acp.js.map +1 -0
  26. package/dist/server/acp.test.d.ts +2 -0
  27. package/dist/server/acp.test.d.ts.map +1 -0
  28. package/dist/server/acp.test.js +1087 -0
  29. package/dist/server/acp.test.js.map +1 -0
  30. package/dist/server/adapter-auth-promotion.d.ts +102 -0
  31. package/dist/server/adapter-auth-promotion.d.ts.map +1 -0
  32. package/dist/server/adapter-auth-promotion.js +210 -0
  33. package/dist/server/adapter-auth-promotion.js.map +1 -0
  34. package/dist/server/adapter-auth-promotion.test.d.ts +2 -0
  35. package/dist/server/adapter-auth-promotion.test.d.ts.map +1 -0
  36. package/dist/server/adapter-auth-promotion.test.js +658 -0
  37. package/dist/server/adapter-auth-promotion.test.js.map +1 -0
  38. package/dist/server/auth-check.d.ts +8 -0
  39. package/dist/server/auth-check.d.ts.map +1 -0
  40. package/dist/server/auth-check.js +8 -0
  41. package/dist/server/auth-check.js.map +1 -0
  42. package/dist/server/auth-precedence.d.ts +16 -0
  43. package/dist/server/auth-precedence.d.ts.map +1 -0
  44. package/dist/server/auth-precedence.js +20 -0
  45. package/dist/server/auth-precedence.js.map +1 -0
  46. package/dist/server/auth-precedence.test.d.ts +2 -0
  47. package/dist/server/auth-precedence.test.d.ts.map +1 -0
  48. package/dist/server/auth-precedence.test.js +100 -0
  49. package/dist/server/auth-precedence.test.js.map +1 -0
  50. package/dist/server/codex-args.d.ts +13 -0
  51. package/dist/server/codex-args.d.ts.map +1 -0
  52. package/dist/server/codex-args.js +76 -0
  53. package/dist/server/codex-args.js.map +1 -0
  54. package/dist/server/codex-args.test.d.ts +2 -0
  55. package/dist/server/codex-args.test.d.ts.map +1 -0
  56. package/dist/server/codex-args.test.js +243 -0
  57. package/dist/server/codex-args.test.js.map +1 -0
  58. package/dist/server/codex-auth-cache.d.ts +206 -0
  59. package/dist/server/codex-auth-cache.d.ts.map +1 -0
  60. package/dist/server/codex-auth-cache.js +501 -0
  61. package/dist/server/codex-auth-cache.js.map +1 -0
  62. package/dist/server/codex-auth-cache.test.d.ts +2 -0
  63. package/dist/server/codex-auth-cache.test.d.ts.map +1 -0
  64. package/dist/server/codex-auth-cache.test.js +497 -0
  65. package/dist/server/codex-auth-cache.test.js.map +1 -0
  66. package/dist/server/codex-auth-copyback.d.ts +53 -0
  67. package/dist/server/codex-auth-copyback.d.ts.map +1 -0
  68. package/dist/server/codex-auth-copyback.js +123 -0
  69. package/dist/server/codex-auth-copyback.js.map +1 -0
  70. package/dist/server/codex-auth-copyback.test.d.ts +2 -0
  71. package/dist/server/codex-auth-copyback.test.d.ts.map +1 -0
  72. package/dist/server/codex-auth-copyback.test.js +507 -0
  73. package/dist/server/codex-auth-copyback.test.js.map +1 -0
  74. package/dist/server/codex-auth-merge-decision.cjs +170 -0
  75. package/dist/server/codex-auth-merge-decision.d.ts +22 -0
  76. package/dist/server/codex-auth-merge-decision.d.ts.map +1 -0
  77. package/dist/server/codex-auth-merge-decision.js +58 -0
  78. package/dist/server/codex-auth-merge-decision.js.map +1 -0
  79. package/dist/server/codex-auth-merge-decision.test.d.ts +2 -0
  80. package/dist/server/codex-auth-merge-decision.test.d.ts.map +1 -0
  81. package/dist/server/codex-auth-merge-decision.test.js +230 -0
  82. package/dist/server/codex-auth-merge-decision.test.js.map +1 -0
  83. package/dist/server/codex-auth-merge-extract.sh +73 -0
  84. package/dist/server/codex-auth-merge-scripts.d.ts +21 -0
  85. package/dist/server/codex-auth-merge-scripts.d.ts.map +1 -0
  86. package/dist/server/codex-auth-merge-scripts.js +40 -0
  87. package/dist/server/codex-auth-merge-scripts.js.map +1 -0
  88. package/dist/server/codex-auth-merge.test.d.ts +2 -0
  89. package/dist/server/codex-auth-merge.test.d.ts.map +1 -0
  90. package/dist/server/codex-auth-merge.test.js +714 -0
  91. package/dist/server/codex-auth-merge.test.js.map +1 -0
  92. package/dist/server/codex-auth-seed-write.d.ts +30 -0
  93. package/dist/server/codex-auth-seed-write.d.ts.map +1 -0
  94. package/dist/server/codex-auth-seed-write.js +41 -0
  95. package/dist/server/codex-auth-seed-write.js.map +1 -0
  96. package/dist/server/codex-home.d.ts +162 -0
  97. package/dist/server/codex-home.d.ts.map +1 -0
  98. package/dist/server/codex-home.js +771 -0
  99. package/dist/server/codex-home.js.map +1 -0
  100. package/dist/server/codex-home.test.d.ts +2 -0
  101. package/dist/server/codex-home.test.d.ts.map +1 -0
  102. package/dist/server/codex-home.test.js +1183 -0
  103. package/dist/server/codex-home.test.js.map +1 -0
  104. package/dist/server/config-schema.d.ts +3 -0
  105. package/dist/server/config-schema.d.ts.map +1 -0
  106. package/dist/server/config-schema.js +67 -0
  107. package/dist/server/config-schema.js.map +1 -0
  108. package/dist/server/device-login-export.d.ts +56 -0
  109. package/dist/server/device-login-export.d.ts.map +1 -0
  110. package/dist/server/device-login-export.js +230 -0
  111. package/dist/server/device-login-export.js.map +1 -0
  112. package/dist/server/device-login-export.test.d.ts +2 -0
  113. package/dist/server/device-login-export.test.d.ts.map +1 -0
  114. package/dist/server/device-login-export.test.js +245 -0
  115. package/dist/server/device-login-export.test.js.map +1 -0
  116. package/dist/server/device-login-parse.d.ts +20 -0
  117. package/dist/server/device-login-parse.d.ts.map +1 -0
  118. package/dist/server/device-login-parse.js +155 -0
  119. package/dist/server/device-login-parse.js.map +1 -0
  120. package/dist/server/device-login-parse.test.d.ts +2 -0
  121. package/dist/server/device-login-parse.test.d.ts.map +1 -0
  122. package/dist/server/device-login-parse.test.js +269 -0
  123. package/dist/server/device-login-parse.test.js.map +1 -0
  124. package/dist/server/device-login-runner.d.ts +69 -0
  125. package/dist/server/device-login-runner.d.ts.map +1 -0
  126. package/dist/server/device-login-runner.js +109 -0
  127. package/dist/server/device-login-runner.js.map +1 -0
  128. package/dist/server/device-login-runner.test.d.ts +2 -0
  129. package/dist/server/device-login-runner.test.d.ts.map +1 -0
  130. package/dist/server/device-login-runner.test.js +201 -0
  131. package/dist/server/device-login-runner.test.js.map +1 -0
  132. package/dist/server/engine-availability.test.d.ts +2 -0
  133. package/dist/server/engine-availability.test.d.ts.map +1 -0
  134. package/dist/server/engine-availability.test.js +41 -0
  135. package/dist/server/engine-availability.test.js.map +1 -0
  136. package/dist/server/execute.acp-fallback.test.d.ts +2 -0
  137. package/dist/server/execute.acp-fallback.test.d.ts.map +1 -0
  138. package/dist/server/execute.acp-fallback.test.js +123 -0
  139. package/dist/server/execute.acp-fallback.test.js.map +1 -0
  140. package/dist/server/execute.auth-precedence.test.d.ts +2 -0
  141. package/dist/server/execute.auth-precedence.test.d.ts.map +1 -0
  142. package/dist/server/execute.auth-precedence.test.js +137 -0
  143. package/dist/server/execute.auth-precedence.test.js.map +1 -0
  144. package/dist/server/execute.auth.test.d.ts +2 -0
  145. package/dist/server/execute.auth.test.d.ts.map +1 -0
  146. package/dist/server/execute.auth.test.js +197 -0
  147. package/dist/server/execute.auth.test.js.map +1 -0
  148. package/dist/server/execute.d.ts +40 -0
  149. package/dist/server/execute.d.ts.map +1 -0
  150. package/dist/server/execute.js +1290 -0
  151. package/dist/server/execute.js.map +1 -0
  152. package/dist/server/execute.remote.test.d.ts +2 -0
  153. package/dist/server/execute.remote.test.d.ts.map +1 -0
  154. package/dist/server/execute.remote.test.js +556 -0
  155. package/dist/server/execute.remote.test.js.map +1 -0
  156. package/dist/server/execute.stderr-error.test.d.ts +2 -0
  157. package/dist/server/execute.stderr-error.test.d.ts.map +1 -0
  158. package/dist/server/execute.stderr-error.test.js +142 -0
  159. package/dist/server/execute.stderr-error.test.js.map +1 -0
  160. package/dist/server/execute.test.d.ts +2 -0
  161. package/dist/server/execute.test.d.ts.map +1 -0
  162. package/dist/server/execute.test.js +240 -0
  163. package/dist/server/execute.test.js.map +1 -0
  164. package/dist/server/index.d.ts +18 -0
  165. package/dist/server/index.d.ts.map +1 -0
  166. package/dist/server/index.js +71 -0
  167. package/dist/server/index.js.map +1 -0
  168. package/dist/server/output-inactivity-monitor.d.ts +62 -0
  169. package/dist/server/output-inactivity-monitor.d.ts.map +1 -0
  170. package/dist/server/output-inactivity-monitor.integration.test.d.ts +2 -0
  171. package/dist/server/output-inactivity-monitor.integration.test.d.ts.map +1 -0
  172. package/dist/server/output-inactivity-monitor.integration.test.js +136 -0
  173. package/dist/server/output-inactivity-monitor.integration.test.js.map +1 -0
  174. package/dist/server/output-inactivity-monitor.js +112 -0
  175. package/dist/server/output-inactivity-monitor.js.map +1 -0
  176. package/dist/server/output-inactivity-monitor.test.d.ts +2 -0
  177. package/dist/server/output-inactivity-monitor.test.d.ts.map +1 -0
  178. package/dist/server/output-inactivity-monitor.test.js +284 -0
  179. package/dist/server/output-inactivity-monitor.test.js.map +1 -0
  180. package/dist/server/parse.d.ts +51 -0
  181. package/dist/server/parse.d.ts.map +1 -0
  182. package/dist/server/parse.js +261 -0
  183. package/dist/server/parse.js.map +1 -0
  184. package/dist/server/parse.test.d.ts +2 -0
  185. package/dist/server/parse.test.d.ts.map +1 -0
  186. package/dist/server/parse.test.js +186 -0
  187. package/dist/server/parse.test.js.map +1 -0
  188. package/dist/server/process-activity-monitor.d.ts +21 -0
  189. package/dist/server/process-activity-monitor.d.ts.map +1 -0
  190. package/dist/server/process-activity-monitor.js +103 -0
  191. package/dist/server/process-activity-monitor.js.map +1 -0
  192. package/dist/server/process-activity-monitor.test.d.ts +2 -0
  193. package/dist/server/process-activity-monitor.test.d.ts.map +1 -0
  194. package/dist/server/process-activity-monitor.test.js +82 -0
  195. package/dist/server/process-activity-monitor.test.js.map +1 -0
  196. package/dist/server/quota-spawn-error.test.d.ts +2 -0
  197. package/dist/server/quota-spawn-error.test.d.ts.map +1 -0
  198. package/dist/server/quota-spawn-error.test.js +174 -0
  199. package/dist/server/quota-spawn-error.test.js.map +1 -0
  200. package/dist/server/quota.d.ts +66 -0
  201. package/dist/server/quota.d.ts.map +1 -0
  202. package/dist/server/quota.js +511 -0
  203. package/dist/server/quota.js.map +1 -0
  204. package/dist/server/runtime-config.d.ts +11 -0
  205. package/dist/server/runtime-config.d.ts.map +1 -0
  206. package/dist/server/runtime-config.js +380 -0
  207. package/dist/server/runtime-config.js.map +1 -0
  208. package/dist/server/runtime-config.test.d.ts +2 -0
  209. package/dist/server/runtime-config.test.d.ts.map +1 -0
  210. package/dist/server/runtime-config.test.js +367 -0
  211. package/dist/server/runtime-config.test.js.map +1 -0
  212. package/dist/server/skills.d.ts +8 -0
  213. package/dist/server/skills.d.ts.map +1 -0
  214. package/dist/server/skills.js +26 -0
  215. package/dist/server/skills.js.map +1 -0
  216. package/dist/server/test.d.ts +3 -0
  217. package/dist/server/test.d.ts.map +1 -0
  218. package/dist/server/test.js +458 -0
  219. package/dist/server/test.js.map +1 -0
  220. package/dist/server/test.remote.test.d.ts +2 -0
  221. package/dist/server/test.remote.test.d.ts.map +1 -0
  222. package/dist/server/test.remote.test.js +396 -0
  223. package/dist/server/test.remote.test.js.map +1 -0
  224. package/dist/ui/build-config.d.ts +5 -0
  225. package/dist/ui/build-config.d.ts.map +1 -0
  226. package/dist/ui/build-config.js +229 -0
  227. package/dist/ui/build-config.js.map +1 -0
  228. package/dist/ui/build-config.test.d.ts +2 -0
  229. package/dist/ui/build-config.test.d.ts.map +1 -0
  230. package/dist/ui/build-config.test.js +272 -0
  231. package/dist/ui/build-config.test.js.map +1 -0
  232. package/dist/ui/index.d.ts +4 -0
  233. package/dist/ui/index.d.ts.map +1 -0
  234. package/dist/ui/index.js +8 -0
  235. package/dist/ui/index.js.map +1 -0
  236. package/dist/ui/parse-stdout.d.ts +3 -0
  237. package/dist/ui/parse-stdout.d.ts.map +1 -0
  238. package/dist/ui/parse-stdout.js +265 -0
  239. package/dist/ui/parse-stdout.js.map +1 -0
  240. package/dist/ui/parse-stdout.test.d.ts +2 -0
  241. package/dist/ui/parse-stdout.test.d.ts.map +1 -0
  242. package/dist/ui/parse-stdout.test.js +77 -0
  243. package/dist/ui/parse-stdout.test.js.map +1 -0
  244. package/package.json +61 -0
  245. package/skills/agentmail/SKILL.md +70 -0
  246. package/skills/paperclip/SKILL.md +712 -0
  247. package/skills/paperclip/references/api-reference.md +1675 -0
  248. package/skills/paperclip/references/artifacts.md +158 -0
  249. package/skills/paperclip/references/cases.md +295 -0
  250. package/skills/paperclip/references/company-skills.md +266 -0
  251. package/skills/paperclip/references/issue-workspaces.md +80 -0
  252. package/skills/paperclip/references/routines.md +231 -0
  253. package/skills/paperclip/references/workflows.md +141 -0
  254. package/skills/paperclip/scripts/paperclip-upload-artifact.sh +592 -0
  255. package/skills/paperclip-board/SKILL.md +619 -0
  256. package/skills/paperclip-converting-plans-to-tasks/SKILL.md +60 -0
  257. package/skills/paperclip-create-agent/SKILL.md +179 -0
  258. package/skills/paperclip-create-agent/references/agent-instruction-templates.md +123 -0
  259. package/skills/paperclip-create-agent/references/agents/coder.md +64 -0
  260. package/skills/paperclip-create-agent/references/agents/qa.md +88 -0
  261. package/skills/paperclip-create-agent/references/agents/securityengineer.md +135 -0
  262. package/skills/paperclip-create-agent/references/agents/uxdesigner.md +115 -0
  263. package/skills/paperclip-create-agent/references/api-reference.md +110 -0
  264. package/skills/paperclip-create-agent/references/baseline-role-guide.md +168 -0
  265. package/skills/paperclip-create-agent/references/draft-review-checklist.md +95 -0
  266. package/skills/para-memory-files/SKILL.md +100 -0
  267. package/skills/para-memory-files/references/schemas.md +35 -0
  268. 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":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=codex-auth-copyback.test.d.ts.map
@@ -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