@humwick/runner 0.0.0-stage → 0.130.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 (293) hide show
  1. package/LICENSE +167 -0
  2. package/agent-bin/agent-relay-browser +10 -0
  3. package/agent-bin/humwick +10 -0
  4. package/hooks/fff-search-redirect/fff-search-redirect.cjs +161 -0
  5. package/hooks/fff-search-redirect/hooks.json +13 -0
  6. package/hooks/rg-r-guard/hooks.json +18 -0
  7. package/hooks/rg-r-guard/rg-r-guard.cjs +144 -0
  8. package/hooks/web-tools-redirect/hooks.json +13 -0
  9. package/hooks/web-tools-redirect/web-tools-redirect.cjs +84 -0
  10. package/node_modules/@humwick/offload/LICENSE +167 -0
  11. package/node_modules/@humwick/offload/README.md +57 -0
  12. package/node_modules/@humwick/offload/dist/async-runner.d.ts +18 -0
  13. package/node_modules/@humwick/offload/dist/async-runner.d.ts.map +1 -0
  14. package/node_modules/@humwick/offload/dist/async-runner.js +158 -0
  15. package/node_modules/@humwick/offload/dist/async-runner.js.map +1 -0
  16. package/node_modules/@humwick/offload/dist/envelope.d.ts +25 -0
  17. package/node_modules/@humwick/offload/dist/envelope.d.ts.map +1 -0
  18. package/node_modules/@humwick/offload/dist/envelope.js +80 -0
  19. package/node_modules/@humwick/offload/dist/envelope.js.map +1 -0
  20. package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts +10 -0
  21. package/node_modules/@humwick/offload/dist/fixtures/worker-task.d.ts.map +1 -0
  22. package/node_modules/@humwick/offload/dist/fixtures/worker-task.js +59 -0
  23. package/node_modules/@humwick/offload/dist/fixtures/worker-task.js.map +1 -0
  24. package/node_modules/@humwick/offload/dist/index.d.ts +5 -0
  25. package/node_modules/@humwick/offload/dist/index.d.ts.map +1 -0
  26. package/node_modules/@humwick/offload/dist/index.js +5 -0
  27. package/node_modules/@humwick/offload/dist/index.js.map +1 -0
  28. package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts +62 -0
  29. package/node_modules/@humwick/offload/dist/subprocess-runner.d.ts.map +1 -0
  30. package/node_modules/@humwick/offload/dist/subprocess-runner.js +295 -0
  31. package/node_modules/@humwick/offload/dist/subprocess-runner.js.map +1 -0
  32. package/node_modules/@humwick/offload/dist/types.d.ts +111 -0
  33. package/node_modules/@humwick/offload/dist/types.d.ts.map +1 -0
  34. package/node_modules/@humwick/offload/dist/types.js +176 -0
  35. package/node_modules/@humwick/offload/dist/types.js.map +1 -0
  36. package/node_modules/@humwick/offload/dist/worker-pool.d.ts +23 -0
  37. package/node_modules/@humwick/offload/dist/worker-pool.d.ts.map +1 -0
  38. package/node_modules/@humwick/offload/dist/worker-pool.js +435 -0
  39. package/node_modules/@humwick/offload/dist/worker-pool.js.map +1 -0
  40. package/node_modules/@humwick/offload/dist/worker-runtime.d.ts +2 -0
  41. package/node_modules/@humwick/offload/dist/worker-runtime.d.ts.map +1 -0
  42. package/node_modules/@humwick/offload/dist/worker-runtime.js +27 -0
  43. package/node_modules/@humwick/offload/dist/worker-runtime.js.map +1 -0
  44. package/node_modules/@humwick/offload/dist/worker.d.ts +5 -0
  45. package/node_modules/@humwick/offload/dist/worker.d.ts.map +1 -0
  46. package/node_modules/@humwick/offload/dist/worker.js +1 -0
  47. package/node_modules/@humwick/offload/dist/worker.js.map +1 -0
  48. package/node_modules/@humwick/offload/package.json +46 -0
  49. package/node_modules/@humwick/offload/src/async-runner.ts +170 -0
  50. package/node_modules/@humwick/offload/src/envelope.ts +92 -0
  51. package/node_modules/@humwick/offload/src/fixtures/worker-task.ts +49 -0
  52. package/node_modules/@humwick/offload/src/index.ts +4 -0
  53. package/node_modules/@humwick/offload/src/subprocess-runner.ts +334 -0
  54. package/node_modules/@humwick/offload/src/types.ts +235 -0
  55. package/node_modules/@humwick/offload/src/worker-pool.ts +463 -0
  56. package/node_modules/@humwick/offload/src/worker-runtime.ts +32 -0
  57. package/node_modules/@humwick/offload/src/worker.ts +5 -0
  58. package/package.json +47 -4
  59. package/plugins/browser/skills/playwright-cli/SKILL.md +53 -0
  60. package/plugins/claude/.claude-plugin/plugin.json +8 -0
  61. package/plugins/claude/claude-statusline-probe.cjs +144 -0
  62. package/plugins/claude/hooks/gate-run-guard.provisioned.mjs +914 -0
  63. package/plugins/claude/hooks/gate-run-guard.sh +9 -0
  64. package/plugins/claude/hooks/gate-run-guard.ts +237 -0
  65. package/plugins/claude/hooks/hooks.json +158 -0
  66. package/plugins/claude/hooks/kill-guard.provisioned.mjs +7027 -0
  67. package/plugins/claude/hooks/kill-guard.sh +341 -0
  68. package/plugins/claude/hooks/kill-guard.ts +337 -0
  69. package/plugins/claude/hooks/permission-request.sh +22 -0
  70. package/plugins/claude/hooks/post-compact.sh +10 -0
  71. package/plugins/claude/hooks/post-tool-use.sh +8 -0
  72. package/plugins/claude/hooks/pre-compact.sh +12 -0
  73. package/plugins/claude/hooks/read-only-bash-guard.sh +587 -0
  74. package/plugins/claude/hooks/relay-status.sh +373 -0
  75. package/plugins/claude/hooks/session-end.sh +26 -0
  76. package/plugins/claude/hooks/session-start.sh +31 -0
  77. package/plugins/claude/hooks/shell-trust.ts +28 -0
  78. package/plugins/claude/hooks/stop-failure.sh +47 -0
  79. package/plugins/claude/hooks/stop.sh +48 -0
  80. package/plugins/claude/hooks/subagent-guard.cjs +26 -0
  81. package/plugins/claude/hooks/subagent-skill-policy.cjs +160 -0
  82. package/plugins/claude/hooks/subagent-start.sh +13 -0
  83. package/plugins/claude/hooks/subagent-stop.sh +19 -0
  84. package/plugins/claude/hooks/user-prompt-submit.sh +18 -0
  85. package/plugins/claude/hooks/wait-loop-guard.ts +120 -0
  86. package/plugins/claude/monitors/monitors.json +7 -0
  87. package/plugins/claude/monitors/relay-monitor.provisioned.mjs +786 -0
  88. package/plugins/claude/monitors/relay-monitor.ts +58 -0
  89. package/plugins/claude/skills/disconnect/SKILL.md +16 -0
  90. package/plugins/claude/skills/label/SKILL.md +23 -0
  91. package/plugins/claude/skills/message/SKILL.md +40 -0
  92. package/plugins/claude/skills/pair/SKILL.md +30 -0
  93. package/plugins/claude/skills/react/SKILL.md +18 -0
  94. package/plugins/claude/skills/read-message/SKILL.md +24 -0
  95. package/plugins/claude/skills/reply/SKILL.md +44 -0
  96. package/plugins/claude/skills/send-claimable/SKILL.md +39 -0
  97. package/plugins/claude/skills/slice-first/SKILL.md +32 -0
  98. package/plugins/claude/skills/slice-first/references/plan-recipe.md +46 -0
  99. package/plugins/claude/skills/status/SKILL.md +16 -0
  100. package/plugins/claude/skills/tags/SKILL.md +25 -0
  101. package/plugins/claude/skills/task/SKILL.md +28 -0
  102. package/plugins/claude-output-style/.claude-plugin/plugin.json +5 -0
  103. package/plugins/claude-output-style/output-styles/agent-relay.md +5 -0
  104. package/plugins/codex/skills/disconnect/SKILL.md +15 -0
  105. package/plugins/codex/skills/guide/SKILL.md +15 -0
  106. package/plugins/codex/skills/label/SKILL.md +22 -0
  107. package/plugins/codex/skills/message/SKILL.md +40 -0
  108. package/plugins/codex/skills/pair/SKILL.md +29 -0
  109. package/plugins/codex/skills/react/SKILL.md +17 -0
  110. package/plugins/codex/skills/read-message/SKILL.md +23 -0
  111. package/plugins/codex/skills/reply/SKILL.md +44 -0
  112. package/plugins/codex/skills/send-claimable/SKILL.md +39 -0
  113. package/plugins/codex/skills/slice-first/SKILL.md +32 -0
  114. package/plugins/codex/skills/slice-first/references/plan-recipe.md +46 -0
  115. package/plugins/codex/skills/status/SKILL.md +15 -0
  116. package/plugins/codex/skills/tags/SKILL.md +24 -0
  117. package/plugins/codex/skills/task/SKILL.md +28 -0
  118. package/src/adapter.ts +1061 -0
  119. package/src/adapters/__fixtures__/codex-app-server.ts +47 -0
  120. package/src/adapters/claude-api.ts +243 -0
  121. package/src/adapters/claude-context-command.ts +16 -0
  122. package/src/adapters/claude-credentials.ts +22 -0
  123. package/src/adapters/claude-delivery-lanes.ts +28 -0
  124. package/src/adapters/claude-delivery.ts +166 -0
  125. package/src/adapters/claude-effort.ts +94 -0
  126. package/src/adapters/claude-exit.ts +290 -0
  127. package/src/adapters/claude-headless-mirror.ts +88 -0
  128. package/src/adapters/claude-headless-refusal.ts +51 -0
  129. package/src/adapters/claude-headless.ts +617 -0
  130. package/src/adapters/claude-monitor-delivery-plan.ts +317 -0
  131. package/src/adapters/claude-permission.ts +243 -0
  132. package/src/adapters/claude-prompt-gates.ts +269 -0
  133. package/src/adapters/claude-prompt-hook.ts +145 -0
  134. package/src/adapters/claude-session-capture.ts +293 -0
  135. package/src/adapters/claude-session-probe.ts +414 -0
  136. package/src/adapters/claude-shutdown.ts +190 -0
  137. package/src/adapters/claude-status-detectors.ts +426 -0
  138. package/src/adapters/claude-subagents.ts +93 -0
  139. package/src/adapters/claude-submit-confirmation.ts +287 -0
  140. package/src/adapters/claude-tmux.ts +548 -0
  141. package/src/adapters/claude-token-usage-capture.ts +253 -0
  142. package/src/adapters/claude-tool-policy.ts +165 -0
  143. package/src/adapters/claude-transcript-tail.ts +193 -0
  144. package/src/adapters/claude-transcript.ts +812 -0
  145. package/src/adapters/claude.ts +1097 -0
  146. package/src/adapters/codex-agent-message-capture.ts +189 -0
  147. package/src/adapters/codex-app-server.ts +70 -0
  148. package/src/adapters/codex-client.ts +362 -0
  149. package/src/adapters/codex-effective-config.ts +87 -0
  150. package/src/adapters/codex-launch-config.ts +153 -0
  151. package/src/adapters/codex-rollout-context.ts +272 -0
  152. package/src/adapters/codex-skill-discovery.ts +18 -0
  153. package/src/adapters/codex-token-usage-capture.ts +163 -0
  154. package/src/adapters/codex-tool-output.ts +53 -0
  155. package/src/adapters/codex-turn-outcome.ts +27 -0
  156. package/src/adapters/codex-version.ts +64 -0
  157. package/src/adapters/codex.ts +1442 -0
  158. package/src/adapters/incremental-file.ts +110 -0
  159. package/src/adapters/opencode-api.ts +71 -0
  160. package/src/adapters/opencode-context.ts +277 -0
  161. package/src/adapters/opencode-model-registry.ts +126 -0
  162. package/src/adapters/opencode-provider.ts +169 -0
  163. package/src/adapters/opencode-server-retry.ts +50 -0
  164. package/src/adapters/opencode-soft-steer.ts +302 -0
  165. package/src/adapters/opencode-spawn.ts +145 -0
  166. package/src/adapters/opencode-subscribe.ts +62 -0
  167. package/src/adapters/opencode-token-usage-capture.ts +187 -0
  168. package/src/adapters/opencode-tool-events.ts +302 -0
  169. package/src/adapters/opencode-tools-config.ts +17 -0
  170. package/src/adapters/opencode.ts +2264 -0
  171. package/src/adapters/project-shell.ts +80 -0
  172. package/src/adapters/provider-launch-config.ts +21 -0
  173. package/src/adapters/provider-quota-harvest.ts +127 -0
  174. package/src/adapters/service-access.ts +153 -0
  175. package/src/adapters/tmux-offload.ts +207 -0
  176. package/src/adapters/tool-summary.ts +214 -0
  177. package/src/agent-redact.ts +75 -0
  178. package/src/agent-runner/commands/dispatch.ts +185 -0
  179. package/src/agent-runner/commands/lifecycle.ts +84 -0
  180. package/src/agent-runner/commands/prompt.ts +284 -0
  181. package/src/agent-runner/credentials.ts +208 -0
  182. package/src/agent-runner/http-liveness.ts +85 -0
  183. package/src/agent-runner/lifecycle-milestones.ts +207 -0
  184. package/src/agent-runner/lifecycle-phase.ts +80 -0
  185. package/src/agent-runner/registration.ts +163 -0
  186. package/src/agent-runner/runner-context.ts +158 -0
  187. package/src/agent-runner/session-mirror.ts +278 -0
  188. package/src/agent-runner/task-claims.ts +116 -0
  189. package/src/agent-runner/token-usage-coverage.ts +54 -0
  190. package/src/agent-runner/turn-report.ts +269 -0
  191. package/src/api-budget-accounting.ts +91 -0
  192. package/src/area-memory.ts +255 -0
  193. package/src/artifact-path-materialize.ts +52 -0
  194. package/src/async-guard.ts +35 -0
  195. package/src/attachment-cache-sweep.ts +31 -0
  196. package/src/attachment-cache.ts +195 -0
  197. package/src/background-script-reconcile.ts +106 -0
  198. package/src/bounded-log.ts +59 -0
  199. package/src/bounded-spawn-sync.ts +18 -0
  200. package/src/busy-reconciler.ts +282 -0
  201. package/src/claim-tracker.ts +140 -0
  202. package/src/claude-settings.ts +167 -0
  203. package/src/claude-workspace-trust.ts +289 -0
  204. package/src/config.ts +547 -0
  205. package/src/context-boundary.ts +101 -0
  206. package/src/context-command.ts +151 -0
  207. package/src/context-probe-state.ts +6 -0
  208. package/src/continuation-archive.ts +179 -0
  209. package/src/control-server.ts +730 -0
  210. package/src/delivery-policy.ts +9 -0
  211. package/src/delivery-result.ts +24 -0
  212. package/src/effort-command.ts +125 -0
  213. package/src/empty-turn.ts +816 -0
  214. package/src/free-port.ts +25 -0
  215. package/src/fs-offload.ts +26 -0
  216. package/src/gate-run-config.ts +35 -0
  217. package/src/gate-run-lease-probe.ts +38 -0
  218. package/src/host-asset-plan.ts +450 -0
  219. package/src/host-claude-config-worker.ts +34 -0
  220. package/src/host-sources.ts +40 -0
  221. package/src/inbox-carrier.ts +7 -0
  222. package/src/index.ts +386 -0
  223. package/src/launch-assembly.ts +955 -0
  224. package/src/liveness.ts +105 -0
  225. package/src/logger.ts +108 -0
  226. package/src/mcp-outbox.ts +204 -0
  227. package/src/mcp-preflight.ts +86 -0
  228. package/src/mcp-source-selection.ts +54 -0
  229. package/src/mcp-tool-deferral.ts +35 -0
  230. package/src/mcp-vault-env.ts +25 -0
  231. package/src/message-body-config/config.ts +16 -0
  232. package/src/message-body-config.ts +1 -0
  233. package/src/mid-turn-delivery.ts +43 -0
  234. package/src/native-memory-lever.ts +329 -0
  235. package/src/native-self-resume.ts +244 -0
  236. package/src/outbox.ts +453 -0
  237. package/src/park-post.ts +17 -0
  238. package/src/pending-permission-store.ts +148 -0
  239. package/src/process-meta.ts +9 -0
  240. package/src/profile-home.ts +419 -0
  241. package/src/profile-projection.ts +411 -0
  242. package/src/project-hooks.ts +65 -0
  243. package/src/project-instructions.ts +119 -0
  244. package/src/provider-double.ts +59 -0
  245. package/src/provider-hold.ts +228 -0
  246. package/src/provider-home-marker.ts +3 -0
  247. package/src/provider-home-tombstone.ts +4 -0
  248. package/src/provider-input-queue.ts +134 -0
  249. package/src/provider-integration.ts +93 -0
  250. package/src/provider-processes.ts +17 -0
  251. package/src/provider-submit-failure.ts +15 -0
  252. package/src/providers.ts +35 -0
  253. package/src/provisioning.ts +703 -0
  254. package/src/question-decision.ts +18 -0
  255. package/src/quota.ts +22 -0
  256. package/src/rate-limit.ts +351 -0
  257. package/src/refused-prompt-store.ts +36 -0
  258. package/src/relay-injection-events.ts +161 -0
  259. package/src/relay-instructions.ts +107 -0
  260. package/src/relay-mcp-proxy.long-turn-e2e.ts +183 -0
  261. package/src/relay-mcp-proxy.ts +704 -0
  262. package/src/relay-mcp.ts +583 -0
  263. package/src/reply-obligation-cache.ts +211 -0
  264. package/src/response-capture-report.ts +111 -0
  265. package/src/response-routing.ts +6 -0
  266. package/src/runner-core.ts +2955 -0
  267. package/src/runner-exit.ts +12 -0
  268. package/src/runner-helpers.ts +582 -0
  269. package/src/runner-insights.ts +105 -0
  270. package/src/runner-mcp-proxy.ts +46 -0
  271. package/src/runner-spawn-config.ts +68 -0
  272. package/src/runner-stale-scratch-sweep.ts +21 -0
  273. package/src/runner.ts +9 -0
  274. package/src/runtime-token-file.ts +45 -0
  275. package/src/runtime-token-remint.ts +56 -0
  276. package/src/service-access.ts +88 -0
  277. package/src/session-destroy.ts +27 -0
  278. package/src/session-insights.ts +118 -0
  279. package/src/session-scratch.ts +296 -0
  280. package/src/spawn-input.ts +147 -0
  281. package/src/startup-config-fetch.ts +48 -0
  282. package/src/subreaper.ts +113 -0
  283. package/src/template-resolver.ts +29 -0
  284. package/src/test-run-dedup.ts +435 -0
  285. package/src/transcript-context.ts +99 -0
  286. package/src/transport-errors.ts +48 -0
  287. package/src/turn-opener.ts +55 -0
  288. package/src/turn-reconcile-event.ts +88 -0
  289. package/src/vault-proxy-hop.ts +99 -0
  290. package/src/version.ts +44 -0
  291. package/src/web-tool-call-proxy.ts +128 -0
  292. package/src/web-tools.ts +59 -0
  293. package/README.md +0 -3
