@moondeck/station 0.0.0-stage → 1.0.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 (325) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -2
  3. package/bin/uai-host.mjs +130 -0
  4. package/db/migrations/0000_host_tasks.sql +12 -0
  5. package/db/migrations/0001_host_ui.sql +11 -0
  6. package/db/migrations/0002_host_github_tokens.sql +8 -0
  7. package/db/migrations/0003_host_ssh_keys.sql +8 -0
  8. package/db/migrations/0004_host_owner_name.sql +1 -0
  9. package/db/migrations/0005_host_env_vars.sql +5 -0
  10. package/db/migrations/0006_host_project_env.sql +9 -0
  11. package/db/migrations/0007_host_github_token_kind.sql +7 -0
  12. package/db/migrations/0008_host_mcp_connections.sql +18 -0
  13. package/db/migrations/0009_host_agent_sessions.sql +15 -0
  14. package/db/migrations/0010_host_engine_accounts.sql +13 -0
  15. package/db/migrations/0011_codex_thread_id.sql +5 -0
  16. package/db/migrations/0012_agent_session_attach_key.sql +4 -0
  17. package/db/migrations/0013_github_credential_generations.sql +11 -0
  18. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  19. package/db/migrations/0015_host_settings.sql +9 -0
  20. package/db/migrations/0016_task_environment.sql +2 -0
  21. package/db/migrations/0017_engine_account_scope.sql +7 -0
  22. package/db/migrations/0018_engine_account_position.sql +3 -0
  23. package/db/migrations/0019_engine_account_store_ref.sql +3 -0
  24. package/db/migrations/0020_user_credential_store_ref.sql +6 -0
  25. package/db/migrations/0021_project_env_store_ref.sql +3 -0
  26. package/db/migrations/0022_task_project_ids.sql +5 -0
  27. package/db/migrations/0023_project_env_org.sql +4 -0
  28. package/db/migrations/0024_task_env_mode.sql +9 -0
  29. package/db/migrations/0025_project_env_authority.sql +147 -0
  30. package/db/migrations/0026_project_env_replica.sql +150 -0
  31. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  32. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  33. package/db/migrations/0029_task_shared_files_mode.sql +5 -0
  34. package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
  35. package/db/migrations/meta/_journal.json +223 -0
  36. package/db/schema.ts +850 -0
  37. package/images/standard/Dockerfile +347 -0
  38. package/images/standard/README.md +167 -0
  39. package/images/standard/container/code-server-settings.json +63 -0
  40. package/images/standard/container/corepack-version +1 -0
  41. package/images/standard/container/uai-init +704 -0
  42. package/images/standard/container/uai-materialize-runtimes +1527 -0
  43. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  44. package/images/standard/tool-versions +2 -0
  45. package/lib/__fixtures__/skill-index/SKILL.md +10 -0
  46. package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
  47. package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
  48. package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
  49. package/lib/agent-cli.ts +1507 -0
  50. package/lib/agent-errors.ts +22 -0
  51. package/lib/agent.ts +672 -0
  52. package/lib/agents/attach-compatibility.ts +51 -0
  53. package/lib/agents/claude.ts +918 -0
  54. package/lib/agents/codex-token-usage.fixture.json +130 -0
  55. package/lib/agents/codex.ts +1392 -0
  56. package/lib/agents/cursor-period-usage.ts +253 -0
  57. package/lib/agents/cursor.ts +562 -0
  58. package/lib/agents/dispatch.ts +78 -0
  59. package/lib/agents/durable-proc.ts +334 -0
  60. package/lib/agents/factory.ts +57 -0
  61. package/lib/agents/grok.ts +381 -0
  62. package/lib/agents/kimi.ts +329 -0
  63. package/lib/agents/machine-durable.ts +604 -0
  64. package/lib/agents/mock.ts +133 -0
  65. package/lib/agents/mode.ts +64 -0
  66. package/lib/agents/opencode.ts +508 -0
  67. package/lib/agents/output-source.ts +3 -0
  68. package/lib/agents/proc.ts +221 -0
  69. package/lib/agents/rate-limit.ts +337 -0
  70. package/lib/agents/registry.ts +182 -0
  71. package/lib/agents/session-state.ts +197 -0
  72. package/lib/agents/skill-index-preamble.fixture.ts +26 -0
  73. package/lib/agents/transport.ts +741 -0
  74. package/lib/agents/types.ts +254 -0
  75. package/lib/agents/uai.ts +106 -0
  76. package/lib/agents/usage.ts +44 -0
  77. package/lib/apple-one-off.ts +277 -0
  78. package/lib/apple-runtime-recycle.ts +237 -0
  79. package/lib/apple-uninstall-teardown.ts +224 -0
  80. package/lib/assignment-evidence.ts +94 -0
  81. package/lib/attachments.ts +48 -0
  82. package/lib/browser-testing.ts +1537 -0
  83. package/lib/capture-flow.ts +332 -0
  84. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  85. package/lib/cloud-credential-host.ts +512 -0
  86. package/lib/cloud-credential-leases.ts +2587 -0
  87. package/lib/cloud-credential-runtime.ts +1357 -0
  88. package/lib/cloud-state.ts +56 -0
  89. package/lib/codex-auth.ts +456 -0
  90. package/lib/command-db.ts +310 -0
  91. package/lib/compose-network-recovery.ts +283 -0
  92. package/lib/connection-secret-redaction.ts +25 -0
  93. package/lib/container-runtime.ts +1453 -0
  94. package/lib/credential-cleanup-inventory.ts +490 -0
  95. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  96. package/lib/credential-consumers/engines.ts +797 -0
  97. package/lib/credential-consumers/files.ts +934 -0
  98. package/lib/credential-consumers/github.ts +702 -0
  99. package/lib/credential-consumers/mcp.ts +389 -0
  100. package/lib/credential-consumers/task-files.ts +127 -0
  101. package/lib/credential-consumers/task-secrets.ts +10 -0
  102. package/lib/credential-store-pickup.ts +149 -0
  103. package/lib/credential-store-scope.ts +112 -0
  104. package/lib/credential-store.ts +932 -0
  105. package/lib/credentials-activation-authority.ts +709 -0
  106. package/lib/credentials-purge.ts +1006 -0
  107. package/lib/custom-connection-capture.ts +395 -0
  108. package/lib/custom-connection-launch.ts +384 -0
  109. package/lib/custom-connection-runtime.ts +141 -0
  110. package/lib/db.ts +80 -0
  111. package/lib/disk-pressure.ts +42 -0
  112. package/lib/docker-exec.ts +156 -0
  113. package/lib/engine-accounts.ts +1970 -0
  114. package/lib/engine-connect.ts +321 -0
  115. package/lib/engine-credential-env.ts +52 -0
  116. package/lib/engine-login.ts +2933 -0
  117. package/lib/engines.ts +1342 -0
  118. package/lib/enrollment-state.ts +251 -0
  119. package/lib/env-file.ts +246 -0
  120. package/lib/env-schema.ts +891 -0
  121. package/lib/env.ts +195 -0
  122. package/lib/fs-aliases.ts +49 -0
  123. package/lib/git-clone-credentials.ts +334 -0
  124. package/lib/git-diff.ts +530 -0
  125. package/lib/git-identity.ts +715 -0
  126. package/lib/github-git-auth.ts +207 -0
  127. package/lib/github-origin.ts +28 -0
  128. package/lib/github-tokens.ts +2439 -0
  129. package/lib/host-cloud-url.ts +62 -0
  130. package/lib/host-config.ts +279 -0
  131. package/lib/host-env.ts +424 -0
  132. package/lib/host-logs.ts +965 -0
  133. package/lib/internal-connections.ts +5 -0
  134. package/lib/keyed-promise-tail.ts +23 -0
  135. package/lib/launch-mode.ts +25 -0
  136. package/lib/lease.ts +239 -0
  137. package/lib/legacy-host.ts +769 -0
  138. package/lib/legacy-runtime-retirement.ts +424 -0
  139. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  140. package/lib/machine-exec.ts +238 -0
  141. package/lib/machine-gateway-tunnel.ts +344 -0
  142. package/lib/machine-keys.ts +65 -0
  143. package/lib/machine-project-env-loader.ts +152 -0
  144. package/lib/machine-provider-aws.ts +923 -0
  145. package/lib/machine-provider-local.ts +338 -0
  146. package/lib/machine-provider.ts +90 -0
  147. package/lib/managed-activation-watcher.ts +72 -0
  148. package/lib/managed-install-owner-watcher.ts +55 -0
  149. package/lib/managed-operation-drain.ts +57 -0
  150. package/lib/managed-runtime.ts +4296 -0
  151. package/lib/managed-update-scheduler.ts +125 -0
  152. package/lib/mcp-capture.ts +865 -0
  153. package/lib/mcp-config-lock.ts +2 -0
  154. package/lib/mcp-connections.ts +959 -0
  155. package/lib/mcp-gateway.ts +2594 -0
  156. package/lib/obs.ts +512 -0
  157. package/lib/openrouter-budget.ts +247 -0
  158. package/lib/orchestrator.ts +11409 -0
  159. package/lib/person-capture.ts +327 -0
  160. package/lib/preview-ports.ts +85 -0
  161. package/lib/preview-sidecar.ts +331 -0
  162. package/lib/project-env-authority.ts +1319 -0
  163. package/lib/project-env-feature.ts +74 -0
  164. package/lib/project-env-legacy-tags.ts +130 -0
  165. package/lib/project-env-loader.ts +242 -0
  166. package/lib/project-env-replica.ts +2094 -0
  167. package/lib/project-env-schema-capture.ts +342 -0
  168. package/lib/project-env-staging.ts +148 -0
  169. package/lib/project-env-task-policy.ts +176 -0
  170. package/lib/project-env-task-replica.ts +1752 -0
  171. package/lib/release-feed.ts +61 -0
  172. package/lib/release-manifest.ts +710 -0
  173. package/lib/release-trust.ts +46 -0
  174. package/lib/repo-clone.ts +38 -0
  175. package/lib/routing/shadow.ts +261 -0
  176. package/lib/runner-boot.ts +10 -0
  177. package/lib/runtime-activation-tail.ts +279 -0
  178. package/lib/runtime-archive.ts +1216 -0
  179. package/lib/runtime-authority.ts +166 -0
  180. package/lib/runtime-guard.ts +36 -0
  181. package/lib/runtime-provider-state.ts +169 -0
  182. package/lib/runtime-reconnect.ts +86 -0
  183. package/lib/runtime-state.ts +1033 -0
  184. package/lib/safe-http.ts +364 -0
  185. package/lib/secrets-proxy/address.ts +95 -0
  186. package/lib/secrets-proxy/audit.ts +30 -0
  187. package/lib/secrets-proxy/ca-store.ts +200 -0
  188. package/lib/secrets-proxy/ca.ts +470 -0
  189. package/lib/secrets-proxy/container-steps.ts +138 -0
  190. package/lib/secrets-proxy/der.ts +273 -0
  191. package/lib/secrets-proxy/engine.ts +1633 -0
  192. package/lib/secrets-proxy/host.ts +93 -0
  193. package/lib/secrets-proxy/hostname.ts +57 -0
  194. package/lib/secrets-proxy/placeholders.ts +196 -0
  195. package/lib/secrets-proxy/policy.ts +150 -0
  196. package/lib/secrets-proxy/readiness.ts +148 -0
  197. package/lib/secrets-proxy/runtime.ts +582 -0
  198. package/lib/secrets-proxy/scrub.ts +559 -0
  199. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  200. package/lib/secrets-proxy/task-scripts.ts +262 -0
  201. package/lib/secrets-proxy/task-trust.ts +605 -0
  202. package/lib/secrets-proxy/trust-watch.ts +80 -0
  203. package/lib/secrets.ts +71 -0
  204. package/lib/shared-files-s3.ts +976 -0
  205. package/lib/shared-files.ts +156 -0
  206. package/lib/sidecar-assign.ts +49 -0
  207. package/lib/sidecar-guard.ts +58 -0
  208. package/lib/sidecar-resume.ts +178 -0
  209. package/lib/sidecar.ts +69 -0
  210. package/lib/signin-box-credentials.ts +465 -0
  211. package/lib/signin-box.ts +114 -0
  212. package/lib/skill-index-example.fixture.ts +56 -0
  213. package/lib/skill-layout.ts +417 -0
  214. package/lib/skills.ts +489 -0
  215. package/lib/ssh.ts +401 -0
  216. package/lib/standard-image-cleanup.ts +117 -0
  217. package/lib/standard-image-pin.ts +207 -0
  218. package/lib/standard-image.ts +1697 -0
  219. package/lib/stopped-task-status-queue.ts +120 -0
  220. package/lib/task-container-cli.ts +269 -0
  221. package/lib/task-credential-cleanup.ts +752 -0
  222. package/lib/task-diff-sandbox.ts +464 -0
  223. package/lib/task-diff.ts +91 -0
  224. package/lib/task-env-forwarder.ts +228 -0
  225. package/lib/task-env-source.ts +249 -0
  226. package/lib/task-environment/anchor.ts +31 -0
  227. package/lib/task-environment/apple-container.ts +860 -0
  228. package/lib/task-environment/docker.ts +1344 -0
  229. package/lib/task-environment/index.ts +466 -0
  230. package/lib/task-environment/legacy-adoption.ts +467 -0
  231. package/lib/task-environment/machine-environment.ts +61 -0
  232. package/lib/task-environment/machine-task-up.ts +1227 -0
  233. package/lib/task-environment/machine.ts +974 -0
  234. package/lib/task-environment/registry.ts +59 -0
  235. package/lib/task-environment/self-identity.ts +20 -0
  236. package/lib/task-environment/self.ts +566 -0
  237. package/lib/task-environment/types.ts +642 -0
  238. package/lib/task-environment/workspace-files.ts +685 -0
  239. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  240. package/lib/task-files-machine-retirement-outbox.ts +592 -0
  241. package/lib/task-files-machine-retirement.ts +116 -0
  242. package/lib/task-files-readiness.ts +127 -0
  243. package/lib/task-gateway-port.ts +22 -0
  244. package/lib/task-gateway-protocol.ts +190 -0
  245. package/lib/task-gateway-recovery.ts +47 -0
  246. package/lib/task-gateway-state.ts +512 -0
  247. package/lib/task-identity.ts +19 -0
  248. package/lib/task-inventory.ts +682 -0
  249. package/lib/task-machine-credentials-activation.ts +95 -0
  250. package/lib/task-machine-swap-safety.ts +729 -0
  251. package/lib/task-person-credentials.ts +76 -0
  252. package/lib/task-project-ops-host.ts +147 -0
  253. package/lib/task-project-ops.ts +1062 -0
  254. package/lib/task-secret-staging.ts +16 -0
  255. package/lib/task-status.ts +46 -0
  256. package/lib/task-token.ts +46 -0
  257. package/lib/token-broker-adopt.ts +344 -0
  258. package/lib/token-broker-lambda.ts +105 -0
  259. package/lib/token-broker-local.ts +392 -0
  260. package/lib/token-broker-revision.ts +310 -0
  261. package/lib/token-broker-ssm.ts +525 -0
  262. package/lib/token-broker.ts +218 -0
  263. package/lib/token-lanes/openai-oauth.ts +123 -0
  264. package/lib/token-lanes/registry.ts +45 -0
  265. package/lib/token-lanes/types.ts +163 -0
  266. package/lib/token-lanes/xai-oauth.ts +165 -0
  267. package/lib/transcript.ts +128 -0
  268. package/lib/tunnel-registry.ts +220 -0
  269. package/lib/tunnel-runtime.ts +235 -0
  270. package/lib/ulid.ts +16 -0
  271. package/lib/user-bus-env.ts +116 -0
  272. package/lib/user-credentials-store.ts +435 -0
  273. package/lib/vnode-pressure.ts +142 -0
  274. package/package.json +131 -4
  275. package/runner/runner.mjs +442 -0
  276. package/scripts/agent/_common.sh +633 -0
  277. package/scripts/agent/env-hook.sh +626 -0
  278. package/scripts/agent/moondeck-cli.sh +5 -0
  279. package/scripts/agent/task-down.sh +291 -0
  280. package/scripts/agent/task-status.sh +70 -0
  281. package/scripts/agent/task-up.sh +3761 -0
  282. package/scripts/agent/uai-env-exec.sh +26 -0
  283. package/scripts/install/darwin.ts +1048 -0
  284. package/scripts/install/linux.ts +958 -0
  285. package/scripts/install/types.ts +78 -0
  286. package/scripts/install/util.ts +254 -0
  287. package/scripts/install/win.ts +143 -0
  288. package/src/activation-respawn.ts +155 -0
  289. package/src/apple-tunnel-route.ts +104 -0
  290. package/src/capture-wire.ts +102 -0
  291. package/src/cli.ts +2583 -0
  292. package/src/cloud-credentials.ts +2144 -0
  293. package/src/cloud-origins.ts +23 -0
  294. package/src/credential-location.ts +248 -0
  295. package/src/credential-wire-validation.ts +25 -0
  296. package/src/custom-connections.ts +237 -0
  297. package/src/engine-account-paths.ts +11 -0
  298. package/src/engine-models.ts +39 -0
  299. package/src/engine-usage.ts +104 -0
  300. package/src/event-outbox.ts +185 -0
  301. package/src/index.ts +2899 -0
  302. package/src/load-env.ts +97 -0
  303. package/src/machine-tunnel-route.ts +156 -0
  304. package/src/main.ts +6349 -0
  305. package/src/never-proxied-domains.ts +31 -0
  306. package/src/outbound-frame.ts +31 -0
  307. package/src/paths.ts +81 -0
  308. package/src/project-env-latest.ts +130 -0
  309. package/src/project-env-policy.ts +396 -0
  310. package/src/project-env-wording.ts +53 -0
  311. package/src/protocol.ts +5545 -0
  312. package/src/runtime-bootstrap.ts +165 -0
  313. package/src/station-identity.ts +96 -0
  314. package/src/task-env-names.ts +28 -0
  315. package/src/task-secrets.ts +75 -0
  316. package/src/tunnel-dial.ts +102 -0
  317. package/src/ui/server.ts +852 -0
  318. package/src/ui/types.ts +173 -0
  319. package/src/workspace-path-policy.ts +41 -0
  320. package/ui/app.js +1102 -0
  321. package/ui/index.html +90 -0
  322. package/ui/style.css +685 -0
  323. package/ui/uai-favicon.svg +21 -0
  324. package/ui/uai-logo-black.svg +9 -0
  325. package/ui/uai-wheel.svg +9 -0
