@runuai/host 0.8.42 → 0.8.43

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.
@@ -2,43 +2,144 @@
2
2
  * ADR-053: in-container browser for agents — Playwright MCP wiring.
3
3
  *
4
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:
5
+ * `setupBrowserTesting`, which installs Chromium and points both engines at
6
+ * it. Idempotent, and safe to call repeatedly:
8
7
  *
9
8
  * - `/workspace/.mcp.json` — Claude Code's project-scoped MCP config,
10
9
  * declaring the `browser` server (headFUL on DISPLAY :99).
11
10
  * - `/workspace/.claude/settings.json` — `enableAllProjectMcpServers` so
12
11
  * 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).
12
+ * - Every active Codex account's `config.toml` — an
13
+ * `[mcp_servers.browser]` block in the account's private CODEX_HOME copy.
14
+ *
15
+ * Our `browser` entry is RE-ASSERTED on every call — rewritten, not skipped
16
+ * when present, and not gated on a marker. Recovery/start credential
17
+ * injection and account provisioning can replace a Codex config before the
18
+ * serialized setup boundary, so a marker would only ever prove what we once
19
+ * wrote, not what is there now. (Live auth refresh deliberately excludes
20
+ * config.toml.) Only shapes uai itself wrote are touched; anything else is
21
+ * reported and left alone. When a rewrite was needed, affected sessions are
22
+ * recycled once the browser is ready and their active turn is done, since
23
+ * agent CLIs read MCP servers once at process start.
15
24
  *
16
25
  * Phase 2 (the WATCHABLE browser): Chromium runs headful under Xvfb (:99),
17
26
  * mirrored by x11vnc → websockify/noVNC on container port 6080 — which the
18
27
  * cloud surfaces as the synthetic "browser" preview, so the human can watch
19
28
  * 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.
29
+ * (re)started on every setup call, lockfile/pidfile-guarded, so it survives
30
+ * container restarts.
22
31
  *
23
32
  * Browser binaries land on the host-wide `uai-playwright` volume
24
33
  * (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.
34
+ * compose) so Chromium downloads once per HOST.
35
+ *
36
+ * The browser install DOES block session start, on purpose — see
37
+ * `setupBrowserTesting`. Everything else here is best-effort. The apt/viewer
38
+ * chain stays backgrounded; the browser does not depend on it.
27
39
  *
28
40
  * Hard-won asdf rules (2026-07-07): npx resolves ONLY where a .tool-versions
29
41
  * applies → always `-w /workspace`; and root has no ~/.tool-versions →
30
42
  * root execs also need `-e HOME=/home/node`.
31
43
  */
32
- import { dockerCli } from "./docker-exec";
44
+ import { createHash } from "node:crypto";
33
45
 
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";
46
+ import { dockerCli } from "./docker-exec";
47
+ import { MCP_CONFIG_LOCK_PATH } from "./mcp-config-lock";
38
48
 
39
49
  const SERVER_DISPLAY = ":99";
40
50
  const XVFB_CMD = `Xvfb ${SERVER_DISPLAY} -screen 0 1440x900x24 -nolisten tcp`;
41
51
 
