@runuai/host 0.9.147 → 0.9.149

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 (98) hide show
  1. package/db/migrations/0024_task_env_mode.sql +9 -0
  2. package/db/migrations/0025_project_env_authority.sql +147 -0
  3. package/db/migrations/0026_project_env_replica.sql +150 -0
  4. package/db/migrations/meta/_journal.json +21 -0
  5. package/db/schema.ts +312 -2
  6. package/images/standard/Dockerfile +44 -6
  7. package/images/standard/container/uai-init +85 -2
  8. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  9. package/lib/agent-cli.ts +18 -1
  10. package/lib/agent.ts +99 -34
  11. package/lib/agents/attach-compatibility.ts +51 -0
  12. package/lib/agents/codex.ts +137 -13
  13. package/lib/agents/cursor.ts +15 -1
  14. package/lib/agents/durable-proc.ts +2 -0
  15. package/lib/agents/grok.ts +8 -0
  16. package/lib/agents/kimi.ts +1 -0
  17. package/lib/agents/machine-durable.ts +17 -0
  18. package/lib/agents/opencode.ts +1 -0
  19. package/lib/agents/proc.ts +6 -1
  20. package/lib/agents/session-state.ts +107 -0
  21. package/lib/agents/transport.ts +125 -58
  22. package/lib/agents/types.ts +3 -3
  23. package/lib/browser-testing.ts +6 -0
  24. package/lib/codex-auth.ts +2 -0
  25. package/lib/credential-store.ts +141 -6
  26. package/lib/engine-accounts.ts +1 -0
  27. package/lib/env-file.ts +7 -0
  28. package/lib/env-schema.ts +841 -51
  29. package/lib/git-diff.ts +1 -0
  30. package/lib/git-identity.ts +2 -0
  31. package/lib/github-tokens.ts +1 -0
  32. package/lib/host-env.ts +98 -18
  33. package/lib/lease.ts +11 -3
  34. package/lib/machine-gateway-tunnel.ts +195 -17
  35. package/lib/machine-project-env-loader.ts +152 -0
  36. package/lib/managed-runtime.ts +270 -51
  37. package/lib/mcp-gateway.ts +777 -26
  38. package/lib/orchestrator.ts +1681 -138
  39. package/lib/project-env-authority.ts +1319 -0
  40. package/lib/project-env-feature.ts +74 -0
  41. package/lib/project-env-loader.ts +237 -0
  42. package/lib/project-env-replica.ts +2094 -0
  43. package/lib/project-env-schema-capture.ts +342 -0
  44. package/lib/project-env-staging.ts +148 -0
  45. package/lib/project-env-task-policy.ts +176 -0
  46. package/lib/runtime-archive.ts +79 -16
  47. package/lib/runtime-authority.ts +44 -0
  48. package/lib/runtime-state.ts +175 -10
  49. package/lib/secrets-proxy/address.ts +72 -0
  50. package/lib/secrets-proxy/audit.ts +30 -0
  51. package/lib/secrets-proxy/ca-store.ts +200 -0
  52. package/lib/secrets-proxy/ca.ts +470 -0
  53. package/lib/secrets-proxy/container-steps.ts +138 -0
  54. package/lib/secrets-proxy/der.ts +273 -0
  55. package/lib/secrets-proxy/engine.ts +1633 -0
  56. package/lib/secrets-proxy/host.ts +93 -0
  57. package/lib/secrets-proxy/hostname.ts +60 -0
  58. package/lib/secrets-proxy/placeholders.ts +196 -0
  59. package/lib/secrets-proxy/policy.ts +141 -0
  60. package/lib/secrets-proxy/readiness.ts +148 -0
  61. package/lib/secrets-proxy/runtime.ts +582 -0
  62. package/lib/secrets-proxy/scrub.ts +559 -0
  63. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  64. package/lib/secrets-proxy/task-scripts.ts +262 -0
  65. package/lib/secrets-proxy/task-trust.ts +574 -0
  66. package/lib/secrets-proxy/trust-watch.ts +80 -0
  67. package/lib/shared-files-s3.ts +3 -0
  68. package/lib/sidecar-guard.ts +58 -0
  69. package/lib/sidecar-resume.ts +51 -3
  70. package/lib/standard-image.ts +50 -12
  71. package/lib/task-env-forwarder.ts +228 -0
  72. package/lib/task-env-source.ts +193 -0
  73. package/lib/task-environment/apple-container.ts +45 -11
  74. package/lib/task-environment/docker.ts +51 -12
  75. package/lib/task-environment/index.ts +6 -2
  76. package/lib/task-environment/machine-task-up.ts +258 -25
  77. package/lib/task-environment/machine.ts +80 -22
  78. package/lib/task-environment/registry.ts +3 -2
  79. package/lib/task-environment/self-identity.ts +20 -0
  80. package/lib/task-environment/self.ts +92 -16
  81. package/lib/task-environment/types.ts +63 -0
  82. package/lib/task-gateway-port.ts +22 -0
  83. package/lib/task-gateway-protocol.ts +190 -0
  84. package/lib/task-gateway-recovery.ts +44 -0
  85. package/lib/task-gateway-state.ts +497 -0
  86. package/lib/transcript.ts +1 -0
  87. package/lib/ulid.ts +10 -1
  88. package/package.json +13 -1
  89. package/scripts/agent/env-hook.sh +626 -0
  90. package/scripts/agent/task-up.sh +476 -27
  91. package/scripts/agent/uai-env-exec.sh +26 -0
  92. package/src/cli.ts +26 -3
  93. package/src/index.ts +273 -19
  94. package/src/main.ts +941 -48
  95. package/src/project-env-latest.ts +130 -0
  96. package/src/project-env-policy.ts +281 -0
  97. package/src/project-env-wording.ts +47 -0
  98. package/src/protocol.ts +2239 -15
@@ -26,12 +26,17 @@ import {
26
26
  writeFileSync,
27
27
  } from "node:fs";
28
28
  import {
29
- createServer,
29
+ createServer as createHttpServer,
30
30
  type IncomingMessage,
31
- type Server,
31
+ type Server as HttpServer,
32
32
  type ServerResponse,
33
33
  } from "node:http";
34
34
  import { randomBytes, timingSafeEqual } from "node:crypto";