@@ -0,0 +1,2594 @@
1
+ /**
2
+ * MCP gateway (ADR-057) — the host-side proxy that puts the owner's MCP
3
+ * connections in front of task containers WITHOUT the credentials ever
4
+ * entering them.
5
+ *
6
+ * Containers call `http://<gateway-host>:<port>/t/<token>/<slug>` (Docker:
7
+ * host.docker.internal; Apple: the vmnet gateway IP); the
8
+ * gateway resolves the per-task token to an ACL file written at session
9
+ * ensure (which connections this task may use), attaches the connection's
10
+ * Authorization header host-side (refreshing OAuth tokens as needed), and
11
+ * streams the MCP traffic through (streamable HTTP + SSE). Every request
12
+ * lands in an audit JSONL. Disconnecting a connection kills its secrets in
13
+ * the host store, so the gateway 401s instantly — the kill switch.
14
+ *
15
+ * VERIFY-ON-MAC: OrbStack forwards host.docker.internal to loopback-bound
16
+ * host services; if not, set UAI_MCP_GATEWAY_BIND=0.0.0.0 (the unguessable
17
+ * per-task token stays the auth).
18
+ *
19
+ * ADR-154 §11: a task on cloud credentials is served from its lease instead
20
+ * (credential-consumers/mcp.ts): the header comes from memory, every request
21
+ * goes through the SSRF rule, and an expired or refused OAuth token is
22
+ * refreshed by the cloud's broker, never here. On a runner past its
23
+ * credentials floor nothing falls back to the local store.
24
+ */
25
+
26
+ import {
27
+ appendFileSync,
28
+ lstatSync,
29
+ mkdirSync,
30
+ readFileSync,
31
+ rmSync,
32
+ writeFileSync,
33
+ } from "node:fs";
34
+ import {
35
+ createServer as createHttpServer,
36
+ type IncomingMessage,
37
+ type Server as HttpServer,
38
+ type ServerResponse,
39
+ } from "node:http";
40
+ import { randomBytes, timingSafeEqual } from "node:crypto";
41
+ import {
42
+ createServer as createNetServer,
43
+ type Server as NetServer,
44
+ type Socket,
45
+ } from "node:net";
46
+ import { Readable, pipeline } from "node:stream";
47
+ import { resolve } from "node:path";
48
+
49
+ import { env } from "./env";
50
+ import {
51
+ appleContainerRuntimeBinding,
52
+ pinnedContainerRuntimeProvider,
53
+ } from "./container-runtime";
54
+ import {
55
+ taskAppContainerName,
56
+ taskContainerBackend,
57
+ taskContainerCli,
58
+ } from "./task-container-cli";
59
+ import { MCP_CONFIG_LOCK_PATH } from "./mcp-config-lock";
60
+ import {
61
+ RUNTIME_AUTHORITY_ENV,
62
+ runtimeAuthorityDockerExecArgs,
63
+ } from "./runtime-authority";
64
+ import type { TaskEnvironmentHandle } from "./task-environment/types";
65
+ import { LeftOwnCredentialsError, onOwnCredentials, whileOnOwnCredentials } from "./cloud-credential-host";
66
+ import { authHeaderFor, getConnection } from "./mcp-connections";
67
+ import {
68
+ forgetLeasedMcp,
69
+ holdLeasedMcp,
70
+ leasedMcpFor,
71
+ type McpRequestHold,
72
+ type LeasedMcpConnection,
73
+ } from "./credential-consumers/mcp";
74
+ import { safeRequest, type SafeHttpDeps, type SafeHttpResponse } from "./safe-http";
75
+ import { agentClisReady } from "./standard-image";
76
+ import { isSafeHostTaskId, assertSafeHostTaskId } from "./task-identity";
77
+ import type { McpGatewayCapability } from "../src/protocol";
78
+ import { isEnvironmentTransportProvider } from "./task-environment/types";
79
+ import { sidecarTaskId } from "./sidecar";
80
+ import {
81
+ TASK_FORWARDER_ACCEPT_MAGIC,
82
+ TASK_FORWARDER_MAX_PREAMBLE_BYTES,
83
+ TASK_FORWARDER_PREAMBLE_HEADER_BYTES,
84
+ TASK_FORWARDER_RESPONSE_BYTES,
85
+ decodeTaskForwarderPreamble,
86
+ decodeTaskForwarderResponse,
87
+ encodeTaskForwarderChallenge,
88
+ taskForwarderPreambleLength,
89
+ type TaskForwarderIdentity,
90
+ } from "./task-gateway-protocol";
91
+ import {
92
+ currentTaskGatewayRegistration,
93
+ isTaskGatewayGenerationCurrent,
94
+ resolveTaskGatewayEnv,
95
+ subscribeTaskGatewayRevocations,
96
+ verifyTaskGatewayAuthentication,
97
+ } from "./task-gateway-state";
98
+ import { MCP_GATEWAY_PORT } from "./task-gateway-port";
99
+ import { MAX_MANIFEST_MCP } from "../src/cloud-credentials";
100
+
101
+ export type { McpGatewayCapability } from "../src/protocol";
102
+
103
+ export { MCP_GATEWAY_PORT };
104
+
105
+ /** Loopback works only where the container runtime forwards
106
+ * host.docker.internal INTO the host loopback — macOS Docker
107
+ * (Desktop/OrbStack). Native Linux docker resolves host.docker.internal to
108
+ * the bridge gateway IP, where a loopback bind is unreachable: every task's
109
+ * MCP config carried a dead URL and the gateway showed connection-refused
110
+ * from inside containers (live 2026-08-24, first Linux host with an MCP
111
+ * connection). Apple vmnet guests cannot reach host loopback either. Both
112
+ * non-mac-docker cases therefore bind all interfaces — the unguessable
113
+ * per-task path token remains the auth (same posture the operator override
114
+ * has always allowed). Resolved per call: the runtime pin lands before the
115
+ * production listener starts, and a pin never changes within a process. */
116
+ function gatewayBindAddress(): string {
117
+ const configured = process.env.UAI_MCP_GATEWAY_BIND;
118
+ if (configured) return configured;
119
+ // The pin, not the ready-gated identity: the production listener starts
120
+ // during boot while activation still reports `checking`, and a loopback
121
+ // bind chosen then would strand every vmnet guest.
122
+ if (pinnedContainerRuntimeProvider() === "apple-container") return "0.0.0.0";
123
+ return process.platform === "darwin" ? "127.0.0.1" : "0.0.0.0";
124
+ }
125
+
126
+ /** Host address a task container dials to reach this gateway. Docker guests
127
+ * resolve host.docker.internal; Apple vmnet guests dial the vmnet gateway IP,
128
+ * read once per process from the bundled CLI's network inspection (the
129
+ * builtin `default` network the app containers join). A resolution failure
130
+ * throws so the config write fails visibly and is retried on the next session
131
+ * ensure, rather than baking a dead URL into the task. */
132
+ let cachedAppleGatewayHost: string | null = null;
133
+ export async function taskGatewayHost(): Promise<string> {
134
+ if (pinnedContainerRuntimeProvider() !== "apple-container") {
135
+ return "host.docker.internal";
136
+ }
137
+ if (cachedAppleGatewayHost !== null) return cachedAppleGatewayHost;
138
+ const binding = appleContainerRuntimeBinding();
139
+ if (binding === null) {
140
+ throw new Error("apple-container selected without a bundled CLI binding");
141
+ }
142
+ const { execFile } = await import("node:child_process");
143
+ const stdout = await new Promise<string>((resolveExec, rejectExec) => {
144
+ execFile(
145
+ binding.containerCliPath,
146
+ ["network", "inspect", "default"],
147
+ { timeout: 15_000, maxBuffer: 256 * 1024 },
148
+ (error, out) => (error ? rejectExec(error) : resolveExec(out)),
149
+ );
150
+ });
151
+ const parsed = JSON.parse(stdout) as Array<{
152
+ status?: { ipv4Gateway?: unknown };
153
+ }>;
154
+ const gateway = parsed[0]?.status?.ipv4Gateway;
155
+ if (
156
+ typeof gateway !== "string" ||
157
+ !/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(gateway)
158
+ ) {
159
+ throw new Error("could not resolve the vmnet gateway address");
160
+ }
161
+ cachedAppleGatewayHost = gateway;
162
+ return gateway;
163
+ }
164
+ /** MCP request bodies are small JSON-RPC frames; cap so audit parsing (and a
165
+ * hostile container) can't balloon host memory. Responses stream freely. */
166
+ const MAX_BODY_BYTES = 4 * 1024 * 1024;
167
+ /** Bounds the upstream CONNECT (until response headers) only. The response
168
+ * BODY must never be time-bounded: MCP streamable-HTTP clients hold SSE
169
+ * streams open indefinitely, and an abort mid-pipe once took the whole host
170
+ * process down as an unhandled Readable 'error' (2026-07-13). */
171
+ const UPSTREAM_CONNECT_TIMEOUT_MS = 60_000;
172
+ const GATEWAY_RETRY_INITIAL_DELAY_MS = 1_000;
173
+ const GATEWAY_RETRY_MAX_DELAY_MS = 15_000;
174
+ const GATEWAY_RETRY_MIN_JITTER = 0.8;
175
+ const GATEWAY_RETRY_JITTER_SPAN = 0.4;
176
+ const TASK_GATEWAY_PREAUTH_TIMEOUT_MS = 3_000;
177
+ const TASK_GATEWAY_MAX_PENDING_PREAUTH = 128;
178
+ const TASK_GATEWAY_MAX_PENDING_PREAUTH_PER_PEER = 16;
179
+ const TASK_GATEWAY_MAX_PENDING_PREAUTH_PER_TASK = 16;
180
+ // MCP streamable-HTTP/SSE and proxied WebSockets can legitimately hold a
181
+ // connection for the life of a session, so active limits are deliberately
182
+ // separate from the tight three-second handshake limits above. They are still
183
+ // finite: once a socket has supplied its first application byte, HTTP's header
184
+ // and request deadlines bound time, while these counters bound cardinality.
185
+ const TASK_GATEWAY_MAX_ACTIVE_CONNECTIONS = 2_048;
186
+ // Direct sockets have not proved which task they belong to yet. Treat this as
187
+ // a small, evictable staging allowance; tokenized MCP moves to the task quota
188
+ // as soon as Node parses and validates the request path.
189
+ const TASK_GATEWAY_MAX_DIRECT_STAGING_CONNECTIONS_PER_PEER = 16;
190
+ const TASK_GATEWAY_MAX_ACTIVE_CONNECTIONS_PER_TASK = 256;
191
+
192
+ export interface TaskMcpConnection {
193
+ id: string;
194
+ slug: string;
195
+ }
196
+
197
+ interface TaskAcl {
198
+ token: string;
199
+ connections: TaskMcpConnection[];
200
+ }
201
+
202
+ function aclDir(): string {
203
+ return resolve(env.dataDir, "mcp-gateway");
204
+ }
205
+
206
+ function aclPath(taskId: string): string {
207
+ return resolve(aclDir(), `${taskId}.json`);
208
+ }
209
+
210
+ function readAcl(taskId: string): TaskAcl | null {
211
+ try {
212
+ return JSON.parse(readFileSync(aclPath(taskId), "utf8")) as TaskAcl;
213
+ } catch {
214
+ return null;
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Write/refresh the task's gateway ACL (the connection set snapshots the
220
+ * cloud's latest ensure input). The token is minted once per task and
221
+ * survives re-ensures so already-written .mcp.json files stay valid.
222
+ */
223
+ export function ensureTaskGatewayAcl(
224
+ taskId: string,
225
+ connections: TaskMcpConnection[],
226
+ ): TaskAcl {
227
+ const existing = readAcl(taskId);
228
+ const acl: TaskAcl = {
229
+ token: existing?.token ?? `${taskId}.${randomBytes(24).toString("base64url")}`,
230
+ connections,
231
+ };
232
+ mkdirSync(aclDir(), { recursive: true, mode: 0o700 });
233
+ writeFileSync(aclPath(taskId), JSON.stringify(acl), { mode: 0o600 });
234
+ return acl;
235
+ }
236
+
237
+ export function clearTaskGatewayAcl(taskId: string): void {
238
+ // Defense in depth: the id becomes a filesystem path component, and some
239
+ // callers derive it from container-influenced data (labels). A traversal
240
+ // like `../container-runtime-provider` must never resolve outside the ACL
241
+ // directory, so an unsafe id is a no-op here rather than a deletion.
242
+ if (!isSafeHostTaskId(taskId)) return;
243
+ forgetLeasedMcp(taskId);
244
+ try {
245
+ rmSync(aclPath(taskId));
246
+ } catch {
247
+ // already gone
248
+ }
249
+ }
250
+
251
+ /** Strict variant used only when acknowledging orphan-GC completion. */
252
+ export function clearTaskGatewayAclStrict(taskId: string): void {
253
+ assertSafeHostTaskId(taskId);
254
+ const path = aclPath(taskId);
255
+ rmSync(path, { force: true });
256
+ try {
257
+ lstatSync(path);
258
+ } catch (error) {
259
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
260
+ throw error;
261
+ }
262
+ throw new Error("task gateway ACL remains after removal");
263
+ }
264
+
265
+ function audit(entry: Record<string, unknown>): void {
266
+ try {
267
+ const dir = resolve(env.dataDir, "logs");
268
+ mkdirSync(dir, { recursive: true });
269
+ appendFileSync(
270
+ resolve(dir, "mcp-audit.jsonl"),
271
+ `${JSON.stringify({ ts: Date.now(), ...entry })}\n`,
272
+ );
273
+ } catch {
274
+ // audit is best-effort
275
+ }
276
+ }
277
+
278
+ function deny(res: ServerResponse, status: number, message: string): void {
279
+ res.writeHead(status, { "content-type": "application/json" });
280
+ res.end(JSON.stringify({ error: message }));
281
+ }
282
+
283
+ /** The request's body, or null past MAX_BODY_BYTES. With `signal`, an abort
284
+ * ends the request where it is (the read rejects): a task that stops
285
+ * sending mid-body never holds what waits for its request to end. */
286
+ async function readBody(req: IncomingMessage, signal?: AbortSignal): Promise<Buffer | null> {
287
+ const stop = (): void => {
288
+ req.destroy(new Error("request ended"));
289
+ };
290
+ if (signal?.aborted) stop();
291
+ else signal?.addEventListener("abort", stop, { once: true });
292
+ try {
293
+ const chunks: Buffer[] = [];
294
+ let size = 0;
295
+ for await (const chunk of req) {
296
+ const buf = chunk as Buffer;
297
+ size += buf.length;
298
+ if (size > MAX_BODY_BYTES) return null;
299
+ chunks.push(buf);
300
+ }
301
+ return Buffer.concat(chunks);
302
+ } finally {
303
+ signal?.removeEventListener("abort", stop);
304
+ }
305
+ }
306
+
307
+ /** JSON-RPC method (+ tool name for tools/call) out of a request body. */
308
+ function rpcSummary(body: Buffer): { method?: string; tool?: string } {
309
+ try {
310
+ const json = JSON.parse(body.toString("utf8")) as {
311
+ method?: string;
312
+ params?: { name?: string };
313
+ };
314
+ return {
315
+ method: typeof json.method === "string" ? json.method : undefined,
316
+ tool:
317
+ json.method === "tools/call" && typeof json.params?.name === "string"
318
+ ? json.params.name
319
+ : undefined,
320
+ };
321
+ } catch {
322
+ return {};
323
+ }
324
+ }
325
+
326
+ const FORWARD_REQ_HEADERS = [
327
+ "content-type",
328
+ "accept",
329
+ "mcp-session-id",
330
+ "last-event-id",
331
+ "mcp-protocol-version",
332
+ ];
333
+ const FORWARD_RES_HEADERS = ["content-type", "mcp-session-id"];
334
+
335
+ export interface AuthenticatedTaskGatewayConnection
336
+ extends TaskForwarderIdentity {
337
+ readonly socket: Socket;
338
+ /** Re-check immediately before using task authority. */
339
+ readonly isCurrent: () => boolean;
340
+ }
341
+
342
+ export type TaskGatewayTlsHandler = (
343
+ connection: AuthenticatedTaskGatewayConnection,
344
+ ) => void;
345
+
346
+ let taskGatewayTlsHandler: TaskGatewayTlsHandler | null = null;
347
+
348
+ /**
349
+ * Register the TLS consumer used by the ADR-153 secrets proxy. There is one
350
+ * gateway and therefore one consumer; a second registration is a wiring bug.
351
+ */
352
+ export function registerTaskGatewayTlsHandler(
353
+ handler: TaskGatewayTlsHandler,
354
+ ): () => void {
355
+ if (taskGatewayTlsHandler !== null) {
356
+ throw new Error("task gateway TLS handler is already registered");
357
+ }
358
+ taskGatewayTlsHandler = handler;
359
+ return () => {
360
+ if (taskGatewayTlsHandler === handler) taskGatewayTlsHandler = null;
361
+ };
362
+ }
363
+
364
+ function loopbackPeer(socket: Socket): boolean {
365
+ const address = socket.remoteAddress ?? "";
366
+ return (
367
+ address === "::1" ||
368
+ /^127(?:\.\d{1,3}){3}$/.test(address) ||
369
+ /^::ffff:127(?:\.\d{1,3}){3}$/i.test(address)
370
+ );
371
+ }
372
+
373
+ async function handleEnvRequest(
374
+ req: IncomingMessage,
375
+ res: ServerResponse,
376
+ identity: TaskForwarderIdentity | null,
377
+ ): Promise<void> {
378
+ // Keep the task hook's deliberately tiny response grammar stable. Node adds
379
+ // Date by default, but the response is local, non-cacheable, and has no use
380
+ // for a wall-clock header.
381
+ res.sendDate = false;
382
+ if (identity === null) return deny(res, 404, "not found");
383
+ if (req.method !== "GET") {
384
+ res.setHeader("allow", "GET");
385
+ return deny(res, 405, "method not allowed");
386
+ }
387
+ const snapshot = resolveTaskGatewayEnv(identity);
388
+ if (snapshot === null) return deny(res, 503, "task env unavailable");
389
+ const headers = {
390
+ etag: snapshot.etag,
391
+ "cache-control": "no-store",
392
+ };
393
+ if (req.headers["if-none-match"] === snapshot.etag) {
394
+ res.writeHead(304, { ...headers, "content-length": "0" });
395
+ res.end();
396
+ return;
397
+ }
398
+ res.writeHead(200, {
399
+ ...headers,
400
+ "content-type": "text/plain; charset=utf-8",
401
+ "content-length": String(snapshot.body.length),
402
+ });
403
+ res.end(snapshot.body);
404
+ }
405
+
406
+ async function handleMcpRequest(
407
+ req: IncomingMessage,
408
+ res: ServerResponse,
409
+ claimTaskQuota: (socket: Socket, taskId: string) => boolean,
410
+ ): Promise<void> {
411
+ const match = /^\/t\/([^/]+)\/([^/?]+)\/?(?:\?.*)?$/.exec(req.url ?? "");
412
+ if (!match) return deny(res, 404, "not found");
413
+ const [, token, slug] = match as unknown as [string, string, string];
414
+
415
+ const taskId = token.split(".")[0] ?? "";
416
+ const acl = readAcl(taskId);
417
+ const expected = acl ? Buffer.from(acl.token) : null;
418
+ const provided = Buffer.from(token);
419
+ if (
420
+ !acl ||
421
+ !expected ||
422
+ expected.length !== provided.length ||
423
+ !timingSafeEqual(expected, provided)
424
+ ) {
425
+ return deny(res, 401, "unknown task token");
426
+ }
427
+ // A direct connection is admitted only to an evictable peer staging pool.
428
+ // Once its unguessable ACL token proves the task, move the close-scoped
429
+ // reservation into that task's quota before using any task authority. An
430
+ // authenticated forwarder is already in this quota, and must name the same
431
+ // task if it sends an MCP request over its connection.
432
+ if (!claimTaskQuota(req.socket, taskId)) {
433
+ res.shouldKeepAlive = false;
434
+ res.setHeader("connection", "close");
435
+ return deny(res, 429, "task connection limit reached");
436
+ }
437
+ // ADR-154 §11: a task on cloud credentials is served from its lease.
438
+ const lease = leasedMcpFor(taskId);
439
+ if (lease || leaseOnly) {
440
+ if (!lease?.open) return deny(res, 503, "credentials unavailable");
441
+ const leased = lease.connections.get(slug);
442
+ if (!leased) return deny(res, 404, `no connection "${slug}" for this task`);
443
+ // Named but unreadable: unavailable, never "no such connection".
444
+ const hold = "unavailable" in leased ? null : holdLeasedMcp(taskId, slug);
445
+ if (!hold) return deny(res, 503, "credentials unavailable");
446
+ return serveLeasedConnection(req, res, taskId, hold);
447
+ }
448
+ // ADR-154: the runner's own connections only while it is on its own
449
+ // credentials; from the moment it begins leaving them, none is read.
450
+ if (!onOwnCredentials()) return deny(res, 503, "credentials unavailable");
451
+ const entry = acl.connections.find((c) => c.slug === slug);
452
+ if (!entry) return deny(res, 404, `no connection "${slug}" for this task`);
453
+ const conn = getConnection(entry.id);
454
+ if (!conn || conn.status !== "connected") {
455
+ // Disconnected since the task started — the kill switch answering.
456
+ return deny(res, 401, `connection "${slug}" is no longer available`);
457
+ }
458
+
459
+ const body =
460
+ req.method === "POST" || req.method === "PUT" ? await readBody(req) : null;
461
+ if ((req.method === "POST" || req.method === "PUT") && body === null) {
462
+ return deny(res, 413, "request body too large");
463
+ }
464
+
465
+ const headers: Record<string, string> = {};
466
+ for (const name of FORWARD_REQ_HEADERS) {
467
+ const v = req.headers[name];
468
+ if (typeof v === "string") headers[name] = v;
469
+ }
470
+
471
+ // The controller lives for the whole exchange: the timer only guards the
472
+ // connect phase (cleared once headers land), and a client hang-up reaps the
473
+ // upstream so dead streams don't accumulate.
474
+ const controller = new AbortController();
475
+ res.on("close", () => controller.abort());
476
+
477
+ const attempt = async (forceRefresh: boolean): Promise<Response> => {
478
+ // Counted (leaving waits for a refresh under way), and never a local
479
+ // header sent once leaving has begun, however long the read took.
480
+ const reading = whileOnOwnCredentials(() => authHeaderFor(conn, forceRefresh));
481
+ if (reading === null) throw new LeftOwnCredentialsError();
482
+ const auth = await reading;
483
+ if (!onOwnCredentials()) throw new LeftOwnCredentialsError();
484
+ if (auth) headers[auth.name] = auth.value;
485
+ const connectTimer = setTimeout(
486
+ () => controller.abort(),
487
+ UPSTREAM_CONNECT_TIMEOUT_MS,
488
+ );
489
+ try {
490
+ return await fetch(conn.url, {
491
+ method: req.method,
492
+ headers,
493
+ body: body ?? undefined,
494
+ signal: controller.signal,
495
+ });
496
+ } finally {
497
+ clearTimeout(connectTimer);
498
+ }
499
+ };
500
+
501
+ let upstream: Response;
502
+ try {
503
+ upstream = await attempt(false);
504
+ if (upstream.status === 401 && conn.authKind === "oauth") {
505
+ upstream = await attempt(true);
506
+ }
507
+ } catch (err) {
508
+ if (err instanceof LeftOwnCredentialsError) return deny(res, 503, "credentials unavailable");
509
+ audit({ taskId, slug, method: req.method, error: String(err) });
510
+ return deny(res, 502, "upstream unreachable");
511
+ }
512
+
513
+ const summary = body ? rpcSummary(body) : {};
514
+ audit({
515
+ taskId,
516
+ slug,
517
+ method: req.method,
518
+ rpc: summary.method,
519
+ tool: summary.tool,
520
+ status: upstream.status,
521
+ });
522
+
523
+ const resHeaders: Record<string, string> = {};
524
+ for (const name of FORWARD_RES_HEADERS) {
525
+ const v = upstream.headers.get(name);
526
+ if (v) resHeaders[name] = v;
527
+ }
528
+ res.writeHead(upstream.status, resHeaders);
529
+ if (upstream.body) {
530
+ // pipeline (NOT .pipe) so a mid-stream error — client gone, upstream
531
+ // reset, abort — tears both ends down instead of crashing the process
532
+ // as an unhandled Readable 'error'.
533
+ pipeline(
534
+ Readable.fromWeb(upstream.body as Parameters<typeof Readable.fromWeb>[0]),
535
+ res,
536
+ (err) => {
537
+ if (err && err.name !== "AbortError") {
538
+ audit({ taskId, slug, stream: "ended", error: err.name });
539
+ }
540
+ },
541
+ );
542
+ } else {
543
+ res.end();
544
+ }
545
+ }
546
+
547
+ /** ADR-154 §13: a runner past its credentials floor serves MCP only from
548
+ * leases; a task without one is answered 503, never from the local store. */
549
+ let leaseOnly = false;
550
+
551
+ export function setMcpGatewayLeaseOnly(on: boolean): void {
552
+ leaseOnly = on;
553
+ }
554
+
555
+ /** Tests only: the SSRF rule's injectable resolver and trust roots. */
556
+ let leasedSafeHttpDeps: SafeHttpDeps = {};
557
+ export function setLeasedMcpSafeHttpDepsForTests(deps: SafeHttpDeps): void {
558
+ leasedSafeHttpDeps = deps;
559
+ }
560
+
561
+ const LEASED_METHODS = new Set(["GET", "POST", "DELETE"]);
562
+
563
+ /**
564
+ * One request for a leased connection: attach its header, send it under the
565
+ * SSRF rule, and stream the answer back. The request is held on its task: the
566
+ * gate closing, a cleanup, or the task hanging up ends it wherever it is,
567
+ * connecting, waiting for headers or streaming. An OAuth token past its
568
+ * expiry is refreshed first and never sent; a 401 is retried once, only with
569
+ * a newer token for the very connection the request began with.
570
+ */
571
+ async function serveLeasedConnection(
572
+ req: IncomingMessage,
573
+ res: ServerResponse,
574
+ taskId: string,
575
+ hold: McpRequestHold,
576
+ ): Promise<void> {
577
+ const slug = hold.connection.slug;
578
+ // From the start: a hang-up ends the request before headers too.
579
+ const client = new AbortController();
580
+ res.once("close", () => {
581
+ client.abort();
582
+ hold.end();
583
+ });
584
+ const signal = AbortSignal.any([hold.signal, client.signal]);
585
+ const method = req.method ?? "GET";
586
+ if (!LEASED_METHODS.has(method)) return deny(res, 405, "method not allowed");
587
+ let body: Buffer | null = null;
588
+ if (method === "POST") {
589
+ try {
590
+ body = await readBody(req, signal);
591
+ } catch {
592
+ // Ended before its body arrived: the gate closed, or the task went.
593
+ if (!res.destroyed && !res.headersSent) deny(res, 503, "credentials unavailable");
594
+ return;
595
+ }
596
+ if (body === null) return deny(res, 413, "request body too large");
597
+ }
598
+ const forwarded: Record<string, string> = {};
599
+ for (const name of FORWARD_REQ_HEADERS) {
600
+ const v = req.headers[name];
601
+ if (typeof v === "string") forwarded[name] = v;
602
+ }
603
+
604
+ let connection = hold.current();
605
+ if (!connection) return deny(res, 503, "credentials unavailable");
606
+ if (connection.authKind === "oauth" && connection.expiresAt !== null && connection.expiresAt <= Date.now()) {
607
+ connection = await hold.refresh(connection);
608
+ if (!connection) return deny(res, 503, "credentials unavailable");
609
+ }
610
+
611
+ const attempt = (current: LeasedMcpConnection): Promise<SafeHttpResponse> =>
612
+ safeRequest(
613
+ {
614
+ url: current.url,
615
+ method: method as "GET" | "POST" | "DELETE",
616
+ headers: current.header ? { ...forwarded, [current.header.name]: current.header.value } : forwarded,
617
+ ...(body ? { body } : {}),
618
+ signal,
619
+ },
620
+ undefined,
621
+ leasedSafeHttpDeps,
622
+ );
623
+
624
+ let upstream: SafeHttpResponse;
625
+ try {
626
+ upstream = await attempt(connection);
627
+ if (upstream.status === 401 && connection.authKind === "oauth") {
628
+ upstream.body.destroy();
629
+ const refreshed = await hold.refresh(connection);
630
+ if (!refreshed) return deny(res, 401, `connection "${slug}" needs a new sign-in`);
631
+ connection = refreshed;
632
+ upstream = await attempt(connection);
633
+ }
634
+ } catch (err) {
635
+ if (signal.aborted) return deny(res, 503, "credentials unavailable");
636
+ audit({ taskId, slug, method, error: err instanceof Error ? err.message : String(err) });
637
+ return deny(res, 502, "upstream unreachable");
638
+ }
639
+
640
+ const summary = body ? rpcSummary(body) : {};
641
+ audit({ taskId, slug, method, rpc: summary.method, tool: summary.tool, status: upstream.status });
642
+ const resHeaders: Record<string, string> = {};
643
+ for (const name of FORWARD_RES_HEADERS) {
644
+ const v = upstream.headers[name];
645
+ if (typeof v === "string") resHeaders[name] = v;
646
+ }
647
+ res.writeHead(upstream.status, resHeaders);
648
+ // A stream ends with its task's gate or its client: the signal destroys the
649
+ // upstream body, and the pipeline tears down both ends.
650
+ pipeline(upstream.body, res, (err) => {
651
+ if (err && err.name !== "AbortError" && !signal.aborted) {
652
+ audit({ taskId, slug, stream: "ended", error: err.name });
653
+ }
654
+ });
655
+ }
656
+
657
+ async function handleGatewayRequest(
658
+ req: IncomingMessage,
659
+ res: ServerResponse,
660
+ authenticatedSockets: WeakMap<Socket, TaskForwarderIdentity>,
661
+ claimTaskQuota: (socket: Socket, taskId: string) => boolean,
662
+ ): Promise<void> {
663
+ const pathname = (() => {
664
+ try {
665
+ return new URL(req.url ?? "", "http://uai.invalid").pathname;
666
+ } catch {
667
+ return "";
668
+ }
669
+ })();
670
+ if (pathname === "/env" && req.url === "/env") {
671
+ const socket = req.socket;
672
+ let identity = authenticatedSockets.get(socket) ?? null;
673
+ if (identity === null) {
674
+ const fixedTaskId = sidecarTaskId();
675
+ if (fixedTaskId !== null && loopbackPeer(socket)) {
676
+ identity = currentTaskGatewayRegistration(fixedTaskId);
677
+ }
678
+ }
679
+ await handleEnvRequest(req, res, identity);
680
+ return;
681
+ }
682
+ await handleMcpRequest(req, res, claimTaskQuota);
683
+ }
684
+
685
+ type GatewayTransportServer = NetServer & {
686
+ closeAllConnections?: () => void;
687
+ };
688
+
689
+ function readSocketBytes(
690
+ socket: Socket,
691
+ length: number,
692
+ deadline: number,
693
+ ): Promise<Buffer> {
694
+ return new Promise((resolveRead, rejectRead) => {
695
+ let settled = false;
696
+ const finish = (error: Error | null, bytes?: Buffer): void => {
697
+ if (settled) return;
698
+ settled = true;
699
+ clearTimeout(timer);
700
+ socket.off("readable", onReadable);
701
+ socket.off("error", onError);
702
+ socket.off("end", onEnd);
703
+ socket.off("close", onClose);
704
+ if (error) rejectRead(error);
705
+ else if (bytes) resolveRead(bytes);
706
+ else rejectRead(new Error("task gateway stream ended"));
707
+ };
708
+ const tryRead = (): void => {
709
+ const bytes = socket.read(length) as Buffer | null;
710
+ if (bytes === null) return;
711
+ if (bytes.length !== length) {
712
+ finish(new Error("truncated task gateway frame"));
713
+ return;
714
+ }
715
+ finish(null, bytes);
716
+ };
717
+ const onReadable = (): void => tryRead();
718
+ const onError = (): void => finish(new Error("task gateway socket error"));
719
+ const onEnd = (): void => {
720
+ tryRead();
721
+ if (!settled) finish(new Error("truncated task gateway frame"));
722
+ };
723
+ const onClose = (): void => finish(new Error("task gateway socket closed"));
724
+ const remainingMs = Math.max(1, deadline - Date.now());
725
+ const timer = setTimeout(
726
+ () => finish(new Error("task gateway pre-auth timed out")),
727
+ remainingMs,
728
+ );
729
+ timer.unref();
730
+ socket.on("readable", onReadable);
731
+ socket.once("error", onError);
732
+ socket.once("end", onEnd);
733
+ socket.once("close", onClose);
734
+ tryRead();
735
+ });
736
+ }
737
+
738
+ interface TaskGatewayDemuxOptions {
739
+ readonly httpServer: HttpServer;
740
+ readonly authenticatedSockets: WeakMap<Socket, TaskForwarderIdentity>;
741
+ readonly authenticatedConnections: Map<Socket, TaskForwarderIdentity>;
742
+ readonly preauthTimeoutMs: number;
743
+ readonly maxPendingPreauth: number;
744
+ readonly maxPendingPreauthPerPeer: number;
745
+ readonly maxPendingPreauthPerTask: number;
746
+ readonly maxActiveConnections: number;
747
+ readonly maxDirectStagingConnectionsPerPeer: number;
748
+ readonly maxActiveConnectionsPerTask: number;
749
+ readonly nonce: () => Buffer;
750
+ readonly tlsHandler: () => TaskGatewayTlsHandler | null;
751
+ }
752
+
753
+ interface AuthenticatedTaskForwarder {
754
+ readonly identity: TaskForwarderIdentity;
755
+ /** Release the task-scoped pre-auth reservation exactly once. */
756
+ readonly release: () => void;
757
+ }
758
+
759
+ function createTaskGatewayDemux(options: TaskGatewayDemuxOptions): {
760
+ accept: (socket: Socket) => void;
761
+ claimTaskQuota: (socket: Socket, taskId: string) => boolean;
762
+ } {
763
+ let pendingPreauth = 0;
764
+ const pendingPeerBySocket = new Map<Socket, string>();
765
+ const pendingSocketsByPeer = new Map<string, Set<Socket>>();
766
+ const pendingByTask = new Map<string, number>();
767
+ let activeConnections = 0;
768
+ const activeScopeBySocket = new Map<
769
+ Socket,
770
+ { kind: "peer" | "task"; key: string }
771
+ >();
772
+ const activeSocketsByPeer = new Map<string, Set<Socket>>();
773
+ const activeSocketsByTask = new Map<string, Set<Socket>>();
774
+ const activeUnauthenticatedSockets = new Set<Socket>();
775
+
776
+ const peerKey = (socket: Socket): string => {
777
+ if (loopbackPeer(socket)) return "loopback";
778
+ const address = (socket.remoteAddress ?? "unknown").toLowerCase();
779
+ return address.startsWith("::ffff:") ? address.slice(7) : address;
780
+ };
781
+
782
+ const releasePreauth = (socket: Socket): void => {
783
+ const peer = pendingPeerBySocket.get(socket);
784
+ if (peer === undefined) return;
785
+ pendingPeerBySocket.delete(socket);
786
+ pendingPreauth -= 1;
787
+ const peerSockets = pendingSocketsByPeer.get(peer);
788
+ peerSockets?.delete(socket);
789
+ if (peerSockets?.size === 0) pendingSocketsByPeer.delete(peer);
790
+ };
791
+
792
+ const reservePreauth = (socket: Socket, peer: string): void => {
793
+ pendingPreauth += 1;
794
+ pendingPeerBySocket.set(socket, peer);
795
+ const peerSockets = pendingSocketsByPeer.get(peer) ?? new Set<Socket>();
796
+ peerSockets.add(socket);
797
+ pendingSocketsByPeer.set(peer, peerSockets);
798
+ };
799
+
800
+ const releaseActive = (socket: Socket): void => {
801
+ const scope = activeScopeBySocket.get(socket);
802
+ if (scope === undefined) return;
803
+ activeScopeBySocket.delete(socket);
804
+ activeConnections -= 1;
805
+ const scopedSockets =
806
+ scope.kind === "task"
807
+ ? activeSocketsByTask.get(scope.key)
808
+ : activeSocketsByPeer.get(scope.key);
809
+ scopedSockets?.delete(socket);
810
+ if (scope.kind === "peer") activeUnauthenticatedSockets.delete(socket);
811
+ if (scopedSockets?.size === 0) {
812
+ if (scope.kind === "task") activeSocketsByTask.delete(scope.key);
813
+ else activeSocketsByPeer.delete(scope.key);
814
+ }
815
+ };
816
+
817
+ const evictUnauthenticated = (socket: Socket): void => {
818
+ // Release synchronously: Socket's close event is asynchronous and the
819
+ // replacement must be able to reserve the slot in this turn.
820
+ releaseActive(socket);
821
+ socket.destroy();
822
+ };
823
+
824
+ /** Transfer one fully-demultiplexed socket into a close-scoped quota.
825
+ * Forwarder-authenticated traffic and fixed-sidecar traffic are charged to
826
+ * their proved task. Direct HTTP starts in an evictable peer staging pool;
827
+ * a valid MCP token moves it to the non-evictable task pool below. */
828
+ const reserveActive = (
829
+ socket: Socket,
830
+ scope: { kind: "peer" | "task"; key: string },
831
+ ): boolean => {
832
+ if (activeScopeBySocket.has(socket)) return true;
833
+ const scopedSockets =
834
+ scope.kind === "task"
835
+ ? activeSocketsByTask.get(scope.key)
836
+ : activeSocketsByPeer.get(scope.key);
837
+ const scopedLimit =
838
+ scope.kind === "task"
839
+ ? options.maxActiveConnectionsPerTask
840
+ : options.maxDirectStagingConnectionsPerPeer;
841
+ if ((scopedSockets?.size ?? 0) >= scopedLimit) {
842
+ if (scope.kind === "task") return false;
843
+ const oldest = scopedSockets?.values().next().value as Socket | undefined;
844
+ if (oldest) evictUnauthenticated(oldest);
845
+ }
846
+ if (activeConnections >= options.maxActiveConnections) {
847
+ // Never evict a proved task socket: it may be a long-lived MCP SSE stream
848
+ // or secrets-proxy tunnel. Unproved direct traffic is replaceable, which
849
+ // keeps a handful of peers from filling the global pool permanently.
850
+ const oldest = activeUnauthenticatedSockets.values().next().value as
851
+ | Socket
852
+ | undefined;
853
+ if (!oldest) return false;
854
+ evictUnauthenticated(oldest);
855
+ }
856
+ const currentScopedSockets =
857
+ scope.kind === "task"
858
+ ? activeSocketsByTask.get(scope.key)
859
+ : activeSocketsByPeer.get(scope.key);
860
+ const nextScopedSockets = currentScopedSockets ?? new Set<Socket>();
861
+ nextScopedSockets.add(socket);
862
+ if (scope.kind === "task") {
863
+ activeSocketsByTask.set(scope.key, nextScopedSockets);
864
+ } else {
865
+ activeSocketsByPeer.set(scope.key, nextScopedSockets);
866
+ activeUnauthenticatedSockets.add(socket);
867
+ }
868
+ activeScopeBySocket.set(socket, scope);
869
+ activeConnections += 1;
870
+ socket.once("close", () => releaseActive(socket));
871
+ return true;
872
+ };
873
+
874
+ const claimTaskQuota = (socket: Socket, taskId: string): boolean => {
875
+ const current = activeScopeBySocket.get(socket);
876
+ if (current === undefined) return false;
877
+ if (current.kind === "task") return current.key === taskId;
878
+
879
+ const taskSockets = activeSocketsByTask.get(taskId);
880
+ if (
881
+ (taskSockets?.size ?? 0) >= options.maxActiveConnectionsPerTask
882
+ ) {
883
+ return false;
884
+ }
885
+
886
+ const peerSockets = activeSocketsByPeer.get(current.key);
887
+ peerSockets?.delete(socket);
888
+ if (peerSockets?.size === 0) activeSocketsByPeer.delete(current.key);
889
+ activeUnauthenticatedSockets.delete(socket);
890
+
891
+ const nextTaskSockets = taskSockets ?? new Set<Socket>();
892
+ nextTaskSockets.add(socket);
893
+ activeSocketsByTask.set(taskId, nextTaskSockets);
894
+ activeScopeBySocket.set(socket, { kind: "task", key: taskId });
895
+ return true;
896
+ };
897
+
898
+ const rememberAuthenticated = (
899
+ socket: Socket,
900
+ identity: TaskForwarderIdentity,
901
+ ): void => {
902
+ if (options.authenticatedConnections.has(socket)) return;
903
+ options.authenticatedSockets.set(socket, identity);
904
+ options.authenticatedConnections.set(socket, identity);
905
+ socket.once("close", () => {
906
+ options.authenticatedConnections.delete(socket);
907
+ });
908
+ };
909
+
910
+ const dispatchHttp = (
911
+ socket: Socket,
912
+ firstByte: Buffer,
913
+ identity: TaskForwarderIdentity | null,
914
+ activeScope: { kind: "peer" | "task"; key: string },
915
+ ): void => {
916
+ if (!reserveActive(socket, activeScope)) {
917
+ socket.destroy();
918
+ return;
919
+ }
920
+ if (identity) rememberAuthenticated(socket, identity);
921
+ socket.unshift(firstByte);
922
+ options.httpServer.emit("connection", socket);
923
+ };
924
+
925
+ const dispatchTls = (
926
+ socket: Socket,
927
+ firstByte: Buffer,
928
+ identity: TaskForwarderIdentity,
929
+ ): void => {
930
+ const handler = options.tlsHandler();
931
+ if (
932
+ !handler ||
933
+ !isTaskGatewayGenerationCurrent(identity) ||
934
+ !reserveActive(socket, { kind: "task", key: identity.taskId })
935
+ ) {
936
+ socket.destroy();
937
+ return;
938
+ }
939
+ rememberAuthenticated(socket, identity);
940
+ socket.unshift(firstByte);
941
+ try {
942
+ handler({
943
+ ...identity,
944
+ socket,
945
+ isCurrent: () => isTaskGatewayGenerationCurrent(identity),
946
+ });
947
+ } catch {
948
+ socket.destroy();
949
+ }
950
+ };
951
+
952
+ const authenticate = async (
953
+ socket: Socket,
954
+ firstByte: Buffer,
955
+ deadline: number,
956
+ ): Promise<AuthenticatedTaskForwarder | null> => {
957
+ const headerTail = await readSocketBytes(
958
+ socket,
959
+ TASK_FORWARDER_PREAMBLE_HEADER_BYTES - 1,
960
+ deadline,
961
+ );
962
+ const header = Buffer.concat([firstByte, headerTail]);
963
+ const preambleLength = taskForwarderPreambleLength(header);
964
+ if (
965
+ preambleLength === null ||
966
+ preambleLength > TASK_FORWARDER_MAX_PREAMBLE_BYTES
967
+ ) {
968
+ return null;
969
+ }
970
+ const payload = await readSocketBytes(
971
+ socket,
972
+ preambleLength - header.length,
973
+ deadline,
974
+ );
975
+ const identity = decodeTaskForwarderPreamble(
976
+ Buffer.concat([header, payload], preambleLength),
977
+ );
978
+ if (identity === null || !isSafeHostTaskId(identity.taskId)) return null;
979
+
980
+ const nonce = options.nonce();
981
+ socket.write(encodeTaskForwarderChallenge(nonce));
982
+ const response = await readSocketBytes(
983
+ socket,
984
+ TASK_FORWARDER_RESPONSE_BYTES,
985
+ deadline,
986
+ );
987
+ const mac = decodeTaskForwarderResponse(response);
988
+ if (
989
+ mac === null ||
990
+ !verifyTaskGatewayAuthentication(identity, nonce, mac)
991
+ ) {
992
+ return null;
993
+ }
994
+
995
+ // A claimed task id is untrusted until its MAC verifies. Charging its
996
+ // reservation earlier lets a peer lock out a real task with only the
997
+ // public task id.
998
+ const taskPending = pendingByTask.get(identity.taskId) ?? 0;
999
+ if (taskPending >= options.maxPendingPreauthPerTask) return null;
1000
+ pendingByTask.set(identity.taskId, taskPending + 1);
1001
+ let released = false;
1002
+ const release = (): void => {
1003
+ if (released) return;
1004
+ released = true;
1005
+ const next = (pendingByTask.get(identity.taskId) ?? 1) - 1;
1006
+ if (next > 0) pendingByTask.set(identity.taskId, next);
1007
+ else pendingByTask.delete(identity.taskId);
1008
+ };
1009
+ if (!reserveActive(socket, { kind: "task", key: identity.taskId })) {
1010
+ release();
1011
+ return null;
1012
+ }
1013
+ let retained = false;
1014
+ try {
1015
+ socket.write(TASK_FORWARDER_ACCEPT_MAGIC);
1016
+ retained = true;
1017
+ return { identity, release };
1018
+ } finally {
1019
+ // A synchronous write failure must not permanently consume this task's
1020
+ // post-MAC quotas. Once returned, the caller owns the pending release;
1021
+ // the active reservation remains close-scoped.
1022
+ if (!retained) {
1023
+ release();
1024
+ releaseActive(socket);
1025
+ }
1026
+ }
1027
+ };
1028
+
1029
+ return {
1030
+ claimTaskQuota,
1031
+ accept: (socket) => {
1032
+ socket.pause();
1033
+ socket.setNoDelay(true);
1034
+ const peer = peerKey(socket);
1035
+ const peerSockets = pendingSocketsByPeer.get(peer);
1036
+ if (
1037
+ peerSockets &&
1038
+ peerSockets.size >= options.maxPendingPreauthPerPeer
1039
+ ) {
1040
+ // Prefer the newest connection instead of letting a peer reserve its
1041
+ // own entire quota with idle sockets. A real hook/forwarder sends its
1042
+ // first frame immediately, so replacing the oldest pending socket
1043
+ // preserves progress even when several task runtimes share one NATed
1044
+ // source address (notably desktop container VMs).
1045
+ const oldest = peerSockets.values().next().value as Socket | undefined;
1046
+ if (oldest) {
1047
+ releasePreauth(oldest);
1048
+ oldest.destroy();
1049
+ }
1050
+ }
1051
+ if (pendingPreauth >= options.maxPendingPreauth) {
1052
+ // Pending sockets carry no authority and have a short deadline. Drop
1053
+ // the oldest globally so several peers cannot permanently consume all
1054
+ // admission slots before a real forwarder gets a turn.
1055
+ const oldest = pendingPeerBySocket.keys().next().value as
1056
+ | Socket
1057
+ | undefined;
1058
+ if (oldest) {
1059
+ releasePreauth(oldest);
1060
+ oldest.destroy();
1061
+ }
1062
+ }
1063
+ reservePreauth(socket, peer);
1064
+ const deadline = Date.now() + options.preauthTimeoutMs;
1065
+ void (async () => {
1066
+ try {
1067
+ const firstByte = await readSocketBytes(socket, 1, deadline);
1068
+ if (firstByte[0] === 0x00) {
1069
+ const authenticated = await authenticate(socket, firstByte, deadline);
1070
+ if (authenticated === null) {
1071
+ socket.destroy();
1072
+ return;
1073
+ }
1074
+ const { identity } = authenticated;
1075
+ try {
1076
+ rememberAuthenticated(socket, identity);
1077
+ if (!isTaskGatewayGenerationCurrent(identity)) {
1078
+ socket.destroy();
1079
+ return;
1080
+ }
1081
+ // The forwarder does not relay task bytes until it receives the
1082
+ // accept frame, so this byte cannot be unauthenticated pipelining.
1083
+ const applicationByte = await readSocketBytes(socket, 1, deadline);
1084
+ if (applicationByte[0] === 0x16) {
1085
+ dispatchTls(socket, applicationByte, identity);
1086
+ } else {
1087
+ dispatchHttp(socket, applicationByte, identity, {
1088
+ kind: "task",
1089
+ key: identity.taskId,
1090
+ });
1091
+ }
1092
+ } finally {
1093
+ authenticated.release();
1094
+ }
1095
+ return;
1096
+ }
1097
+
1098
+ const fixedTaskId = sidecarTaskId();
1099
+ const sidecarIdentity =
1100
+ fixedTaskId !== null && loopbackPeer(socket)
1101
+ ? currentTaskGatewayRegistration(fixedTaskId)
1102
+ : null;
1103
+ if (firstByte[0] === 0x16) {
1104
+ // Direct TLS is valid only for the fixed one-task sidecar. Docker
1105
+ // and Apple must prove identity through the forwarder first.
1106
+ if (sidecarIdentity) dispatchTls(socket, firstByte, sidecarIdentity);
1107
+ else socket.destroy();
1108
+ } else {
1109
+ dispatchHttp(
1110
+ socket,
1111
+ firstByte,
1112
+ sidecarIdentity,
1113
+ sidecarIdentity
1114
+ ? { kind: "task", key: sidecarIdentity.taskId }
1115
+ : { kind: "peer", key: peer },
1116
+ );
1117
+ }
1118
+ } catch {
1119
+ socket.destroy();
1120
+ } finally {
1121
+ releasePreauth(socket);
1122
+ }
1123
+ })();
1124
+ },
1125
+ };
1126
+ }
1127
+
1128
+ export interface McpGatewayController {
1129
+ /** Current bounded capability snapshot. Never contains a raw listener error. */
1130
+ state(): McpGatewayCapability;
1131
+ /** Resolve only after this listener has positively bound its configured port. */
1132
+ waitUntilReady(timeoutMs?: number): Promise<void>;
1133
+ /** Subscribe to semantic state changes. The current snapshot is read via state(). */
1134
+ subscribe(
1135
+ listener: (state: McpGatewayCapability) => void,
1136
+ ): () => void;
1137
+ /** Permanently stop this controller and release its listener/timer. */
1138
+ stop(): Promise<void>;
1139
+ }
1140
+
1141
+ export interface McpGatewayListener extends McpGatewayController {
1142
+ /** Start once; repeated calls before stop are idempotent. */
1143
+ start(): McpGatewayController;
1144
+ }
1145
+
1146
+ export interface McpGatewayListenerOptions {
1147
+ port?: number;
1148
+ bind?: string;
1149
+ retryInitialDelayMs?: number;
1150
+ retryMaxDelayMs?: number;
1151
+ serverFactory?: (
1152
+ connectionListener: (socket: Socket) => void,
1153
+ ) => GatewayTransportServer;
1154
+ httpServerFactory?: (
1155
+ requestListener: (req: IncomingMessage, res: ServerResponse) => void,
1156
+ ) => HttpServer;
1157
+ tlsHandler?: TaskGatewayTlsHandler;
1158
+ preauthTimeoutMs?: number;
1159
+ maxPendingPreauth?: number;
1160
+ maxPendingPreauthPerPeer?: number;
1161
+ maxPendingPreauthPerTask?: number;
1162
+ maxActiveConnections?: number;
1163
+ maxDirectStagingConnectionsPerPeer?: number;
1164
+ maxActiveConnectionsPerTask?: number;
1165
+ nonce?: () => Buffer;
1166
+ setRetryTimeout?: (
1167
+ callback: () => void,
1168
+ delayMs: number,
1169
+ ) => NodeJS.Timeout;
1170
+ clearRetryTimeout?: (timer: NodeJS.Timeout) => void;
1171
+ random?: () => number;
1172
+ logger?: Pick<Console, "log" | "warn">;
1173
+ }
1174
+
1175
+ function listenerFailureReason(
1176
+ error: unknown,
1177
+ ): "port_in_use" | "bind_failed" {
1178
+ return (error as NodeJS.ErrnoException | null)?.code === "EADDRINUSE"
1179
+ ? "port_in_use"
1180
+ : "bind_failed";
1181
+ }
1182
+
1183
+ function positiveInteger(value: number | undefined, fallback: number): number {
1184
+ return typeof value === "number" && Number.isFinite(value) && value >= 1
1185
+ ? Math.floor(value)
1186
+ : fallback;
1187
+ }
1188
+
1189
+ function sameGatewayState(
1190
+ left: McpGatewayCapability,
1191
+ right: McpGatewayCapability,
1192
+ ): boolean {
1193
+ return (
1194
+ left.status === right.status &&
1195
+ left.port === right.port &&
1196
+ (left.status !== "down" ||
1197
+ (right.status === "down" && left.reason === right.reason))
1198
+ );
1199
+ }
1200
+
1201
+ /**
1202
+ * Construct one restart-safe MCP gateway listener. Exported so listener
1203
+ * lifecycle tests can use deterministic fake servers without opening a real
1204
+ * host port; production uses the singleton wrapper below.
1205
+ */
1206
+ export function createMcpGatewayListener(
1207
+ options: McpGatewayListenerOptions = {},
1208
+ ): McpGatewayListener {
1209
+ const port =
1210
+ Number.isSafeInteger(options.port) &&
1211
+ (options.port ?? 0) >= 1 &&
1212
+ (options.port ?? 0) <= 65_535
1213
+ ? (options.port as number)
1214
+ : MCP_GATEWAY_PORT;
1215
+ const bind = options.bind ?? gatewayBindAddress();
1216
+ const retryInitialDelayMs = positiveInteger(
1217
+ options.retryInitialDelayMs,
1218
+ GATEWAY_RETRY_INITIAL_DELAY_MS,
1219
+ );
1220
+ const retryMaxDelayMs = Math.max(
1221
+ retryInitialDelayMs,
1222
+ positiveInteger(
1223
+ options.retryMaxDelayMs,
1224
+ GATEWAY_RETRY_MAX_DELAY_MS,
1225
+ ),
1226
+ );
1227
+ const serverFactory =
1228
+ options.serverFactory ??
1229
+ ((connectionListener: (socket: Socket) => void) =>
1230
+ createNetServer({ pauseOnConnect: true }, connectionListener));
1231
+ const httpServerFactory = options.httpServerFactory ?? createHttpServer;
1232
+ const preauthTimeoutMs = positiveInteger(
1233
+ options.preauthTimeoutMs,
1234
+ TASK_GATEWAY_PREAUTH_TIMEOUT_MS,
1235
+ );
1236
+ const maxPendingPreauth = positiveInteger(
1237
+ options.maxPendingPreauth,
1238
+ TASK_GATEWAY_MAX_PENDING_PREAUTH,
1239
+ );
1240
+ const maxPendingPreauthPerPeer = positiveInteger(
1241
+ options.maxPendingPreauthPerPeer,
1242
+ TASK_GATEWAY_MAX_PENDING_PREAUTH_PER_PEER,
1243
+ );
1244
+ const maxPendingPreauthPerTask = positiveInteger(
1245
+ options.maxPendingPreauthPerTask,
1246
+ TASK_GATEWAY_MAX_PENDING_PREAUTH_PER_TASK,
1247
+ );
1248
+ const maxActiveConnections = positiveInteger(
1249
+ options.maxActiveConnections,
1250
+ TASK_GATEWAY_MAX_ACTIVE_CONNECTIONS,
1251
+ );
1252
+ const maxDirectStagingConnectionsPerPeer = positiveInteger(
1253
+ options.maxDirectStagingConnectionsPerPeer,
1254
+ TASK_GATEWAY_MAX_DIRECT_STAGING_CONNECTIONS_PER_PEER,
1255
+ );
1256
+ const maxActiveConnectionsPerTask = positiveInteger(
1257
+ options.maxActiveConnectionsPerTask,
1258
+ TASK_GATEWAY_MAX_ACTIVE_CONNECTIONS_PER_TASK,
1259
+ );
1260
+ const nonce = options.nonce ?? (() => randomBytes(32));
1261
+ const setRetryTimeout =
1262
+ options.setRetryTimeout ??
1263
+ ((callback: () => void, delayMs: number) =>
1264
+ setTimeout(callback, delayMs));
1265
+ const clearRetryTimeout = options.clearRetryTimeout ?? clearTimeout;
1266
+ const random = options.random ?? Math.random;
1267
+ const logger = options.logger ?? console;
1268
+ const subscribers = new Set<
1269
+ (state: McpGatewayCapability) => void
1270
+ >();
1271
+ const authenticatedSockets = new WeakMap<Socket, TaskForwarderIdentity>();
1272
+ const authenticatedConnections = new Map<Socket, TaskForwarderIdentity>();
1273
+ let claimTaskQuota = (_socket: Socket, _taskId: string): boolean => false;
1274
+ const httpServer = httpServerFactory((req, res) => {
1275
+ void handleGatewayRequest(
1276
+ req,
1277
+ res,
1278
+ authenticatedSockets,
1279
+ claimTaskQuota,
1280
+ ).catch(() => deny(res, 500, "gateway error"));
1281
+ });
1282
+ httpServer.on("clientError", (_error, socket) => socket.destroy());
1283
+ // This HTTP server never owns the TCP listener: authenticated sockets are
1284
+ // demultiplexed into it. Node starts its headers/request timeout checker on
1285
+ // the `listening` event, so emit that lifecycle event for this hidden
1286
+ // server too or a peer can drip one byte forever after pre-authentication.
1287
+ httpServer.emit("listening");
1288
+ const demux = createTaskGatewayDemux({
1289
+ httpServer,
1290
+ authenticatedSockets,
1291
+ authenticatedConnections,
1292
+ preauthTimeoutMs,
1293
+ maxPendingPreauth,
1294
+ maxPendingPreauthPerPeer,
1295
+ maxPendingPreauthPerTask,
1296
+ maxActiveConnections,
1297
+ maxDirectStagingConnectionsPerPeer,
1298
+ maxActiveConnectionsPerTask,
1299
+ nonce,
1300
+ tlsHandler: () => options.tlsHandler ?? taskGatewayTlsHandler,
1301
+ });
1302
+ claimTaskQuota = demux.claimTaskQuota;
1303
+ const socketsByServer = new Map<GatewayTransportServer, Set<Socket>>();
1304
+ const unsubscribeRevocations = subscribeTaskGatewayRevocations((revoked) => {
1305
+ for (const [socket, identity] of authenticatedConnections) {
1306
+ if (
1307
+ identity.taskId === revoked.taskId &&
1308
+ identity.generation === revoked.generation
1309
+ ) {
1310
+ socket.destroy();
1311
+ }
1312
+ }
1313
+ });
1314
+
1315
+ let capability: McpGatewayCapability = Object.freeze({
1316
+ status: "starting",
1317
+ port,
1318
+ });
1319
+ let server: GatewayTransportServer | null = null;
1320
+ let retryTimer: NodeJS.Timeout | null = null;
1321
+ let consecutiveFailures = 0;
1322
+ let started = false;
1323
+ let stopped = false;
1324
+ let stopPromise: Promise<void> | null = null;
1325
+
1326
+ const setState = (next: McpGatewayCapability): void => {
1327
+ const frozen = Object.freeze(next);
1328
+ if (sameGatewayState(capability, frozen)) return;
1329
+ capability = frozen;
1330
+ for (const subscriber of subscribers) {
1331
+ try {
1332
+ subscriber(capability);
1333
+ } catch {
1334
+ // Capability observers must never take down the host listener.
1335
+ }
1336
+ }
1337
+ };
1338
+
1339
+ const closeServer = (target: GatewayTransportServer): void => {
1340
+ const sockets = socketsByServer.get(target);
1341
+ socketsByServer.delete(target);
1342
+ if (sockets) {
1343
+ for (const socket of sockets) socket.destroy();
1344
+ sockets.clear();
1345
+ }
1346
+ try {
1347
+ target.close(() => undefined);
1348
+ target.closeAllConnections?.();
1349
+ } catch {
1350
+ // A listener that failed before binding has nothing left to close.
1351
+ }
1352
+ };
1353
+
1354
+ const retryDelay = (): number => {
1355
+ const exponent = Math.min(Math.max(consecutiveFailures - 1, 0), 30);
1356
+ const baseDelayMs = Math.min(
1357
+ retryMaxDelayMs,
1358
+ retryInitialDelayMs * 2 ** exponent,
1359
+ );
1360
+ const sample = random();
1361
+ const boundedSample = Number.isFinite(sample)
1362
+ ? Math.min(1, Math.max(0, sample))
1363
+ : 0.5;
1364
+ return Math.min(
1365
+ retryMaxDelayMs,
1366
+ Math.max(
1367
+ 1,
1368
+ Math.round(
1369
+ baseDelayMs *
1370
+ (GATEWAY_RETRY_MIN_JITTER +
1371
+ GATEWAY_RETRY_JITTER_SPAN * boundedSample),
1372
+ ),
1373
+ ),
1374
+ );
1375
+ };
1376
+
1377
+ let beginAttempt: () => void;
1378
+
1379
+ const scheduleRetry = (
1380
+ reason: "port_in_use" | "bind_failed",
1381
+ ): void => {
1382
+ if (stopped || retryTimer !== null) return;
1383
+ consecutiveFailures += 1;
1384
+ setState({ status: "down", port, reason });
1385
+ const delayMs = retryDelay();
1386
+ logger.warn(
1387
+ `[mcp-gateway] listener down (${reason}); retrying in ${delayMs}ms`,
1388
+ );
1389
+ retryTimer = setRetryTimeout(() => {
1390
+ retryTimer = null;
1391
+ beginAttempt();
1392
+ }, delayMs);
1393
+ retryTimer.unref();
1394
+ };
1395
+
1396
+ const listenerFailed = (
1397
+ target: GatewayTransportServer,
1398
+ error: unknown,
1399
+ ): void => {
1400
+ if (stopped || server !== target) return;
1401
+ server = null;
1402
+ closeServer(target);
1403
+ scheduleRetry(listenerFailureReason(error));
1404
+ };
1405
+
1406
+ beginAttempt = (): void => {
1407
+ if (stopped || server !== null || retryTimer !== null) return;
1408
+
1409
+ let nextServer: GatewayTransportServer;
1410
+ const attemptSockets = new Set<Socket>();
1411
+ try {
1412
+ nextServer = serverFactory((socket) => {
1413
+ attemptSockets.add(socket);
1414
+ socket.once("close", () => attemptSockets.delete(socket));
1415
+ demux.accept(socket);
1416
+ });
1417
+ } catch {
1418
+ scheduleRetry("bind_failed");
1419
+ return;
1420
+ }
1421
+ socketsByServer.set(nextServer, attemptSockets);
1422
+ server = nextServer;
1423
+
1424
+ nextServer.on("error", (error) => {
1425
+ listenerFailed(nextServer, error);
1426
+ });
1427
+ nextServer.on("listening", () => {
1428
+ if (stopped || server !== nextServer) {
1429
+ closeServer(nextServer);
1430
+ return;
1431
+ }
1432
+ consecutiveFailures = 0;
1433
+ setState({ status: "ready", port });
1434
+ logger.log(`[mcp-gateway] listening on ${bind}:${port}`);
1435
+ });
1436
+ nextServer.on("close", () => {
1437
+ socketsByServer.delete(nextServer);
1438
+ if (stopped || server !== nextServer) return;
1439
+ server = null;
1440
+ scheduleRetry("bind_failed");
1441
+ });
1442
+
1443
+ try {
1444
+ nextServer.listen(port, bind);
1445
+ } catch (error) {
1446
+ listenerFailed(nextServer, error);
1447
+ }
1448
+ };
1449
+
1450
+ const controller: McpGatewayListener = {
1451
+ state: () => capability,
1452
+ waitUntilReady: (timeoutMs = 5_000) => {
1453
+ if (capability.status === "ready") return Promise.resolve();
1454
+ if (capability.status === "down") {
1455
+ return Promise.reject(
1456
+ new Error(`MCP gateway listener is down (${capability.reason})`),
1457
+ );
1458
+ }
1459
+ if (stopped) {
1460
+ return Promise.reject(new Error("MCP gateway listener is stopped"));
1461
+ }
1462
+ const boundedTimeoutMs =
1463
+ Number.isFinite(timeoutMs) && timeoutMs >= 1
1464
+ ? Math.floor(timeoutMs)
1465
+ : 5_000;
1466
+ return new Promise<void>((resolveReady, rejectReady) => {
1467
+ let settled = false;
1468
+ let unsubscribe = (): void => undefined;
1469
+ const finish = (error?: Error): void => {
1470
+ if (settled) return;
1471
+ settled = true;
1472
+ clearTimeout(timer);
1473
+ unsubscribe();
1474
+ if (error) rejectReady(error);
1475
+ else resolveReady();
1476
+ };
1477
+ const timer = setTimeout(
1478
+ () =>
1479
+ finish(
1480
+ new Error(
1481
+ `MCP gateway listener did not become ready within ${boundedTimeoutMs}ms`,
1482
+ ),
1483
+ ),
1484
+ boundedTimeoutMs,
1485
+ );
1486
+ timer.unref();
1487
+ const observe = (state: McpGatewayCapability): void => {
1488
+ if (state.status === "ready") finish();
1489
+ else if (state.status === "down") {
1490
+ finish(new Error(`MCP gateway listener is down (${state.reason})`));
1491
+ }
1492
+ };
1493
+ unsubscribe = controller.subscribe(observe);
1494
+ // Close the state-change race between the initial check and subscribe.
1495
+ observe(capability);
1496
+ });
1497
+ },
1498
+ subscribe: (listener) => {
1499
+ subscribers.add(listener);
1500
+ return () => subscribers.delete(listener);
1501
+ },
1502
+ start: () => {
1503
+ if (stopped) {
1504
+ throw new Error("MCP gateway listener cannot restart after stop");
1505
+ }
1506
+ if (!started) {
1507
+ started = true;
1508
+ beginAttempt();
1509
+ }
1510
+ return controller;
1511
+ },
1512
+ stop: () => {
1513
+ if (stopPromise) return stopPromise;
1514
+ stopped = true;
1515
+ if (retryTimer !== null) {
1516
+ clearRetryTimeout(retryTimer);
1517
+ retryTimer = null;
1518
+ }
1519
+ const activeServer = server;
1520
+ server = null;
1521
+ stopPromise = new Promise<void>((resolveStop) => {
1522
+ if (activeServer === null) {
1523
+ resolveStop();
1524
+ return;
1525
+ }
1526
+ let resolved = false;
1527
+ const finish = (): void => {
1528
+ if (resolved) return;
1529
+ resolved = true;
1530
+ resolveStop();
1531
+ };
1532
+ activeServer.once("close", finish);
1533
+ try {
1534
+ const sockets = socketsByServer.get(activeServer);
1535
+ socketsByServer.delete(activeServer);
1536
+ if (sockets) {
1537
+ for (const socket of sockets) socket.destroy();
1538
+ }
1539
+ activeServer.close(finish);
1540
+ activeServer.closeAllConnections?.();
1541
+ if (!activeServer.listening) queueMicrotask(finish);
1542
+ } catch {
1543
+ finish();
1544
+ }
1545
+ }).finally(() => {
1546
+ unsubscribeRevocations();
1547
+ for (const socket of authenticatedConnections.keys()) socket.destroy();
1548
+ authenticatedConnections.clear();
1549
+ httpServer.closeAllConnections();
1550
+ // Clears the hidden server's timeout-check interval. It has no own
1551
+ // listening handle, so close is otherwise a no-op.
1552
+ httpServer.close();
1553
+ subscribers.clear();
1554
+ });
1555
+ return stopPromise;
1556
+ },
1557
+ };
1558
+ return controller;
1559
+ }
1560
+
1561
+ let gatewayController: McpGatewayListener | null = null;
1562
+
1563
+ /** Start (or return) the process-wide gateway listener. */
1564
+ export function startMcpGateway(): McpGatewayController {
1565
+ gatewayController ??= createMcpGatewayListener();
1566
+ return gatewayController.start();
1567
+ }
1568
+
1569
+ /** Task admission gate: no runtime starts until the shared listener is bound. */
1570
+ export function waitForMcpGatewayReady(timeoutMs = 5_000): Promise<void> {
1571
+ return startMcpGateway().waitUntilReady(timeoutMs);
1572
+ }
1573
+
1574
+ /** Stop and reset the process-wide listener (also the deterministic test seam). */
1575
+ export async function stopMcpGateway(): Promise<void> {
1576
+ const active = gatewayController;
1577
+ if (active === null) return;
1578
+ await active.stop();
1579
+ if (gatewayController === active) gatewayController = null;
1580
+ }
1581
+
1582
+ // --- task container wiring (ADR-057 task-up writers) -------------------------
1583
+
1584
+ /** Reconcile JSON MCP definitions atomically and with compare-before-rename.
1585
+ * Only exact Uai gateway shapes whose URL names this task are owned: stale
1586
+ * ones are removed, while browser and foreign/user entries are untouched. */
1587
+ export const MERGE_MCP_JSON = `
1588
+ const fs = require("fs");
1589
+ const path = require("path");
1590
+ const crypto = require("crypto");
1591
+ const p = process.argv[1];
1592
+ const key = process.argv[3] || "mcpServers";
1593
+ const flavor = process.argv[4] || "claude";
1594
+ const taskId = process.argv[5];
1595
+ const plainObject = (value) =>
1596
+ value !== null && typeof value === "object" && !Array.isArray(value);
1597
+ if (!["claude", "cursor", "opencode"].includes(flavor)) {
1598
+ throw new Error("invalid MCP config flavor");
1599
+ }
1600
+ if (typeof taskId !== "string" || taskId.length === 0) {
1601
+ throw new Error("invalid MCP task id");
1602
+ }
1603
+ const desired = JSON.parse(process.argv[2]);
1604
+ if (!plainObject(desired)) {
1605
+ throw new Error("desired MCP definitions must be an object");
1606
+ }
1607
+ const exactKeys = (value, keys) => plainObject(value) &&
1608
+ Object.keys(value).sort().join("\\0") === keys.slice().sort().join("\\0");
1609
+ const gatewayPort = ${JSON.stringify(String(MCP_GATEWAY_PORT))};
1610
+ const managedUrl = (value) => {
1611
+ if (flavor === "claude" && exactKeys(value, ["type", "url"]) &&
1612
+ value.type === "http" && typeof value.url === "string") return value.url;
1613
+ if (flavor === "cursor" && exactKeys(value, ["url"]) &&
1614
+ typeof value.url === "string") return value.url;
1615
+ if (flavor === "opencode" &&
1616
+ exactKeys(value, ["enabled", "oauth", "type", "url"]) &&
1617
+ value.type === "remote" && value.enabled === true && value.oauth === false &&
1618
+ typeof value.url === "string") return value.url;
1619
+ return null;
1620
+ };
1621
+ const isTaskGatewayUrl = (value, slug) => {
1622
+ let parsed;
1623
+ try { parsed = new URL(value); } catch { return false; }
1624
+ if (parsed.protocol !== "http:" || (parsed.port || "80") !== gatewayPort ||
1625
+ parsed.username || parsed.password || parsed.search || parsed.hash) return false;
1626
+ const segments = parsed.pathname.split("/").filter(Boolean);
1627
+ if (segments.length !== 3 || segments[0] !== "t" || segments[2] !== slug) return false;
1628
+ const prefix = taskId + ".";
1629
+ return value === "http://" + parsed.hostname + ":" + gatewayPort +
1630
+ "/t/" + segments[1] + "/" + slug &&
1631
+ segments[1].startsWith(prefix) &&
1632
+ /^[A-Za-z0-9_-]{32}$/.test(segments[1].slice(prefix.length));
1633
+ };
1634
+ const isManaged = (value, slug) => {
1635
+ const url = managedUrl(value);
1636
+ return url !== null && isTaskGatewayUrl(url, slug);
1637
+ };
1638
+ for (const [slug, value] of Object.entries(desired)) {
1639
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(slug) || !isManaged(value, slug)) {
1640
+ throw new Error("invalid desired MCP definition for " + slug);
1641
+ }
1642
+ }
1643
+ let original = null;
1644
+ let j = {};
1645
+ try {
1646
+ original = fs.readFileSync(p, "utf8");
1647
+ j = JSON.parse(original);
1648
+ } catch (error) {
1649
+ if (!error || error.code !== "ENOENT") throw error;
1650
+ }
1651
+ if (!plainObject(j)) throw new Error("MCP config root must be an object");
1652
+ if (j[key] === undefined) {
1653
+ if (Object.keys(desired).length === 0 && flavor !== "claude") process.exit(0);
1654
+ j[key] = {};
1655
+ }
1656
+ if (!plainObject(j[key])) {
1657
+ throw new Error("MCP config " + key + " must be an object");
1658
+ }
1659
+ let changed = false;
1660
+ // A foreign same-name entry is a local shadow, not a failed reconciliation:
1661
+ // preserve it, apply every other slug, and memoize success. If the user later
1662
+ // removes it, the next dirty pass or task restart installs the Uai definition.
1663
+ const skipped = new Set();
1664
+ const warnings = [];
1665
+ for (const [slug, current] of Object.entries(j[key])) {
1666
+ if (Object.hasOwn(desired, slug)) {
1667
+ if (JSON.stringify(current) === JSON.stringify(desired[slug])) continue;
1668
+ if (!isManaged(current, slug)) {
1669
+ skipped.add(slug);
1670
+ continue;
1671
+ }
1672
+ j[key][slug] = desired[slug];
1673
+ changed = true;
1674
+ } else if (isManaged(current, slug)) {
1675
+ delete j[key][slug];
1676
+ changed = true;
1677
+ }
1678
+ }
1679
+ for (const [slug, value] of Object.entries(desired)) {
1680
+ if (!Object.hasOwn(j[key], slug)) {
1681
+ j[key][slug] = value;
1682
+ changed = true;
1683
+ }
1684
+ }
1685
+ for (const slug of skipped) {
1686
+ warnings.push(
1687
+ "[uai-mcp] kept foreign server " + slug +
1688
+ "; skipped its Uai definition in " + p + "\\n"
1689
+ );
1690
+ }
1691
+ if (changed || original === null) {
1692
+ fs.mkdirSync(path.dirname(p), { recursive: true });
1693
+ const mode = original === null ? 0o644 : fs.statSync(p).mode & 0o777;
1694
+ const tmp = path.join(
1695
+ path.dirname(p),
1696
+ "." + path.basename(p) + "." + process.pid + "." +
1697
+ crypto.randomBytes(8).toString("hex") + ".tmp"
1698
+ );
1699
+ try {
1700
+ fs.writeFileSync(tmp, JSON.stringify(j, null, 2) + "\\n", {
1701
+ encoding: "utf8",
1702
+ flag: "wx",
1703
+ mode,
1704
+ });
1705
+ let current = null;
1706
+ try {
1707
+ current = fs.readFileSync(p, "utf8");
1708
+ } catch (error) {
1709
+ if (!error || error.code !== "ENOENT") throw error;
1710
+ }
1711
+ if (current !== original) {
1712
+ throw new Error("MCP config changed concurrently");
1713
+ }
1714
+ fs.renameSync(tmp, p);
1715
+ } finally {
1716
+ try { fs.unlinkSync(tmp); } catch {}
1717
+ }
1718
+ }
1719
+ for (const warning of warnings) process.stderr.write(warning);
1720
+ `.trim();
1721
+
1722
+ /**
1723
+ * Ensure the host-gateway MCP definitions in one Codex home.
1724
+ *
1725
+ * Codex itself is the TOML parser. This matters because a text search cannot
1726
+ * distinguish `[mcp_servers.foo]` from a comment/string, and quoted or spaced
1727
+ * spellings name the same TOML table. A same-name definition is accepted only
1728
+ * when Codex reports the exact shape Uai writes. Foreign same-name definitions
1729
+ * shadow only that slug; malformed configs still fail without modification.
1730
+ */
1731
+ export const ENSURE_CODEX_MCP_TOML = String.raw`
1732
+ const fs = require("fs");
1733
+ const path = require("path");
1734
+ const crypto = require("crypto");
1735
+ const child = require("child_process");
1736
+
1737
+ const home = process.argv[1];
1738
+ const desired = JSON.parse(process.argv[2]);
1739
+ const codexBin = process.argv[3] || "codex";
1740
+ const taskId = process.argv[4];
1741
+ const plainObject = (value) =>
1742
+ value !== null && typeof value === "object" && !Array.isArray(value);
1743
+ if (typeof home !== "string" || !path.isAbsolute(home)) {
1744
+ throw new Error("Codex home must be an absolute path");
1745
+ }
1746
+ if (typeof taskId !== "string" || taskId.length === 0) {
1747
+ throw new Error("invalid MCP task id");
1748
+ }
1749
+ if (!plainObject(desired)) {
1750
+ throw new Error("Codex MCP definitions must be an object");
1751
+ }
1752
+ for (const [slug, url] of Object.entries(desired)) {
1753
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(slug)) {
1754
+ throw new Error("invalid Codex MCP slug");
1755
+ }
1756
+ if (typeof url !== "string") {
1757
+ throw new Error("invalid Codex MCP URL");
1758
+ }
1759
+ }
1760
+
1761
+ fs.mkdirSync(home, { recursive: true });
1762
+ // Codex canonicalizes CODEX_HOME before resolving relative config paths. Keep
1763
+ // the same stable target so symlinked homes validate beside the directory
1764
+ // Codex actually uses, not beside the link's lexical parent.
1765
+ const sourceHome = fs.realpathSync(home);
1766
+ const file = path.join(sourceHome, "config.toml");
1767
+ let original = null;
1768
+ try {
1769
+ original = fs.readFileSync(file, "utf8");
1770
+ } catch (error) {
1771
+ if (!error || error.code !== "ENOENT") throw error;
1772
+ }
1773
+
1774
+ function runCodex(args, codexHome = sourceHome) {
1775
+ return child.spawnSync(codexBin, args, {
1776
+ encoding: "utf8",
1777
+ env: Object.assign({}, process.env, { CODEX_HOME: codexHome }),
1778
+ maxBuffer: 1024 * 1024,
1779
+ timeout: 5000,
1780
+ killSignal: "SIGKILL",
1781
+ });
1782
+ }
1783
+
1784
+ function detail(result) {
1785
+ const lines = String(result.stderr || "")
1786
+ .split(/\r?\n/)
1787
+ .map((line) => line.trim())
1788
+ .filter(Boolean);
1789
+ return String(lines.pop() || result.error || result.status);
1790
+ }
1791
+
1792
+ function exactKeys(value, keys) {
1793
+ return plainObject(value) &&
1794
+ Object.keys(value).sort().join("\0") === keys.slice().sort().join("\0");
1795
+ }
1796
+
1797
+ function managedDefinitionUrl(value, slug) {
1798
+ const topKeys = [
1799
+ "auth_status", "disabled_reason", "enabled", "name",
1800
+ "startup_timeout_sec", "tool_timeout_sec", "transport"
1801
+ ];
1802
+ const transportKeys = ["args", "command", "cwd", "env", "env_vars", "type"];
1803
+ if (!(exactKeys(value, topKeys) &&
1804
+ value.name === slug &&
1805
+ value.enabled === true &&
1806
+ value.disabled_reason === null &&
1807
+ value.auth_status === "unsupported" &&
1808
+ value.startup_timeout_sec === null &&
1809
+ value.tool_timeout_sec === null &&
1810
+ exactKeys(value.transport, transportKeys) &&
1811
+ value.transport.type === "stdio" &&
1812
+ value.transport.command === "npx" &&
1813
+ Array.isArray(value.transport.args) &&
1814
+ value.transport.args.length === 4 &&
1815
+ value.transport.args[0] === "-y" &&
1816
+ value.transport.args[1] === "mcp-remote" &&
1817
+ typeof value.transport.args[2] === "string" &&
1818
+ value.transport.args[3] === "--allow-http" &&
1819
+ value.transport.cwd === null &&
1820
+ value.transport.env === null &&
1821
+ Array.isArray(value.transport.env_vars) &&
1822
+ value.transport.env_vars.length === 0)) {
1823
+ return null;
1824
+ }
1825
+ return value.transport.args[2];
1826
+ }
1827
+
1828
+ function exactDefinition(value, slug, url) {
1829
+ return managedDefinitionUrl(value, slug) === url;
1830
+ }
1831
+
1832
+ function gatewayIdentity(value, slug) {
1833
+ let parsed;
1834
+ try {
1835
+ parsed = new URL(value);
1836
+ } catch {
1837
+ return null;
1838
+ }
1839
+ if (parsed.protocol !== "http:" || (parsed.port || "80") !== "${MCP_GATEWAY_PORT}" ||
1840
+ parsed.username || parsed.password || parsed.search || parsed.hash) {
1841
+ return null;
1842
+ }
1843
+ const segments = parsed.pathname.split("/").filter(Boolean);
1844
+ if (segments.length !== 3 || segments[0] !== "t" || segments[2] !== slug ||
1845
+ parsed.pathname !== "/t/" + segments[1] + "/" + slug) {
1846
+ return null;
1847
+ }
1848
+ const token = /^([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]{32})$/.exec(segments[1]);
1849
+ if (!token || value !== "http://" + parsed.hostname + ":${MCP_GATEWAY_PORT}" +
1850
+ "/t/" + segments[1] + "/" + slug) return null;
1851
+ return { origin: parsed.origin, taskId: token[1] };
1852
+ }
1853
+
1854
+ function sameTaskGateway(oldUrl, desiredUrl, slug) {
1855
+ // Ownership is proven by the /t/<taskId>.<token>/<slug> path, not the
1856
+ // origin: the gateway host is topology-dependent (container gateway IP,
1857
+ // host.docker.internal, machine loopback behind a reverse tunnel) and
1858
+ // migrating a task across topologies is exactly when the rewrite runs.
1859
+ const oldIdentity = gatewayIdentity(oldUrl, slug);
1860
+ const desiredIdentity = gatewayIdentity(desiredUrl, slug);
1861
+ return oldIdentity !== null &&
1862
+ desiredIdentity !== null &&
1863
+ oldIdentity.taskId === desiredIdentity.taskId;
1864
+ }
1865
+
1866
+ function escapeRegExp(value) {
1867
+ return value.replace(/[.*+?^$(){}|[\]\\]/g, "\\$&");
1868
+ }
1869
+
1870
+ /**
1871
+ * Replace or remove a raw block Uai can prove it authored.
1872
+ * The exact legacy three-line shape predates the marker. New blocks carry the
1873
+ * ADR-057 marker. A quoted/spaced header, extra field, or neighboring body
1874
+ * text is deliberately foreign even if Codex normalizes it to the same JSON.
1875
+ */
1876
+ function editManagedRawBlock(text, slug, oldUrl, desiredUrl) {
1877
+ const prefix =
1878
+ "(^|\\r?\\n)\\[mcp_servers\\." + escapeRegExp(slug) + "\\]\\r?\\n" +
1879
+ "command = \\\"npx\\\"\\r?\\n";
1880
+ const legacyPattern = new RegExp(
1881
+ prefix +
1882
+ "args = \\[\\\"-y\\\", \\\"mcp-remote\\\", " +
1883
+ escapeRegExp(JSON.stringify(oldUrl)) +
1884
+ ", \\\"--allow-http\\\"\\](?=\\r?\\n|$)",
1885
+ "g"
1886
+ );
1887
+ const markedPattern = new RegExp(
1888
+ "(^|\\r?\\n)# uai ADR-057: host MCP gateway\\r?\\n" +
1889
+ "\\[mcp_servers\\." + escapeRegExp(slug) + "\\]\\r?\\n" +
1890
+ "command = \\\"npx\\\"\\r?\\n" +
1891
+ "args = " +
1892
+ escapeRegExp(JSON.stringify([
1893
+ "-y", "mcp-remote", oldUrl, "--allow-http"
1894
+ ])) +
1895
+ "(?=\\r?\\n|$)",
1896
+ "g"
1897
+ );
1898
+ const matches = [
1899
+ ...Array.from(text.matchAll(legacyPattern)),
1900
+ ...Array.from(text.matchAll(markedPattern)),
1901
+ ].filter((match) => {
1902
+ const after = (match.index || 0) + match[0].length;
1903
+ const remainder = text.slice(after);
1904
+ const nextHeader = remainder.search(
1905
+ /^[ \t]*\[\[?.+\]\]?[ \t]*(?:#.*)?\r?$/m
1906
+ );
1907
+ const gap = nextHeader === -1 ? remainder : remainder.slice(0, nextHeader);
1908
+ return gap.split(/\r?\n/).every((line) => {
1909
+ const trimmed = line.trim();
1910
+ return trimmed === "" || trimmed.startsWith("#");
1911
+ });
1912
+ });
1913
+ if (matches.length !== 1) return null;
1914
+ const match = matches[0];
1915
+ const start = match.index || 0;
1916
+ const replacement = desiredUrl === null
1917
+ ? match[1]
1918
+ : match[0].replace(JSON.stringify(oldUrl), JSON.stringify(desiredUrl));
1919
+ return text.slice(0, start) + replacement + text.slice(start + match[0].length);
1920
+ }
1921
+
1922
+ /** The Codex parser has already proved the table's semantics. This raw check
1923
+ * rejects user customizations the list view intentionally omits, while still
1924
+ * accepting equivalent quoted/spaced table headers without claiming them as
1925
+ * removable Uai-owned text. */
1926
+ function plainRawDefinition(text, slug) {
1927
+ const atom = escapeRegExp(slug);
1928
+ const header = new RegExp(
1929
+ "^[ \\t]*\\[[ \\t]*(?:\\\"mcp_servers\\\"|'mcp_servers'|mcp_servers)" +
1930
+ "[ \\t]*\\.[ \\t]*(?:\\\"" + atom + "\\\"|'" + atom + "'|" + atom + ")" +
1931
+ "[ \\t]*\\][ \\t]*(?:#.*)?$",
1932
+ "gm"
1933
+ );
1934
+ const matches = Array.from(text.matchAll(header));
1935
+ if (matches.length !== 1) return false;
1936
+ const match = matches[0];
1937
+ const remainder = text.slice((match.index || 0) + match[0].length);
1938
+ const nextHeader = remainder.search(/^[ \t]*\[\[?.+\]\]?[ \t]*(?:#.*)?\r?$/m);
1939
+ const body = nextHeader === -1 ? remainder : remainder.slice(0, nextHeader);
1940
+ const fields = body
1941
+ .split(/\r?\n/)
1942
+ .map((line) => line.trim())
1943
+ .filter((line) => line !== "" && !line.startsWith("#"));
1944
+ return fields.length === 2 &&
1945
+ fields.some((line) => /^command[ \t]*=/.test(line)) &&
1946
+ fields.some((line) => /^args[ \t]*=/.test(line));
1947
+ }
1948
+
1949
+ /** Any descendant table is user/Codex-authored state that the parent marker
1950
+ * does not prove Uai owns. Scan the whole document: TOML permits unrelated
1951
+ * tables between a parent and its descendants. */
1952
+ function hasRawDescendantTable(text, slug) {
1953
+ const atom = escapeRegExp(slug);
1954
+ return new RegExp(
1955
+ "^[ \\t]*\\[\\[?[ \\t]*(?:\\\"mcp_servers\\\"|'mcp_servers'|mcp_servers)" +
1956
+ "[ \\t]*\\.[ \\t]*(?:\\\"" + atom + "\\\"|'" + atom + "'|" + atom + ")" +
1957
+ "[ \\t]*\\.",
1958
+ "m"
1959
+ ).test(text);
1960
+ }
1961
+
1962
+ function listDefinitions(codexHome = sourceHome) {
1963
+ // One list call validates the whole TOML document and identifies truly
1964
+ // absent servers. A malformed document fails before any edit.
1965
+ const result = runCodex(["mcp", "list", "--json"], codexHome);
1966
+ if (result.status !== 0) {
1967
+ throw new Error("Codex config is invalid: " + detail(result));
1968
+ }
1969
+ let value;
1970
+ try {
1971
+ value = JSON.parse(result.stdout);
1972
+ } catch {
1973
+ throw new Error("codex mcp list returned invalid JSON");
1974
+ }
1975
+ if (!Array.isArray(value) ||
1976
+ !value.every((entry) => plainObject(entry) && typeof entry.name === "string")) {
1977
+ throw new Error("codex mcp list returned an invalid server list");
1978
+ }
1979
+ return value;
1980
+ }
1981
+
1982
+ /** Validate a candidate before publication through the same Codex parser used
1983
+ * for the real home. The sibling directory preserves ../ relative paths; its
1984
+ * top-level symlinks preserve paths within CODEX_HOME without copying secrets
1985
+ * or other account state. */
1986
+ function validateCandidate(text, mode) {
1987
+ const validationHome = fs.mkdtempSync(path.join(
1988
+ path.dirname(sourceHome),
1989
+ "." + path.basename(sourceHome) + ".uai-validate-"
1990
+ ));
1991
+ try {
1992
+ for (const name of fs.readdirSync(sourceHome)) {
1993
+ if (name === path.basename(file)) continue;
1994
+ fs.symlinkSync(
1995
+ path.join(sourceHome, name),
1996
+ path.join(validationHome, name)
1997
+ );
1998
+ }
1999
+ const validationFile = path.join(validationHome, path.basename(file));
2000
+ fs.writeFileSync(validationFile, text, {
2001
+ encoding: "utf8",
2002
+ flag: "wx",
2003
+ mode,
2004
+ });
2005
+ const definitions = listDefinitions(validationHome);
2006
+ if (fs.readFileSync(validationFile, "utf8") !== text) {
2007
+ throw new Error("Codex changed its config during candidate validation");
2008
+ }
2009
+ return definitions;
2010
+ } finally {
2011
+ fs.rmSync(validationHome, { recursive: true, force: true });
2012
+ }
2013
+ }
2014
+
2015
+ for (const [slug, url] of Object.entries(desired)) {
2016
+ if (gatewayIdentity(url, slug)?.taskId !== taskId) {
2017
+ throw new Error("invalid desired Codex MCP gateway URL");
2018
+ }
2019
+ }
2020
+
2021
+ const listed = listDefinitions();
2022
+ const bySlug = new Map();
2023
+ for (const entry of listed) {
2024
+ if (bySlug.has(entry.name)) {
2025
+ throw new Error("Codex MCP server " + entry.name + " is duplicated - left unchanged");
2026
+ }
2027
+ bySlug.set(entry.name, entry);
2028
+ }
2029
+
2030
+ const managed = new Map();
2031
+ const customized = new Set();
2032
+ for (const [slug, definition] of bySlug) {
2033
+ const url = managedDefinitionUrl(definition, slug);
2034
+ if (url !== null && gatewayIdentity(url, slug)?.taskId === taskId) {
2035
+ if (!hasRawDescendantTable(original || "", slug) &&
2036
+ editManagedRawBlock(original || "", slug, url, url) !== null) {
2037
+ managed.set(slug, url);
2038
+ } else {
2039
+ customized.add(slug);
2040
+ }
2041
+ }
2042
+ }
2043
+
2044
+ const missing = [];
2045
+ const rewrites = [];
2046
+ // As with JSON configs, a foreign definition shadows only this slug in this
2047
+ // home. Other homes and engine writers must still run.
2048
+ const skipped = new Set();
2049
+ const warnings = [];
2050
+ for (const [slug, url] of Object.entries(desired)) {
2051
+ const definition = bySlug.get(slug);
2052
+ if (definition === undefined) {
2053
+ missing.push([slug, url]);
2054
+ continue;
2055
+ }
2056
+ if (exactDefinition(definition, slug, url) &&
2057
+ plainRawDefinition(original || "", slug)) continue;
2058
+ const oldUrl = managed.get(slug);
2059
+ if (oldUrl === undefined || !sameTaskGateway(oldUrl, url, slug)) {
2060
+ skipped.add(slug);
2061
+ continue;
2062
+ }
2063
+ rewrites.push([slug, oldUrl, url]);
2064
+ }
2065
+ const removals = [...managed.entries()].filter(([slug]) =>
2066
+ !Object.hasOwn(desired, slug)
2067
+ );
2068
+ for (const slug of skipped) {
2069
+ warnings.push(
2070
+ "[uai-mcp] kept foreign Codex server " + slug +
2071
+ "; skipped its Uai definition in " + home + "\n"
2072
+ );
2073
+ }
2074
+ for (const slug of customized) {
2075
+ if (!Object.hasOwn(desired, slug)) {
2076
+ warnings.push(
2077
+ "[uai-mcp] kept customized Codex server " + slug +
2078
+ "; skipped its Uai removal in " + home + "\n"
2079
+ );
2080
+ }
2081
+ }
2082
+ if (missing.length === 0 && rewrites.length === 0 && removals.length === 0) {
2083
+ for (const warning of warnings) process.stderr.write(warning);
2084
+ process.exit(0);
2085
+ }
2086
+
2087
+ const newline = original && original.includes("\r\n") ? "\r\n" : "\n";
2088
+ const blocks = missing.map(([slug, url]) => [
2089
+ "# uai ADR-057: host MCP gateway",
2090
+ "[mcp_servers." + slug + "]",
2091
+ 'command = "npx"',
2092
+ "args = " + JSON.stringify(["-y", "mcp-remote", url, "--allow-http"]),
2093
+ "",
2094
+ ].join(newline)).join(newline);
2095
+ let next = original || "";
2096
+ for (const [slug, oldUrl] of removals) {
2097
+ const removed = editManagedRawBlock(next, slug, oldUrl, null);
2098
+ if (removed === null) {
2099
+ throw new Error("Codex MCP server " + slug + " has foreign TOML text - left unchanged");
2100
+ }
2101
+ next = removed;
2102
+ }
2103
+ for (const [slug, oldUrl, desiredUrl] of rewrites) {
2104
+ const rewritten = editManagedRawBlock(next, slug, oldUrl, desiredUrl);
2105
+ if (rewritten === null) {
2106
+ throw new Error("Codex MCP server " + slug + " has foreign TOML text - left unchanged");
2107
+ }
2108
+ next = rewritten;
2109
+ }
2110
+ if (next.length > 0 && !next.endsWith("\n")) next += newline;
2111
+ if (blocks.length > 0) {
2112
+ if (next.length > 0 && !next.endsWith(newline + newline)) next += newline;
2113
+ next += blocks;
2114
+ }
2115
+
2116
+ const mode = original === null ? 0o644 : fs.statSync(file).mode & 0o777;
2117
+ // Validate and verify the complete candidate before it can replace the real
2118
+ // file. This is the final safety boundary for TOML spellings the conservative
2119
+ // raw ownership checks do not recognize (for example escaped quoted keys).
2120
+ const verified = validateCandidate(next, mode);
2121
+ const verifiedBySlug = new Map(verified.map((entry) => [entry.name, entry]));
2122
+ for (const [slug, url] of Object.entries(desired)) {
2123
+ if (skipped.has(slug)) continue;
2124
+ if (!exactDefinition(verifiedBySlug.get(slug), slug, url) ||
2125
+ !plainRawDefinition(next, slug)) {
2126
+ throw new Error("Codex MCP server " + slug + " failed verification");
2127
+ }
2128
+ }
2129
+ for (const [slug] of removals) {
2130
+ if (verifiedBySlug.has(slug)) {
2131
+ throw new Error("Codex MCP server " + slug + " was not removed");
2132
+ }
2133
+ }
2134
+
2135
+ const tmp = path.join(
2136
+ sourceHome,
2137
+ "." + path.basename(file) + "." + process.pid + "." +
2138
+ crypto.randomBytes(8).toString("hex") + ".tmp"
2139
+ );
2140
+ try {
2141
+ fs.writeFileSync(tmp, next, {
2142
+ encoding: "utf8",
2143
+ flag: "wx",
2144
+ mode,
2145
+ });
2146
+ let current = null;
2147
+ try {
2148
+ current = fs.readFileSync(file, "utf8");
2149
+ } catch (error) {
2150
+ if (!error || error.code !== "ENOENT") throw error;
2151
+ }
2152
+ if (current !== original) {
2153
+ throw new Error("Codex config changed concurrently");
2154
+ }
2155
+ if (fs.realpathSync(home) !== sourceHome) {
2156
+ throw new Error("Codex home changed concurrently");
2157
+ }
2158
+ fs.renameSync(tmp, file);
2159
+ } finally {
2160
+ try { fs.unlinkSync(tmp); } catch {}
2161
+ }
2162
+ for (const warning of warnings) process.stderr.write(warning);
2163
+ `.trim();
2164
+
2165
+ /** Reconcile one GROK_HOME through Grok's own parser/writer. Compatibility
2166
+ * sources and project scope are disabled for the ownership probe: only an
2167
+ * exact user-scope HTTP entry routed through this task's gateway is removed
2168
+ * or replaced. */
2169
+ export const RECONCILE_GROK_MCP = String.raw`
2170
+ const child = require("child_process");
2171
+ const path = require("path");
2172
+
2173
+ const home = process.argv[1];
2174
+ const desired = JSON.parse(process.argv[2]);
2175
+ const grokBin = process.argv[3];
2176
+ const taskId = process.argv[4];
2177
+ const plainObject = (value) =>
2178
+ value !== null && typeof value === "object" && !Array.isArray(value);
2179
+ if (typeof home !== "string" || !path.isAbsolute(home)) {
2180
+ throw new Error("Grok home must be an absolute path");
2181
+ }
2182
+ if (!plainObject(desired)) {
2183
+ throw new Error("Grok MCP definitions must be an object");
2184
+ }
2185
+ if (typeof grokBin !== "string" || !path.isAbsolute(grokBin)) {
2186
+ throw new Error("Grok binary must be an absolute path");
2187
+ }
2188
+ if (typeof taskId !== "string" || taskId.length === 0) {
2189
+ throw new Error("invalid MCP task id");
2190
+ }
2191
+
2192
+ function gatewayIdentity(value, slug) {
2193
+ let parsed;
2194
+ try { parsed = new URL(value); } catch { return null; }
2195
+ if (parsed.protocol !== "http:" || (parsed.port || "80") !== "${MCP_GATEWAY_PORT}" ||
2196
+ parsed.username || parsed.password || parsed.search || parsed.hash) return null;
2197
+ const segments = parsed.pathname.split("/").filter(Boolean);
2198
+ if (segments.length !== 3 || segments[0] !== "t" || segments[2] !== slug ||
2199
+ parsed.pathname !== "/t/" + segments[1] + "/" + slug) return null;
2200
+ const prefix = taskId + ".";
2201
+ if (!segments[1].startsWith(prefix) ||
2202
+ !/^[A-Za-z0-9_-]{32}$/.test(segments[1].slice(prefix.length))) return null;
2203
+ if (value !== "http://" + parsed.hostname + ":${MCP_GATEWAY_PORT}" +
2204
+ "/t/" + segments[1] + "/" + slug) return null;
2205
+ return { taskId: taskId };
2206
+ }
2207
+
2208
+ for (const [slug, url] of Object.entries(desired)) {
2209
+ if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(slug) ||
2210
+ typeof url !== "string" || gatewayIdentity(url, slug) === null) {
2211
+ throw new Error("invalid desired Grok MCP definition for " + slug);
2212
+ }
2213
+ }
2214
+
2215
+ function run(args) {
2216
+ return child.spawnSync(grokBin, args, {
2217
+ cwd: "/",
2218
+ encoding: "utf8",
2219
+ env: Object.assign({}, process.env, {
2220
+ GROK_HOME: home,
2221
+ GROK_CURSOR_MCPS_ENABLED: "false",
2222
+ GROK_CLAUDE_MCPS_ENABLED: "false",
2223
+ }),
2224
+ maxBuffer: 1024 * 1024,
2225
+ timeout: 5000,
2226
+ killSignal: "SIGKILL",
2227
+ });
2228
+ }
2229
+
2230
+ function detail(result) {
2231
+ const lines = String(result.stderr || result.stdout || "")
2232
+ .split(/\r?\n/)
2233
+ .map((line) => line.trim())
2234
+ .filter(Boolean);
2235
+ return String(lines.pop() || result.error || result.status);
2236
+ }
2237
+
2238
+ function list() {
2239
+ const result = run(["mcp", "list", "--json"]);
2240
+ if (result.status !== 0) {
2241
+ throw new Error("Grok config is invalid: " + detail(result));
2242
+ }
2243
+ let value;
2244
+ try { value = JSON.parse(result.stdout); }
2245
+ catch { throw new Error("grok mcp list returned invalid JSON"); }
2246
+ if (!Array.isArray(value) ||
2247
+ !value.every((entry) => plainObject(entry) && typeof entry.name === "string")) {
2248
+ throw new Error("grok mcp list returned an invalid server list");
2249
+ }
2250
+ return value;
2251
+ }
2252
+
2253
+ function managedUrl(entry, slug) {
2254
+ if (!plainObject(entry)) return null;
2255
+ const keys = Object.keys(entry).sort().join("\0");
2256
+ if (keys !== ["enabled", "name", "scope", "url"].sort().join("\0") ||
2257
+ entry.name !== slug || entry.scope !== "user" || entry.enabled !== true ||
2258
+ typeof entry.url !== "string") return null;
2259
+ return gatewayIdentity(entry.url, slug) === null ? null : entry.url;
2260
+ }
2261
+
2262
+ const current = new Map();
2263
+ const managed = new Map();
2264
+ for (const entry of list()) {
2265
+ if (current.has(entry.name)) {
2266
+ throw new Error("Grok MCP server " + entry.name + " is duplicated - left unchanged");
2267
+ }
2268
+ current.set(entry.name, entry);
2269
+ const url = managedUrl(entry, entry.name);
2270
+ if (url !== null) managed.set(entry.name, url);
2271
+ }
2272
+
2273
+ const additions = [];
2274
+ // A user-scope foreign entry shadows only this slug in this GROK_HOME.
2275
+ const skipped = new Set();
2276
+ const warnings = [];
2277
+ for (const [slug, url] of Object.entries(desired)) {
2278
+ const entry = current.get(slug);
2279
+ if (entry === undefined) {
2280
+ additions.push([slug, url]);
2281
+ } else if (managed.get(slug) === url) {
2282
+ continue;
2283
+ } else if (managed.has(slug)) {
2284
+ additions.push([slug, url]);
2285
+ } else {
2286
+ skipped.add(slug);
2287
+ }
2288
+ }
2289
+ const removals = [...managed.keys()].filter((slug) => !Object.hasOwn(desired, slug));
2290
+ for (const slug of skipped) {
2291
+ warnings.push(
2292
+ "[uai-mcp] kept foreign Grok server " + slug +
2293
+ "; skipped its Uai definition in " + home + "\n"
2294
+ );
2295
+ }
2296
+ if (additions.length === 0 && removals.length === 0) {
2297
+ for (const warning of warnings) process.stderr.write(warning);
2298
+ process.exit(0);
2299
+ }
2300
+
2301
+ for (const [slug, url] of additions) {
2302
+ const result = run(["mcp", "add", slug, url, "-t", "http", "-s", "user"]);
2303
+ if (result.status !== 0) {
2304
+ throw new Error("grok mcp add " + slug + " failed: " + detail(result));
2305
+ }
2306
+ }
2307
+ for (const slug of removals) {
2308
+ const result = run(["mcp", "remove", slug, "-s", "user"]);
2309
+ if (result.status !== 0) {
2310
+ throw new Error("grok mcp remove " + slug + " failed: " + detail(result));
2311
+ }
2312
+ }
2313
+
2314
+ const verified = new Map(list().map((entry) => [entry.name, entry]));
2315
+ for (const [slug, url] of Object.entries(desired)) {
2316
+ if (skipped.has(slug)) continue;
2317
+ if (managedUrl(verified.get(slug), slug) !== url) {
2318
+ throw new Error("Grok MCP server " + slug + " failed verification");
2319
+ }
2320
+ }
2321
+ for (const slug of removals) {
2322
+ if (verified.has(slug)) {
2323
+ throw new Error("Grok MCP server " + slug + " was not removed");
2324
+ }
2325
+ }
2326
+ for (const warning of warnings) process.stderr.write(warning);
2327
+ `.trim();
2328
+
2329
+ function shellQuote(value: string): string {
2330
+ return `'${value.replace(/'/g, `'\\''`)}'`;
2331
+ }
2332
+
2333
+ /** Config homes are independent, but every Uai/browser writer must remain
2334
+ * behind one flock. Run a bounded batch at a time so the supported account ×
2335
+ * agent count cannot turn the outer timeout into a deterministic failure. */
2336
+ const MCP_CONFIG_HOME_WRITE_CONCURRENCY = 8;
2337
+
2338
+ function boundedParallelShell(
2339
+ commands: readonly string[],
2340
+ width = MCP_CONFIG_HOME_WRITE_CONCURRENCY,
2341
+ ): string | null {
2342
+ if (commands.length === 0) return null;
2343
+ const batches: string[] = [];
2344
+ for (let offset = 0; offset < commands.length; offset += width) {
2345
+ const batch = commands.slice(offset, offset + width);
2346
+ const launches = batch.map(
2347
+ (command, index) => `(${command}) & uai_mcp_pid_${index}=$!`,
2348
+ );
2349
+ const waits = batch.map(
2350
+ (_command, index) =>
2351
+ `wait "$uai_mcp_pid_${index}" || uai_mcp_status=1`,
2352
+ );
2353
+ batches.push(
2354
+ `{ ${launches.join("; ")}; uai_mcp_status=0; ${waits.join("; ")}; ` +
2355
+ `[ "$uai_mcp_status" -eq 0 ]; }`,
2356
+ );
2357
+ }
2358
+ return batches.join(" && ");
2359
+ }
2360
+
2361
+ // The host must never win this timeout race: killing the local `docker exec`
2362
+ // client does not kill its in-container child. The in-runtime deadline scales
2363
+ // with bounded home batches below; the host backstop always follows TERM/KILL.
2364
+ /** The grok CLI inside task containers/machines (x.ai installer path). */
2365
+ const GROK_BIN = "/home/node/.grok/bin/grok";
2366
+ const MCP_CONFIG_MIN_WRITE_TIMEOUT_S = 25;
2367
+ const MCP_CONFIG_WRITE_KILL_AFTER_S = 5;
2368
+ const MCP_CONFIG_HOST_GRACE_S = 10;
2369
+ const MCP_CONFIG_CODEX_BATCH_BUDGET_S = 3;
2370
+ // One home can replace/remove two disjoint maximum-size memberships. Grok's
2371
+ // CLI has no bulk mutation, so give a bounded batch a budget proportional to
2372
+ // the accepted manifest ceiling rather than pretending it is constant work.
2373
+ const MCP_CONFIG_GROK_BATCH_BUDGET_S = 2 + Math.ceil(MAX_MANIFEST_MCP / 4);
2374
+
2375
+ /**
2376
+ * Write the task's MCP configs inside the container, one shape per engine on
2377
+ * the roster — all pointing at the same host gateway URLs:
2378
+ * - Claude — /workspace/.mcp.json (merged; coexists with the ADR-053 browser
2379
+ * server). Written unconditionally: the adapter passes `--mcp-config`.
2380
+ * - Codex — reconciled mcp-remote shims in config.toml.
2381
+ * - Cursor — ~/.cursor/mcp.json ({ url } form; the adapter passes
2382
+ * `--approve-mcps`).
2383
+ * - Grok — reconciled through `grok mcp` in every GROK_HOME the task may use
2384
+ * (project scope is refused in an untrusted folder).
2385
+ * - OpenCode — ~/.config/opencode/opencode.json `mcp` block (type:"remote",
2386
+ * oauth:false so it treats the gateway URL as a plain server; the path
2387
+ * token is the auth).
2388
+ * Safe to re-run every ensure. `engineKinds` is the roster's agent kinds.
2389
+ */
2390
+ export async function setupMcpTaskConfig(
2391
+ taskId: string,
2392
+ containerName: string,
2393
+ connections: TaskMcpConnection[],
2394
+ engineKinds: string[],
2395
+ codexHomes: readonly string[] = ["/home/node/.codex"],
2396
+ environment?: Pick<TaskEnvironmentHandle, "exec"> &
2397
+ Partial<Pick<TaskEnvironmentHandle, "descriptor">>,
2398
+ /** Every GROK_HOME the task may run under (default + account homes). */
2399
+ grokHomes: readonly string[] = ["/home/node/.grok"],
2400
+ ): Promise<boolean> {
2401
+ // No early return on empty: the claude adapter passes
2402
+ // `--mcp-config /workspace/.mcp.json` unconditionally (ADR-057), so the
2403
+ // file must exist — an empty mcpServers map — even with no connections.
2404
+ try {
2405
+ // Boot-time standard-image maintenance can briefly replace the shared CLI
2406
+ // shims used by these in-container writers. The ACL is itself observable
2407
+ // state, so hold the same barrier before it or any Docker side effect.
2408
+ await agentClisReady;
2409
+ const has = (kind: string): boolean => engineKinds.includes(kind);
2410
+ if (
2411
+ has("codex") &&
2412
+ connections.some(
2413
+ (connection) =>
2414
+ !/^[a-z0-9][a-z0-9-]{0,63}$/.test(connection.slug),
2415
+ )
2416
+ ) {
2417
+ throw new Error("invalid Codex MCP connection slug");
2418
+ }
2419
+ const acl = ensureTaskGatewayAcl(taskId, connections);
2420
+ // ADR-121: a machine dials the gateway through its ssh REVERSE tunnel —
2421
+ // the host's loopback appears at the machine's own 127.0.0.1:PORT
2422
+ // (machine-gateway-tunnel.ts). host.docker.internal / vmnet addresses
2423
+ // are container topology a machine can never route.
2424
+ const gatewayHost =
2425
+ isEnvironmentTransportProvider(environment?.descriptor?.locator.provider)
2426
+ ? "127.0.0.1"
2427
+ : await taskGatewayHost();
2428
+ const urlFor = (slug: string): string =>
2429
+ `http://${gatewayHost}:${MCP_GATEWAY_PORT}/t/${acl.token}/${slug}`;
2430
+
2431
+ const claudeEntries: Record<string, unknown> = {};
2432
+ const codexEntries: Record<string, string> = {};
2433
+ const cursorEntries: Record<string, unknown> = {};
2434
+ const opencodeEntries: Record<string, unknown> = {};
2435
+ for (const c of connections) {
2436
+ claudeEntries[c.slug] = { type: "http", url: urlFor(c.slug) };
2437
+ codexEntries[c.slug] = urlFor(c.slug);
2438
+ // Cursor's mcp.json wants a bare { url } for remote (http/sse) servers.
2439
+ cursorEntries[c.slug] = { url: urlFor(c.slug) };
2440
+ // OpenCode: type:"remote" + oauth:false — the gateway needs no client
2441
+ // auth (the path token is the secret), so suppress OpenCode's own OAuth
2442
+ // flow that would otherwise fire on a gateway 401 and hang headless.
2443
+ opencodeEntries[c.slug] = {
2444
+ type: "remote",
2445
+ url: urlFor(c.slug),
2446
+ enabled: true,
2447
+ oauth: false,
2448
+ };
2449
+ }
2450
+ const codexWrites = has("codex")
2451
+ ? codexHomes.map(
2452
+ (home) =>
2453
+ `node -e "$uai_codex_mcp_writer" ${shellQuote(home)} ` +
2454
+ `"$uai_mcp_desired" codex "$uai_mcp_task"`,
2455
+ )
2456
+ : [];
2457
+ const grokWrites = has("grok")
2458
+ ? grokHomes
2459
+ .filter((home) => /^\/home\/node\/\.grok(-acct-[A-Za-z0-9_-]+)?$/.test(home))
2460
+ .map(
2461
+ (home) =>
2462
+ `node -e "$uai_grok_mcp_writer" ${shellQuote(home)} ` +
2463
+ `"$uai_mcp_desired" ${GROK_BIN} "$uai_mcp_task"`,
2464
+ )
2465
+ : [];
2466
+ const homeWrites = boundedParallelShell([...codexWrites, ...grokWrites]);
2467
+ const codexBatches = Math.ceil(
2468
+ codexWrites.length / MCP_CONFIG_HOME_WRITE_CONCURRENCY,
2469
+ );
2470
+ const grokBatches = Math.ceil(
2471
+ grokWrites.length / MCP_CONFIG_HOME_WRITE_CONCURRENCY,
2472
+ );
2473
+ const writeTimeoutS = Math.max(
2474
+ MCP_CONFIG_MIN_WRITE_TIMEOUT_S,
2475
+ 10 +
2476
+ codexBatches * MCP_CONFIG_CODEX_BATCH_BUDGET_S +
2477
+ grokBatches * MCP_CONFIG_GROK_BATCH_BUDGET_S,
2478
+ );
2479
+ const hostTimeoutMs =
2480
+ (writeTimeoutS +
2481
+ MCP_CONFIG_WRITE_KILL_AFTER_S +
2482
+ MCP_CONFIG_HOST_GRACE_S) *
2483
+ 1_000;
2484
+ // Browser migration touches these same Claude/Codex/Cursor files and
2485
+ // invokes Grok's own read-modify-write CLI. Keep every shared writer in
2486
+ // one critical section so neither side can erase the other's update.
2487
+ const lockedWrites = [
2488
+ `[ -f /workspace/.claude/settings.json ] || printf '%s\\n' ${shellQuote(
2489
+ JSON.stringify({ enableAllProjectMcpServers: true }, null, 2),
2490
+ )} > /workspace/.claude/settings.json`,
2491
+ `node -e ${shellQuote(
2492
+ MERGE_MCP_JSON,
2493
+ )} /workspace/.mcp.json ${shellQuote(JSON.stringify(claudeEntries))} mcpServers claude ${shellQuote(
2494
+ taskId,
2495
+ )}`,
2496
+ ...(homeWrites === null
2497
+ ? []
2498
+ : [
2499
+ `uai_mcp_desired=${shellQuote(JSON.stringify(codexEntries))}`,
2500
+ `uai_mcp_task=${shellQuote(taskId)}`,
2501
+ ...(codexWrites.length === 0
2502
+ ? []
2503
+ : [`uai_codex_mcp_writer=${shellQuote(ENSURE_CODEX_MCP_TOML)}`]),
2504
+ ...(grokWrites.length === 0
2505
+ ? []
2506
+ : [`uai_grok_mcp_writer=${shellQuote(RECONCILE_GROK_MCP)}`]),
2507
+ ]),
2508
+ // Codex/Grok homes do not share files. Bound their parallelism while
2509
+ // holding the one cross-writer lock; each home still reconciles itself
2510
+ // serially through its engine's parser/writer.
2511
+ ...(homeWrites === null ? [] : [homeWrites]),
2512
+ ...(has("cursor")
2513
+ ? [
2514
+ `node -e ${shellQuote(MERGE_MCP_JSON)} /home/node/.cursor/mcp.json ${shellQuote(
2515
+ JSON.stringify(cursorEntries),
2516
+ )} mcpServers cursor ${shellQuote(taskId)}`,
2517
+ ]
2518
+ : []),
2519
+ ].join(" && ");
2520
+ const writes = [
2521
+ "mkdir -p /workspace/.claude",
2522
+ `flock -w 10 ${MCP_CONFIG_LOCK_PATH} sh -lc ${shellQuote(lockedWrites)}`,
2523
+ // OpenCode: global config `mcp` block (auto-discovered regardless of the
2524
+ // exec cwd, and independent of any per-account OPENCODE_DATA_DIR).
2525
+ ...(has("opencode")
2526
+ ? [
2527
+ `node -e ${shellQuote(MERGE_MCP_JSON)} /home/node/.config/opencode/opencode.json ${shellQuote(
2528
+ JSON.stringify(opencodeEntries),
2529
+ )} mcp opencode ${shellQuote(taskId)}`,
2530
+ ]
2531
+ : []),
2532
+ ].join(" && ");
2533
+ const steps =
2534
+ `timeout --kill-after=${MCP_CONFIG_WRITE_KILL_AFTER_S} ` +
2535
+ `${writeTimeoutS} sh -lc ${shellQuote(writes)}`;
2536
+ const result = environment
2537
+ ? await environment.exec({
2538
+ argv: ["sh", "-lc", steps],
2539
+ env: RUNTIME_AUTHORITY_ENV,
2540
+ projectEnvProfile: "maintenance",
2541
+ timeoutMs: hostTimeoutMs,
2542
+ maxOutputBytes: 256 * 1024,
2543
+ })
2544
+ : await taskContainerCli(
2545
+ [
2546
+ "exec",
2547
+ ...runtimeAuthorityDockerExecArgs(),
2548
+ // Backend-aware fallback: the passed name is the Docker replica
2549
+ // shape and `docker` does not exist on an apple host (live
2550
+ // 2026-08-18: spawn docker ENOENT killed every no-handle config
2551
+ // write, so sessions never saw their MCP tools).
2552
+ taskContainerBackend().apple
2553
+ ? taskAppContainerName(taskId)
2554
+ : containerName,
2555
+ "sh",
2556
+ "-lc",
2557
+ steps,
2558
+ ],
2559
+ { timeoutMs: hostTimeoutMs },
2560
+ );
2561
+ const status = "exitCode" in result ? result.exitCode : result.status;
2562
+ const stderrValue = "stderr" in result ? result.stderr : "";
2563
+ const stderr = stderrValue instanceof Uint8Array
2564
+ ? Buffer.from(stderrValue).toString("utf8")
2565
+ : String(stderrValue ?? "");
2566
+ if (status !== 0) {
2567
+ // Earlier writers in the && chain may have succeeded with local-shadow
2568
+ // warnings before a later writer failed. Remove only those known soft
2569
+ // lines so the bounded log cannot hide the hard parse/I/O diagnostic.
2570
+ const hardStderr = stderr
2571
+ .split(/\r?\n/)
2572
+ .filter((line) =>
2573
+ !/^\[uai-mcp\] kept (?:foreign|customized) .+; skipped its Uai (?:definition|removal) in /.test(line),
2574
+ )
2575
+ .join("\n")
2576
+ .trim();
2577
+ console.warn(
2578
+ `[mcp-gateway] task ${taskId}: config write failed: ${(hardStderr || stderr).slice(0, 300)}`,
2579
+ );
2580
+ return false;
2581
+ }
2582
+ if (stderr.trim().length > 0) {
2583
+ console.warn(
2584
+ `[mcp-gateway] task ${taskId}: config write warning: ${stderr.slice(0, 300)}`,
2585
+ );
2586
+ }
2587
+ return true;
2588
+ } catch (err) {
2589
+ console.warn(
2590
+ `[mcp-gateway] task ${taskId}: setup failed: ${err instanceof Error ? err.message : err}`,
2591
+ );
2592
+ return false;
2593
+ }
2594
+ }