@wardby/cli 0.7.0 → 0.8.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 (304) hide show
  1. package/.env.example +5 -5
  2. package/README.md +6 -1
  3. package/deploy/local/docker-compose.quickstart-coding.yml +6 -0
  4. package/dist/claude-coding-worker/driver.d.ts +12 -0
  5. package/dist/claude-coding-worker/driver.js +29 -7
  6. package/dist/claude-coding-worker/main.js +5 -1
  7. package/dist/claude-coding-worker/sdk.js +17 -1
  8. package/dist/claude-plugin/command.d.ts +61 -0
  9. package/dist/claude-plugin/command.js +376 -0
  10. package/dist/cli-help.d.ts +1 -1
  11. package/dist/cli-help.js +2 -1
  12. package/dist/cli.js +47 -3
  13. package/dist/coding/code-host.d.ts +16 -0
  14. package/dist/coding/code-host.js +60 -0
  15. package/dist/coding/mcp-token.d.ts +12 -0
  16. package/dist/coding/mcp-token.js +26 -0
  17. package/dist/coding/protocol.js +2 -0
  18. package/dist/coding/redact.js +6 -1
  19. package/dist/coding/transcript/recorder.d.ts +5 -4
  20. package/dist/coding/transcript/recorder.js +1 -1
  21. package/dist/coding-worker/debug-trace.d.ts +3 -1
  22. package/dist/coding-worker/debug-trace.js +4 -2
  23. package/dist/coding-worker/driver.js +8 -1
  24. package/dist/coding-worker/main.js +5 -1
  25. package/dist/coding-worker/mcp-config.d.ts +27 -0
  26. package/dist/coding-worker/mcp-config.js +32 -0
  27. package/dist/coding-worker/sdk.d.ts +36 -0
  28. package/dist/coding-worker/sdk.js +28 -1
  29. package/dist/coding-worker/types.d.ts +4 -0
  30. package/dist/config/mcp-call-limit.d.ts +8 -0
  31. package/dist/config/mcp-call-limit.js +18 -0
  32. package/dist/config/mcp-servers.d.ts +8 -0
  33. package/dist/config/mcp-servers.js +12 -0
  34. package/dist/config/test-simulators.d.ts +10 -0
  35. package/dist/config/test-simulators.js +28 -0
  36. package/dist/config/tool-timeout.d.ts +1 -0
  37. package/dist/config/tool-timeout.js +18 -0
  38. package/dist/core/coding-mcp.d.ts +36 -0
  39. package/dist/core/coding-mcp.js +253 -0
  40. package/dist/core/coding-repository.d.ts +46 -0
  41. package/dist/core/coding-repository.js +92 -0
  42. package/dist/core/delegation-slots.d.ts +27 -0
  43. package/dist/core/delegation-slots.js +44 -0
  44. package/dist/core/dispatch.d.ts +6 -2
  45. package/dist/core/dispatch.js +36 -11
  46. package/dist/core/error-code.js +1 -0
  47. package/dist/core/grants.d.ts +28 -3
  48. package/dist/core/grants.js +46 -4
  49. package/dist/core/host-events.js +7 -3
  50. package/dist/core/host-status.js +2 -2
  51. package/dist/core/issue-status.js +4 -3
  52. package/dist/core/mark-ready.d.ts +1 -1
  53. package/dist/core/mark-ready.js +1 -1
  54. package/dist/core/mcp-client/client.d.ts +47 -0
  55. package/dist/core/mcp-client/client.js +141 -0
  56. package/dist/core/mcp-client/jsonrpc.d.ts +15 -0
  57. package/dist/core/mcp-client/jsonrpc.js +54 -0
  58. package/dist/core/mcp-client/results.d.ts +13 -0
  59. package/dist/core/mcp-client/results.js +42 -0
  60. package/dist/core/mcp-servers.d.ts +90 -0
  61. package/dist/core/mcp-servers.js +230 -0
  62. package/dist/core/mcp-tools.d.ts +108 -0
  63. package/dist/core/mcp-tools.js +287 -0
  64. package/dist/core/merge-order-check.js +38 -19
  65. package/dist/core/notification-dispatcher.js +2 -1
  66. package/dist/core/related-pull-requests.js +26 -10
  67. package/dist/core/review-fix.js +2 -2
  68. package/dist/core/review-host-tools.js +3 -1
  69. package/dist/core/runner.d.ts +66 -16
  70. package/dist/core/runner.js +281 -73
  71. package/dist/core/secret-broker-config.d.ts +2 -0
  72. package/dist/core/secret-broker-config.js +1 -1
  73. package/dist/core/secret-guard.d.ts +5 -2
  74. package/dist/core/secret-guard.js +22 -7
  75. package/dist/core/secrets.d.ts +11 -1
  76. package/dist/core/secrets.js +27 -1
  77. package/dist/core/serializable-retry.d.ts +18 -0
  78. package/dist/core/serializable-retry.js +43 -0
  79. package/dist/core/tool-admin.d.ts +5 -1
  80. package/dist/core/tool-admin.js +7 -2
  81. package/dist/core/tool-names.d.ts +13 -0
  82. package/dist/core/tool-names.js +20 -0
  83. package/dist/core/webhooks.d.ts +4 -0
  84. package/dist/core/webhooks.js +68 -49
  85. package/dist/core/workflow-run-events.js +2 -1
  86. package/dist/generated/prisma/browser.d.ts +31 -0
  87. package/dist/generated/prisma/client.d.ts +31 -0
  88. package/dist/generated/prisma/commonInputTypes.d.ts +50 -50
  89. package/dist/generated/prisma/internal/class.d.ts +66 -0
  90. package/dist/generated/prisma/internal/class.js +4 -4
  91. package/dist/generated/prisma/internal/prismaNamespace.d.ts +530 -1
  92. package/dist/generated/prisma/internal/prismaNamespace.js +75 -2
  93. package/dist/generated/prisma/internal/prismaNamespaceBrowser.d.ts +79 -0
  94. package/dist/generated/prisma/internal/prismaNamespaceBrowser.js +75 -2
  95. package/dist/generated/prisma/models/Agent.d.ts +271 -0
  96. package/dist/generated/prisma/models/AgentMcpServer.d.ts +1412 -0
  97. package/dist/generated/prisma/models/AgentMcpServer.js +1 -0
  98. package/dist/generated/prisma/models/AgentRepository.d.ts +40 -1
  99. package/dist/generated/prisma/models/AuthUser.d.ts +1 -1
  100. package/dist/generated/prisma/models/CodingAgentProfile.d.ts +0 -7
  101. package/dist/generated/prisma/models/CodingProxySession.d.ts +119 -2
  102. package/dist/generated/prisma/models/CodingRun.d.ts +76 -1
  103. package/dist/generated/prisma/models/HostProvider.d.ts +2176 -0
  104. package/dist/generated/prisma/models/HostProvider.js +1 -0
  105. package/dist/generated/prisma/models/HostProviderCredential.d.ts +1285 -0
  106. package/dist/generated/prisma/models/HostProviderCredential.js +1 -0
  107. package/dist/generated/prisma/models/HostProviderOperation.d.ts +1470 -0
  108. package/dist/generated/prisma/models/HostProviderOperation.js +1 -0
  109. package/dist/generated/prisma/models/HostProviderTrustedAccount.d.ts +1145 -0
  110. package/dist/generated/prisma/models/HostProviderTrustedAccount.js +1 -0
  111. package/dist/generated/prisma/models/McpServer.d.ts +1500 -0
  112. package/dist/generated/prisma/models/McpServer.js +1 -0
  113. package/dist/generated/prisma/models/NativeGatewayCall.d.ts +39 -1
  114. package/dist/generated/prisma/models/Principal.d.ts +167 -0
  115. package/dist/generated/prisma/models/ResourceGrant.d.ts +1 -1
  116. package/dist/generated/prisma/models/Secret.d.ts +387 -0
  117. package/dist/generated/prisma/models/Tool.d.ts +223 -1
  118. package/dist/generated/prisma/models.d.ts +6 -0
  119. package/dist/help/search.js +12 -3
  120. package/dist/help-index.json +719 -47
  121. package/dist/import/create.js +16 -1
  122. package/dist/import/neutral-schema.d.ts +1 -0
  123. package/dist/import/neutral-schema.js +2 -0
  124. package/dist/mcp/auth/grants-cli.d.ts +6 -4
  125. package/dist/mcp/auth/grants-cli.js +42 -3
  126. package/dist/mcp/auth/resource-server.d.ts +3 -1
  127. package/dist/mcp/auth/resource-server.js +17 -2
  128. package/dist/mcp/context.d.ts +3 -0
  129. package/dist/mcp/index.js +11 -0
  130. package/dist/mcp/tools/agents.js +100 -15
  131. package/dist/mcp/tools/grants.js +66 -28
  132. package/dist/mcp/tools/host-providers.d.ts +2 -0
  133. package/dist/mcp/tools/host-providers.js +215 -0
  134. package/dist/mcp/tools/mcp-servers.d.ts +26 -0
  135. package/dist/mcp/tools/mcp-servers.js +481 -0
  136. package/dist/mcp/tools/repositories.js +50 -10
  137. package/dist/mcp/tools/runs.js +178 -18
  138. package/dist/mcp/tools/secret-elicitation.d.ts +2 -0
  139. package/dist/mcp/tools/secret-elicitation.js +25 -2
  140. package/dist/mcp/tools/secrets.js +38 -4
  141. package/dist/mcp/tools/tools.d.ts +6 -4
  142. package/dist/mcp/tools/tools.js +40 -8
  143. package/dist/mcp/tools/trigger.js +28 -6
  144. package/dist/mcp/transport/http-limits.d.ts +2 -0
  145. package/dist/mcp/transport/http-limits.js +6 -2
  146. package/dist/mcp/webhooks/ingress.js +10 -0
  147. package/dist/native-worker/gateway-server.d.ts +14 -0
  148. package/dist/native-worker/gateway-server.js +28 -7
  149. package/dist/native-worker/gateway.d.ts +2 -0
  150. package/dist/native-worker/gateway.js +5 -1
  151. package/dist/native-worker/http-server.d.ts +4 -0
  152. package/dist/native-worker/http-server.js +51 -0
  153. package/dist/native-worker/ledger.d.ts +6 -2
  154. package/dist/native-worker/ledger.js +5 -4
  155. package/dist/native-worker/protocol.d.ts +1 -0
  156. package/dist/native-worker/protocol.js +4 -1
  157. package/dist/providers/coding-proxy/capability-hash.d.ts +2 -0
  158. package/dist/providers/coding-proxy/capability-hash.js +5 -0
  159. package/dist/providers/coding-proxy/mcp-route.d.ts +34 -0
  160. package/dist/providers/coding-proxy/mcp-route.js +166 -0
  161. package/dist/providers/coding-proxy/memory-ledger.d.ts +2 -0
  162. package/dist/providers/coding-proxy/memory-ledger.js +15 -0
  163. package/dist/providers/coding-proxy/prisma-ledger.d.ts +1 -0
  164. package/dist/providers/coding-proxy/prisma-ledger.js +30 -2
  165. package/dist/providers/coding-proxy/proxy.d.ts +7 -1
  166. package/dist/providers/coding-proxy/proxy.js +134 -34
  167. package/dist/providers/coding-proxy/runtime.d.ts +6 -0
  168. package/dist/providers/coding-proxy/runtime.js +32 -1
  169. package/dist/providers/coding-proxy/server.d.ts +3 -0
  170. package/dist/providers/coding-proxy/server.js +10 -0
  171. package/dist/providers/coding-proxy/types.d.ts +13 -0
  172. package/dist/providers/executor/composition.js +5 -0
  173. package/dist/providers/executor/container.d.ts +33 -1
  174. package/dist/providers/executor/container.js +76 -7
  175. package/dist/providers/host-tools/invoke.d.ts +37 -0
  176. package/dist/providers/host-tools/invoke.js +117 -0
  177. package/dist/providers/host-tools/operations.d.ts +65 -0
  178. package/dist/providers/host-tools/operations.js +149 -0
  179. package/dist/providers/host-tools/registry.d.ts +79 -0
  180. package/dist/providers/host-tools/registry.js +362 -0
  181. package/dist/providers/host-tools/request-guard.d.ts +17 -0
  182. package/dist/providers/host-tools/request-guard.js +99 -0
  183. package/dist/providers/host-tools/self-test.d.ts +64 -0
  184. package/dist/providers/host-tools/self-test.js +142 -0
  185. package/dist/providers/jobs/docker-isolation.d.ts +2 -2
  186. package/dist/providers/jobs/docker-isolation.js +30 -8
  187. package/dist/providers/jobs/docker.d.ts +8 -0
  188. package/dist/providers/jobs/docker.js +33 -9
  189. package/dist/providers/jobs/kubernetes-isolation.d.ts +3 -1
  190. package/dist/providers/jobs/kubernetes-isolation.js +15 -2
  191. package/dist/providers/jobs/kubernetes.d.ts +5 -0
  192. package/dist/providers/jobs/kubernetes.js +6 -1
  193. package/dist/providers/jobs/types.d.ts +6 -0
  194. package/dist/providers/review-host/types.d.ts +6 -4
  195. package/dist/providers/review-host/types.js +1 -1
  196. package/dist/providers/vcs/git.d.ts +6 -2
  197. package/dist/providers/vcs/git.js +15 -4
  198. package/dist/providers/vcs/github.d.ts +2 -71
  199. package/dist/providers/vcs/github.js +8 -11
  200. package/dist/providers/vcs/index.d.ts +1 -1
  201. package/dist/providers/vcs/index.js +1 -1
  202. package/dist/providers/vcs/remote.d.ts +4 -0
  203. package/dist/providers/vcs/remote.js +7 -1
  204. package/dist/providers/vcs/routing.d.ts +12 -6
  205. package/dist/providers/vcs/routing.js +21 -6
  206. package/dist/providers/vcs/types.d.ts +71 -3
  207. package/dist/quickstart/index.d.ts +5 -0
  208. package/dist/quickstart/index.js +21 -0
  209. package/dist/quickstart-images.json +1 -1
  210. package/dist/sandbox/eval-core.d.ts +12 -2
  211. package/dist/sandbox/eval-core.js +20 -4
  212. package/dist/sandbox/host-functions.d.ts +4 -0
  213. package/dist/sandbox/host-functions.js +7 -2
  214. package/dist/sandbox/limits.d.ts +4 -1
  215. package/dist/sandbox/limits.js +4 -1
  216. package/dist/sandbox/run-in-sandbox.d.ts +6 -0
  217. package/dist/sandbox/run-in-sandbox.js +14 -2
  218. package/dist/sandbox/safe-fetch.d.ts +2 -0
  219. package/dist/sandbox/safe-fetch.js +1 -1
  220. package/dist/sandbox/secret-broker.d.ts +1 -0
  221. package/dist/sandbox/secret-broker.js +1 -1
  222. package/dist/sandbox/tool-timeout.d.ts +32 -0
  223. package/dist/sandbox/tool-timeout.js +52 -0
  224. package/dist/sandbox/user-tool.d.ts +8 -1
  225. package/dist/sandbox/user-tool.js +9 -3
  226. package/dist/viewer/graph.js +2 -1
  227. package/dist/wardby-bin.js +5 -0
  228. package/docs/README.md +11 -1
  229. package/docs/agent-recipes.md +37 -4
  230. package/docs/architecture-runtime.md +1 -1
  231. package/docs/claude-code-plugin.md +164 -0
  232. package/docs/code-review-agents.md +6 -0
  233. package/docs/coding-agent-setup.md +77 -1
  234. package/docs/coding-worker-byo-images.md +4 -2
  235. package/docs/coding-worker-isolation.md +78 -7
  236. package/docs/debugging-agent-runs.md +242 -0
  237. package/docs/getting-started-gke.md +5 -4
  238. package/docs/getting-started-identity-provider.md +43 -37
  239. package/docs/getting-started.md +8 -0
  240. package/docs/host-providers.md +294 -0
  241. package/docs/mcp-servers.md +254 -0
  242. package/docs/native-sandbox.md +25 -9
  243. package/docs/security-deployment.md +110 -31
  244. package/docs/tool-secrets.md +3 -0
  245. package/docs/viewer-api.md +6 -0
  246. package/docs/viewer-api.openapi.yml +1 -1
  247. package/help/admin-viewer.md +4 -0
  248. package/help/agent-recipes.md +33 -2
  249. package/help/brokered-secrets.md +3 -0
  250. package/help/build-claude-code-image.md +1 -1
  251. package/help/claude-code-run-pane.md +40 -0
  252. package/help/errors/coding-mcp-pins-unavailable.md +35 -0
  253. package/help/errors/database-auth-invalid.md +1 -1
  254. package/help/errors/host-provider-error.md +40 -0
  255. package/help/errors/host-tool-failed.md +36 -0
  256. package/help/errors/mcp-call-limit.md +22 -0
  257. package/help/errors/mcp-call-timeout.md +22 -0
  258. package/help/errors/mcp-server-auth-failed.md +22 -0
  259. package/help/errors/mcp-server-credentials-unavailable.md +45 -0
  260. package/help/errors/mcp-server-error.md +28 -0
  261. package/help/errors/mcp-server-unreachable.md +22 -0
  262. package/help/errors/mcp-servers-off.md +21 -0
  263. package/help/errors/mcp-tool-not-allowed.md +26 -0
  264. package/help/errors/mcp-tool-removed.md +22 -0
  265. package/help/errors/mcp-unavailable.md +25 -0
  266. package/help/errors/native-sandbox-capacity.md +1 -1
  267. package/help/errors/native-sandbox-deadline-exceeded.md +1 -1
  268. package/help/errors/native-sandbox-docker-failed.md +1 -1
  269. package/help/errors/native-sandbox-gateway-error.md +1 -1
  270. package/help/errors/native-sandbox-gateway-unavailable.md +1 -1
  271. package/help/errors/native-sandbox-image-not-pinned.md +1 -1
  272. package/help/errors/native-sandbox-isolation-mismatch.md +1 -1
  273. package/help/errors/native-sandbox-network-unenforced.md +1 -1
  274. package/help/errors/native-sandbox-requires-catalog.md +1 -1
  275. package/help/errors/native-sandbox-unavailable.md +1 -1
  276. package/help/errors/native-sandbox-warm-delivery-failed.md +1 -1
  277. package/help/errors/native-sandbox-worker-exited.md +1 -1
  278. package/help/errors/native-sandbox-worker-lost.md +1 -1
  279. package/help/errors/native-sandbox-worker-unready.md +1 -1
  280. package/help/errors/repository-not-listed.md +36 -0
  281. package/help/errors/repository-required.md +36 -0
  282. package/help/errors/tool-timeout.md +38 -0
  283. package/help/host-providers.md +40 -0
  284. package/help/identity-and-access.md +4 -1
  285. package/help/mcp-servers.md +73 -0
  286. package/help/mcp.md +7 -2
  287. package/help/native-capabilities.md +27 -1
  288. package/help/native-sandbox.md +1 -1
  289. package/help/operating-agents.md +16 -0
  290. package/help/related-pull-requests.md +7 -3
  291. package/help/repo-instructions-and-skills.md +3 -1
  292. package/help/resume-runs-locally.md +3 -0
  293. package/help/stop-run.md +1 -1
  294. package/help/troubleshooting/coding-workers.md +9 -1
  295. package/help/troubleshooting/debugging-agent-runs.md +29 -0
  296. package/package.json +2 -1
  297. package/prisma/migrations/20261102000000_host_providers/migration.sql +56 -0
  298. package/prisma/migrations/20261104000000_coding_agent_repository_list/migration.sql +5 -0
  299. package/prisma/migrations/20261106000000_tool_timeout/migration.sql +4 -0
  300. package/prisma/migrations/20261107000000_mcp_servers/migration.sql +33 -0
  301. package/prisma/migrations/20261108000000_coding_mcp/migration.sql +5 -0
  302. package/prisma/migrations/20261109000000_task_run_index/migration.sql +3 -0
  303. package/prisma/schema.prisma +159 -14
  304. package/scripts/git-askpass.sh +1 -1
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Where one bound tool's request may go: the provider's API hosts, inside the path of
3
+ * the repository this call is for, using the operation's own methods. Wardby picks
4
+ * the repository, so a tool can't point a token at another one. Pure.
5
+ */
6
+ export const MAX_HOST_TOOL_FETCHES = 3;
7
+ export class HostToolRequestDenied extends Error {
8
+ constructor() {
9
+ super("host_tool_request_denied");
10
+ this.name = "HostToolRequestDenied";
11
+ }
12
+ }
13
+ export function assertHostToolRequest(url, method, scope) {
14
+ let parsed;
15
+ try {
16
+ parsed = new URL(url);
17
+ }
18
+ catch {
19
+ throw new HostToolRequestDenied();
20
+ }
21
+ if (parsed.protocol !== "https:" || parsed.port !== "" || parsed.username || parsed.password) {
22
+ throw new HostToolRequestDenied();
23
+ }
24
+ if (!scope.apiHosts.includes(parsed.hostname.toLowerCase()))
25
+ throw new HostToolRequestDenied();
26
+ if (!scope.methods.includes(method.toUpperCase()))
27
+ throw new HostToolRequestDenied();
28
+ // Some frameworks read the method from these, which would turn a checked GET into a write.
29
+ for (const key of parsed.searchParams.keys()) {
30
+ const name = key.toLowerCase();
31
+ if (name === "_method" || name.startsWith("x-http-method"))
32
+ throw new HostToolRequestDenied();
33
+ }
34
+ const prefix = scope.pathPrefix.toLowerCase();
35
+ if (!prefix.startsWith("/"))
36
+ throw new HostToolRequestDenied();
37
+ // The repository is matched literally on the raw path; decoding is only for the segment rules.
38
+ const raw = parsed.pathname.toLowerCase();
39
+ if (raw !== prefix && !raw.startsWith(`${prefix}/`))
40
+ throw new HostToolRequestDenied();
41
+ assertSafeSegments(parsed.pathname);
42
+ }
43
+ const MAX_DECODE_ROUNDS = 8;
44
+ const ALLOWED_HEADERS = ["accept", "content-type", "if-match", "if-none-match"];
45
+ const utf8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
46
+ /** Decodes every run of valid %XX escapes as UTF-8 bytes; a stray % stays literal. */
47
+ function decodeOnce(text) {
48
+ return text.replace(/(?:%[0-9a-f]{2})+/gi, (run) => {
49
+ const bytes = Uint8Array.from(run.match(/%([0-9a-f]{2})/gi) ?? [], (hex) => parseInt(hex.slice(1), 16));
50
+ return utf8.decode(bytes);
51
+ });
52
+ }
53
+ /**
54
+ * The path as a server may finally read it: decoded and NFKC-normalized together to
55
+ * stable (a fullwidth percent sign becomes a real one), then judged per segment. Servers and proxies disagree on what a segment "is" (encoded or
56
+ * Unicode lookalike separators, stripped whitespace or format characters, trailing
57
+ * delimiters), so a segment that is only dots once those are removed is refused, and so
58
+ * is anything that changes the segment count. Ordinary names with odd characters pass.
59
+ */
60
+ function assertSafeSegments(pathname) {
61
+ let current = pathname;
62
+ for (let round = 0;; round++) {
63
+ if (round > MAX_DECODE_ROUNDS)
64
+ throw new HostToolRequestDenied();
65
+ let next;
66
+ try {
67
+ next = decodeOnce(current).normalize("NFKC");
68
+ }
69
+ catch {
70
+ throw new HostToolRequestDenied();
71
+ }
72
+ if (next === current)
73
+ break;
74
+ current = next;
75
+ }
76
+ // eslint-disable-next-line no-control-regex
77
+ if (/[\\\u0000\u2215\u2044]/.test(current))
78
+ throw new HostToolRequestDenied();
79
+ const segments = current.split("/");
80
+ if (segments.length !== pathname.split("/").length)
81
+ throw new HostToolRequestDenied();
82
+ for (const segment of segments) {
83
+ // % is stripped too: a stray % must not turn "..%" into a passing name.
84
+ const bare = segment.replace(/[\p{Z}\p{C}%]/gu, "");
85
+ if (/^\.+(?:[;?#,:].*)?$/.test(bare))
86
+ throw new HostToolRequestDenied();
87
+ }
88
+ }
89
+ /** Request headers a bound tool may set: no override, routing or credential headers. */
90
+ export function assertHostToolHeaders(headers) {
91
+ if (headers === undefined)
92
+ return;
93
+ if (typeof headers !== "object" || headers === null || Array.isArray(headers))
94
+ throw new HostToolRequestDenied();
95
+ for (const name of Object.keys(headers)) {
96
+ if (!ALLOWED_HEADERS.includes(name.toLowerCase()))
97
+ throw new HostToolRequestDenied();
98
+ }
99
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Calling a provider's approved snapshots, and the read-only checks test_host_provider reports.
3
+ * A report holds an operation, its repository, an outcome and a fixed code, so it can be shared
4
+ * from a team whose host we can't reach, carrying nothing else.
5
+ */
6
+ import type { PrismaClient, Secret } from "#prisma";
7
+ import { safeFetch } from "../../sandbox/safe-fetch.js";
8
+ import type { SecretCipher } from "../secrets/types.js";
9
+ import { type HostToolResult } from "./invoke.js";
10
+ import { type HostOperation } from "./operations.js";
11
+ type StoredSecret = Pick<Secret, "ciphertext" | "broker">;
12
+ export interface LoadedProvider {
13
+ name: string;
14
+ apiHosts: string[];
15
+ repositoryApiPath: string;
16
+ permissionApiPath: string | null;
17
+ permissionSecret: StoredSecret | null;
18
+ credentials: {
19
+ repositoryPattern: string;
20
+ secret: StoredSecret;
21
+ }[];
22
+ operations: {
23
+ operation: string;
24
+ code: string;
25
+ paramsZod: string;
26
+ timeoutMs?: number | null;
27
+ }[];
28
+ }
29
+ /**
30
+ * The fetch for bound tools: the real one, or under WARDBY_TEST_SIMULATORS the simulator's. Under
31
+ * the switch only `sim-` credentials are sent, as the switch refuses every other real credential.
32
+ */
33
+ export declare function hostToolFetch(env?: NodeJS.ProcessEnv): {
34
+ fetchImpl?: typeof safeFetch;
35
+ simulated: boolean;
36
+ };
37
+ export declare function loadProviderForCalls(db: PrismaClient, name: string): Promise<LoadedProvider | null>;
38
+ /** The repository is compared in its normalized form, so callers must pass one. */
39
+ export declare function callHostOperation(provider: LoadedProvider, cipher: SecretCipher, repository: string, operation: HostOperation, fields: Record<string, unknown>, options?: {
40
+ fetchImpl?: typeof safeFetch;
41
+ simulated?: boolean;
42
+ }): Promise<HostToolResult>;
43
+ export interface SelfTestInput {
44
+ repository: string;
45
+ pullRequest?: number;
46
+ headRef?: string;
47
+ ref?: string;
48
+ accountId?: string;
49
+ }
50
+ export interface SelfTestReport {
51
+ provider: string;
52
+ repository: string;
53
+ mode: "permission" | "allowlist";
54
+ results: {
55
+ operation: string;
56
+ outcome: "pass" | "fail" | "skipped";
57
+ code?: string;
58
+ }[];
59
+ }
60
+ export declare function selfTestHostProvider(provider: LoadedProvider, cipher: SecretCipher, input: SelfTestInput, options?: {
61
+ fetchImpl?: typeof safeFetch;
62
+ simulated?: boolean;
63
+ }): Promise<SelfTestReport>;
64
+ export {};
@@ -0,0 +1,142 @@
1
+ import { codeProviderOf, normalizeHostedRepository } from "../../coding/code-host.js";
2
+ import { loadTestSimulators, simulatorHostFetch } from "../../config/test-simulators.js";
3
+ import { parseSecretBrokerConfig } from "../../core/secret-broker-config.js";
4
+ import { safeFetch } from "../../sandbox/safe-fetch.js";
5
+ import { invokeHostTool } from "./invoke.js";
6
+ import { HOST_OPERATIONS, isHostOperation } from "./operations.js";
7
+ import { findCredential } from "./registry.js";
8
+ import { effectiveToolTimeoutMs } from "../../sandbox/tool-timeout.js";
9
+ import { loadToolMaxTimeoutMs } from "../../config/tool-timeout.js";
10
+ /**
11
+ * The fetch for bound tools: the real one, or under WARDBY_TEST_SIMULATORS the simulator's. Under
12
+ * the switch only `sim-` credentials are sent, as the switch refuses every other real credential.
13
+ */
14
+ export function hostToolFetch(env = process.env) {
15
+ const simulators = loadTestSimulators(env);
16
+ return simulators ? { fetchImpl: simulatorHostFetch(simulators, safeFetch), simulated: true } : { simulated: false };
17
+ }
18
+ export async function loadProviderForCalls(db, name) {
19
+ const row = await db.hostProvider.findUnique({
20
+ where: { name },
21
+ include: { permissionSecret: true, credentials: { include: { secret: true } }, operations: true },
22
+ });
23
+ if (!row)
24
+ return null;
25
+ return {
26
+ name: row.name,
27
+ apiHosts: row.apiHosts,
28
+ repositoryApiPath: row.repositoryApiPath,
29
+ permissionApiPath: row.permissionApiPath,
30
+ permissionSecret: row.permissionSecret,
31
+ credentials: row.credentials,
32
+ operations: row.operations,
33
+ };
34
+ }
35
+ // No URL-encoding: callHostOperation only takes a repository in its normalized form, which admits only [A-Za-z0-9._-], and the request guard compares a raw prefix.
36
+ function encodePath(template, fields) {
37
+ return template.replace(/\{([a-z]+)\}/g, (_m, field) => fields[field]);
38
+ }
39
+ /** The repository is compared in its normalized form, so callers must pass one. */
40
+ export async function callHostOperation(provider, cipher, repository, operation, fields, options = {}) {
41
+ let ours = false;
42
+ try {
43
+ ours = normalizeHostedRepository(repository) === repository && codeProviderOf(repository) === provider.name;
44
+ }
45
+ catch {
46
+ // Not a repository we can name: denied below.
47
+ }
48
+ if (!ours)
49
+ return { ok: false, code: "host_tool_request_denied" };
50
+ const snapshot = provider.operations.find((o) => o.operation === operation);
51
+ if (!snapshot)
52
+ return { ok: false, code: "host_tool_failed" };
53
+ const path = repository.slice(`${provider.name}:`.length);
54
+ const [workspace, repo] = path.split("/");
55
+ const permission = HOST_OPERATIONS[operation].credential === "permission";
56
+ const stored = permission ? provider.permissionSecret : (findCredential(provider.credentials, path)?.secret ?? null);
57
+ const template = permission ? provider.permissionApiPath : provider.repositoryApiPath;
58
+ if (!stored || !template)
59
+ return { ok: false, code: "host_tool_no_credential" };
60
+ const credential = async () => {
61
+ const value = await cipher.decrypt(stored.ciphertext);
62
+ if (options.simulated && !value.startsWith("sim-"))
63
+ return undefined;
64
+ return { value, broker: parseSecretBrokerConfig(stored.broker) };
65
+ };
66
+ return invokeHostTool({
67
+ provider: provider.name,
68
+ operation,
69
+ snapshot,
70
+ params: { ...fields, workspace, repo, repository: path },
71
+ apiHosts: provider.apiHosts,
72
+ pathPrefix: encodePath(template, { repository: path, workspace, repo }),
73
+ credential,
74
+ fetchImpl: options.fetchImpl,
75
+ timeoutMs: effectiveToolTimeoutMs(snapshot.timeoutMs, loadToolMaxTimeoutMs()),
76
+ });
77
+ }
78
+ export async function selfTestHostProvider(provider, cipher, input, options = {}) {
79
+ const repository = normalizeHostedRepository(input.repository);
80
+ if (codeProviderOf(repository) !== provider.name)
81
+ throw new Error("host_provider_repository_mismatch");
82
+ const results = [];
83
+ const skip = (operation, code) => results.push({ operation, outcome: "skipped", code });
84
+ const check = async (operation, fields) => {
85
+ const result = await callHostOperation(provider, cipher, repository, operation, fields, options);
86
+ results.push(result.ok ? { operation, outcome: "pass" } : { operation, outcome: "fail", code: result.code });
87
+ return result;
88
+ };
89
+ const bound = (name) => provider.operations.some((o) => o.operation === name);
90
+ // The pull request comes first: its head is the ref for the checks that need one.
91
+ let ref = input.ref;
92
+ const number = input.pullRequest;
93
+ if (!bound("getPullRequest"))
94
+ skip("getPullRequest", "not_bound");
95
+ else if (number === undefined)
96
+ skip("getPullRequest", "needs_pull_request");
97
+ else {
98
+ const pr = await check("getPullRequest", { number });
99
+ if (pr.ok)
100
+ ref ??= pr.value.headSha;
101
+ }
102
+ for (const operation of ["listPullRequestFiles", "getDiff", "listComments"]) {
103
+ if (!bound(operation))
104
+ skip(operation, "not_bound");
105
+ else if (number === undefined)
106
+ skip(operation, "needs_pull_request");
107
+ else
108
+ await check(operation, { number });
109
+ }
110
+ if (!bound("findPullRequest"))
111
+ skip("findPullRequest", "not_bound");
112
+ else if (input.headRef === undefined)
113
+ skip("findPullRequest", "needs_head_ref");
114
+ else
115
+ await check("findPullRequest", { headRef: input.headRef });
116
+ for (const [operation, fields] of [
117
+ ["readFile", { path: "README.md" }],
118
+ ["listFiles", { path: "" }],
119
+ ["listCommitStatuses", {}],
120
+ ]) {
121
+ if (!bound(operation))
122
+ skip(operation, "not_bound");
123
+ else if (ref === undefined)
124
+ skip(operation, "needs_ref");
125
+ else
126
+ await check(operation, { ref, ...fields });
127
+ }
128
+ const mode = provider.permissionSecret ? "permission" : "allowlist";
129
+ if (mode === "allowlist")
130
+ skip("getRepositoryPermission", "allowlist_mode");
131
+ else if (!bound("getRepositoryPermission"))
132
+ skip("getRepositoryPermission", "not_bound");
133
+ else if (input.accountId === undefined)
134
+ skip("getRepositoryPermission", "needs_account");
135
+ else
136
+ await check("getRepositoryPermission", { accountId: input.accountId });
137
+ for (const { operation } of provider.operations) {
138
+ if (isHostOperation(operation) && !HOST_OPERATIONS[operation].readOnly)
139
+ skip(operation, "not_read_only");
140
+ }
141
+ return { provider: provider.name, repository, mode, results };
142
+ }
@@ -214,6 +214,6 @@ export declare function assertStorageVolumeInspection(volume: DockerVolumeInspec
214
214
  export declare function assertKeeperContainerInspection(container: DockerContainerInspection, spec: JobSpec): void;
215
215
  export declare function assertNetworkKeeperContainerInspection(container: DockerContainerInspection, spec: JobSpec): void;
216
216
  export declare function assertProxyContainerInspection(container: DockerContainerInspection, runId: string): void;
217
- export declare function assertWorkerContainerInspection(container: DockerContainerInspection, spec: JobSpec, expectedCapability: string, networkKeeperId?: string): void;
218
- export declare function assertClaudeAgentContainerInspection(container: DockerContainerInspection, spec: JobSpec, expectedCapability: string): void;
217
+ export declare function assertWorkerContainerInspection(container: DockerContainerInspection, spec: JobSpec, expectedCapability: string, networkKeeperId?: string, expectedMcpToken?: string): void;
218
+ export declare function assertClaudeAgentContainerInspection(container: DockerContainerInspection, spec: JobSpec, expectedCapability: string, expectedMcpToken?: string): void;
219
219
  export declare function assertClaudeToolRunnerContainerInspection(container: DockerContainerInspection, spec: JobSpec, expectedSetup: string, networkKeeperId?: string): void;
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { MCP_TOKEN_ENV } from "../../coding/mcp-token.js";
2
3
  export const CODING_WORKER_UID = 10001;
3
4
  export const CODING_WORKER_GID = 10001;
4
5
  // The keeper needs headroom for runc's short-lived exec process plus Node's threads.
@@ -101,6 +102,8 @@ function validateSpec(spec) {
101
102
  if (spec.sessionResume !== undefined && (spec.sessionResume !== true || spec.provider !== "claude-code")) {
102
103
  throw isolationError();
103
104
  }
105
+ if (spec.mcpTools !== undefined && spec.mcpTools !== true)
106
+ throw isolationError();
104
107
  if (spec.services !== undefined) {
105
108
  // docker-services.ts re-validates every entry against the catalog schema. Both providers' shells
106
109
  // share the network keeper's namespace: Codex's worker, and Claude Code's tool runner.
@@ -306,6 +309,27 @@ function workerNetworkMode(spec) {
306
309
  const names = isolationNames(spec.runId);
307
310
  return spec.services === undefined ? names.network : `container:${names.networkKeeperContainer}`;
308
311
  }
312
+ /**
313
+ * A run with MCP tools only: the agent's MCP token, by name. Like the capability, its value comes
314
+ * from the docker process's own environment (DockerCommandOptions.env), never the command line.
315
+ */
316
+ function mcpTokenEnvArgs(spec) {
317
+ return spec.mcpTools ? ["--env", MCP_TOKEN_ENV] : [];
318
+ }
319
+ /**
320
+ * The agent container's exact WARDBY_ environment: the proxy URL, the capability, and for a run with
321
+ * MCP tools its MCP token. Anything missing, changed or extra fails the attestation.
322
+ */
323
+ function agentEnvironmentMatches(environment, spec, expectedCapability, expectedMcpToken) {
324
+ if (spec.mcpTools && expectedMcpToken === undefined)
325
+ return false;
326
+ const expected = [
327
+ `WARDBY_PROXY_URL=http://${CODING_PROXY_ALIAS}:${CODING_PROXY_PORT}`,
328
+ `WARDBY_RUN_CAPABILITY=${expectedCapability}`,
329
+ ...(spec.mcpTools ? [`${MCP_TOKEN_ENV}=${expectedMcpToken}`] : []),
330
+ ];
331
+ return environment.length === expected.length && expected.every((value) => environment.includes(value));
332
+ }
309
333
  export function buildWorkerCreateArgs(spec, proxyPort = CODING_PROXY_PORT) {
310
334
  validateSpec(spec);
311
335
  assertIntegerRange(proxyPort, 1, 65_535);
@@ -360,6 +384,7 @@ export function buildWorkerCreateArgs(spec, proxyPort = CODING_PROXY_PORT) {
360
384
  `WARDBY_PROXY_URL=http://${CODING_PROXY_ALIAS}:${proxyPort}`,
361
385
  "--env",
362
386
  "WARDBY_RUN_CAPABILITY",
387
+ ...mcpTokenEnvArgs(spec),
363
388
  "--restart",
364
389
  "no",
365
390
  "--stop-signal",
@@ -459,6 +484,7 @@ export function buildClaudeAgentCreateArgs(spec, proxyPort = CODING_PROXY_PORT)
459
484
  `WARDBY_PROXY_URL=http://${CODING_PROXY_ALIAS}:${proxyPort}`,
460
485
  "--env",
461
486
  "WARDBY_RUN_CAPABILITY",
487
+ ...mcpTokenEnvArgs(spec),
462
488
  "--restart",
463
489
  "no",
464
490
  "--stop-signal",
@@ -780,7 +806,7 @@ function assertExactWorkerMounts(container, names) {
780
806
  }
781
807
  }
782
808
  }
783
- export function assertWorkerContainerInspection(container, spec, expectedCapability, networkKeeperId) {
809
+ export function assertWorkerContainerInspection(container, spec, expectedCapability, networkKeeperId, expectedMcpToken) {
784
810
  validateSpec(spec);
785
811
  const names = isolationNames(spec.runId);
786
812
  const host = container.HostConfig;
@@ -822,9 +848,7 @@ export function assertWorkerContainerInspection(container, spec, expectedCapabil
822
848
  Object.keys(container.NetworkSettings?.Ports ?? {}).length !== 0) {
823
849
  throw isolationError();
824
850
  }
825
- if (wardbyEnvironment.length !== 2 ||
826
- !wardbyEnvironment.includes(`WARDBY_PROXY_URL=http://${CODING_PROXY_ALIAS}:${CODING_PROXY_PORT}`) ||
827
- !wardbyEnvironment.includes(`WARDBY_RUN_CAPABILITY=${expectedCapability}`)) {
851
+ if (!agentEnvironmentMatches(wardbyEnvironment, spec, expectedCapability, expectedMcpToken)) {
828
852
  throw isolationError();
829
853
  }
830
854
  const tmpfs = host.Tmpfs ?? {};
@@ -893,16 +917,14 @@ function assertClaudeContainerBaseline(container, image, spec, limits) {
893
917
  !host.Tmpfs?.["/home/wardby"]?.includes("noexec"))
894
918
  throw isolationError();
895
919
  }
896
- export function assertClaudeAgentContainerInspection(container, spec, expectedCapability) {
920
+ export function assertClaudeAgentContainerInspection(container, spec, expectedCapability, expectedMcpToken) {
897
921
  validateSpec(spec);
898
922
  if (spec.provider !== "claude-code")
899
923
  throw isolationError();
900
924
  const names = isolationNames(spec.runId);
901
925
  assertClaudeContainerBaseline(container, spec.image, spec, claudeAgentLimits(spec));
902
926
  const environment = (container.Config?.Env ?? []).filter((value) => value.startsWith("WARDBY_"));
903
- if (environment.length !== 2 ||
904
- !environment.includes(`WARDBY_PROXY_URL=http://${CODING_PROXY_ALIAS}:${CODING_PROXY_PORT}`) ||
905
- !environment.includes(`WARDBY_RUN_CAPABILITY=${expectedCapability}`))
927
+ if (!agentEnvironmentMatches(environment, spec, expectedCapability, expectedMcpToken))
906
928
  throw isolationError();
907
929
  assertExactMountSet(container, names, [
908
930
  { path: WORKER_PATHS.input, writable: false, subpath: "input" },
@@ -88,6 +88,12 @@ export interface DockerJobLauncherOptions {
88
88
  workspaceRoot: string;
89
89
  proxyContainer: string;
90
90
  resolveCapability: (runId: string) => Promise<string>;
91
+ /**
92
+ * The run's MCP token (the capability vault), asked for only when the spec has mcpTools; the agent
93
+ * container gets it as WARDBY_MCP_TOKEN, through the docker process's environment like the
94
+ * capability. Absent = a run with MCP tools can't be launched.
95
+ */
96
+ resolveMcpToken?: (runId: string) => Promise<string | undefined>;
91
97
  /** Consult the durable application state before a startup sweep deletes resources. */
92
98
  isRunActive: (runId: string, handle: JobHandle) => Promise<boolean>;
93
99
  docker?: DockerCommandRunner;
@@ -131,6 +137,8 @@ export declare class DockerJobLauncher implements WorkspaceJobLauncher {
131
137
  remove(handle: JobHandle): Promise<void>;
132
138
  private initialize;
133
139
  private launchLocked;
140
+ /** The run's MCP token for a spec with mcpTools (refused unless well formed); undefined otherwise, unasked. */
141
+ private mcpTokenFor;
134
142
  private provision;
135
143
  /**
136
144
  * A run with services (docs/coding-services.md). The network keeper joins the run's internal
@@ -12,6 +12,7 @@ import { assertDockerHostSupportsIsolation, assertClaudeAgentContainerInspection
12
12
  import { serviceUnreadyError } from "../../coding/services/wording.js";
13
13
  import { assertServiceContainerInspection, buildDockerServicePlan, dockerServiceContainerNames, } from "./docker-services.js";
14
14
  import { claudeToolSetup } from "./claude-tool-setup.js";
15
+ import { isMcpToken, MCP_TOKEN_ENV } from "../../coding/mcp-token.js";
15
16
  import { reportServiceState, } from "./service-state.js";
16
17
  import { replaceDirectoryFromStaging } from "./workspace-swap.js";
17
18
  const BACKEND = "docker";
@@ -95,7 +96,7 @@ export class NodeDockerCommandRunner {
95
96
  if (args.some((argument) => argument.includes("\0")))
96
97
  throw new Error("docker_argument_invalid");
97
98
  const extraEnv = options.env ?? {};
98
- if (Object.keys(extraEnv).some((key) => key !== "WARDBY_RUN_CAPABILITY" && key !== CLAUDE_TOOL_SETUP_ENV))
99
+ if (Object.keys(extraEnv).some((key) => key !== "WARDBY_RUN_CAPABILITY" && key !== MCP_TOKEN_ENV && key !== CLAUDE_TOOL_SETUP_ENV))
99
100
  throw new Error("docker_environment_invalid");
100
101
  const env = {
101
102
  PATH: this.path,
@@ -447,6 +448,10 @@ function serviceUnready(name, cause) {
447
448
  function capabilityFromInspection(container) {
448
449
  return container.Config?.Env?.find((value) => value.startsWith("WARDBY_RUN_CAPABILITY="))?.slice("WARDBY_RUN_CAPABILITY=".length);
449
450
  }
451
+ function mcpTokenFromInspection(container) {
452
+ const prefix = `${MCP_TOKEN_ENV}=`;
453
+ return container.Config?.Env?.find((value) => value.startsWith(prefix))?.slice(prefix.length);
454
+ }
450
455
  function dockerState(inspection) {
451
456
  const state = inspection.State;
452
457
  if (!state)
@@ -655,14 +660,19 @@ export class DockerJobLauncher {
655
660
  const capability = ensureCapability(await this.options.resolveCapability(spec.runId));
656
661
  if (hash(capability) !== existing.capabilityHash)
657
662
  throw new Error("docker_capability_changed");
663
+ const mcpToken = await this.mcpTokenFor(spec);
664
+ if ((mcpToken === undefined ? undefined : hash(mcpToken)) !== existing.mcpTokenHash) {
665
+ throw new Error("docker_mcp_token_changed");
666
+ }
658
667
  if (existing.phase === "active") {
659
668
  await this.refresh(existing);
660
669
  return clone(existing.handle);
661
670
  }
662
- return this.provision(existing, plan, services, capability);
671
+ return this.provision(existing, plan, services, capability, mcpToken);
663
672
  }
664
673
  await this.validateInputArtifact(spec.inputArtifact);
665
674
  const capability = ensureCapability(await this.options.resolveCapability(spec.runId));
675
+ const mcpToken = await this.mcpTokenFor(spec);
666
676
  const record = {
667
677
  schemaVersion: STATE_SCHEMA_VERSION,
668
678
  runId: spec.runId,
@@ -673,12 +683,13 @@ export class DockerJobLauncher {
673
683
  createdAt: this.now(),
674
684
  deadlineAt: this.now() + plan.deadlineMs,
675
685
  capabilityHash: hash(capability),
686
+ ...(mcpToken !== undefined ? { mcpTokenHash: hash(mcpToken) } : {}),
676
687
  phase: "provisioning",
677
688
  };
678
689
  // This durable record is the idempotency fence before any Docker side effect.
679
690
  await this.writeRecord(record);
680
691
  try {
681
- return await this.provision(record, plan, services, capability);
692
+ return await this.provision(record, plan, services, capability, mcpToken);
682
693
  }
683
694
  catch (error) {
684
695
  await this.options
@@ -691,7 +702,16 @@ export class DockerJobLauncher {
691
702
  throw error;
692
703
  }
693
704
  }
694
- async provision(record, plan, services, capability) {
705
+ /** The run's MCP token for a spec with mcpTools (refused unless well formed); undefined otherwise, unasked. */
706
+ async mcpTokenFor(spec) {
707
+ if (!spec.mcpTools)
708
+ return undefined;
709
+ const token = await this.options.resolveMcpToken?.(spec.runId);
710
+ if (!isMcpToken(token))
711
+ throw new Error("docker_mcp_token_invalid");
712
+ return token;
713
+ }
714
+ async provision(record, plan, services, capability, mcpToken) {
695
715
  await this.assertHost();
696
716
  const labels = resourceLabels(record);
697
717
  await this.createAndAssert(appendLabels(plan.networkCreateArgs, labels), ["network", "inspect", plan.names.network], (value) => {
@@ -740,14 +760,14 @@ export class DockerJobLauncher {
740
760
  await this.createAndAssert(workerArgs, ["container", "inspect", plan.names.workerContainer], (value) => {
741
761
  const inspected = value;
742
762
  if (record.spec.provider === "claude-code") {
743
- assertClaudeAgentContainerInspection(inspected, record.spec, capability);
763
+ assertClaudeAgentContainerInspection(inspected, record.spec, capability, mcpToken);
744
764
  }
745
765
  else {
746
- assertWorkerContainerInspection(inspected, record.spec, capability, networkKeeperId);
766
+ assertWorkerContainerInspection(inspected, record.spec, capability, networkKeeperId, mcpToken);
747
767
  }
748
768
  if (!labelsMatch(inspected.Config?.Labels, labels))
749
769
  throw new Error("docker_resource_attestation_failed");
750
- }, { env: { WARDBY_RUN_CAPABILITY: capability } });
770
+ }, { env: { WARDBY_RUN_CAPABILITY: capability, ...(mcpToken !== undefined ? { [MCP_TOKEN_ENV]: mcpToken } : {}) } });
751
771
  await this.startContainer(plan.names.workerContainer);
752
772
  record.phase = "active";
753
773
  await this.writeRecord(record);
@@ -939,8 +959,12 @@ export class DockerJobLauncher {
939
959
  const capability = capabilityFromInspection(inspection);
940
960
  if (!capability || hash(capability) !== record.capabilityHash)
941
961
  throw new Error("docker_resource_attestation_failed");
962
+ // Only a run with MCP tools carries a token, and only the one its record hashed.
963
+ const mcpToken = record.spec.mcpTools ? mcpTokenFromInspection(inspection) : undefined;
964
+ if (record.spec.mcpTools && (!mcpToken || hash(mcpToken) !== record.mcpTokenHash))
965
+ throw new Error("docker_resource_attestation_failed");
942
966
  if (record.spec.provider === "claude-code") {
943
- assertClaudeAgentContainerInspection(inspection, record.spec, capability);
967
+ assertClaudeAgentContainerInspection(inspection, record.spec, capability, mcpToken);
944
968
  const tool = await this.inspect([
945
969
  "container",
946
970
  "inspect",
@@ -956,7 +980,7 @@ export class DockerJobLauncher {
956
980
  }
957
981
  }
958
982
  else {
959
- assertWorkerContainerInspection(inspection, record.spec, capability, await this.attestedNetworkKeeperId(record));
983
+ assertWorkerContainerInspection(inspection, record.spec, capability, await this.attestedNetworkKeeperId(record), mcpToken);
960
984
  }
961
985
  const phase = dockerState(inspection);
962
986
  if (!phase || phase === "provisioning" || phase === "active")
@@ -13,6 +13,8 @@ export declare const WORKER_CONTAINER = "worker";
13
13
  export declare const TOOL_RUNNER_CONTAINER = "tool-runner";
14
14
  /** The run Secret's key holding the tool runner's setup (claude-tool-setup.ts). */
15
15
  export declare const TOOL_SETUP_SECRET_KEY = "tool-setup";
16
+ /** The run Secret's key for the MCP token (a run with MCP tools only); the agent container alone reads it. */
17
+ export declare const MCP_TOKEN_SECRET_KEY = "mcp-token";
16
18
  /** Where the tool runner listens and the agent's relay connects (claude-tool-runner/main.mjs). */
17
19
  export declare const TOOL_SOCKET_PATH = "/run/wardby/tool/runner.sock";
18
20
  /** Every service sidecar's container name starts with this (service-postgres, service-redis, ...). */
@@ -82,7 +84,7 @@ export interface RunPodOptions {
82
84
  }
83
85
  export declare function buildRunPod(spec: JobSpec, options: RunPodOptions): V1Pod;
84
86
  export declare function buildRunNetworkPolicy(spec: JobSpec, namespace: string): V1NetworkPolicy;
85
- export declare function buildCapabilitySecret(spec: JobSpec, namespace: string, capability: string, toolSetup?: string): V1Secret;
87
+ export declare function buildCapabilitySecret(spec: JobSpec, namespace: string, capability: string, toolSetup?: string, mcpToken?: string): V1Secret;
86
88
  /** A copy of a container's resources with every quantity in one rendering ("1" and "1000m" alike); see normalizeResources. */
87
89
  export declare function canonicalResources(r?: V1Container["resources"]): V1Container["resources"];
88
90
  /**
@@ -7,6 +7,7 @@
7
7
  import { createHash } from "node:crypto";
8
8
  import { isIP } from "node:net";
9
9
  import { StoredCodingServicesSchema } from "../../coding/services/catalog.js";
10
+ import { MCP_TOKEN_ENV } from "../../coding/mcp-token.js";
10
11
  import { CLAUDE_CONFIG_PATH, CLAUDE_CONFIG_SCRATCH_MIB, CLAUDE_TOOL_SETUP_ENV, CODING_PROXY_ALIAS, CODING_PROXY_DENY_PORT, CODING_PROXY_PORT, CODING_WORKER_GID, CODING_WORKER_UID, WORKER_STOP_GRACE_SECONDS, claudeToolLimits, isRepositoryDigest, } from "./docker-isolation.js";
11
12
  import { GVISOR_RUNTIME_CLASS, KubernetesPlatformError, STORAGE_INIT_EPHEMERAL_MIB, TOOL_RUNNER_EPHEMERAL_MIB, WORKER_EPHEMERAL_MIB, conformResources, describeMib, normalizePlatformMetadata, platformProfile, podEphemeralStorageMib, } from "./kubernetes-platform.js";
12
13
  export const KUBERNETES_ISOLATION_ERROR = "kubernetes_isolation_unsupported";
@@ -19,6 +20,8 @@ export const WORKER_CONTAINER = "worker";
19
20
  export const TOOL_RUNNER_CONTAINER = "tool-runner";
20
21
  /** The run Secret's key holding the tool runner's setup (claude-tool-setup.ts). */
21
22
  export const TOOL_SETUP_SECRET_KEY = "tool-setup";
23
+ /** The run Secret's key for the MCP token (a run with MCP tools only); the agent container alone reads it. */
24
+ export const MCP_TOKEN_SECRET_KEY = "mcp-token";
22
25
  /** Where the tool runner listens and the agent's relay connects (claude-tool-runner/main.mjs). */
23
26
  export const TOOL_SOCKET_PATH = "/run/wardby/tool/runner.sock";
24
27
  const CODEX_ENTRYPOINT = "/opt/wardby/coding-worker/main.js";
@@ -172,6 +175,8 @@ export function validateKubernetesSpec(spec) {
172
175
  if (spec.sessionResume !== undefined && (spec.sessionResume !== true || spec.provider !== "claude-code")) {
173
176
  throw isolationError("session-resume-not-claude");
174
177
  }
178
+ if (spec.mcpTools !== undefined && spec.mcpTools !== true)
179
+ throw isolationError("mcp-tools-invalid");
175
180
  if (!isRegistryDigest(spec.image))
176
181
  throw isolationError("image-not-registry-digest");
177
182
  if (spec.services !== undefined) {
@@ -313,6 +318,10 @@ export function buildRunPod(spec, options) {
313
318
  env: [
314
319
  { name: "WARDBY_PROXY_URL", value: `http://${CODING_PROXY_ALIAS}:${CODING_PROXY_PORT}` },
315
320
  { name: "WARDBY_RUN_CAPABILITY", valueFrom: { secretKeyRef: { name: names.secret, key: "capability" } } },
321
+ // A run with MCP tools only: the agent's bearer for the proxy's /mcp route. Never the tool runner's.
322
+ ...(spec.mcpTools
323
+ ? [{ name: MCP_TOKEN_ENV, valueFrom: { secretKeyRef: { name: names.secret, key: MCP_TOKEN_SECRET_KEY } } }]
324
+ : []),
316
325
  ],
317
326
  securityContext: containerSecurity(),
318
327
  resources: conformResources(profile, {
@@ -464,14 +473,18 @@ export function buildRunNetworkPolicy(spec, namespace) {
464
473
  },
465
474
  };
466
475
  }
467
- export function buildCapabilitySecret(spec, namespace, capability, toolSetup) {
476
+ export function buildCapabilitySecret(spec, namespace, capability, toolSetup, mcpToken) {
468
477
  const names = kubernetesRunNames(spec.runId);
469
478
  return {
470
479
  apiVersion: "v1",
471
480
  kind: "Secret",
472
481
  type: "Opaque",
473
482
  metadata: { name: names.secret, namespace, labels: runLabels(spec.runId) },
474
- stringData: { capability, ...(toolSetup !== undefined ? { [TOOL_SETUP_SECRET_KEY]: toolSetup } : {}) },
483
+ stringData: {
484
+ capability,
485
+ ...(toolSetup !== undefined ? { [TOOL_SETUP_SECRET_KEY]: toolSetup } : {}),
486
+ ...(mcpToken !== undefined ? { [MCP_TOKEN_SECRET_KEY]: mcpToken } : {}),
487
+ },
475
488
  };
476
489
  }
477
490
  /**
@@ -14,6 +14,11 @@ export interface KubernetesJobLauncherOptions {
14
14
  config: KubernetesJobConfig;
15
15
  workspaceRoot: string;
16
16
  resolveCapability: (runId: string) => Promise<string>;
17
+ /**
18
+ * The run's MCP token (the capability vault), asked for only when the spec has mcpTools; it goes into
19
+ * the run's Secret next to the capability. Absent = a run with MCP tools can't be launched.
20
+ */
21
+ resolveMcpToken?: (runId: string) => Promise<string | undefined>;
17
22
  /**
18
23
  * Cluster preflight run once before the first launch; failure fails every launch. It may return the
19
24
  * proxy ClusterIP it validated; otherwise the launcher reads the proxy witness once itself. Either way