@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,1183 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { CODEX_SYNC_ALLOWLIST, codexHomeHasUsableAuth, ensureSymlink, evaluateCodexCredentialReadiness, mergeManagedCodexMcpGateways, isManagedCodexHomePath, prepareManagedCodexHome, reconcileManagedCodexHome, seedManagedCodexHome, stageCodexHomeForSync, writeManagedCodexMcpConfig, } from "./codex-home.js";
6
+ describe("mergeManagedCodexMcpGateways", () => {
7
+ it("keeps runtime gateways and appends non-overlapping context gateways", () => {
8
+ expect(mergeManagedCodexMcpGateways([{ name: "runtime", endpointPath: "/runtime", bearerToken: "runtime-token" }], [
9
+ { name: "runtime", endpointPath: "/stale", bearerToken: "stale-token" },
10
+ { name: "manual", endpointPath: "/manual", bearerToken: "manual-token" },
11
+ ])).toEqual([
12
+ { name: "runtime", endpointPath: "/runtime", bearerToken: "runtime-token" },
13
+ { name: "manual", endpointPath: "/manual", bearerToken: "manual-token" },
14
+ ]);
15
+ });
16
+ });
17
+ describe("codex managed home", () => {
18
+ afterEach(() => {
19
+ vi.restoreAllMocks();
20
+ });
21
+ it("treats a concurrently-created expected auth symlink as success", async () => {
22
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-home-"));
23
+ const sharedCodexHome = path.join(root, "shared-codex-home");
24
+ const paperclipHome = path.join(root, "paperclip-home");
25
+ const managedCodexHome = path.join(paperclipHome, "instances", "default", "companies", "company-1", "codex-home");
26
+ const sharedAuth = path.join(sharedCodexHome, "auth.json");
27
+ const managedAuth = path.join(managedCodexHome, "auth.json");
28
+ await fs.mkdir(sharedCodexHome, { recursive: true });
29
+ await fs.writeFile(sharedAuth, '{"OPENAI_API_KEY":"shared"}\n', "utf8");
30
+ const originalSymlink = fs.symlink.bind(fs);
31
+ vi.spyOn(fs, "symlink").mockImplementationOnce(async (source, target, type) => {
32
+ await originalSymlink(source, target, type);
33
+ const error = new Error("file already exists");
34
+ error.code = "EEXIST";
35
+ throw error;
36
+ });
37
+ try {
38
+ await expect(prepareManagedCodexHome({
39
+ CODEX_HOME: sharedCodexHome,
40
+ PAPERCLIP_HOME: paperclipHome,
41
+ PAPERCLIP_INSTANCE_ID: "default",
42
+ }, async () => { }, "company-1")).resolves.toBe(managedCodexHome);
43
+ expect((await fs.lstat(managedAuth)).isSymbolicLink()).toBe(true);
44
+ expect(await fs.realpath(managedAuth)).toBe(await fs.realpath(sharedAuth));
45
+ }
46
+ finally {
47
+ await fs.rm(root, { recursive: true, force: true });
48
+ }
49
+ });
50
+ it("still throws on EEXIST when a raced-in auth symlink points elsewhere", async () => {
51
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-home-"));
52
+ const sharedCodexHome = path.join(root, "shared-codex-home");
53
+ const paperclipHome = path.join(root, "paperclip-home");
54
+ const managedCodexHome = path.join(paperclipHome, "instances", "default", "companies", "company-1", "codex-home");
55
+ const sharedAuth = path.join(sharedCodexHome, "auth.json");
56
+ const wrongAuth = path.join(sharedCodexHome, "other-auth.json");
57
+ const managedAuth = path.join(managedCodexHome, "auth.json");
58
+ await fs.mkdir(sharedCodexHome, { recursive: true });
59
+ await fs.writeFile(sharedAuth, '{"OPENAI_API_KEY":"shared"}\n', "utf8");
60
+ await fs.writeFile(wrongAuth, '{"token":"other"}\n', "utf8");
61
+ const originalSymlink = fs.symlink.bind(fs);
62
+ vi.spyOn(fs, "symlink").mockImplementationOnce(async (_source, target, type) => {
63
+ await originalSymlink(wrongAuth, target, type);
64
+ const error = new Error("file already exists");
65
+ error.code = "EEXIST";
66
+ throw error;
67
+ });
68
+ try {
69
+ await expect(prepareManagedCodexHome({
70
+ CODEX_HOME: sharedCodexHome,
71
+ PAPERCLIP_HOME: paperclipHome,
72
+ PAPERCLIP_INSTANCE_ID: "default",
73
+ }, async () => { }, "company-1")).rejects.toMatchObject({ code: "EEXIST" });
74
+ expect((await fs.lstat(managedAuth)).isSymbolicLink()).toBe(true);
75
+ expect(await fs.readlink(managedAuth)).toBe(wrongAuth);
76
+ }
77
+ finally {
78
+ await fs.rm(root, { recursive: true, force: true });
79
+ }
80
+ });
81
+ // Regression for #5028: older Paperclip versions copied auth.json into the
82
+ // managed home instead of symlinking. After upgrading to the symlink-based
83
+ // logic, the stale regular file at the target stayed in place and every
84
+ // subsequent codex_local run failed with refresh_token_reused as soon as the
85
+ // source token rotated. `ensureSymlink` now heals the upgrade path by
86
+ // unlinking the stale copy and creating a symlink to the live source.
87
+ it("replaces a stale regular-file auth.json with a symlink to the live source (#5028)", async () => {
88
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-home-"));
89
+ try {
90
+ const sharedCodexHome = path.join(root, "shared-codex-home");
91
+ const paperclipHome = path.join(root, "paperclip-home");
92
+ const managedCodexHome = path.join(paperclipHome, "instances", "default", "companies", "company-1", "codex-home");
93
+ const sharedAuth = path.join(sharedCodexHome, "auth.json");
94
+ const managedAuth = path.join(managedCodexHome, "auth.json");
95
+ await fs.mkdir(sharedCodexHome, { recursive: true });
96
+ // The live source has rotated since the stale copy was written.
97
+ await fs.writeFile(sharedAuth, '{"token":"fresh"}', "utf8");
98
+ // Simulate a stale copy left by a previous Paperclip version.
99
+ await fs.mkdir(managedCodexHome, { recursive: true });
100
+ await fs.writeFile(managedAuth, '{"token":"stale-from-copy"}', "utf8");
101
+ await prepareManagedCodexHome({
102
+ CODEX_HOME: sharedCodexHome,
103
+ PAPERCLIP_HOME: paperclipHome,
104
+ PAPERCLIP_INSTANCE_ID: "default",
105
+ }, async () => { }, "company-1");
106
+ expect((await fs.lstat(managedAuth)).isSymbolicLink()).toBe(true);
107
+ expect(await fs.readFile(managedAuth, "utf8")).toBe('{"token":"fresh"}');
108
+ }
109
+ finally {
110
+ await fs.rm(root, { recursive: true, force: true });
111
+ }
112
+ });
113
+ // Direct unit coverage for the new ensureSymlink branch (#5028). The
114
+ // regression test above goes through prepareManagedCodexHome, whose
115
+ // pre-existing apikey-mode cleanup `fs.rm`s the stale auth.json before
116
+ // ensureSymlink runs — so the heal branch never executes there. Call
117
+ // ensureSymlink directly to prove the unlink-and-recreate path itself.
118
+ it("ensureSymlink: unlinks a stale regular file and recreates the symlink", async () => {
119
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-ensure-symlink-"));
120
+ try {
121
+ const source = path.join(root, "live-source.json");
122
+ const target = path.join(root, "stale-target.json");
123
+ await fs.writeFile(source, '{"token":"fresh"}', "utf8");
124
+ await fs.writeFile(target, '{"token":"stale-from-copy"}', "utf8");
125
+ await ensureSymlink(target, source);
126
+ expect((await fs.lstat(target)).isSymbolicLink()).toBe(true);
127
+ expect(await fs.readFile(target, "utf8")).toBe('{"token":"fresh"}');
128
+ }
129
+ finally {
130
+ await fs.rm(root, { recursive: true, force: true });
131
+ }
132
+ });
133
+ // The isDirectory() guard added with the heal branch must keep an unexpected
134
+ // directory in place rather than throwing EISDIR. We treat a directory at
135
+ // this path as operator-owned, not a stale Paperclip copy.
136
+ it("ensureSymlink: leaves an unexpected directory in place instead of throwing", async () => {
137
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-ensure-symlink-dir-"));
138
+ try {
139
+ const source = path.join(root, "live-source.json");
140
+ const target = path.join(root, "unexpected-dir");
141
+ await fs.writeFile(source, '{"token":"fresh"}', "utf8");
142
+ await fs.mkdir(target);
143
+ await fs.writeFile(path.join(target, "sentinel"), "keep-me", "utf8");
144
+ await expect(ensureSymlink(target, source)).resolves.toBeUndefined();
145
+ expect((await fs.lstat(target)).isDirectory()).toBe(true);
146
+ expect(await fs.readFile(path.join(target, "sentinel"), "utf8")).toBe("keep-me");
147
+ }
148
+ finally {
149
+ await fs.rm(root, { recursive: true, force: true });
150
+ }
151
+ });
152
+ });
153
+ describe("isManagedCodexHomePath", () => {
154
+ const env = {
155
+ PAPERCLIP_HOME: "/srv/paperclip",
156
+ PAPERCLIP_INSTANCE_ID: "default",
157
+ };
158
+ const companyRoot = path.resolve("/srv/paperclip/instances/default/companies/company-1");
159
+ it("treats the per-agent managed home as managed", () => {
160
+ expect(isManagedCodexHomePath(env, "company-1", path.join(companyRoot, "agents", "agent-7", "codex-home"))).toBe(true);
161
+ });
162
+ it("treats the shared company home as managed", () => {
163
+ expect(isManagedCodexHomePath(env, "company-1", path.join(companyRoot, "codex-home"))).toBe(true);
164
+ });
165
+ it("treats a path outside the company tree as an external override", () => {
166
+ expect(isManagedCodexHomePath(env, "company-1", "/home/dev/.codex")).toBe(false);
167
+ expect(isManagedCodexHomePath(env, "company-1", path.resolve("/srv/paperclip/instances/default/companies/company-2/codex-home"))).toBe(false);
168
+ });
169
+ it("returns false without a companyId", () => {
170
+ expect(isManagedCodexHomePath(env, undefined, path.join(companyRoot, "codex-home"))).toBe(false);
171
+ });
172
+ });
173
+ describe("codexHomeHasUsableAuth", () => {
174
+ it("is true for credential-bearing auth.json and false when missing", async () => {
175
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-auth-"));
176
+ try {
177
+ expect(await codexHomeHasUsableAuth(root)).toBe(false);
178
+ await fs.writeFile(path.join(root, "auth.json"), "{}", "utf8");
179
+ expect(await codexHomeHasUsableAuth(root)).toBe(false);
180
+ await fs.writeFile(path.join(root, "auth.json"), '{"foo":"bar"}', "utf8");
181
+ expect(await codexHomeHasUsableAuth(root)).toBe(false);
182
+ await fs.writeFile(path.join(root, "auth.json"), '{"token":"shared"}', "utf8");
183
+ expect(await codexHomeHasUsableAuth(root)).toBe(false);
184
+ await fs.writeFile(path.join(root, "auth.json"), '{"access_token":"shared"}', "utf8");
185
+ expect(await codexHomeHasUsableAuth(root)).toBe(false);
186
+ await fs.writeFile(path.join(root, "auth.json"), '{"OPENAI_API_KEY":"shared"}', "utf8");
187
+ expect(await codexHomeHasUsableAuth(root)).toBe(true);
188
+ }
189
+ finally {
190
+ await fs.rm(root, { recursive: true, force: true });
191
+ }
192
+ });
193
+ it("recognizes the Codex 0.143 AuthDotJson subscription shape", async () => {
194
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-auth-modern-"));
195
+ try {
196
+ await fs.writeFile(path.join(root, "auth.json"), JSON.stringify({
197
+ tokens: {
198
+ id_token: "synthetic-id-token",
199
+ access_token: "synthetic-access-token",
200
+ refresh_token: "synthetic-refresh-token",
201
+ account_id: "acct-modern",
202
+ },
203
+ last_refresh: "2026-07-09T00:00:00Z",
204
+ }), "utf8");
205
+ expect(await codexHomeHasUsableAuth(root)).toBe(true);
206
+ }
207
+ finally {
208
+ await fs.rm(root, { recursive: true, force: true });
209
+ }
210
+ });
211
+ it("treats subscription auth without account_id or token material as unusable", async () => {
212
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-auth-modern-invalid-"));
213
+ try {
214
+ await fs.writeFile(path.join(root, "auth.json"), JSON.stringify({
215
+ tokens: {
216
+ id_token: "synthetic-id-token",
217
+ access_token: "synthetic-access-token",
218
+ refresh_token: "synthetic-refresh-token",
219
+ },
220
+ last_refresh: "2026-07-09T00:00:00Z",
221
+ }), "utf8");
222
+ expect(await codexHomeHasUsableAuth(root)).toBe(false);
223
+ await fs.writeFile(path.join(root, "auth.json"), JSON.stringify({
224
+ tokens: {
225
+ account_id: "acct-modern",
226
+ },
227
+ last_refresh: "2026-07-09T00:00:00Z",
228
+ }), "utf8");
229
+ expect(await codexHomeHasUsableAuth(root)).toBe(false);
230
+ }
231
+ finally {
232
+ await fs.rm(root, { recursive: true, force: true });
233
+ }
234
+ });
235
+ it("is false for a dangling auth.json symlink", async () => {
236
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-auth-dangling-"));
237
+ try {
238
+ await fs.symlink(path.join(root, "missing-source.json"), path.join(root, "auth.json"));
239
+ expect(await codexHomeHasUsableAuth(root)).toBe(false);
240
+ }
241
+ finally {
242
+ await fs.rm(root, { recursive: true, force: true });
243
+ }
244
+ });
245
+ });
246
+ describe("seedManagedCodexHome", () => {
247
+ it("symlinks auth.json from the shared source into an explicit per-agent home", async () => {
248
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-seed-"));
249
+ try {
250
+ const sharedCodexHome = path.join(root, "shared-codex-home");
251
+ const agentHome = path.join(root, "instances", "default", "companies", "company-1", "agents", "agent-7", "codex-home");
252
+ const sharedAuth = path.join(sharedCodexHome, "auth.json");
253
+ const agentAuth = path.join(agentHome, "auth.json");
254
+ await fs.mkdir(sharedCodexHome, { recursive: true });
255
+ await fs.writeFile(sharedAuth, '{"OPENAI_API_KEY":"shared"}', "utf8");
256
+ await seedManagedCodexHome(agentHome, { CODEX_HOME: sharedCodexHome }, async () => { });
257
+ expect((await fs.lstat(agentAuth)).isSymbolicLink()).toBe(true);
258
+ expect(await fs.realpath(agentAuth)).toBe(await fs.realpath(sharedAuth));
259
+ }
260
+ finally {
261
+ await fs.rm(root, { recursive: true, force: true });
262
+ }
263
+ });
264
+ it("writes an API-key auth.json into the home when an apiKey is supplied", async () => {
265
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-seed-apikey-"));
266
+ try {
267
+ const agentHome = path.join(root, "agent-home");
268
+ const emptyShared = path.join(root, "empty-shared");
269
+ await fs.mkdir(emptyShared, { recursive: true });
270
+ await seedManagedCodexHome(agentHome, { CODEX_HOME: emptyShared }, async () => { }, {
271
+ apiKey: "sk-test-123",
272
+ });
273
+ const written = JSON.parse(await fs.readFile(path.join(agentHome, "auth.json"), "utf8"));
274
+ expect(written).toEqual({ OPENAI_API_KEY: "sk-test-123" });
275
+ }
276
+ finally {
277
+ await fs.rm(root, { recursive: true, force: true });
278
+ }
279
+ });
280
+ // A device-login promotion writes the company credential as a regular-file
281
+ // subscription auth.json. Re-seeding must keep it, or the first Test probe or
282
+ // run after a successful login silently signs the company out. The cases
283
+ // below pin the identity- and freshness-anchored rule: keep a subscription
284
+ // identity the shared source does not hold; keep a same-identity file the
285
+ // shared source is not strictly fresher than (ties and unparseable freshness
286
+ // included); still heal the same-identity stale copy once the shared source
287
+ // has moved past it (#5028) and still remove apikey-mode residue.
288
+ const subscriptionAuth = (accountId, marker, lastRefresh = "2026-07-09T00:00:00Z") => JSON.stringify({
289
+ tokens: {
290
+ id_token: `synthetic-id-token-${marker}`,
291
+ access_token: `synthetic-access-token-${marker}`,
292
+ refresh_token: `synthetic-refresh-token-${marker}`,
293
+ account_id: accountId,
294
+ },
295
+ ...(lastRefresh ? { last_refresh: lastRefresh } : {}),
296
+ });
297
+ it("keeps a promoted subscription auth.json when the shared source has no auth", async () => {
298
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-seed-promoted-"));
299
+ try {
300
+ const companyHome = path.join(root, "company-home");
301
+ const emptyShared = path.join(root, "empty-shared");
302
+ const promoted = subscriptionAuth("acct-promoted", "promoted");
303
+ await fs.mkdir(emptyShared, { recursive: true });
304
+ await fs.mkdir(companyHome, { recursive: true });
305
+ await fs.writeFile(path.join(companyHome, "auth.json"), promoted, "utf8");
306
+ await seedManagedCodexHome(companyHome, { CODEX_HOME: emptyShared }, async () => { });
307
+ const kept = path.join(companyHome, "auth.json");
308
+ expect((await fs.lstat(kept)).isSymbolicLink()).toBe(false);
309
+ expect(await fs.readFile(kept, "utf8")).toBe(promoted);
310
+ }
311
+ finally {
312
+ await fs.rm(root, { recursive: true, force: true });
313
+ }
314
+ });
315
+ it("keeps a promoted subscription auth.json whose identity differs from the shared source", async () => {
316
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-seed-foreign-"));
317
+ try {
318
+ const companyHome = path.join(root, "company-home");
319
+ const sharedCodexHome = path.join(root, "shared-codex-home");
320
+ const promoted = subscriptionAuth("acct-promoted", "promoted");
321
+ await fs.mkdir(sharedCodexHome, { recursive: true });
322
+ await fs.writeFile(path.join(sharedCodexHome, "auth.json"), subscriptionAuth("acct-host", "host"), "utf8");
323
+ await fs.mkdir(companyHome, { recursive: true });
324
+ await fs.writeFile(path.join(companyHome, "auth.json"), promoted, "utf8");
325
+ await seedManagedCodexHome(companyHome, { CODEX_HOME: sharedCodexHome }, async () => { });
326
+ const kept = path.join(companyHome, "auth.json");
327
+ expect((await fs.lstat(kept)).isSymbolicLink()).toBe(false);
328
+ expect(await fs.readFile(kept, "utf8")).toBe(promoted);
329
+ }
330
+ finally {
331
+ await fs.rm(root, { recursive: true, force: true });
332
+ }
333
+ });
334
+ it("still replaces a same-identity stale regular copy with the shared symlink once the source is strictly fresher (#5028)", async () => {
335
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-seed-stale-"));
336
+ try {
337
+ const companyHome = path.join(root, "company-home");
338
+ const sharedCodexHome = path.join(root, "shared-codex-home");
339
+ // The live source has rotated since the stale copy was written, so its
340
+ // last_refresh is strictly greater — the real #5028 shape.
341
+ const fresh = subscriptionAuth("acct-same", "fresh", "2026-07-09T02:00:00Z");
342
+ await fs.mkdir(sharedCodexHome, { recursive: true });
343
+ await fs.writeFile(path.join(sharedCodexHome, "auth.json"), fresh, "utf8");
344
+ await fs.mkdir(companyHome, { recursive: true });
345
+ await fs.writeFile(path.join(companyHome, "auth.json"), subscriptionAuth("acct-same", "stale", "2026-07-09T01:00:00Z"), "utf8");
346
+ await seedManagedCodexHome(companyHome, { CODEX_HOME: sharedCodexHome }, async () => { });
347
+ const healed = path.join(companyHome, "auth.json");
348
+ expect((await fs.lstat(healed)).isSymbolicLink()).toBe(true);
349
+ expect(await fs.readFile(healed, "utf8")).toBe(fresh);
350
+ }
351
+ finally {
352
+ await fs.rm(root, { recursive: true, force: true });
353
+ }
354
+ });
355
+ it("keeps a same-identity promoted auth.json that is strictly newer than the shared source", async () => {
356
+ // The device-login promotion mints a credential whose last_refresh is newer
357
+ // than the host copy the user was failing with. Swapping it for the shared
358
+ // symlink here would sign the company back in with that failing credential
359
+ // right after the login that replaced it.
360
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-seed-newer-"));
361
+ try {
362
+ const companyHome = path.join(root, "company-home");
363
+ const sharedCodexHome = path.join(root, "shared-codex-home");
364
+ const promoted = subscriptionAuth("acct-same", "promoted", "2026-07-09T02:00:00Z");
365
+ await fs.mkdir(sharedCodexHome, { recursive: true });
366
+ await fs.writeFile(path.join(sharedCodexHome, "auth.json"), subscriptionAuth("acct-same", "host", "2026-07-09T01:00:00Z"), "utf8");
367
+ await fs.mkdir(companyHome, { recursive: true });
368
+ await fs.writeFile(path.join(companyHome, "auth.json"), promoted, "utf8");
369
+ await seedManagedCodexHome(companyHome, { CODEX_HOME: sharedCodexHome }, async () => { });
370
+ const kept = path.join(companyHome, "auth.json");
371
+ expect((await fs.lstat(kept)).isSymbolicLink()).toBe(false);
372
+ expect(await fs.readFile(kept, "utf8")).toBe(promoted);
373
+ }
374
+ finally {
375
+ await fs.rm(root, { recursive: true, force: true });
376
+ }
377
+ });
378
+ it("keeps a same-identity auth.json when freshness ties or cannot be compared", async () => {
379
+ // Ties and unparseable timestamps keep the file: deleting a promoted
380
+ // credential is irreversible, while a kept file self-corrects on the next
381
+ // seed once the shared source has provably moved past it.
382
+ const cases = [
383
+ // Tie: same last_refresh on both sides.
384
+ { source: "2026-07-09T01:00:00Z", target: "2026-07-09T01:00:00Z" },
385
+ // The shared source carries no parseable last_refresh.
386
+ { source: null, target: "2026-07-09T01:00:00Z" },
387
+ // The file carries no parseable last_refresh.
388
+ { source: "2026-07-09T02:00:00Z", target: null },
389
+ ];
390
+ for (const { source, target } of cases) {
391
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-seed-tie-"));
392
+ try {
393
+ const companyHome = path.join(root, "company-home");
394
+ const sharedCodexHome = path.join(root, "shared-codex-home");
395
+ const file = subscriptionAuth("acct-same", "file", target);
396
+ await fs.mkdir(sharedCodexHome, { recursive: true });
397
+ await fs.writeFile(path.join(sharedCodexHome, "auth.json"), subscriptionAuth("acct-same", "host", source), "utf8");
398
+ await fs.mkdir(companyHome, { recursive: true });
399
+ await fs.writeFile(path.join(companyHome, "auth.json"), file, "utf8");
400
+ await seedManagedCodexHome(companyHome, { CODEX_HOME: sharedCodexHome }, async () => { });
401
+ const kept = path.join(companyHome, "auth.json");
402
+ expect((await fs.lstat(kept)).isSymbolicLink()).toBe(false);
403
+ expect(await fs.readFile(kept, "utf8")).toBe(file);
404
+ }
405
+ finally {
406
+ await fs.rm(root, { recursive: true, force: true });
407
+ }
408
+ }
409
+ });
410
+ it("keeps the usable target when the shared source exists but cannot be read", async () => {
411
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-seed-src-err-"));
412
+ try {
413
+ const companyHome = path.join(root, "company-home");
414
+ const sharedCodexHome = path.join(root, "shared-codex-home");
415
+ // A directory at the source auth.json path makes the read fail with
416
+ // EISDIR — a deterministic present-but-unreadable source. Removal plus
417
+ // the existence-only symlink pass would link the home to a source no
418
+ // downstream reader can use, so the usable target must survive.
419
+ await fs.mkdir(path.join(sharedCodexHome, "auth.json"), { recursive: true });
420
+ await fs.mkdir(companyHome, { recursive: true });
421
+ const target = subscriptionAuth("acct-unknown-source", "target");
422
+ await fs.writeFile(path.join(companyHome, "auth.json"), target, "utf8");
423
+ await seedManagedCodexHome(companyHome, { CODEX_HOME: sharedCodexHome }, async () => { });
424
+ const kept = path.join(companyHome, "auth.json");
425
+ expect((await fs.lstat(kept)).isSymbolicLink()).toBe(false);
426
+ expect(await fs.readFile(kept, "utf8")).toBe(target);
427
+ }
428
+ finally {
429
+ await fs.rm(root, { recursive: true, force: true });
430
+ }
431
+ });
432
+ it("never symlinks or heals a credential-store entry's auth.json, even for a fresher same-identity source", async () => {
433
+ // An agent can bind CODEX_HOME to a per-identity store entry through the
434
+ // login's account-home secret, and this seeding pass runs before every
435
+ // probe and execute. The entry's auth.json is the durable login the
436
+ // promotion/vend/copy-back own — a strictly-fresher same-identity shared
437
+ // source must NOT trigger the #5028 heal here, or the bound account is
438
+ // silently swapped for the host login.
439
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-seed-store-"));
440
+ try {
441
+ const sharedCodexHome = path.join(root, "shared-codex-home");
442
+ const entryHome = path.join(root, "paperclip-home", "instances", "default", "companies", "company-1", "codex-auth-cache", "acct-bound");
443
+ const env = {
444
+ CODEX_HOME: sharedCodexHome,
445
+ PAPERCLIP_HOME: path.join(root, "paperclip-home"),
446
+ PAPERCLIP_INSTANCE_ID: "default",
447
+ };
448
+ const stored = subscriptionAuth("acct-same", "stored", "2026-07-09T01:00:00Z");
449
+ await fs.mkdir(sharedCodexHome, { recursive: true });
450
+ await fs.writeFile(path.join(sharedCodexHome, "auth.json"), subscriptionAuth("acct-same", "host", "2026-07-09T02:00:00Z"), "utf8");
451
+ await fs.writeFile(path.join(sharedCodexHome, "config.toml"), 'model = "gpt-5"\n', "utf8");
452
+ await fs.mkdir(entryHome, { recursive: true });
453
+ await fs.writeFile(path.join(entryHome, "auth.json"), stored, "utf8");
454
+ await seedManagedCodexHome(entryHome, env, async () => { });
455
+ const kept = path.join(entryHome, "auth.json");
456
+ expect((await fs.lstat(kept)).isSymbolicLink()).toBe(false);
457
+ expect(await fs.readFile(kept, "utf8")).toBe(stored);
458
+ // The static shared config still copies in, so a bound run gets the
459
+ // same config a per-agent home gets.
460
+ expect(await fs.readFile(path.join(entryHome, "config.toml"), "utf8")).toBe('model = "gpt-5"\n');
461
+ }
462
+ finally {
463
+ await fs.rm(root, { recursive: true, force: true });
464
+ }
465
+ });
466
+ it("refuses an API-key rewrite of a credential-store entry's auth.json", async () => {
467
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-seed-store-apikey-"));
468
+ try {
469
+ const sharedCodexHome = path.join(root, "shared-codex-home");
470
+ const entryHome = path.join(root, "paperclip-home", "instances", "default", "companies", "company-1", "codex-auth-cache", "acct-bound");
471
+ const env = {
472
+ CODEX_HOME: sharedCodexHome,
473
+ PAPERCLIP_HOME: path.join(root, "paperclip-home"),
474
+ PAPERCLIP_INSTANCE_ID: "default",
475
+ };
476
+ const stored = subscriptionAuth("acct-bound-id", "stored", "2026-07-09T01:00:00Z");
477
+ await fs.mkdir(sharedCodexHome, { recursive: true });
478
+ await fs.mkdir(entryHome, { recursive: true });
479
+ await fs.writeFile(path.join(entryHome, "auth.json"), stored, "utf8");
480
+ const logs = [];
481
+ await seedManagedCodexHome(entryHome, env, async (_stream, line) => {
482
+ logs.push(line);
483
+ }, { apiKey: "sk-configured" });
484
+ expect(await fs.readFile(path.join(entryHome, "auth.json"), "utf8")).toBe(stored);
485
+ expect(logs.join("\n")).toContain("Refusing to write an API-key auth.json into credential-store entry");
486
+ }
487
+ finally {
488
+ await fs.rm(root, { recursive: true, force: true });
489
+ }
490
+ });
491
+ it("still removes an apikey-mode auth.json so the chatgpt-mode symlink is restored", async () => {
492
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-seed-apikey-residue-"));
493
+ try {
494
+ const companyHome = path.join(root, "company-home");
495
+ const sharedCodexHome = path.join(root, "shared-codex-home");
496
+ const shared = subscriptionAuth("acct-host", "host");
497
+ await fs.mkdir(sharedCodexHome, { recursive: true });
498
+ await fs.writeFile(path.join(sharedCodexHome, "auth.json"), shared, "utf8");
499
+ await fs.mkdir(companyHome, { recursive: true });
500
+ await fs.writeFile(path.join(companyHome, "auth.json"), '{"OPENAI_API_KEY":"stale-key"}', "utf8");
501
+ await seedManagedCodexHome(companyHome, { CODEX_HOME: sharedCodexHome }, async () => { });
502
+ const healed = path.join(companyHome, "auth.json");
503
+ expect((await fs.lstat(healed)).isSymbolicLink()).toBe(true);
504
+ expect(await fs.readFile(healed, "utf8")).toBe(shared);
505
+ }
506
+ finally {
507
+ await fs.rm(root, { recursive: true, force: true });
508
+ }
509
+ });
510
+ });
511
+ // Startup backfill for already-isolated managed homes.
512
+ describe("reconcileManagedCodexHome", () => {
513
+ async function makeFixture() {
514
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-reconcile-"));
515
+ const sharedCodexHome = path.join(root, "shared-codex-home");
516
+ const paperclipHome = path.join(root, "paperclip-home");
517
+ const agentHome = path.join(paperclipHome, "instances", "default", "companies", "company-1", "agents", "agent-7", "codex-home");
518
+ const sharedAuth = path.join(sharedCodexHome, "auth.json");
519
+ const agentAuth = path.join(agentHome, "auth.json");
520
+ await fs.mkdir(sharedCodexHome, { recursive: true });
521
+ await fs.writeFile(sharedAuth, '{"OPENAI_API_KEY":"shared"}', "utf8");
522
+ const env = {
523
+ CODEX_HOME: sharedCodexHome,
524
+ PAPERCLIP_HOME: paperclipHome,
525
+ PAPERCLIP_INSTANCE_ID: "default",
526
+ };
527
+ return { root, sharedCodexHome, sharedAuth, agentHome, agentAuth, env };
528
+ }
529
+ it("seeds a previously-stranded managed home and is a no-op on re-run", async () => {
530
+ const fx = await makeFixture();
531
+ try {
532
+ // The isolation guard created the per-agent home with no auth.json.
533
+ expect(await codexHomeHasUsableAuth(fx.agentHome)).toBe(false);
534
+ const first = await reconcileManagedCodexHome({
535
+ companyId: "company-1",
536
+ configuredCodexHome: fx.agentHome,
537
+ env: fx.env,
538
+ });
539
+ expect(first.status).toBe("seeded");
540
+ expect(first.home).toBe(fx.agentHome);
541
+ expect((await fs.lstat(fx.agentAuth)).isSymbolicLink()).toBe(true);
542
+ expect(await fs.realpath(fx.agentAuth)).toBe(await fs.realpath(fx.sharedAuth));
543
+ const second = await reconcileManagedCodexHome({
544
+ companyId: "company-1",
545
+ configuredCodexHome: fx.agentHome,
546
+ env: fx.env,
547
+ });
548
+ expect(second.status).toBe("already_seeded");
549
+ expect((await fs.lstat(fx.agentAuth)).isSymbolicLink()).toBe(true);
550
+ expect(await fs.realpath(fx.agentAuth)).toBe(await fs.realpath(fx.sharedAuth));
551
+ }
552
+ finally {
553
+ await fs.rm(fx.root, { recursive: true, force: true });
554
+ }
555
+ });
556
+ it("reports source_auth_missing when shared auth is unavailable", async () => {
557
+ const fx = await makeFixture();
558
+ try {
559
+ await fs.rm(fx.sharedAuth, { force: true });
560
+ const result = await reconcileManagedCodexHome({
561
+ companyId: "company-1",
562
+ configuredCodexHome: fx.agentHome,
563
+ env: fx.env,
564
+ });
565
+ expect(result.status).toBe("source_auth_missing");
566
+ await expect(fs.lstat(fx.agentAuth)).rejects.toThrow();
567
+ }
568
+ finally {
569
+ await fs.rm(fx.root, { recursive: true, force: true });
570
+ }
571
+ });
572
+ it("leaves a genuine external override untouched", async () => {
573
+ const fx = await makeFixture();
574
+ try {
575
+ const external = path.join(fx.root, "user-codex");
576
+ await fs.mkdir(external, { recursive: true });
577
+ const result = await reconcileManagedCodexHome({
578
+ companyId: "company-1",
579
+ configuredCodexHome: external,
580
+ env: fx.env,
581
+ });
582
+ expect(result.status).toBe("external_override");
583
+ expect(await codexHomeHasUsableAuth(external)).toBe(false);
584
+ }
585
+ finally {
586
+ await fs.rm(fx.root, { recursive: true, force: true });
587
+ }
588
+ });
589
+ it("reports no_managed_home when no CODEX_HOME is configured", async () => {
590
+ const fx = await makeFixture();
591
+ try {
592
+ const result = await reconcileManagedCodexHome({
593
+ companyId: "company-1",
594
+ configuredCodexHome: null,
595
+ env: fx.env,
596
+ });
597
+ expect(result).toEqual({ status: "no_managed_home", home: null });
598
+ }
599
+ finally {
600
+ await fs.rm(fx.root, { recursive: true, force: true });
601
+ }
602
+ });
603
+ it("preserves an existing API-key auth.json when the key is secret-bound", async () => {
604
+ const fx = await makeFixture();
605
+ try {
606
+ // A prior execute-time run resolved the secret and wrote a regular-file
607
+ // auth.json containing the key.
608
+ await fs.mkdir(fx.agentHome, { recursive: true });
609
+ await fs.writeFile(fx.agentAuth, JSON.stringify({ OPENAI_API_KEY: "sk-secret-resolved" }), { mode: 0o600 });
610
+ const result = await reconcileManagedCodexHome({
611
+ companyId: "company-1",
612
+ configuredCodexHome: fx.agentHome,
613
+ apiKeySecretBound: true,
614
+ env: fx.env,
615
+ });
616
+ expect(result.status).toBe("already_seeded");
617
+ expect((await fs.lstat(fx.agentAuth)).isSymbolicLink()).toBe(false);
618
+ expect(JSON.parse(await fs.readFile(fx.agentAuth, "utf8"))).toEqual({
619
+ OPENAI_API_KEY: "sk-secret-resolved",
620
+ });
621
+ }
622
+ finally {
623
+ await fs.rm(fx.root, { recursive: true, force: true });
624
+ }
625
+ });
626
+ it("seeds the shared symlink for a secret-bound key when no auth exists yet", async () => {
627
+ const fx = await makeFixture();
628
+ try {
629
+ const result = await reconcileManagedCodexHome({
630
+ companyId: "company-1",
631
+ configuredCodexHome: fx.agentHome,
632
+ apiKeySecretBound: true,
633
+ env: fx.env,
634
+ });
635
+ expect(result.status).toBe("seeded");
636
+ expect((await fs.lstat(fx.agentAuth)).isSymbolicLink()).toBe(true);
637
+ expect(await fs.realpath(fx.agentAuth)).toBe(await fs.realpath(fx.sharedAuth));
638
+ }
639
+ finally {
640
+ await fs.rm(fx.root, { recursive: true, force: true });
641
+ }
642
+ });
643
+ it("writes an API-key auth.json into a managed home when an apiKey is supplied", async () => {
644
+ const fx = await makeFixture();
645
+ try {
646
+ const result = await reconcileManagedCodexHome({
647
+ companyId: "company-1",
648
+ configuredCodexHome: fx.agentHome,
649
+ apiKey: "sk-reconcile-1",
650
+ env: fx.env,
651
+ });
652
+ expect(result.status).toBe("seeded");
653
+ const written = JSON.parse(await fs.readFile(fx.agentAuth, "utf8"));
654
+ expect(written).toEqual({ OPENAI_API_KEY: "sk-reconcile-1" });
655
+ const second = await reconcileManagedCodexHome({
656
+ companyId: "company-1",
657
+ configuredCodexHome: fx.agentHome,
658
+ apiKey: "sk-reconcile-1",
659
+ env: fx.env,
660
+ });
661
+ expect(second.status).toBe("already_seeded");
662
+ expect(JSON.parse(await fs.readFile(fx.agentAuth, "utf8"))).toEqual({
663
+ OPENAI_API_KEY: "sk-reconcile-1",
664
+ });
665
+ }
666
+ finally {
667
+ await fs.rm(fx.root, { recursive: true, force: true });
668
+ }
669
+ });
670
+ });
671
+ describe("evaluateCodexCredentialReadiness", () => {
672
+ async function makeFixture() {
673
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-readiness-"));
674
+ const sharedCodexHome = path.join(root, "shared-codex-home");
675
+ const paperclipHome = path.join(root, "paperclip-home");
676
+ const companyRoot = path.join(paperclipHome, "instances", "default", "companies", "company-1");
677
+ const managedCompanyHome = path.join(companyRoot, "codex-home");
678
+ const managedAgentHome = path.join(companyRoot, "agents", "agent-1", "codex-home");
679
+ const env = {
680
+ CODEX_HOME: sharedCodexHome,
681
+ PAPERCLIP_HOME: paperclipHome,
682
+ PAPERCLIP_INSTANCE_ID: "default",
683
+ };
684
+ await fs.mkdir(sharedCodexHome, { recursive: true });
685
+ return { root, sharedCodexHome, managedCompanyHome, managedAgentHome, env };
686
+ }
687
+ async function writeUsableAuth(home) {
688
+ await fs.mkdir(home, { recursive: true });
689
+ await fs.writeFile(path.join(home, "auth.json"), '{"OPENAI_API_KEY":"sk-live"}\n', "utf8");
690
+ }
691
+ it("flags a managed home with no source auth and empty OPENAI_API_KEY as not ready", async () => {
692
+ const fx = await makeFixture();
693
+ try {
694
+ const result = await evaluateCodexCredentialReadiness({
695
+ env: fx.env,
696
+ companyId: "company-1",
697
+ configuredCodexHome: fx.managedAgentHome,
698
+ configuredApiKey: "",
699
+ });
700
+ expect(result).toMatchObject({ managed: true, authMode: "subscription", ready: false });
701
+ expect(result.effectiveHome).toBe(path.resolve(fx.managedAgentHome));
702
+ }
703
+ finally {
704
+ await fs.rm(fx.root, { recursive: true, force: true });
705
+ }
706
+ });
707
+ it("treats a non-empty resolved OPENAI_API_KEY as ready without touching disk", async () => {
708
+ const fx = await makeFixture();
709
+ try {
710
+ const result = await evaluateCodexCredentialReadiness({
711
+ env: fx.env,
712
+ companyId: "company-1",
713
+ configuredCodexHome: fx.managedAgentHome,
714
+ configuredApiKey: "sk-agent-key",
715
+ });
716
+ expect(result).toMatchObject({ managed: true, authMode: "api", ready: true });
717
+ }
718
+ finally {
719
+ await fs.rm(fx.root, { recursive: true, force: true });
720
+ }
721
+ });
722
+ it("is ready when the shared source home carries usable subscription auth", async () => {
723
+ const fx = await makeFixture();
724
+ try {
725
+ await writeUsableAuth(fx.sharedCodexHome);
726
+ const result = await evaluateCodexCredentialReadiness({
727
+ env: fx.env,
728
+ companyId: "company-1",
729
+ configuredCodexHome: fx.managedAgentHome,
730
+ configuredApiKey: "",
731
+ });
732
+ expect(result).toMatchObject({ managed: true, authMode: "subscription", ready: true });
733
+ }
734
+ finally {
735
+ await fs.rm(fx.root, { recursive: true, force: true });
736
+ }
737
+ });
738
+ it("is ready when the already-seeded effective home carries usable auth", async () => {
739
+ const fx = await makeFixture();
740
+ try {
741
+ await writeUsableAuth(fx.managedAgentHome);
742
+ const result = await evaluateCodexCredentialReadiness({
743
+ env: fx.env,
744
+ companyId: "company-1",
745
+ configuredCodexHome: fx.managedAgentHome,
746
+ configuredApiKey: "",
747
+ });
748
+ expect(result).toMatchObject({ managed: true, authMode: "subscription", ready: true });
749
+ }
750
+ finally {
751
+ await fs.rm(fx.root, { recursive: true, force: true });
752
+ }
753
+ });
754
+ it("defaults to the managed company home when no CODEX_HOME is configured", async () => {
755
+ const fx = await makeFixture();
756
+ try {
757
+ const result = await evaluateCodexCredentialReadiness({
758
+ env: fx.env,
759
+ companyId: "company-1",
760
+ configuredCodexHome: null,
761
+ configuredApiKey: "",
762
+ });
763
+ expect(result).toMatchObject({ managed: true, authMode: "subscription", ready: false });
764
+ expect(result.effectiveHome).toBe(path.resolve(fx.managedCompanyHome));
765
+ }
766
+ finally {
767
+ await fs.rm(fx.root, { recursive: true, force: true });
768
+ }
769
+ });
770
+ it("treats an external/user-supplied CODEX_HOME override as self-managed and ready", async () => {
771
+ const fx = await makeFixture();
772
+ try {
773
+ const externalHome = path.join(fx.root, "user-codex-home");
774
+ await fs.mkdir(externalHome, { recursive: true });
775
+ const result = await evaluateCodexCredentialReadiness({
776
+ env: fx.env,
777
+ companyId: "company-1",
778
+ configuredCodexHome: externalHome,
779
+ configuredApiKey: "",
780
+ });
781
+ expect(result).toMatchObject({ managed: false, ready: true });
782
+ }
783
+ finally {
784
+ await fs.rm(fx.root, { recursive: true, force: true });
785
+ }
786
+ });
787
+ it("replaces the managed MCP block and clears stale servers for an empty runtime set", async () => {
788
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-mcp-config-"));
789
+ try {
790
+ const alphaHome = path.join(root, "agent-alpha");
791
+ const zeroHome = path.join(root, "agent-zero");
792
+ await writeManagedCodexMcpConfig({
793
+ codexHome: alphaHome,
794
+ apiBaseUrl: "https://paperclip.example",
795
+ gateways: [{
796
+ name: "alpha",
797
+ endpointPath: "https://paperclip.example/api/tool-gateway/gateways/alpha/mcp",
798
+ bearerToken: "alpha-token",
799
+ }],
800
+ });
801
+ await writeManagedCodexMcpConfig({
802
+ codexHome: zeroHome,
803
+ apiBaseUrl: "https://paperclip.example",
804
+ gateways: [{
805
+ name: "stale",
806
+ endpointPath: "/api/tool-gateway/gateways/stale/mcp",
807
+ bearerToken: "stale-token",
808
+ }],
809
+ });
810
+ await writeManagedCodexMcpConfig({
811
+ codexHome: zeroHome,
812
+ apiBaseUrl: "https://paperclip.example",
813
+ gateways: [],
814
+ });
815
+ const alpha = await fs.readFile(path.join(alphaHome, "config.toml"), "utf8");
816
+ const zero = await fs.readFile(path.join(zeroHome, "config.toml"), "utf8");
817
+ expect(alpha).toContain('[mcp_servers."alpha"]');
818
+ expect(alpha).toContain('Authorization = "Bearer alpha-token"');
819
+ expect(zero).not.toContain("mcp_servers.");
820
+ expect(zero).not.toContain("stale-token");
821
+ expect(alphaHome).not.toBe(zeroHome);
822
+ }
823
+ finally {
824
+ await fs.rm(root, { recursive: true, force: true });
825
+ }
826
+ });
827
+ it("restricts permissions on an existing managed MCP config", async () => {
828
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-mcp-config-"));
829
+ try {
830
+ const configPath = path.join(root, "config.toml");
831
+ await fs.writeFile(configPath, "model = \"gpt-5\"\n", { mode: 0o644 });
832
+ await writeManagedCodexMcpConfig({
833
+ codexHome: root,
834
+ apiBaseUrl: "https://paperclip.example",
835
+ gateways: [],
836
+ });
837
+ expect((await fs.stat(configPath)).mode & 0o777).toBe(0o600);
838
+ }
839
+ finally {
840
+ await fs.rm(root, { recursive: true, force: true });
841
+ }
842
+ });
843
+ });
844
+ describe("stageCodexHomeForSync", () => {
845
+ afterEach(() => {
846
+ vi.restoreAllMocks();
847
+ });
848
+ // Builds a fake managed CODEX_HOME containing the full allowlist (with
849
+ // `auth.json` as a symlink into a separate source-bytes file and a populated
850
+ // `skills/` symlink, mirroring the real managed home) plus decoy runtime
851
+ // state the allowlist must NOT copy.
852
+ async function buildFakeHome(root) {
853
+ const home = path.join(root, "codex-home");
854
+ const authSource = path.join(root, "shared", "auth.json");
855
+ const skillSource = path.join(root, "shared", "skill-src.md");
856
+ const authBytes = '{"tokens":{"account_id":"acct","refresh_token":"r"}}\n';
857
+ const skillBytes = "# injected skill\n";
858
+ await fs.mkdir(path.join(root, "shared"), { recursive: true });
859
+ await fs.writeFile(authSource, authBytes, "utf8");
860
+ await fs.writeFile(skillSource, skillBytes, "utf8");
861
+ await fs.mkdir(home, { recursive: true });
862
+ // auth.json is a symlink into the shared source (single-use rotating tokens).
863
+ await fs.symlink(authSource, path.join(home, "auth.json"));
864
+ await fs.writeFile(path.join(home, "config.toml"), "model_provider = \"paperclip\"\n", "utf8");
865
+ await fs.writeFile(path.join(home, "config.json"), "{}\n", "utf8");
866
+ await fs.writeFile(path.join(home, "instructions.md"), "hi\n", "utf8");
867
+ // skills/ is a directory of symlinks.
868
+ await fs.mkdir(path.join(home, "skills"), { recursive: true });
869
+ await fs.symlink(skillSource, path.join(home, "skills", "demo.md"));
870
+ // Decoys: large runtime state the 4-name denylist missed.
871
+ await fs.writeFile(path.join(home, "logs_2.sqlite"), "x", "utf8");
872
+ await fs.writeFile(path.join(home, "state_5.sqlite"), "x", "utf8");
873
+ await fs.mkdir(path.join(home, "plugins", "cache"), { recursive: true });
874
+ await fs.writeFile(path.join(home, "plugins", "cache", "x"), "x", "utf8");
875
+ await fs.mkdir(path.join(home, "sessions"), { recursive: true });
876
+ await fs.writeFile(path.join(home, "sessions", "y"), "x", "utf8");
877
+ await fs.mkdir(path.join(home, "tmp"), { recursive: true });
878
+ await fs.symlink("/usr/bin/env", path.join(home, "tmp", "arg0"));
879
+ return { home, authBytes, skillBytes };
880
+ }
881
+ it("stages exactly the allowlist, derefs auth.json to bytes, and excludes decoys", async () => {
882
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-"));
883
+ let staged = null;
884
+ try {
885
+ const { home, authBytes, skillBytes } = await buildFakeHome(root);
886
+ staged = await stageCodexHomeForSync(home, { runId: "run-1" });
887
+ const entries = (await fs.readdir(staged)).sort();
888
+ expect(entries).toEqual([...CODEX_SYNC_ALLOWLIST].sort());
889
+ // Decoys must be absent.
890
+ for (const decoy of ["logs_2.sqlite", "state_5.sqlite", "plugins", "sessions", "tmp"]) {
891
+ expect(entries).not.toContain(decoy);
892
+ }
893
+ // auth.json is a regular file (symlink dereferenced) whose bytes equal the target.
894
+ const stagedAuth = path.join(staged, "auth.json");
895
+ expect((await fs.lstat(stagedAuth)).isSymbolicLink()).toBe(false);
896
+ expect(await fs.readFile(stagedAuth, "utf8")).toBe(authBytes);
897
+ // skills/ copied recursively with the symlink dereferenced to bytes.
898
+ const stagedSkill = path.join(staged, "skills", "demo.md");
899
+ expect((await fs.lstat(stagedSkill)).isSymbolicLink()).toBe(false);
900
+ expect(await fs.readFile(stagedSkill, "utf8")).toBe(skillBytes);
901
+ // config.toml (post-rewrite state) carried through.
902
+ expect(await fs.readFile(path.join(staged, "config.toml"), "utf8")).toContain("model_provider");
903
+ }
904
+ finally {
905
+ if (staged)
906
+ await fs.rm(staged, { recursive: true, force: true });
907
+ await fs.rm(root, { recursive: true, force: true });
908
+ }
909
+ });
910
+ // C1 — staged credential file must be mode 0600 (not the world-readable default).
911
+ it("writes the staged auth.json with mode 0600", async () => {
912
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-mode-"));
913
+ let staged = null;
914
+ try {
915
+ const { home } = await buildFakeHome(root);
916
+ staged = await stageCodexHomeForSync(home, { runId: "run-mode" });
917
+ const mode = (await fs.stat(path.join(staged, "auth.json"))).mode & 0o777;
918
+ expect(mode).toBe(0o600);
919
+ }
920
+ finally {
921
+ if (staged)
922
+ await fs.rm(staged, { recursive: true, force: true });
923
+ await fs.rm(root, { recursive: true, force: true });
924
+ }
925
+ });
926
+ // config.toml carries the managed MCP `Authorization: Bearer …` header and is
927
+ // secret-bearing; the staged copy must be 0600, not the world-readable default.
928
+ it("writes the staged config.toml (managed MCP bearer header) with mode 0600", async () => {
929
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-toml-mode-"));
930
+ let staged = null;
931
+ try {
932
+ const home = path.join(root, "codex-home");
933
+ await fs.mkdir(home, { recursive: true });
934
+ // Mirror the source writer: config.toml holds an MCP gateway bearer token
935
+ // and is persisted 0600 on disk.
936
+ await fs.writeFile(path.join(home, "config.toml"), "[mcp_servers.paperclip]\nheaders = { Authorization = \"Bearer secret-token\" }\n", { mode: 0o600 });
937
+ staged = await stageCodexHomeForSync(home, { runId: "run-toml-mode" });
938
+ const mode = (await fs.stat(path.join(staged, "config.toml"))).mode & 0o777;
939
+ expect(mode).toBe(0o600);
940
+ }
941
+ finally {
942
+ if (staged)
943
+ await fs.rm(staged, { recursive: true, force: true });
944
+ await fs.rm(root, { recursive: true, force: true });
945
+ }
946
+ });
947
+ // Least privilege: no staged regular file needs group/other read, so every
948
+ // one (config.json, instructions.md — not just credentials) is staged 0600.
949
+ it("writes every staged regular file with mode 0600", async () => {
950
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-all-mode-"));
951
+ let staged = null;
952
+ try {
953
+ const { home } = await buildFakeHome(root);
954
+ staged = await stageCodexHomeForSync(home, { runId: "run-all-mode" });
955
+ for (const entry of ["auth.json", "config.toml", "config.json", "instructions.md"]) {
956
+ const mode = (await fs.stat(path.join(staged, entry))).mode & 0o777;
957
+ expect(mode, `${entry} should be staged 0600`).toBe(0o600);
958
+ }
959
+ }
960
+ finally {
961
+ if (staged)
962
+ await fs.rm(staged, { recursive: true, force: true });
963
+ await fs.rm(root, { recursive: true, force: true });
964
+ }
965
+ });
966
+ // C2 — staged dir must be 0700 (mkdtemp guarantees this on POSIX).
967
+ it("creates the staged dir with mode 0700", async () => {
968
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-dir-"));
969
+ let staged = null;
970
+ try {
971
+ const { home } = await buildFakeHome(root);
972
+ staged = await stageCodexHomeForSync(home, { runId: "run-dir" });
973
+ const mode = (await fs.stat(staged)).mode & 0o777;
974
+ expect(mode).toBe(0o700);
975
+ }
976
+ finally {
977
+ if (staged)
978
+ await fs.rm(staged, { recursive: true, force: true });
979
+ await fs.rm(root, { recursive: true, force: true });
980
+ }
981
+ });
982
+ it("skips absent optional entries without throwing", async () => {
983
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-absent-"));
984
+ let staged = null;
985
+ try {
986
+ // Keyring-credential mode: no auth.json, no config.json.
987
+ const home = path.join(root, "codex-home");
988
+ await fs.mkdir(home, { recursive: true });
989
+ await fs.writeFile(path.join(home, "config.toml"), "x\n", "utf8");
990
+ staged = await stageCodexHomeForSync(home, { runId: "run-absent" });
991
+ const entries = await fs.readdir(staged);
992
+ expect(entries).toEqual(["config.toml"]);
993
+ }
994
+ finally {
995
+ if (staged)
996
+ await fs.rm(staged, { recursive: true, force: true });
997
+ await fs.rm(root, { recursive: true, force: true });
998
+ }
999
+ });
1000
+ it("treats a dangling auth.json symlink as absent (skips it, no throw)", async () => {
1001
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-dangling-"));
1002
+ let staged = null;
1003
+ try {
1004
+ const home = path.join(root, "codex-home");
1005
+ await fs.mkdir(home, { recursive: true });
1006
+ await fs.symlink(path.join(root, "gone", "auth.json"), path.join(home, "auth.json"));
1007
+ await fs.writeFile(path.join(home, "config.toml"), "x\n", "utf8");
1008
+ staged = await stageCodexHomeForSync(home, { runId: "run-dangling" });
1009
+ expect(await fs.readdir(staged)).toEqual(["config.toml"]);
1010
+ }
1011
+ finally {
1012
+ if (staged)
1013
+ await fs.rm(staged, { recursive: true, force: true });
1014
+ await fs.rm(root, { recursive: true, force: true });
1015
+ }
1016
+ });
1017
+ // C3 + C4 — an unexpected I/O error must reject (fail-closed, not partial)
1018
+ // AND remove the temp dir it created (cleanup on the error path).
1019
+ it("fails closed and removes the temp dir on an unexpected I/O error", async () => {
1020
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-fail-"));
1021
+ try {
1022
+ const { home } = await buildFakeHome(root);
1023
+ let createdDir = null;
1024
+ const realMkdtemp = fs.mkdtemp.bind(fs);
1025
+ vi.spyOn(fs, "mkdtemp").mockImplementation(async (prefix, ...rest) => {
1026
+ const dir = await realMkdtemp(prefix, ...rest);
1027
+ createdDir = dir;
1028
+ return dir;
1029
+ });
1030
+ vi.spyOn(fs, "readFile").mockRejectedValue(Object.assign(new Error("boom"), { code: "EACCES" }));
1031
+ await expect(stageCodexHomeForSync(home, { runId: "run-fail" })).rejects.toThrow("boom");
1032
+ expect(createdDir).not.toBeNull();
1033
+ // The staged temp dir was cleaned up despite the failure.
1034
+ await expect(fs.access(createdDir)).rejects.toMatchObject({ code: "ENOENT" });
1035
+ }
1036
+ finally {
1037
+ await fs.rm(root, { recursive: true, force: true });
1038
+ }
1039
+ });
1040
+ // Circular symlinks inside skills/ must be silently skipped (not throw ELOOP).
1041
+ // Skill symlinks that point OUTSIDE skills/ are intentional design (Paperclip
1042
+ // stores skill packages in a shared location) and are dereferenced normally;
1043
+ // all resulting files land 0600 inside the 0700 staged dir.
1044
+ it("skips circular skill symlinks (ELOOP) without throwing", async () => {
1045
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-circular-"));
1046
+ let staged = null;
1047
+ try {
1048
+ const home = path.join(root, "codex-home");
1049
+ await fs.mkdir(path.join(home, "skills"), { recursive: true });
1050
+ // Self-referential symlink: would loop forever — must be skipped.
1051
+ const circularLink = path.join(home, "skills", "loop.md");
1052
+ await fs.symlink(circularLink, circularLink);
1053
+ // A normal skill file — must still be staged.
1054
+ await fs.writeFile(path.join(home, "skills", "legit.md"), "# ok\n", "utf8");
1055
+ staged = await stageCodexHomeForSync(home, { runId: "run-circular" });
1056
+ const stagedSkillEntries = await fs.readdir(path.join(staged, "skills"));
1057
+ // Circular link must be absent.
1058
+ expect(stagedSkillEntries).not.toContain("loop.md");
1059
+ // Normal skill file must be present.
1060
+ expect(stagedSkillEntries).toContain("legit.md");
1061
+ }
1062
+ finally {
1063
+ if (staged)
1064
+ await fs.rm(staged, { recursive: true, force: true });
1065
+ await fs.rm(root, { recursive: true, force: true });
1066
+ }
1067
+ });
1068
+ // Mode normalization: nested skill files must be staged 0600 regardless of
1069
+ // their source mode (0644 documents, 0755 scripts, etc.).
1070
+ it("writes nested skill files with mode 0600 regardless of source mode", async () => {
1071
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-skill-mode-"));
1072
+ let staged = null;
1073
+ try {
1074
+ const home = path.join(root, "codex-home");
1075
+ await fs.mkdir(path.join(home, "skills", "my-skill"), { recursive: true });
1076
+ // Typical source modes: readable doc (0644) and executable script (0755);
1077
+ // both must land 0600 in the staged dir.
1078
+ await fs.writeFile(path.join(home, "skills", "my-skill", "SKILL.md"), "# skill\n", { mode: 0o644 });
1079
+ await fs.writeFile(path.join(home, "skills", "my-skill", "run.sh"), "#!/bin/sh\n", { mode: 0o755 });
1080
+ staged = await stageCodexHomeForSync(home, { runId: "run-skill-mode" });
1081
+ for (const rel of ["my-skill/SKILL.md", "my-skill/run.sh"]) {
1082
+ const mode = (await fs.stat(path.join(staged, "skills", rel))).mode & 0o777;
1083
+ expect(mode, `skills/${rel} should be staged 0600`).toBe(0o600);
1084
+ }
1085
+ }
1086
+ finally {
1087
+ if (staged)
1088
+ await fs.rm(staged, { recursive: true, force: true });
1089
+ await fs.rm(root, { recursive: true, force: true });
1090
+ }
1091
+ });
1092
+ // Finding 1 (Greptile): a directory symlink such as `back -> .` inside a skill
1093
+ // resolves to an ancestor directory (it does NOT raise ELOOP), so naive
1094
+ // recursion would traverse the same tree forever until disk/memory is
1095
+ // exhausted. Cycle detection must let staging finish while still copying the
1096
+ // real content and skipping the self-referential link.
1097
+ it("does not infinitely traverse an ancestor directory link (back -> .) inside a skill", async () => {
1098
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-cycle-"));
1099
+ let staged = null;
1100
+ try {
1101
+ const home = path.join(root, "codex-home");
1102
+ const skillDir = path.join(home, "skills", "my-skill");
1103
+ await fs.mkdir(skillDir, { recursive: true });
1104
+ await fs.writeFile(path.join(skillDir, "SKILL.md"), "# skill\n", "utf8");
1105
+ // Directory symlink pointing at the skill's own dir — resolves to a
1106
+ // directory already on the active traversal path; must be skipped.
1107
+ await fs.symlink(".", path.join(skillDir, "back"));
1108
+ staged = await stageCodexHomeForSync(home, { runId: "run-cycle" });
1109
+ // Completed without hanging; the real file is staged and the cyclic link
1110
+ // produced no runaway nested `back/back/…` chain (it is skipped entirely).
1111
+ expect(await fs.readdir(path.join(staged, "skills", "my-skill"))).toEqual(["SKILL.md"]);
1112
+ expect(await fs.readFile(path.join(staged, "skills", "my-skill", "SKILL.md"), "utf8")).toBe("# skill\n");
1113
+ }
1114
+ finally {
1115
+ if (staged)
1116
+ await fs.rm(staged, { recursive: true, force: true });
1117
+ await fs.rm(root, { recursive: true, force: true });
1118
+ }
1119
+ });
1120
+ // Finding 2 (Greptile): a symlink *inside* a skill that points to a host file
1121
+ // or directory OUTSIDE that skill (e.g. `~/.ssh/id_rsa`) must NOT be
1122
+ // dereferenced into the staged asset — otherwise a malformed/compromised skill
1123
+ // could smuggle host secrets past CODEX_SYNC_ALLOWLIST.
1124
+ it("does not stage a nested skill symlink that escapes the skill root", async () => {
1125
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-escape-"));
1126
+ let staged = null;
1127
+ try {
1128
+ const home = path.join(root, "codex-home");
1129
+ const skillDir = path.join(home, "skills", "my-skill");
1130
+ await fs.mkdir(skillDir, { recursive: true });
1131
+ await fs.writeFile(path.join(skillDir, "SKILL.md"), "# skill\n", "utf8");
1132
+ // A host "secret" file and directory living OUTSIDE the skill dir.
1133
+ const secretFile = path.join(root, "host-secret.txt");
1134
+ await fs.writeFile(secretFile, "TOP SECRET\n", "utf8");
1135
+ const secretDir = path.join(root, "host-secret-dir");
1136
+ await fs.mkdir(secretDir, { recursive: true });
1137
+ await fs.writeFile(path.join(secretDir, "creds"), "creds\n", "utf8");
1138
+ // Nested symlinks inside the skill escaping to those host paths.
1139
+ await fs.symlink(secretFile, path.join(skillDir, "stolen.txt"));
1140
+ await fs.symlink(secretDir, path.join(skillDir, "stolen-dir"));
1141
+ staged = await stageCodexHomeForSync(home, { runId: "run-escape" });
1142
+ const stagedEntries = await fs.readdir(path.join(staged, "skills", "my-skill"));
1143
+ // The legit in-skill file is staged…
1144
+ expect(stagedEntries).toContain("SKILL.md");
1145
+ // …but neither escaping link is followed into the staged asset.
1146
+ expect(stagedEntries).not.toContain("stolen.txt");
1147
+ expect(stagedEntries).not.toContain("stolen-dir");
1148
+ }
1149
+ finally {
1150
+ if (staged)
1151
+ await fs.rm(staged, { recursive: true, force: true });
1152
+ await fs.rm(root, { recursive: true, force: true });
1153
+ }
1154
+ });
1155
+ // Defense-in-depth for finding 1: a degenerate top-level `skills/<x> -> ..`
1156
+ // link resolves to an ancestor of `skills/` (the home). It must be skipped,
1157
+ // never adopted as a containment root — otherwise the whole home
1158
+ // (`sessions/`, `*.sqlite`, …) would be dragged into the staged skills asset.
1159
+ it("skips a top-level skills entry that resolves to an ancestor of skills/", async () => {
1160
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-stage-ancestor-"));
1161
+ let staged = null;
1162
+ try {
1163
+ const home = path.join(root, "codex-home");
1164
+ await fs.mkdir(path.join(home, "skills"), { recursive: true });
1165
+ await fs.writeFile(path.join(home, "skills", "legit.md"), "# ok\n", "utf8");
1166
+ // Runtime state in the home that must never reach the staged asset.
1167
+ await fs.writeFile(path.join(home, "logs.sqlite"), "x", "utf8");
1168
+ // `up -> ..` resolves to the home dir (an ancestor of skills/).
1169
+ await fs.symlink("..", path.join(home, "skills", "up"));
1170
+ staged = await stageCodexHomeForSync(home, { runId: "run-ancestor" });
1171
+ const stagedSkillEntries = await fs.readdir(path.join(staged, "skills"));
1172
+ expect(stagedSkillEntries).toContain("legit.md");
1173
+ // The ancestor link is skipped, so the home's runtime state is not dragged in.
1174
+ expect(stagedSkillEntries).not.toContain("up");
1175
+ }
1176
+ finally {
1177
+ if (staged)
1178
+ await fs.rm(staged, { recursive: true, force: true });
1179
+ await fs.rm(root, { recursive: true, force: true });
1180
+ }
1181
+ });
1182
+ });
1183
+ //# sourceMappingURL=codex-home.test.js.map