@runuai/host 0.8.42 → 0.8.44

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