@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,208 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* uai session runner (ADR-061) — runs INSIDE the task container and owns an
|
|
4
|
+
* agent CLI process, so the CLI's lifetime is decoupled from the host
|
|
5
|
+
* process. The host talks to it only through files on the task-workspace
|
|
6
|
+
* bind mount (no sockets/FIFOs — they don't cross the macOS↔VM boundary):
|
|
7
|
+
*
|
|
8
|
+
* <sessionDir>/inbox.jsonl host appends; runner tails → CLI stdin
|
|
9
|
+
* <sessionDir>/outbox.jsonl CLI stdout lines verbatim + __uai meta lines
|
|
10
|
+
* <sessionDir>/heartbeat rewritten every HEARTBEAT_MS (host checks mtime)
|
|
11
|
+
* <sessionDir>/runner.json pid, protocol, argv, startedAt
|
|
12
|
+
*
|
|
13
|
+
* Meta lines are `{"__uai":"spawn"|"exit", ...}`; the host filters them out
|
|
14
|
+
* before handing lines to the protocol adapters. Control lines the host
|
|
15
|
+
* appends to the inbox use the same shape (`{"__uai":"stop"}`); everything
|
|
16
|
+
* else in the inbox goes to the CLI's stdin untouched.
|
|
17
|
+
*
|
|
18
|
+
* Plain Node ≥18, dependency-free, ESM. Testable outside docker: point it
|
|
19
|
+
* at a tmp dir and any line-oriented fake CLI.
|
|
20
|
+
*
|
|
21
|
+
* Usage: node runner.mjs <sessionDir> -- <command> [args...]
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { spawn } from "node:child_process";
|
|
25
|
+
import { appendFileSync, mkdirSync, openSync, readSync, fstatSync, closeSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import process from "node:process";
|
|
28
|
+
|
|
29
|
+
const PROTOCOL = 1;
|
|
30
|
+
const POLL_MS = 50;
|
|
31
|
+
const HEARTBEAT_MS = 5_000;
|
|
32
|
+
const STDERR_CAP = 8 * 1024;
|
|
33
|
+
const STOP_GRACE_MS = 5_000;
|
|
34
|
+
|
|
35
|
+
// ---- argv ------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
const sep = process.argv.indexOf("--");
|
|
38
|
+
const sessionDir = process.argv[2];
|
|
39
|
+
if (!sessionDir || sep < 0 || sep + 1 >= process.argv.length) {
|
|
40
|
+
process.stderr.write("usage: runner.mjs <sessionDir> -- <command> [args...]\n");
|
|
41
|
+
process.exit(2);
|
|
42
|
+
}
|
|
43
|
+
const command = process.argv[sep + 1];
|
|
44
|
+
const args = process.argv.slice(sep + 2);
|
|
45
|
+
|
|
46
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
47
|
+
const inboxPath = join(sessionDir, "inbox.jsonl");
|
|
48
|
+
const outboxPath = join(sessionDir, "outbox.jsonl");
|
|
49
|
+
const heartbeatPath = join(sessionDir, "heartbeat");
|
|
50
|
+
const runnerJsonPath = join(sessionDir, "runner.json");
|
|
51
|
+
|
|
52
|
+
// Single append point — appendFileSync with O_APPEND keeps lines atomic for
|
|
53
|
+
// the sizes we write; the runner is the outbox's only writer.
|
|
54
|
+
function outbox(line) {
|
|
55
|
+
appendFileSync(outboxPath, line.endsWith("\n") ? line : `${line}\n`);
|
|
56
|
+
}
|
|
57
|
+
function meta(kind, extra = {}) {
|
|
58
|
+
outbox(JSON.stringify({ __uai: kind, ts: Date.now(), ...extra }));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---- the CLI ---------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
// The runner is launched through the container's asdf `node` shim, which
|
|
64
|
+
// exports its resolved version (ASDF_NODEJS_VERSION et al.) into our env.
|
|
65
|
+
// Passing that through would pin the CLI's own asdf shim to the WORKSPACE's
|
|
66
|
+
// node version — "No claude executable found for nodejs X" when the agent
|
|
67
|
+
// CLIs are installed under a different one. Strip ASDF_* so the CLI shim
|
|
68
|
+
// resolves exactly as a direct `docker exec <cli>` would.
|
|
69
|
+
const cliEnv = { ...process.env };
|
|
70
|
+
for (const key of Object.keys(cliEnv)) {
|
|
71
|
+
if (key.startsWith("ASDF_")) delete cliEnv[key];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"], env: cliEnv });
|
|
75
|
+
|
|
76
|
+
writeFileSync(
|
|
77
|
+
runnerJsonPath,
|
|
78
|
+
JSON.stringify({
|
|
79
|
+
protocol: PROTOCOL,
|
|
80
|
+
runnerPid: process.pid,
|
|
81
|
+
cliPid: child.pid ?? null,
|
|
82
|
+
command,
|
|
83
|
+
args,
|
|
84
|
+
startedAt: Date.now(),
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
meta("spawn", { protocol: PROTOCOL, runnerPid: process.pid, cliPid: child.pid ?? null });
|
|
88
|
+
|
|
89
|
+
let stderrTail = "";
|
|
90
|
+
child.stderr?.setEncoding("utf8");
|
|
91
|
+
child.stderr?.on("data", (chunk) => {
|
|
92
|
+
stderrTail = (stderrTail + chunk).slice(-STDERR_CAP);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// CLI stdout → outbox, complete lines only (partial line buffered).
|
|
96
|
+
let stdoutBuf = "";
|
|
97
|
+
child.stdout?.setEncoding("utf8");
|
|
98
|
+
child.stdout?.on("data", (chunk) => {
|
|
99
|
+
stdoutBuf += chunk;
|
|
100
|
+
for (;;) {
|
|
101
|
+
const nl = stdoutBuf.indexOf("\n");
|
|
102
|
+
if (nl < 0) break;
|
|
103
|
+
const line = stdoutBuf.slice(0, nl);
|
|
104
|
+
stdoutBuf = stdoutBuf.slice(nl + 1);
|
|
105
|
+
if (line.trim().length > 0) outbox(line);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
let exiting = false;
|
|
110
|
+
child.on("exit", (code, signal) => {
|
|
111
|
+
if (exiting) return;
|
|
112
|
+
exiting = true;
|
|
113
|
+
if (stdoutBuf.trim().length > 0) outbox(stdoutBuf); // flush the partial tail
|
|
114
|
+
meta("exit", { code, signal: signal ?? null, stderrTail });
|
|
115
|
+
process.exit(0);
|
|
116
|
+
});
|
|
117
|
+
child.on("error", (err) => {
|
|
118
|
+
if (exiting) return;
|
|
119
|
+
exiting = true;
|
|
120
|
+
meta("exit", { code: null, signal: null, stderrTail: String(err?.message ?? err) });
|
|
121
|
+
process.exit(0);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ---- inbox tail → CLI stdin --------------------------------------------------
|
|
125
|
+
|
|
126
|
+
let inboxOffset = 0; // fresh session dir per spawn — always start at 0
|
|
127
|
+
let inboxBuf = "";
|
|
128
|
+
|
|
129
|
+
function stopCli() {
|
|
130
|
+
if (exiting) return;
|
|
131
|
+
try {
|
|
132
|
+
child.kill("SIGTERM");
|
|
133
|
+
} catch {
|
|
134
|
+
/* already gone */
|
|
135
|
+
}
|
|
136
|
+
setTimeout(() => {
|
|
137
|
+
try {
|
|
138
|
+
child.kill("SIGKILL");
|
|
139
|
+
} catch {
|
|
140
|
+
/* already gone */
|
|
141
|
+
}
|
|
142
|
+
}, STOP_GRACE_MS).unref();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function handleInboxLine(line) {
|
|
146
|
+
if (line.trim().length === 0) return;
|
|
147
|
+
if (line.startsWith('{"__uai"')) {
|
|
148
|
+
try {
|
|
149
|
+
const ctl = JSON.parse(line);
|
|
150
|
+
if (ctl.__uai === "stop") stopCli();
|
|
151
|
+
} catch {
|
|
152
|
+
/* malformed control — ignore */
|
|
153
|
+
}
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
child.stdin?.write(`${line}\n`);
|
|
158
|
+
} catch {
|
|
159
|
+
/* EPIPE after CLI death — the exit meta already tells the host */
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function pollInbox() {
|
|
164
|
+
let fd;
|
|
165
|
+
try {
|
|
166
|
+
fd = openSync(inboxPath, "r");
|
|
167
|
+
} catch {
|
|
168
|
+
return; // inbox not created yet
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
const size = fstatSync(fd).size;
|
|
172
|
+
if (size > inboxOffset) {
|
|
173
|
+
const len = size - inboxOffset;
|
|
174
|
+
const buf = Buffer.alloc(len);
|
|
175
|
+
const read = readSync(fd, buf, 0, len, inboxOffset);
|
|
176
|
+
inboxOffset += read;
|
|
177
|
+
inboxBuf += buf.toString("utf8", 0, read);
|
|
178
|
+
for (;;) {
|
|
179
|
+
const nl = inboxBuf.indexOf("\n");
|
|
180
|
+
if (nl < 0) break;
|
|
181
|
+
const line = inboxBuf.slice(0, nl);
|
|
182
|
+
inboxBuf = inboxBuf.slice(nl + 1);
|
|
183
|
+
handleInboxLine(line);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
} finally {
|
|
187
|
+
closeSync(fd);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
setInterval(pollInbox, POLL_MS);
|
|
192
|
+
|
|
193
|
+
// ---- heartbeat ---------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
function beat() {
|
|
196
|
+
try {
|
|
197
|
+
writeFileSync(heartbeatPath, `${process.pid} ${Date.now()}\n`);
|
|
198
|
+
} catch {
|
|
199
|
+
/* disk hiccup — next beat retries */
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
beat();
|
|
203
|
+
setInterval(beat, HEARTBEAT_MS);
|
|
204
|
+
|
|
205
|
+
// The runner dies only with its CLI (or on stop/SIGTERM) — never because a
|
|
206
|
+
// host-side consumer went away. That is the entire point (ADR-061).
|
|
207
|
+
process.on("SIGTERM", stopCli);
|
|
208
|
+
process.on("SIGINT", stopCli);
|
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
|
| {
|