@runuai/host 0.9.13 → 0.9.42

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 (97) hide show
  1. package/README.md +22 -5
  2. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  3. package/db/migrations/0015_host_settings.sql +9 -0
  4. package/db/migrations/0016_task_environment.sql +2 -0
  5. package/db/migrations/meta/_journal.json +21 -0
  6. package/db/schema.ts +80 -30
  7. package/images/standard/Dockerfile +36 -10
  8. package/images/standard/README.md +63 -18
  9. package/images/standard/container/corepack-version +1 -0
  10. package/images/standard/container/uai-init +308 -38
  11. package/images/standard/container/uai-materialize-runtimes +1527 -0
  12. package/lib/agent-cli.ts +69 -4
  13. package/lib/agent.ts +46 -7
  14. package/lib/agents/claude.ts +13 -8
  15. package/lib/agents/codex.ts +11 -6
  16. package/lib/agents/cursor.ts +39 -29
  17. package/lib/agents/durable-proc.ts +20 -27
  18. package/lib/agents/factory.ts +9 -25
  19. package/lib/agents/grok.ts +43 -30
  20. package/lib/agents/kimi.ts +44 -29
  21. package/lib/agents/opencode.ts +43 -31
  22. package/lib/agents/proc.ts +149 -114
  23. package/lib/agents/transport.ts +62 -50
  24. package/lib/agents/types.ts +6 -4
  25. package/lib/apple-runtime-recycle.ts +236 -0
  26. package/lib/apple-uninstall-teardown.ts +224 -0
  27. package/lib/browser-testing.ts +233 -93
  28. package/lib/codex-auth.ts +40 -6
  29. package/lib/command-db.ts +20 -0
  30. package/lib/container-runtime.ts +1338 -0
  31. package/lib/db.ts +1 -0
  32. package/lib/docker-exec.ts +87 -5
  33. package/lib/engine-accounts.ts +68 -5
  34. package/lib/engine-login.ts +1952 -0
  35. package/lib/enrollment-state.ts +251 -0
  36. package/lib/env-file.ts +155 -0
  37. package/lib/env.ts +4 -0
  38. package/lib/git-diff.ts +98 -32
  39. package/lib/git-identity.ts +199 -87
  40. package/lib/github-tokens.ts +202 -91
  41. package/lib/host-cloud-url.ts +62 -0
  42. package/lib/host-config.ts +279 -0
  43. package/lib/host-logs.ts +962 -0
  44. package/lib/keyed-promise-tail.ts +23 -0
  45. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  46. package/lib/managed-activation-watcher.ts +72 -0
  47. package/lib/managed-install-owner-watcher.ts +55 -0
  48. package/lib/managed-operation-drain.ts +49 -0
  49. package/lib/managed-runtime.ts +3644 -0
  50. package/lib/managed-update-scheduler.ts +125 -0
  51. package/lib/mcp-gateway.ts +450 -23
  52. package/lib/orchestrator.ts +3070 -223
  53. package/lib/preview-sidecar.ts +57 -13
  54. package/lib/release-manifest.ts +708 -0
  55. package/lib/release-trust.ts +28 -0
  56. package/lib/runtime-activation-tail.ts +232 -0
  57. package/lib/runtime-archive.ts +1086 -0
  58. package/lib/runtime-authority.ts +79 -0
  59. package/lib/runtime-guard.ts +36 -0
  60. package/lib/runtime-provider-state.ts +169 -0
  61. package/lib/runtime-state.ts +232 -12
  62. package/lib/skills.ts +24 -3
  63. package/lib/ssh.ts +18 -0
  64. package/lib/standard-image.ts +1104 -141
  65. package/lib/stopped-task-status-queue.ts +44 -0
  66. package/lib/task-container-cli.ts +269 -0
  67. package/lib/task-diff.ts +66 -46
  68. package/lib/task-environment/apple-container.ts +757 -0
  69. package/lib/task-environment/docker.ts +945 -0
  70. package/lib/task-environment/index.ts +364 -0
  71. package/lib/task-environment/legacy-adoption.ts +443 -0
  72. package/lib/task-environment/registry.ts +58 -0
  73. package/lib/task-environment/types.ts +408 -0
  74. package/lib/task-identity.ts +19 -0
  75. package/lib/task-inventory.ts +585 -0
  76. package/lib/tunnel-registry.ts +135 -19
  77. package/lib/tunnel-runtime.ts +235 -0
  78. package/package.json +1 -1
  79. package/scripts/agent/_common.sh +123 -3
  80. package/scripts/agent/task-down.sh +146 -38
  81. package/scripts/agent/task-status.sh +19 -3
  82. package/scripts/agent/task-up.sh +1463 -109
  83. package/scripts/install/darwin.ts +848 -50
  84. package/scripts/install/linux.ts +838 -35
  85. package/scripts/install/types.ts +43 -0
  86. package/scripts/install/util.ts +215 -8
  87. package/scripts/install/win.ts +12 -0
  88. package/src/apple-tunnel-route.ts +104 -0
  89. package/src/cli.ts +1464 -72
  90. package/src/event-outbox.ts +83 -4
  91. package/src/index.ts +871 -50
  92. package/src/main.ts +1398 -255
  93. package/src/paths.ts +17 -1
  94. package/src/protocol.ts +695 -1
  95. package/src/runtime-bootstrap.ts +165 -0
  96. package/src/ui/server.ts +46 -10
  97. package/src/ui/types.ts +37 -0
