@runuai/host 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,342 @@
1
+ /**
2
+ * MCP gateway (ADR-057) — the host-side proxy that puts the owner's MCP
3
+ * connections in front of task containers WITHOUT the credentials ever
4
+ * entering them.
5
+ *
6
+ * Containers call `http://host.docker.internal:<port>/t/<token>/<slug>`; the
7
+ * gateway resolves the per-task token to an ACL file written at session
8
+ * ensure (which connections this task may use), attaches the connection's
9
+ * Authorization header host-side (refreshing OAuth tokens as needed), and
10
+ * streams the MCP traffic through (streamable HTTP + SSE). Every request
11
+ * lands in an audit JSONL. Disconnecting a connection kills its secrets in
12
+ * the host store, so the gateway 401s instantly — the kill switch.
13
+ *
14
+ * VERIFY-ON-MAC: OrbStack forwards host.docker.internal to loopback-bound
15
+ * host services; if not, set UAI_MCP_GATEWAY_BIND=0.0.0.0 (the unguessable
16
+ * per-task token stays the auth).
17
+ */
18
+
19
+ import { appendFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
20
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
21
+ import { randomBytes, timingSafeEqual } from "node:crypto";
22
+ import { Readable, pipeline } from "node:stream";
23
+ import { resolve } from "node:path";
24
+
25
+ import { env } from "./env";
26
+ import { dockerCli } from "./docker-exec";
27
+ import { authHeaderFor, getConnection } from "./mcp-connections";
28
+
29
+ export const MCP_GATEWAY_PORT = Number(process.env.UAI_MCP_GATEWAY_PORT ?? 5877);
30
+ const BIND = process.env.UAI_MCP_GATEWAY_BIND ?? "127.0.0.1";
31
+ /** MCP request bodies are small JSON-RPC frames; cap so audit parsing (and a
32
+ * hostile container) can't balloon host memory. Responses stream freely. */
33
+ const MAX_BODY_BYTES = 4 * 1024 * 1024;
34
+ /** Bounds the upstream CONNECT (until response headers) only. The response
35
+ * BODY must never be time-bounded: MCP streamable-HTTP clients hold SSE
36
+ * streams open indefinitely, and an abort mid-pipe once took the whole host
37
+ * process down as an unhandled Readable 'error' (2026-07-13). */
38
+ const UPSTREAM_CONNECT_TIMEOUT_MS = 60_000;
39
+
40
+ export interface TaskMcpConnection {
41
+ id: string;
42
+ slug: string;
43
+ }
44
+
45
+ interface TaskAcl {
46
+ token: string;
47
+ connections: TaskMcpConnection[];
48
+ }
49
+
50
+ function aclDir(): string {
51
+ return resolve(env.dataDir, "mcp-gateway");
52
+ }
53
+
54
+ function aclPath(taskId: string): string {
55
+ return resolve(aclDir(), `${taskId}.json`);
56
+ }
57
+
58
+ function readAcl(taskId: string): TaskAcl | null {
59
+ try {
60
+ return JSON.parse(readFileSync(aclPath(taskId), "utf8")) as TaskAcl;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Write/refresh the task's gateway ACL (the connection set snapshots the
68
+ * cloud's latest ensure input). The token is minted once per task and
69
+ * survives re-ensures so already-written .mcp.json files stay valid.
70
+ */
71
+ export function ensureTaskGatewayAcl(
72
+ taskId: string,
73
+ connections: TaskMcpConnection[],
74
+ ): TaskAcl {
75
+ const existing = readAcl(taskId);
76
+ const acl: TaskAcl = {
77
+ token: existing?.token ?? `${taskId}.${randomBytes(24).toString("base64url")}`,
78
+ connections,
79
+ };
80
+ mkdirSync(aclDir(), { recursive: true, mode: 0o700 });
81
+ writeFileSync(aclPath(taskId), JSON.stringify(acl), { mode: 0o600 });
82
+ return acl;
83
+ }
84
+
85
+ export function clearTaskGatewayAcl(taskId: string): void {
86
+ try {
87
+ rmSync(aclPath(taskId));
88
+ } catch {
89
+ // already gone
90
+ }
91
+ }
92
+
93
+ function audit(entry: Record<string, unknown>): void {
94
+ try {
95
+ const dir = resolve(env.dataDir, "logs");
96
+ mkdirSync(dir, { recursive: true });
97
+ appendFileSync(
98
+ resolve(dir, "mcp-audit.jsonl"),
99
+ `${JSON.stringify({ ts: Date.now(), ...entry })}\n`,
100
+ );
101
+ } catch {
102
+ // audit is best-effort
103
+ }
104
+ }
105
+
106
+ function deny(res: ServerResponse, status: number, message: string): void {
107
+ res.writeHead(status, { "content-type": "application/json" });
108
+ res.end(JSON.stringify({ error: message }));
109
+ }
110
+
111
+ async function readBody(req: IncomingMessage): Promise<Buffer | null> {
112
+ const chunks: Buffer[] = [];
113
+ let size = 0;
114
+ for await (const chunk of req) {
115
+ const buf = chunk as Buffer;
116
+ size += buf.length;
117
+ if (size > MAX_BODY_BYTES) return null;
118
+ chunks.push(buf);
119
+ }
120
+ return Buffer.concat(chunks);
121
+ }
122
+
123
+ /** JSON-RPC method (+ tool name for tools/call) out of a request body. */
124
+ function rpcSummary(body: Buffer): { method?: string; tool?: string } {
125
+ try {
126
+ const json = JSON.parse(body.toString("utf8")) as {
127
+ method?: string;
128
+ params?: { name?: string };
129
+ };
130
+ return {
131
+ method: typeof json.method === "string" ? json.method : undefined,
132
+ tool:
133
+ json.method === "tools/call" && typeof json.params?.name === "string"
134
+ ? json.params.name
135
+ : undefined,
136
+ };
137
+ } catch {
138
+ return {};
139
+ }
140
+ }
141
+
142
+ const FORWARD_REQ_HEADERS = [
143
+ "content-type",
144
+ "accept",
145
+ "mcp-session-id",
146
+ "last-event-id",
147
+ "mcp-protocol-version",
148
+ ];
149
+ const FORWARD_RES_HEADERS = ["content-type", "mcp-session-id"];
150
+
151
+ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
152
+ const match = /^\/t\/([^/]+)\/([^/?]+)\/?(?:\?.*)?$/.exec(req.url ?? "");
153
+ if (!match) return deny(res, 404, "not found");
154
+ const [, token, slug] = match as unknown as [string, string, string];
155
+
156
+ const taskId = token.split(".")[0] ?? "";
157
+ const acl = readAcl(taskId);
158
+ const expected = acl ? Buffer.from(acl.token) : null;
159
+ const provided = Buffer.from(token);
160
+ if (
161
+ !acl ||
162
+ !expected ||
163
+ expected.length !== provided.length ||
164
+ !timingSafeEqual(expected, provided)
165
+ ) {
166
+ return deny(res, 401, "unknown task token");
167
+ }
168
+ const entry = acl.connections.find((c) => c.slug === slug);
169
+ if (!entry) return deny(res, 404, `no connection "${slug}" for this task`);
170
+ const conn = getConnection(entry.id);
171
+ if (!conn || conn.status !== "connected") {
172
+ // Disconnected since the task started — the kill switch answering.
173
+ return deny(res, 401, `connection "${slug}" is no longer available`);
174
+ }
175
+
176
+ const body =
177
+ req.method === "POST" || req.method === "PUT" ? await readBody(req) : null;
178
+ if ((req.method === "POST" || req.method === "PUT") && body === null) {
179
+ return deny(res, 413, "request body too large");
180
+ }
181
+
182
+ const headers: Record<string, string> = {};
183
+ for (const name of FORWARD_REQ_HEADERS) {
184
+ const v = req.headers[name];
185
+ if (typeof v === "string") headers[name] = v;
186
+ }
187
+
188
+ // The controller lives for the whole exchange: the timer only guards the
189
+ // connect phase (cleared once headers land), and a client hang-up reaps the
190
+ // upstream so dead streams don't accumulate.
191
+ const controller = new AbortController();
192
+ res.on("close", () => controller.abort());
193
+
194
+ const attempt = async (forceRefresh: boolean): Promise<Response> => {
195
+ const auth = await authHeaderFor(conn, forceRefresh);
196
+ if (auth) headers[auth.name] = auth.value;
197
+ const connectTimer = setTimeout(
198
+ () => controller.abort(),
199
+ UPSTREAM_CONNECT_TIMEOUT_MS,
200
+ );
201
+ try {
202
+ return await fetch(conn.url, {
203
+ method: req.method,
204
+ headers,
205
+ body: body ?? undefined,
206
+ signal: controller.signal,
207
+ });
208
+ } finally {
209
+ clearTimeout(connectTimer);
210
+ }
211
+ };
212
+
213
+ let upstream: Response;
214
+ try {
215
+ upstream = await attempt(false);
216
+ if (upstream.status === 401 && conn.authKind === "oauth") {
217
+ upstream = await attempt(true);
218
+ }
219
+ } catch (err) {
220
+ audit({ taskId, slug, method: req.method, error: String(err) });
221
+ return deny(res, 502, "upstream unreachable");
222
+ }
223
+
224
+ const summary = body ? rpcSummary(body) : {};
225
+ audit({
226
+ taskId,
227
+ slug,
228
+ method: req.method,
229
+ rpc: summary.method,
230
+ tool: summary.tool,
231
+ status: upstream.status,
232
+ });
233
+
234
+ const resHeaders: Record<string, string> = {};
235
+ for (const name of FORWARD_RES_HEADERS) {
236
+ const v = upstream.headers.get(name);
237
+ if (v) resHeaders[name] = v;
238
+ }
239
+ res.writeHead(upstream.status, resHeaders);
240
+ if (upstream.body) {
241
+ // pipeline (NOT .pipe) so a mid-stream error — client gone, upstream
242
+ // reset, abort — tears both ends down instead of crashing the process
243
+ // as an unhandled Readable 'error'.
244
+ pipeline(
245
+ Readable.fromWeb(upstream.body as Parameters<typeof Readable.fromWeb>[0]),
246
+ res,
247
+ (err) => {
248
+ if (err && err.name !== "AbortError") {
249
+ audit({ taskId, slug, stream: "ended", error: err.name });
250
+ }
251
+ },
252
+ );
253
+ } else {
254
+ res.end();
255
+ }
256
+ }
257
+
258
+ /** Start the gateway listener. Idempotent-ish: call once from main. */
259
+ export function startMcpGateway(): void {
260
+ const server = createServer((req, res) => {
261
+ void handle(req, res).catch(() => deny(res, 500, "gateway error"));
262
+ });
263
+ server.on("error", (err) => {
264
+ console.warn(`[mcp-gateway] listener error: ${err.message}`);
265
+ });
266
+ server.listen(MCP_GATEWAY_PORT, BIND, () => {
267
+ console.log(`[mcp-gateway] listening on ${BIND}:${MCP_GATEWAY_PORT}`);
268
+ });
269
+ }
270
+
271
+ // --- task container wiring (ADR-057 task-up writers) -------------------------
272
+
273
+ /** Idempotent node -e merge of entries into /workspace/.mcp.json. */
274
+ const MERGE_MCP_JSON = `
275
+ const fs = require("fs");
276
+ const p = "/workspace/.mcp.json";
277
+ let j = {};
278
+ try { j = JSON.parse(fs.readFileSync(p, "utf8")); } catch {}
279
+ j.mcpServers = j.mcpServers || {};
280
+ let changed = false;
281
+ for (const [k, v] of Object.entries(JSON.parse(process.argv[1]))) {
282
+ if (JSON.stringify(j.mcpServers[k]) !== JSON.stringify(v)) { j.mcpServers[k] = v; changed = true; }
283
+ }
284
+ if (changed) fs.writeFileSync(p, JSON.stringify(j, null, 2) + "\\n");
285
+ `.trim();
286
+
287
+ function shellQuote(value: string): string {
288
+ return `'${value.replace(/'/g, `'\\''`)}'`;
289
+ }
290
+
291
+ /**
292
+ * Write the task's MCP configs inside the container: gateway-URL entries per
293
+ * connection for Claude (/workspace/.mcp.json, merged — coexists with the
294
+ * ADR-053 browser server) and mcp-remote shims for Codex (config.toml,
295
+ * append-once per slug). Safe to re-run every ensure.
296
+ */
297
+ export async function setupMcpTaskConfig(
298
+ taskId: string,
299
+ containerName: string,
300
+ connections: TaskMcpConnection[],
301
+ hasCodex: boolean,
302
+ ): Promise<void> {
303
+ if (connections.length === 0) return;
304
+ try {
305
+ const acl = ensureTaskGatewayAcl(taskId, connections);
306
+ const urlFor = (slug: string): string =>
307
+ `http://host.docker.internal:${MCP_GATEWAY_PORT}/t/${acl.token}/${slug}`;
308
+
309
+ const claudeEntries: Record<string, unknown> = {};
310
+ for (const c of connections) {
311
+ claudeEntries[c.slug] = { type: "http", url: urlFor(c.slug) };
312
+ }
313
+ const steps = [
314
+ "mkdir -p /workspace/.claude",
315
+ `[ -f /workspace/.claude/settings.json ] || printf '%s\\n' ${shellQuote(
316
+ JSON.stringify({ enableAllProjectMcpServers: true }, null, 2),
317
+ )} > /workspace/.claude/settings.json`,
318
+ `node -e ${shellQuote(MERGE_MCP_JSON)} ${shellQuote(JSON.stringify(claudeEntries))}`,
319
+ ...(hasCodex
320
+ ? connections.map(
321
+ (c) =>
322
+ `grep -q "mcp_servers.${c.slug}]" /home/node/.codex/config.toml 2>/dev/null || printf '%s' ${shellQuote(
323
+ `\n[mcp_servers.${c.slug}]\ncommand = "npx"\nargs = ["-y", "mcp-remote", "${urlFor(c.slug)}", "--allow-http"]\n`,
324
+ )} >> /home/node/.codex/config.toml`,
325
+ )
326
+ : []),
327
+ ].join(" && ");
328
+ const result = await dockerCli(
329
+ ["exec", containerName, "sh", "-lc", steps],
330
+ { timeoutMs: 20_000 },
331
+ );
332
+ if (result.status !== 0) {
333
+ console.warn(
334
+ `[mcp-gateway] task ${taskId}: config write failed: ${result.stderr.slice(0, 300)}`,
335
+ );
336
+ }
337
+ } catch (err) {
338
+ console.warn(
339
+ `[mcp-gateway] task ${taskId}: setup failed: ${err instanceof Error ? err.message : err}`,
340
+ );
341
+ }
342
+ }