@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.
- package/lib/browser-testing.ts +1056 -90
- package/lib/codex-auth.ts +22 -15
- package/lib/engine-accounts.ts +23 -4
- package/lib/mcp-config-lock.ts +2 -0
- package/lib/mcp-gateway.ts +393 -21
- package/lib/orchestrator.ts +1351 -217
- package/lib/preview-sidecar.ts +123 -3
- package/package.json +1 -1
- package/scripts/agent/task-down.sh +16 -0
- package/src/index.ts +215 -97
- package/src/main.ts +19 -10
package/lib/browser-testing.ts
CHANGED
|
@@ -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
|
|
6
|
-
*
|
|
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
|
-
* -
|
|
14
|
-
*
|
|
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,
|
|
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.
|
|
26
|
-
*
|
|
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 {
|
|
44
|
+
import { createHash } from "node:crypto";
|
|
33
45
|
|
|
34
|
-
|
|
35
|
-
|
|
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
|
+
/**
|
|
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
|
|
217
|
+
`exec npx -y ${MCP_PKG} --config ${MCP_LAUNCH_CONFIG} ` +
|
|
218
|
+
`--browser ${BROWSER} --no-sandbox; ` +
|
|
62
219
|
`else ` +
|
|
63
|
-
`exec npx -y
|
|
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
|
-
|
|
225
|
+
/** Exported for the test that locks the pin/browser invariants. */
|
|
226
|
+
export const SERVER_ARGS: [string, string] = ["-lc", SERVER_LAUNCHER];
|
|
68
227
|
|
|
69
|
-
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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}:
|
|
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
|
-
//
|
|
172
|
-
//
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
)
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
185
|
-
"-
|
|
186
|
-
"
|
|
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
|
-
{
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|