@runuai/host 0.4.2 → 0.4.3
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/meta/_journal.json +8 -1
- package/db/schema.ts +28 -0
- package/images/standard/Dockerfile +15 -0
- package/lib/agent-cli.ts +6 -0
- package/lib/agents/claude.ts +4 -3
- package/lib/agents/codex.ts +15 -8
- package/lib/browser-testing.ts +235 -0
- package/lib/mcp-connections.ts +522 -0
- package/lib/mcp-gateway.ts +315 -0
- package/lib/orchestrator.ts +247 -13
- package/lib/standard-image.ts +127 -10
- package/package.json +1 -1
- 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,315 @@
|
|
|
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 } 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
|
+
const UPSTREAM_TIMEOUT_MS = 120_000;
|
|
35
|
+
|
|
36
|
+
export interface TaskMcpConnection {
|
|
37
|
+
id: string;
|
|
38
|
+
slug: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface TaskAcl {
|
|
42
|
+
token: string;
|
|
43
|
+
connections: TaskMcpConnection[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function aclDir(): string {
|
|
47
|
+
return resolve(env.dataDir, "mcp-gateway");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function aclPath(taskId: string): string {
|
|
51
|
+
return resolve(aclDir(), `${taskId}.json`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readAcl(taskId: string): TaskAcl | null {
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(readFileSync(aclPath(taskId), "utf8")) as TaskAcl;
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Write/refresh the task's gateway ACL (the connection set snapshots the
|
|
64
|
+
* cloud's latest ensure input). The token is minted once per task and
|
|
65
|
+
* survives re-ensures so already-written .mcp.json files stay valid.
|
|
66
|
+
*/
|
|
67
|
+
export function ensureTaskGatewayAcl(
|
|
68
|
+
taskId: string,
|
|
69
|
+
connections: TaskMcpConnection[],
|
|
70
|
+
): TaskAcl {
|
|
71
|
+
const existing = readAcl(taskId);
|
|
72
|
+
const acl: TaskAcl = {
|
|
73
|
+
token: existing?.token ?? `${taskId}.${randomBytes(24).toString("base64url")}`,
|
|
74
|
+
connections,
|
|
75
|
+
};
|
|
76
|
+
mkdirSync(aclDir(), { recursive: true, mode: 0o700 });
|
|
77
|
+
writeFileSync(aclPath(taskId), JSON.stringify(acl), { mode: 0o600 });
|
|
78
|
+
return acl;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function clearTaskGatewayAcl(taskId: string): void {
|
|
82
|
+
try {
|
|
83
|
+
rmSync(aclPath(taskId));
|
|
84
|
+
} catch {
|
|
85
|
+
// already gone
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function audit(entry: Record<string, unknown>): void {
|
|
90
|
+
try {
|
|
91
|
+
const dir = resolve(env.dataDir, "logs");
|
|
92
|
+
mkdirSync(dir, { recursive: true });
|
|
93
|
+
appendFileSync(
|
|
94
|
+
resolve(dir, "mcp-audit.jsonl"),
|
|
95
|
+
`${JSON.stringify({ ts: Date.now(), ...entry })}\n`,
|
|
96
|
+
);
|
|
97
|
+
} catch {
|
|
98
|
+
// audit is best-effort
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function deny(res: ServerResponse, status: number, message: string): void {
|
|
103
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
104
|
+
res.end(JSON.stringify({ error: message }));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function readBody(req: IncomingMessage): Promise<Buffer | null> {
|
|
108
|
+
const chunks: Buffer[] = [];
|
|
109
|
+
let size = 0;
|
|
110
|
+
for await (const chunk of req) {
|
|
111
|
+
const buf = chunk as Buffer;
|
|
112
|
+
size += buf.length;
|
|
113
|
+
if (size > MAX_BODY_BYTES) return null;
|
|
114
|
+
chunks.push(buf);
|
|
115
|
+
}
|
|
116
|
+
return Buffer.concat(chunks);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** JSON-RPC method (+ tool name for tools/call) out of a request body. */
|
|
120
|
+
function rpcSummary(body: Buffer): { method?: string; tool?: string } {
|
|
121
|
+
try {
|
|
122
|
+
const json = JSON.parse(body.toString("utf8")) as {
|
|
123
|
+
method?: string;
|
|
124
|
+
params?: { name?: string };
|
|
125
|
+
};
|
|
126
|
+
return {
|
|
127
|
+
method: typeof json.method === "string" ? json.method : undefined,
|
|
128
|
+
tool:
|
|
129
|
+
json.method === "tools/call" && typeof json.params?.name === "string"
|
|
130
|
+
? json.params.name
|
|
131
|
+
: undefined,
|
|
132
|
+
};
|
|
133
|
+
} catch {
|
|
134
|
+
return {};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const FORWARD_REQ_HEADERS = [
|
|
139
|
+
"content-type",
|
|
140
|
+
"accept",
|
|
141
|
+
"mcp-session-id",
|
|
142
|
+
"last-event-id",
|
|
143
|
+
"mcp-protocol-version",
|
|
144
|
+
];
|
|
145
|
+
const FORWARD_RES_HEADERS = ["content-type", "mcp-session-id"];
|
|
146
|
+
|
|
147
|
+
async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
148
|
+
const match = /^\/t\/([^/]+)\/([^/?]+)\/?(?:\?.*)?$/.exec(req.url ?? "");
|
|
149
|
+
if (!match) return deny(res, 404, "not found");
|
|
150
|
+
const [, token, slug] = match as unknown as [string, string, string];
|
|
151
|
+
|
|
152
|
+
const taskId = token.split(".")[0] ?? "";
|
|
153
|
+
const acl = readAcl(taskId);
|
|
154
|
+
const expected = acl ? Buffer.from(acl.token) : null;
|
|
155
|
+
const provided = Buffer.from(token);
|
|
156
|
+
if (
|
|
157
|
+
!acl ||
|
|
158
|
+
!expected ||
|
|
159
|
+
expected.length !== provided.length ||
|
|
160
|
+
!timingSafeEqual(expected, provided)
|
|
161
|
+
) {
|
|
162
|
+
return deny(res, 401, "unknown task token");
|
|
163
|
+
}
|
|
164
|
+
const entry = acl.connections.find((c) => c.slug === slug);
|
|
165
|
+
if (!entry) return deny(res, 404, `no connection "${slug}" for this task`);
|
|
166
|
+
const conn = getConnection(entry.id);
|
|
167
|
+
if (!conn || conn.status !== "connected") {
|
|
168
|
+
// Disconnected since the task started — the kill switch answering.
|
|
169
|
+
return deny(res, 401, `connection "${slug}" is no longer available`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const body =
|
|
173
|
+
req.method === "POST" || req.method === "PUT" ? await readBody(req) : null;
|
|
174
|
+
if ((req.method === "POST" || req.method === "PUT") && body === null) {
|
|
175
|
+
return deny(res, 413, "request body too large");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const headers: Record<string, string> = {};
|
|
179
|
+
for (const name of FORWARD_REQ_HEADERS) {
|
|
180
|
+
const v = req.headers[name];
|
|
181
|
+
if (typeof v === "string") headers[name] = v;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const attempt = async (forceRefresh: boolean): Promise<Response> => {
|
|
185
|
+
const auth = await authHeaderFor(conn, forceRefresh);
|
|
186
|
+
if (auth) headers[auth.name] = auth.value;
|
|
187
|
+
return fetch(conn.url, {
|
|
188
|
+
method: req.method,
|
|
189
|
+
headers,
|
|
190
|
+
body: body ?? undefined,
|
|
191
|
+
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
|
192
|
+
});
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
let upstream: Response;
|
|
196
|
+
try {
|
|
197
|
+
upstream = await attempt(false);
|
|
198
|
+
if (upstream.status === 401 && conn.authKind === "oauth") {
|
|
199
|
+
upstream = await attempt(true);
|
|
200
|
+
}
|
|
201
|
+
} catch (err) {
|
|
202
|
+
audit({ taskId, slug, method: req.method, error: String(err) });
|
|
203
|
+
return deny(res, 502, "upstream unreachable");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const summary = body ? rpcSummary(body) : {};
|
|
207
|
+
audit({
|
|
208
|
+
taskId,
|
|
209
|
+
slug,
|
|
210
|
+
method: req.method,
|
|
211
|
+
rpc: summary.method,
|
|
212
|
+
tool: summary.tool,
|
|
213
|
+
status: upstream.status,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const resHeaders: Record<string, string> = {};
|
|
217
|
+
for (const name of FORWARD_RES_HEADERS) {
|
|
218
|
+
const v = upstream.headers.get(name);
|
|
219
|
+
if (v) resHeaders[name] = v;
|
|
220
|
+
}
|
|
221
|
+
res.writeHead(upstream.status, resHeaders);
|
|
222
|
+
if (upstream.body) {
|
|
223
|
+
Readable.fromWeb(upstream.body as Parameters<typeof Readable.fromWeb>[0]).pipe(
|
|
224
|
+
res,
|
|
225
|
+
);
|
|
226
|
+
} else {
|
|
227
|
+
res.end();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Start the gateway listener. Idempotent-ish: call once from main. */
|
|
232
|
+
export function startMcpGateway(): void {
|
|
233
|
+
const server = createServer((req, res) => {
|
|
234
|
+
void handle(req, res).catch(() => deny(res, 500, "gateway error"));
|
|
235
|
+
});
|
|
236
|
+
server.on("error", (err) => {
|
|
237
|
+
console.warn(`[mcp-gateway] listener error: ${err.message}`);
|
|
238
|
+
});
|
|
239
|
+
server.listen(MCP_GATEWAY_PORT, BIND, () => {
|
|
240
|
+
console.log(`[mcp-gateway] listening on ${BIND}:${MCP_GATEWAY_PORT}`);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// --- task container wiring (ADR-057 task-up writers) -------------------------
|
|
245
|
+
|
|
246
|
+
/** Idempotent node -e merge of entries into /workspace/.mcp.json. */
|
|
247
|
+
const MERGE_MCP_JSON = `
|
|
248
|
+
const fs = require("fs");
|
|
249
|
+
const p = "/workspace/.mcp.json";
|
|
250
|
+
let j = {};
|
|
251
|
+
try { j = JSON.parse(fs.readFileSync(p, "utf8")); } catch {}
|
|
252
|
+
j.mcpServers = j.mcpServers || {};
|
|
253
|
+
let changed = false;
|
|
254
|
+
for (const [k, v] of Object.entries(JSON.parse(process.argv[1]))) {
|
|
255
|
+
if (JSON.stringify(j.mcpServers[k]) !== JSON.stringify(v)) { j.mcpServers[k] = v; changed = true; }
|
|
256
|
+
}
|
|
257
|
+
if (changed) fs.writeFileSync(p, JSON.stringify(j, null, 2) + "\\n");
|
|
258
|
+
`.trim();
|
|
259
|
+
|
|
260
|
+
function shellQuote(value: string): string {
|
|
261
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Write the task's MCP configs inside the container: gateway-URL entries per
|
|
266
|
+
* connection for Claude (/workspace/.mcp.json, merged — coexists with the
|
|
267
|
+
* ADR-053 browser server) and mcp-remote shims for Codex (config.toml,
|
|
268
|
+
* append-once per slug). Safe to re-run every ensure.
|
|
269
|
+
*/
|
|
270
|
+
export async function setupMcpTaskConfig(
|
|
271
|
+
taskId: string,
|
|
272
|
+
containerName: string,
|
|
273
|
+
connections: TaskMcpConnection[],
|
|
274
|
+
hasCodex: boolean,
|
|
275
|
+
): Promise<void> {
|
|
276
|
+
if (connections.length === 0) return;
|
|
277
|
+
try {
|
|
278
|
+
const acl = ensureTaskGatewayAcl(taskId, connections);
|
|
279
|
+
const urlFor = (slug: string): string =>
|
|
280
|
+
`http://host.docker.internal:${MCP_GATEWAY_PORT}/t/${acl.token}/${slug}`;
|
|
281
|
+
|
|
282
|
+
const claudeEntries: Record<string, unknown> = {};
|
|
283
|
+
for (const c of connections) {
|
|
284
|
+
claudeEntries[c.slug] = { type: "http", url: urlFor(c.slug) };
|
|
285
|
+
}
|
|
286
|
+
const steps = [
|
|
287
|
+
"mkdir -p /workspace/.claude",
|
|
288
|
+
`[ -f /workspace/.claude/settings.json ] || printf '%s\\n' ${shellQuote(
|
|
289
|
+
JSON.stringify({ enableAllProjectMcpServers: true }, null, 2),
|
|
290
|
+
)} > /workspace/.claude/settings.json`,
|
|
291
|
+
`node -e ${shellQuote(MERGE_MCP_JSON)} ${shellQuote(JSON.stringify(claudeEntries))}`,
|
|
292
|
+
...(hasCodex
|
|
293
|
+
? connections.map(
|
|
294
|
+
(c) =>
|
|
295
|
+
`grep -q "mcp_servers.${c.slug}]" /home/node/.codex/config.toml 2>/dev/null || printf '%s' ${shellQuote(
|
|
296
|
+
`\n[mcp_servers.${c.slug}]\ncommand = "npx"\nargs = ["-y", "mcp-remote", "${urlFor(c.slug)}", "--allow-http"]\n`,
|
|
297
|
+
)} >> /home/node/.codex/config.toml`,
|
|
298
|
+
)
|
|
299
|
+
: []),
|
|
300
|
+
].join(" && ");
|
|
301
|
+
const result = await dockerCli(
|
|
302
|
+
["exec", containerName, "sh", "-lc", steps],
|
|
303
|
+
{ timeoutMs: 20_000 },
|
|
304
|
+
);
|
|
305
|
+
if (result.status !== 0) {
|
|
306
|
+
console.warn(
|
|
307
|
+
`[mcp-gateway] task ${taskId}: config write failed: ${result.stderr.slice(0, 300)}`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
} catch (err) {
|
|
311
|
+
console.warn(
|
|
312
|
+
`[mcp-gateway] task ${taskId}: setup failed: ${err instanceof Error ? err.message : err}`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
}
|
package/lib/orchestrator.ts
CHANGED
|
@@ -50,8 +50,14 @@ import {
|
|
|
50
50
|
loadTaskCliSecret,
|
|
51
51
|
writeAgentCli,
|
|
52
52
|
} from "./agent-cli";
|
|
53
|
+
import { setupBrowserTesting } from "./browser-testing";
|
|
54
|
+
import { setupMcpTaskConfig } from "./mcp-gateway";
|
|
53
55
|
import { env } from "./env";
|
|
54
|
-
import type {
|
|
56
|
+
import type {
|
|
57
|
+
ChannelEnsureInput,
|
|
58
|
+
ChannelHuman,
|
|
59
|
+
HostEvent,
|
|
60
|
+
} from "../src/protocol";
|
|
55
61
|
|
|
56
62
|
export type HostEventSubscriber = (event: HostEvent) => void;
|
|
57
63
|
|
|
@@ -86,6 +92,19 @@ interface Channel {
|
|
|
86
92
|
/** Per-agent respawn counter — bounded so a broken agent can't
|
|
87
93
|
* loop forever rewriting its config. */
|
|
88
94
|
respawns: Map<string, number>;
|
|
95
|
+
/** ADR-049: humans in the chat (from the latest channel spec). */
|
|
96
|
+
humans: ChannelHuman[];
|
|
97
|
+
/** ADR-053: wire the Playwright MCP browser at session start. */
|
|
98
|
+
browserTesting: boolean;
|
|
99
|
+
/** ADR-057: the owner's MCP connections exposed through the host gateway. */
|
|
100
|
+
mcpConnections: Array<{ id: string; slug: string }>;
|
|
101
|
+
/** Last connection set written into the container (skip repeat execs —
|
|
102
|
+
* ensure runs on every message). Live sessions read MCP config at spawn,
|
|
103
|
+
* so a mid-task write takes effect on the next (re)spawn. */
|
|
104
|
+
mcpConfigFingerprint?: string;
|
|
105
|
+
/** Agents with a reconcile-spawn in flight (ADR-049 mid-task adds) — guards
|
|
106
|
+
* against a concurrent ensure double-spawning the same new agent. */
|
|
107
|
+
spawning: Set<string>;
|
|
89
108
|
}
|
|
90
109
|
|
|
91
110
|
/** Hard cap on automatic respawns per agent per channel lifetime. */
|
|
@@ -133,6 +152,44 @@ class Orchestrator {
|
|
|
133
152
|
|
|
134
153
|
registerChannelSpec(spec: ChannelEnsureInput): void {
|
|
135
154
|
this.channelSpecs.set(spec.taskId, spec);
|
|
155
|
+
// ADR-049: the cloud re-sends the spec on every message AND right after a
|
|
156
|
+
// mid-task roster/participant change. If the channel is already live,
|
|
157
|
+
// fold the fresh spec in: append new roster agents (their sessions spawn
|
|
158
|
+
// in the reconcile pass of ensureSessions) and rebuild every preamble so
|
|
159
|
+
// a later respawn briefs agents with the CURRENT roster + humans.
|
|
160
|
+
const channel = this.channels.get(spec.taskId);
|
|
161
|
+
if (channel) this.refreshChannel(channel, spec);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Fold a fresh channel spec into a live channel (ADR-049). */
|
|
165
|
+
private refreshChannel(channel: Channel, spec: ChannelEnsureInput): void {
|
|
166
|
+
const known = new Set(channel.roster.map((a) => a.id));
|
|
167
|
+
for (const agent of spec.agents) {
|
|
168
|
+
if (!known.has(agent.id)) {
|
|
169
|
+
channel.roster.push(agent);
|
|
170
|
+
// Per-agent link/document skills, like getOrCreateChannel does at
|
|
171
|
+
// channel birth. Best-effort.
|
|
172
|
+
writeAgentSkills(channel.taskId, agent);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
channel.humans = spec.humans ?? [];
|
|
176
|
+
channel.browserTesting = spec.browserTesting === true;
|
|
177
|
+
channel.mcpConnections = spec.mcpConnections ?? [];
|
|
178
|
+
for (const agent of channel.roster) {
|
|
179
|
+
channel.preambles.set(
|
|
180
|
+
agent.id,
|
|
181
|
+
buildSystemPreamble(
|
|
182
|
+
channel.roster,
|
|
183
|
+
agent,
|
|
184
|
+
spec.projects,
|
|
185
|
+
spec.globalContext,
|
|
186
|
+
spec.workspacePath,
|
|
187
|
+
spec.branch,
|
|
188
|
+
channel.humans,
|
|
189
|
+
channel.browserTesting,
|
|
190
|
+
),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
136
193
|
}
|
|
137
194
|
|
|
138
195
|
private async getOrCreateChannel(taskId: string): Promise<Channel | null> {
|
|
@@ -155,6 +212,8 @@ class Orchestrator {
|
|
|
155
212
|
spec.globalContext,
|
|
156
213
|
spec.workspacePath,
|
|
157
214
|
spec.branch,
|
|
215
|
+
spec.humans,
|
|
216
|
+
spec.browserTesting,
|
|
158
217
|
),
|
|
159
218
|
);
|
|
160
219
|
// ADR-046: materialise this agent's skills to its per-agent SKILL.md in
|
|
@@ -181,6 +240,10 @@ class Orchestrator {
|
|
|
181
240
|
openTurns: new Set(),
|
|
182
241
|
interrupted: new Set(),
|
|
183
242
|
respawns: new Map(),
|
|
243
|
+
humans: spec.humans ?? [],
|
|
244
|
+
browserTesting: spec.browserTesting === true,
|
|
245
|
+
mcpConnections: spec.mcpConnections ?? [],
|
|
246
|
+
spawning: new Set(),
|
|
184
247
|
};
|
|
185
248
|
this.channels.set(taskId, channel);
|
|
186
249
|
return channel;
|
|
@@ -197,7 +260,7 @@ class Orchestrator {
|
|
|
197
260
|
*
|
|
198
261
|
* Returns whether sessions are ready.
|
|
199
262
|
*/
|
|
200
|
-
private ensureSessions(channel: Channel): Promise<boolean> {
|
|
263
|
+
private async ensureSessions(channel: Channel): Promise<boolean> {
|
|
201
264
|
// Memoized: every caller awaits the SAME in-flight start, so a concurrent
|
|
202
265
|
// deliver() can't observe "ready" while the sessions map is still empty
|
|
203
266
|
// (startSessions awaits docker work before populating it — the old boolean
|
|
@@ -215,7 +278,86 @@ class Orchestrator {
|
|
|
215
278
|
},
|
|
216
279
|
);
|
|
217
280
|
}
|
|
218
|
-
|
|
281
|
+
const ready = await channel.sessionsReady;
|
|
282
|
+
// ADR-049 reconcile pass: spawn any roster agent that has no live session
|
|
283
|
+
// yet — the initial batch, host-restart recovery, and mid-task adds all
|
|
284
|
+
// converge here. No-op when every roster agent has a session.
|
|
285
|
+
if (ready) {
|
|
286
|
+
await this.reconcileSessions(channel);
|
|
287
|
+
// ADR-057: keep the container's MCP configs current so a connection
|
|
288
|
+
// added mid-task lands (effective at each session's next spawn).
|
|
289
|
+
await this.ensureMcpConfig(channel);
|
|
290
|
+
}
|
|
291
|
+
return ready;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Write the task's MCP configs when the connection set changed. */
|
|
295
|
+
private async ensureMcpConfig(channel: Channel): Promise<void> {
|
|
296
|
+
const fingerprint = JSON.stringify(channel.mcpConnections.map((c) => c.id));
|
|
297
|
+
if (channel.mcpConfigFingerprint === fingerprint) return;
|
|
298
|
+
channel.mcpConfigFingerprint = fingerprint;
|
|
299
|
+
await setupMcpTaskConfig(
|
|
300
|
+
channel.taskId,
|
|
301
|
+
channel.containerName,
|
|
302
|
+
channel.mcpConnections,
|
|
303
|
+
channel.roster.some((a) => a.kind === "codex"),
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Spawn sessions for roster agents added after the initial start. */
|
|
308
|
+
private async reconcileSessions(channel: Channel): Promise<void> {
|
|
309
|
+
const missing = channel.roster.filter(
|
|
310
|
+
(agent) =>
|
|
311
|
+
!channel.sessions.has(agent.id) &&
|
|
312
|
+
!channel.spawning.has(agent.id) &&
|
|
313
|
+
// Crash-loop budget: an agent whose session keeps dying stops being
|
|
314
|
+
// respawned after MAX_RESPAWNS_PER_AGENT (the exit paths increment).
|
|
315
|
+
(channel.respawns.get(agent.id) ?? 0) <= MAX_RESPAWNS_PER_AGENT,
|
|
316
|
+
);
|
|
317
|
+
if (missing.length === 0) return;
|
|
318
|
+
|
|
319
|
+
const task = getHostTask(channel.taskId);
|
|
320
|
+
if (!task || task.statusMirror !== "running") return;
|
|
321
|
+
|
|
322
|
+
for (const agent of missing) channel.spawning.add(agent.id);
|
|
323
|
+
try {
|
|
324
|
+
// Same per-agent materialisation the initial start does: package
|
|
325
|
+
// skills (idempotent — only the new agents' installs run), and the
|
|
326
|
+
// shared `uai` CLI rewritten for the full roster.
|
|
327
|
+
await installPackageSkills(channel.taskId, missing);
|
|
328
|
+
if (channel.browserTesting) {
|
|
329
|
+
await setupBrowserTesting(
|
|
330
|
+
channel.taskId,
|
|
331
|
+
channel.containerName,
|
|
332
|
+
channel.roster.some((a) => a.kind === "codex"),
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
const apiUrl = apiUrlFromCloudUrl(env.UAI_CLOUD_URL);
|
|
336
|
+
const cliSecret = loadTaskCliSecret(channel.taskId);
|
|
337
|
+
writeAgentCli(channel.taskId, channel.roster, apiUrl);
|
|
338
|
+
|
|
339
|
+
for (const agent of missing) {
|
|
340
|
+
const session = await this.factory.create({
|
|
341
|
+
taskId: channel.taskId,
|
|
342
|
+
agent,
|
|
343
|
+
containerName: channel.containerName,
|
|
344
|
+
systemPreamble: channel.preambles.get(agent.id) ?? "",
|
|
345
|
+
agentEnv: agentCliEnv(
|
|
346
|
+
channel.taskId,
|
|
347
|
+
agent,
|
|
348
|
+
task.ownerUserId,
|
|
349
|
+
apiUrl,
|
|
350
|
+
cliSecret,
|
|
351
|
+
),
|
|
352
|
+
});
|
|
353
|
+
channel.sessions.set(agent.id, session);
|
|
354
|
+
session.onEvent((event) => {
|
|
355
|
+
void this.handleAgentEvent(channel, agent.id, event);
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
} finally {
|
|
359
|
+
for (const agent of missing) channel.spawning.delete(agent.id);
|
|
360
|
+
}
|
|
219
361
|
}
|
|
220
362
|
|
|
221
363
|
private async startSessions(channel: Channel): Promise<boolean> {
|
|
@@ -246,6 +388,21 @@ class Orchestrator {
|
|
|
246
388
|
// tasks. Never throws.
|
|
247
389
|
await installPackageSkills(channel.taskId, channel.roster);
|
|
248
390
|
|
|
391
|
+
// ADR-053: wire the Playwright MCP browser (configs + backgrounded
|
|
392
|
+
// Chromium install) before agents spawn. Idempotent + best-effort.
|
|
393
|
+
if (channel.browserTesting) {
|
|
394
|
+
await setupBrowserTesting(
|
|
395
|
+
channel.taskId,
|
|
396
|
+
channel.containerName,
|
|
397
|
+
channel.roster.some((a) => a.kind === "codex"),
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ADR-057: the owner's MCP connections, reached through the host gateway
|
|
402
|
+
// (tokens never enter the container) — written BEFORE agents spawn so
|
|
403
|
+
// first sessions load them.
|
|
404
|
+
await this.ensureMcpConfig(channel);
|
|
405
|
+
|
|
249
406
|
// ADR-048: write the in-container `uai` CLI (apiUrl only, no token) into the
|
|
250
407
|
// workspace. Each agent's OWN task token — carrying only ITS permissions — is
|
|
251
408
|
// injected per-agent via its docker exec env below, so per-persona permissions
|
|
@@ -449,6 +606,14 @@ class Orchestrator {
|
|
|
449
606
|
agentId,
|
|
450
607
|
reason: event.message,
|
|
451
608
|
});
|
|
609
|
+
// The session is DEAD — drop it so the reconciling ensureSessions
|
|
610
|
+
// respawns it on the next delivery/ensure. Without this the agent
|
|
611
|
+
// becomes a zombie: the map still holds the dead session and every
|
|
612
|
+
// later deliver "succeeds" into a closed pipe (found live 2026-07-08
|
|
613
|
+
// after a double SIGKILL). Bounded by the respawn budget, checked in
|
|
614
|
+
// reconcileSessions.
|
|
615
|
+
channel.respawns.set(agentId, (channel.respawns.get(agentId) ?? 0) + 1);
|
|
616
|
+
channel.sessions.delete(agentId);
|
|
452
617
|
break;
|
|
453
618
|
}
|
|
454
619
|
case "turn_complete": {
|
|
@@ -467,6 +632,10 @@ class Orchestrator {
|
|
|
467
632
|
break;
|
|
468
633
|
}
|
|
469
634
|
case "exit":
|
|
635
|
+
// Same zombie hazard as the error path — a session whose process
|
|
636
|
+
// ended (even cleanly) can never carry another turn.
|
|
637
|
+
channel.respawns.set(agentId, (channel.respawns.get(agentId) ?? 0) + 1);
|
|
638
|
+
channel.sessions.delete(agentId);
|
|
470
639
|
break;
|
|
471
640
|
}
|
|
472
641
|
}
|
|
@@ -754,12 +923,49 @@ export function buildSystemPreamble(
|
|
|
754
923
|
globalContext: string | undefined,
|
|
755
924
|
workspacePath: string,
|
|
756
925
|
taskBranch: string,
|
|
926
|
+
humans?: ChannelHuman[],
|
|
927
|
+
browserTesting?: boolean,
|
|
757
928
|
): string {
|
|
758
929
|
const channelList = roster
|
|
759
930
|
.map((a) =>
|
|
760
931
|
a.id === agent.id ? `@${a.id} (${a.label}, you)` : `@${a.id} (${a.label})`,
|
|
761
932
|
)
|
|
762
933
|
.join(", ");
|
|
934
|
+
// ADR-049: with several humans in the chat, brief the agent on who they are
|
|
935
|
+
// and how to address one specifically. Single-human tasks keep the original
|
|
936
|
+
// wording byte-identical.
|
|
937
|
+
const multiHuman = (humans?.length ?? 0) > 1;
|
|
938
|
+
const humanList = (humans ?? [])
|
|
939
|
+
.map((h) => `@${h.handle} (${h.name}${h.isOwner ? ", task owner" : ""})`)
|
|
940
|
+
.join(", ");
|
|
941
|
+
const humanIntro = multiHuman
|
|
942
|
+
? [
|
|
943
|
+
`SEVERAL humans share this channel: ${humanList}. Their messages`,
|
|
944
|
+
"arrive prefixed with the sender's name so you can tell them apart.",
|
|
945
|
+
"`@you` still works and reaches the human you're currently talking",
|
|
946
|
+
"to (whoever last addressed you); to reach a SPECIFIC human, mention",
|
|
947
|
+
"their handle instead (e.g. `@" +
|
|
948
|
+
(humans?.[0]?.handle ?? "name") +
|
|
949
|
+
"`). Mentioning a human sends a",
|
|
950
|
+
"NOTIFICATION, so use it sparingly — only when you actually need",
|
|
951
|
+
"them: a decision you can't make, a blocker, an approval, or you've",
|
|
952
|
+
"finished your work and are handing it back. For everything else —",
|
|
953
|
+
"status updates, thinking out loud, a direct reply to something they",
|
|
954
|
+
"just asked, acknowledgments — post in the channel WITHOUT",
|
|
955
|
+
"@-mentioning; they can read the channel and don't need a ping for",
|
|
956
|
+
"every message.",
|
|
957
|
+
]
|
|
958
|
+
: [
|
|
959
|
+
"The human you're working with is **@you**. @-mentioning them sends a",
|
|
960
|
+
"NOTIFICATION, so use it sparingly — only when you actually need them: a",
|
|
961
|
+
"decision you can't make, a blocker, an approval, or you've finished your",
|
|
962
|
+
"work and are handing it back for them to act on (e.g. `@you which`",
|
|
963
|
+
"`approach do you prefer?` or `@you done — PR is up for review`). For",
|
|
964
|
+
"everything else — status updates, thinking out loud, a direct reply to",
|
|
965
|
+
"something they just asked, acknowledgments — post in the channel WITHOUT",
|
|
966
|
+
"@-mentioning @you; they can read the channel and don't need a ping for",
|
|
967
|
+
"every message. Do NOT reflexively end messages with @you.",
|
|
968
|
+
];
|
|
763
969
|
const projectLines =
|
|
764
970
|
projects.length === 0
|
|
765
971
|
? ["(none mounted)"]
|
|
@@ -781,15 +987,7 @@ export function buildSystemPreamble(
|
|
|
781
987
|
"",
|
|
782
988
|
`Agents in this channel: ${channelList}.`,
|
|
783
989
|
"",
|
|
784
|
-
|
|
785
|
-
"NOTIFICATION, so use it sparingly — only when you actually need them: a",
|
|
786
|
-
"decision you can't make, a blocker, an approval, or you've finished your",
|
|
787
|
-
"work and are handing it back for them to act on (e.g. `@you which`",
|
|
788
|
-
"`approach do you prefer?` or `@you done — PR is up for review`). For",
|
|
789
|
-
"everything else — status updates, thinking out loud, a direct reply to",
|
|
790
|
-
"something they just asked, acknowledgments — post in the channel WITHOUT",
|
|
791
|
-
"@-mentioning @you; they can read the channel and don't need a ping for",
|
|
792
|
-
"every message. Do NOT reflexively end messages with @you.",
|
|
990
|
+
...humanIntro,
|
|
793
991
|
"",
|
|
794
992
|
"An agent only receives a message when it is explicitly @-mentioned",
|
|
795
993
|
"(or addressed by the human) — so always @-mention the agent (or @you)",
|
|
@@ -827,6 +1025,15 @@ export function buildSystemPreamble(
|
|
|
827
1025
|
"context (e.g. the human shared a file or instruction with another",
|
|
828
1026
|
"agent); it's appended live, so re-read it for the latest.",
|
|
829
1027
|
"",
|
|
1028
|
+
"Two channel conventions (ADR-050): (1) If your reply @-mentions nobody,",
|
|
1029
|
+
"uai hands it back to whoever prompted you — so when you're ANSWERING,",
|
|
1030
|
+
"just answer plainly; you don't need to re-mention the asker. Mention",
|
|
1031
|
+
"someone only to bring them in or hand work off. (2) You may occasionally",
|
|
1032
|
+
"receive a `[channel check-in]` asking you to catch up on the channel.",
|
|
1033
|
+
"Read the transcript, and speak ONLY if you have something substantive to",
|
|
1034
|
+
"add; otherwise reply with exactly `PASS` — a PASS reply is discarded and",
|
|
1035
|
+
"never shown to anyone, so it is always a safe way to decline a turn.",
|
|
1036
|
+
"",
|
|
830
1037
|
"Hand off when you finish your part of the work. When you've made",
|
|
831
1038
|
"and committed your changes, or completed a review, end your reply by",
|
|
832
1039
|
"@-mentioning the agent who should act next and telling them what you",
|
|
@@ -886,6 +1093,28 @@ export function buildSystemPreamble(
|
|
|
886
1093
|
"",
|
|
887
1094
|
]
|
|
888
1095
|
: []),
|
|
1096
|
+
// ADR-053: the in-container browser, when the project opted in.
|
|
1097
|
+
...(browserTesting
|
|
1098
|
+
? [
|
|
1099
|
+
"## Browser",
|
|
1100
|
+
"",
|
|
1101
|
+
"This container has a headless Chromium available through the",
|
|
1102
|
+
"`browser` MCP server (Playwright). Use it to VERIFY UI work",
|
|
1103
|
+
"end-to-end — the dev server you're building runs in this same",
|
|
1104
|
+
"container, so navigate to `http://localhost:<port>` directly.",
|
|
1105
|
+
"Prefer accessibility snapshots for navigation and assertions;",
|
|
1106
|
+
"take an actual screenshot only when rendering matters (vision",
|
|
1107
|
+
"input is expensive). To show a screenshot in the chat, save it",
|
|
1108
|
+
"under `/workspace/.uai/attachments/<name>.png` and reference it",
|
|
1109
|
+
"in your reply as `[image: /workspace/.uai/attachments/<name>.png]`.",
|
|
1110
|
+
"Your browser runs on a virtual display the humans can WATCH live",
|
|
1111
|
+
"(the \"browser\" preview) — nothing for you to do about that.",
|
|
1112
|
+
"The first browser launch in a fresh container may take a minute",
|
|
1113
|
+
"while Chromium's system deps finish installing — retry once if",
|
|
1114
|
+
"it fails immediately after task start.",
|
|
1115
|
+
"",
|
|
1116
|
+
]
|
|
1117
|
+
: []),
|
|
889
1118
|
// ADR-048: tell agents with permissions about their `uai` CLI.
|
|
890
1119
|
...((agent.permissions?.length ?? 0) > 0
|
|
891
1120
|
? [
|
|
@@ -904,7 +1133,12 @@ export function buildSystemPreamble(
|
|
|
904
1133
|
(agent.permissions?.includes("memory.read")
|
|
905
1134
|
? ", `memory search <query>`"
|
|
906
1135
|
: "") +
|
|
907
|
-
"
|
|
1136
|
+
", `react <heart|check|x> [--msg #id]`.",
|
|
1137
|
+
"**Reacting:** `uai react check` is a lightweight ack of the message",
|
|
1138
|
+
"you were last handed — use it to acknowledge an instruction or",
|
|
1139
|
+
"approve a proposal without spending a whole reply (❌ = disagree,",
|
|
1140
|
+
"❤️ = appreciation). Messages in chat.md carry their `#id` if you",
|
|
1141
|
+
"want to react to an older one. Reactions wake nobody.",
|
|
908
1142
|
...(agent.permissions?.includes("tasks.create")
|
|
909
1143
|
? [
|
|
910
1144
|
"**Creating a task in Uai:** when a human asks you to create/file/",
|