@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.
- package/db/migrations/0008_host_mcp_connections.sql +18 -0
- package/db/migrations/0009_host_agent_sessions.sql +15 -0
- package/db/migrations/meta/_journal.json +15 -1
- package/db/schema.ts +61 -0
- package/images/standard/Dockerfile +15 -0
- package/lib/agent-cli.ts +6 -0
- package/lib/agents/claude.ts +22 -16
- package/lib/agents/codex.ts +36 -22
- package/lib/agents/durable-proc.ts +306 -0
- package/lib/agents/transport.ts +229 -0
- package/lib/browser-testing.ts +235 -0
- package/lib/mcp-connections.ts +554 -0
- package/lib/mcp-gateway.ts +342 -0
- package/lib/orchestrator.ts +274 -14
- package/lib/standard-image.ts +137 -10
- package/package.json +2 -1
- package/runner/runner.mjs +208 -0
- package/scripts/agent/task-up.sh +10 -0
- package/src/index.ts +52 -1
- package/src/main.ts +102 -0
- package/src/protocol.ts +82 -2
|
@@ -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
|
+
}
|