35
+ import {
36
+ createServer as createNetServer,
37
+ type Server as NetServer,
38
+ type Socket,
39
+ } from "node:net";
35
40
  import { Readable, pipeline } from "node:stream";
36
41
  import { resolve } from "node:path";
37
42
 
@@ -56,24 +61,31 @@ import { agentClisReady } from "./standard-image";
56
61
  import { isSafeHostTaskId, assertSafeHostTaskId } from "./task-identity";
57
62
  import type { McpGatewayCapability } from "../src/protocol";
58
63
  import { isEnvironmentTransportProvider } from "./task-environment/types";
64
+ import { sidecarTaskId } from "./sidecar";
65
+ import {
66
+ TASK_FORWARDER_ACCEPT_MAGIC,
67
+ TASK_FORWARDER_MAX_PREAMBLE_BYTES,
68
+ TASK_FORWARDER_PREAMBLE_HEADER_BYTES,
69
+ TASK_FORWARDER_RESPONSE_BYTES,
70
+ decodeTaskForwarderPreamble,
71
+ decodeTaskForwarderResponse,
72
+ encodeTaskForwarderChallenge,
73
+ taskForwarderPreambleLength,
74
+ type TaskForwarderIdentity,
75
+ } from "./task-gateway-protocol";
76
+ import {
77
+ currentTaskGatewayRegistration,
78
+ isTaskGatewayGenerationCurrent,
79
+ resolveTaskGatewayEnv,
80
+ subscribeTaskGatewayRevocations,
81
+ verifyTaskGatewayAuthentication,
82
+ } from "./task-gateway-state";
83
+ import { MCP_GATEWAY_PORT } from "./task-gateway-port";
59
84
 
60
85
  export type { McpGatewayCapability } from "../src/protocol";
61
86
 
62
- const DEFAULT_MCP_GATEWAY_PORT = 5877;
63
-
64
- function gatewayPort(value: string | undefined): number {
65
- if (value === undefined || !/^\d+$/.test(value)) {
66
- return DEFAULT_MCP_GATEWAY_PORT;
67
- }
68
- const port = Number(value);
69
- return Number.isSafeInteger(port) && port >= 1 && port <= 65_535
70
- ? port
71
- : DEFAULT_MCP_GATEWAY_PORT;
72
- }
87
+ export { MCP_GATEWAY_PORT };
73
88
 
74
- export const MCP_GATEWAY_PORT = gatewayPort(
75
- process.env.UAI_MCP_GATEWAY_PORT,
76
- );
77
89
  /** Loopback works only where the container runtime forwards
78
90
  * host.docker.internal INTO the host loopback — macOS Docker
79
91
  * (Desktop/OrbStack). Native Linux docker resolves host.docker.internal to
@@ -102,7 +114,7 @@ function gatewayBindAddress(): string {
102
114
  * throws so the config write fails visibly and is retried on the next session
103
115
  * ensure, rather than baking a dead URL into the task. */
104
116
  let cachedAppleGatewayHost: string | null = null;
