@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.
@@ -17,7 +17,9 @@
17
17
  */
18
18
 
19
19
  import { spawn } from "node:child_process";
20
- import { dirname, resolve } from "node:path";
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 inspect = await run("docker", ["image", "inspect", STANDARD_IMAGE_TAG]);
202
- if (inspect.code === 0) {
203
- console.log(`[host-agent] standard image ${STANDARD_IMAGE_TAG} present`);
204
- imageReady = true;
205
- } else if (inspect.code === null) {
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
- `[host-agent] building standard image ${STANDARD_IMAGE_TAG} from ${context}`,
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", ["build", "-t", STANDARD_IMAGE_TAG, context]);
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) await ensureVolumeAgentClis();
345
+ if (imageReady) {
346
+ await ensureVolumeAgentClis();
347
+ await upgradeVolumeAgentClis();
348
+ }
232
349
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Uai host — runs ephemeral AI coding tasks in Docker on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Diogo Perillo <diogo.perillo@gmail.com>",
@@ -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
  | {