@runuai/host 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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);
@@ -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
- return wrapAgent(ctx, "taskStatus", () => agent.taskStatus(taskId));
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
  | {