105
- async function taskGatewayHost(): Promise<string> {
117
+ export async function taskGatewayHost(): Promise<string> {
106
118
  if (pinnedContainerRuntimeProvider() !== "apple-container") {
107
119
  return "host.docker.internal";
108
120
  }
@@ -145,6 +157,21 @@ const GATEWAY_RETRY_INITIAL_DELAY_MS = 1_000;
145
157
  const GATEWAY_RETRY_MAX_DELAY_MS = 15_000;
146
158
  const GATEWAY_RETRY_MIN_JITTER = 0.8;
147
159
  const GATEWAY_RETRY_JITTER_SPAN = 0.4;
160
+ const TASK_GATEWAY_PREAUTH_TIMEOUT_MS = 3_000;
161
+ const TASK_GATEWAY_MAX_PENDING_PREAUTH = 128;
162
+ const TASK_GATEWAY_MAX_PENDING_PREAUTH_PER_PEER = 16;
163
+ const TASK_GATEWAY_MAX_PENDING_PREAUTH_PER_TASK = 16;
164
+ // MCP streamable-HTTP/SSE and proxied WebSockets can legitimately hold a
165
+ // connection for the life of a session, so active limits are deliberately
166
+ // separate from the tight three-second handshake limits above. They are still
167
+ // finite: once a socket has supplied its first application byte, HTTP's header
168
+ // and request deadlines bound time, while these counters bound cardinality.
169
+ const TASK_GATEWAY_MAX_ACTIVE_CONNECTIONS = 2_048;
170
+ // Direct sockets have not proved which task they belong to yet. Treat this as
171
+ // a small, evictable staging allowance; tokenized MCP moves to the task quota
172
+ // as soon as Node parses and validates the request path.
173
+ const TASK_GATEWAY_MAX_DIRECT_STAGING_CONNECTIONS_PER_PEER = 16;
174
+ const TASK_GATEWAY_MAX_ACTIVE_CONNECTIONS_PER_TASK = 256;
148
175
 
149
176
  export interface TaskMcpConnection {
150
177
  id: string;
@@ -276,7 +303,82 @@ const FORWARD_REQ_HEADERS = [
276
303
  ];
277
304
  const FORWARD_RES_HEADERS = ["content-type", "mcp-session-id"];
278
305
 
279
- async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
306
+ export interface AuthenticatedTaskGatewayConnection
307
+ extends TaskForwarderIdentity {
308
+ readonly socket: Socket;
309
+ /** Re-check immediately before using task authority. */
310
+ readonly isCurrent: () => boolean;
311
+ }
312
+
313
+ export type TaskGatewayTlsHandler = (
314
+ connection: AuthenticatedTaskGatewayConnection,
315
+ ) => void;
316
+
317
+ let taskGatewayTlsHandler: TaskGatewayTlsHandler | null = null;
318
+
319
+ /**
320
+ * Register the TLS consumer used by the ADR-153 secrets proxy. There is one
321
+ * gateway and therefore one consumer; a second registration is a wiring bug.
322
+ */
323
+ export function registerTaskGatewayTlsHandler(
324
+ handler: TaskGatewayTlsHandler,
325
+ ): () => void {
326
+ if (taskGatewayTlsHandler !== null) {
327
+ throw new Error("task gateway TLS handler is already registered");
328
+ }
329
+ taskGatewayTlsHandler = handler;
330
+ return () => {
331
+ if (taskGatewayTlsHandler === handler) taskGatewayTlsHandler = null;
332
+ };
333
+ }
334
+
335
+ function loopbackPeer(socket: Socket): boolean {
336
+ const address = socket.remoteAddress ?? "";
337
+ return (
338
+ address === "::1" ||
339
+ /^127(?:\.\d{1,3}){3}$/.test(address) ||
340
+ /^::ffff:127(?:\.\d{1,3}){3}$/i.test(address)
341
+ );
342
+ }
343
+
344
+ async function handleEnvRequest(
345
+ req: IncomingMessage,
346
+ res: ServerResponse,
347
+ identity: TaskForwarderIdentity | null,
348
+ ): Promise<void> {
349
+ // Keep the task hook's deliberately tiny response grammar stable. Node adds
350
+ // Date by default, but the response is local, non-cacheable, and has no use
351
+ // for a wall-clock header.
352
+ res.sendDate = false;
353
+ if (identity === null) return deny(res, 404, "not found");
354
+ if (req.method !== "GET") {
355
+ res.setHeader("allow", "GET");
356
+ return deny(res, 405, "method not allowed");
357
+ }
358
+ const snapshot = resolveTaskGatewayEnv(identity);
359
+ if (snapshot === null) return deny(res, 503, "task env unavailable");
360
+ const headers = {
361
+ etag: snapshot.etag,
362
+ "cache-control": "no-store",
363
+ };
364
+ if (req.headers["if-none-match"] === snapshot.etag) {
365
+ res.writeHead(304, { ...headers, "content-length": "0" });
366
+ res.end();
367
+ return;
368
+ }
369
+ res.writeHead(200, {
370
+ ...headers,
371
+ "content-type": "text/plain; charset=utf-8",
372
+ "content-length": String(snapshot.body.length),
373
+ });
374
+ res.end(snapshot.body);
375
+ }
376
+
377
+ async function handleMcpRequest(
378
+ req: IncomingMessage,
379
+ res: ServerResponse,
380
+ claimTaskQuota: (socket: Socket, taskId: string) => boolean,
381
+ ): Promise<void> {
280
382
  const match = /^\/t\/([^/]+)\/([^/?]+)\/?(?:\?.*)?$/.exec(req.url ?? "");
281
383
  if (!match) return deny(res, 404, "not found");
282
384
  const [, token, slug] = match as unknown as [string, string, string];
@@ -293,6 +395,16 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
293
395
  ) {
294
396
  return deny(res, 401, "unknown task token");
295
397
  }
398
+ // A direct connection is admitted only to an evictable peer staging pool.
399
+ // Once its unguessable ACL token proves the task, move the close-scoped
400
+ // reservation into that task's quota before using any task authority. An
401
+ // authenticated forwarder is already in this quota, and must name the same
402
+ // task if it sends an MCP request over its connection.
403
+ if (!claimTaskQuota(req.socket, taskId)) {
404
+ res.shouldKeepAlive = false;
405
+ res.setHeader("connection", "close");
406
+ return deny(res, 429, "task connection limit reached");
407
+ }
296
408
  const entry = acl.connections.find((c) => c.slug === slug);
297
409
  if (!entry) return deny(res, 404, `no connection "${slug}" for this task`);
298
410
  const conn = getConnection(entry.id);
@@ -383,9 +495,482 @@ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void>
383
495
  }
384
496
  }
385
497
 
498
+ async function handleGatewayRequest(
499
+ req: IncomingMessage,
500
+ res: ServerResponse,
501
+ authenticatedSockets: WeakMap<Socket, TaskForwarderIdentity>,
502
+ claimTaskQuota: (socket: Socket, taskId: string) => boolean,
503
+ ): Promise<void> {
504
+ const pathname = (() => {
505
+ try {
506
+ return new URL(req.url ?? "", "http://uai.invalid").pathname;
507
+ } catch {
508
+ return "";
509
+ }
510
+ })();
511
+ if (pathname === "/env" && req.url === "/env") {
512
+ const socket = req.socket;
513
+ let identity = authenticatedSockets.get(socket) ?? null;
514
+ if (identity === null) {
515
+ const fixedTaskId = sidecarTaskId();
516
+ if (fixedTaskId !== null && loopbackPeer(socket)) {
517
+ identity = currentTaskGatewayRegistration(fixedTaskId);
518
+ }
519
+ }
520
+ await handleEnvRequest(req, res, identity);
521
+ return;
522
+ }
523
+ await handleMcpRequest(req, res, claimTaskQuota);
524
+ }
525
+
526
+ type GatewayTransportServer = NetServer & {
527
+ closeAllConnections?: () => void;
528
+ };
529
+
530
+ function readSocketBytes(
531
+ socket: Socket,
532
+ length: number,
533
+ deadline: number,
534
+ ): Promise<Buffer> {
535
+ return new Promise((resolveRead, rejectRead) => {
536
+ let settled = false;
537
+ const finish = (error: Error | null, bytes?: Buffer): void => {
538
+ if (settled) return;
539
+ settled = true;
540
+ clearTimeout(timer);
541
+ socket.off("readable", onReadable);
542
+ socket.off("error", onError);
543
+ socket.off("end", onEnd);
544
+ socket.off("close", onClose);
545
+ if (error) rejectRead(error);
546
+ else if (bytes) resolveRead(bytes);
547
+ else rejectRead(new Error("task gateway stream ended"));
548
+ };
549
+ const tryRead = (): void => {
550
+ const bytes = socket.read(length) as Buffer | null;
551
+ if (bytes === null) return;
552
+ if (bytes.length !== length) {
553
+ finish(new Error("truncated task gateway frame"));
554
+ return;
555
+ }
556
+ finish(null, bytes);
557
+ };
558
+ const onReadable = (): void => tryRead();
559
+ const onError = (): void => finish(new Error("task gateway socket error"));
560
+ const onEnd = (): void => {
561
+ tryRead();
562
+ if (!settled) finish(new Error("truncated task gateway frame"));
563
+ };
564
+ const onClose = (): void => finish(new Error("task gateway socket closed"));
565
+ const remainingMs = Math.max(1, deadline - Date.now());
566
+ const timer = setTimeout(
567
+ () => finish(new Error("task gateway pre-auth timed out")),
568
+ remainingMs,
569
+ );
570
+ timer.unref();
571
+ socket.on("readable", onReadable);
572
+ socket.once("error", onError);
573
+ socket.once("end", onEnd);
574
+ socket.once("close", onClose);
575
+ tryRead();
576
+ });
577
+ }
578
+
579
+ interface TaskGatewayDemuxOptions {
580
+ readonly httpServer: HttpServer;
581
+ readonly authenticatedSockets: WeakMap<Socket, TaskForwarderIdentity>;
582
+ readonly authenticatedConnections: Map<Socket, TaskForwarderIdentity>;
583
+ readonly preauthTimeoutMs: number;
584
+ readonly maxPendingPreauth: number;
585
+ readonly maxPendingPreauthPerPeer: number;
586
+ readonly maxPendingPreauthPerTask: number;
587
+ readonly maxActiveConnections: number;
588
+ readonly maxDirectStagingConnectionsPerPeer: number;
589
+ readonly maxActiveConnectionsPerTask: number;
590
+ readonly nonce: () => Buffer;
591
+ readonly tlsHandler: () => TaskGatewayTlsHandler | null;
592
+ }
593
+
594
+ interface AuthenticatedTaskForwarder {
595
+ readonly identity: TaskForwarderIdentity;
596
+ /** Release the task-scoped pre-auth reservation exactly once. */
597
+ readonly release: () => void;
598
+ }
599
+
600
+ function createTaskGatewayDemux(options: TaskGatewayDemuxOptions): {
601
+ accept: (socket: Socket) => void;
602
+ claimTaskQuota: (socket: Socket, taskId: string) => boolean;
603
+ } {
604
+ let pendingPreauth = 0;
605
+ const pendingPeerBySocket = new Map<Socket, string>();
606
+ const pendingSocketsByPeer = new Map<string, Set<Socket>>();
607
+ const pendingByTask = new Map<string, number>();
608
+ let activeConnections = 0;
609
+ const activeScopeBySocket = new Map<
610
+ Socket,
611
+ { kind: "peer" | "task"; key: string }
612
+ >();
613
+ const activeSocketsByPeer = new Map<string, Set<Socket>>();
614
+ const activeSocketsByTask = new Map<string, Set<Socket>>();
615
+ const activeUnauthenticatedSockets = new Set<Socket>();
616
+
617
+ const peerKey = (socket: Socket): string => {
618
+ if (loopbackPeer(socket)) return "loopback";
619
+ const address = (socket.remoteAddress ?? "unknown").toLowerCase();
620
+ return address.startsWith("::ffff:") ? address.slice(7) : address;
621
+ };
622
+
623
+ const releasePreauth = (socket: Socket): void => {
624
+ const peer = pendingPeerBySocket.get(socket);
625
+ if (peer === undefined) return;
626
+ pendingPeerBySocket.delete(socket);
627
+ pendingPreauth -= 1;
628
+ const peerSockets = pendingSocketsByPeer.get(peer);
629
+ peerSockets?.delete(socket);
630
+ if (peerSockets?.size === 0) pendingSocketsByPeer.delete(peer);
631
+ };
632
+
633
+ const reservePreauth = (socket: Socket, peer: string): void => {
634
+ pendingPreauth += 1;
635
+ pendingPeerBySocket.set(socket, peer);
636
+ const peerSockets = pendingSocketsByPeer.get(peer) ?? new Set<Socket>();
637
+ peerSockets.add(socket);
638
+ pendingSocketsByPeer.set(peer, peerSockets);
639
+ };
640
+
641
+ const releaseActive = (socket: Socket): void => {
642
+ const scope = activeScopeBySocket.get(socket);
643
+ if (scope === undefined) return;
644
+ activeScopeBySocket.delete(socket);
645
+ activeConnections -= 1;
646
+ const scopedSockets =
647
+ scope.kind === "task"
648
+ ? activeSocketsByTask.get(scope.key)
649
+ : activeSocketsByPeer.get(scope.key);
650
+ scopedSockets?.delete(socket);
651
+ if (scope.kind === "peer") activeUnauthenticatedSockets.delete(socket);
652
+ if (scopedSockets?.size === 0) {
653
+ if (scope.kind === "task") activeSocketsByTask.delete(scope.key);
654
+ else activeSocketsByPeer.delete(scope.key);
655
+ }
656
+ };
657
+
658
+ const evictUnauthenticated = (socket: Socket): void => {
659
+ // Release synchronously: Socket's close event is asynchronous and the
660
+ // replacement must be able to reserve the slot in this turn.
661
+ releaseActive(socket);
662
+ socket.destroy();
663
+ };
664
+
665
+ /** Transfer one fully-demultiplexed socket into a close-scoped quota.
666
+ * Forwarder-authenticated traffic and fixed-sidecar traffic are charged to
667
+ * their proved task. Direct HTTP starts in an evictable peer staging pool;
668
+ * a valid MCP token moves it to the non-evictable task pool below. */
669
+ const reserveActive = (
670
+ socket: Socket,
671
+ scope: { kind: "peer" | "task"; key: string },
672
+ ): boolean => {
673
+ if (activeScopeBySocket.has(socket)) return true;
674
+ const scopedSockets =
675
+ scope.kind === "task"
676
+ ? activeSocketsByTask.get(scope.key)
677
+ : activeSocketsByPeer.get(scope.key);
678
+ const scopedLimit =
679
+ scope.kind === "task"
680
+ ? options.maxActiveConnectionsPerTask
681
+ : options.maxDirectStagingConnectionsPerPeer;
682
+ if ((scopedSockets?.size ?? 0) >= scopedLimit) {
683
+ if (scope.kind === "task") return false;
684
+ const oldest = scopedSockets?.values().next().value as Socket | undefined;
685
+ if (oldest) evictUnauthenticated(oldest);
686
+ }
687
+ if (activeConnections >= options.maxActiveConnections) {
688
+ // Never evict a proved task socket: it may be a long-lived MCP SSE stream
689
+ // or secrets-proxy tunnel. Unproved direct traffic is replaceable, which
690
+ // keeps a handful of peers from filling the global pool permanently.
691
+ const oldest = activeUnauthenticatedSockets.values().next().value as
692
+ | Socket
693
+ | undefined;
694
+ if (!oldest) return false;
695
+ evictUnauthenticated(oldest);
696
+ }
697
+ const currentScopedSockets =
698
+ scope.kind === "task"
699
+ ? activeSocketsByTask.get(scope.key)
700
+ : activeSocketsByPeer.get(scope.key);
701
+ const nextScopedSockets = currentScopedSockets ?? new Set<Socket>();
702
+ nextScopedSockets.add(socket);
703
+ if (scope.kind === "task") {
704
+ activeSocketsByTask.set(scope.key, nextScopedSockets);
705
+ } else {
706
+ activeSocketsByPeer.set(scope.key, nextScopedSockets);
707
+ activeUnauthenticatedSockets.add(socket);
708
+ }
709
+ activeScopeBySocket.set(socket, scope);
710
+ activeConnections += 1;
711
+ socket.once("close", () => releaseActive(socket));
712
+ return true;
713
+ };
714
+
715
+ const claimTaskQuota = (socket: Socket, taskId: string): boolean => {
716
+ const current = activeScopeBySocket.get(socket);
717
+ if (current === undefined) return false;
718
+ if (current.kind === "task") return current.key === taskId;
719
+
720
+ const taskSockets = activeSocketsByTask.get(taskId);
721
+ if (
722
+ (taskSockets?.size ?? 0) >= options.maxActiveConnectionsPerTask
723
+ ) {
724
+ return false;
725
+ }
726
+
727
+ const peerSockets = activeSocketsByPeer.get(current.key);
728
+ peerSockets?.delete(socket);
729
+ if (peerSockets?.size === 0) activeSocketsByPeer.delete(current.key);
730
+ activeUnauthenticatedSockets.delete(socket);
731
+
732
+ const nextTaskSockets = taskSockets ?? new Set<Socket>();
733
+ nextTaskSockets.add(socket);
734
+ activeSocketsByTask.set(taskId, nextTaskSockets);
735
+ activeScopeBySocket.set(socket, { kind: "task", key: taskId });
736
+ return true;
737
+ };
738
+
739
+ const rememberAuthenticated = (
740
+ socket: Socket,
741
+ identity: TaskForwarderIdentity,
742
+ ): void => {
743
+ if (options.authenticatedConnections.has(socket)) return;
744
+ options.authenticatedSockets.set(socket, identity);
745
+ options.authenticatedConnections.set(socket, identity);
746
+ socket.once("close", () => {
747
+ options.authenticatedConnections.delete(socket);
748
+ });
749
+ };
750
+
751
+ const dispatchHttp = (
752
+ socket: Socket,
753
+ firstByte: Buffer,
754
+ identity: TaskForwarderIdentity | null,
755
+ activeScope: { kind: "peer" | "task"; key: string },
756
+ ): void => {
757
+ if (!reserveActive(socket, activeScope)) {
758
+ socket.destroy();
759
+ return;
760
+ }
761
+ if (identity) rememberAuthenticated(socket, identity);
762
+ socket.unshift(firstByte);
763
+ options.httpServer.emit("connection", socket);
764
+ };
765
+
766
+ const dispatchTls = (
767
+ socket: Socket,
768
+ firstByte: Buffer,
769
+ identity: TaskForwarderIdentity,
770
+ ): void => {
771
+ const handler = options.tlsHandler();
772
+ if (
773
+ !handler ||
774
+ !isTaskGatewayGenerationCurrent(identity) ||
775
+ !reserveActive(socket, { kind: "task", key: identity.taskId })
776
+ ) {
777
+ socket.destroy();
778
+ return;
779
+ }
780
+ rememberAuthenticated(socket, identity);
781
+ socket.unshift(firstByte);
782
+ try {
783
+ handler({
784
+ ...identity,
785
+ socket,
786
+ isCurrent: () => isTaskGatewayGenerationCurrent(identity),
787
+ });
788
+ } catch {
789
+ socket.destroy();
790
+ }
791
+ };
792
+
793
+ const authenticate = async (
794
+ socket: Socket,
795
+ firstByte: Buffer,
796
+ deadline: number,
797
+ ): Promise<AuthenticatedTaskForwarder | null> => {
798
+ const headerTail = await readSocketBytes(
799
+ socket,
800
+ TASK_FORWARDER_PREAMBLE_HEADER_BYTES - 1,
801
+ deadline,
802
+ );
803
+ const header = Buffer.concat([firstByte, headerTail]);
804
+ const preambleLength = taskForwarderPreambleLength(header);
805
+ if (
806
+ preambleLength === null ||
807
+ preambleLength > TASK_FORWARDER_MAX_PREAMBLE_BYTES
808
+ ) {
809
+ return null;
810
+ }
811
+ const payload = await readSocketBytes(
812
+ socket,
813
+ preambleLength - header.length,
814
+ deadline,
815
+ );
816
+ const identity = decodeTaskForwarderPreamble(
817
+ Buffer.concat([header, payload], preambleLength),
818
+ );
819
+ if (identity === null || !isSafeHostTaskId(identity.taskId)) return null;
820
+
821
+ const nonce = options.nonce();
822
+ socket.write(encodeTaskForwarderChallenge(nonce));
823
+ const response = await readSocketBytes(
824
+ socket,
825
+ TASK_FORWARDER_RESPONSE_BYTES,
826
+ deadline,
827
+ );
828
+ const mac = decodeTaskForwarderResponse(response);
829
+ if (
830
+ mac === null ||
831
+ !verifyTaskGatewayAuthentication(identity, nonce, mac)
832
+ ) {
833
+ return null;
834
+ }
835
+
836
+ // A claimed task id is untrusted until its MAC verifies. Charging its
837
+ // reservation earlier lets a peer lock out a real task with only the
838
+ // public task id.
839
+ const taskPending = pendingByTask.get(identity.taskId) ?? 0;
840
+ if (taskPending >= options.maxPendingPreauthPerTask) return null;
841
+ pendingByTask.set(identity.taskId, taskPending + 1);
842
+ let released = false;
843
+ const release = (): void => {
844
+ if (released) return;
845
+ released = true;
846
+ const next = (pendingByTask.get(identity.taskId) ?? 1) - 1;
847
+ if (next > 0) pendingByTask.set(identity.taskId, next);
848
+ else pendingByTask.delete(identity.taskId);
849
+ };
850
+ if (!reserveActive(socket, { kind: "task", key: identity.taskId })) {
851
+ release();
852
+ return null;
853
+ }
854
+ let retained = false;
855
+ try {
856
+ socket.write(TASK_FORWARDER_ACCEPT_MAGIC);
857
+ retained = true;
858
+ return { identity, release };
859
+ } finally {
860
+ // A synchronous write failure must not permanently consume this task's
861
+ // post-MAC quotas. Once returned, the caller owns the pending release;
862
+ // the active reservation remains close-scoped.
863
+ if (!retained) {
864
+ release();
865
+ releaseActive(socket);
866
+ }
867
+ }
868
+ };
869
+
870
+ return {
871
+ claimTaskQuota,
872
+ accept: (socket) => {
873
+ socket.pause();
874
+ socket.setNoDelay(true);
875
+ const peer = peerKey(socket);
876
+ const peerSockets = pendingSocketsByPeer.get(peer);
877
+ if (
878
+ peerSockets &&
879
+ peerSockets.size >= options.maxPendingPreauthPerPeer
880
+ ) {
881
+ // Prefer the newest connection instead of letting a peer reserve its
882
+ // own entire quota with idle sockets. A real hook/forwarder sends its
883
+ // first frame immediately, so replacing the oldest pending socket
884
+ // preserves progress even when several task runtimes share one NATed
885
+ // source address (notably desktop container VMs).
886
+ const oldest = peerSockets.values().next().value as Socket | undefined;
887
+ if (oldest) {
888
+ releasePreauth(oldest);
889
+ oldest.destroy();
890
+ }
891
+ }
892
+ if (pendingPreauth >= options.maxPendingPreauth) {
893
+ // Pending sockets carry no authority and have a short deadline. Drop
894
+ // the oldest globally so several peers cannot permanently consume all
895
+ // admission slots before a real forwarder gets a turn.
896
+ const oldest = pendingPeerBySocket.keys().next().value as
897
+ | Socket
898
+ | undefined;
899
+ if (oldest) {
900
+ releasePreauth(oldest);
901
+ oldest.destroy();
902
+ }
903
+ }
904
+ reservePreauth(socket, peer);
905
+ const deadline = Date.now() + options.preauthTimeoutMs;
906
+ void (async () => {
907
+ try {
908
+ const firstByte = await readSocketBytes(socket, 1, deadline);
909
+ if (firstByte[0] === 0x00) {
910
+ const authenticated = await authenticate(socket, firstByte, deadline);
911
+ if (authenticated === null) {
912
+ socket.destroy();
913
+ return;
914
+ }
915
+ const { identity } = authenticated;
916
+ try {
917
+ rememberAuthenticated(socket, identity);
918
+ if (!isTaskGatewayGenerationCurrent(identity)) {
919
+ socket.destroy();
920
+ return;
921
+ }
922
+ // The forwarder does not relay task bytes until it receives the
923
+ // accept frame, so this byte cannot be unauthenticated pipelining.
924
+ const applicationByte = await readSocketBytes(socket, 1, deadline);
925
+ if (applicationByte[0] === 0x16) {
926
+ dispatchTls(socket, applicationByte, identity);
927
+ } else {
928
+ dispatchHttp(socket, applicationByte, identity, {
929
+ kind: "task",
930
+ key: identity.taskId,
931
+ });
932
+ }
933
+ } finally {
934
+ authenticated.release();
935
+ }
936
+ return;
937
+ }
938
+
939
+ const fixedTaskId = sidecarTaskId();
940
+ const sidecarIdentity =
941
+ fixedTaskId !== null && loopbackPeer(socket)
942
+ ? currentTaskGatewayRegistration(fixedTaskId)
943
+ : null;
944
+ if (firstByte[0] === 0x16) {
945
+ // Direct TLS is valid only for the fixed one-task sidecar. Docker
946
+ // and Apple must prove identity through the forwarder first.
947
+ if (sidecarIdentity) dispatchTls(socket, firstByte, sidecarIdentity);
948
+ else socket.destroy();
949
+ } else {
950
+ dispatchHttp(
951
+ socket,
952
+ firstByte,
953
+ sidecarIdentity,
954
+ sidecarIdentity
955
+ ? { kind: "task", key: sidecarIdentity.taskId }
956
+ : { kind: "peer", key: peer },
957
+ );
958
+ }
959
+ } catch {
960
+ socket.destroy();
961
+ } finally {
962
+ releasePreauth(socket);
963
+ }
964
+ })();
965
+ },
966
+ };
967
+ }
968
+
386
969
  export interface McpGatewayController {
387
970
  /** Current bounded capability snapshot. Never contains a raw listener error. */
388
971
  state(): McpGatewayCapability;
972
+ /** Resolve only after this listener has positively bound its configured port. */
973
+ waitUntilReady(timeoutMs?: number): Promise<void>;
389
974
  /** Subscribe to semantic state changes. The current snapshot is read via state(). */
390
975
  subscribe(
391
976
  listener: (state: McpGatewayCapability) => void,
@@ -405,8 +990,20 @@ export interface McpGatewayListenerOptions {
405
990
  retryInitialDelayMs?: number;
406
991
  retryMaxDelayMs?: number;
407
992
  serverFactory?: (
993
+ connectionListener: (socket: Socket) => void,
994
+ ) => GatewayTransportServer;
995
+ httpServerFactory?: (
408
996
  requestListener: (req: IncomingMessage, res: ServerResponse) => void,
409
- ) => Server;
997
+ ) => HttpServer;
998
+ tlsHandler?: TaskGatewayTlsHandler;
999
+ preauthTimeoutMs?: number;
1000
+ maxPendingPreauth?: number;
1001
+ maxPendingPreauthPerPeer?: number;
1002
+ maxPendingPreauthPerTask?: number;
1003
+ maxActiveConnections?: number;
1004
+ maxDirectStagingConnectionsPerPeer?: number;
1005
+ maxActiveConnectionsPerTask?: number;
1006
+ nonce?: () => Buffer;
410
1007
  setRetryTimeout?: (
411
1008
  callback: () => void,
412
1009
  delayMs: number,
@@ -468,7 +1065,40 @@ export function createMcpGatewayListener(
468
1065
  GATEWAY_RETRY_MAX_DELAY_MS,
469
1066
  ),
470
1067
  );
471
- const serverFactory = options.serverFactory ?? createServer;
1068
+ const serverFactory =
1069
+ options.serverFactory ??
1070
+ ((connectionListener: (socket: Socket) => void) =>
1071
+ createNetServer({ pauseOnConnect: true }, connectionListener));
1072
+ const httpServerFactory = options.httpServerFactory ?? createHttpServer;
1073
+ const preauthTimeoutMs = positiveInteger(
1074
+ options.preauthTimeoutMs,
1075
+ TASK_GATEWAY_PREAUTH_TIMEOUT_MS,
1076
+ );
1077
+ const maxPendingPreauth = positiveInteger(
1078
+ options.maxPendingPreauth,
1079
+ TASK_GATEWAY_MAX_PENDING_PREAUTH,
1080
+ );
1081
+ const maxPendingPreauthPerPeer = positiveInteger(
1082
+ options.maxPendingPreauthPerPeer,
1083
+ TASK_GATEWAY_MAX_PENDING_PREAUTH_PER_PEER,
1084
+ );
1085
+ const maxPendingPreauthPerTask = positiveInteger(
1086
+ options.maxPendingPreauthPerTask,
1087
+ TASK_GATEWAY_MAX_PENDING_PREAUTH_PER_TASK,
1088
+ );
1089
+ const maxActiveConnections = positiveInteger(
1090
+ options.maxActiveConnections,
1091
+ TASK_GATEWAY_MAX_ACTIVE_CONNECTIONS,
1092
+ );
1093
+ const maxDirectStagingConnectionsPerPeer = positiveInteger(
1094
+ options.maxDirectStagingConnectionsPerPeer,
1095
+ TASK_GATEWAY_MAX_DIRECT_STAGING_CONNECTIONS_PER_PEER,
1096
+ );
1097
+ const maxActiveConnectionsPerTask = positiveInteger(
1098
+ options.maxActiveConnectionsPerTask,
1099
+ TASK_GATEWAY_MAX_ACTIVE_CONNECTIONS_PER_TASK,
1100
+ );
1101
+ const nonce = options.nonce ?? (() => randomBytes(32));
472
1102
  const setRetryTimeout =
473
1103
  options.setRetryTimeout ??
474
1104
  ((callback: () => void, delayMs: number) =>
@@ -479,12 +1109,55 @@ export function createMcpGatewayListener(
479
1109
  const subscribers = new Set<
480
1110
  (state: McpGatewayCapability) => void
481
1111
  >();
1112
+ const authenticatedSockets = new WeakMap<Socket, TaskForwarderIdentity>();
1113
+ const authenticatedConnections = new Map<Socket, TaskForwarderIdentity>();
1114
+ let claimTaskQuota = (_socket: Socket, _taskId: string): boolean => false;
1115
+ const httpServer = httpServerFactory((req, res) => {
1116
+ void handleGatewayRequest(
1117
+ req,
1118
+ res,
1119
+ authenticatedSockets,
1120
+ claimTaskQuota,
1121
+ ).catch(() => deny(res, 500, "gateway error"));
1122
+ });
1123
+ httpServer.on("clientError", (_error, socket) => socket.destroy());
1124
+ // This HTTP server never owns the TCP listener: authenticated sockets are
1125
+ // demultiplexed into it. Node starts its headers/request timeout checker on
1126
+ // the `listening` event, so emit that lifecycle event for this hidden
1127
+ // server too or a peer can drip one byte forever after pre-authentication.
1128
+ httpServer.emit("listening");
1129
+ const demux = createTaskGatewayDemux({
1130
+ httpServer,
1131
+ authenticatedSockets,
1132
+ authenticatedConnections,
1133
+ preauthTimeoutMs,
1134
+ maxPendingPreauth,
1135
+ maxPendingPreauthPerPeer,
1136
+ maxPendingPreauthPerTask,
1137
+ maxActiveConnections,
1138
+ maxDirectStagingConnectionsPerPeer,
1139
+ maxActiveConnectionsPerTask,
1140
+ nonce,
1141
+ tlsHandler: () => options.tlsHandler ?? taskGatewayTlsHandler,
1142
+ });
1143
+ claimTaskQuota = demux.claimTaskQuota;
1144
+ const socketsByServer = new Map<GatewayTransportServer, Set<Socket>>();
1145
+ const unsubscribeRevocations = subscribeTaskGatewayRevocations((revoked) => {
1146
+ for (const [socket, identity] of authenticatedConnections) {
1147
+ if (
1148
+ identity.taskId === revoked.taskId &&
1149
+ identity.generation === revoked.generation
1150
+ ) {
1151
+ socket.destroy();
1152
+ }
1153
+ }
1154
+ });
482
1155
 
483
1156
  let capability: McpGatewayCapability = Object.freeze({
484
1157
  status: "starting",
485
1158
  port,
486
1159
  });
487
- let server: Server | null = null;
1160
+ let server: GatewayTransportServer | null = null;
488
1161
  let retryTimer: NodeJS.Timeout | null = null;
489
1162
  let consecutiveFailures = 0;
490
1163
  let started = false;
@@ -504,7 +1177,13 @@ export function createMcpGatewayListener(
504
1177
  }
505
1178
  };
506
1179
 
507
- const closeServer = (target: Server): void => {
1180
+ const closeServer = (target: GatewayTransportServer): void => {
1181
+ const sockets = socketsByServer.get(target);
1182
+ socketsByServer.delete(target);
1183
+ if (sockets) {
1184
+ for (const socket of sockets) socket.destroy();
1185
+ sockets.clear();
1186
+ }
508
1187
  try {
509
1188
  target.close(() => undefined);
510
1189
  target.closeAllConnections?.();
@@ -555,7 +1234,10 @@ export function createMcpGatewayListener(
555
1234
  retryTimer.unref();
556
1235
  };
557
1236
 
558
- const listenerFailed = (target: Server, error: unknown): void => {
1237
+ const listenerFailed = (
1238
+ target: GatewayTransportServer,
1239
+ error: unknown,
1240
+ ): void => {
559
1241
  if (stopped || server !== target) return;
560
1242
  server = null;
561
1243
  closeServer(target);
@@ -565,15 +1247,19 @@ export function createMcpGatewayListener(
565
1247
  beginAttempt = (): void => {
566
1248
  if (stopped || server !== null || retryTimer !== null) return;
567
1249
 
568
- let nextServer: Server;
1250
+ let nextServer: GatewayTransportServer;
1251
+ const attemptSockets = new Set<Socket>();
569
1252
  try {
570
- nextServer = serverFactory((req, res) => {
571
- void handle(req, res).catch(() => deny(res, 500, "gateway error"));
1253
+ nextServer = serverFactory((socket) => {
1254
+ attemptSockets.add(socket);
1255
+ socket.once("close", () => attemptSockets.delete(socket));
1256
+ demux.accept(socket);
572
1257
  });
573
1258
  } catch {
574
1259
  scheduleRetry("bind_failed");
575
1260
  return;
576
1261
  }
1262
+ socketsByServer.set(nextServer, attemptSockets);
577
1263
  server = nextServer;
578
1264
 
579
1265
  nextServer.on("error", (error) => {
@@ -589,6 +1275,7 @@ export function createMcpGatewayListener(
589
1275
  logger.log(`[mcp-gateway] listening on ${bind}:${port}`);
590
1276
  });
591
1277
  nextServer.on("close", () => {
1278
+ socketsByServer.delete(nextServer);
592
1279
  if (stopped || server !== nextServer) return;
593
1280
  server = null;
594
1281
  scheduleRetry("bind_failed");
@@ -603,6 +1290,52 @@ export function createMcpGatewayListener(
603
1290
 
604
1291
  const controller: McpGatewayListener = {
605
1292
  state: () => capability,
1293
+ waitUntilReady: (timeoutMs = 5_000) => {
1294
+ if (capability.status === "ready") return Promise.resolve();
1295
+ if (capability.status === "down") {
1296
+ return Promise.reject(
1297
+ new Error(`MCP gateway listener is down (${capability.reason})`),
1298
+ );
1299
+ }
1300
+ if (stopped) {
1301
+ return Promise.reject(new Error("MCP gateway listener is stopped"));
1302
+ }
1303
+ const boundedTimeoutMs =
1304
+ Number.isFinite(timeoutMs) && timeoutMs >= 1
1305
+ ? Math.floor(timeoutMs)
1306
+ : 5_000;
1307
+ return new Promise<void>((resolveReady, rejectReady) => {
1308
+ let settled = false;
1309
+ let unsubscribe = (): void => undefined;
1310
+ const finish = (error?: Error): void => {
1311
+ if (settled) return;
1312
+ settled = true;
1313
+ clearTimeout(timer);
1314
+ unsubscribe();
1315
+ if (error) rejectReady(error);
1316
+ else resolveReady();
1317
+ };
1318
+ const timer = setTimeout(
1319
+ () =>
1320
+ finish(
1321
+ new Error(
1322
+ `MCP gateway listener did not become ready within ${boundedTimeoutMs}ms`,
1323
+ ),
1324
+ ),
1325
+ boundedTimeoutMs,
1326
+ );
1327
+ timer.unref();
1328
+ const observe = (state: McpGatewayCapability): void => {
1329
+ if (state.status === "ready") finish();
1330
+ else if (state.status === "down") {
1331
+ finish(new Error(`MCP gateway listener is down (${state.reason})`));
1332
+ }
1333
+ };
1334
+ unsubscribe = controller.subscribe(observe);
1335
+ // Close the state-change race between the initial check and subscribe.
1336
+ observe(capability);
1337
+ });
1338
+ },
606
1339
  subscribe: (listener) => {
607
1340
  subscribers.add(listener);
608
1341
  return () => subscribers.delete(listener);
@@ -639,6 +1372,11 @@ export function createMcpGatewayListener(
639
1372
  };
640
1373
  activeServer.once("close", finish);
641
1374
  try {
1375
+ const sockets = socketsByServer.get(activeServer);
1376
+ socketsByServer.delete(activeServer);
1377
+ if (sockets) {
1378
+ for (const socket of sockets) socket.destroy();
1379
+ }
642
1380
  activeServer.close(finish);
643
1381
  activeServer.closeAllConnections?.();
644
1382
  if (!activeServer.listening) queueMicrotask(finish);
@@ -646,6 +1384,13 @@ export function createMcpGatewayListener(
646
1384
  finish();
647
1385
  }
648
1386
  }).finally(() => {
1387
+ unsubscribeRevocations();
1388
+ for (const socket of authenticatedConnections.keys()) socket.destroy();
1389
+ authenticatedConnections.clear();
1390
+ httpServer.closeAllConnections();
1391
+ // Clears the hidden server's timeout-check interval. It has no own
1392
+ // listening handle, so close is otherwise a no-op.
1393
+ httpServer.close();
649
1394
  subscribers.clear();
650
1395
  });
651
1396
  return stopPromise;
@@ -662,6 +1407,11 @@ export function startMcpGateway(): McpGatewayController {
662
1407
  return gatewayController.start();
663
1408
  }
664
1409
 
1410
+ /** Task admission gate: no runtime starts until the shared listener is bound. */
1411
+ export function waitForMcpGatewayReady(timeoutMs = 5_000): Promise<void> {
1412
+ return startMcpGateway().waitUntilReady(timeoutMs);
1413
+ }
1414
+
665
1415
  /** Stop and reset the process-wide listener (also the deterministic test seam). */
666
1416
  export async function stopMcpGateway(): Promise<void> {
667
1417
  const active = gatewayController;
@@ -1190,6 +1940,7 @@ export async function setupMcpTaskConfig(
1190
1940
  ? await environment.exec({
1191
1941
  argv: ["sh", "-lc", steps],
1192
1942
  env: RUNTIME_AUTHORITY_ENV,
1943
+ projectEnvProfile: "maintenance",
1193
1944
  timeoutMs: MCP_CONFIG_HOST_TIMEOUT_MS,
1194
1945
  maxOutputBytes: 256 * 1024,
1195
1946
  })