@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,771 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { resolvePaperclipInstanceRootForAdapter } from "@tickernelz/paperclip-pro-adapter-utils/server-utils";
5
+ import { isCodexAuthCachePath, readSubscriptionAccountId } from "./codex-auth-cache.js";
6
+ const TRUTHY_ENV_RE = /^(1|true|yes|on)$/i;
7
+ const COPIED_SHARED_FILES = ["config.json", "config.toml", "instructions.md"];
8
+ const SYMLINKED_SHARED_FILES = ["auth.json"];
9
+ const MANAGED_MCP_BLOCK_START = "# BEGIN PAPERCLIP MANAGED MCP";
10
+ const MANAGED_MCP_BLOCK_END = "# END PAPERCLIP MANAGED MCP";
11
+ /**
12
+ * The allowlist of managed `CODEX_HOME` entries that the codex-local adapter
13
+ * stages into the sandbox `home` asset (see {@link stageCodexHomeForSync}).
14
+ * Derived from the seeding constants so it can never drift from what the adapter
15
+ * actually writes into the home: the copied static config files, the symlinked
16
+ * credential file, and the injected `skills/` directory. Everything else the
17
+ * stock upstream `codex` binary writes at runtime (`*.sqlite`, `*-wal`,
18
+ * `plugins/`, `cache/`, `sessions/`, `shell_snapshots/`, …) is intentionally
19
+ * excluded — it is large host-local runtime state the sandbox run never needs.
20
+ */
21
+ export const CODEX_SYNC_ALLOWLIST = [
22
+ ...COPIED_SHARED_FILES,
23
+ ...SYMLINKED_SHARED_FILES,
24
+ "skills",
25
+ ];
26
+ export function mergeManagedCodexMcpGateways(primary, secondary) {
27
+ const merged = [...primary];
28
+ const names = new Set(primary.map((gateway) => gateway.name));
29
+ for (const gateway of secondary) {
30
+ if (names.has(gateway.name))
31
+ continue;
32
+ merged.push(gateway);
33
+ names.add(gateway.name);
34
+ }
35
+ return merged;
36
+ }
37
+ function nonEmpty(value) {
38
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
39
+ }
40
+ export async function pathExists(candidate) {
41
+ return fs.access(candidate).then(() => true).catch(() => false);
42
+ }
43
+ // Co-change notice: this function's logic is mirrored by parseAuth in
44
+ // packages/adapter-utils/src/sandbox-managed-runtime.ts (buildCodexAuthMergeDecisionScript).
45
+ // If the auth format changes (new shape, renamed field), update both sites together.
46
+ function hasUsableAuthPayload(authPayload) {
47
+ if (authPayload === null || typeof authPayload !== "object" || Array.isArray(authPayload)) {
48
+ return false;
49
+ }
50
+ const parsedPayload = authPayload;
51
+ const apiKey = parsedPayload.OPENAI_API_KEY;
52
+ if (typeof apiKey === "string" && apiKey.trim().length > 0) {
53
+ return true;
54
+ }
55
+ const tokens = parsedPayload.tokens;
56
+ if (tokens !== null && typeof tokens === "object" && !Array.isArray(tokens)) {
57
+ const parsedTokens = tokens;
58
+ const accountId = parsedTokens.account_id;
59
+ const hasAccountId = typeof accountId === "string" && accountId.trim().length > 0;
60
+ const hasTokenMaterial = ["id_token", "access_token", "refresh_token"].some((key) => {
61
+ const value = parsedTokens[key];
62
+ return typeof value === "string" && value.trim().length > 0;
63
+ });
64
+ if (hasAccountId && hasTokenMaterial)
65
+ return true;
66
+ }
67
+ return false;
68
+ }
69
+ function readApiKeyFromAuthPayload(authPayload) {
70
+ if (authPayload === null || typeof authPayload !== "object" || Array.isArray(authPayload)) {
71
+ return null;
72
+ }
73
+ const raw = authPayload.OPENAI_API_KEY;
74
+ return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : null;
75
+ }
76
+ /**
77
+ * The `last_refresh` timestamp of an auth.json payload, in epoch milliseconds,
78
+ * or null when the bytes are unreadable or carry no parseable timestamp. This is
79
+ * the same freshness field the shared merge decision predicate
80
+ * (`codex-auth-merge-decision.cjs`) compares, read the same way, so the seeding
81
+ * heal below and the credential writers agree on what "fresher" means.
82
+ */
83
+ function readAuthLastRefreshMs(bytes) {
84
+ if (!bytes)
85
+ return null;
86
+ let parsed;
87
+ try {
88
+ parsed = JSON.parse(bytes.toString("utf8"));
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
94
+ return null;
95
+ }
96
+ const raw = parsed.last_refresh;
97
+ const ms = typeof raw === "string" ? Date.parse(raw) : NaN;
98
+ return Number.isFinite(ms) ? ms : null;
99
+ }
100
+ export function resolveSharedCodexHomeDir(env = process.env) {
101
+ const fromEnv = nonEmpty(env.CODEX_HOME);
102
+ return fromEnv ? path.resolve(fromEnv) : path.join(os.homedir(), ".codex");
103
+ }
104
+ function isWorktreeMode(env) {
105
+ return TRUTHY_ENV_RE.test(env.PAPERCLIP_IN_WORKTREE ?? "");
106
+ }
107
+ export function resolveManagedCodexHomeDir(env, companyId) {
108
+ const instanceRoot = resolvePaperclipInstanceRootForAdapter({
109
+ homeDir: nonEmpty(env.PAPERCLIP_HOME) ?? undefined,
110
+ instanceId: nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? undefined,
111
+ env,
112
+ });
113
+ return companyId
114
+ ? path.resolve(instanceRoot, "companies", companyId, "codex-home")
115
+ : path.resolve(instanceRoot, "codex-home");
116
+ }
117
+ /**
118
+ * True when `homePath` lives under the Paperclip-managed company tree
119
+ * (`<instanceRoot>/companies/<companyId>/...`). This covers both the shared
120
+ * company `codex-home` and the per-agent `agents/<agentId>/codex-home` set by
121
+ * the server-side isolation guard. A path outside that tree is a genuine
122
+ * external/user-supplied override that Paperclip must not seed or overwrite.
123
+ */
124
+ export function isManagedCodexHomePath(env, companyId, homePath) {
125
+ if (!companyId)
126
+ return false;
127
+ const instanceRoot = resolvePaperclipInstanceRootForAdapter({
128
+ homeDir: nonEmpty(env.PAPERCLIP_HOME) ?? undefined,
129
+ instanceId: nonEmpty(env.PAPERCLIP_INSTANCE_ID) ?? undefined,
130
+ env,
131
+ });
132
+ const companyRoot = path.resolve(instanceRoot, "companies", companyId);
133
+ const resolved = path.resolve(homePath);
134
+ return resolved === companyRoot || resolved.startsWith(companyRoot + path.sep);
135
+ }
136
+ /**
137
+ * True when the Codex home has a usable `auth.json`. Uses `fs.access` (follows
138
+ * symlinks), so a dangling auth symlink whose source has been removed counts as
139
+ * no usable credentials.
140
+ */
141
+ export async function codexHomeHasUsableAuth(home) {
142
+ const authPath = path.join(home, "auth.json");
143
+ if (!(await pathExists(authPath)))
144
+ return false;
145
+ try {
146
+ const raw = await fs.readFile(authPath, "utf8");
147
+ const parsed = JSON.parse(raw);
148
+ return hasUsableAuthPayload(parsed);
149
+ }
150
+ catch {
151
+ return false;
152
+ }
153
+ }
154
+ async function codexHomeHasMatchingApiKeyAuth(home, apiKey) {
155
+ const authPath = path.join(home, "auth.json");
156
+ const existing = await fs.lstat(authPath).catch(() => null);
157
+ if (!existing || existing.isSymbolicLink())
158
+ return false;
159
+ try {
160
+ const raw = await fs.readFile(authPath, "utf8");
161
+ const parsed = JSON.parse(raw);
162
+ return readApiKeyFromAuthPayload(parsed) === apiKey.trim();
163
+ }
164
+ catch {
165
+ return false;
166
+ }
167
+ }
168
+ async function ensureParentDir(target) {
169
+ await fs.mkdir(path.dirname(target), { recursive: true });
170
+ }
171
+ async function isExpectedSymlink(target, source) {
172
+ const existing = await fs.lstat(target).catch(() => null);
173
+ if (!existing?.isSymbolicLink())
174
+ return false;
175
+ const linkedPath = await fs.readlink(target).catch(() => null);
176
+ if (!linkedPath)
177
+ return false;
178
+ return path.resolve(path.dirname(target), linkedPath) === path.resolve(source);
179
+ }
180
+ async function createExpectedSymlink(target, source) {
181
+ try {
182
+ await fs.symlink(source, target);
183
+ }
184
+ catch (error) {
185
+ const code = error.code;
186
+ if (code === "EEXIST" && await isExpectedSymlink(target, source))
187
+ return;
188
+ throw error;
189
+ }
190
+ }
191
+ export async function ensureSymlink(target, source) {
192
+ const existing = await fs.lstat(target).catch(() => null);
193
+ if (!existing) {
194
+ await ensureParentDir(target);
195
+ await createExpectedSymlink(target, source);
196
+ return;
197
+ }
198
+ if (!existing.isSymbolicLink()) {
199
+ // A previous Paperclip version copied this file into the managed home
200
+ // instead of symlinking it. Codex refresh tokens rotate and are
201
+ // single-use, so a stale copy fails with refresh_token_reused on the next
202
+ // run (#5028). Replace the regular file with a symlink so the CLI follows
203
+ // the live source. Safe to delete: target is always under the
204
+ // Paperclip-managed company home, never the user's real ~/.codex.
205
+ // Directories are left alone — `fs.unlink` would throw EISDIR on Unix
206
+ // (and behave inconsistently on Windows). A directory at this path is not
207
+ // a Paperclip-written stale copy and warrants operator inspection rather
208
+ // than silent removal.
209
+ if (existing.isDirectory())
210
+ return;
211
+ await fs.unlink(target);
212
+ await createExpectedSymlink(target, source);
213
+ return;
214
+ }
215
+ if (await isExpectedSymlink(target, source))
216
+ return;
217
+ await fs.unlink(target);
218
+ await createExpectedSymlink(target, source);
219
+ }
220
+ async function ensureCopiedFile(target, source) {
221
+ const existing = await fs.lstat(target).catch(() => null);
222
+ if (existing)
223
+ return;
224
+ await ensureParentDir(target);
225
+ await fs.copyFile(source, target);
226
+ }
227
+ function tomlString(value) {
228
+ return JSON.stringify(value);
229
+ }
230
+ function sanitizeMcpServerName(value, fallback) {
231
+ return value
232
+ .trim()
233
+ .toLowerCase()
234
+ .replace(/[^a-z0-9_-]+/g, "-")
235
+ .replace(/^-+|-+$/g, "")
236
+ .slice(0, 80) || fallback;
237
+ }
238
+ function stripManagedMcpBlock(config) {
239
+ const start = config.indexOf(MANAGED_MCP_BLOCK_START);
240
+ if (start < 0)
241
+ return config.trimEnd();
242
+ const end = config.indexOf(MANAGED_MCP_BLOCK_END, start);
243
+ if (end < 0)
244
+ return config.slice(0, start).trimEnd();
245
+ return `${config.slice(0, start)}${config.slice(end + MANAGED_MCP_BLOCK_END.length)}`.trimEnd();
246
+ }
247
+ function readCodexMcpServerNames(config) {
248
+ const names = new Set();
249
+ for (const match of config.matchAll(/^\s*\[\s*mcp_servers\s*\.\s*(?:"([^"]+)"|'([^']+)'|([^\]\s#]+))\s*\]/gm)) {
250
+ const name = match[1] ?? match[2] ?? match[3];
251
+ if (name)
252
+ names.add(name.trim());
253
+ }
254
+ return names;
255
+ }
256
+ function buildManagedMcpBlock(input) {
257
+ const warnings = [];
258
+ const usedNames = new Set();
259
+ const lines = [
260
+ MANAGED_MCP_BLOCK_START,
261
+ "# Written by Paperclip for governed MCP gateway access. Do not edit this block by hand.",
262
+ ];
263
+ input.gateways.forEach((gateway, index) => {
264
+ const baseName = sanitizeMcpServerName(gateway.name, `gateway-${index + 1}`);
265
+ const directOverlap = input.existingNames.has(gateway.name) || input.existingNames.has(baseName);
266
+ let managedName = directOverlap ? `paperclip-${baseName}` : baseName;
267
+ let suffix = 2;
268
+ while (usedNames.has(managedName) || input.existingNames.has(managedName)) {
269
+ managedName = `paperclip-${baseName}-${suffix}`;
270
+ suffix += 1;
271
+ }
272
+ usedNames.add(managedName);
273
+ if (directOverlap) {
274
+ warnings.push(`Found unmanaged Codex MCP server "${gateway.name}" overlapping a Paperclip-governed gateway; leaving the direct entry in place and adding managed gateway "${managedName}". Paperclip cannot enforce policies for that direct entry.`);
275
+ }
276
+ const url = new URL(gateway.endpointPath, input.apiBaseUrl).toString();
277
+ lines.push("", `[mcp_servers.${tomlString(managedName)}]`, `url = ${tomlString(url)}`, `headers = { Authorization = ${tomlString(`Bearer ${gateway.bearerToken}`)} }`);
278
+ });
279
+ lines.push(MANAGED_MCP_BLOCK_END);
280
+ return { block: lines.join("\n"), warnings };
281
+ }
282
+ export async function writeManagedCodexMcpConfig(input) {
283
+ const configPath = path.join(input.codexHome, "config.toml");
284
+ await fs.mkdir(input.codexHome, { recursive: true });
285
+ const existing = await fs.readFile(configPath, "utf8").catch((error) => {
286
+ if (error.code === "ENOENT")
287
+ return "";
288
+ throw error;
289
+ });
290
+ const unmanagedConfig = stripManagedMcpBlock(existing);
291
+ const { block, warnings } = buildManagedMcpBlock({
292
+ gateways: input.gateways,
293
+ apiBaseUrl: input.apiBaseUrl,
294
+ existingNames: readCodexMcpServerNames(unmanagedConfig),
295
+ });
296
+ const next = input.gateways.length > 0
297
+ ? `${unmanagedConfig}${unmanagedConfig ? "\n\n" : ""}${block}\n`
298
+ : `${unmanagedConfig}${unmanagedConfig ? "\n" : ""}`;
299
+ await fs.writeFile(configPath, next, { mode: 0o600 });
300
+ await fs.chmod(configPath, 0o600);
301
+ return { configPath, warnings };
302
+ }
303
+ /**
304
+ * Writes an `auth.json` containing only `OPENAI_API_KEY` so the codex CLI can
305
+ * authenticate via API key. Overwrites any existing file or symlink at that
306
+ * path. Required because the codex CLI (>= 0.122) ignores the `OPENAI_API_KEY`
307
+ * environment variable and only reads credentials from `$CODEX_HOME/auth.json`.
308
+ */
309
+ export async function writeApiKeyAuthJson(home, apiKey) {
310
+ await fs.mkdir(home, { recursive: true });
311
+ const target = path.join(home, "auth.json");
312
+ await fs.rm(target, { force: true });
313
+ await fs.writeFile(target, JSON.stringify({ OPENAI_API_KEY: apiKey }), { mode: 0o600 });
314
+ }
315
+ /**
316
+ * True when `candidate` is `root` itself or a descendant of it. Both arguments
317
+ * must be absolute, already-resolved (symlink-free) paths — callers pass
318
+ * `fs.realpath` output — so `path.relative` is a reliable containment test that
319
+ * is not fooled by `..` segments or a trailing-separator prefix collision
320
+ * (`/a/skills` vs `/a/skills-evil`).
321
+ */
322
+ function isResolvedPathInside(candidate, root) {
323
+ if (candidate === root)
324
+ return true;
325
+ const rel = path.relative(root, candidate);
326
+ return rel.length > 0 && !rel.startsWith("..") && !path.isAbsolute(rel);
327
+ }
328
+ /**
329
+ * Recursively copies one skill subtree — rooted at its real directory
330
+ * `containmentRoot` — into `targetDir`, dereferencing symlinks to bytes (so the
331
+ * sandbox receives real file content, not host-relative links) and normalizing
332
+ * every copied regular file to mode `0600`. Created directories get mode `0700`.
333
+ *
334
+ * Two containment guards protect the staged upload:
335
+ *
336
+ * - **Allowlist escape (finding 2).** After dereferencing, a symlink whose real
337
+ * target falls *outside* `containmentRoot` is skipped. A malformed or
338
+ * compromised skill could otherwise smuggle host files that are not in
339
+ * `CODEX_SYNC_ALLOWLIST` (e.g. `~/.ssh/id_rsa`) into the upload by pointing a
340
+ * nested link at them. The skill's own top-level link into the shared skill
341
+ * store is still honoured — it is what establishes `containmentRoot` in
342
+ * {@link stageDirectorySecure}; only links that escape *that* root are cut.
343
+ * - **Directory cycles (finding 1).** A directory symlink such as `back -> .` or
344
+ * `back -> ..` resolves to an ancestor directory instead of raising `ELOOP`;
345
+ * recursing into it would traverse the same tree forever until disk/memory is
346
+ * exhausted. A resolved directory already on the active traversal path
347
+ * (`activePath`) is therefore skipped.
348
+ *
349
+ * Dangling symlinks (`ENOENT`) and self-referential links that do trip `ELOOP`
350
+ * are silently skipped, as are non-file/dir entries (sockets, devices).
351
+ */
352
+ async function stageContainedSubtree(sourceDir, targetDir, containmentRoot, activePath) {
353
+ await fs.mkdir(targetDir, { recursive: true, mode: 0o700 });
354
+ const entries = await fs.readdir(sourceDir, { withFileTypes: true });
355
+ for (const entry of entries) {
356
+ const entrySource = path.join(sourceDir, entry.name);
357
+ const entryTarget = path.join(targetDir, entry.name);
358
+ // Resolve the real path; dangling or self-referential (`ELOOP`) links skip.
359
+ const resolved = await fs.realpath(entrySource).catch((error) => {
360
+ const code = error.code;
361
+ if (code === "ENOENT" || code === "ELOOP")
362
+ return null;
363
+ throw error;
364
+ });
365
+ if (!resolved)
366
+ continue;
367
+ // Allowlist containment: never dereference a link that escapes this skill's
368
+ // real root (host files outside CODEX_SYNC_ALLOWLIST) into the upload.
369
+ if (!isResolvedPathInside(resolved, containmentRoot))
370
+ continue;
371
+ const entryStat = await fs.stat(resolved).catch((error) => {
372
+ if (error.code === "ENOENT")
373
+ return null;
374
+ throw error;
375
+ });
376
+ if (!entryStat)
377
+ continue;
378
+ if (entryStat.isDirectory()) {
379
+ // Cycle guard: a directory already open on the active path (reached via a
380
+ // `back -> .`-style link) would otherwise recurse forever.
381
+ if (activePath.has(resolved))
382
+ continue;
383
+ activePath.add(resolved);
384
+ await stageContainedSubtree(resolved, entryTarget, containmentRoot, activePath);
385
+ activePath.delete(resolved);
386
+ }
387
+ else if (entryStat.isFile()) {
388
+ const bytes = await fs.readFile(resolved);
389
+ await fs.writeFile(entryTarget, bytes, { mode: 0o600 });
390
+ await fs.chmod(entryTarget, 0o600);
391
+ }
392
+ // Other types (sockets, devices) are silently skipped.
393
+ }
394
+ }
395
+ /**
396
+ * Recursively copies `sourceDir` (a directory allowlist entry — currently only
397
+ * `skills/`) into `targetDir`, dereferencing symlinks to bytes and normalizing
398
+ * every copied regular file to mode `0600`. Created directories get mode `0700`.
399
+ *
400
+ * This replaces `fs.cp({ dereference: true })` which preserves source file modes,
401
+ * leaving `0644` documents and `0755` scripts group/other-readable in the staged
402
+ * asset; here all regular files are normalized to `0600` regardless of source mode.
403
+ *
404
+ * `sourceDir`'s *direct* children are the Paperclip-injected skill symlinks that
405
+ * intentionally point into a shared skill store *outside* `CODEX_HOME/skills/`,
406
+ * so each child is allowed to resolve anywhere — and when it resolves to a
407
+ * directory it becomes the containment root for its own subtree. Everything
408
+ * *below* that root is copied via {@link stageContainedSubtree}, which refuses to
409
+ * follow a nested symlink out of the skill (finding 2) and detects directory
410
+ * cycles (finding 1). A direct child that resolves to `sourceDir` itself or to
411
+ * an ancestor of it (a degenerate `-> .` / `-> ..` link at the top level) is
412
+ * skipped rather than used as a root, so it can never drag the wider home into
413
+ * the staged skills asset. The `0700` staged directory and per-file `0600` mode
414
+ * together ensure even externally-sourced skill content is not group/world-readable.
415
+ */
416
+ async function stageDirectorySecure(sourceDir, targetDir) {
417
+ await fs.mkdir(targetDir, { recursive: true, mode: 0o700 });
418
+ const realSourceDir = await fs.realpath(sourceDir);
419
+ const entries = await fs.readdir(sourceDir, { withFileTypes: true });
420
+ for (const entry of entries) {
421
+ const entrySource = path.join(sourceDir, entry.name);
422
+ const entryTarget = path.join(targetDir, entry.name);
423
+ // Resolve the real path; dangling or self-referential (`ELOOP`) links skip.
424
+ const resolved = await fs.realpath(entrySource).catch((error) => {
425
+ const code = error.code;
426
+ if (code === "ENOENT" || code === "ELOOP")
427
+ return null;
428
+ throw error;
429
+ });
430
+ if (!resolved)
431
+ continue;
432
+ // A top-level child that resolves to the skills dir itself or an ancestor
433
+ // of it (`back -> .` / `back -> ..`) is degenerate: using it as a root would
434
+ // re-stage the whole home under `skills/`. Skip it.
435
+ if (isResolvedPathInside(realSourceDir, resolved))
436
+ continue;
437
+ const entryStat = await fs.stat(resolved).catch((error) => {
438
+ if (error.code === "ENOENT")
439
+ return null;
440
+ throw error;
441
+ });
442
+ if (!entryStat)
443
+ continue;
444
+ if (entryStat.isDirectory()) {
445
+ // This child skill establishes its own containment root: nested links may
446
+ // not escape it, and the root seeds the cycle-detection active path.
447
+ await stageContainedSubtree(resolved, entryTarget, resolved, new Set([resolved]));
448
+ }
449
+ else if (entryStat.isFile()) {
450
+ const bytes = await fs.readFile(resolved);
451
+ await fs.writeFile(entryTarget, bytes, { mode: 0o600 });
452
+ await fs.chmod(entryTarget, 0o600);
453
+ }
454
+ // Other types (sockets, devices) are silently skipped.
455
+ }
456
+ }
457
+ /**
458
+ * Copies a single allowlist entry from the managed home into the staged dir,
459
+ * dereferencing symlinks to bytes. Missing entries are skipped (keyring mode has
460
+ * no `auth.json`; some homes have no `config.json`). Every staged regular file is
461
+ * written `0600` (least privilege). Any non-`ENOENT` error propagates to the caller.
462
+ */
463
+ async function stageCodexHomeEntry(sourceHome, stagedHome, entry) {
464
+ const source = path.join(sourceHome, entry);
465
+ // `fs.stat` follows symlinks, so a dangling link (e.g. a removed auth source)
466
+ // reports ENOENT and is skipped exactly like a genuinely absent file.
467
+ const stat = await fs.stat(source).catch((error) => {
468
+ if (error.code === "ENOENT")
469
+ return null;
470
+ throw error;
471
+ });
472
+ if (!stat)
473
+ return;
474
+ const target = path.join(stagedHome, entry);
475
+ if (stat.isDirectory()) {
476
+ // Recursively copy with mode normalization — nested regular files land
477
+ // `0600` and dangling/circular symlinks are skipped.
478
+ await stageDirectorySecure(source, target);
479
+ return;
480
+ }
481
+ // `fs.readFile` follows the symlink into the shared source and returns the
482
+ // resolved bytes (the live single-use auth token), which we write as a plain
483
+ // regular file so copy-back and in-sandbox auth read real bytes.
484
+ const bytes = await fs.readFile(source);
485
+ // Stage every regular file `0600`, not just `auth.json`. The staged dir is a
486
+ // 0700 mkdtemp and each file is read back only by the owner (Codex in-sandbox +
487
+ // copy-back), so nothing needs group/other read. This is least privilege and,
488
+ // critically, keeps secret-bearing entries protected: `config.toml` embeds the
489
+ // managed MCP `Authorization = "Bearer …"` header (and the source writer
490
+ // persists it 0600), so a per-file credential allowlist would silently
491
+ // downgrade it to 0644 in a world-readable tmpdir.
492
+ await fs.writeFile(target, bytes, { mode: 0o600 });
493
+ // Explicit chmod so the mode is 0600 regardless of the process umask.
494
+ await fs.chmod(target, 0o600);
495
+ }
496
+ /**
497
+ * Stages exactly {@link CODEX_SYNC_ALLOWLIST} from `effectiveCodexHome` into a
498
+ * fresh private temp dir and returns its path, for registration as the sandbox
499
+ * `home` asset. This replaces syncing the whole managed home + a name denylist:
500
+ * only the files Codex actually needs are uploaded, so oversized runtime state
501
+ * (`sessions/`, `*.sqlite`, `plugins/`, …) never reaches the sandbox.
502
+ *
503
+ * - **Symlinks are dereferenced to bytes** — the single-use `auth.json`
504
+ * credential (a symlink into the shared source home) and each `skills/` entry
505
+ * land as real files, never dangling links.
506
+ * - **Missing-but-optional entries are skipped** — no `auth.json` in
507
+ * keyring-credential mode, or no `config.json`, is not an error.
508
+ * - **`mkdtemp` guarantees the staged dir is `0700`** on POSIX, and every staged
509
+ * regular file is written `0600` (least privilege), so staged credentials —
510
+ * `auth.json` (OAuth token) and `config.toml` (managed MCP bearer header) —
511
+ * are never group/other-readable.
512
+ * - **Fail-closed** — any *unexpected* I/O error removes the partial temp dir
513
+ * and re-throws, so a run never proceeds with a partial or empty home.
514
+ *
515
+ * The caller owns removing the returned dir on run teardown.
516
+ */
517
+ export async function stageCodexHomeForSync(effectiveCodexHome, options = {}) {
518
+ const runIdPart = nonEmpty(options.runId ?? undefined);
519
+ const stagedHome = await fs.mkdtemp(path.join(os.tmpdir(), `paperclip-codex-home-sync-${runIdPart ? `${runIdPart}-` : ""}`));
520
+ try {
521
+ for (const entry of CODEX_SYNC_ALLOWLIST) {
522
+ await stageCodexHomeEntry(effectiveCodexHome, stagedHome, entry);
523
+ }
524
+ return stagedHome;
525
+ }
526
+ catch (error) {
527
+ // Fail-closed: never hand back a partial home. Remove the temp dir we
528
+ // created before propagating the failure.
529
+ await fs.rm(stagedHome, { recursive: true, force: true }).catch(() => { });
530
+ throw error;
531
+ }
532
+ }
533
+ /**
534
+ * Seeds auth/config into an explicit Paperclip-managed `targetHome`. Symlinks
535
+ * `auth.json` from the shared source home (so ChatGPT-subscription credentials
536
+ * stay live and single-use refresh tokens are not copied), copies the static
537
+ * shared config files, and — when an API key is supplied — writes an API-key
538
+ * `auth.json` instead. A promoted device-login credential — a regular-file
539
+ * `auth.json` holding a subscription identity the shared source does not hold,
540
+ * or the same identity with a `last_refresh` the shared source has not strictly
541
+ * moved past — is kept authoritative: it is neither removed nor replaced by the
542
+ * shared symlink. Used both for the default company home and for the per-agent
543
+ * home set by the server isolation guard.
544
+ */
545
+ export async function seedManagedCodexHome(targetHome, env, onLog, options = {}) {
546
+ const apiKey = nonEmpty(options.apiKey ?? undefined);
547
+ const sourceHome = resolveSharedCodexHomeDir(env);
548
+ const seedFromShared = path.resolve(sourceHome) !== path.resolve(targetHome);
549
+ // A per-identity credential-store entry is not a seedable home: its
550
+ // auth.json is the durable, identity-anchored result of a device login,
551
+ // maintained by the promotion, the vend, and the copy-back under their own
552
+ // locks. An agent can bind `CODEX_HOME` to an entry through the login's
553
+ // account-home secret, and this pass runs before every probe and execute —
554
+ // symlinking the entry to the shared source would silently swap the bound
555
+ // account for the host login, and an API-key rewrite would destroy the
556
+ // stored credential outright. The static shared config files still copy
557
+ // in below, so a bound run gets the same config a per-agent home gets.
558
+ const credentialStoreEntry = isCodexAuthCachePath(env, targetHome);
559
+ await fs.mkdir(targetHome, { recursive: true });
560
+ // A regular-file auth.json in the target home is one of two very different
561
+ // things. The device-login promotion writes the company credential as a
562
+ // regular file, and that file is the durable outcome of an interactive login,
563
+ // so it must survive re-seeding. Everything else — an apikey-mode file left by
564
+ // a previous run, a stale pre-symlink copy of the shared credential (#5028),
565
+ // or an unreadable payload — is residue, and removing it lets the chatgpt-mode
566
+ // symlink be restored (ensureSymlink would otherwise replace it and Codex
567
+ // would keep authenticating with the stale key).
568
+ //
569
+ // The discriminator is identity- and freshness-anchored, like the promotion
570
+ // and the cache vend: keep the file when it holds a usable subscription
571
+ // identity that the shared source does not also hold, and also when it holds
572
+ // the SAME identity but the shared source is not strictly fresher by
573
+ // `last_refresh`. A device login for the account the host is also signed in
574
+ // to promotes a file strictly newer than the host copy; swapping that file
575
+ // for the symlink would sign the company back in with the very credential the
576
+ // login just replaced — the failing one that made the user sign in. The
577
+ // #5028 stale copy is the strictly-older direction of the same comparison,
578
+ // and it still heals: the live host credential refreshes on use, so as soon
579
+ // as the shared source is strictly fresher the swap applies. Ties and
580
+ // unparseable freshness keep the file — the same fail-closed direction the
581
+ // shared merge decision predicate uses — because deleting a promoted
582
+ // credential is irreversible while keeping it self-corrects on the next seed
583
+ // once the source has provably moved past it. A different-identity (or
584
+ // source-less) subscription file is the promoted company credential; on a
585
+ // server with no shared login there is nothing to symlink at all, and
586
+ // deleting it would silently sign the company out right after a successful
587
+ // device login.
588
+ let keepPromotedAuth = false;
589
+ if (!apiKey && seedFromShared && !credentialStoreEntry) {
590
+ const authPath = path.join(targetHome, "auth.json");
591
+ const existing = await fs.lstat(authPath).catch(() => null);
592
+ if (existing && !existing.isSymbolicLink()) {
593
+ const targetBytes = await fs.readFile(authPath).catch(() => null);
594
+ const targetIdentity = targetBytes ? readSubscriptionAccountId(targetBytes) : null;
595
+ if (targetIdentity) {
596
+ // Any source read failure — absent or unreadable — keeps the usable
597
+ // target file. The alternative, removal plus the existence-only
598
+ // symlink pass below, links the home to a source this process just
599
+ // failed to read, and every downstream reader (the probe seeding, the
600
+ // sandbox stage sync, the CLI itself) runs with the same access, so
601
+ // that home is unusable in every scenario. Keeping the target is
602
+ // better or equal in each case: a promoted credential keeps working,
603
+ // and even a stale same-identity copy (#5028) can still work, while
604
+ // the unreadable symlink cannot. A transient read failure also
605
+ // self-corrects — the next seed with a readable source heals a
606
+ // same-identity copy into the symlink — whereas removing the promoted
607
+ // credential is irreversible. The #5028 heal therefore applies
608
+ // exactly when the source is readable and the identities match.
609
+ let sourceReadErrorCode = null;
610
+ const sourceBytes = await fs
611
+ .readFile(path.join(sourceHome, "auth.json"))
612
+ .catch((error) => {
613
+ if (error.code !== "ENOENT" && error.code !== "ENOTDIR") {
614
+ sourceReadErrorCode = error.code ?? "unknown";
615
+ }
616
+ return null;
617
+ });
618
+ const sourceIdentity = sourceBytes ? readSubscriptionAccountId(sourceBytes) : null;
619
+ if (sourceIdentity !== targetIdentity) {
620
+ keepPromotedAuth = true;
621
+ }
622
+ else {
623
+ // Same identity: swap to the symlink only when the shared source is
624
+ // strictly fresher. A tie or an unparseable timestamp keeps the file
625
+ // (see the freshness rationale above).
626
+ const sourceLastRefresh = readAuthLastRefreshMs(sourceBytes);
627
+ const targetLastRefresh = readAuthLastRefreshMs(targetBytes);
628
+ keepPromotedAuth = !(sourceLastRefresh !== null &&
629
+ targetLastRefresh !== null &&
630
+ sourceLastRefresh > targetLastRefresh);
631
+ }
632
+ if (keepPromotedAuth && sourceReadErrorCode) {
633
+ // Deferred heal, made visible: seeding runs before every probe and
634
+ // every execute, so the next call with a readable source applies
635
+ // the same-identity symlink heal this call could not decide.
636
+ await onLog("stdout", `[paperclip] Keeping the existing subscription auth.json in Codex home "${targetHome}" (shared source read failed: ${sourceReadErrorCode}); the next seed with a readable source reconciles it.\n`);
637
+ }
638
+ }
639
+ if (keepPromotedAuth) {
640
+ await onLog("stdout", `[paperclip] Keeping the promoted subscription auth.json in Codex home "${targetHome}".\n`);
641
+ }
642
+ else {
643
+ await fs.rm(authPath, { force: true });
644
+ }
645
+ }
646
+ }
647
+ if (seedFromShared) {
648
+ for (const name of SYMLINKED_SHARED_FILES) {
649
+ // The kept promoted credential is authoritative for this home; the shared
650
+ // symlink would silently swap the account back to the host login. A
651
+ // credential-store entry's auth.json is authoritative unconditionally.
652
+ if (name === "auth.json" && (keepPromotedAuth || credentialStoreEntry))
653
+ continue;
654
+ const source = path.join(sourceHome, name);
655
+ if (!(await pathExists(source)))
656
+ continue;
657
+ await ensureSymlink(path.join(targetHome, name), source);
658
+ }
659
+ for (const name of COPIED_SHARED_FILES) {
660
+ const source = path.join(sourceHome, name);
661
+ if (!(await pathExists(source)))
662
+ continue;
663
+ await ensureCopiedFile(path.join(targetHome, name), source);
664
+ }
665
+ await onLog("stdout", `[paperclip] Using ${isWorktreeMode(env) ? "worktree-isolated" : "Paperclip-managed"} Codex home "${targetHome}" (seeded from "${sourceHome}").\n`);
666
+ }
667
+ if (apiKey) {
668
+ if (credentialStoreEntry) {
669
+ // Refuse, loudly: overwriting a store entry's subscription credential
670
+ // with an API-key file would destroy the durable login the entry
671
+ // exists to hold. The operator combined an account binding with a
672
+ // configured OPENAI_API_KEY; the binding wins for this home.
673
+ await onLog("stdout", `[paperclip] Refusing to write an API-key auth.json into credential-store entry "${targetHome}"; the bound account's stored login stays authoritative.\n`);
674
+ }
675
+ else {
676
+ await writeApiKeyAuthJson(targetHome, apiKey);
677
+ await onLog("stdout", `[paperclip] Wrote API-key auth.json into Codex home "${targetHome}" from configured OPENAI_API_KEY.\n`);
678
+ }
679
+ }
680
+ }
681
+ export async function prepareManagedCodexHome(env, onLog, companyId, options = {}) {
682
+ const targetHome = resolveManagedCodexHomeDir(env, companyId);
683
+ await seedManagedCodexHome(targetHome, env, onLog, options);
684
+ return targetHome;
685
+ }
686
+ const noopOnLog = async () => { };
687
+ /**
688
+ * Idempotently reconciles a persisted `codex_local` agent home. Phase 1 seeds
689
+ * managed homes at execute time; this is the backfill for agents that already
690
+ * carry a persisted (but unseeded) per-agent `CODEX_HOME` and have not run
691
+ * since the seeding fix landed. Shares the managed-home detection
692
+ * (`isManagedCodexHomePath`) and seeding (`seedManagedCodexHome`) logic so a
693
+ * genuine external/user override is never touched. Safe to re-run: when a valid
694
+ * `auth.json` is already present (and no API-key rewrite is requested) it is a
695
+ * no-op and reports `already_seeded`.
696
+ */
697
+ export async function reconcileManagedCodexHome(input) {
698
+ const env = input.env ?? process.env;
699
+ const configured = nonEmpty(input.configuredCodexHome ?? undefined);
700
+ if (!configured)
701
+ return { status: "no_managed_home", home: null };
702
+ const resolved = path.resolve(configured);
703
+ if (!isManagedCodexHomePath(env, input.companyId, resolved)) {
704
+ return { status: "external_override", home: resolved };
705
+ }
706
+ const apiKey = nonEmpty(input.apiKey ?? undefined);
707
+ const hadUsableAuth = await codexHomeHasUsableAuth(resolved);
708
+ // A secret-bound OPENAI_API_KEY cannot be resolved here, so we cannot rewrite
709
+ // it into auth.json. If the home already has usable auth — typically an
710
+ // API-key auth.json written at execute time when the secret WAS resolved —
711
+ // preserve it. Re-seeding without the key would delete that file and restore
712
+ // the shared subscription symlink, silently changing the agent's credentials
713
+ // on every boot while the persisted config still says "use the secret key".
714
+ if (input.apiKeySecretBound && hadUsableAuth) {
715
+ return { status: "already_seeded", home: resolved };
716
+ }
717
+ if (apiKey && await codexHomeHasMatchingApiKeyAuth(resolved, apiKey)) {
718
+ return { status: "already_seeded", home: resolved };
719
+ }
720
+ await seedManagedCodexHome(resolved, env, input.onLog ?? noopOnLog, { apiKey });
721
+ if (!apiKey && !(await codexHomeHasUsableAuth(resolved))) {
722
+ return { status: "source_auth_missing", home: resolved };
723
+ }
724
+ // Without an API key, seeding only changes disk state when auth was missing.
725
+ // With an API key, the matching-file short-circuit above filters out the
726
+ // already-seeded case before this write path.
727
+ const status = !apiKey && hadUsableAuth ? "already_seeded" : "seeded";
728
+ return { status, home: resolved };
729
+ }
730
+ /**
731
+ * Read-only predictor for whether a `codex_local` run will be able to
732
+ * authenticate, without seeding or mutating any home. Mirrors the execute-time
733
+ * fail-fast in `execute.ts`, factored out so the control plane can run the same
734
+ * check *before* dispatch and surface a configuration-incomplete blocker instead
735
+ * of dispatching a run that is guaranteed to fail with "no Codex credentials".
736
+ *
737
+ * - An external/user-supplied `CODEX_HOME` override manages its own auth, so it
738
+ * is always treated as ready (Paperclip must not seed or inspect it).
739
+ * - A non-empty resolved `OPENAI_API_KEY` means API-key auth, always ready.
740
+ * - Otherwise (subscription mode) the run needs a usable `auth.json`. Because a
741
+ * managed home symlinks `auth.json` from the shared source home at seed time,
742
+ * we treat the run as ready when either the (possibly already-seeded) effective
743
+ * home or the shared source home carries usable auth.
744
+ */
745
+ export async function evaluateCodexCredentialReadiness(input) {
746
+ const env = input.env ?? process.env;
747
+ const configuredRaw = nonEmpty(input.configuredCodexHome ?? undefined);
748
+ const configuredCodexHome = configuredRaw ? path.resolve(configuredRaw) : null;
749
+ const configuredApiKey = nonEmpty(input.configuredApiKey ?? undefined);
750
+ const sharedSourceHome = resolveSharedCodexHomeDir(env);
751
+ const configuredHomeIsManaged = configuredCodexHome != null && isManagedCodexHomePath(env, input.companyId, configuredCodexHome);
752
+ const effectiveHomeIsManaged = configuredCodexHome == null || configuredHomeIsManaged;
753
+ const effectiveHome = configuredCodexHome ?? resolveManagedCodexHomeDir(env, input.companyId);
754
+ if (!effectiveHomeIsManaged) {
755
+ // Genuine external override: Paperclip never seeds or inspects it.
756
+ return {
757
+ managed: false,
758
+ authMode: configuredApiKey ? "api" : "subscription",
759
+ ready: true,
760
+ effectiveHome,
761
+ sharedSourceHome,
762
+ };
763
+ }
764
+ if (configuredApiKey) {
765
+ return { managed: true, authMode: "api", ready: true, effectiveHome, sharedSourceHome };
766
+ }
767
+ const ready = (await codexHomeHasUsableAuth(effectiveHome)) ||
768
+ (await codexHomeHasUsableAuth(sharedSourceHome));
769
+ return { managed: true, authMode: "subscription", ready, effectiveHome, sharedSourceHome };
770
+ }
771
+ //# sourceMappingURL=codex-home.js.map