52
+ /**
53
+ * The MCP server package — PINNED, deliberately, and the single source of
54
+ * truth for the whole browser stack.
55
+ *
56
+ * Every Playwright release wants one exact Chromium *build number*, and
57
+ * treats any other build as "not installed". So the pre-warm and the runtime
58
+ * must resolve the same `playwright-core`, or the browser never launches.
59
+ * `@playwright/mcp@latest` cannot promise that: it floats, and its pinned
60
+ * `playwright-core` floats with it.
61
+ *
62
+ * That is not hypothetical — it was the live bug (task 01kye1b8..., Jul 2026,
63
+ * fixed here). Provisioning ran `playwright@latest install chromium` (build
64
+ * 1234) while the MCP resolved to 0.0.78 → build 1232, so EVERY container
65
+ * started with a dead browser and a black VNC preview, forever: a version
66
+ * mismatch never heals on retry. A follow-up isolated diagnostic also proved
67
+ * that `playwright install` garbage-collects builds it does not recognise and
68
+ * can delete another revision from the shared host volume. That eviction was
69
+ * deliberately induced while diagnosing the incident, not observed as part
70
+ * of the original outage, but it makes an unflagged pre-warm unsafe.
71
+ *
72
+ * The fix is structural: browsers are installed THROUGH this exact package
73
+ * (below), so provisioning and runtime cannot disagree by construction. To
74
+ * upgrade, change this one string — the pre-warm follows automatically.
75
+ */
76
+ export const MCP_PKG = "@playwright/mcp@0.0.78";
77
+
78
+ /**
79
+ * The browser to launch AND to pre-warm. One constant feeding both
80
+ * `--browser` and `install-browser`, so the two can never name different
81
+ * browsers either. (In current playwright-core `chromium` is an alias for the
82
+ * `chrome-for-testing` channel — hence the error text agents used to hit.)
83
+ */
84
+ export const BROWSER = "chromium";
85
+
86
+ /**
87
+ * Install the exact Chromium build `MCP_PKG`'s bundled playwright-core wants.
88
+ *
89
+ * `install-browser` is the MCP's own passthrough to that playwright-core, so
90
+ * the build is right by construction, and it is idempotent and fast (~0.5s)
91
+ * once present.
92
+ *
93
+ * `PLAYWRIGHT_SKIP_BROWSER_GC=1` is load-bearing, not decoration.
94
+ * `install-browser` is NOT inherently additive — `@playwright/mcp`'s `cli.js`
95
+ * literally rewrites the word to `install`, and Playwright's registry install
96
+ * then garbage-collects: it keeps a `.links` entry per installed
97
+ * playwright-core under the browsers path and deletes any build no LIVE link
98
+ * still wants. Since `/opt/pw-browsers` is a host-WIDE volume, an unflagged
99
+ * install in one container evicts browsers other tasks are mid-use of; that
100
+ * is measured, not theorised (a stale build vanishes on the very next run).
101
+ * The flag is honoured by the pinned core and makes the install genuinely
102
+ * additive, so provisioning can never be the thing that breaks another task.
103
+ *
104
+ * The cost is that dead builds are never reclaimed automatically. That is not
105
+ * the ~300 MB of download it sounds like: unpacked, one build set is ~980 MB
106
+ * (measured — 641 MB Chromium + 340 MB headless shell), so every pin bump
107
+ * strands about a gigabyte per host until someone prunes `/opt/pw-browsers`.
108
+ * Still the right trade against deleting a browser out from under a running
109
+ * agent, but it is a real cost and the prune is a real chore.
110
+ *
111
+ * Bounded IN the container too. `dockerCli`'s own timeout SIGKILLs the local
112
+ * `docker exec` client, and docker does not forward that to the process
113
+ * inside (moby/moby#9098) — so a host-side bound alone would let a wedged
114
+ * installer keep running, and keep holding Playwright's registry lock, while
115
+ * we conclude it failed and spawn agents anyway. Kept under the host-side
116
+ * bound so this is the one that fires.
117
+ */
118
+ const INSTALL_TIMEOUT_S = 280;
119
+
120
+ // The env assignment leads: `timeout N VAR=1 cmd` makes timeout try to exec
121
+ // "VAR=1" as a program and exit 127 without running anything. As a shell
122
+ // prefix it applies to `timeout` and is inherited straight through.
123
+ // `--kill-after` because plain `timeout` only sends TERM: an installer wedged
124
+ // in an uninterruptible or signal-ignoring state would absorb it and keep the
125
+ // registry lock, which is the whole thing this is here to prevent.
126
+ const INSTALL_BROWSER =
127
+ `PLAYWRIGHT_SKIP_BROWSER_GC=1 timeout --kill-after=10 ${INSTALL_TIMEOUT_S} ` +
128
+ `npx -y ${MCP_PKG} install-browser ${BROWSER}`;
129
+ const INSTALL_LOG = "/tmp/uai-pw-browser.log";
130
+ /** Host-side backstop, deliberately looser than the in-container `timeout`. */
131
+ const INSTALL_TIMEOUT_MS = 300_000;
132
+ const MCP_CONFIG_PATH = "/workspace/.mcp.json";
133
+ export const DEFAULT_CODEX_HOME = "/home/node/.codex";
134
+ const CLAUDE_SETTINGS_PATH = "/workspace/.claude/settings.json";
135
+ const CONFIG_RESULT_TOKEN = "__UAI_BROWSER_CONFIG__";
136
+
137
+ /**
138
+ * The pre-warm run at session start. No `|| true`: it is awaited now, so its
139
+ * exit status is the readiness signal we log on.
140
+ */
141
+ export const PREWARM_CMD = `${INSTALL_BROWSER} >${INSTALL_LOG} 2>&1`;
142
+
42
143
  /**
43
144
  * The MCP server launcher — self-sufficient by design (learned live: a
44
145
  * session that spawns while the apt installs are still running got a DEAD
@@ -53,28 +154,28 @@ const XVFB_CMD = `Xvfb ${SERVER_DISPLAY} -screen 0 1440x900x24 -nolisten tcp`;
53
154
  // live 2026-07-08), and it also detects displays an AGENT started itself.
54
155
  const X_LOCK = `/tmp/.X${SERVER_DISPLAY.slice(1)}-lock`;
55
156
 
157
+ // NOTHING that can download belongs on this path. An MCP client gives the
158
+ // server a fixed budget to complete its handshake — Codex 10s, Claude Code
159
+ // 30s — and neither config raises it, so a ~90s install here doesn't delay
160
+ // the first session, it LOSES it. Readiness is established before agents
161
+ // spawn instead (see setupBrowserTesting), where a slow install is merely
162
+ // slow. Ensuring the display is fine: Xvfb is local and instant.
56
163
  const SERVER_LAUNCHER =
57
164
  `cd /workspace; ` +
58
165
  `if command -v Xvfb >/dev/null 2>&1; then ` +
59
166
  `[ -e ${X_LOCK} ] || (nohup ${XVFB_CMD} >>/tmp/uai-xvfb.log 2>&1 &); ` +
60
167
  `sleep 1; export DISPLAY=${SERVER_DISPLAY}; ` +
61
- `exec npx -y @playwright/mcp@latest --browser chromium --no-sandbox; ` +
168
+ `exec npx -y ${MCP_PKG} --browser ${BROWSER} --no-sandbox; ` +
62
169
  `else ` +
63
- `exec npx -y @playwright/mcp@latest --browser chromium --no-sandbox --headless; ` +
170
+ `exec npx -y ${MCP_PKG} --browser ${BROWSER} --no-sandbox --headless; ` +
64
171
  `fi`;
65
172
 
66
173
  const SERVER_COMMAND = "sh";
67
- const SERVER_ARGS = ["-lc", SERVER_LAUNCHER];
174
+ /** Exported for the test that locks the pin/browser invariants. */
175
+ export const SERVER_ARGS: [string, string] = ["-lc", SERVER_LAUNCHER];
68
176
 
69
- const MCP_JSON = JSON.stringify(
70
- {
71
- mcpServers: {
72
- browser: { command: SERVER_COMMAND, args: SERVER_ARGS },
73
- },
74
- },
75
- null,
76
- 2,
77
- );
177
+ /** The `browser` server definition both engines get. */
178
+ const SERVER_DEF = { command: SERVER_COMMAND, args: SERVER_ARGS };
78
179
 