@@ -0,0 +1,125 @@
1
+ export const MANAGED_UPDATE_INITIAL_DELAY_MS = 2 * 60_000;
2
+ export const MANAGED_UPDATE_DAILY_DELAY_MS = 24 * 60 * 60_000;
3
+ export const MANAGED_UPDATE_BUSY_RETRY_MS = 10 * 60_000;
4
+ export const MANAGED_UPDATE_FAILURE_RETRY_MS = 15 * 60_000;
5
+ export const MANAGED_UPDATE_MAX_FAILURE_RETRY_MS = 6 * 60 * 60_000;
6
+ export const MANAGED_UPDATE_JITTER_FRACTION = 0.2;
7
+
8
+ export type ManagedScheduledCheckResult =
9
+ | "current"
10
+ | "updated"
11
+ | "busy"
12
+ | "not-managed";
13
+
14
+ export interface ManagedUpdateSchedulerOptions {
15
+ readonly isIdle: () => boolean;
16
+ readonly check: () => Promise<ManagedScheduledCheckResult>;
17
+ readonly onUpdated: () => void;
18
+ readonly onError?: (error: unknown) => void;
19
+ readonly random?: () => number;
20
+ readonly setTimer?: (callback: () => void, delayMs: number) => NodeJS.Timeout;
21
+ readonly clearTimer?: (timer: NodeJS.Timeout) => void;
22
+ }
23
+
24
+ /**
25
+ * Availability-first automatic update loop. It never blocks boot, never even
26
+ * fetches while a task is active, rechecks idle at activation through the
27
+ * caller's `check`, and jitters every fleet-wide edge.
28
+ */
29
+ export class ManagedUpdateScheduler {
30
+ private timer: NodeJS.Timeout | null = null;
31
+ private running = false;
32
+ private stopped = false;
33
+ private pendingRestart = false;
34
+ private failureCount = 0;
35
+ private readonly random: () => number;
36
+ private readonly setTimer: NonNullable<ManagedUpdateSchedulerOptions["setTimer"]>;
37
+ private readonly clearTimer: NonNullable<ManagedUpdateSchedulerOptions["clearTimer"]>;
38
+
39
+ constructor(private readonly options: ManagedUpdateSchedulerOptions) {
40
+ this.random = options.random ?? Math.random;
41
+ this.setTimer = options.setTimer ?? ((callback, delayMs) => setTimeout(callback, delayMs));
42
+ this.clearTimer = options.clearTimer ?? clearTimeout;
43
+ }
44
+
45
+ start(): void {
46
+ if (this.stopped || this.timer || this.running) return;
47
+ this.schedule(MANAGED_UPDATE_INITIAL_DELAY_MS);
48
+ }
49
+
50
+ stop(): void {
51
+ this.stopped = true;
52
+ if (this.timer) this.clearTimer(this.timer);
53
+ this.timer = null;
54
+ }
55
+
56
+ private schedule(baseDelayMs: number): void {
57
+ if (this.stopped) return;
58
+ const delay = jitterDelay(baseDelayMs, this.random());
59
+ this.timer = this.setTimer(() => {
60
+ this.timer = null;
61
+ void this.run();
62
+ }, delay);
63
+ this.timer.unref?.();
64
+ }
65
+
66
+ private async run(): Promise<void> {
67
+ if (this.stopped || this.running) return;
68
+ this.running = true;
69
+ try {
70
+ if (!this.options.isIdle()) {
71
+ this.schedule(MANAGED_UPDATE_BUSY_RETRY_MS);
72
+ return;
73
+ }
74
+ if (this.pendingRestart) {
75
+ this.stop();
76
+ this.options.onUpdated();
77
+ return;
78
+ }
79
+ const result = await this.options.check();
80
+ if (this.stopped) return;
81
+ if (result === "updated") {
82
+ this.pendingRestart = true;
83
+ // Work may have appeared while the network/extraction pass awaited.
84
+ // This second synchronous check is immediately adjacent to the
85
+ // synchronous shutdown request, leaving no event-loop race between.
86
+ if (this.options.isIdle()) {
87
+ this.stop();
88
+ this.options.onUpdated();
89
+ } else {
90
+ this.schedule(MANAGED_UPDATE_BUSY_RETRY_MS);
91
+ }
92
+ return;
93
+ }
94
+ if (result === "not-managed") {
95
+ this.stop();
96
+ return;
97
+ }
98
+ this.failureCount = 0;
99
+ this.schedule(
100
+ result === "busy"
101
+ ? MANAGED_UPDATE_BUSY_RETRY_MS
102
+ : MANAGED_UPDATE_DAILY_DELAY_MS,
103
+ );
104
+ } catch (error) {
105
+ this.options.onError?.(error);
106
+ const backoff = Math.min(
107
+ MANAGED_UPDATE_FAILURE_RETRY_MS * 2 ** this.failureCount,
108
+ MANAGED_UPDATE_MAX_FAILURE_RETRY_MS,
109
+ );
110
+ this.failureCount += 1;
111
+ this.schedule(backoff);
112
+ } finally {
113
+ this.running = false;
114
+ }
115
+ }
116
+ }
117
+
118
+ export function jitterDelay(baseDelayMs: number, random: number): number {
119
+ if (!Number.isFinite(random) || random < 0 || random > 1) {
120
+ throw new Error("managed update jitter source must be between 0 and 1");
121
+ }
122
+ const factor = 1 - MANAGED_UPDATE_JITTER_FRACTION +
123
+ 2 * MANAGED_UPDATE_JITTER_FRACTION * random;
124
+ return Math.round(baseDelayMs * factor);
125
+ }
@@ -3,7 +3,8 @@
3
3
  * connections in front of task containers WITHOUT the credentials ever
