@humwick/runner 0.0.0-stage → 0.130.1

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 +42 -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,703 @@
1
+ import { providerIntegrationFiles, projectClaudePermissionRules } from "./provider-integration";
2
+ import { runnerMcpGeneration } from "./relay-mcp";
3
+ import { writeChildEnv } from "@humwick/sdk/env";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
6
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
7
+ import { homedir } from "node:os";
8
+ import { sanitizeFsName } from "@humwick/sdk/fs-name";
9
+ import { RETIRED_SHARED_MCP_SERVER_NAMES, isRelayMcpServerName, mcpServerInfo, wireToolName, type ToolGeneration, coerceProjectMcpServer, fffMcpBinaryPath, PROJECT_MCP_FILE, profileProjectSourceEnabled } from "@humwick/sdk";
10
+ import { getManifest } from "@humwick/providers";
11
+ import type { ProvisioningAssetFile, ProvisioningHookSet, ProvisioningMcpServer, ResolvedProvisioning } from "@humwick/sdk";
12
+ import type { RunnerSpawnConfig } from "./adapter";
13
+ import { humwickHome } from "./config";
14
+ import { preflightProvisionedMcpServers } from "./mcp-preflight";
15
+ import { tomlInlineValue, tomlString } from "./relay-mcp";
16
+ import { profileAllowsHostGlobalInstructions, providerSourceHome } from "./profile-home";
17
+ import { hostSourcesFor, skillDirsUnder } from "./host-asset-plan";
18
+
19
+ // Runner-side materialization of the relay-resolved provisioning bundle
20
+ // (#554/#555/#556). The relay resolves a profile's refs against the DB registry and
21
+ // attaches the concrete definitions to profile.provisioning; here we turn those into
22
+ // real on-disk dirs (skills/plugins). MCP is handled in relay-mcp.ts.
23
+ //
24
+ // #557 — split into PURE RESOLUTION (resolve* → where each asset lands, no writes) and
25
+ // MATERIALIZATION (materialize* → the disk writes). The single launch assembler
26
+ // (launch-assembly.ts) calls the resolvers to build BOTH the launch args and the
27
+ // projection report, so what's reported can never drift from what's written; the
28
+ // materialize* writers consume the same resolved entries, so what's written can never
29
+ // drift from what's reported either. One resolution, three consumers.
30
+
31
+ const EMPTY: ResolvedProvisioning = { skills: [], plugins: [], hooks: [], mcpServers: {} };
32
+
33
+ export function profileProvisioning(config: Pick<RunnerSpawnConfig, "agentProfile">): ResolvedProvisioning {
34
+ const p = config.agentProfile?.provisioning;
35
+ if (!p) return EMPTY;
36
+ return { skills: p.skills ?? [], plugins: p.plugins ?? [], hooks: p.hooks ?? [], mcpServers: p.mcpServers ?? {}, ...(p.skipped?.length ? { skipped: p.skipped } : {}) };
37
+ }
38
+
39
+ // The fff search-redirect hook is bundled per provider manifest; it only needs the binary path in env.
40
+ function providerBundlesFffRedirect(provider: RunnerSpawnConfig["provider"]): boolean {
41
+ return Boolean(getManifest(provider)?.provisioning?.bundledHooks?.some((hook) => hook.ref === "agent-relay-fff-search-redirect"));
42
+ }
43
+
44
+ // #2729 — provisioned stdio commands authored on another host are swapped for this host's
45
+ // provisioned descriptor or dropped here, never emitted broken. #2809 — the fff path rewrite
46
+ // lives here too so every provider launch gets it before the preflight judges the path.
47
+ export function launchProvisionedMcpServers(
48
+ config: Pick<RunnerSpawnConfig, "agentProfile">,
49
+ servers: Record<string, ProvisioningMcpServer> = profileProvisioning(config).mcpServers,
50
+ ): Record<string, ProvisioningMcpServer> {
51
+ const fff = servers.fff;
52
+ const binary = fff?.command && !fff.url ? fffMcpBinaryPath(humwickHome()) : undefined;
53
+ return preflightProvisionedMcpServers(binary ? { ...servers, fff: { ...fff, command: binary } } : servers, {
54
+ sharedListener: Boolean(config.agentProfile?.sharedMcp),
55
+ // The fff binary is downloaded after launch resolution, so its (rewritten) path is exempt.
56
+ skip: binary ? ["fff"] : [],
57
+ home: humwickHome(),
58
+ });
59
+ }
60
+
61
+ export function fffMcpHookEnv(config: Pick<RunnerSpawnConfig, "agentProfile" | "provider">): Record<string, string> {
62
+ return writeChildEnv({}, { AGENT_RELAY_FFF_MCP_BINARY: providerBundlesFffRedirect(config.provider)
63
+ ? launchProvisionedMcpServers(config).fff?.command ?? "" : "" });
64
+ }
65
+
66
+ /** The resolved source matrix decides project MCP/skill projection (see profileProjectSourceEnabled). */
67
+ export function projectSourceEnabled(
68
+ config: Pick<RunnerSpawnConfig, "agentProfile">,
69
+ capability: "mcp" | "skills",
70
+ ): boolean {
71
+ return profileProjectSourceEnabled(config.agentProfile, capability);
72
+ }
73
+
74
+ /**
75
+ * Project settings are currently the `projectSettings`/`localSettings` sibling of the source
76
+ * matrix (see ProfileCellSiblings), rather than a Project capability cell. It is nevertheless
77
+ * resolved profile policy: the runner must not require, or consult, a spawn-time override.
78
+ */
79
+ export function projectSettingsEnabled(config: Pick<RunnerSpawnConfig, "agentProfile">): boolean {
80
+ return Boolean(config.agentProfile?.projectSettings);
81
+ }
82
+
83
+ // #667 — project `.mcp.json` MCP projection. OPT-IN via the resolved profile's `projectMcp`
84
+ // (set per-spawn or by profile, relay-side). Reads the spawn cwd's COMMITTED `.mcp.json` and
85
+ // returns its `mcpServers` as a provider-neutral record, so the launch assembler injects them
86
+ // through the CONTROLLED `--mcp-config` (Claude) / `-c mcp_servers.*` (Codex) path that
87
+ // survives `--strict-mcp-config` — the project's own servers travel with the repo like they do
88
+ // for a human who clones it, WITHOUT enabling Claude's ambient project-discovery trust modal
89
+ // (which wedges headless agents, the reason #662 made every managed launch strict). The runner
90
+ // is the filesystem authority for the spawn cwd (relay may run on another host), so the read
91
+ // lands here. Best-effort: a missing/malformed file (or one with no `mcpServers`) yields {} and
92
+ // never aborts the launch. Returns {} when projection is OFF.
93
+ export function resolveProjectMcpServers(
94
+ config: Pick<RunnerSpawnConfig, "agentProfile" | "cwd">,
95
+ ): Record<string, ProvisioningMcpServer> {
96
+ const profile = config.agentProfile;
97
+ if (!projectSourceEnabled(config, "mcp")) return {};
98
+ // N7 — a stamped source matrix is the only trust-gated project MCP path. The
99
+ // server decides roots/pinned/any against its registry and carries the result
100
+ // through the spawn-scoped profile. Missing/denied decisions fail closed.
101
+ if (profile?.sources?.project && !profile.projectGates?.mcp?.allowed) return {};
102
+ // #3043 — a project role's entries were read at one commit; the working tree never applies.
103
+ if (profile?.pinnedProjectMcp) return { ...profile.pinnedProjectMcp.servers };
104
+ const file = join(config.cwd, PROJECT_MCP_FILE);
105
+ if (!existsSync(file)) return {};
106
+ let parsed: unknown;
107
+ try {
108
+ parsed = JSON.parse(readFileSync(file, "utf8"));
109
+ } catch (err) {
110
+ console.warn(`project .mcp.json projection: failed to parse ${file}: ${(err as Error).message}`);
111
+ return {};
112
+ }
113
+ const servers = (parsed as { mcpServers?: unknown } | null)?.mcpServers;
114
+ if (!servers || typeof servers !== "object") return {};
115
+ const out: Record<string, ProvisioningMcpServer> = {};
116
+ for (const [name, raw] of Object.entries(servers as Record<string, unknown>)) {
117
+ const server = coerceProjectMcpServer(raw);
118
+ if (server) out[name] = server;
119
+ }
120
+ return out;
121
+ }
122
+
123
+ export function resolveClaudeProjectSkills(
124
+ claudeHome: string,
125
+ config: Pick<RunnerSpawnConfig, "agentProfile" | "cwd">,
126
+ reservedNames: ReadonlySet<string> = new Set(),
127
+ ): ResolvedAsset[] {
128
+ if (!projectSourceEnabled(config, "skills")) return [];
129
+ return linkedSkillAssets(join(config.cwd, ".claude", "skills"), claudeHome, reservedNames);
130
+ }
131
+
132
+ // Skill dirs (including symlinked ones) under `skillsRoot`, linked into `claudeHome/skills`.
133
+ function linkedSkillAssets(skillsRoot: string, claudeHome: string, reservedNames: ReadonlySet<string>): ResolvedAsset[] {
134
+ const targetRoot = join(claudeHome, "skills");
135
+ return skillDirsUnder(skillsRoot)
136
+ .map((source) => ({ name: basename(source), source }))
137
+ .filter(({ name }) => !reservedNames.has(name))
138
+ .map(({ name, source }) => ({ name, dir: join(targetRoot, assetDirName(name)), source: { kind: "link" as const, source } }))
139
+ .sort((a, b) => a.name.localeCompare(b.name));
140
+ }
141
+
142
+ export function resolveClaudeHostSkills(
143
+ claudeHome: string,
144
+ config: Pick<RunnerSpawnConfig, "agentProfile">,
145
+ reservedNames: ReadonlySet<string> = new Set(),
146
+ provider: string = "claude",
147
+ ): ResolvedAsset[] {
148
+ if (!hostSourcesFor(config).skills) return [];
149
+ // #2513 — keyed by provider id so a dedicatedHome claude-family provider (claude-b)
150
+ // resolves host skills from ITS OWN source home, never ~/.claude's.
151
+ return linkedSkillAssets(join(providerSourceHome(provider), "skills"), claudeHome, reservedNames);
152
+ }
153
+
154
+ export interface ResolvedSourceShadow {
155
+ source: "host" | "project" | "agent-relay";
156
+ capability: "mcp" | "hooks" | "skills" | "plugins";
157
+ name: string;
158
+ }
159
+
160
+ export interface ResolvedClaudeSkillSources {
161
+ relay: ResolvedAsset[];
162
+ project: ResolvedAsset[];
163
+ host: ResolvedAsset[];
164
+ shadows: ResolvedSourceShadow[];
165
+ }
166
+
167
+ /** Resolve the skill union once, preserving the actual lower-precedence collisions. */
168
+ export function resolveClaudeSkillSources(claudeHome: string, config: RunnerSpawnConfig, provider: string = "claude"): ResolvedClaudeSkillSources {
169
+ const relay = resolveClaudeProvisionedSkills(claudeHome, config);
170
+ const relayNames = new Set(relay.map((asset) => asset.name));
171
+ const projectAll = resolveClaudeProjectSkills(claudeHome, config);
172
+ const project = projectAll.filter((asset) => !relayNames.has(asset.name));
173
+ const projectNames = new Set(project.map((asset) => asset.name));
174
+ const hostAll = resolveClaudeHostSkills(claudeHome, config, new Set(), provider);
175
+ const host = hostAll.filter((asset) => !relayNames.has(asset.name) && !projectNames.has(asset.name));
176
+ return {
177
+ relay,
178
+ project,
179
+ host,
180
+ shadows: [
181
+ ...projectAll.filter((asset) => relayNames.has(asset.name)).map((asset) => ({ source: "project" as const, capability: "skills" as const, name: asset.name })),
182
+ ...hostAll.filter((asset) => relayNames.has(asset.name) || projectNames.has(asset.name)).map((asset) => ({ source: "host" as const, capability: "skills" as const, name: asset.name })),
183
+ ],
184
+ };
185
+ }
186
+
187
+ export function resolveMcpSourceShadows(
188
+ projectRaw: Record<string, unknown>,
189
+ provisioned: Record<string, unknown>,
190
+ host: Record<string, unknown>,
191
+ declared: Record<string, unknown>,
192
+ shared: Record<string, unknown>,
193
+ relayEndpoint: boolean,
194
+ ): ResolvedSourceShadow[] {
195
+ const wins = new Set([...Object.keys(provisioned), ...Object.keys(shared), ...RETIRED_SHARED_MCP_SERVER_NAMES]);
196
+ return [
197
+ ...Object.keys(projectRaw).filter((name) => (isRelayMcpServerName(name) && relayEndpoint) || wins.has(name)).map((name) => ({ source: "project" as const, capability: "mcp" as const, name })),
198
+ ...Object.keys(host).filter((name) => (isRelayMcpServerName(name) && relayEndpoint) || name in declared || wins.has(name)).map((name) => ({ source: "host" as const, capability: "mcp" as const, name })),
199
+ ];
200
+ }
201
+
202
+ const UNSAFE_PROJECT_CLAUDE_SETTINGS = new Set([
203
+ "apiKeyHelper",
204
+ "allowDangerouslySkipPermissions",
205
+ "allowedTools",
206
+ "disabledMcpjsonServers",
207
+ "disallowedTools",
208
+ "enableAllProjectMcpServers",
209
+ "enabledMcpjsonServers",
210
+ "env",
211
+ "mcpServers",
212
+ "permissionMode",
213
+ "permissions",
214
+ "statusLine",
215
+ "tools",
216
+ ]);
217
+
218
+ export interface ResolvedProjectClaudeSettings {
219
+ applied: boolean;
220
+ settings: Record<string, unknown>;
221
+ keys: string[];
222
+ ignoredKeys: string[];
223
+ file?: string;
224
+ }
225
+
226
+ export function resolveProjectClaudeSettings(
227
+ config: Pick<RunnerSpawnConfig, "agentProfile" | "cwd">,
228
+ ): ResolvedProjectClaudeSettings {
229
+ if (!projectSettingsEnabled(config)) return { applied: false, settings: {}, keys: [], ignoredKeys: [] };
230
+ const file = join(config.cwd, ".claude", "settings.json");
231
+ if (!existsSync(file)) return { applied: true, settings: {}, keys: [], ignoredKeys: [], file };
232
+ let parsed: unknown;
233
+ try {
234
+ parsed = JSON.parse(readFileSync(file, "utf8"));
235
+ } catch (err) {
236
+ console.warn(`project .claude/settings.json projection: failed to parse ${file}: ${(err as Error).message}`);
237
+ return { applied: true, settings: {}, keys: [], ignoredKeys: [], file };
238
+ }
239
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
240
+ return { applied: true, settings: {}, keys: [], ignoredKeys: [], file };
241
+ }
242
+ const settings: Record<string, unknown> = {};
243
+ const ignoredKeys: string[] = [];
244
+ for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
245
+ if (key === "hooks") {
246
+ if (config.agentProfile?.sources?.project?.hooks?.enabled && config.agentProfile.projectGates?.hooks?.allowed) settings[key] = value;
247
+ else ignoredKeys.push(key);
248
+ continue;
249
+ }
250
+ if (UNSAFE_PROJECT_CLAUDE_SETTINGS.has(key)) {
251
+ ignoredKeys.push(key);
252
+ continue;
253
+ }
254
+ settings[key] = value;
255
+ }
256
+ return {
257
+ applied: true,
258
+ settings,
259
+ keys: Object.keys(settings).sort(),
260
+ ignoredKeys: ignoredKeys.sort(),
261
+ file,
262
+ };
263
+ }
264
+
265
+ // A resolved provisioning asset: WHERE it lands (`dir`) and HOW it materializes
266
+ // (`source`). `files` writes the inline set into `dir`; `link` symlinks a pre-existing
267
+ // source dir at `dir`; `inPlace` uses the source dir as-is (no write, dir IS the source).
268
+ export type ResolvedAssetSource =
269
+ | { kind: "files"; files: ProvisioningAssetFile[] }
270
+ | { kind: "link"; source: string }
271
+ | { kind: "inPlace" };
272
+ export interface ResolvedAsset {
273
+ name: string;
274
+ dir: string;
275
+ source: ResolvedAssetSource;
276
+ provenance?: "relay" | "project";
277
+ }
278
+
279
+ export interface ResolvedHookAsset extends ResolvedAsset {
280
+ hooks: ProvisioningHookSet;
281
+ }
282
+
283
+ function assetDirName(name: string): string {
284
+ return sanitizeFsName(name, { replacement: "-", collapse: false, maxLen: 120, fallback: "asset" });
285
+ }
286
+
287
+ function resolveExistingDir(dir: string, cwd: string): string | null {
288
+ const abs = isAbsolute(dir) ? dir : resolve(cwd, dir);
289
+ return existsSync(abs) ? abs : null;
290
+ }
291
+
292
+ function hasRootSkillManifest(files: ProvisioningAssetFile[]): boolean {
293
+ return files.some((f) => f.path === "SKILL.md");
294
+ }
295
+
296
+ // Write an inline file set into <dir>, guarding against path escapes from a malformed
297
+ // registry entry. Shared by every files-source materialization.
298
+ function writeInlineFiles(dir: string, files: ProvisioningAssetFile[]): void {
299
+ mkdirSync(dir, { recursive: true });
300
+ const root = resolve(dir);
301
+ for (const file of files) {
302
+ const dest = resolve(dir, file.path);
303
+ const rel = relative(root, dest);
304
+ if (rel.startsWith("..") || isAbsolute(rel)) continue;
305
+ mkdirSync(dirname(dest), { recursive: true });
306
+ writeFileSync(dest, file.content, { mode: 0o600 });
307
+ }
308
+ }
309
+
310
+ // Perform the disk write a resolved asset describes. files → write inline; link →
311
+ // (re)create the symlink; inPlace → nothing (the dir already exists at the source).
312
+ // Best-effort for links: a failed link must not abort the launch.
313
+ function materializeAsset(asset: ResolvedAsset): boolean {
314
+ if (asset.source.kind === "files") {
315
+ writeInlineFiles(asset.dir, asset.source.files);
316
+ return true;
317
+ }
318
+ if (asset.source.kind === "link") {
319
+ try {
320
+ mkdirSync(dirname(asset.dir), { recursive: true });
321
+ if (existsSync(asset.dir)) rmSync(asset.dir, { recursive: true, force: true });
322
+ symlinkSync(asset.source.source, asset.dir);
323
+ return true;
324
+ } catch {
325
+ return false;
326
+ }
327
+ }
328
+ return existsSync(asset.dir);
329
+ }
330
+
331
+ // #554 (Claude) — each provisioned skill lands at <claudeHome>/skills/<name> so it's
332
+ // auto-discovered under the launch's `--setting-sources "user"` scope (host skills
333
+ // never bleed). Inline files are written there; a pre-existing dir is symlinked in.
334
+ export function resolveClaudeProvisionedSkills(claudeHome: string, config: RunnerSpawnConfig): ResolvedAsset[] {
335
+ const { skills } = profileProvisioning(config);
336
+ const skillsRoot = join(claudeHome, "skills");
337
+ const resolved: ResolvedAsset[] = [];
338
+ for (const skill of skills) {
339
+ const dir = join(skillsRoot, assetDirName(skill.name));
340
+ if (skill.files?.length) {
341
+ resolved.push({ name: skill.name, dir, source: { kind: "files", files: providerIntegrationFiles(skill.name, skill.files, runnerMcpGeneration(config.env)) } });
342
+ continue;
343
+ }
344
+ if (skill.dir) {
345
+ const source = resolveExistingDir(skill.dir, config.cwd);
346
+ if (source) resolved.push({ name: skill.name, dir, source: { kind: "link", source } });
347
+ }
348
+ }
349
+ return resolved;
350
+ }
351
+
352
+ // #556 (Claude) — each provisioned plugin resolves to a --plugin-dir. Inline plugins
353
+ // materialize under <claudeHome>/relay-provisioned-plugins/<name>; declared dirs are
354
+ // used in place. The resolved `dir` is appended to the launch's --plugin-dir set.
355
+ export function resolveClaudeProvisionedPlugins(claudeHome: string, config: RunnerSpawnConfig): ResolvedAsset[] {
356
+ const { plugins } = profileProvisioning(config);
357
+ const parent = join(claudeHome, "relay-provisioned-plugins");
358
+ const resolved: ResolvedAsset[] = [];
359
+ for (const plugin of plugins) {
360
+ if (plugin.files?.length) {
361
+ resolved.push({ name: plugin.name, dir: join(parent, assetDirName(plugin.name)), source: { kind: "files", files: providerIntegrationFiles(plugin.name, plugin.files, runnerMcpGeneration(config.env)) } });
362
+ continue;
363
+ }
364
+ if (plugin.dir) {
365
+ const source = resolveExistingDir(plugin.dir, config.cwd);
366
+ if (source) resolved.push({ name: plugin.name, dir: source, source: { kind: "inPlace" } });
367
+ }
368
+ }
369
+ return resolved;
370
+ }
371
+
372
+ // #554 (Codex) — each provisioned skill resolves to a dir for `-c skills.config`.
373
+ // Inline skills materialize under <codexHome>/relay-provisioned-skills/<name>; declared
374
+ // dirs are used in place. Codex requires a SKILL.md at the dir root to register it, so
375
+ // inline sets without one and dirs missing it are dropped at resolution time.
376
+ export function resolveCodexProvisionedSkills(codexHome: string, config: RunnerSpawnConfig): ResolvedAsset[] {
377
+ const { skills } = profileProvisioning(config);
378
+ const parent = join(codexHome, "relay-provisioned-skills");
379
+ const resolved: ResolvedAsset[] = [];
380
+ for (const skill of skills) {
381
+ if (skill.files?.length) {
382
+ if (!hasRootSkillManifest(skill.files)) continue;
383
+ resolved.push({ name: skill.name, dir: join(parent, assetDirName(skill.name)), source: { kind: "files", files: providerIntegrationFiles(skill.name, skill.files, runnerMcpGeneration(config.env)) } });
384
+ continue;
385
+ }
386
+ if (skill.dir) {
387
+ const source = resolveExistingDir(skill.dir, config.cwd);
388
+ if (source && existsSync(join(source, "SKILL.md"))) resolved.push({ name: skill.name, dir: source, source: { kind: "inPlace" } });
389
+ }
390
+ }
391
+ return resolved;
392
+ }
393
+
394
+ export function resolveProvisionedHooks(home: string, config: RunnerSpawnConfig): ResolvedHookAsset[] {
395
+ const { hooks } = profileProvisioning(config);
396
+ const parent = join(home, "relay-provisioned-hooks");
397
+ const resolved: ResolvedHookAsset[] = [];
398
+ for (const hook of hooks ?? []) {
399
+ const dir = join(parent, assetDirName(hook.name));
400
+ if (hook.files?.length) {
401
+ resolved.push({ name: hook.name, dir, hooks: hook.hooks, source: { kind: "files", files: hook.files } });
402
+ continue;
403
+ }
404
+ if (hook.dir) {
405
+ const source = resolveExistingDir(hook.dir, config.cwd);
406
+ if (source) resolved.push({ name: hook.name, dir: source, hooks: hook.hooks, source: { kind: "inPlace" } });
407
+ continue;
408
+ }
409
+ resolved.push({ name: hook.name, dir, hooks: hook.hooks, source: { kind: "inPlace" } });
410
+ }
411
+ return resolved;
412
+ }
413
+
414
+ export function renderProvisionedHookSet(hooks: ResolvedHookAsset[]): ProvisioningHookSet {
415
+ const out: ProvisioningHookSet = {};
416
+ for (const hook of hooks) {
417
+ for (const [event, entries] of Object.entries(hook.hooks)) {
418
+ out[event] = [
419
+ ...(out[event] ?? []),
420
+ ...entries.map((entry) => ({
421
+ ...entry,
422
+ hooks: entry.hooks.map((command) => ({
423
+ ...command,
424
+ command: command.command.replaceAll("${HOOK_DIR}", hook.dir),
425
+ })),
426
+ })),
427
+ ];
428
+ }
429
+ }
430
+ return out;
431
+ }
432
+
433
+ export function writeCodexHooksJson(codexHome: string, hooks: ProvisioningHookSet): void {
434
+ mkdirSync(codexHome, { recursive: true });
435
+ if (!Object.keys(hooks).length) {
436
+ rmSync(join(codexHome, "hooks.json"), { force: true });
437
+ return;
438
+ }
439
+ writeFileSync(join(codexHome, "hooks.json"), JSON.stringify({ hooks }, null, 2), { mode: 0o600 });
440
+ writeCodexHookTrustState(codexHome, hooks);
441
+ }
442
+
443
+ const CODEX_HOOK_EVENT_STATE_KEYS: Record<string, string> = {
444
+ PreToolUse: "pre_tool_use",
445
+ PermissionRequest: "permission_request",
446
+ PostToolUse: "post_tool_use",
447
+ PreCompact: "pre_compact",
448
+ PostCompact: "post_compact",
449
+ SessionStart: "session_start",
450
+ UserPromptSubmit: "user_prompt_submit",
451
+ SubagentStart: "subagent_start",
452
+ SubagentStop: "subagent_stop",
453
+ Stop: "stop",
454
+ };
455
+
456
+ interface CodexHookTrustEntry {
457
+ key: string;
458
+ trustedHash: string;
459
+ }
460
+
461
+ export function codexHookTrustEntries(codexHome: string, hooks: ProvisioningHookSet): CodexHookTrustEntry[] {
462
+ // Codex keys hooks.state on the canonicalized CODEX_HOME; a symlinked ancestor (macOS
463
+ // /var → /private/var) would leave a lexical-only key untrusted and the hook skipped.
464
+ return hookTrustEntries([...new Set([codexHome, realpathOr(codexHome)])].map((home) => join(home, "hooks.json")), hooks);
465
+ }
466
+
467
+ // #3025 — Codex also reads TOML hooks and their trust state from the `-c` session-flags layer,
468
+ // keyed on this synthetic source, so a launch can carry hooks while keeping any CODEX_HOME.
469
+ const CODEX_SESSION_FLAGS_SOURCE = "/<session-flags>/config.toml";
470
+
471
+ export function codexSessionHookArgs(hooks: ProvisioningHookSet): string[] {
472
+ const state = Object.fromEntries(hookTrustEntries([CODEX_SESSION_FLAGS_SOURCE], hooks).map((entry) => [entry.key, { trusted_hash: entry.trustedHash }]));
473
+ return ["-c", `hooks=${tomlInlineValue({ ...hooks, state })}`];
474
+ }
475
+
476
+ function hookTrustEntries(sourcePaths: string[], hooks: ProvisioningHookSet): CodexHookTrustEntry[] {
477
+ const entries: CodexHookTrustEntry[] = [];
478
+ for (const [eventName, groups] of Object.entries(hooks)) {
479
+ const eventKey = CODEX_HOOK_EVENT_STATE_KEYS[eventName];
480
+ if (!eventKey) continue;
481
+ groups.forEach((group, groupIndex) => {
482
+ group.hooks.forEach((handler, handlerIndex) => {
483
+ if (handler.type !== "command") return;
484
+ const trustedHash = codexCommandHookHash(eventKey, group.matcher, group, handler);
485
+ for (const sourcePath of sourcePaths) {
486
+ entries.push({ key: `${sourcePath}:${eventKey}:${groupIndex}:${handlerIndex}`, trustedHash });
487
+ }
488
+ });
489
+ });
490
+ }
491
+ return entries;
492
+ }
493
+
494
+ function realpathOr(path: string): string {
495
+ try {
496
+ return realpathSync(path);
497
+ } catch {
498
+ return path;
499
+ }
500
+ }
501
+
502
+ function writeCodexHookTrustState(codexHome: string, hooks: ProvisioningHookSet): void {
503
+ const entries = codexHookTrustEntries(codexHome, hooks);
504
+ if (!entries.length) return;
505
+ const configPath = join(codexHome, "config.toml");
506
+ let config = existsSync(configPath) ? readFileSync(configPath, "utf8") : "";
507
+ for (const entry of entries) {
508
+ config = upsertCodexHookTrustEntry(config, entry);
509
+ }
510
+ writeFileSync(configPath, config, { mode: 0o600 });
511
+ }
512
+
513
+ function upsertCodexHookTrustEntry(config: string, entry: CodexHookTrustEntry): string {
514
+ const header = `[hooks.state.${tomlString(entry.key)}]`;
515
+ const lines = config.length ? config.split(/\n/) : [];
516
+ const headerIndex = lines.findIndex((line) => line.trim() === header);
517
+ if (headerIndex < 0) {
518
+ const prefix = config.endsWith("\n") || config.length === 0 ? config : `${config}\n`;
519
+ return `${prefix}\n${header}\ntrusted_hash = ${tomlString(entry.trustedHash)}\n`;
520
+ }
521
+
522
+ let nextHeaderIndex = lines.length;
523
+ for (let i = headerIndex + 1; i < lines.length; i += 1) {
524
+ if (/^\s*\[.*\]\s*$/.test(lines[i] ?? "")) {
525
+ nextHeaderIndex = i;
526
+ break;
527
+ }
528
+ }
529
+
530
+ const trustedHashLine = `trusted_hash = ${tomlString(entry.trustedHash)}`;
531
+ const existingIndex = lines
532
+ .slice(headerIndex + 1, nextHeaderIndex)
533
+ .findIndex((line) => /^\s*trusted_hash\s*=/.test(line));
534
+ if (existingIndex >= 0) {
535
+ lines[headerIndex + 1 + existingIndex] = trustedHashLine;
536
+ } else {
537
+ lines.splice(nextHeaderIndex, 0, trustedHashLine);
538
+ }
539
+ return lines.join("\n");
540
+ }
541
+
542
+ function codexCommandHookHash(
543
+ eventKey: string,
544
+ matcher: string | undefined,
545
+ group: ProvisioningHookSet[string][number],
546
+ handler: ProvisioningHookSet[string][number]["hooks"][number],
547
+ ): string {
548
+ // Mirrors Codex's command_hook_hash: hash the normalized hook identity, not the source file.
549
+ const normalizedHandler: Record<string, unknown> = {
550
+ type: "command",
551
+ command: handler.command,
552
+ async: false,
553
+ timeout: Math.max(1, handler.timeout ?? 600),
554
+ };
555
+ if (handler.statusMessage !== undefined) normalizedHandler.statusMessage = handler.statusMessage;
556
+
557
+ const identity: Record<string, unknown> = {
558
+ event_name: eventKey,
559
+ hooks: [normalizedHandler],
560
+ };
561
+ if (matcher !== undefined) identity.matcher = matcher;
562
+
563
+ const canonical = canonicalJson(identity);
564
+ const hash = createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
565
+ return `sha256:${hash}`;
566
+ }
567
+
568
+ function canonicalJson(value: unknown): unknown {
569
+ if (Array.isArray(value)) return value.map(canonicalJson);
570
+ if (value && typeof value === "object") {
571
+ return Object.fromEntries(
572
+ Object.entries(value as Record<string, unknown>)
573
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
574
+ .map(([key, val]) => [key, canonicalJson(val)]),
575
+ );
576
+ }
577
+ return value;
578
+ }
579
+
580
+ // #1096 — write relay-owned settings into an ISOLATED Claude managed home's `settings.json`
581
+ // (the DOCUMENTED hook source Claude honors under `--setting-sources "user"` or the default,
582
+ // unlike the inline `--settings` CLI flag). The managed home is relay-owned and freshly keyed
583
+ // per spawn, so an unconditional write is safe (profile-home seeds only `.claude.json`/`CLAUDE.md`,
584
+ // never `settings.json`). Removes the file when there is nothing relay-owned to deliver so disk
585
+ // stays == report. Never called for a host-base launch (which reuses the real `~/.claude`).
586
+ export function writeClaudeSettingsJson(claudeHome: string, settings: Record<string, unknown>): void {
587
+ mkdirSync(claudeHome, { recursive: true });
588
+ const path = join(claudeHome, "settings.json");
589
+ if (!Object.keys(settings).length) {
590
+ rmSync(path, { force: true });
591
+ return;
592
+ }
593
+ // Claude can read this file while a replacement starts. Publish a complete JSON snapshot
594
+ // in one rename so it never observes a truncated or half-written settings file.
595
+ const pending = `${path}.relay-${process.pid}-${randomUUID()}.tmp`;
596
+ try {
597
+ writeFileSync(pending, JSON.stringify(settings, null, 2), { mode: 0o600 });
598
+ renameSync(pending, path);
599
+ } finally {
600
+ rmSync(pending, { force: true });
601
+ }
602
+ }
603
+
604
+ // T3182 — merge a permissions.deny entry into a managed Claude settings payload WITHOUT
605
+ // clobbering permissions the payload already carries (deny lists union, everything else passes
606
+ // through). Pure — the caller composes and writes.
607
+ export function mergeClaudeSettingsDeny(settings: Record<string, unknown>, deny: string[]): Record<string, unknown> {
608
+ const permissions = typeof settings.permissions === "object" && settings.permissions !== null && !Array.isArray(settings.permissions)
609
+ ? settings.permissions as Record<string, unknown>
610
+ : {};
611
+ const existing = Array.isArray(permissions.deny) ? permissions.deny : [];
612
+ return { ...settings, permissions: { ...permissions, deny: [...new Set([...existing, ...deny])] } };
613
+ }
614
+
615
+ /** Merge Relay-owned guarded control-plane grants without replacing caller-authored permissions. */
616
+ export function mergeClaudeSettingsAllow(settings: Record<string, unknown>, allow: string[]): Record<string, unknown> {
617
+ const permissions = typeof settings.permissions === "object" && settings.permissions !== null && !Array.isArray(settings.permissions)
618
+ ? settings.permissions as Record<string, unknown>
619
+ : {};
620
+ const existing = Array.isArray(permissions.allow) ? permissions.allow : [];
621
+ return { ...settings, permissions: { ...permissions, allow: [...new Set([...existing, ...allow])] } };
622
+ }
623
+
624
+ export const CLAUDE_GUARDED_RELAY_TOOLS = [
625
+ "mcp__agent-relay__relay_send_message",
626
+ "mcp__agent-relay__relay_reply",
627
+ "mcp__agent-relay__relay_task_set_status",
628
+ "mcp__agent-relay__relay_task_comment",
629
+ "mcp__agent-relay__relay_task_deliver",
630
+ ];
631
+
632
+ function guardedRelayTools(generation: ToolGeneration): string[] {
633
+ return CLAUDE_GUARDED_RELAY_TOOLS.map(name => `mcp__${mcpServerInfo(generation).name}__${wireToolName(name.split("__").at(-1)!, generation)}`);
634
+ }
635
+
636
+ export function claudeGuardedRelaySettings(approvalMode: string | undefined, generation: ToolGeneration = "relay"): Record<string, unknown> {
637
+ return approvalMode === "guarded" ? { permissions: { allow: guardedRelayTools(generation) } } : {};
638
+ }
639
+
640
+ // #3388 — Claude's project scan loads CLAUDE.md, CLAUDE.local.md, .claude/CLAUDE.md and .claude/rules/**
641
+ // from EVERY ancestor of the cwd, so host files above the repo (~/.claude/CLAUDE.md, ~/CLAUDE.md,
642
+ // ~/projects/CLAUDE.md) reach an agent despite the isolated CLAUDE_CONFIG_DIR. Host instructions
643
+ // `ignore` excludes them through the supported `claudeMdExcludes` setting. Rule: the repo root
644
+ // (nearest ancestor holding .git, else the cwd) and below are repo instructions and keep loading;
645
+ // everything strictly above it is host-level. The walk starts from the physical cwd, which is what
646
+ // the provider process sees.
647
+ export function claudeHostInstructionExcludeSettings(config: { agentProfile?: RunnerSpawnConfig["agentProfile"]; cwd: string }): Record<string, unknown> {
648
+ if (profileAllowsHostGlobalInstructions(config)) return {};
649
+ const excludes: string[] = [];
650
+ for (const home of new Set([homedir(), realpathOr(homedir())])) excludes.push(...claudeUserDirPatterns(home));
651
+ for (let dir = dirname(repoRootOf(realpathOr(resolve(config.cwd)))); ; dir = dirname(dir)) {
652
+ excludes.push(...claudeUserDirPatterns(dir), join(globLiteral(dir), "CLAUDE.md"), join(globLiteral(dir), "CLAUDE.local.md"));
653
+ if (dirname(dir) === dir) break;
654
+ }
655
+ return { claudeMdExcludes: [...new Set(excludes)] };
656
+ }
657
+
658
+ // claudeMdExcludes entries are picomatch globs, and Claude rewrites every backslash to "/" before
659
+ // matching, so backslash escapes break: wrap each metacharacter in a bracket literal instead.
660
+ function globLiteral(path: string): string {
661
+ return path.replace(/[*?[\](){}!+@]/g, "[$&]");
662
+ }
663
+
664
+ function claudeUserDirPatterns(dir: string): string[] {
665
+ const claudeDir = join(globLiteral(dir), ".claude");
666
+ return [join(claudeDir, "CLAUDE.md"), join(claudeDir, "rules", "**")];
667
+ }
668
+
669
+ function repoRootOf(cwd: string): string {
670
+ const start = resolve(cwd);
671
+ for (let dir = start; ; dir = dirname(dir)) {
672
+ if (existsSync(join(dir, ".git"))) return dir;
673
+ if (dirname(dir) === dir) return start;
674
+ }
675
+ }
676
+
677
+ /** Claude Code 2.1.282 persists bypass acceptance in user settings.json. */
678
+ export function claudeBypassPermissionSettings(approvalMode: string | undefined): Record<string, unknown> {
679
+ return approvalMode === "open" ? { skipDangerousModePermissionPrompt: true } : {};
680
+ }
681
+
682
+ export function mergeClaudeRelayPermissions(settings: Record<string, unknown>, relayPermissions: unknown): Record<string, unknown> {
683
+ const candidate = typeof relayPermissions === "object" && relayPermissions !== null && !Array.isArray(relayPermissions)
684
+ ? relayPermissions as Record<string, unknown>
685
+ : {};
686
+ const allow = Array.isArray(candidate.allow) ? candidate.allow.filter((item): item is string => typeof item === "string") : [];
687
+ return mergeClaudeSettingsAllow(settings, allow);
688
+ }
689
+
690
+ export function withManagedClaudePermissions(settings: Record<string, unknown>, approvalMode: string | undefined, generation: ToolGeneration = "relay"): Record<string, unknown> {
691
+ const guarded = approvalMode === "guarded" ? mergeClaudeSettingsAllow(settings, guardedRelayTools(generation)) : settings;
692
+ return projectClaudePermissionRules(mergeClaudeSettingsDeny({ ...guarded, ...claudeBypassPermissionSettings(approvalMode), idleCompaction: false }, ["SendFeedback"]), generation);
693
+ }
694
+
695
+ // Write the resolved assets to disk; returns the dirs that materialized successfully,
696
+ // in resolution order. Idempotent — safe to re-run against an existing home.
697
+ export function materializeResolvedAssets(assets: ResolvedAsset[]): string[] {
698
+ const dirs: string[] = [];
699
+ for (const asset of assets) {
700
+ if (materializeAsset(asset)) dirs.push(asset.dir);
701
+ }
702
+ return dirs;
703
+ }