@@ -0,0 +1,583 @@
1
+ import { mcpServerInfo, parseToolGeneration, isRelayMcpServerName, type ToolGeneration } from "@humwick/sdk";
2
+ import { relayEnv, writeChildEnv } from "@humwick/sdk/env";
3
+ import { codexVaultMcpEnv } from "./mcp-vault-env";
4
+ // Single home for the relay HTTP MCP endpoint descriptor + per-provider injection
5
+ // args. Both adapters (and Stage 2's proxy) import from here so the endpoint path,
6
+ // server name, and token-handling rules live in exactly one place.
7
+ //
8
+ // Token handling: the bearer token is NEVER placed in argv (it would leak via `ps`
9
+ // and the agent's own process inspection). Claude expands `${AGENT_RELAY_SESSION_TOKEN}`
10
+ // from the env at MCP-config parse time; Codex reads it from the named env var.
11
+ //
12
+ // Why a dedicated var and NOT `AGENT_RELAY_TOKEN`: a managed session inherits the
13
+ // runner's scoped, identity-bearing session token, but Claude Code applies a rig/user
14
+ // `settings.json` `env` block OVER the inherited env. A rig that sets
15
+ // `env.AGENT_RELAY_TOKEN` (e.g. to the admin token, for interactive CLI use) would
16
+ // clobber the scoped token at parse time, so the relay MCP connection authenticates as
17
+ // the `server` actor with no agent identity — `relay_whoami` returns null and
18
+ // `relay_send_message` demands a `from` it can't supply (#233). The runner exports the
19
+ // scoped token under this dedicated name that rigs don't set, so nothing on the host can
20
+ // hijack the managed agent's identity.
21
+ export const RELAY_MCP_SERVER_NAME = mcpServerInfo("relay").name;
22
+ export { isRelayMcpServerName };
23
+ export const RELAY_MCP_PATH = "/api/mcp";
24
+ export const RELAY_MCP_TOKEN_ENV = "AGENT_RELAY_SESSION_TOKEN";
25
+
26
+ // #672 — shared host-listener MCP. The agent connects to ONE orchestrator-owned shared
27
+ // `callmux` listener via a per-agent `callmux bridge`, fronting the host's NON-IDENTITY MCP
28
+ // (tokenlean, github). The bridge is a stdio child (auto-reconnect + retry-once on a
29
+ // listener/downstream hiccup; `--cwd` injects `x-callmux-cwd` for per-worktree session-cwd
30
+ // isolation), so it rides the SAME provider injection as any provisioned stdio server. The
31
+ // identity-bearing relay endpoint is NEVER routed here — it stays per-agent on #215.
32
+ export { SHARED_MCP_SERVER_NAME };
33
+ // callmux's documented manual-listener endpoint (`callmux --listen 4860`, Streamable HTTP at
34
+ // /mcp). In managed launches the orchestrator owns the real listener URL and passes it through
35
+ // AGENT_RELAY_SHARED_MCP_URL; this fallback is for direct runner/manual-listener use.
36
+ export const DEFAULT_SHARED_MCP_URL = "http://localhost:4860/mcp";
37
+ export const SHARED_MCP_URL_ENV = "AGENT_RELAY_SHARED_MCP_URL";
38
+ export { runnerManagedToolDeferralRoute } from "./mcp-tool-deferral";
39
+
40
+ /** Resolve the shared-listener URL: explicit runner option → env → documented default. */
41
+ export function resolveSharedMcpUrl(explicit?: string): string {
42
+ return explicit ?? relayEnv(process.env, SHARED_MCP_URL_ENV) ?? DEFAULT_SHARED_MCP_URL;
43
+ }
44
+
45
+ export function runnerMcpGeneration(env: Record<string, string | undefined>): ToolGeneration {
46
+ return parseToolGeneration(relayEnv(env, "AGENT_RELAY_MCP_TOOLS"));
47
+ }
48
+
49
+ function generationEndpoint(endpoint: string, generation: ToolGeneration): string {
50
+ const url = new URL(endpoint);
51
+ if (generation === "humwick") url.searchParams.set("tools", generation);
52
+ else url.searchParams.delete("tools");
53
+ return url.toString();
54
+ }
55
+
56
+ export function relayMcpEndpoint(relayUrl: string, generation: ToolGeneration = "relay"): string {
57
+ return generationEndpoint(`${relayUrl.replace(/\/+$/, "")}${RELAY_MCP_PATH}`, generation);
58
+ }
59
+
60
+ // #1248 — per-server Codex MCP overrides. Default `tool_timeout_sec` (60s) and
61
+ // `startup_timeout_ms` are too short for our long-running tools (tests/releases run
62
+ // 5+ min), forcing agents to poll/sleep instead of just waiting on the call. Verified
63
+ // against the installed codex-cli binary (0.144.0): both fields live on the
64
+ // per-`[mcp_servers.<name>]` McpServerConfig struct, alongside `bearer_token_env_var`.
65
+ export const CODEX_MCP_TOOL_TIMEOUT_SEC = 900;
66
+ export const CODEX_MCP_STARTUP_TIMEOUT_MS = 30000;
67
+ export const OPENCODE_MCP_TIMEOUT_MS = CODEX_MCP_TOOL_TIMEOUT_SEC * 1_000;
68
+
69
+ function codexTimeoutConfigArgs(key: string): string[] {
70
+ return [
71
+ "-c",
72
+ `${key}.tool_timeout_sec=${CODEX_MCP_TOOL_TIMEOUT_SEC}`,
73
+ "-c",
74
+ `${key}.startup_timeout_ms=${CODEX_MCP_STARTUP_TIMEOUT_MS}`,
75
+ ];
76
+ }
77
+
78
+ // Codex: `-c mcp_servers.<name>.*` overrides. `bearer_token_env_var` tells Codex to
79
+ // read the token from the env var itself → transport resolves to streamable_http.
80
+ // `endpoint` overrides the target URL (runner-local proxy, Stage 2 #215) — see above.
81
+ export function relayMcpCodexConfigArgs(relayUrl: string, endpoint?: string, generation: ToolGeneration = "relay"): string[] {
82
+ const key = `mcp_servers.${mcpServerInfo(generation).name}`;
83
+ return [
84
+ "-c",
85
+ `${key}.url=${tomlString(generationEndpoint(endpoint ?? relayMcpEndpoint(relayUrl), generation))}`,
86
+ "-c",
87
+ `${key}.bearer_token_env_var=${tomlString(RELAY_MCP_TOKEN_ENV)}`,
88
+ ...codexTimeoutConfigArgs(key),
89
+ ];
90
+ }
91
+
92
+ // Shared TOML string escaper for Codex `-c key=value` overrides. One home; codex.ts
93
+ // imports this rather than re-declaring it.
94
+ export function tomlString(value: string): string {
95
+ return JSON.stringify(value);
96
+ }
97
+
98
+ /** A JSON-shaped value as one TOML inline value, for a structured `-c key=<value>` override. */
99
+ export function tomlInlineValue(value: unknown): string {
100
+ if (typeof value === "string") return tomlString(value);
101
+ if (typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) return String(value);
102
+ if (Array.isArray(value)) return `[${value.map(tomlInlineValue).join(",")}]`;
103
+ const record = plainRecord(value);
104
+ if (!record) throw new Error(`no TOML inline form for ${value === null ? "null" : typeof value}`);
105
+ return `{${Object.entries(record).filter(([, item]) => item !== undefined).map(([key, item]) => `${tomlString(key)}=${tomlInlineValue(item)}`).join(",")}}`;
106
+ }
107
+
108
+ type TomlScalar = string | number | boolean;
109
+
110
+ function tomlValue(value: unknown): string | undefined {
111
+ if (typeof value === "string") return tomlString(value);
112
+ if (typeof value === "boolean") return String(value);
113
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
114
+ if (Array.isArray(value) && value.every((item): item is TomlScalar =>
115
+ typeof item === "string" || typeof item === "number" && Number.isFinite(item) || typeof item === "boolean")) {
116
+ return `[${value.map((item) => tomlValue(item)!).join(", ")}]`;
117
+ }
118
+ return undefined;
119
+ }
120
+
121
+ function plainRecord(value: unknown): Record<string, unknown> | undefined {
122
+ return value !== null && typeof value === "object" && !Array.isArray(value)
123
+ ? value as Record<string, unknown>
124
+ : undefined;
125
+ }
126
+
127
+ /**
128
+ * Keep Codex-native MCP fields when projecting a host config into an isolated home. The
129
+ * provider-neutral shape has historically carried only command/args/url/env; dropping the
130
+ * native auth/header fields makes a copied remote server look applied while removing its
131
+ * credentials. Nested tables are returned separately because TOML tables must follow the
132
+ * server table rather than being emitted as dotted keys in its body.
133
+ */
134
+ function codexNativeMcpToml(server: ProvisioningMcpServer): { root: string[]; nested: Array<{ name: string; lines: string[] }> } {
135
+ const root: string[] = [];
136
+ for (const key of ["env_vars", "cwd", "bearer_token_env_var", "disabled_tools"] as const) {
137
+ const rendered = tomlValue(server[key]);
138
+ if (rendered !== undefined) root.push(`${key} = ${rendered}`);
139
+ }
140
+ const nested: Array<{ name: string; lines: string[] }> = [];
141
+ for (const key of ["http_headers", "env_http_headers", "auth"] as const) {
142
+ const record = plainRecord(server[key]);
143
+ if (!record) {
144
+ const rendered = tomlValue(server[key]);
145
+ if (rendered !== undefined) root.push(`${key} = ${rendered}`);
146
+ continue;
147
+ }
148
+ const lines = Object.entries(record).flatMap(([name, value]) => {
149
+ const rendered = tomlValue(value);
150
+ return rendered === undefined ? [] : [`${tomlString(name)} = ${rendered}`];
151
+ });
152
+ if (lines.length) nested.push({ name: key, lines });
153
+ }
154
+ return { root, nested };
155
+ }
156
+
157
+ // #555 — compose the launch MCP set from (the relay endpoint, if relay.mcp) PLUS the
158
+ // profile's relay-provisioned servers, PLUS (#662) the host USER-scope servers for an
159
+ // `mcp.mode:"host"` profile. One `--mcp-config` JSON carries them all. `strict` adds
160
+ // `--strict-mcp-config` so ONLY these load — Claude reads no on-disk config, so the
161
+ // project-cwd `.mcp.json` is never discovered (its enable modal can't wedge a headless
162
+ // agent). Whatever the assembler decides host mode should keep is injected here explicitly;
163
+ // strict then preserves exactly that set and nothing else.
164
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
165
+ import { dirname, join, resolve as resolvePath } from "node:path";
166
+ import { fileURLToPath } from "node:url";
167
+ import { SHARED_MCP_SERVER_NAME, sharedCallmuxToolCallTimeoutMsFromEnv, type ProvisioningMcpServer } from "@humwick/sdk";
168
+
169
+ const __dirname = dirname(fileURLToPath(import.meta.url));
170
+
171
+ // #1301 — a bare `command: "callmux"` resolves off PATH, which can drift from the version
172
+ // this package pins (root/runner/orchestrator all pin the same `callmux` dependency). npm
173
+ // (and bun/pnpm workspaces) always link a dependency's declared bin to `node_modules/.bin/<name>`
174
+ // at whichever ancestor directory it resolves/hoists to — the same layout-agnostic-bin
175
+ // invariant #1305/#1222 rely on for the humwick CLI itself — so walk up from this
176
+ // module's own location to find the PINNED bridge binary instead of trusting PATH. Falls
177
+ // back to the bare name (old behavior) only if no bundled bin is found anywhere above.
178
+ export function resolveCallmuxBin(startDir: string = __dirname): string {
179
+ let dir = resolvePath(startDir);
180
+ while (true) {
181
+ const candidate = join(dir, "node_modules", ".bin", "callmux");
182
+ if (existsSync(candidate)) return candidate;
183
+ const parent = dirname(dir);
184
+ if (parent === dir) return "callmux";
185
+ dir = parent;
186
+ }
187
+ }
188
+
189
+ // The shared-listener bridge as a provider-neutral stdio server descriptor: a `callmux bridge`
190
+ // child fronting the host's shared listener, with per-agent `--cwd "$WORKTREE"` for session-cwd
191
+ // isolation (#672). It flows through the existing claude/codex stdio injection unchanged.
192
+ export function sharedMcpBridgeServer(url: string, cwd: string): ProvisioningMcpServer {
193
+ return {
194
+ command: resolveCallmuxBin(),
195
+ args: ["bridge", "--url", url, "--cwd", cwd, "--call-timeout", String(sharedCallmuxToolCallTimeoutMsFromEnv())],
196
+ };
197
+ }
198
+
199
+ // #1575 — the per-agent metaOnly callmux for Codex workers. The header callmux reads to scope
200
+ // session-cwd stdio downstreams (tokenlean) per worktree; the shared bridge injects it dynamically,
201
+ // and here we pin it statically because a stdio callmux has no incoming request headers to forward.
202
+ const CALLMUX_CWD_HEADER = "x-callmux-cwd";
203
+ // The filename the per-agent metaOnly config is materialized under, inside the agent's relay-owned
204
+ // provisioning home. Shared by the assembler (points `callmux --config <path>` at it) and the
205
+ // materializer (writes it) so disk == launch.
206
+ export const SHARED_MCP_METAONLY_CONFIG_FILENAME = "callmux-metaonly.json";
207
+
208
+ // #1450 — the request header the per-agent metaOnly callmux sets on its relay downstream. The runner
209
+ // proxy reads it to decide which relay tools/list view to serve: WITHOUT it (the agent's DIRECT relay
210
+ // client) the proxy hides the on-demand set so only the eager hot-path set boots; WITH it (this
211
+ // callmux, fronting the on-demand set) the proxy serves the FULL set the relay already returned so
212
+ // callmux can route callmux_call to any of them. It can only ever widen the LIST back to that
213
+ // relay-returned set — never authorization: tools/call stays scope-gated regardless (#215 / capability
214
+ // neutrality). Kept out of `forward` so the relay itself never sees it.
215
+ const RELAY_MCP_ONDEMAND_SURFACE_HEADER = "x-agent-relay-ondemand-front";
216
+ export const RELAY_MCP_ONDEMAND_SURFACE_VALUE = "1";
217
+
218
+ // #1450 — a Codex worker's relay downstream inside the per-agent metaOnly callmux. It points at the
219
+ // runner-LOCAL proxy (never the relay directly): the proxy injects the live relay token, so identity
220
+ // rides through and the agent/callmux never hold the real token on disk (#215). `authSecret` is the
221
+ // per-session PROXY secret (which the agent already holds in AGENT_RELAY_SESSION_TOKEN — writing it to
222
+ // this 0600 config adds no exposure, and it is NOT the relay token). cachePolicy denies every tool:
223
+ // relay ops are identity-bearing/mutating and must never be served from cache.
224
+ export interface MetaOnlyRelayDownstream {
225
+ /** The runner-local proxy MCP endpoint (config.relayMcpEndpoint when the proxy is active). */
226
+ endpoint: string;
227
+ /** The per-session proxy secret the agent authenticates to the local proxy with. */
228
+ authSecret: string;
229
+ generation?: ToolGeneration;
230
+ }
231
+
232
+ function metaOnlyRelayServerConfig(relay: MetaOnlyRelayDownstream): Record<string, unknown> {
233
+ return {
234
+ url: generationEndpoint(relay.endpoint, relay.generation ?? "relay"),
235
+ transport: "streamable-http",
236
+ headers: {
237
+ Authorization: `Bearer ${relay.authSecret}`,
238
+ [RELAY_MCP_ONDEMAND_SURFACE_HEADER]: RELAY_MCP_ONDEMAND_SURFACE_VALUE,
239
+ },
240
+ // #1450 — never cache relay ops (identity-bearing/mutating). Wildcard-deny is explicit belt-and-
241
+ // suspenders on top of the config's disabled global cache TTL.
242
+ cachePolicy: { denyTools: ["*"] },
243
+ callTimeoutMs: sharedCallmuxToolCallTimeoutMsFromEnv(),
244
+ };
245
+ }
246
+
247
+ // The per-agent metaOnly callmux config: streamable-http downstreams exposing ONLY the callmux
248
+ // meta-tools (callmux_search_tools/callmux_call/…). A Codex worker boots with ~11 meta-tool schemas
249
+ // instead of the flat downstream schemas (#1570 dominant lever) and reaches the real tools on demand
250
+ // through callmux_call.
251
+ // • The shared listener (SHARED_MCP_SERVER_NAME): the worktree cwd is a STATIC downstream header —
252
+ // the per-agent callmux runs over stdio (no incoming headers to forwardHeaders) and the worktree
253
+ // is fixed per agent, so a fixed x-callmux-cwd routes the shared listener's session-cwd tools to
254
+ // this worktree exactly as the dynamic bridge header did.
255
+ // • #1450 — when `relay` is given, the identity-bearing relay endpoint is added as a SECOND
256
+ // downstream (via the runner-local proxy) so the on-demand relay set loads through callmux_call.
257
+ // Timeouts mirror the bridge's SHARED_CALLMUX_TOOL_CALL_TIMEOUT_MS floor so nested calls keep the same
258
+ // call-timeout budget.
259
+ export function sharedMcpMetaOnlyConfig(url: string, cwd: string, relay?: MetaOnlyRelayDownstream): Record<string, unknown> {
260
+ const callTimeoutMs = sharedCallmuxToolCallTimeoutMsFromEnv();
261
+ return {
262
+ $schema: "callmux/schema.json",
263
+ metaOnly: true,
264
+ exposeMetaTools: true,
265
+ callTimeoutMs,
266
+ servers: {
267
+ [SHARED_MCP_SERVER_NAME]: {
268
+ url,
269
+ transport: "streamable-http",
270
+ headers: { [CALLMUX_CWD_HEADER]: cwd },
271
+ // The shared listener owns caching; a per-agent cache would bypass the call log.
272
+ cachePolicy: { denyTools: ["*"] },
273
+ callTimeoutMs,
274
+ },
275
+ ...(relay ? { [mcpServerInfo(relay.generation ?? "relay").name]: metaOnlyRelayServerConfig(relay) } : {}),
276
+ },
277
+ };
278
+ }
279
+
280
+ // The per-agent metaOnly callmux as a provider-neutral stdio server descriptor: a `callmux --config
281
+ // <path>` child running the config above. Rides the SAME claude/codex stdio injection as the plain
282
+ // bridge. The config path is non-secret (a relay-owned scratch file), so it is safe in argv/config.
283
+ export function sharedMcpMetaOnlyServer(configPath: string): ProvisioningMcpServer {
284
+ return {
285
+ command: resolveCallmuxBin(),
286
+ args: ["--config", configPath],
287
+ };
288
+ }
289
+
290
+ // Materialize the per-agent metaOnly config `sharedMcpMetaOnlyServer(configPath)` points at. The
291
+ // shared-listener URL is non-secret. #1450 — when a `relay` downstream is given, the config carries
292
+ // the per-session PROXY secret (NOT the relay token — the proxy injects that; #215). The agent
293
+ // already holds the proxy secret in AGENT_RELAY_SESSION_TOKEN, so the 0600 file adds no exposure.
294
+ export function writeSharedMcpMetaOnlyConfig(configPath: string, url: string, cwd: string, relay?: MetaOnlyRelayDownstream): void {
295
+ mkdirSync(dirname(configPath), { recursive: true });
296
+ writeFileSync(configPath, JSON.stringify(sharedMcpMetaOnlyConfig(url, cwd, relay), null, 2) + "\n", { mode: 0o600 });
297
+ }
298
+
299
+ // #1292 — shared by the Claude JSON payload builder and the Codex TOML materializer, so
300
+ // both projections merge `configEnv` (non-secret, allowlisted literals) into `env` the
301
+ // same way instead of re-deriving the merge twice.
302
+ export function mergedConfigEnv(server: ProvisioningMcpServer): ProvisioningMcpServer {
303
+ if (!server.configEnv || Object.keys(server.configEnv).length === 0) return server;
304
+ return {
305
+ ...server,
306
+ env: writeChildEnv({}, { ...server.configEnv, ...(server.env ?? {}) }),
307
+ };
308
+ }
309
+
310
+ function claudeMcpServerEntry(server: ProvisioningMcpServer): Record<string, unknown> {
311
+ const merged = mergedConfigEnv(server);
312
+ const entry: Record<string, unknown> = {};
313
+ if (merged.type) entry.type = merged.type;
314
+ if (merged.command) entry.command = merged.command;
315
+ if (merged.args?.length) entry.args = merged.args;
316
+ if (merged.env && Object.keys(merged.env).length) entry.env = merged.env;
317
+ if (merged.url) entry.url = merged.url;
318
+ if (merged.headers && Object.keys(merged.headers).length) entry.headers = merged.headers;
319
+ return entry;
320
+ }
321
+
322
+ export interface ClaudeMcpConfigOpts {
323
+ relayUrl: string;
324
+ endpoint?: string;
325
+ includeRelay: boolean;
326
+ generation?: ToolGeneration;
327
+ servers?: Record<string, ProvisioningMcpServer>;
328
+ /** #662 — host USER-scope MCP servers (`~/.claude.json` `mcpServers`), re-injected for
329
+ * `mcp.mode:"host"` so `--strict-mcp-config` PRESERVES them instead of dropping the
330
+ * ambient host MCP a host-base agent loaded before strict. Lowest precedence: the relay
331
+ * endpoint and relay-provisioned servers win on a name collision (relay decides). */
332
+ hostServers?: Record<string, ProvisioningMcpServer>;
333
+ }
334
+
335
+ // #1292 — the `{ mcpServers: {...} }` object Claude parses, extracted so both the launch
336
+ // assembler (decides whether it's written to argv or a file) and the materializer (writes
337
+ // the file) build the IDENTICAL payload from the same inputs.
338
+ export function claudeMcpConfigPayload(opts: ClaudeMcpConfigOpts): Record<string, unknown> {
339
+ const mcpServers: Record<string, unknown> = {};
340
+ // Host user-scope servers go in first so relay endpoint + provisioned servers below can
341
+ // override them by name (relay-owned config is authoritative over ambient host MCP).
342
+ for (const [name, server] of Object.entries(opts.hostServers ?? {})) {
343
+ if (isRelayMcpServerName(name)) continue;
344
+ const entry = claudeMcpServerEntry(server);
345
+ if (Object.keys(entry).length) mcpServers[name] = entry;
346
+ }
347
+ if (opts.includeRelay) {
348
+ mcpServers[mcpServerInfo(opts.generation ?? "relay").name] = {
349
+ type: "http",
350
+ url: generationEndpoint(opts.endpoint ?? relayMcpEndpoint(opts.relayUrl), opts.generation ?? "relay"),
351
+ headers: { Authorization: `Bearer \${${RELAY_MCP_TOKEN_ENV}}` },
352
+ };
353
+ }
354
+ for (const [name, server] of Object.entries(opts.servers ?? {})) {
355
+ // Never let a declared server shadow the relay endpoint (identity-bearing).
356
+ if (isRelayMcpServerName(name)) continue;
357
+ const entry = claudeMcpServerEntry(server);
358
+ if (Object.keys(entry).length) mcpServers[name] = entry;
359
+ }
360
+ return mcpServers;
361
+ }
362
+
363
+ // OpenCode's config strings use `{env:NAME}` rather than Claude's `${NAME}` convention.
364
+ // Translate only the provider-neutral env/header values where `${NAME}` is documented as an
365
+ // environment reference; command arguments remain literal.
366
+ function openCodeConfigEnvValue(value: string): string {
367
+ return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, "{env:$1}");
368
+ }
369
+
370
+ function openCodeMcpServerEntry(server: ProvisioningMcpServer): Record<string, unknown> | undefined {
371
+ const merged = mergedConfigEnv(server);
372
+ if (merged.command) {
373
+ const environment = Object.fromEntries(
374
+ Object.entries(merged.env ?? {}).map(([name, value]) => [name, openCodeConfigEnvValue(value)]),
375
+ );
376
+ return {
377
+ type: "local",
378
+ command: [merged.command, ...(merged.args ?? [])],
379
+ ...(merged.cwd ? { cwd: merged.cwd } : {}),
380
+ ...(Object.keys(environment).length ? { environment } : {}),
381
+ timeout: OPENCODE_MCP_TIMEOUT_MS,
382
+ };
383
+ }
384
+ if (!merged.url) return undefined;
385
+ const headers = Object.fromEntries(
386
+ Object.entries(merged.headers ?? {}).map(([name, value]) => [name, openCodeConfigEnvValue(value)]),
387
+ );
388
+ return {
389
+ type: "remote",
390
+ url: merged.url,
391
+ ...(Object.keys(headers).length ? { headers } : {}),
392
+ // Managed agents cannot complete an interactive OAuth flow. Provisioned credentials ride
393
+ // headers/env instead, so fail directly rather than opening a browser prompt.
394
+ oauth: false,
395
+ timeout: OPENCODE_MCP_TIMEOUT_MS,
396
+ };
397
+ }
398
+
399
+ export interface OpenCodeMcpConfigOpts {
400
+ relayUrl: string;
401
+ endpoint?: string;
402
+ includeRelay: boolean;
403
+ generation?: ToolGeneration;
404
+ servers?: Record<string, ProvisioningMcpServer>;
405
+ }
406
+
407
+ /** Render OpenCode's native `opencode.json#mcp` map without persisting any Relay credential. */
408
+ export function openCodeMcpConfigPayload(opts: OpenCodeMcpConfigOpts): Record<string, unknown> {
409
+ const mcp: Record<string, unknown> = {};
410
+ if (opts.includeRelay) {
411
+ mcp[mcpServerInfo(opts.generation ?? "relay").name] = {
412
+ type: "remote",
413
+ url: generationEndpoint(opts.endpoint ?? relayMcpEndpoint(opts.relayUrl), opts.generation ?? "relay"),
414
+ headers: { Authorization: `Bearer {env:${RELAY_MCP_TOKEN_ENV}}` },
415
+ oauth: false,
416
+ timeout: OPENCODE_MCP_TIMEOUT_MS,
417
+ };
418
+ }
419
+ for (const [name, server] of Object.entries(opts.servers ?? {})) {
420
+ if (isRelayMcpServerName(name)) continue;
421
+ const entry = openCodeMcpServerEntry(server);
422
+ if (entry) mcp[name] = entry;
423
+ }
424
+ return mcp;
425
+ }
426
+
427
+ export function claudeMcpLaunchArgs(opts: ClaudeMcpConfigOpts & {
428
+ strict?: boolean;
429
+ /** #1292 — when set, `--mcp-config` points at this path instead of carrying the JSON
430
+ * inline. Inline JSON lands verbatim in argv, which is world-readable via `ps`/
431
+ * `/proc/<pid>/cmdline`; a provisioned server's `env`/`headers` can carry a literal
432
+ * secret, so any launch with a relay-owned scratch dir to write into (see
433
+ * `providerProvisioningHomePathFor`) MUST use this instead. The caller is responsible
434
+ * for actually writing the file (the launch assembler is pure) — see
435
+ * `materializeLaunchAssembly` / `claudeMcpConfigPayload`. */
436
+ configFilePath?: string;
437
+ }): string[] {
438
+ const mcpServers = claudeMcpConfigPayload(opts);
439
+ // Nothing to inject and not enforcing strict isolation → don't pass an empty config.
440
+ if (Object.keys(mcpServers).length === 0 && !opts.strict) return [];
441
+ const configArg = opts.configFilePath ?? JSON.stringify({ mcpServers });
442
+ return ["--mcp-config", configArg, ...(opts.strict ? ["--strict-mcp-config"] : [])];
443
+ }
444
+
445
+ // #555 (Codex) — `-c mcp_servers.<name>.*` overrides for each relay-provisioned server, the
446
+ // same shape relayMcpCodexConfigArgs uses for the relay endpoint. #1292 — this puts a
447
+ // provisioned server's literal `env`/`headers` secret material in argv (world-readable via
448
+ // `ps`/`/proc/<pid>/cmdline`), so it's ONLY safe to use when there is no isolated CODEX_HOME
449
+ // to materialize `codexProvisionedMcpToml` into instead (host-base Codex reuses the real
450
+ // `~/.codex`, which relay must not write provisioned secrets into — see
451
+ // `providerHomePathFor`). Callers with an isolated CODEX_HOME must use
452
+ // `codexProvisionedMcpTimeoutArgs` + `codexProvisionedMcpToml` instead.
453
+ export function codexProvisionedMcpArgs(servers?: Record<string, ProvisioningMcpServer>): string[] {
454
+ const args: string[] = [];
455
+ for (const [name, rawServer] of Object.entries(servers ?? {})) {
456
+ if (isRelayMcpServerName(name)) continue;
457
+ const server = codexVaultMcpEnv(mergedConfigEnv(rawServer));
458
+ const key = `mcp_servers.${name}`;
459
+ if (server.command) args.push("-c", `${key}.command=${tomlString(server.command)}`);
460
+ if (server.args?.length) args.push("-c", `${key}.args=[${server.args.map(tomlString).join(",")}]`);
461
+ if (server.url) args.push("-c", `${key}.url=${tomlString(server.url)}`);
462
+ if (server.env_vars) args.push("-c", `${key}.env_vars=${tomlInlineValue(server.env_vars)}`);
463
+ for (const [envKey, envVal] of Object.entries(server.env ?? {})) {
464
+ args.push("-c", `${key}.env.${envKey}=${tomlString(envVal)}`);
465
+ }
466
+ if (server.disabled_tools?.length) args.push("-c", `${key}.disabled_tools=${tomlValue(server.disabled_tools)}`);
467
+ args.push(...codexTimeoutConfigArgs(key)); // #1248
468
+ }
469
+ return args;
470
+ }
471
+
472
+ // #1292 — the non-secret half of the provisioned-server `-c` overrides (per-server
473
+ // tool/startup timeouts only). Pairs with `codexProvisionedMcpToml`, which carries
474
+ // command/args/url/env into CODEX_HOME/config.toml instead of argv.
475
+ export function codexProvisionedMcpTimeoutArgs(servers?: Record<string, ProvisioningMcpServer>): string[] {
476
+ const args: string[] = [];
477
+ for (const name of Object.keys(servers ?? {})) {
478
+ if (isRelayMcpServerName(name)) continue;
479
+ args.push(...codexTimeoutConfigArgs(`mcp_servers.${name}`));
480
+ }
481
+ return args;
482
+ }
483
+
484
+ // #1292 — renders provisioned servers as `[mcp_servers.<name>]` / `[mcp_servers.<name>.env]`
485
+ // TOML tables for CODEX_HOME/config.toml, mirroring the `-c mcp_servers.<name>.*` dotted-key
486
+ // shape `codexProvisionedMcpArgs` uses, so command/args/url/env never touch argv. Table/key
487
+ // names are always TOML-quoted (server + env-var names aren't guaranteed bare-key safe).
488
+ export function codexProvisionedMcpToml(servers?: Record<string, ProvisioningMcpServer>): string {
489
+ const blocks: string[] = [];
490
+ for (const [name, rawServer] of Object.entries(servers ?? {})) {
491
+ if (isRelayMcpServerName(name)) continue;
492
+ const server = codexVaultMcpEnv(mergedConfigEnv(rawServer));
493
+ const tableKey = `mcp_servers.${tomlString(name)}`;
494
+ const lines: string[] = [`[${tableKey}]`];
495
+ if (server.command) lines.push(`command = ${tomlString(server.command)}`);
496
+ if (server.args?.length) lines.push(`args = [${server.args.map(tomlString).join(", ")}]`);
497
+ if (server.url) lines.push(`url = ${tomlString(server.url)}`);
498
+ const native = codexNativeMcpToml(server);
499
+ lines.push(...native.root);
500
+ if (lines.length > 1) blocks.push(lines.join("\n"));
501
+ const envEntries = Object.entries(server.env ?? {});
502
+ if (envEntries.length) {
503
+ blocks.push([`[${tableKey}.env]`, ...envEntries.map(([k, v]) => `${tomlString(k)} = ${tomlString(v)}`)].join("\n"));
504
+ }
505
+ for (const nested of native.nested) {
506
+ blocks.push([`[${tableKey}.${nested.name}]`, ...nested.lines].join("\n"));
507
+ }
508
+ }
509
+ return blocks.length ? `${blocks.join("\n\n")}\n` : "";
510
+ }
511
+
512
+ // Replace a `[header]` TOML table's body in-place (or append it if absent). Used to keep
513
+ // repeated materialize calls for the same instance idempotent — a changed server env
514
+ // replaces the old table body instead of duplicating it.
515
+ /** Remove every `[prefix…]` table (header through the line before the next header). */
516
+ export function withoutTomlTables(config: string, prefix: string): string {
517
+ const lines = config.split("\n");
518
+ const kept: string[] = [];
519
+ let dropping = false;
520
+ for (const line of lines) {
521
+ const header = /^\s*\[([^\]]+)\]\s*(?:#.*)?$/.exec(line);
522
+ if (header) dropping = header[1]!.trim().startsWith(prefix);
523
+ if (!dropping) kept.push(line);
524
+ }
525
+ return kept.join("\n");
526
+ }
527
+
528
+ export function upsertTomlTable(config: string, header: string, bodyLines: string[]): string {
529
+ const headerLine = `[${header}]`;
530
+ const lines = config.length ? config.split("\n") : [];
531
+ const headerIndex = lines.findIndex((line) => line.trim() === headerLine);
532
+ if (headerIndex < 0) {
533
+ const prefix = config.length ? (config.endsWith("\n") ? config : `${config}\n`) : "";
534
+ return `${prefix}${prefix.length ? "\n" : ""}${headerLine}\n${bodyLines.join("\n")}${bodyLines.length ? "\n" : ""}`;
535
+ }
536
+ let nextHeaderIndex = lines.length;
537
+ for (let i = headerIndex + 1; i < lines.length; i += 1) {
538
+ if (/^\s*\[[^\]]+\]\s*(?:#.*)?$/.test(lines[i] ?? "")) {
539
+ nextHeaderIndex = i;
540
+ break;
541
+ }
542
+ }
543
+ lines.splice(headerIndex + 1, nextHeaderIndex - (headerIndex + 1), ...bodyLines);
544
+ return lines.join("\n");
545
+ }
546
+
547
+ // #1292 — idempotently upsert provisioned MCP servers' command/args/url/env into
548
+ // CODEX_HOME/config.toml, the isolated per-instance home Codex actually reads. Pairs with
549
+ // `codexProvisionedMcpTimeoutArgs` (the non-secret `-c` overrides that stay in argv). Only
550
+ // safe to call for an ISOLATED CODEX_HOME (host-base reuses the real `~/.codex`, which relay
551
+ // must never write provisioned secrets into) — callers gate on `providerHomePathFor`.
552
+ export function writeCodexMcpServersToml(codexHome: string, servers: Record<string, ProvisioningMcpServer> | undefined): void {
553
+ const relevant = Object.entries(servers ?? {}).filter(([name]) => !isRelayMcpServerName(name));
554
+ const configPath = join(codexHome, "config.toml");
555
+ const existing = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
556
+ // #3387 — every mcp_servers table in a managed CODEX_HOME is Relay-written: drop the previous
557
+ // launch's set first, so a host MCP class switched off on reuse leaves no stale server behind.
558
+ let config = withoutTomlTables(existing, "mcp_servers.");
559
+ if (!relevant.length) {
560
+ if (config !== existing) writeFileSync(configPath, config, { mode: 0o600 });
561
+ return;
562
+ }
563
+ mkdirSync(codexHome, { recursive: true });
564
+ for (const [name, rawServer] of relevant) {
565
+ const server = codexVaultMcpEnv(mergedConfigEnv(rawServer));
566
+ const tableKey = `mcp_servers.${tomlString(name)}`;
567
+ const bodyLines: string[] = [];
568
+ if (server.command) bodyLines.push(`command = ${tomlString(server.command)}`);
569
+ if (server.args?.length) bodyLines.push(`args = [${server.args.map(tomlString).join(", ")}]`);
570
+ if (server.url) bodyLines.push(`url = ${tomlString(server.url)}`);
571
+ const native = codexNativeMcpToml(server);
572
+ bodyLines.push(...native.root);
573
+ config = upsertTomlTable(config, tableKey, bodyLines);
574
+ const envEntries = Object.entries(server.env ?? {});
575
+ if (envEntries.length) {
576
+ config = upsertTomlTable(config, `${tableKey}.env`, envEntries.map(([k, v]) => `${tomlString(k)} = ${tomlString(v)}`));
577
+ }
578
+ for (const nested of native.nested) {
579
+ config = upsertTomlTable(config, `${tableKey}.${nested.name}`, nested.lines);
580
+ }
581
+ }
582
+ writeFileSync(configPath, config, { mode: 0o600 });
583
+ }