@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.
- package/db/migrations/0024_task_env_mode.sql +9 -0
- package/db/migrations/0025_project_env_authority.sql +147 -0
- package/db/migrations/0026_project_env_replica.sql +150 -0
- package/db/migrations/meta/_journal.json +21 -0
- package/db/schema.ts +312 -2
- package/images/standard/Dockerfile +44 -6
- package/images/standard/container/uai-init +85 -2
- package/images/standard/container/uai-task-forwarder.mjs +303 -0
- package/lib/agent-cli.ts +18 -1
- package/lib/agent.ts +99 -34
- package/lib/agents/attach-compatibility.ts +51 -0
- package/lib/agents/codex.ts +137 -13
- package/lib/agents/cursor.ts +15 -1
- package/lib/agents/durable-proc.ts +2 -0
- package/lib/agents/grok.ts +8 -0
- package/lib/agents/kimi.ts +1 -0
- package/lib/agents/machine-durable.ts +17 -0
- package/lib/agents/opencode.ts +1 -0
- package/lib/agents/proc.ts +6 -1
- package/lib/agents/session-state.ts +107 -0
- package/lib/agents/transport.ts +125 -58
- package/lib/agents/types.ts +3 -3
- package/lib/browser-testing.ts +6 -0
- package/lib/codex-auth.ts +2 -0
- package/lib/credential-store.ts +141 -6
- package/lib/engine-accounts.ts +1 -0
- package/lib/env-file.ts +7 -0
- package/lib/env-schema.ts +841 -51
- package/lib/git-diff.ts +1 -0
- package/lib/git-identity.ts +2 -0
- package/lib/github-tokens.ts +1 -0
- package/lib/host-env.ts +98 -18
- package/lib/lease.ts +11 -3
- package/lib/machine-gateway-tunnel.ts +195 -17
- package/lib/machine-project-env-loader.ts +152 -0
- package/lib/managed-runtime.ts +270 -51
- package/lib/mcp-gateway.ts +777 -26
- package/lib/orchestrator.ts +1681 -138
- package/lib/project-env-authority.ts +1319 -0
- package/lib/project-env-feature.ts +74 -0
- package/lib/project-env-loader.ts +237 -0
- package/lib/project-env-replica.ts +2094 -0
- package/lib/project-env-schema-capture.ts +342 -0
- package/lib/project-env-staging.ts +148 -0
- package/lib/project-env-task-policy.ts +176 -0
- package/lib/runtime-archive.ts +79 -16
- package/lib/runtime-authority.ts +44 -0
- package/lib/runtime-state.ts +175 -10
- package/lib/secrets-proxy/address.ts +72 -0
- package/lib/secrets-proxy/audit.ts +30 -0
- package/lib/secrets-proxy/ca-store.ts +200 -0
- package/lib/secrets-proxy/ca.ts +470 -0
- package/lib/secrets-proxy/container-steps.ts +138 -0
- package/lib/secrets-proxy/der.ts +273 -0
- package/lib/secrets-proxy/engine.ts +1633 -0
- package/lib/secrets-proxy/host.ts +93 -0
- package/lib/secrets-proxy/hostname.ts +60 -0
- package/lib/secrets-proxy/placeholders.ts +196 -0
- package/lib/secrets-proxy/policy.ts +141 -0
- package/lib/secrets-proxy/readiness.ts +148 -0
- package/lib/secrets-proxy/runtime.ts +582 -0
- package/lib/secrets-proxy/scrub.ts +559 -0
- package/lib/secrets-proxy/sidecar-view.ts +139 -0
- package/lib/secrets-proxy/task-scripts.ts +262 -0
- package/lib/secrets-proxy/task-trust.ts +574 -0
- package/lib/secrets-proxy/trust-watch.ts +80 -0
- package/lib/shared-files-s3.ts +3 -0
- package/lib/sidecar-guard.ts +58 -0
- package/lib/sidecar-resume.ts +51 -3
- package/lib/standard-image.ts +50 -12
- package/lib/task-env-forwarder.ts +228 -0
- package/lib/task-env-source.ts +193 -0
- package/lib/task-environment/apple-container.ts +45 -11
- package/lib/task-environment/docker.ts +51 -12
- package/lib/task-environment/index.ts +6 -2
- package/lib/task-environment/machine-task-up.ts +258 -25
- package/lib/task-environment/machine.ts +80 -22
- package/lib/task-environment/registry.ts +3 -2
- package/lib/task-environment/self-identity.ts +20 -0
- package/lib/task-environment/self.ts +92 -16
- package/lib/task-environment/types.ts +63 -0
- package/lib/task-gateway-port.ts +22 -0
- package/lib/task-gateway-protocol.ts +190 -0
- package/lib/task-gateway-recovery.ts +44 -0
- package/lib/task-gateway-state.ts +497 -0
- package/lib/transcript.ts +1 -0
- package/lib/ulid.ts +10 -1
- package/package.json +13 -1
- package/scripts/agent/env-hook.sh +626 -0
- package/scripts/agent/task-up.sh +476 -27
- package/scripts/agent/uai-env-exec.sh +26 -0
- package/src/cli.ts +26 -3
- package/src/index.ts +273 -19
- package/src/main.ts +941 -48
- package/src/project-env-latest.ts +130 -0
- package/src/project-env-policy.ts +281 -0
- package/src/project-env-wording.ts +47 -0
- package/src/protocol.ts +2239 -15
package/lib/mcp-gateway.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
) =>
|
|
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 =
|
|
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:
|
|
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:
|
|
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 = (
|
|
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:
|
|
1250
|
+
let nextServer: GatewayTransportServer;
|
|
1251
|
+
const attemptSockets = new Set<Socket>();
|
|
569
1252
|
try {
|
|
570
|
-
nextServer = serverFactory((
|
|
571
|
-
|
|
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
|
})
|