@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
package/lib/standard-image.ts
CHANGED
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { spawn } from "node:child_process";
|
|
20
|
-
import {
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
22
|
+
import { dirname, join, resolve } from "node:path";
|
|
21
23
|
import { fileURLToPath } from "node:url";
|
|
22
24
|
|
|
23
25
|
/** Pinned, host-wide constants (must match task-up.sh and the compose gen). */
|
|
@@ -57,6 +59,40 @@ export function standardRuntimes(): Array<{
|
|
|
57
59
|
}));
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
/** Image label carrying the build-context content hash (rebuild trigger). */
|
|
63
|
+
const CONTEXT_HASH_LABEL = "com.runuai.context-hash";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Content-hash the build context (every file under images/standard, sorted
|
|
67
|
+
* by relative path). Null when the context can't be read — the caller then
|
|
68
|
+
* keeps whatever image exists.
|
|
69
|
+
*/
|
|
70
|
+
async function hashBuildContext(): Promise<string | null> {
|
|
71
|
+
try {
|
|
72
|
+
const root = standardImageDir();
|
|
73
|
+
const files: string[] = [];
|
|
74
|
+
const walk = async (dir: string, prefix: string): Promise<void> => {
|
|
75
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
76
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
77
|
+
if (entry.isDirectory()) await walk(join(dir, entry.name), rel);
|
|
78
|
+
else files.push(rel);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
await walk(root, "");
|
|
82
|
+
files.sort();
|
|
83
|
+
const hash = createHash("sha256");
|
|
84
|
+
for (const rel of files) {
|
|
85
|
+
hash.update(rel);
|
|
86
|
+
hash.update("\0");
|
|
87
|
+
hash.update(await readFile(join(root, rel)));
|
|
88
|
+
hash.update("\0");
|
|
89
|
+
}
|
|
90
|
+
return hash.digest("hex").slice(0, 32);
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
60
96
|
/** Absolute path to the standard image build context. */
|
|
61
97
|
function standardImageDir(): string {
|
|
62
98
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
@@ -177,6 +213,55 @@ async function ensureVolumeAgentClis(): Promise<void> {
|
|
|
177
213
|
}
|
|
178
214
|
}
|
|
179
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Upgrade the shared volume's agent CLIs to latest — once per HOST START, not
|
|
218
|
+
* per task. Per-task installs would add npm latency to every task-up, race
|
|
219
|
+
* concurrent starts on the shared volume, and give a broken vendor release a
|
|
220
|
+
* fleet-wide blast radius with no rollback; host restarts are the deliberate
|
|
221
|
+
* update moment (sessions respawn then anyway, and a bad release is one
|
|
222
|
+
* `UAI_AGENT_CLI_AUTOUPDATE=0` + manual pin away from contained). Running
|
|
223
|
+
* sessions keep their already-loaded process; new spawns pick up the new bin.
|
|
224
|
+
* Best-effort: an offline registry just logs and keeps the current versions.
|
|
225
|
+
*/
|
|
226
|
+
async function upgradeVolumeAgentClis(): Promise<void> {
|
|
227
|
+
if (process.env.UAI_AGENT_CLI_AUTOUPDATE === "0") {
|
|
228
|
+
console.log("[host-agent] agent CLI auto-update disabled (UAI_AGENT_CLI_AUTOUPDATE=0)");
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
const pkgs = VOLUME_AGENT_CLIS.map((c) => `${c.pkg}@latest`).join(" ");
|
|
232
|
+
const bins = VOLUME_AGENT_CLIS.map((c) => c.bin);
|
|
233
|
+
// npm's in-place upgrade renames the old package dir, which fails with
|
|
234
|
+
// ENOTEMPTY while (or right after) live sessions held it open through the
|
|
235
|
+
// volume. Fallback: remove the scoped dirs and install fresh — safe at host
|
|
236
|
+
// start, when the previous sessions' execs are gone.
|
|
237
|
+
const scopeDirs = "/opt/asdf-data/installs/nodejs/*/lib/node_modules/@anthropic-ai /opt/asdf-data/installs/nodejs/*/lib/node_modules/@openai";
|
|
238
|
+
const upgrade = await run("docker", [
|
|
239
|
+
"run",
|
|
240
|
+
"--rm",
|
|
241
|
+
"-u",
|
|
242
|
+
"root",
|
|
243
|
+
"-w",
|
|
244
|
+
"/home/node",
|
|
245
|
+
"-v",
|
|
246
|
+
`${ASDF_DATA_VOLUME}:/opt/asdf-data`,
|
|
247
|
+
STANDARD_IMAGE_TAG,
|
|
248
|
+
"bash",
|
|
249
|
+
"-lc",
|
|
250
|
+
`npm install -g ${pkgs} >/dev/null 2>&1 || { rm -rf ${scopeDirs}; npm install -g ${pkgs} >/dev/null 2>&1; } && asdf reshim nodejs >/dev/null 2>&1; ` +
|
|
251
|
+
bins.map((b) => `printf '%s ' "$(${b} --version 2>/dev/null | head -1)"`).join("; "),
|
|
252
|
+
]);
|
|
253
|
+
if (upgrade.code === 0) {
|
|
254
|
+
console.log(
|
|
255
|
+
`[host-agent] agent CLIs current on ${ASDF_DATA_VOLUME}: ${upgrade.stdout.trim()}`,
|
|
256
|
+
);
|
|
257
|
+
} else {
|
|
258
|
+
console.warn(
|
|
259
|
+
`[host-agent] agent CLI upgrade skipped (exit ${upgrade.code ?? "spawn"}) — ` +
|
|
260
|
+
`tasks keep the volume's current versions. ${upgrade.stderr.trim().slice(0, 200)}`,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
180
265
|
/**
|
|
181
266
|
* Ensure the standard image and the shared asdf data volume exist, and that the
|
|
182
267
|
* agent CLIs are present inside the volume. Builds the image only when `docker
|
|
@@ -196,24 +281,51 @@ export async function ensureStandardImage(): Promise<void> {
|
|
|
196
281
|
if (vol.code === null) return;
|
|
197
282
|
}
|
|
198
283
|
|
|
199
|
-
// 2. Ensure the image is built.
|
|
284
|
+
// 2. Ensure the image is built AND current. "Present" is not enough — a
|
|
285
|
+
// Dockerfile change used to no-op forever because we only built when
|
|
286
|
+
// inspect failed (found live 2026-07-08: ADR-053's baked packages never
|
|
287
|
+
// landed). The build context is content-hashed into an image label;
|
|
288
|
+
// a mismatch triggers a rebuild (layer cache keeps it cheap).
|
|
200
289
|
let imageReady = false;
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
290
|
+
const contextHash = await hashBuildContext();
|
|
291
|
+
const inspect = await run("docker", [
|
|
292
|
+
"image",
|
|
293
|
+
"inspect",
|
|
294
|
+
"-f",
|
|
295
|
+
`{{index .Config.Labels "${CONTEXT_HASH_LABEL}"}}`,
|
|
296
|
+
STANDARD_IMAGE_TAG,
|
|
297
|
+
]);
|
|
298
|
+
if (inspect.code === null) {
|
|
206
299
|
console.warn(
|
|
207
300
|
"[host-agent] docker unavailable; skipping standard image build. " +
|
|
208
301
|
"Tasks will fail until docker is running.",
|
|
209
302
|
);
|
|
210
303
|
return;
|
|
304
|
+
}
|
|
305
|
+
const labeledHash = inspect.code === 0 ? inspect.stdout.trim() : null;
|
|
306
|
+
if (inspect.code === 0 && contextHash !== null && labeledHash === contextHash) {
|
|
307
|
+
console.log(`[host-agent] standard image ${STANDARD_IMAGE_TAG} current`);
|
|
308
|
+
imageReady = true;
|
|
309
|
+
} else if (inspect.code === 0 && contextHash === null) {
|
|
310
|
+
// Can't hash (packaged install without the context?) — keep the image.
|
|
311
|
+
console.log(`[host-agent] standard image ${STANDARD_IMAGE_TAG} present`);
|
|
312
|
+
imageReady = true;
|
|
211
313
|
} else {
|
|
212
314
|
const context = standardImageDir();
|
|
213
315
|
console.log(
|
|
214
|
-
|
|
316
|
+
inspect.code === 0
|
|
317
|
+
? `[host-agent] standard image ${STANDARD_IMAGE_TAG} stale (context changed) — rebuilding from ${context}`
|
|
318
|
+
: `[host-agent] building standard image ${STANDARD_IMAGE_TAG} from ${context}`,
|
|
215
319
|
);
|
|
216
|
-
const build = await run("docker", [
|
|
320
|
+
const build = await run("docker", [
|
|
321
|
+
"build",
|
|
322
|
+
"-t",
|
|
323
|
+
STANDARD_IMAGE_TAG,
|
|
324
|
+
...(contextHash !== null
|
|
325
|
+
? ["--label", `${CONTEXT_HASH_LABEL}=${contextHash}`]
|
|
326
|
+
: []),
|
|
327
|
+
context,
|
|
328
|
+
]);
|
|
217
329
|
if (build.code === 0) {
|
|
218
330
|
console.log(`[host-agent] built standard image ${STANDARD_IMAGE_TAG}`);
|
|
219
331
|
imageReady = true;
|
|
@@ -223,10 +335,15 @@ export async function ensureStandardImage(): Promise<void> {
|
|
|
223
335
|
"continuing. Tasks needing the image will surface this error.\n" +
|
|
224
336
|
build.stderr.trim(),
|
|
225
337
|
);
|
|
338
|
+
// A stale-but-working image is better than none.
|
|
339
|
+
imageReady = labeledHash !== null;
|
|
226
340
|
}
|
|
227
341
|
}
|
|
228
342
|
|
|
229
343
|
// 3. Reconcile the agent CLIs into the shared volume (it shadows the image's
|
|
230
344
|
// shims, so a stale volume can be missing one — the claude-127 bug).
|
|
231
|
-
if (imageReady)
|
|
345
|
+
if (imageReady) {
|
|
346
|
+
await ensureVolumeAgentClis();
|
|
347
|
+
await upgradeVolumeAgentClis();
|
|
348
|
+
}
|
|
232
349
|
}
|
package/package.json
CHANGED
package/scripts/agent/task-up.sh
CHANGED
|
@@ -34,6 +34,8 @@ setup_err_trap
|
|
|
34
34
|
# Pinned constants (all clusters must agree — see ADR-022).
|
|
35
35
|
STANDARD_IMAGE="uai-standard:dev"
|
|
36
36
|
ASDF_VOLUME="uai-asdf-data"
|
|
37
|
+
# ADR-053: host-wide Playwright browser cache (Chromium downloads once).
|
|
38
|
+
PW_VOLUME="uai-playwright"
|
|
37
39
|
DERIVED_IMAGE="uai-task-${task_id}"
|
|
38
40
|
|
|
39
41
|
# -----------------------------------------------------------------------------
|
|
@@ -308,6 +310,9 @@ fi
|
|
|
308
310
|
"$projects_root" "$vol_pid" "$projects_root" "$vol_pid"
|
|
309
311
|
done < <(jq -c '.[]' <<<"$projects_json")
|
|
310
312
|
printf ' - "%s:/opt/asdf-data"\n' "$ASDF_VOLUME"
|
|
313
|
+
# ADR-053: host-wide Playwright browser cache. Always mounted (harmless
|
|
314
|
+
# when browser testing is off); Chromium downloads once per HOST.
|
|
315
|
+
printf ' - "%s:/opt/pw-browsers"\n' "$PW_VOLUME"
|
|
311
316
|
printf ' ports:\n'
|
|
312
317
|
# code-server (the Editor tunnel) is the only auto-published port. Preview
|
|
313
318
|
# ports are NOT published at task-up (ADR-043 Stage 2: opt-in previews) —
|
|
@@ -324,6 +329,8 @@ fi
|
|
|
324
329
|
# claude and the code-server terminal's interactive claude authenticate.
|
|
325
330
|
printf ' %s: "${%s:-}"\n' \
|
|
326
331
|
"CLAUDE_CODE_OAUTH_TOKEN" "CLAUDE_CODE_OAUTH_TOKEN"
|
|
332
|
+
# ADR-053: point Playwright at the shared browser cache volume.
|
|
333
|
+
printf ' PLAYWRIGHT_BROWSERS_PATH: "/opt/pw-browsers"\n'
|
|
327
334
|
# No GH_TOKEN/GITHUB_TOKEN in the container env (ADR-027). `gh` prefers such
|
|
328
335
|
# an env var over its stored credentials, which blocks the per-user
|
|
329
336
|
# `gh auth login --with-token` the host runs (and would re-attribute every PR
|
|
@@ -361,6 +368,8 @@ fi
|
|
|
361
368
|
printf 'volumes:\n'
|
|
362
369
|
printf ' %s:\n' "$ASDF_VOLUME"
|
|
363
370
|
printf ' external: true\n'
|
|
371
|
+
printf ' %s:\n' "$PW_VOLUME"
|
|
372
|
+
printf ' external: true\n'
|
|
364
373
|
} > "$task_uai_dir/docker-compose.yml"
|
|
365
374
|
|
|
366
375
|
if [ "$has_derived" = "1" ]; then
|
|
@@ -381,6 +390,7 @@ fi
|
|
|
381
390
|
# The shared asdf cache volume is host-wide and declared external — make
|
|
382
391
|
# sure it exists before compose references it. Idempotent.
|
|
383
392
|
docker volume create "$ASDF_VOLUME" >/dev/null 2>&1 || true
|
|
393
|
+
docker volume create "$PW_VOLUME" >/dev/null 2>&1 || true
|
|
384
394
|
|
|
385
395
|
step "COMPOSE_UP_FAILED" "docker compose up -d"
|
|
386
396
|
if [ "$has_derived" = "1" ]; then
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { readAttachment, writeAttachment } from "../lib/attachments";
|
|
|
7
7
|
import { appendTranscript as writeTranscript } from "../lib/transcript";
|
|
8
8
|
import { buildTaskDiff } from "../lib/task-diff";
|
|
9
9
|
import {
|
|
10
|
+
getHostTask,
|
|
10
11
|
recordHostEvent,
|
|
11
12
|
recordTaskDown,
|
|
12
13
|
recordTaskError,
|
|
@@ -14,6 +15,9 @@ import {
|
|
|
14
15
|
recordTaskStarting,
|
|
15
16
|
recordTaskUpResult,
|
|
16
17
|
} from "../lib/runtime-state";
|
|
18
|
+
import { clearTaskGatewayAcl } from "../lib/mcp-gateway";
|
|
19
|
+
import { parsePreviewPortRuntimes } from "../lib/preview-ports";
|
|
20
|
+
import { ensurePreviewSidecar } from "../lib/preview-sidecar";
|
|
17
21
|
import {
|
|
18
22
|
HostErrorCode,
|
|
19
23
|
type CommandContext,
|
|
@@ -145,7 +149,21 @@ export const hostCommands: HostCommands = {
|
|
|
145
149
|
|
|
146
150
|
async taskStatus(ctx, taskId) {
|
|
147
151
|
logCommand(ctx, "taskStatus", taskId);
|
|
148
|
-
|
|
152
|
+
const result = await wrapAgent(ctx, "taskStatus", () =>
|
|
153
|
+
agent.taskStatus(taskId),
|
|
154
|
+
);
|
|
155
|
+
// ADR-051: attach the published preview ports from the host DB so the
|
|
156
|
+
// cloud can backfill its mirror on reconcile (tasks launched before the
|
|
157
|
+
// cloud started recording them at task-up).
|
|
158
|
+
if (result.ok) {
|
|
159
|
+
const ports = parsePreviewPortRuntimes(
|
|
160
|
+
getHostTask(taskId)?.previewPorts,
|
|
161
|
+
);
|
|
162
|
+
if (ports.length > 0) {
|
|
163
|
+
return { ...result, value: { ...result.value, previewPorts: ports } };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return result;
|
|
149
167
|
},
|
|
150
168
|
|
|
151
169
|
async channelEnsure(ctx, input) {
|
|
@@ -163,6 +181,8 @@ export const hostCommands: HostCommands = {
|
|
|
163
181
|
logCommand(ctx, "channelTeardown", taskId);
|
|
164
182
|
try {
|
|
165
183
|
await getOrchestrator().closeChannel(taskId);
|
|
184
|
+
// ADR-057: the task's gateway routes die with the channel.
|
|
185
|
+
clearTaskGatewayAcl(taskId);
|
|
166
186
|
return ok(undefined);
|
|
167
187
|
} catch (err) {
|
|
168
188
|
return failFromUnknown(err);
|
|
@@ -186,6 +206,37 @@ export const hostCommands: HostCommands = {
|
|
|
186
206
|
}
|
|
187
207
|
},
|
|
188
208
|
|
|
209
|
+
async previewEnsure(ctx, taskId, name, containerPort) {
|
|
210
|
+
logCommand(ctx, "previewEnsure", taskId, name);
|
|
211
|
+
try {
|
|
212
|
+
const task = getHostTask(taskId);
|
|
213
|
+
if (!task) {
|
|
214
|
+
return {
|
|
215
|
+
ok: false as const,
|
|
216
|
+
code: HostErrorCode.TaskNotFound,
|
|
217
|
+
message: `no such task: ${taskId}`,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
// Task-up published port (preview enabled at launch) wins.
|
|
221
|
+
const declared = parsePreviewPortRuntimes(task.previewPorts).find(
|
|
222
|
+
(port) => port.name === name,
|
|
223
|
+
);
|
|
224
|
+
if (declared) return ok({ hostPort: declared.hostPort });
|
|
225
|
+
// Else start (or find) the node-proxy sidecar NOW — the same one the
|
|
226
|
+
// tunnel lazy-starts on first access — and hand back its host port.
|
|
227
|
+
if (!task.composeProject) return ok({ hostPort: null });
|
|
228
|
+
const hostPort = await ensurePreviewSidecar({
|
|
229
|
+
taskId,
|
|
230
|
+
composeProject: task.composeProject,
|
|
231
|
+
name,
|
|
232
|
+
containerPort,
|
|
233
|
+
});
|
|
234
|
+
return ok({ hostPort });
|
|
235
|
+
} catch (err) {
|
|
236
|
+
return failFromUnknown(err);
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
|
|
189
240
|
async channelResolvePermission(ctx, taskId, agentId, requestId, decision) {
|
|
190
241
|
logCommand(ctx, "channelResolvePermission", taskId, agentId, requestId);
|
|
191
242
|
try {
|
package/src/main.ts
CHANGED
|
@@ -41,6 +41,8 @@ import {
|
|
|
41
41
|
listProjectEnvKeys,
|
|
42
42
|
setProjectEnvVar,
|
|
43
43
|
} from "../lib/host-env";
|
|
44
|
+
import { handleMcpOp } from "../lib/mcp-connections";
|
|
45
|
+
import { startMcpGateway } from "../lib/mcp-gateway";
|
|
44
46
|
import {
|
|
45
47
|
packageVersion,
|
|
46
48
|
serviceLogPath,
|
|
@@ -68,6 +70,7 @@ import { hostCommands, hostEvents } from "./index";
|
|
|
68
70
|
import {
|
|
69
71
|
HostErrorCode,
|
|
70
72
|
type CloudToHost,
|
|
73
|
+
type McpOp,
|
|
71
74
|
type CommandContext,
|
|
72
75
|
type ChannelEnsureInput,
|
|
73
76
|
type HostCapabilities,
|
|
@@ -147,6 +150,8 @@ connect();
|
|
|
147
150
|
// Local browser UI (ADR-028) — same single process, alongside the WSS client.
|
|
148
151
|
// Best-effort: a UI bind failure must not take the host service down.
|
|
149
152
|
void startLocalUi();
|
|
153
|
+
// ADR-057: the MCP gateway task containers reach via host.docker.internal.
|
|
154
|
+
startMcpGateway();
|
|
150
155
|
|
|
151
156
|
async function startLocalUi(): Promise<void> {
|
|
152
157
|
try {
|
|
@@ -172,6 +177,7 @@ async function startLocalUi(): Promise<void> {
|
|
|
172
177
|
/** Build the current host capability advertisement (ADR-021). */
|
|
173
178
|
function buildCapabilities(): HostCapabilities {
|
|
174
179
|
return {
|
|
180
|
+
version: packageVersion(),
|
|
175
181
|
agentKinds: agentKindCapabilities(),
|
|
176
182
|
runtimes: standardRuntimes(),
|
|
177
183
|
githubUsers: connectedUserIds(),
|
|
@@ -331,6 +337,22 @@ function connect(): void {
|
|
|
331
337
|
}
|
|
332
338
|
break;
|
|
333
339
|
}
|
|
340
|
+
case "mcp.op": {
|
|
341
|
+
// Async network work (discovery, DCR, exchange) — ack when done.
|
|
342
|
+
void handleMcpOp(frame.op)
|
|
343
|
+
.then((ack) =>
|
|
344
|
+
send(socket, { kind: "mcp.ack", opId: frame.opId, ok: true, ...ack }),
|
|
345
|
+
)
|
|
346
|
+
.catch((err: unknown) =>
|
|
347
|
+
send(socket, {
|
|
348
|
+
kind: "mcp.ack",
|
|
349
|
+
opId: frame.opId,
|
|
350
|
+
ok: false,
|
|
351
|
+
error: err instanceof Error ? err.message : "mcp op failed",
|
|
352
|
+
}),
|
|
353
|
+
);
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
334
356
|
case "env.var.list":
|
|
335
357
|
case "env.var.set":
|
|
336
358
|
case "env.var.delete": {
|
|
@@ -866,9 +888,24 @@ function dispatchCommand(
|
|
|
866
888
|
expectString(args, 1),
|
|
867
889
|
expectString(args, 2),
|
|
868
890
|
);
|
|
891
|
+
case "previewEnsure":
|
|
892
|
+
return hostCommands.previewEnsure(
|
|
893
|
+
ctx,
|
|
894
|
+
expectString(args, 0),
|
|
895
|
+
expectString(args, 1),
|
|
896
|
+
expectNumberArg(args, 2),
|
|
897
|
+
);
|
|
869
898
|
}
|
|
870
899
|
}
|
|
871
900
|
|
|
901
|
+
function expectNumberArg(args: unknown[], index: number): number {
|
|
902
|
+
const value = args[index];
|
|
903
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
904
|
+
throw new Error(`invalid command args: expected number at ${index}`);
|
|
905
|
+
}
|
|
906
|
+
return value;
|
|
907
|
+
}
|
|
908
|
+
|
|
872
909
|
function parseCloudFrame(data: RawData): CloudToHost | null {
|
|
873
910
|
let parsed: unknown;
|
|
874
911
|
try {
|
|
@@ -1003,10 +1040,41 @@ function parseCloudFrame(data: RawData): CloudToHost | null {
|
|
|
1003
1040
|
key: frame.key,
|
|
1004
1041
|
};
|
|
1005
1042
|
}
|
|
1043
|
+
if (
|
|
1044
|
+
frame.kind === "mcp.op" &&
|
|
1045
|
+
typeof frame.opId === "string" &&
|
|
1046
|
+
isMcpOp(frame.op)
|
|
1047
|
+
) {
|
|
1048
|
+
return { kind: "mcp.op", opId: frame.opId, op: frame.op };
|
|
1049
|
+
}
|
|
1006
1050
|
console.warn("[host-agent] dropping unknown frame");
|
|
1007
1051
|
return null;
|
|
1008
1052
|
}
|
|
1009
1053
|
|
|
1054
|
+
/** ADR-057 op payload guard (parseCloudFrame whitelists every frame kind —
|
|
1055
|
+
* a new frame that skips this is silently dropped and acks time out). */
|
|
1056
|
+
function isMcpOp(op: unknown): op is McpOp {
|
|
1057
|
+
if (!op || typeof op !== "object") return false;
|
|
1058
|
+
const o = op as Record<string, unknown>;
|
|
1059
|
+
const optStr = (v: unknown): boolean => v === undefined || typeof v === "string";
|
|
1060
|
+
if (
|
|
1061
|
+
o.kind === "probe" &&
|
|
1062
|
+
typeof o.connectionId === "string" &&
|
|
1063
|
+
typeof o.userId === "string" &&
|
|
1064
|
+
typeof o.url === "string"
|
|
1065
|
+
) {
|
|
1066
|
+
return [o.state, o.redirectUri, o.headerName, o.headerValue, o.clientId, o.clientSecret].every(optStr);
|
|
1067
|
+
}
|
|
1068
|
+
if (
|
|
1069
|
+
o.kind === "oauth.complete" &&
|
|
1070
|
+
typeof o.connectionId === "string" &&
|
|
1071
|
+
typeof o.code === "string"
|
|
1072
|
+
) {
|
|
1073
|
+
return true;
|
|
1074
|
+
}
|
|
1075
|
+
return o.kind === "disconnect" && typeof o.connectionId === "string";
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1010
1078
|
function isReqLine(value: unknown): value is {
|
|
1011
1079
|
method: string;
|
|
1012
1080
|
url: string;
|
|
@@ -1057,6 +1125,7 @@ function isHostCommand(command: string): command is keyof HostCommands {
|
|
|
1057
1125
|
"attachmentRead",
|
|
1058
1126
|
"channelInterrupt",
|
|
1059
1127
|
"appendTranscript",
|
|
1128
|
+
"previewEnsure",
|
|
1060
1129
|
].includes(command);
|
|
1061
1130
|
}
|
|
1062
1131
|
|
|
@@ -1232,6 +1301,39 @@ function expectChannelEnsureInput(
|
|
|
1232
1301
|
if (typeof input.globalContext === "string") {
|
|
1233
1302
|
out.globalContext = input.globalContext;
|
|
1234
1303
|
}
|
|
1304
|
+
// ADR-053: browser testing flag (optional; tolerant of absence).
|
|
1305
|
+
if (input.browserTesting === true) out.browserTesting = true;
|
|
1306
|
+
// ADR-049: humans in the chat (optional; tolerant of absence for older
|
|
1307
|
+
// clouds). Malformed entries are dropped, not fatal.
|
|
1308
|
+
if (Array.isArray(input.humans)) {
|
|
1309
|
+
const humans = input.humans.flatMap((entry) => {
|
|
1310
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return [];
|
|
1311
|
+
const row = entry as Record<string, unknown>;
|
|
1312
|
+
if (typeof row.handle !== "string" || typeof row.name !== "string") {
|
|
1313
|
+
return [];
|
|
1314
|
+
}
|
|
1315
|
+
return [
|
|
1316
|
+
{
|
|
1317
|
+
handle: row.handle,
|
|
1318
|
+
name: row.name,
|
|
1319
|
+
isOwner: row.isOwner === true,
|
|
1320
|
+
},
|
|
1321
|
+
];
|
|
1322
|
+
});
|
|
1323
|
+
if (humans.length > 0) out.humans = humans;
|
|
1324
|
+
}
|
|
1325
|
+
// ADR-057: the owner's MCP connections (optional; tolerant of absence).
|
|
1326
|
+
// NOTE this validator is the THIRD whitelist a new field must pass, after
|
|
1327
|
+
// parseCloudFrame/parseHostFrame — skipping it silently drops the field.
|
|
1328
|
+
if (Array.isArray(input.mcpConnections)) {
|
|
1329
|
+
const connections = input.mcpConnections.flatMap((entry) => {
|
|
1330
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) return [];
|
|
1331
|
+
const row = entry as Record<string, unknown>;
|
|
1332
|
+
if (typeof row.id !== "string" || typeof row.slug !== "string") return [];
|
|
1333
|
+
return [{ id: row.id, slug: row.slug }];
|
|
1334
|
+
});
|
|
1335
|
+
if (connections.length > 0) out.mcpConnections = connections;
|
|
1336
|
+
}
|
|
1235
1337
|
return out;
|
|
1236
1338
|
}
|
|
1237
1339
|
|
package/src/protocol.ts
CHANGED
|
@@ -67,6 +67,9 @@ export interface TaskAgent {
|
|
|
67
67
|
* changes. The cloud caches it per hostId to drive the task-creation pickers.
|
|
68
68
|
*/
|
|
69
69
|
export interface HostCapabilities {
|
|
70
|
+
/** The host-agent package version — the cloud UI shows it on the host page
|
|
71
|
+
* and flags when npm has a newer release. Optional (older hosts omit it). */
|
|
72
|
+
version?: string;
|
|
70
73
|
agentKinds: Array<{
|
|
71
74
|
kind: string;
|
|
72
75
|
label: string;
|
|
@@ -102,6 +105,10 @@ export interface TaskStatusResult {
|
|
|
102
105
|
composeRunning: boolean;
|
|
103
106
|
containers: string[];
|
|
104
107
|
worktreePresent: boolean;
|
|
108
|
+
/** ADR-051: the task's published preview host ports (from the host DB), so
|
|
109
|
+
* the cloud can backfill/refresh its mirror on reconcile. Optional for
|
|
110
|
+
* wire back-compat with older hosts. */
|
|
111
|
+
previewPorts?: Array<{ name: string; hostPort: number }>;
|
|
105
112
|
}
|
|
106
113
|
|
|
107
114
|
export interface CloneRepoInput {
|
|
@@ -176,9 +183,26 @@ export interface TaskDiffInput {
|
|
|
176
183
|
projects: Array<{ id: string; slug: string }>;
|
|
177
184
|
}
|
|
178
185
|
|
|
186
|
+
/** A human in the task chat (ADR-049) — owner or invited collaborator. */
|
|
187
|
+
export interface ChannelHuman {
|
|
188
|
+
/** Per-task mention handle (`@diogo`), unique vs agent ids. */
|
|
189
|
+
handle: string;
|
|
190
|
+
name: string;
|
|
191
|
+
isOwner: boolean;
|
|
192
|
+
}
|
|
193
|
+
|
|
179
194
|
export interface ChannelEnsureInput {
|
|
180
195
|
taskId: string;
|
|
181
196
|
agents: TaskAgent[];
|
|
197
|
+
/** ADR-049: the humans in the chat. Optional for wire back-compat; absent
|
|
198
|
+
* or single-entry behaves exactly like the pre-ADR-049 single-human task. */
|
|
199
|
+
humans?: ChannelHuman[];
|
|
200
|
+
/** ADR-053: any joined project opted into browser testing — the host wires
|
|
201
|
+
* the Playwright MCP browser at session start. */
|
|
202
|
+
browserTesting?: boolean;
|
|
203
|
+
/** ADR-057: the owner's usable MCP connections (policy "on", connected,
|
|
204
|
+
* remote). The host writes gateway-URL MCP configs at session start. */
|
|
205
|
+
mcpConnections?: Array<{ id: string; slug: string }>;
|
|
182
206
|
globalContext?: string;
|
|
183
207
|
projects: Array<{ slug: string; defaultPrompt: string }>;
|
|
184
208
|
branch: string;
|
|
@@ -266,6 +290,19 @@ export interface HostCommands {
|
|
|
266
290
|
requestId: string,
|
|
267
291
|
decision: PermissionDecision,
|
|
268
292
|
): Promise<HostCommandResult<void>>;
|
|
293
|
+
/**
|
|
294
|
+
* ADR-051: make a preview reachable NOW and return its 127.0.0.1 host
|
|
295
|
+
* port — the task-up published port when one exists, else the lazy
|
|
296
|
+
* node-proxy sidecar's published port (started here instead of waiting
|
|
297
|
+
* for the first tunnel access). Backs the local URL in the preview menu.
|
|
298
|
+
* `hostPort: null` when the task isn't running / can't be exposed.
|
|
299
|
+
*/
|
|
300
|
+
previewEnsure(
|
|
301
|
+
ctx: CommandContext,
|
|
302
|
+
taskId: string,
|
|
303
|
+
name: string,
|
|
304
|
+
containerPort: number,
|
|
305
|
+
): Promise<HostCommandResult<{ hostPort: number | null }>>;
|
|
269
306
|
cloneRepo(
|
|
270
307
|
ctx: CommandContext,
|
|
271
308
|
input: CloneRepoInput,
|
|
@@ -375,7 +412,38 @@ export type CloudToHost =
|
|
|
375
412
|
key: string;
|
|
376
413
|
value: string;
|
|
377
414
|
}
|
|
378
|
-
| { kind: "env.var.delete"; opId: string; projectId: string; key: string }
|
|
415
|
+
| { kind: "env.var.delete"; opId: string; projectId: string; key: string }
|
|
416
|
+
// User-authed MCP connections (ADR-057). Ops run entirely host-side —
|
|
417
|
+
// discovery, DCR, PKCE, token exchange, encrypted storage. Secrets in a
|
|
418
|
+
// probe (headerValue, clientSecret) are write-only; nothing secret ever
|
|
419
|
+
// rides an ack. `opId` correlates request↔ack.
|
|
420
|
+
| { kind: "mcp.op"; opId: string; op: McpOp };
|
|
421
|
+
|
|
422
|
+
/** One MCP-connection operation (ADR-057), executed by the host. */
|
|
423
|
+
export type McpOp =
|
|
424
|
+
// Probe `url` (MCP initialize). No auth → connected. 401 → walk the OAuth
|
|
425
|
+
// discovery chain (RFC 9728 → 8414 → 7591), prepare PKCE, and ack
|
|
426
|
+
// `auth_required` + authorizeUrl (built with the cloud-minted `state`).
|
|
427
|
+
// A static-header connection passes headerName/Value instead and skips
|
|
428
|
+
// discovery. clientId/Secret are the manual fallback when the
|
|
429
|
+
// authorization server lacks DCR.
|
|
430
|
+
| {
|
|
431
|
+
kind: "probe";
|
|
432
|
+
connectionId: string;
|
|
433
|
+
userId: string;
|
|
434
|
+
url: string;
|
|
435
|
+
state?: string;
|
|
436
|
+
redirectUri?: string;
|
|
437
|
+
headerName?: string;
|
|
438
|
+
headerValue?: string;
|
|
439
|
+
clientId?: string;
|
|
440
|
+
clientSecret?: string;
|
|
441
|
+
}
|
|
442
|
+
// The vendor redirected back: exchange `code` with the PKCE verifier the
|
|
443
|
+
// host kept for this connection.
|
|
444
|
+
| { kind: "oauth.complete"; connectionId: string; code: string }
|
|
445
|
+
// Delete the connection's secrets. Gateway routes 401 immediately after.
|
|
446
|
+
| { kind: "disconnect"; connectionId: string };
|
|
379
447
|
|
|
380
448
|
export type HostToCloud =
|
|
381
449
|
| { kind: "auth"; token: string; hostId: string }
|
|
@@ -409,7 +477,19 @@ export type HostToCloud =
|
|
|
409
477
|
// KEY NAMES only — values never cross the bridge (secret-blind, ADR-015).
|
|
410
478
|
// `opId` correlates to the request.
|
|
411
479
|
| { kind: "env.var.ack"; opId: string; ok: true; keys: string[] }
|
|
412
|
-
| { kind: "env.var.ack"; opId: string; ok: false; error: string }
|
|
480
|
+
| { kind: "env.var.ack"; opId: string; ok: false; error: string }
|
|
481
|
+
// Ack for mcp.op (ADR-057). `connected` = usable now; `auth_required` =
|
|
482
|
+
// open authorizeUrl in the user's browser and wait for the callback;
|
|
483
|
+
// `disconnected` = secrets gone. No secret material ever crosses back.
|
|
484
|
+
| {
|
|
485
|
+
kind: "mcp.ack";
|
|
486
|
+
opId: string;
|
|
487
|
+
ok: true;
|
|
488
|
+
status: "connected" | "auth_required" | "disconnected";
|
|
489
|
+
authorizeUrl?: string;
|
|
490
|
+
scopes?: string[];
|
|
491
|
+
}
|
|
492
|
+
| { kind: "mcp.ack"; opId: string; ok: false; error: string };
|
|
413
493
|
|
|
414
494
|
export type HostEvent =
|
|
415
495
|
| {
|