@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.
- package/README.md +22 -5
- package/db/migrations/0014_host_inventory_event_index.sql +1 -0
- package/db/migrations/0015_host_settings.sql +9 -0
- package/db/migrations/0016_task_environment.sql +2 -0
- package/db/migrations/meta/_journal.json +21 -0
- package/db/schema.ts +80 -30
- package/images/standard/Dockerfile +36 -10
- package/images/standard/README.md +63 -18
- package/images/standard/container/corepack-version +1 -0
- package/images/standard/container/uai-init +308 -38
- package/images/standard/container/uai-materialize-runtimes +1527 -0
- package/lib/agent-cli.ts +69 -4
- package/lib/agent.ts +46 -7
- package/lib/agents/claude.ts +13 -8
- package/lib/agents/codex.ts +11 -6
- package/lib/agents/cursor.ts +39 -29
- package/lib/agents/durable-proc.ts +20 -27
- package/lib/agents/factory.ts +9 -25
- package/lib/agents/grok.ts +43 -30
- package/lib/agents/kimi.ts +44 -29
- package/lib/agents/opencode.ts +43 -31
- package/lib/agents/proc.ts +149 -114
- package/lib/agents/transport.ts +62 -50
- package/lib/agents/types.ts +6 -4
- package/lib/apple-runtime-recycle.ts +236 -0
- package/lib/apple-uninstall-teardown.ts +224 -0
- package/lib/browser-testing.ts +233 -93
- package/lib/codex-auth.ts +40 -6
- package/lib/command-db.ts +20 -0
- package/lib/container-runtime.ts +1338 -0
- package/lib/db.ts +1 -0
- package/lib/docker-exec.ts +87 -5
- package/lib/engine-accounts.ts +68 -5
- package/lib/engine-login.ts +1952 -0
- package/lib/enrollment-state.ts +251 -0
- package/lib/env-file.ts +155 -0
- package/lib/env.ts +4 -0
- package/lib/git-diff.ts +98 -32
- package/lib/git-identity.ts +199 -87
- package/lib/github-tokens.ts +202 -91
- package/lib/host-cloud-url.ts +62 -0
- package/lib/host-config.ts +279 -0
- package/lib/host-logs.ts +962 -0
- package/lib/keyed-promise-tail.ts +23 -0
- package/lib/legacy-runtime-v1.fixture.ts +627 -0
- package/lib/managed-activation-watcher.ts +72 -0
- package/lib/managed-install-owner-watcher.ts +55 -0
- package/lib/managed-operation-drain.ts +49 -0
- package/lib/managed-runtime.ts +3644 -0
- package/lib/managed-update-scheduler.ts +125 -0
- package/lib/mcp-gateway.ts +450 -23
- package/lib/orchestrator.ts +3070 -223
- package/lib/preview-sidecar.ts +57 -13
- package/lib/release-manifest.ts +708 -0
- package/lib/release-trust.ts +28 -0
- package/lib/runtime-activation-tail.ts +232 -0
- package/lib/runtime-archive.ts +1086 -0
- package/lib/runtime-authority.ts +79 -0
- package/lib/runtime-guard.ts +36 -0
- package/lib/runtime-provider-state.ts +169 -0
- package/lib/runtime-state.ts +232 -12
- package/lib/skills.ts +24 -3
- package/lib/ssh.ts +18 -0
- package/lib/standard-image.ts +1104 -141
- package/lib/stopped-task-status-queue.ts +44 -0
- package/lib/task-container-cli.ts +269 -0
- package/lib/task-diff.ts +66 -46
- package/lib/task-environment/apple-container.ts +757 -0
- package/lib/task-environment/docker.ts +945 -0
- package/lib/task-environment/index.ts +364 -0
- package/lib/task-environment/legacy-adoption.ts +443 -0
- package/lib/task-environment/registry.ts +58 -0
- package/lib/task-environment/types.ts +408 -0
- package/lib/task-identity.ts +19 -0
- package/lib/task-inventory.ts +585 -0
- package/lib/tunnel-registry.ts +135 -19
- package/lib/tunnel-runtime.ts +235 -0
- package/package.json +1 -1
- package/scripts/agent/_common.sh +123 -3
- package/scripts/agent/task-down.sh +146 -38
- package/scripts/agent/task-status.sh +19 -3
- package/scripts/agent/task-up.sh +1463 -109
- package/scripts/install/darwin.ts +848 -50
- package/scripts/install/linux.ts +838 -35
- package/scripts/install/types.ts +43 -0
- package/scripts/install/util.ts +215 -8
- package/scripts/install/win.ts +12 -0
- package/src/apple-tunnel-route.ts +104 -0
- package/src/cli.ts +1464 -72
- package/src/event-outbox.ts +83 -4
- package/src/index.ts +871 -50
- package/src/main.ts +1398 -255
- package/src/paths.ts +17 -1
- package/src/protocol.ts +695 -1
- package/src/runtime-bootstrap.ts +165 -0
- package/src/ui/server.ts +46 -10
- 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
|
+
}
|
package/lib/mcp-gateway.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
20
|
-
|
|
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 {
|
|
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
|
|
31
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
|
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 =
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
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: ${
|
|
1191
|
+
`[mcp-gateway] task ${taskId}: config write failed: ${stderr.slice(0, 300)}`,
|
|
765
1192
|
);
|
|
766
1193
|
return false;
|
|
767
1194
|
}
|