@runuai/host 0.4.3 → 0.6.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.
@@ -92,10 +92,16 @@ interface Channel {
92
92
  /** Per-agent respawn counter — bounded so a broken agent can't
93
93
  * loop forever rewriting its config. */
94
94
  respawns: Map<string, number>;
95
+ /** When each agent last burned a respawn — a budget older than the
96
+ * cooldown resets, so a task self-heals once the underlying failure
97
+ * (missing CLI, broken config) clears instead of needing an operator. */
98
+ respawnLastAt: Map<string, number>;
95
99
  /** ADR-049: humans in the chat (from the latest channel spec). */
96
100
  humans: ChannelHuman[];
97
101
  /** ADR-053: wire the Playwright MCP browser at session start. */
98
102
  browserTesting: boolean;
103
+ /** ADR-062: shared-files mount mode ("off" | "ro" | "rw") for the preamble. */
104
+ sharedFiles: string;
99
105
  /** ADR-057: the owner's MCP connections exposed through the host gateway. */
100
106
  mcpConnections: Array<{ id: string; slug: string }>;
101
107
  /** Last connection set written into the container (skip repeat execs —
@@ -109,6 +115,8 @@ interface Channel {
109
115
 
110
116
  /** Hard cap on automatic respawns per agent per channel lifetime. */
111
117
  const MAX_RESPAWNS_PER_AGENT = 5;
118
+ /** A burned respawn budget resets after this quiet period (see reconcile). */
119
+ const RESPAWN_COOLDOWN_MS = 10 * 60_000;
112
120
 
113
121
  /** Substrings in an agent's error output that mean "config was
114
122
  * unlinked between runs" — repair-and-respawn covers the common
@@ -174,6 +182,7 @@ class Orchestrator {
174
182
  }
175
183
  channel.humans = spec.humans ?? [];
176
184
  channel.browserTesting = spec.browserTesting === true;
185
+ channel.sharedFiles = spec.sharedFiles ?? "ro";
177
186
  channel.mcpConnections = spec.mcpConnections ?? [];
178
187
  for (const agent of channel.roster) {
179
188
  channel.preambles.set(
@@ -187,6 +196,7 @@ class Orchestrator {
187
196
  spec.branch,
188
197
  channel.humans,
189
198
  channel.browserTesting,
199
+ channel.sharedFiles,
190
200
  ),
191
201
  );
192
202
  }
@@ -214,6 +224,7 @@ class Orchestrator {
214
224
  spec.branch,
215
225
  spec.humans,
216
226
  spec.browserTesting,
227
+ spec.sharedFiles ?? "ro",
217
228
  ),
218
229
  );
219
230
  // ADR-046: materialise this agent's skills to its per-agent SKILL.md in
@@ -240,8 +251,10 @@ class Orchestrator {
240
251
  openTurns: new Set(),
241
252
  interrupted: new Set(),
242
253
  respawns: new Map(),
254
+ respawnLastAt: new Map(),
243
255
  humans: spec.humans ?? [],
244
256
  browserTesting: spec.browserTesting === true,
257
+ sharedFiles: spec.sharedFiles ?? "ro",
245
258
  mcpConnections: spec.mcpConnections ?? [],
246
259
  spawning: new Set(),
247
260
  };
@@ -273,7 +286,14 @@ class Orchestrator {
273
286
  (ok) => {
274
287
  if (!ok) channel.sessionsReady = null;
275
288
  },
276
- () => {
289
+ (err: unknown) => {
290
+ // A start failure retries on the next ensure — but it must be
291
+ // VISIBLE: an unlogged throw here once looped silently every ~2s
292
+ // while a task sat dead with no sessions and no trace.
293
+ console.warn(
294
+ `[orchestrator] ${channel.taskId}: session start failed: ` +
295
+ `${err instanceof Error ? (err.stack ?? err.message) : String(err)}`,
296
+ );
277
297
  channel.sessionsReady = null;
278
298
  },
279
299
  );
@@ -295,6 +315,7 @@ class Orchestrator {
295
315
  private async ensureMcpConfig(channel: Channel): Promise<void> {
296
316
  const fingerprint = JSON.stringify(channel.mcpConnections.map((c) => c.id));
297
317
  if (channel.mcpConfigFingerprint === fingerprint) return;
318
+ const hadPrevious = channel.mcpConfigFingerprint !== undefined;
298
319
  channel.mcpConfigFingerprint = fingerprint;
299
320
  await setupMcpTaskConfig(
300
321
  channel.taskId,
@@ -302,18 +323,62 @@ class Orchestrator {
302
323
  channel.mcpConnections,
303
324
  channel.roster.some((a) => a.kind === "codex"),
304
325
  );
326
+ // Agent CLIs read MCP servers once, at process start — and durable
327
+ // sessions (ADR-061) make processes long-lived, so without this a
328
+ // connection added mid-task stays invisible indefinitely. Recycle IDLE
329
+ // sessions so the change actually reaches the agents; busy agents keep
330
+ // their turn (and in-memory context) and pick it up on a later change or
331
+ // natural respawn. Trade-off: a recycled agent loses its in-memory
332
+ // context and is re-briefed from chat — same as any respawn, and better
333
+ // than never seeing the connection the user just added. Skipped on the
334
+ // channel's first fingerprint (sessions just spawned with this config).
335
+ if (hadPrevious) await this.recycleIdleSessions(channel);
336
+ }
337
+
338
+ /** Close + respawn every session with no open turn (MCP set changed). */
339
+ private async recycleIdleSessions(channel: Channel): Promise<void> {
340
+ let recycled = 0;
341
+ for (const [agentId, session] of [...channel.sessions]) {
342
+ if (channel.openTurns.has(agentId)) continue; // never mid-turn
343
+ if (channel.spawning.has(agentId)) continue;
344
+ channel.sessions.delete(agentId);
345
+ try {
346
+ // close() flips the adapter's `closed` flag first, so the session's
347
+ // exit is suppressed — no budget increment, no agent.exit noise, no
348
+ // race with the respawn below (recoverClaudeAgent's pattern).
349
+ await session.close();
350
+ } catch {
351
+ // Already dead — close is idempotent for our adapters.
352
+ }
353
+ recycled += 1;
354
+ }
355
+ if (recycled > 0) {
356
+ console.log(
357
+ `[orchestrator] ${channel.taskId}: recycled ${recycled} idle session(s) for MCP config change`,
358
+ );
359
+ await this.reconcileSessions(channel);
360
+ }
305
361
  }
306
362
 
307
363
  /** Spawn sessions for roster agents added after the initial start. */
308
364
  private async reconcileSessions(channel: Channel): Promise<void> {
309
- const missing = channel.roster.filter(
310
- (agent) =>
311
- !channel.sessions.has(agent.id) &&
312
- !channel.spawning.has(agent.id) &&
313
- // Crash-loop budget: an agent whose session keeps dying stops being
314
- // respawned after MAX_RESPAWNS_PER_AGENT (the exit paths increment).
315
- (channel.respawns.get(agent.id) ?? 0) <= MAX_RESPAWNS_PER_AGENT,
316
- );
365
+ const missing = channel.roster.filter((agent) => {
366
+ if (channel.sessions.has(agent.id) || channel.spawning.has(agent.id)) {
367
+ return false;
368
+ }
369
+ // Crash-loop budget: an agent whose session keeps dying stops being
370
+ // respawned after MAX_RESPAWNS_PER_AGENT (the exit paths increment)
371
+ // but a budget that has been cold for RESPAWN_COOLDOWN_MS resets, so
372
+ // the task heals itself once the cause (a missing CLI on the shared
373
+ // volume, a broken config) is fixed, instead of staying dead until a
374
+ // host restart.
375
+ if ((channel.respawns.get(agent.id) ?? 0) > MAX_RESPAWNS_PER_AGENT) {
376
+ const lastAt = channel.respawnLastAt.get(agent.id) ?? 0;
377
+ if (Date.now() - lastAt < RESPAWN_COOLDOWN_MS) return false;
378
+ channel.respawns.set(agent.id, 0);
379
+ }
380
+ return true;
381
+ });
317
382
  if (missing.length === 0) return;
318
383
 
319
384
  const task = getHostTask(channel.taskId);
@@ -613,6 +678,7 @@ class Orchestrator {
613
678
  // after a double SIGKILL). Bounded by the respawn budget, checked in
614
679
  // reconcileSessions.
615
680
  channel.respawns.set(agentId, (channel.respawns.get(agentId) ?? 0) + 1);
681
+ channel.respawnLastAt.set(agentId, Date.now());
616
682
  channel.sessions.delete(agentId);
617
683
  break;
618
684
  }
@@ -635,6 +701,7 @@ class Orchestrator {
635
701
  // Same zombie hazard as the error path — a session whose process
636
702
  // ended (even cleanly) can never carry another turn.
637
703
  channel.respawns.set(agentId, (channel.respawns.get(agentId) ?? 0) + 1);
704
+ channel.respawnLastAt.set(agentId, Date.now());
638
705
  channel.sessions.delete(agentId);
639
706
  break;
640
707
  }
@@ -653,6 +720,7 @@ class Orchestrator {
653
720
  ): Promise<void> {
654
721
  const tries = (channel.respawns.get(agentId) ?? 0) + 1;
655
722
  channel.respawns.set(agentId, tries);
723
+ channel.respawnLastAt.set(agentId, Date.now());
656
724
 
657
725
  if (tries > MAX_RESPAWNS_PER_AGENT) {
658
726
  this.emitHost({
@@ -925,6 +993,7 @@ export function buildSystemPreamble(
925
993
  taskBranch: string,
926
994
  humans?: ChannelHuman[],
927
995
  browserTesting?: boolean,
996
+ sharedFiles?: string,
928
997
  ): string {
929
998
  const channelList = roster
930
999
  .map((a) =>
@@ -1065,6 +1134,31 @@ export function buildSystemPreamble(
1065
1134
  "part of the project. Never review, edit, stage, commit, or flag it;",
1066
1135
  "treat it as ignored, even though git may show it as untracked.",
1067
1136
  "",
1137
+ // ADR-062: shared files — only when this container actually carries the
1138
+ // mounts (task-up drops a marker; pre-feature containers have none).
1139
+ ...(sharedFiles &&
1140
+ sharedFiles !== "off" &&
1141
+ existsSync(join(workspacePath, ".uai", "files-mounted"))
1142
+ ? [
1143
+ "## Shared files",
1144
+ "",
1145
+ `Non-code files (${sharedFiles === "rw" ? "read-write" : "READ-ONLY"} for this task):`,
1146
+ "- `/workspace/files/org` — the org's shared files (logos, specs,",
1147
+ " datasets), visible to every task in the org on this host.",
1148
+ "- `/workspace/files/me` — the task owner's personal files, shared",
1149
+ " across their tasks on this host.",
1150
+ ...(sharedFiles === "rw"
1151
+ ? [
1152
+ "When producing artifacts for humans, write them here (use a",
1153
+ "subdirectory named after the task to avoid collisions).",
1154
+ ]
1155
+ : [
1156
+ "Read-only here: to hand a file back, use the chat attachment",
1157
+ "flow instead.",
1158
+ ]),
1159
+ "",
1160
+ ]
1161
+ : []),
1068
1162
  // ADR-046: link/document skills are materialised to a per-agent SKILL.md.
1069
1163
  // Point the agent at its own file (package skills are handled below).
1070
1164
  ...((agent.skills ?? []).some((s) => s.type !== "package")
@@ -0,0 +1,156 @@
1
+ /**
2
+ * ADR-062 shared files — host-side handler for the `filesOp` bridge command.
3
+ * Two roots per host: org files (per host+org) and personal files (per
4
+ * host+user), both under <workspaceRoot>/shared/. The cloud is a window
5
+ * only; this module is the sole writer host-side.
6
+ *
7
+ * SECURITY: the cloud relays paths from browsers, and containers mount these
8
+ * same dirs — every path is treated as hostile. Two gates on every op:
9
+ * `resolveInsideRoot` (relative-only, ".."-free, resolved-prefix check) and
10
+ * `assertRealInsideRoot` (realpath containment, so a symlink the container
11
+ * planted inside a rw mount cannot point ops outside the root).
12
+ */
13
+
14
+ import { promises as fsp } from "node:fs";
15
+ import { dirname, join, normalize, resolve, sep } from "node:path";
16
+
17
+ import type { FilesEntry, FilesOpInput, FilesOpValue } from "../src/protocol";
18
+ import { env } from "./env";
19
+
20
+ /** Max bytes returned per read chunk (base64 expands ~4/3; WS cap is 16MB). */
21
+ const MAX_READ_CHUNK = 4 * 1024 * 1024;
22
+ /** Max bytes accepted per write chunk. */
23
+ const MAX_WRITE_CHUNK = 8 * 1024 * 1024;
24
+ /** Max entries returned by a single list. */
25
+ const MAX_LIST_ENTRIES = 2_000;
26
+
27
+ /**
28
+ * Filesystem-safe directory name for an org/user id. Personal org ids are
29
+ * `personal:<userId>` — the colon breaks docker's short-syntax bind spec
30
+ * (src:dst:mode), so it is mapped to "_". MUST stay in lockstep with the
31
+ * identical mapping in scripts/agent/task-up.sh (shared_fs_name).
32
+ */
33
+ export function sharedDirName(id: string): string {
34
+ return id.replace(/:/g, "_");
35
+ }
36
+
37
+ export function sharedRoot(scope: "org" | "me", orgId: string, userId: string): string {
38
+ return scope === "org"
39
+ ? resolve(env.workspaceRoot, "shared", "orgs", sharedDirName(orgId))
40
+ : resolve(env.workspaceRoot, "shared", "users", sharedDirName(userId));
41
+ }
42
+
43
+ function resolveInsideRoot(root: string, relPath: string): string {
44
+ // Relative paths only — an absolute path is rejected, never reinterpreted.
45
+ if (/^[/\\]/.test(relPath) || /^[A-Za-z]:/.test(relPath)) {
46
+ throw new Error("invalid path");
47
+ }
48
+ const cleaned = normalize(relPath);
49
+ if (
50
+ cleaned.split(/[/\\]/).some((seg) => seg === "..") ||
51
+ cleaned.includes("\0")
52
+ ) {
53
+ throw new Error("invalid path");
54
+ }
55
+ const target = resolve(root, cleaned);
56
+ if (target !== root && !target.startsWith(root + sep)) {
57
+ throw new Error("invalid path");
58
+ }
59
+ return target;
60
+ }
61
+
62
+ /**
63
+ * String containment (resolveInsideRoot) can't see symlinks the container
64
+ * planted inside a rw mount — `root/evil → /` would pass it. Walk up from
65
+ * the target to its deepest EXISTING ancestor, realpath it, and require the
66
+ * REAL location to stay inside the real root (ADR-062 "no symlink escapes").
67
+ */
68
+ async function assertRealInsideRoot(root: string, target: string): Promise<void> {
69
+ const realRoot = await fsp.realpath(root);
70
+ let probe = target;
71
+ for (;;) {
72
+ try {
73
+ const real = await fsp.realpath(probe);
74
+ if (real !== realRoot && !real.startsWith(realRoot + sep)) {
75
+ throw new Error("invalid path");
76
+ }
77
+ return;
78
+ } catch (err) {
79
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
80
+ const parent = dirname(probe);
81
+ if (parent === probe) throw new Error("invalid path");
82
+ probe = parent;
83
+ }
84
+ }
85
+ }
86
+
87
+ export async function handleFilesOp(input: FilesOpInput): Promise<FilesOpValue> {
88
+ if (!input.orgId && input.scope === "org") throw new Error("missing orgId");
89
+ if (!input.userId && input.scope === "me") throw new Error("missing userId");
90
+ const root = sharedRoot(input.scope, input.orgId, input.userId);
91
+ await fsp.mkdir(root, { recursive: true });
92
+ const target = resolveInsideRoot(root, input.path ?? "");
93
+ await assertRealInsideRoot(root, target);
94
+
95
+ switch (input.op) {
96
+ case "list": {
97
+ const names = await fsp.readdir(target);
98
+ const entries: FilesEntry[] = [];
99
+ for (const name of names.slice(0, MAX_LIST_ENTRIES)) {
100
+ try {
101
+ const st = await fsp.lstat(join(target, name));
102
+ entries.push({
103
+ name,
104
+ dir: st.isDirectory(),
105
+ size: st.isDirectory() ? 0 : st.size,
106
+ mtimeMs: st.mtimeMs,
107
+ });
108
+ } catch {
109
+ // Raced deletion — skip.
110
+ }
111
+ }
112
+ entries.sort((a, b) =>
113
+ a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1,
114
+ );
115
+ return { entries };
116
+ }
117
+ case "read": {
118
+ const st = await fsp.stat(target);
119
+ if (st.isDirectory()) throw new Error("is a directory");
120
+ const offset = Math.max(0, input.offset ?? 0);
121
+ const length = Math.min(input.length ?? MAX_READ_CHUNK, MAX_READ_CHUNK);
122
+ const fh = await fsp.open(target, "r");
123
+ try {
124
+ const buf = Buffer.alloc(Math.min(length, Math.max(0, st.size - offset)));
125
+ const { bytesRead } = await fh.read(buf, 0, buf.length, offset);
126
+ return {
127
+ dataBase64: buf.subarray(0, bytesRead).toString("base64"),
128
+ size: st.size,
129
+ eof: offset + bytesRead >= st.size,
130
+ };
131
+ } finally {
132
+ await fh.close();
133
+ }
134
+ }
135
+ case "write": {
136
+ if (target === root) throw new Error("invalid path");
137
+ const data = Buffer.from(input.dataBase64 ?? "", "base64");
138
+ if (data.length > MAX_WRITE_CHUNK) throw new Error("chunk too large");
139
+ await fsp.mkdir(resolve(target, ".."), { recursive: true });
140
+ await fsp.writeFile(target, data, { flag: input.append ? "a" : "w" });
141
+ return {};
142
+ }
143
+ case "mkdir": {
144
+ if (target === root) throw new Error("invalid path");
145
+ await fsp.mkdir(target, { recursive: true });
146
+ return {};
147
+ }
148
+ case "delete": {
149
+ if (target === root) throw new Error("invalid path");
150
+ await fsp.rm(target, { recursive: true, force: true });
151
+ return {};
152
+ }
153
+ default:
154
+ throw new Error(`unknown op: ${String(input.op)}`);
155
+ }
156
+ }
@@ -238,6 +238,12 @@ async function upgradeVolumeAgentClis(): Promise<void> {
238
238
  const upgrade = await run("docker", [
239
239
  "run",
240
240
  "--rm",
241
+ // The container NAME is the mutex: overlapping boots (or a crash-looping
242
+ // service) must never race two npm installs on the shared volume — that
243
+ // once left it with no `claude` at all (2026-07-13). Docker rejects the
244
+ // duplicate name; we treat that as "already upgrading, skip".
245
+ "--name",
246
+ "uai-cli-upgrade",
241
247
  "-u",
242
248
  "root",
243
249
  "-w",
@@ -250,6 +256,10 @@ async function upgradeVolumeAgentClis(): Promise<void> {
250
256
  `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
257
  bins.map((b) => `printf '%s ' "$(${b} --version 2>/dev/null | head -1)"`).join("; "),
252
258
  ]);
259
+ if (upgrade.code !== 0 && /already in use/i.test(upgrade.stderr)) {
260
+ console.log("[host-agent] agent CLI upgrade already running — skipped");
261
+ return;
262
+ }
253
263
  if (upgrade.code === 0) {
254
264
  console.log(
255
265
  `[host-agent] agent CLIs current on ${ASDF_DATA_VOLUME}: ${upgrade.stdout.trim()}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.4.3",
3
+ "version": "0.6.0",
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>",
@@ -40,6 +40,7 @@
40
40
  "db",
41
41
  "scripts/agent",
42
42
  "scripts/install",
43
+ "runner",
43
44
  "images/standard",
44
45
  "ui",
45
46
  "README.md",
@@ -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);
@@ -121,6 +121,28 @@ fi
121
121
 
122
122
  mkdir -p "$task_workspace"
123
123
 
124
+ # ADR-062 shared files: two host-resident roots mounted into the container
125
+ # when the task's mode isn't "off". Created here so Docker doesn't create
126
+ # them root-owned at mount time. Mode defaults to ro; org mount only when
127
+ # the cloud sent an org id (older clouds won't).
128
+ shared_files_mode=$(jq -r '.[0].shared_files // "ro"' <<<"$task_json")
129
+ shared_owner_org=$(jq -r '.[0].owner_org_id // ""' <<<"$task_json")
130
+ shared_owner_user=$(jq -r '.[0].owner_user_id // ""' <<<"$task_json")
131
+ # Colons break docker's src:dst:mode bind syntax, and personal org ids are
132
+ # "personal:<userId>" — map ":" to "_". MUST match sharedDirName() in
133
+ # host-agent/lib/shared-files.ts.
134
+ shared_owner_org=$(printf '%s' "$shared_owner_org" | tr ':' '_')
135
+ shared_owner_user=$(printf '%s' "$shared_owner_user" | tr ':' '_')
136
+ shared_root="$UAI_WORKSPACE_ROOT/shared"
137
+ if [ "$shared_files_mode" != "off" ]; then
138
+ [ -n "$shared_owner_org" ] && mkdir -p "$shared_root/orgs/$shared_owner_org"
139
+ [ -n "$shared_owner_user" ] && mkdir -p "$shared_root/users/$shared_owner_user"
140
+ # Marker for the preamble: only containers that actually carry the mounts
141
+ # get the "## Shared files" briefing (pre-feature containers don't).
142
+ mkdir -p "$task_workspace/.uai"
143
+ : > "$task_workspace/.uai/files-mounted"
144
+ fi
145
+
124
146
  # Iterate using jq -c so each project is a single JSON object per line.
125
147
  # Bash 3.2 friendly: a while-read loop rather than mapfile/readarray.
126
148
  while IFS= read -r project_obj; do
@@ -313,6 +335,20 @@ fi
313
335
  # ADR-053: host-wide Playwright browser cache. Always mounted (harmless
314
336
  # when browser testing is off); Chromium downloads once per HOST.
315
337
  printf ' - "%s:/opt/pw-browsers"\n' "$PW_VOLUME"
338
+ # ADR-062 shared files (org + personal) at /workspace/files/{org,me};
339
+ # ro/rw fixed here for the container's lifetime.
340
+ if [ "$shared_files_mode" != "off" ]; then
341
+ shared_suffix=""
342
+ [ "$shared_files_mode" = "ro" ] && shared_suffix=":ro"
343
+ if [ -n "$shared_owner_org" ]; then
344
+ printf ' - "%s/orgs/%s:/workspace/files/org%s"\n' \
345
+ "$shared_root" "$shared_owner_org" "$shared_suffix"
346
+ fi
347
+ if [ -n "$shared_owner_user" ]; then
348
+ printf ' - "%s/users/%s:/workspace/files/me%s"\n' \
349
+ "$shared_root" "$shared_owner_user" "$shared_suffix"
350
+ fi
351
+ fi
316
352
  printf ' ports:\n'
317
353
  # code-server (the Editor tunnel) is the only auto-published port. Preview
318
354
  # ports are NOT published at task-up (ADR-043 Stage 2: opt-in previews) —