@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,229 @@
1
+ /**
2
+ * Agent transport selection (ADR-061). Adapters call createAgentTransport()
3
+ * where they used to build a LineProcess directly; it returns either:
4
+ *
5
+ * - legacy pipes (`docker exec -i <cli>`, LineProcess) when
6
+ * UAI_DURABLE_SESSIONS=0, or
7
+ * - the durable path: an in-container runner owning the CLI, driven through
8
+ * inbox/outbox files on the workspace mount (DurableProcess).
9
+ *
10
+ * Durable mode makes host restarts transparent WITHOUT orchestrator
11
+ * changes: the orchestrator's lazy respawn calls factory.create as always,
12
+ * and this helper quietly ATTACHES to a still-live runner (fresh heartbeat +
13
+ * a `running` host_agent_sessions row) at the persisted outbox offset
14
+ * instead of spawning a new CLI — the agent's in-memory context survives.
15
+ * Adapters that re-handshake per process (codex) pass allowAttach:false and
16
+ * get a fresh runner each create — the stale predecessor is asked to stop
17
+ * via its inbox first.
18
+ *
19
+ * The runner script is COPIED into each session dir (not bind-mounted):
20
+ * every existing container already sees the workspace at its identical host
21
+ * path, so durable sessions work for containers created before this
22
+ * feature, and each spawn ships the runner version matching this host.
23
+ */
24
+
25
+ import { copyFileSync, mkdirSync, statSync, promises as fsp } from "node:fs";
26
+ import { join } from "node:path";
27
+
28
+ import { and, eq } from "drizzle-orm";
29
+
30
+ import { getDb, schema } from "../db";
31
+ import { taskWorkspaceDir } from "../env";
32
+ import { DurableProcess, runnerScriptPath } from "./durable-proc";
33
+ import { LineProcess, dockerExecArgs, type ExitHandler, type LineHandler } from "./proc";
34
+
35
+ /** The shared surface adapters program against (LineProcess's shape). */
36
+ export interface LineTransport {
37
+ onLine(handler: LineHandler): void;
38
+ onExit(handler: ExitHandler): void;
39
+ writeLine(value: unknown): void;
40
+ readonly stderrTail: string;
41
+ readonly isClosed: boolean;
42
+ close(): Promise<void>;
43
+ }
44
+
45
+ export interface AgentTransportOptions {
46
+ taskId: string;
47
+ agentId: string;
48
+ containerName: string;
49
+ /** CLI + args exactly as they'd follow `docker exec -i <container>`. */
50
+ cli: string;
51
+ cliArgs: string[];
52
+ /** Env names forwarded from the host's own env (`-e NAME`). */
53
+ passEnv?: string[];
54
+ /** Explicit per-exec env values (`-e NAME=value`). */
55
+ explicitEnv?: Record<string, string>;
56
+ /**
57
+ * Whether a live runner from a previous host process may be attached to.
58
+ * True for stateless adapters (claude); false for ones whose host-side
59
+ * protocol state can't outlive the host process (codex handshake).
60
+ */
61
+ allowAttach: boolean;
62
+ kind: string;
63
+ debugLabel?: string;
64
+ }
65
+
66
+ /** Runner heartbeat is 5 s; older than this = not attachable. */
67
+ const ATTACH_HEARTBEAT_FRESH_MS = 20_000;
68
+
69
+ function durableEnabled(): boolean {
70
+ return process.env.UAI_DURABLE_SESSIONS !== "0";
71
+ }
72
+
73
+ export function createAgentTransport(opts: AgentTransportOptions): LineTransport {
74
+ if (!durableEnabled()) {
75
+ const { command, args } = dockerExecArgs(
76
+ opts.containerName,
77
+ opts.cli,
78
+ opts.cliArgs,
79
+ opts.passEnv ?? [],
80
+ opts.explicitEnv ?? {},
81
+ );
82
+ return new LineProcess({ command, args, debugLabel: opts.debugLabel });
83
+ }
84
+
85
+ const db = getDb();
86
+ const row = db
87
+ .select()
88
+ .from(schema.hostAgentSessions)
89
+ .where(
90
+ and(
91
+ eq(schema.hostAgentSessions.taskId, opts.taskId),
92
+ eq(schema.hostAgentSessions.agentId, opts.agentId),
93
+ ),
94
+ )
95
+ .get();
96
+
97
+ const persistOffset = (offset: number): void => {
98
+ db.update(schema.hostAgentSessions)
99
+ .set({ outboxOffset: offset, updatedAt: Date.now() })
100
+ .where(
101
+ and(
102
+ eq(schema.hostAgentSessions.taskId, opts.taskId),
103
+ eq(schema.hostAgentSessions.agentId, opts.agentId),
104
+ ),
105
+ )
106
+ .run();
107
+ };
108
+ const markClosed = (): void => {
109
+ db.update(schema.hostAgentSessions)
110
+ .set({ status: "closed", updatedAt: Date.now() })
111
+ .where(
112
+ and(
113
+ eq(schema.hostAgentSessions.taskId, opts.taskId),
114
+ eq(schema.hostAgentSessions.agentId, opts.agentId),
115
+ ),
116
+ )
117
+ .run();
118
+ };
119
+
120
+ // ---- Attach: a previous host process left this agent's runner alive. ----
121
+ if (opts.allowAttach && row && row.status === "running" && heartbeatFresh(row.sessionDir)) {
122
+ const proc = new DurableProcess({
123
+ hostSessionDir: row.sessionDir,
124
+ initialOutboxOffset: row.outboxOffset,
125
+ onOffsetAdvance: persistOffset,
126
+ onCloseRequested: markClosed,
127
+ debugLabel: opts.debugLabel,
128
+ });
129
+ proc.onExit(markClosed);
130
+ return proc;
131
+ }
132
+
133
+ // ---- Spawn a fresh runner, asking any predecessor to stop. --------------
134
+ // Unconditional (not gated on row.status): a runner falsely marked closed
135
+ // by a stale-heartbeat verdict may still be alive, and a stop appended to
136
+ // a dead session's inbox is harmless — this is what guarantees one live
137
+ // CLI per (task, agent).
138
+ if (row) {
139
+ void fsp
140
+ .appendFile(join(row.sessionDir, "inbox.jsonl"), '{"__uai":"stop"}\n')
141
+ .catch(() => {
142
+ // Dir already gone / runner already dead — nothing to stop.
143
+ });
144
+ }
145
+
146
+ const sessionDir = join(
147
+ taskWorkspaceDir(opts.taskId),
148
+ ".uai",
149
+ "sessions",
150
+ opts.agentId,
151
+ String(Date.now()),
152
+ );
153
+ mkdirSync(sessionDir, { recursive: true });
154
+ // Ship this host's runner with the session: works in containers created
155
+ // before this feature (the workspace is mounted at its host path) and
156
+ // never skews against the host version.
157
+ const runnerInSession = join(sessionDir, "runner.mjs");
158
+ copyFileSync(runnerScriptPath(), runnerInSession);
159
+
160
+ const envArgs: string[] = [];
161
+ for (const name of opts.passEnv ?? []) {
162
+ if (process.env[name]) envArgs.push("-e", name);
163
+ }
164
+ for (const [name, value] of Object.entries(opts.explicitEnv ?? {})) {
165
+ envArgs.push("-e", `${name}=${value}`);
166
+ }
167
+
168
+ const proc = new DurableProcess({
169
+ hostSessionDir: sessionDir,
170
+ onOffsetAdvance: persistOffset,
171
+ onCloseRequested: markClosed,
172
+ debugLabel: opts.debugLabel,
173
+ spawnCommand: {
174
+ command: "docker",
175
+ args: [
176
+ "exec",
177
+ "-d",
178
+ ...envArgs,
179
+ opts.containerName,
180
+ "node",
181
+ runnerInSession, // identical path in-container (workspace self-mount)
182
+ sessionDir,
183
+ "--",
184
+ opts.cli,
185
+ ...opts.cliArgs,
186
+ ],
187
+ },
188
+ });
189
+
190
+ const now = Date.now();
191
+ db.insert(schema.hostAgentSessions)
192
+ .values({
193
+ taskId: opts.taskId,
194
+ agentId: opts.agentId,
195
+ sessionDir,
196
+ containerName: opts.containerName,
197
+ kind: opts.kind,
198
+ outboxOffset: 0,
199
+ status: "running",
200
+ createdAt: now,
201
+ updatedAt: now,
202
+ })
203
+ .onConflictDoUpdate({
204
+ target: [schema.hostAgentSessions.taskId, schema.hostAgentSessions.agentId],
205
+ set: {
206
+ sessionDir,
207
+ containerName: opts.containerName,
208
+ kind: opts.kind,
209
+ outboxOffset: 0,
210
+ status: "running",
211
+ updatedAt: now,
212
+ },
213
+ })
214
+ .run();
215
+
216
+ proc.onExit(markClosed);
217
+ return proc;
218
+ }
219
+
220
+ function heartbeatFresh(sessionDir: string): boolean {
221
+ try {
222
+ return (
223
+ Date.now() - statSync(join(sessionDir, "heartbeat")).mtimeMs <
224
+ ATTACH_HEARTBEAT_FRESH_MS
225
+ );
226
+ } catch {
227
+ return false;
228
+ }
229
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * ADR-053: in-container browser for agents — Playwright MCP wiring.
3
+ *
4
+ * When a task's project opted into browser testing, session start calls
5
+ * `setupBrowserTesting`, which (idempotently, via a versioned marker file)
6
+ * writes the MCP config for BOTH engines and kicks the installs in the
7
+ * background:
8
+ *
9
+ * - `/workspace/.mcp.json` — Claude Code's project-scoped MCP config,
10
+ * declaring the `browser` server (headFUL on DISPLAY :99).
11
+ * - `/workspace/.claude/settings.json` — `enableAllProjectMcpServers` so
12
+ * headless sessions load it without an approval prompt.
13
+ * - `~/.codex/config.toml` — an `[mcp_servers.browser]` block (appended
14
+ * once; the task owns a private copy of this file).
15
+ *
16
+ * Phase 2 (the WATCHABLE browser): Chromium runs headful under Xvfb (:99),
17
+ * mirrored by x11vnc → websockify/noVNC on container port 6080 — which the
18
+ * cloud surfaces as the synthetic "browser" preview, so the human can watch
19
+ * the agents click around from the preview menu. The viewer stack is
20
+ * (re)started on every setup call, pgrep-guarded, so it survives container
21
+ * restarts.
22
+ *
23
+ * Browser binaries land on the host-wide `uai-playwright` volume
24
+ * (PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers, mounted by the generated
25
+ * compose) so Chromium downloads once per HOST. Everything here is
26
+ * best-effort and never blocks session start.
27
+ *
28
+ * Hard-won asdf rules (2026-07-07): npx resolves ONLY where a .tool-versions
29
+ * applies → always `-w /workspace`; and root has no ~/.tool-versions →
30
+ * root execs also need `-e HOME=/home/node`.
31
+ */
32
+ import { dockerCli } from "./docker-exec";
33
+
34
+ // v3: self-sufficient MCP launcher + loop-wrapped viewer daemons. Bumping
35
+ // re-runs setup on live channels; config writes stay `[ -f ] ||`-guarded so
36
+ // a task keeps the configs it started with.
37
+ const MARKER = "/workspace/.uai/.browser-mcp-ready-v3";
38
+
39
+ const SERVER_DISPLAY = ":99";
40
+ const XVFB_CMD = `Xvfb ${SERVER_DISPLAY} -screen 0 1440x900x24 -nolisten tcp`;
41
+
42
+ /**
43
+ * The MCP server launcher — self-sufficient by design (learned live: a
44
+ * session that spawns while the apt installs are still running got a DEAD
45
+ * mcp server, and the agent had to improvise). It ensures its own display
46
+ * when Xvfb exists (headful → watchable), and falls back to a HEADLESS
47
+ * browser while the viewer packages are still installing — the agent always
48
+ * gets working tools. `sh -lc` + cd /workspace for the asdf shims. No
49
+ * single quotes in here: it's embedded in single-quoted JSON/TOML strings.
50
+ */
51
+ // X's own lockfile is the display-up signal — no pgrep (pgrep -f guards
52
+ // self-matched their launcher's cmdline and skipped every start; found
53
+ // live 2026-07-08), and it also detects displays an AGENT started itself.
54
+ const X_LOCK = `/tmp/.X${SERVER_DISPLAY.slice(1)}-lock`;
55
+
56
+ const SERVER_LAUNCHER =
57
+ `cd /workspace; ` +
58
+ `if command -v Xvfb >/dev/null 2>&1; then ` +
59
+ `[ -e ${X_LOCK} ] || (nohup ${XVFB_CMD} >>/tmp/uai-xvfb.log 2>&1 &); ` +
60
+ `sleep 1; export DISPLAY=${SERVER_DISPLAY}; ` +
61
+ `exec npx -y @playwright/mcp@latest --browser chromium --no-sandbox; ` +
62
+ `else ` +
63
+ `exec npx -y @playwright/mcp@latest --browser chromium --no-sandbox --headless; ` +
64
+ `fi`;
65
+
66
+ const SERVER_COMMAND = "sh";
67
+ const SERVER_ARGS = ["-lc", SERVER_LAUNCHER];
68
+
69
+ const MCP_JSON = JSON.stringify(
70
+ {
71
+ mcpServers: {
72
+ browser: { command: SERVER_COMMAND, args: SERVER_ARGS },
73
+ },
74
+ },
75
+ null,
76
+ 2,
77
+ );
78
+
79
+ const CLAUDE_SETTINGS_JSON = JSON.stringify(
80
+ { enableAllProjectMcpServers: true },
81
+ null,
82
+ 2,
83
+ );
84
+
85
+ const CODEX_TOML = [
86
+ "",
87
+ "# uai ADR-053: in-container browser (Playwright MCP)",
88
+ "[mcp_servers.browser]",
89
+ `command = '${SERVER_COMMAND}'`,
90
+ `args = [${SERVER_ARGS.map((a) => `'${a}'`).join(", ")}]`,
91
+ "",
92
+ ].join("\n");
93
+
94
+ /**
95
+ * Start the watchable-browser stack (Xvfb → x11vnc → noVNC on :6080) if its
96
+ * binaries are installed and it isn't already up. x11vnc and websockify run
97
+ * under tiny restart loops — x11vnc EXITS whenever the X server it watches
98
+ * isn't up yet (the race that killed the first live run), and the loops
99
+ * also reattach after the display owner changes. Safe to run repeatedly:
100
+ * every piece is pgrep-guarded, including the loops themselves.
101
+ */
102
+ /**
103
+ * The viewer daemons, one docker-exec each (the single-line nohup shape is
104
+ * the only one that reliably survives `docker exec -d`). Guards use the X
105
+ * lockfile and PIDFILES — never pgrep: a pgrep -f guard sharing a cmdline
106
+ * with its own payload self-matches and skips the start (this exact bug
107
+ * kept the stack down in every container until 2026-07-08).
108
+ */
109
+ const VIEWER_STEPS: string[] = [
110
+ // Xvfb — one-shot; the X lockfile is the truth (also set when an agent
111
+ // started the display itself). If it dies the lock clears and the next
112
+ // session ensure relaunches it.
113
+ `command -v Xvfb >/dev/null 2>&1 || exit 0; [ -e ${X_LOCK} ] || nohup ${XVFB_CMD} >>/tmp/uai-xvfb.log 2>&1 &`,
114
+ // x11vnc under a restart loop (it exits whenever the X server isn't up
115
+ // yet). Pidfile-guarded.
116
+ `command -v x11vnc >/dev/null 2>&1 || exit 0; [ -f /tmp/uai-x11vnc.pid ] && kill -0 "$(cat /tmp/uai-x11vnc.pid)" 2>/dev/null && exit 0; nohup sh -c 'echo $$ > /tmp/uai-x11vnc.pid; while true; do x11vnc -display ${SERVER_DISPLAY} -forever -shared -nopw -quiet >>/tmp/uai-x11vnc.log 2>&1; sleep 2; done' >/dev/null 2>&1 &`,
117
+ // websockify/noVNC under the same pattern.
118
+ `command -v websockify >/dev/null 2>&1 || exit 0; [ -f /tmp/uai-novnc.pid ] && kill -0 "$(cat /tmp/uai-novnc.pid)" 2>/dev/null && exit 0; nohup sh -c 'echo $$ > /tmp/uai-novnc.pid; while true; do websockify --web=/usr/share/novnc 0.0.0.0:6080 localhost:5900 >>/tmp/uai-websockify.log 2>&1; sleep 2; done' >/dev/null 2>&1 &`,
119
+ ];
120
+
121
+ export async function setupBrowserTesting(
122
+ taskId: string,
123
+ containerName: string,
124
+ hasCodex: boolean,
125
+ ): Promise<void> {
126
+ try {
127
+ // The viewer stack restarts whenever it died (container restart, crash) —
128
+ // outside the marker guard on purpose. No-op until its packages install.
129
+ // ONE exec per daemon: the single-line nohup shape is the only one that
130
+ // reliably survives `docker exec -d`.
131
+ for (const step of VIEWER_STEPS) {
132
+ await dockerCli(["exec", "-d", containerName, "sh", "-c", step], {
133
+ timeoutMs: 10_000,
134
+ });
135
+ }
136
+
137
+ const marked = await dockerCli(
138
+ ["exec", containerName, "test", "-f", MARKER],
139
+ { timeoutMs: 5_000 },
140
+ );
141
+ if (marked.status === 0) return;
142
+
143
+ const script = [
144
+ "set -e",
145
+ "mkdir -p /workspace/.uai /workspace/.claude",
146
+ // Claude: project-scoped MCP config + auto-approve setting. The
147
+ // workspace is fresh per task, so plain writes are safe; keep them
148
+ // conditional anyway so a human's later edits survive re-runs.
149
+ `[ -f /workspace/.mcp.json ] || printf '%s\\n' ${shellQuote(MCP_JSON)} > /workspace/.mcp.json`,
150
+ `[ -f /workspace/.claude/settings.json ] || printf '%s\\n' ${shellQuote(CLAUDE_SETTINGS_JSON)} > /workspace/.claude/settings.json`,
151
+ ...(hasCodex
152
+ ? [
153
+ // Codex: append once to the task's PRIVATE config copy.
154
+ `grep -q "mcp_servers.browser" /home/node/.codex/config.toml 2>/dev/null || printf '%s' ${shellQuote(CODEX_TOML)} >> /home/node/.codex/config.toml`,
155
+ ]
156
+ : []),
157
+ `touch ${MARKER}`,
158
+ ].join(" && ");
159
+
160
+ const wrote = await dockerCli(
161
+ ["exec", containerName, "sh", "-lc", script],
162
+ { timeoutMs: 20_000 },
163
+ );
164
+ if (wrote.status !== 0) {
165
+ console.warn(
166
+ `[browser] task ${taskId}: MCP config write failed: ${wrote.stderr.slice(0, 300)}`,
167
+ );
168
+ return;
169
+ }
170
+
171
+ // Volume ownership, then the browser download as NODE (host-wide
172
+ // one-timer on the shared volume).
173
+ await dockerCli(
174
+ ["exec", "-u", "root", containerName, "chown", "node:node", "/opt/pw-browsers"],
175
+ { timeoutMs: 5_000 },
176
+ );
177
+ await dockerCli(
178
+ [
179
+ "exec",
180
+ "-d",
181
+ "-w",
182
+ "/workspace",
183
+ containerName,
184
+ "sh",
185
+ "-lc",
186
+ "npx -y playwright@latest install chromium >/tmp/uai-pw-browser.log 2>&1 || true",
187
+ ],
188
+ { timeoutMs: 10_000 },
189
+ );
190
+ // Chromium apt libs + the viewer stack packages, then start the stack —
191
+ // one ordered root chain, backgrounded, per-container.
192
+ await dockerCli(
193
+ [
194
+ "exec",
195
+ "-d",
196
+ "-u",
197
+ "root",
198
+ // HOME=/home/node is for asdf version resolution ONLY — without the
199
+ // cache redirect below, root's npx writes ROOT-OWNED entries into
200
+ // node's ~/.npm and breaks the node user's own npx (which launches
201
+ // the MCP server). Found live 2026-07-08.
202
+ "-e",
203
+ "HOME=/home/node",
204
+ "-e",
205
+ "npm_config_cache=/tmp/uai-root-npm-cache",
206
+ "-w",
207
+ "/workspace",
208
+ containerName,
209
+ "sh",
210
+ "-lc",
211
+ "{ npx -y playwright@latest install-deps chromium && " +
212
+ "apt-get install -y --no-install-recommends xvfb x11vnc novnc websockify && " +
213
+ // Older-image containers get their packages only HERE (post-apt),
214
+ // so kick the viewer daemons now. Each step subshelled: the steps'
215
+ // internal `exit 0` guards must not abort their siblings.
216
+ `su -s /bin/sh node -c '${VIEWER_STEPS.map((s) => `( ${s} )`)
217
+ .join("; ")
218
+ .replace(/'/g, `'\\''`)}'; ` +
219
+ // Heal any damage a pre-fix host already did to the cache.
220
+ "chown -R node:node /home/node/.npm 2>/dev/null; } " +
221
+ ">/tmp/uai-pw-deps.log 2>&1 || true",
222
+ ],
223
+ { timeoutMs: 10_000 },
224
+ );
225
+ console.log(`[browser] task ${taskId}: Playwright MCP wired (installs backgrounded)`);
226
+ } catch (err) {
227
+ // Best-effort by design — a task without a browser still runs.
228
+ console.warn(`[browser] task ${taskId}: setup failed`, err);
229
+ }
230
+ }
231
+
232
+ /** Single-quote for `sh -c` (POSIX-safe). */
233
+ function shellQuote(text: string): string {
234
+ return `'${text.replace(/'/g, `'\\''`)}'`;
235
+ }