4
4
  * entering them.
5
5
  *
6
- * Containers call `http://host.docker.internal:<port>/t/<token>/<slug>`; the
6
+ * Containers call `http://<gateway-host>:<port>/t/<token>/<slug>` (Docker:
7
+ * host.docker.internal; Apple: the vmnet gateway IP); the
7
8
  * gateway resolves the per-task token to an ACL file written at session
8
9
  * ensure (which connections this task may use), attaches the connection's
9
10
  * Authorization header host-side (refreshing OAuth tokens as needed), and
@@ -16,19 +17,117 @@
16
17
  * per-task token stays the auth).
17
18
  */
18
19
 
19
- import { appendFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
20
- import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
20
+ import {
21
+ appendFileSync,
22
+ lstatSync,
23
+ mkdirSync,
24
+ readFileSync,
25
+ rmSync,
26
+ writeFileSync,
27
+ } from "node:fs";
28
+ import {
29
+ createServer,
30
+ type IncomingMessage,
31
+ type Server,
32
+ type ServerResponse,
33
+ } from "node:http";
21
34
  import { randomBytes, timingSafeEqual } from "node:crypto";
22
35
  import { Readable, pipeline } from "node:stream";
23
36
  import { resolve } from "node:path";
24
37
 
25
38
  import { env } from "./env";
26
- import { dockerCli } from "./docker-exec";
39
+ import {
40
+ appleContainerRuntimeBinding,
41
+ pinnedContainerRuntimeProvider,
42
+ } from "./container-runtime";
43
+ import {
44
+ taskAppContainerName,
45
+ taskContainerBackend,
46
+ taskContainerCli,
47
+ } from "./task-container-cli";
27
48
  import { MCP_CONFIG_LOCK_PATH } from "./mcp-config-lock";
49
+ import {
50
+ RUNTIME_AUTHORITY_ENV,
51
+ runtimeAuthorityDockerExecArgs,
52
+ } from "./runtime-authority";
53
+ import type { TaskEnvironmentHandle } from "./task-environment/types";
28
54
  import { authHeaderFor, getConnection } from "./mcp-connections";
55
+ import { agentClisReady } from "./standard-image";
56
+ import { isSafeHostTaskId, assertSafeHostTaskId } from "./task-identity";
57
+ import type { McpGatewayCapability } from "../src/protocol";
29
58
 
30
- export const MCP_GATEWAY_PORT = Number(process.env.UAI_MCP_GATEWAY_PORT ?? 5877);
31
- const BIND = process.env.UAI_MCP_GATEWAY_BIND ?? "127.0.0.1";
59
+ export type { McpGatewayCapability } from "../src/protocol";
60
+
61
+ const DEFAULT_MCP_GATEWAY_PORT = 5877;
62
+
63
+ function gatewayPort(value: string | undefined): number {
64
+ if (value === undefined || !/^\d+$/.test(value)) {
65
+ return DEFAULT_MCP_GATEWAY_PORT;
66
+ }
67
+ const port = Number(value);
68
+ return Number.isSafeInteger(port) && port >= 1 && port <= 65_535
69
+ ? port
70
+ : DEFAULT_MCP_GATEWAY_PORT;
71
+ }
72
+
73
+ export const MCP_GATEWAY_PORT = gatewayPort(
74
+ process.env.UAI_MCP_GATEWAY_PORT,
75
+ );
76
+ /** Loopback for Docker (Desktop/OrbStack forward host.docker.internal to it).
77
+ * Apple vmnet guests cannot reach host loopback at all, so the apple backend
78
+ * defaults to all interfaces — the unguessable per-task path token remains
79
+ * the auth (same posture the operator override has always allowed). Resolved
80
+ * per call: the runtime pin lands before the production listener starts, and
81
+ * a pin never changes within a process. */
82
+ function gatewayBindAddress(): string {
83
+ const configured = process.env.UAI_MCP_GATEWAY_BIND;
84
+ if (configured) return configured;
85
+ // The pin, not the ready-gated identity: the production listener starts
86
+ // during boot while activation still reports `checking`, and a loopback
87
+ // bind chosen then would strand every vmnet guest.
88
+ return pinnedContainerRuntimeProvider() === "apple-container"
89
+ ? "0.0.0.0"
90
+ : "127.0.0.1";
91
+ }
92
+
93
+ /** Host address a task container dials to reach this gateway. Docker guests
94
+ * resolve host.docker.internal; Apple vmnet guests dial the vmnet gateway IP,
95
+ * read once per process from the bundled CLI's network inspection (the
96
+ * builtin `default` network the app containers join). A resolution failure
97
+ * throws so the config write fails visibly and is retried on the next session
98
+ * ensure, rather than baking a dead URL into the task. */
99
+ let cachedAppleGatewayHost: string | null = null;
100
+ async function taskGatewayHost(): Promise<string> {
101
+ if (pinnedContainerRuntimeProvider() !== "apple-container") {
102
+ return "host.docker.internal";
103
+ }
104
+ if (cachedAppleGatewayHost !== null) return cachedAppleGatewayHost;
105
+ const binding = appleContainerRuntimeBinding();
106
+ if (binding === null) {
107
+ throw new Error("apple-container selected without a bundled CLI binding");
108
+ }
109
+ const { execFile } = await import("node:child_process");
110
+ const stdout = await new Promise<string>((resolveExec, rejectExec) => {
111
+ execFile(
112
+ binding.containerCliPath,
113
+ ["network", "inspect", "default"],
114
+ { timeout: 15_000, maxBuffer: 256 * 1024 },
115
+ (error, out) => (error ? rejectExec(error) : resolveExec(out)),
116
+ );
117
+ });
118
+ const parsed = JSON.parse(stdout) as Array<{
119
+ status?: { ipv4Gateway?: unknown };
120
+ }>;
121
+ const gateway = parsed[0]?.status?.ipv4Gateway;
122
+ if (
123
+ typeof gateway !== "string" ||
124
+ !/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(gateway)
125
+ ) {
126
+ throw new Error("could not resolve the vmnet gateway address");
127
+ }
128
+ cachedAppleGatewayHost = gateway;
129
+ return gateway;
130
+ }
32
131
  /** MCP request bodies are small JSON-RPC frames; cap so audit parsing (and a
33
132
  * hostile container) can't balloon host memory. Responses stream freely. */
34
133
  const MAX_BODY_BYTES = 4 * 1024 * 1024;
@@ -37,6 +136,10 @@ const MAX_BODY_BYTES = 4 * 1024 * 1024;
37
136
  * streams open indefinitely, and an abort mid-pipe once took the whole host
38
137
  * process down as an unhandled Readable 'error' (2026-07-13). */
39
138
  const UPSTREAM_CONNECT_TIMEOUT_MS = 60_000;
139
+ const GATEWAY_RETRY_INITIAL_DELAY_MS = 1_000;
140
+ const GATEWAY_RETRY_MAX_DELAY_MS = 15_000;
141
+ const GATEWAY_RETRY_MIN_JITTER = 0.8;
142
+ const GATEWAY_RETRY_JITTER_SPAN = 0.4;
40
143
 
41
144
  export interface TaskMcpConnection {
42
145
  id: string;
@@ -84,6 +187,11 @@ export function ensureTaskGatewayAcl(
84
187
  }
85
188
 
86
189
  export function clearTaskGatewayAcl(taskId: string): void {
190
+ // Defense in depth: the id becomes a filesystem path component, and some
191
+ // callers derive it from container-influenced data (labels). A traversal
192
+ // like `../container-runtime-provider` must never resolve outside the ACL
193
+ // directory, so an unsafe id is a no-op here rather than a deletion.
194
+ if (!isSafeHostTaskId(taskId)) return;
87
195
  try {
88
196
  rmSync(aclPath(taskId));
89
197
  } catch {
@@ -91,6 +199,20 @@ export function clearTaskGatewayAcl(taskId: string): void {
91
199
  }
92
200
  }
93
201
 
202
+ /** Strict variant used only when acknowledging orphan-GC completion. */
203
+ export function clearTaskGatewayAclStrict(taskId: string): void {
204
+ assertSafeHostTaskId(taskId);
205
+ const path = aclPath(taskId);
206
+ rmSync(path, { force: true });
207
+ try {
208
+ lstatSync(path);
209
+ } catch (error) {
210
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
211
+ throw error;
212
+ }
213
+ throw new Error("task gateway ACL remains after removal");
214
+ }
215
+
94
216
  function audit(entry: Record<string, unknown>): void {
95
217
  try {
96
218
  const dir = resolve(env.dataDir, "logs");
@@ -256,17 +378,291 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
256
378
  }
257
379
  }
258
380
 
259
- /** Start the gateway listener. Idempotent-ish: call once from main. */
260
- export function startMcpGateway(): void {
261
- const server = createServer((req, res) => {
262
- void handle(req, res).catch(() => deny(res, 500, "gateway error"));
263
- });
264
- server.on("error", (err) => {
265
- console.warn(`[mcp-gateway] listener error: ${err.message}`);
266
- });
267
- server.listen(MCP_GATEWAY_PORT, BIND, () => {
268
- console.log(`[mcp-gateway] listening on ${BIND}:${MCP_GATEWAY_PORT}`);
381
+ export interface McpGatewayController {
382
+ /** Current bounded capability snapshot. Never contains a raw listener error. */
383
+ state(): McpGatewayCapability;
384
+ /** Subscribe to semantic state changes. The current snapshot is read via state(). */
385
+ subscribe(
386
+ listener: (state: McpGatewayCapability) => void,
387
+ ): () => void;
388
+ /** Permanently stop this controller and release its listener/timer. */
389
+ stop(): Promise<void>;
390
+ }
391
+
392
+ export interface McpGatewayListener extends McpGatewayController {
393
+ /** Start once; repeated calls before stop are idempotent. */
394
+ start(): McpGatewayController;
395
+ }
396
+
397
+ export interface McpGatewayListenerOptions {
398
+ port?: number;
399
+ bind?: string;
400
+ retryInitialDelayMs?: number;
401
+ retryMaxDelayMs?: number;
402
+ serverFactory?: (
403
+ requestListener: (req: IncomingMessage, res: ServerResponse) => void,
404
+ ) => Server;
405
+ setRetryTimeout?: (
406
+ callback: () => void,
407
+ delayMs: number,
408
+ ) => NodeJS.Timeout;
409
+ clearRetryTimeout?: (timer: NodeJS.Timeout) => void;
410
+ random?: () => number;
411
+ logger?: Pick<Console, "log" | "warn">;
412
+ }
413
+
414
+ function listenerFailureReason(
415
+ error: unknown,
416
+ ): "port_in_use" | "bind_failed" {
417
+ return (error as NodeJS.ErrnoException | null)?.code === "EADDRINUSE"
418
+ ? "port_in_use"
419
+ : "bind_failed";
420
+ }
421
+
422
+ function positiveInteger(value: number | undefined, fallback: number): number {
423
+ return typeof value === "number" && Number.isFinite(value) && value >= 1
424
+ ? Math.floor(value)
425
+ : fallback;
426
+ }
427
+
428
+ function sameGatewayState(
429
+ left: McpGatewayCapability,
430
+ right: McpGatewayCapability,
431
+ ): boolean {
432
+ return (
433
+ left.status === right.status &&
434
+ left.port === right.port &&
435
+ (left.status !== "down" ||
436
+ (right.status === "down" && left.reason === right.reason))
437
+ );
438
+ }
439
+
440
+ /**
441
+ * Construct one restart-safe MCP gateway listener. Exported so listener
442
+ * lifecycle tests can use deterministic fake servers without opening a real
443
+ * host port; production uses the singleton wrapper below.
444
+ */
445
+ export function createMcpGatewayListener(
446
+ options: McpGatewayListenerOptions = {},
447
+ ): McpGatewayListener {
448
+ const port =
449
+ Number.isSafeInteger(options.port) &&
450
+ (options.port ?? 0) >= 1 &&
451
+ (options.port ?? 0) <= 65_535
452
+ ? (options.port as number)
453
+ : MCP_GATEWAY_PORT;
454
+ const bind = options.bind ?? gatewayBindAddress();
455
+ const retryInitialDelayMs = positiveInteger(
456
+ options.retryInitialDelayMs,
457
+ GATEWAY_RETRY_INITIAL_DELAY_MS,
458
+ );
459
+ const retryMaxDelayMs = Math.max(
460
+ retryInitialDelayMs,
461
+ positiveInteger(
462
+ options.retryMaxDelayMs,
463
+ GATEWAY_RETRY_MAX_DELAY_MS,
464
+ ),
465
+ );
466
+ const serverFactory = options.serverFactory ?? createServer;
467
+ const setRetryTimeout =
468
+ options.setRetryTimeout ??
469
+ ((callback: () => void, delayMs: number) =>
470
+ setTimeout(callback, delayMs));
471
+ const clearRetryTimeout = options.clearRetryTimeout ?? clearTimeout;
472
+ const random = options.random ?? Math.random;
473
+ const logger = options.logger ?? console;
474
+ const subscribers = new Set<
475
+ (state: McpGatewayCapability) => void
476
+ >();
477
+
478
+ let capability: McpGatewayCapability = Object.freeze({
479
+ status: "starting",
480
+ port,
269
481
  });
482
+ let server: Server | null = null;
483
+ let retryTimer: NodeJS.Timeout | null = null;
484
+ let consecutiveFailures = 0;
485
+ let started = false;
486
+ let stopped = false;
487
+ let stopPromise: Promise<void> | null = null;
488
+
489
+ const setState = (next: McpGatewayCapability): void => {
490
+ const frozen = Object.freeze(next);
491
+ if (sameGatewayState(capability, frozen)) return;
492
+ capability = frozen;
493
+ for (const subscriber of subscribers) {
494
+ try {
495
+ subscriber(capability);
496
+ } catch {
497
+ // Capability observers must never take down the host listener.
498
+ }
499
+ }
500
+ };
501
+
502
+ const closeServer = (target: Server): void => {
503
+ try {
504
+ target.close(() => undefined);
505
+ target.closeAllConnections?.();
506
+ } catch {
507
+ // A listener that failed before binding has nothing left to close.
508
+ }
509
+ };
510
+
511
+ const retryDelay = (): number => {
512
+ const exponent = Math.min(Math.max(consecutiveFailures - 1, 0), 30);
513
+ const baseDelayMs = Math.min(
514
+ retryMaxDelayMs,
515
+ retryInitialDelayMs * 2 ** exponent,
516
+ );
517
+ const sample = random();
518
+ const boundedSample = Number.isFinite(sample)
519
+ ? Math.min(1, Math.max(0, sample))
520
+ : 0.5;
521
+ return Math.min(
522
+ retryMaxDelayMs,
523
+ Math.max(
524
+ 1,
525
+ Math.round(
526
+ baseDelayMs *
527
+ (GATEWAY_RETRY_MIN_JITTER +
528
+ GATEWAY_RETRY_JITTER_SPAN * boundedSample),
529
+ ),
530
+ ),
531
+ );
532
+ };
533
+
534
+ let beginAttempt: () => void;
535
+
536
+ const scheduleRetry = (
537
+ reason: "port_in_use" | "bind_failed",
538
+ ): void => {
539
+ if (stopped || retryTimer !== null) return;
540
+ consecutiveFailures += 1;
541
+ setState({ status: "down", port, reason });
542
+ const delayMs = retryDelay();
543
+ logger.warn(
544
+ `[mcp-gateway] listener down (${reason}); retrying in ${delayMs}ms`,
545
+ );
546
+ retryTimer = setRetryTimeout(() => {
547
+ retryTimer = null;
548
+ beginAttempt();
549
+ }, delayMs);
550
+ retryTimer.unref();
551
+ };
552
+
553
+ const listenerFailed = (target: Server, error: unknown): void => {
554
+ if (stopped || server !== target) return;
555
+ server = null;
556
+ closeServer(target);
557
+ scheduleRetry(listenerFailureReason(error));
558
+ };
559
+
560
+ beginAttempt = (): void => {
561
+ if (stopped || server !== null || retryTimer !== null) return;
562
+
563
+ let nextServer: Server;
564
+ try {
565
+ nextServer = serverFactory((req, res) => {
566
+ void handle(req, res).catch(() => deny(res, 500, "gateway error"));
567
+ });
568
+ } catch {
569
+ scheduleRetry("bind_failed");
570
+ return;
571
+ }
572
+ server = nextServer;
573
+
574
+ nextServer.on("error", (error) => {
575
+ listenerFailed(nextServer, error);
576
+ });
577
+ nextServer.on("listening", () => {
578
+ if (stopped || server !== nextServer) {
579
+ closeServer(nextServer);
580
+ return;
581
+ }
582
+ consecutiveFailures = 0;
583
+ setState({ status: "ready", port });
584
+ logger.log(`[mcp-gateway] listening on ${bind}:${port}`);
585
+ });
586
+ nextServer.on("close", () => {
587
+ if (stopped || server !== nextServer) return;
588
+ server = null;
589
+ scheduleRetry("bind_failed");
590
+ });
591
+
592
+ try {
593
+ nextServer.listen(port, bind);
594
+ } catch (error) {
595
+ listenerFailed(nextServer, error);
596
+ }
597
+ };
598
+
599
+ const controller: McpGatewayListener = {
600
+ state: () => capability,
601
+ subscribe: (listener) => {
602
+ subscribers.add(listener);
603
+ return () => subscribers.delete(listener);
604
+ },
605
+ start: () => {
606
+ if (stopped) {
607
+ throw new Error("MCP gateway listener cannot restart after stop");
608
+ }
609
+ if (!started) {
610
+ started = true;
611
+ beginAttempt();
612
+ }
613
+ return controller;
614
+ },
615
+ stop: () => {
616
+ if (stopPromise) return stopPromise;
617
+ stopped = true;
618
+ if (retryTimer !== null) {
619
+ clearRetryTimeout(retryTimer);
620
+ retryTimer = null;
621
+ }
622
+ const activeServer = server;
623
+ server = null;
624
+ stopPromise = new Promise<void>((resolveStop) => {
625
+ if (activeServer === null) {
626
+ resolveStop();
627
+ return;
628
+ }
629
+ let resolved = false;
630
+ const finish = (): void => {
631
+ if (resolved) return;
632
+ resolved = true;
633
+ resolveStop();
634
+ };
635
+ activeServer.once("close", finish);
636
+ try {
637
+ activeServer.close(finish);
638
+ activeServer.closeAllConnections?.();
639
+ if (!activeServer.listening) queueMicrotask(finish);
640
+ } catch {
641
+ finish();
642
+ }
643
+ }).finally(() => {
644
+ subscribers.clear();
645
+ });
646
+ return stopPromise;
647
+ },
648
+ };
649
+ return controller;
650
+ }
651
+
652
+ let gatewayController: McpGatewayListener | null = null;
653
+
654
+ /** Start (or return) the process-wide gateway listener. */
655
+ export function startMcpGateway(): McpGatewayController {
656
+ gatewayController ??= createMcpGatewayListener();
657
+ return gatewayController.start();
658
+ }
659
+
660
+ /** Stop and reset the process-wide listener (also the deterministic test seam). */
661
+ export async function stopMcpGateway(): Promise<void> {
662
+ const active = gatewayController;
663
+ if (active === null) return;
664
+ await active.stop();
665
+ if (gatewayController === active) gatewayController = null;
270
666
  }
271
667
 
272
668
  // --- task container wiring (ADR-057 task-up writers) -------------------------
@@ -663,11 +1059,16 @@ export async function setupMcpTaskConfig(
663
1059
  connections: TaskMcpConnection[],
664
1060
  engineKinds: string[],
665
1061
  codexHomes: readonly string[] = ["/home/node/.codex"],
1062
+ environment?: Pick<TaskEnvironmentHandle, "exec">,
666
1063
  ): Promise<boolean> {
667
1064
  // No early return on empty: the claude adapter passes
668
1065
  // `--mcp-config /workspace/.mcp.json` unconditionally (ADR-057), so the
669
1066
  // file must exist — an empty mcpServers map — even with no connections.
670
1067
  try {
1068
+ // Boot-time standard-image maintenance can briefly replace the shared CLI
1069
+ // shims used by these in-container writers. The ACL is itself observable
1070
+ // state, so hold the same barrier before it or any Docker side effect.
1071
+ await agentClisReady;
671
1072
  const has = (kind: string): boolean => engineKinds.includes(kind);
672
1073
  if (
673
1074
  has("codex") &&
@@ -679,8 +1080,9 @@ export async function setupMcpTaskConfig(
679
1080
  throw new Error("invalid Codex MCP connection slug");
680
1081
  }
681
1082
  const acl = ensureTaskGatewayAcl(taskId, connections);
1083
+ const gatewayHost = await taskGatewayHost();
682
1084
  const urlFor = (slug: string): string =>
683
- `http://host.docker.internal:${MCP_GATEWAY_PORT}/t/${acl.token}/${slug}`;
1085
+ `http://${gatewayHost}:${MCP_GATEWAY_PORT}/t/${acl.token}/${slug}`;
684
1086
 
685
1087
  const claudeEntries: Record<string, unknown> = {};
686
1088
  const codexEntries: Record<string, string> = {};
@@ -755,13 +1157,38 @@ export async function setupMcpTaskConfig(
755
1157
  const steps =
756
1158
  `timeout --kill-after=${MCP_CONFIG_WRITE_KILL_AFTER_S} ` +
757
1159
  `${MCP_CONFIG_WRITE_TIMEOUT_S} sh -lc ${shellQuote(writes)}`;
758
- const result = await dockerCli(
759
- ["exec", containerName, "sh", "-lc", steps],
760
- { timeoutMs: MCP_CONFIG_HOST_TIMEOUT_MS },
761
- );
762
- if (result.status !== 0) {
1160
+ const result = environment
1161
+ ? await environment.exec({
1162
+ argv: ["sh", "-lc", steps],
1163
+ env: RUNTIME_AUTHORITY_ENV,
1164
+ timeoutMs: MCP_CONFIG_HOST_TIMEOUT_MS,
1165
+ maxOutputBytes: 256 * 1024,
1166
+ })
1167
+ : await taskContainerCli(
1168
+ [
1169
+ "exec",
1170
+ ...runtimeAuthorityDockerExecArgs(),
1171
+ // Backend-aware fallback: the passed name is the Docker replica
1172
+ // shape and `docker` does not exist on an apple host (live
1173
+ // 2026-08-18: spawn docker ENOENT killed every no-handle config
1174
+ // write, so sessions never saw their MCP tools).
1175
+ taskContainerBackend().apple
1176
+ ? taskAppContainerName(taskId)
1177
+ : containerName,
1178
+ "sh",
1179
+ "-lc",
1180
+ steps,
1181
+ ],
1182
+ { timeoutMs: MCP_CONFIG_HOST_TIMEOUT_MS },
1183
+ );
1184
+ const status = "exitCode" in result ? result.exitCode : result.status;
1185
+ const stderr =
1186
+ "stderr" in result && result.stderr instanceof Uint8Array
1187
+ ? Buffer.from(result.stderr).toString("utf8")
1188
+ : result.stderr;
1189
+ if (status !== 0) {
763
1190
  console.warn(
764
- `[mcp-gateway] task ${taskId}: config write failed: ${result.stderr.slice(0, 300)}`,
1191
+ `[mcp-gateway] task ${taskId}: config write failed: ${stderr.slice(0, 300)}`,
765
1192
  );
766
1193
  return false;
767
1194
  }