79
180
  const CLAUDE_SETTINGS_JSON = JSON.stringify(
80
181
  { enableAllProjectMcpServers: true },
@@ -82,14 +183,669 @@ const CLAUDE_SETTINGS_JSON = JSON.stringify(
82
183
  2,
83
184
  );
84
185
 
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");
186
+ const MANAGED_PACKAGE = "@playwright/mcp@<uai-version>";
187
+
188
+ /**
189
+ * Every browser-server shape uai has shipped. Ownership is an exact semantic
190
+ * allowlist, with only the package version normalised: a pin bump is ours,
191
+ * but one extra flag, env var, timeout, cwd, or tool filter is a human's
192
+ * customisation and must survive untouched.
193
+ */
194
+ const HISTORICAL_SERVER_DEFS: Array<Record<string, unknown>> = [
195
+ {
196
+ command: "npx",
197
+ args: [
198
+ "-y",
199
+ "@playwright/mcp@latest",
200
+ "--headless",
201
+ "--browser",
202
+ "chromium",
203
+ "--no-sandbox",
204
+ ],
205
+ },
206
+ {
207
+ command: "npx",
208
+ args: [
209
+ "-y",
210
+ "@playwright/mcp@latest",
211
+ "--browser",
212
+ "chromium",
213
+ "--no-sandbox",
214
+ ],
215
+ env: { DISPLAY: SERVER_DISPLAY },
216
+ },
217
+ {
218
+ command: "sh",
219
+ args: [
220
+ "-lc",
221
+ `cd /workspace; if command -v Xvfb >/dev/null 2>&1; then ` +
222
+ `pgrep -f "Xvfb ${SERVER_DISPLAY}" >/dev/null 2>&1 || ` +
223
+ `(${XVFB_CMD} >>/tmp/uai-xvfb.log 2>&1 &); ` +
224
+ `sleep 1; export DISPLAY=${SERVER_DISPLAY}; ` +
225
+ `exec npx -y @playwright/mcp@latest --browser chromium --no-sandbox; ` +
226
+ `else exec npx -y @playwright/mcp@latest --browser chromium --no-sandbox --headless; fi`,
227
+ ],
228
+ },
229
+ {
230
+ command: "sh",
231
+ args: [
232
+ "-lc",
233
+ `cd /workspace; if command -v Xvfb >/dev/null 2>&1; then ` +
234
+ `[ -e ${X_LOCK} ] || (nohup ${XVFB_CMD} >>/tmp/uai-xvfb.log 2>&1 &); ` +
235
+ `sleep 1; export DISPLAY=${SERVER_DISPLAY}; ` +
236
+ `exec npx -y @playwright/mcp@latest --browser chromium --no-sandbox; ` +
237
+ `else exec npx -y @playwright/mcp@latest --browser chromium --no-sandbox --headless; fi`,
238
+ ],
239
+ },
240
+ {
241
+ command: "sh",
242
+ args: [
243
+ "-lc",
244
+ `cd /workspace; npx -y @playwright/mcp@0.0.78 install-browser chromium ` +
245
+ `>>${INSTALL_LOG} 2>&1 || true; ` +
246
+ `if command -v Xvfb >/dev/null 2>&1; then ` +
247
+ `[ -e ${X_LOCK} ] || (nohup ${XVFB_CMD} >>/tmp/uai-xvfb.log 2>&1 &); ` +
248
+ `sleep 1; export DISPLAY=${SERVER_DISPLAY}; ` +
249
+ `exec npx -y @playwright/mcp@0.0.78 --browser chromium --no-sandbox; ` +
250
+ `else exec npx -y @playwright/mcp@0.0.78 --browser chromium --no-sandbox --headless; fi`,
251
+ ],
252
+ },
253
+ SERVER_DEF,
254
+ ];
255
+
256
+ function normaliseManagedPackage(value: unknown): unknown {
257
+ if (typeof value === "string") {
258
+ return value.replace(
259
+ /@playwright\/mcp@(?:latest|\d+\.\d+\.\d+)(?=[\s'"]|$)/g,
260
+ MANAGED_PACKAGE,
261
+ );
262
+ }
263
+ if (Array.isArray(value)) return value.map(normaliseManagedPackage);
264
+ if (value && typeof value === "object") {
265
+ return Object.fromEntries(
266
+ Object.entries(value).map(([key, item]) => [key, normaliseManagedPackage(item)]),
267
+ );
268
+ }
269
+ return value;
270
+ }
271
+
272
+ /** Exported so ownership tests lock the historical allowlist itself. */
273
+ export const MANAGED_BROWSER_DEFS = HISTORICAL_SERVER_DEFS.map(
274
+ normaliseManagedPackage,
275
+ );
276
+
277
+ const CODEX_MARKER = "# uai ADR-053: in-container browser (Playwright MCP)";
278
+
279
+ function tomlString(value: string, quote: "'" | '"'): string {
280
+ if (quote === "'") {
281
+ if (value.includes("'")) {
282
+ throw new Error("Uai browser launcher cannot be represented as TOML literal text");
283
+ }
284
+ return `'${value}'`;
285
+ }
286
+ return JSON.stringify(value);
287
+ }
288
+
289
+ function codexBlock(
290
+ definition: Record<string, unknown>,
291
+ quote: "'" | '"',
292
+ ): string {
293
+ const command = String(definition.command);
294
+ const args = (definition.args as unknown[]).map(String);
295
+ const lines = [
296
+ "",
297
+ CODEX_MARKER,
298
+ "[mcp_servers.browser]",
299
+ `command = ${tomlString(command, quote)}`,
300
+ `args = [${args.map((arg) => tomlString(arg, quote)).join(", ")}]`,
301
+ ];
302
+ if (definition.env && typeof definition.env === "object") {
303
+ const pairs = Object.entries(definition.env).map(
304
+ ([key, value]) => `${key} = ${tomlString(String(value), quote)}`,
305
+ );
306
+ lines.push(`env = { ${pairs.join(", ")} }`);
307
+ }
308
+ lines.push("");
309
+ return lines.join("\n");
310
+ }
311
+
312
+ /** Exact text shapes Uai wrote, used only after Codex confirms the semantics. */
313
+ export const MANAGED_CODEX_BLOCKS = [
314
+ codexBlock(HISTORICAL_SERVER_DEFS[0]!, '"'),
315
+ codexBlock(HISTORICAL_SERVER_DEFS[1]!, '"'),
316
+ ...HISTORICAL_SERVER_DEFS.slice(2).map((definition) =>
317
+ codexBlock(definition, "'"),
318
+ ),
319
+ ]
320
+ .map((block) => String(normaliseManagedPackage(block)))
321
+ .filter((block, index, blocks) => blocks.indexOf(block) === index);
322
+
323
+ const CODEX_TOML = codexBlock(SERVER_DEF, "'");
324
+
325
+ /**
326
+ * The active migrator. Claude JSON is parsed and atomically replaced. The
327
+ * shipped Codex CLI is used only as the read-only semantic parser; a Codex
328
+ * write is allowed only when the raw block exactly matches text Uai shipped.
329
+ * That preserves neighboring tables/comments byte-for-byte and declines
330
+ * custom fields the CLI's JSON view may omit.
331
+ */
332
+ export const MIGRATE_JS = String.raw`
333
+ const fs = require("node:fs");
334
+ const path = require("node:path");
335
+ const crypto = require("node:crypto");
336
+ const child = require("node:child_process");
337
+
338
+ const def = JSON.parse(process.env.UAI_BROWSER_DEF);
339
+ const settingsDef = JSON.parse(process.env.UAI_CLAUDE_SETTINGS_DEF);
340
+ const managedDefs = ${JSON.stringify(MANAGED_BROWSER_DEFS)};
341
+ const packageToken = ${JSON.stringify(MANAGED_PACKAGE)};
342
+ const managedCodexBlocks = ${JSON.stringify(MANAGED_CODEX_BLOCKS)};
343
+ const desiredCodexBlock = ${JSON.stringify(CODEX_TOML)};
344
+ const notes = [];
345
+ let changed = false;
346
+ let configured = true;
347
+ let codexHomes = [];
348
+ try {
349
+ const parsed = JSON.parse(process.env.UAI_CODEX_HOMES || "[]");
350
+ if (!Array.isArray(parsed) ||
351
+ !parsed.every(function (home) {
352
+ return typeof home === "string" &&
353
+ (home === ${JSON.stringify(DEFAULT_CODEX_HOME)} ||
354
+ /^\/home\/node\/\.codex-acct-[A-Za-z0-9_-]+$/.test(home));
355
+ })) {
356
+ throw new Error("invalid Codex home list");
357
+ }
358
+ codexHomes = Array.from(new Set(parsed));
359
+ } catch (error) {
360
+ notes.push("Codex home list is invalid: " + String(error.message || error));
361
+ configured = false;
362
+ }
363
+
364
+ function plainObject(value) {
365
+ return value !== null && typeof value === "object" && !Array.isArray(value);
366
+ }
367
+
368
+ function canonical(value) {
369
+ if (Array.isArray(value)) return value.map(canonical);
370
+ if (plainObject(value)) {
371
+ const out = {};
372
+ for (const key of Object.keys(value).sort()) out[key] = canonical(value[key]);
373
+ return out;
374
+ }
375
+ return value;
376
+ }
377
+
378
+ function equal(left, right) {
379
+ return JSON.stringify(canonical(left)) === JSON.stringify(canonical(right));
380
+ }
381
+
382
+ function normalise(value) {
383
+ if (typeof value === "string") {
384
+ return value.replace(
385
+ /@playwright\/mcp@(?:latest|\d+\.\d+\.\d+)(?=[\s'"]|$)/g,
386
+ packageToken
387
+ );
388
+ }
389
+ if (Array.isArray(value)) return value.map(normalise);
390
+ if (plainObject(value)) {
391
+ const out = {};
392
+ for (const [key, item] of Object.entries(value)) out[key] = normalise(item);
393
+ return out;
394
+ }
395
+ return value;
396
+ }
397
+
398
+ function isManaged(value) {
399
+ const normalised = normalise(value);
400
+ return managedDefs.some(function (candidate) {
401
+ return equal(candidate, normalised);
402
+ });
403
+ }
404
+
405
+ function atomicWrite(file, text, expected) {
406
+ fs.mkdirSync(path.dirname(file), { recursive: true });
407
+ const mode = fs.existsSync(file) ? fs.statSync(file).mode & 0o777 : 0o644;
408
+ const tmp = path.join(
409
+ path.dirname(file),
410
+ "." + path.basename(file) + "." + process.pid + "." +
411
+ crypto.randomBytes(8).toString("hex") + ".tmp"
412
+ );
413
+ try {
414
+ fs.writeFileSync(tmp, text, { encoding: "utf8", flag: "wx", mode: mode });
415
+ if (arguments.length >= 3) {
416
+ const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null;
417
+ if (current !== expected) return false;
418
+ }
419
+ fs.renameSync(tmp, file);
420
+ return true;
421
+ } finally {
422
+ try { fs.unlinkSync(tmp); } catch {}
423
+ }
424
+ }
425
+
426
+ function atomicCreate(file, text) {
427
+ fs.mkdirSync(path.dirname(file), { recursive: true });
428
+ const tmp = path.join(
429
+ path.dirname(file),
430
+ "." + path.basename(file) + "." + process.pid + "." +
431
+ crypto.randomBytes(8).toString("hex") + ".tmp"
432
+ );
433
+ try {
434
+ fs.writeFileSync(tmp, text, { encoding: "utf8", flag: "wx", mode: 0o644 });
435
+ try {
436
+ fs.linkSync(tmp, file);
437
+ return true;
438
+ } catch (error) {
439
+ if (error && error.code === "EEXIST") return false;
440
+ throw error;
441
+ }
442
+ } finally {
443
+ try { fs.unlinkSync(tmp); } catch {}
444
+ }
445
+ }
446
+
447
+ function ensureClaudeBrowser() {
448
+ const file = process.env.UAI_MCP_PATH;
449
+ if (!file) return { configured: true, changed: false };
450
+ const exists = fs.existsSync(file);
451
+ let original = null;
452
+ let document;
453
+ try {
454
+ original = exists ? fs.readFileSync(file, "utf8") : null;
455
+ document = exists ? JSON.parse(original) : { mcpServers: {} };
456
+ } catch {
457
+ notes.push("mcp.json is unparseable - left alone");
458
+ return { configured: false, changed: false };
459
+ }
460
+ if (!plainObject(document)) {
461
+ notes.push("mcp.json root is not an object - left alone");
462
+ return { configured: false, changed: false };
463
+ }
464
+ if (document.mcpServers === undefined) document.mcpServers = {};
465
+ if (!plainObject(document.mcpServers)) {
466
+ notes.push("mcp.json mcpServers is not an object - left alone");
467
+ return { configured: false, changed: false };
468
+ }
469
+ const current = document.mcpServers.browser;
470
+ if (current !== undefined && equal(current, def)) {
471
+ return { configured: true, changed: false };
472
+ }
473
+ if (current !== undefined && !isManaged(current)) {
474
+ notes.push("mcp.json browser entry is not uai-managed - left alone");
475
+ return { configured: true, changed: false };
476
+ }
477
+ document.mcpServers.browser = def;
478
+ if (!atomicWrite(file, JSON.stringify(document, null, 2) + "\n", original)) {
479
+ notes.push("mcp.json changed concurrently - retrying later");
480
+ return { configured: false, changed: false };
481
+ }
482
+ return { configured: true, changed: true };
483
+ }
484
+
485
+ function ensureClaudeSettings() {
486
+ const file = process.env.UAI_CLAUDE_SETTINGS_PATH;
487
+ if (!file || fs.existsSync(file)) return { configured: true, changed: false };
488
+ try {
489
+ const created = atomicCreate(file, JSON.stringify(settingsDef, null, 2) + "\n");
490
+ return { configured: true, changed: created };
491
+ } catch (error) {
492
+ notes.push("Claude settings write failed: " + String(error.message || error));
493
+ return { configured: false, changed: false };
494
+ }
495
+ }
496
+
497
+ function exactKeys(value, keys) {
498
+ return plainObject(value) &&
499
+ Object.keys(value).sort().join("\0") === keys.slice().sort().join("\0");
500
+ }
501
+
502
+ const codexTopKeys = [
503
+ "disabled_reason", "disabled_tools", "enabled", "enabled_tools", "name",
504
+ "startup_timeout_sec", "tool_timeout_sec", "transport"
505
+ ];
506
+
507
+ function stringArrayOrNull(value) {
508
+ return value === null ||
509
+ (Array.isArray(value) &&
510
+ value.every(function (item) { return typeof item === "string"; }));
511
+ }
512
+
513
+ function validCodexEnvelope(value) {
514
+ if (!exactKeys(value, codexTopKeys) || value.name !== "browser" ||
515
+ typeof value.enabled !== "boolean" ||
516
+ !(value.disabled_reason === null ||
517
+ typeof value.disabled_reason === "string") ||
518
+ !stringArrayOrNull(value.enabled_tools) ||
519
+ !stringArrayOrNull(value.disabled_tools) ||
520
+ !(value.startup_timeout_sec === null ||
521
+ typeof value.startup_timeout_sec === "number") ||
522
+ !(value.tool_timeout_sec === null ||
523
+ typeof value.tool_timeout_sec === "number") ||
524
+ !plainObject(value.transport) ||
525
+ typeof value.transport.type !== "string") {
526
+ return false;
527
+ }
528
+ if (value.transport.type === "stdio") {
529
+ return exactKeys(
530
+ value.transport,
531
+ ["args", "command", "cwd", "env", "env_vars", "type"]
532
+ ) && typeof value.transport.command === "string" &&
533
+ Array.isArray(value.transport.args) &&
534
+ value.transport.args.every(function (item) {
535
+ return typeof item === "string";
536
+ }) &&
537
+ (value.transport.cwd === null ||
538
+ typeof value.transport.cwd === "string") &&
539
+ (value.transport.env === null || plainObject(value.transport.env)) &&
540
+ Array.isArray(value.transport.env_vars);
541
+ }
542
+ if (value.transport.type === "streamable_http" ||
543
+ value.transport.type === "sse") {
544
+ return exactKeys(
545
+ value.transport,
546
+ [
547
+ "bearer_token_env_var", "env_http_headers", "http_headers", "type",
548
+ "url"
549
+ ]
550
+ ) && typeof value.transport.url === "string";
551
+ }
552
+ return false;
553
+ }
554
+
555
+ /**
556
+ * Convert Codex's normalised JSON to the stdio shape Claude stores. Checking
557
+ * every default makes an added timeout, cwd, env-var mapping, tool filter, or
558
+ * disabled state foreign rather than accidentally uai-owned.
559
+ */
560
+ function codexDefinition(value) {
561
+ if (!validCodexEnvelope(value) ||
562
+ value.enabled !== true || value.disabled_reason !== null ||
563
+ value.enabled_tools !== null || value.disabled_tools !== null ||
564
+ value.startup_timeout_sec !== null || value.tool_timeout_sec !== null) {
565
+ return null;
566
+ }
567
+ const transport = value.transport;
568
+ const transportKeys = ["args", "command", "cwd", "env", "env_vars", "type"];
569
+ if (!exactKeys(transport, transportKeys) || transport.type !== "stdio" ||
570
+ typeof transport.command !== "string" ||
571
+ !Array.isArray(transport.args) ||
572
+ !transport.args.every(function (arg) { return typeof arg === "string"; }) ||
573
+ transport.cwd !== null || !Array.isArray(transport.env_vars) ||
574
+ transport.env_vars.length !== 0 ||
575
+ !(transport.env === null || plainObject(transport.env))) {
576
+ return null;
577
+ }
578
+ if (transport.env !== null &&
579
+ !Object.values(transport.env).every(function (item) {
580
+ return typeof item === "string";
581
+ })) {
582
+ return null;
583
+ }
584
+ const result = { command: transport.command, args: transport.args };
585
+ if (transport.env !== null) result.env = transport.env;
586
+ return result;
587
+ }
588
+
589
+ function runCodex(home, args) {
590
+ return child.spawnSync("codex", args, {
591
+ encoding: "utf8",
592
+ env: Object.assign({}, process.env, { CODEX_HOME: home }),
593
+ maxBuffer: 1024 * 1024,
594
+ timeout: 5000,
595
+ killSignal: "SIGKILL",
596
+ });
597
+ }
598
+
599
+ function getCodex(home) {
600
+ const result = runCodex(home, ["mcp", "get", "browser", "--json"]);
601
+ if (result.status === 0) {
602
+ try {
603
+ return { kind: "found", value: JSON.parse(result.stdout) };
604
+ } catch {
605
+ return { kind: "error", detail: "codex mcp get returned invalid JSON" };
606
+ }
607
+ }
608
+ const lastLine = String(result.stderr || "")
609
+ .split(/\r?\n/)
610
+ .map(function (line) { return line.trim(); })
611
+ .filter(Boolean)
612
+ .pop();
613
+ if (lastLine === "Error: No MCP server named 'browser' found.") {
614
+ return { kind: "missing" };
615
+ }
616
+ return {
617
+ kind: "error",
618
+ detail: "codex mcp get failed: " + String(lastLine || result.error || result.status),
619
+ };
620
+ }
621
+
622
+ function classifyCodex(state) {
623
+ if (state.kind !== "found") return state;
624
+ if (!validCodexEnvelope(state.value)) {
625
+ return { kind: "error", detail: "codex mcp get returned malformed browser JSON" };
626
+ }
627
+ const definition = codexDefinition(state.value);
628
+ if (definition && equal(definition, def)) {
629
+ return { kind: "current", definition: definition };
630
+ }
631
+ if (definition && isManaged(definition)) {
632
+ return { kind: "managed", definition: definition };
633
+ }
634
+ return {
635
+ kind: "foreign",
636
+ configured: true,
637
+ };
638
+ }
639
+
640
+ function tableHeaderPath(line) {
641
+ let index = 0;
642
+ const skipSpace = function () {
643
+ while (
644
+ line[index] === " " ||
645
+ line[index] === "\t" ||
646
+ line[index] === "\r"
647
+ ) index += 1;
648
+ };
649
+ skipSpace();
650
+ const array = line.startsWith("[[", index);
651
+ if (!array && line[index] !== "[") return null;
652
+ index += array ? 2 : 1;
653
+ const close = array ? "]]" : "]";
654
+ const segments = [];
655
+
656
+ while (index < line.length) {
657
+ skipSpace();
658
+ if (line.startsWith(close, index)) {
659
+ index += close.length;
660
+ skipSpace();
661
+ return index === line.length || line[index] === "#" ? segments : null;
662
+ }
663
+
664
+ let segment;
665
+ if (line[index] === '"') {
666
+ const start = index;
667
+ index += 1;
668
+ while (index < line.length) {
669
+ if (line[index] === "\\") {
670
+ index += 2;
671
+ continue;
672
+ }
673
+ if (line[index] === '"') {
674
+ index += 1;
675
+ break;
676
+ }
677
+ index += 1;
678
+ }
679
+ try {
680
+ const jsonString = line.slice(start, index).replace(
681
+ /\\U([0-9A-Fa-f]{8})/g,
682
+ function (_whole, hex) {
683
+ const point = Number.parseInt(hex, 16);
684
+ if (point <= 0xffff) {
685
+ return "\\u" + point.toString(16).padStart(4, "0");
686
+ }
687
+ const shifted = point - 0x10000;
688
+ const high = 0xd800 + (shifted >> 10);
689
+ const low = 0xdc00 + (shifted & 0x3ff);
690
+ return "\\u" + high.toString(16) + "\\u" + low.toString(16);
691
+ }
692
+ );
693
+ segment = JSON.parse(jsonString);
694
+ } catch {
695
+ return null;
696
+ }
697
+ } else if (line[index] === "'") {
698
+ const end = line.indexOf("'", index + 1);
699
+ if (end === -1) return null;
700
+ segment = line.slice(index + 1, end);
701
+ index = end + 1;
702
+ } else {
703
+ const bare = /^[A-Za-z0-9_-]+/.exec(line.slice(index));
704
+ if (!bare) return null;
705
+ segment = bare[0];
706
+ index += bare[0].length;
707
+ }
708
+ segments.push(segment);
709
+ skipSpace();
710
+ if (line[index] === ".") {
711
+ index += 1;
712
+ continue;
713
+ }
714
+ if (!line.startsWith(close, index)) return null;
715
+ }
716
+ return null;
717
+ }
718
+
719
+ function managedBlockEdit(text) {
720
+ const pattern = new RegExp(
721
+ "\\r?\\n# uai ADR-053: in-container browser \\(Playwright MCP\\)\\r?\\n" +
722
+ "\\[mcp_servers\\.browser\\]\\r?\\n" +
723
+ "command = [^\\r\\n]+\\r?\\n" +
724
+ "args = [^\\r\\n]+\\r?\\n" +
725
+ "(?:env = [^\\r\\n]+\\r?\\n)?",
726
+ "g"
727
+ );
728
+ // A descendant table can carry browser settings that Codex's normalised
729
+ // JSON omits. Reject the whole file if ANY browser table other than the
730
+ // single Uai-authored root exists, even when another table sits between the
731
+ // managed block and that descendant.
732
+ const browserHeaders = Array.from(
733
+ text.matchAll(/^[ \t]*\[\[?.+\]\]?[ \t]*(?:#.*)?\r?$/gm)
734
+ ).map(function (match) {
735
+ return tableHeaderPath(match[0]);
736
+ }).filter(function (segments) {
737
+ return segments &&
738
+ segments.length >= 2 &&
739
+ segments[0] === "mcp_servers" &&
740
+ segments[1] === "browser";
741
+ });
742
+ if (browserHeaders.length !== 1 || browserHeaders[0].length !== 2) {
743
+ return null;
744
+ }
745
+ const matches = Array.from(text.matchAll(pattern)).filter(function (match) {
746
+ const normalised = normalise(match[0].replace(/\r\n/g, "\n"));
747
+ if (!managedCodexBlocks.includes(normalised)) return false;
748
+ const after = (match.index || 0) + match[0].length;
749
+ const remainder = text.slice(after);
750
+ const nextHeader = remainder.search(
751
+ /^[ \t]*\[\[?.+\]\]?[ \t]*(?:#.*)?\r?$/m
752
+ );
753
+ const gap = nextHeader === -1 ? remainder : remainder.slice(0, nextHeader);
754
+ return gap.split(/\r?\n/).every(function (line) {
755
+ const trimmed = line.trim();
756
+ return trimmed === "" || trimmed.startsWith("#");
757
+ });
758
+ });
759
+ if (matches.length !== 1) return null;
760
+ const match = matches[0];
761
+ const start = match.index || 0;
762
+ const newline = match[0].includes("\r\n") ? "\r\n" : "\n";
763
+ const replacement = desiredCodexBlock.replace(/\n/g, newline);
764
+ return text.slice(0, start) + replacement + text.slice(start + match[0].length);
765
+ }
766
+
767
+ function verifyCodexWrite(home) {
768
+ const observed = classifyCodex(getCodex(home));
769
+ if (observed.kind === "current") {
770
+ return { configured: true, changed: true };
771
+ }
772
+ if (observed.kind === "foreign") {
773
+ notes.push(home + ": browser definition changed concurrently - left alone");
774
+ return { configured: observed.configured, changed: true };
775
+ }
776
+ if (observed.kind === "error") notes.push(home + ": " + observed.detail);
777
+ else notes.push(home + ": browser rewrite did not produce the pinned definition");
778
+ return { configured: false, changed: true };
779
+ }
780
+
781
+ function ensureCodexBrowser(home) {
782
+ const file = path.join(home, "config.toml");
783
+ try {
784
+ fs.mkdirSync(home, { recursive: true });
785
+ } catch (error) {
786
+ notes.push(home + ": could not create Codex home: " + String(error.message || error));
787
+ return { configured: false, changed: false };
788
+ }
789
+
790
+ // Read before asking Codex to parse. The compare-before-rename below then
791
+ // rejects any writer that lands after this snapshot.
792
+ const original = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null;
793
+ const state = classifyCodex(getCodex(home));
794
+ if (state.kind === "current") return { configured: true, changed: false };
795
+ if (state.kind === "foreign") {
796
+ notes.push(home + ": browser definition is not uai-managed - left alone");
797
+ return { configured: state.configured, changed: false };
798
+ }
799
+ if (state.kind === "error") {
800
+ notes.push(home + ": " + state.detail);
801
+ return { configured: false, changed: false };
802
+ }
803
+
804
+ let next;
805
+ if (state.kind === "missing") {
806
+ const base = original || "";
807
+ const newline = base.includes("\r\n") ? "\r\n" : "\n";
808
+ next = base + desiredCodexBlock.replace(/\n/g, newline);
809
+ } else {
810
+ next = original === null ? null : managedBlockEdit(original);
811
+ if (next === null) {
812
+ notes.push(home + ": managed semantics have a foreign text shape - left alone");
813
+ return { configured: true, changed: false };
814
+ }
815
+ }
816
+
817
+ if (!atomicWrite(file, next, original)) {
818
+ notes.push(home + ": config.toml changed concurrently - retrying later");
819
+ return { configured: false, changed: false };
820
+ }
821
+ return verifyCodexWrite(home);
822
+ }
823
+
824
+ function safeOutcome(label, ensure) {
825
+ try {
826
+ return ensure();
827
+ } catch (error) {
828
+ notes.push(label + ": setup failed: " + String(error.message || error));
829
+ return { configured: false, changed: false };
830
+ }
831
+ }
832
+
833
+ for (const outcome of [
834
+ safeOutcome("Claude browser config", ensureClaudeBrowser),
835
+ safeOutcome("Claude settings", ensureClaudeSettings),
836
+ ...codexHomes.map(function (home) {
837
+ return safeOutcome(home, function () { return ensureCodexBrowser(home); });
838
+ }),
839
+ ]) {
840
+ configured = configured && outcome.configured;
841
+ changed = changed || outcome.changed;
842
+ }
843
+
844
+ console.log(
845
+ ${JSON.stringify(CONFIG_RESULT_TOKEN)} +
846
+ JSON.stringify({ configured: configured, changed: changed, notes: notes })
847
+ );
848
+ `;
93
849
 
94
850
  /**
95
851
  * Start the watchable-browser stack (Xvfb → x11vnc → noVNC on :6080) if its
@@ -118,12 +874,59 @@ const VIEWER_STEPS: string[] = [
118
874
  `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
875
  ];
120
876
 
877
+ const APT_DEPS_CMD = "npx -y playwright@latest install-deps chromium";
878
+ const APT_VIEWER_CMD =
879
+ "apt-get install -y --no-install-recommends xvfb x11vnc novnc websockify";
880
+
881
+ function aptGuardPaths(): { done: string; lock: string } {
882
+ const digest = createHash("sha256")
883
+ .update(JSON.stringify([APT_DEPS_CMD, APT_VIEWER_CMD, VIEWER_STEPS]))
884
+ .digest("hex")
885
+ .slice(0, 12);
886
+ const base = `/tmp/.uai-browser-apt-${digest}`;
887
+ return { done: `${base}.done`, lock: `${base}.lock` };
888
+ }
889
+
890
+ /** What a setup pass established. */
891
+ export interface BrowserSetup {
892
+ /** The browser is installed and will launch. Retry while false. */
893
+ ready: boolean;
894
+ /** Every required engine has a usable browser MCP definition. */
895
+ configured: boolean;
896
+ /** A config had to be rewritten, so running agents hold a stale one. */
897
+ changed: boolean;
898
+ }
899
+
900
+ /**
901
+ * Wire the browser for a task.
902
+ *
903
+ * Reports readiness so the caller can retry a failed install instead of
904
+ * leaving live sessions pointed at a browser that will never launch, and
905
+ * reports whether a config was rewritten so they can be recycled onto it.
906
+ */
121
907
  export async function setupBrowserTesting(
122
908
  taskId: string,
123
909
  containerName: string,
124
910
  hasCodex: boolean,
125
- ): Promise<void> {
911
+ codexHomes: readonly string[] = hasCodex ? [DEFAULT_CODEX_HOME] : [],
912
+ ): Promise<BrowserSetup> {
913
+ let ready = false;
914
+ let configured = false;
915
+ let changed = false;
126
916
  try {
917
+ const selectedCodexHomes = hasCodex
918
+ ? [...new Set(codexHomes.length > 0 ? codexHomes : [DEFAULT_CODEX_HOME])]
919
+ : [];
920
+ if (
921
+ selectedCodexHomes.some(
922
+ (home) =>
923
+ home !== DEFAULT_CODEX_HOME &&
924
+ !/^\/home\/node\/\.codex-acct-[A-Za-z0-9_-]+$/.test(home),
925
+ )
926
+ ) {
927
+ throw new Error("invalid Codex account home");
928
+ }
929
+
127
930
  // The viewer stack restarts whenever it died (container restart, crash) —
128
931
  // outside the marker guard on purpose. No-op until its packages install.
129
932
  // ONE exec per daemon: the single-line nohup shape is the only one that
@@ -134,65 +937,165 @@ export async function setupBrowserTesting(
134
937
  });
135
938
  }
136
939
 
137
- const marked = await dockerCli(
138
- ["exec", containerName, "test", "-f", MARKER],
940
+ // Browser readiness AWAITED, and deliberately OUTSIDE the marker guard.
941
+ //
942
+ // Awaited because the caller runs this before spawning agents, so this is
943
+ // the one place a slow install is merely slow. Detaching it (as this used
944
+ // to) hands the download to whoever hits the browser first, and the only
945
+ // candidate is the MCP server's own startup — which has a hard client
946
+ // deadline of 10s (Codex) / 30s (Claude Code). A cold host would lose its
947
+ // first session every time.
948
+ //
949
+ // Outside the guard because it is the only thing here that can be undone
950
+ // from outside: the volume is host-wide, so another container's Playwright
951
+ // can evict our build mid-task. Re-running it is how that heals — and it
952
+ // costs ~0.5s once the build is present.
953
+ await dockerCli(
954
+ ["exec", "-u", "root", containerName, "chown", "node:node", "/opt/pw-browsers"],
139
955
  { timeoutMs: 5_000 },
140
956
  );
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 },
957
+ const installed = await dockerCli(
958
+ ["exec", "-w", "/workspace", containerName, "sh", "-lc", PREWARM_CMD],
959
+ { timeoutMs: INSTALL_TIMEOUT_MS },
163
960
  );
164
- if (wrote.status !== 0) {
961
+ ready = installed.status === 0;
962
+ if (!ready) {
963
+ // Agents still spawn — a task without a browser beats no task — but the
964
+ // caller retries on this, and it is logged loudly, because the symptom
965
+ // downstream (a browser that won't launch) reads as anything but an
966
+ // install that quietly failed up here.
165
967
  console.warn(
166
- `[browser] task ${taskId}: MCP config write failed: ${wrote.stderr.slice(0, 300)}`,
968
+ `[browser] task ${taskId}: browser install failed (status ${installed.status}); ` +
969
+ `see ${INSTALL_LOG} in the container. ${installed.stderr.slice(0, 200)}`,
167
970
  );
168
- return;
169
971
  }
170
972
 
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(
973
+ // Configs are re-asserted on EVERY call no marker gate.
974
+ //
975
+ // A marker records what we once wrote, which is a different claim from
976
+ // what is on disk now. Recovery/start credential injection and account
977
+ // provisioning can replace config.toml before this serialized boundary
978
+ // (live auth refresh now excludes it). The marker lives in /workspace and
979
+ // survives those copies, so a gated setup could skip the rewrite and
980
+ // report ready while Codex had no browser at all. The migration is cheap
981
+ // and byte-idempotent, so the honest fix is to re-assert.
982
+ const wrote = await dockerCli(
178
983
  [
179
984
  "exec",
180
- "-d",
985
+ // asdf resolves node only where a .tool-versions applies.
181
986
  "-w",
182
987
  "/workspace",
988
+ "-e",
989
+ `UAI_BROWSER_DEF=${JSON.stringify(SERVER_DEF)}`,
990
+ "-e",
991
+ `UAI_MCP_PATH=${MCP_CONFIG_PATH}`,
992
+ "-e",
993
+ `UAI_CLAUDE_SETTINGS_PATH=${CLAUDE_SETTINGS_PATH}`,
994
+ "-e",
995
+ `UAI_CLAUDE_SETTINGS_DEF=${CLAUDE_SETTINGS_JSON}`,
996
+ ...(selectedCodexHomes.length > 0
997
+ ? ["-e", `UAI_CODEX_HOMES=${JSON.stringify(selectedCodexHomes)}`]
998
+ : []),
183
999
  containerName,
184
- "sh",
185
- "-lc",
186
- "npx -y playwright@latest install chromium >/tmp/uai-pw-browser.log 2>&1 || true",
1000
+ "flock",
1001
+ "-w",
1002
+ "10",
1003
+ MCP_CONFIG_LOCK_PATH,
1004
+ "timeout",
1005
+ "--kill-after=5",
1006
+ String(
1007
+ Math.max(45, 15 + selectedCodexHomes.length * 12),
1008
+ ),
1009
+ "node",
1010
+ "-e",
1011
+ MIGRATE_JS,
187
1012
  ],
188
- { timeoutMs: 10_000 },
1013
+ {
1014
+ // Lock wait + the per-home-scaled in-container bound + SIGKILL grace,
1015
+ // with ten seconds left for Docker/process overhead. The host must
1016
+ // never win this race: killing docker exec does not kill its child.
1017
+ timeoutMs:
1018
+ (10 +
1019
+ Math.max(45, 15 + selectedCodexHomes.length * 12) +
1020
+ 5 +
1021
+ 10) *
1022
+ 1_000,
1023
+ },
189
1024
  );
190
- // Chromium apt libs + the viewer stack packages, then start the stack —
191
- // one ordered root chain, backgrounded, per-container.
1025
+ if (wrote.status !== 0) {
1026
+ console.warn(
1027
+ `[browser] task ${taskId}: MCP config write failed: ${wrote.stderr.slice(0, 300)}`,
1028
+ );
1029
+ } else {
1030
+ const resultLine = wrote.stdout
1031
+ .split(/\r?\n/)
1032
+ .find((line) => line.startsWith(CONFIG_RESULT_TOKEN));
1033
+ if (!resultLine) {
1034
+ console.warn(
1035
+ `[browser] task ${taskId}: MCP config write returned no result`,
1036
+ );
1037
+ } else {
1038
+ try {
1039
+ const result = JSON.parse(
1040
+ resultLine.slice(CONFIG_RESULT_TOKEN.length),
1041
+ ) as {
1042
+ configured: boolean;
1043
+ changed: boolean;
1044
+ notes?: string[];
1045
+ };
1046
+ configured = result.configured === true;
1047
+ changed = result.changed === true;
1048
+ if (result.notes?.length) {
1049
+ console.warn(`[browser] task ${taskId}: ${result.notes.join("; ")}`);
1050
+ }
1051
+ } catch (err) {
1052
+ console.warn(
1053
+ `[browser] task ${taskId}: invalid MCP config result`,
1054
+ err,
1055
+ );
1056
+ }
1057
+ }
1058
+ }
1059
+
1060
+ // Chromium apt libs + the viewer stack packages, then start the stack.
1061
+ // The lock is a container-local flock acquired BEFORE detach. The worker
1062
+ // inherits its open file description, so failures and even SIGKILL release
1063
+ // it in the kernel and the next ensure can retry; no stale mkdir lock can
1064
+ // strand the container forever.
1065
+ //
1066
+ // `install-deps` stays on floating `playwright@latest` on purpose: it
1067
+ // installs apt SYSTEM LIBRARIES (libnss3, libatk, …), which are shared
1068
+ // across Chromium builds and carry no build number, so they cannot
1069
+ // participate in the mismatch MCP_PKG exists to prevent. It also touches
1070
+ // no browser binaries, so it cannot GC the shared volume. Pinning it
1071
+ // would mean a second version constant to keep in sync — reintroducing
1072
+ // exactly the drift class this change removes.
1073
+ const aptGuard = aptGuardPaths();
1074
+ const viewerSetup = VIEWER_STEPS.map((step) => `( ${step} )`).join("; ");
1075
+ const aptWorker = [
1076
+ "set -e",
1077
+ APT_DEPS_CMD,
1078
+ APT_VIEWER_CMD,
1079
+ // Close lock fd 9 before starting long-lived viewer grandchildren, or
1080
+ // they inherit it and keep the apt lock forever after this worker exits.
1081
+ `su -s /bin/sh node -c ${shellQuote(viewerSetup)} 9>&-`,
1082
+ "chown -R node:node /home/node/.npm 2>/dev/null || true",
1083
+ `touch ${aptGuard.done}`,
1084
+ ].join("; ");
1085
+ const guardedAptStart = [
1086
+ `[ -f ${aptGuard.done} ] && exit 0`,
1087
+ `exec 9>${aptGuard.lock}`,
1088
+ "flock -n 9 || exit 0",
1089
+ // A different worker may have completed between the first check and
1090
+ // this lock acquisition.
1091
+ `[ -f ${aptGuard.done} ] && exit 0`,
1092
+ `nohup sh -lc ${shellQuote(aptWorker)} 9>&9 </dev/null ` +
1093
+ ">/tmp/uai-pw-deps.log 2>&1 &",
1094
+ ].join("; ");
1095
+
192
1096
  await dockerCli(
193
1097
  [
194
1098
  "exec",
195
- "-d",
196
1099
  "-u",
197
1100
  "root",
198
1101
  // HOME=/home/node is for asdf version resolution ONLY — without the
@@ -208,24 +1111,18 @@ export async function setupBrowserTesting(
208
1111
  containerName,
209
1112
  "sh",
210
1113
  "-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",
1114
+ guardedAptStart,
222
1115
  ],
223
1116
  { timeoutMs: 10_000 },
224
1117
  );
225
- console.log(`[browser] task ${taskId}: Playwright MCP wired (installs backgrounded)`);
1118
+ console.log(
1119
+ `[browser] task ${taskId}: Playwright MCP wired (browser ${ready ? "ready" : "NOT ready"}; apt deps backgrounded)`,
1120
+ );
1121
+ return { ready, configured, changed };
226
1122
  } catch (err) {
227
1123
  // Best-effort by design — a task without a browser still runs.
228
1124
  console.warn(`[browser] task ${taskId}: setup failed`, err);
1125
+ return { ready, configured, changed };
229
1126
  }
230
1127
  }
231
1128