@moondeck/station 0.0.1 → 1.0.1

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.
Files changed (330) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -2
  3. package/bin/uai-host.mjs +130 -0
  4. package/db/migrations/0000_host_tasks.sql +12 -0
  5. package/db/migrations/0001_host_ui.sql +11 -0
  6. package/db/migrations/0002_host_github_tokens.sql +8 -0
  7. package/db/migrations/0003_host_ssh_keys.sql +8 -0
  8. package/db/migrations/0004_host_owner_name.sql +1 -0
  9. package/db/migrations/0005_host_env_vars.sql +5 -0
  10. package/db/migrations/0006_host_project_env.sql +9 -0
  11. package/db/migrations/0007_host_github_token_kind.sql +7 -0
  12. package/db/migrations/0008_host_mcp_connections.sql +18 -0
  13. package/db/migrations/0009_host_agent_sessions.sql +15 -0
  14. package/db/migrations/0010_host_engine_accounts.sql +13 -0
  15. package/db/migrations/0011_codex_thread_id.sql +5 -0
  16. package/db/migrations/0012_agent_session_attach_key.sql +4 -0
  17. package/db/migrations/0013_github_credential_generations.sql +11 -0
  18. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  19. package/db/migrations/0015_host_settings.sql +9 -0
  20. package/db/migrations/0016_task_environment.sql +2 -0
  21. package/db/migrations/0017_engine_account_scope.sql +7 -0
  22. package/db/migrations/0018_engine_account_position.sql +3 -0
  23. package/db/migrations/0019_engine_account_store_ref.sql +3 -0
  24. package/db/migrations/0020_user_credential_store_ref.sql +6 -0
  25. package/db/migrations/0021_project_env_store_ref.sql +3 -0
  26. package/db/migrations/0022_task_project_ids.sql +5 -0
  27. package/db/migrations/0023_project_env_org.sql +4 -0
  28. package/db/migrations/0024_task_env_mode.sql +9 -0
  29. package/db/migrations/0025_project_env_authority.sql +147 -0
  30. package/db/migrations/0026_project_env_replica.sql +150 -0
  31. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  32. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  33. package/db/migrations/0029_task_shared_files_mode.sql +5 -0
  34. package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
  35. package/db/migrations/0031_first_turn_receipts.sql +9 -0
  36. package/db/migrations/meta/_journal.json +230 -0
  37. package/db/schema.ts +871 -0
  38. package/images/standard/Dockerfile +347 -0
  39. package/images/standard/README.md +167 -0
  40. package/images/standard/container/code-server-settings.json +63 -0
  41. package/images/standard/container/corepack-version +1 -0
  42. package/images/standard/container/uai-init +704 -0
  43. package/images/standard/container/uai-materialize-runtimes +1527 -0
  44. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  45. package/images/standard/tool-versions +2 -0
  46. package/lib/__fixtures__/skill-index/SKILL.md +10 -0
  47. package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
  48. package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
  49. package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
  50. package/lib/agent-cli.ts +1507 -0
  51. package/lib/agent-errors.ts +22 -0
  52. package/lib/agent.ts +672 -0
  53. package/lib/agents/attach-compatibility.ts +51 -0
  54. package/lib/agents/claude.ts +918 -0
  55. package/lib/agents/codex-token-usage.fixture.json +130 -0
  56. package/lib/agents/codex.ts +1392 -0
  57. package/lib/agents/cursor-period-usage.ts +253 -0
  58. package/lib/agents/cursor.ts +562 -0
  59. package/lib/agents/dispatch.ts +78 -0
  60. package/lib/agents/durable-proc.ts +334 -0
  61. package/lib/agents/factory.ts +57 -0
  62. package/lib/agents/grok.ts +381 -0
  63. package/lib/agents/kimi.ts +329 -0
  64. package/lib/agents/machine-durable.ts +604 -0
  65. package/lib/agents/mock.ts +133 -0
  66. package/lib/agents/mode.ts +64 -0
  67. package/lib/agents/opencode.ts +508 -0
  68. package/lib/agents/output-source.ts +3 -0
  69. package/lib/agents/proc.ts +221 -0
  70. package/lib/agents/rate-limit.ts +337 -0
  71. package/lib/agents/registry.ts +182 -0
  72. package/lib/agents/session-state.ts +197 -0
  73. package/lib/agents/skill-index-preamble.fixture.ts +26 -0
  74. package/lib/agents/transport.ts +741 -0
  75. package/lib/agents/types.ts +254 -0
  76. package/lib/agents/uai.ts +106 -0
  77. package/lib/agents/usage.ts +44 -0
  78. package/lib/apple-one-off.ts +277 -0
  79. package/lib/apple-runtime-recycle.ts +237 -0
  80. package/lib/apple-uninstall-teardown.ts +224 -0
  81. package/lib/assignment-evidence.ts +94 -0
  82. package/lib/attachments.ts +48 -0
  83. package/lib/browser-testing.ts +1537 -0
  84. package/lib/capture-flow.ts +332 -0
  85. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  86. package/lib/cloud-credential-host.ts +512 -0
  87. package/lib/cloud-credential-leases.ts +2587 -0
  88. package/lib/cloud-credential-runtime.ts +1357 -0
  89. package/lib/cloud-state.ts +56 -0
  90. package/lib/codex-auth.ts +456 -0
  91. package/lib/command-db.ts +310 -0
  92. package/lib/compose-network-recovery.ts +283 -0
  93. package/lib/connection-secret-redaction.ts +25 -0
  94. package/lib/container-runtime.ts +1470 -0
  95. package/lib/credential-cleanup-inventory.ts +490 -0
  96. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  97. package/lib/credential-consumers/engines.ts +797 -0
  98. package/lib/credential-consumers/files.ts +934 -0
  99. package/lib/credential-consumers/github.ts +702 -0
  100. package/lib/credential-consumers/mcp.ts +389 -0
  101. package/lib/credential-consumers/task-files.ts +127 -0
  102. package/lib/credential-consumers/task-secrets.ts +10 -0
  103. package/lib/credential-store-pickup.ts +149 -0
  104. package/lib/credential-store-scope.ts +112 -0
  105. package/lib/credential-store.ts +932 -0
  106. package/lib/credentials-activation-authority.ts +709 -0
  107. package/lib/credentials-purge.ts +1006 -0
  108. package/lib/custom-connection-capture.ts +395 -0
  109. package/lib/custom-connection-launch.ts +384 -0
  110. package/lib/custom-connection-runtime.ts +141 -0
  111. package/lib/db.ts +80 -0
  112. package/lib/disk-pressure.ts +42 -0
  113. package/lib/docker-exec.ts +156 -0
  114. package/lib/engine-accounts.ts +1970 -0
  115. package/lib/engine-connect.ts +321 -0
  116. package/lib/engine-credential-env.ts +52 -0
  117. package/lib/engine-login.ts +2933 -0
  118. package/lib/engines.ts +1342 -0
  119. package/lib/enrollment-state.ts +251 -0
  120. package/lib/env-file.ts +246 -0
  121. package/lib/env-schema.ts +937 -0
  122. package/lib/env.ts +195 -0
  123. package/lib/first-turn-receipts.ts +62 -0
  124. package/lib/fs-aliases.ts +49 -0
  125. package/lib/git-clone-credentials.ts +334 -0
  126. package/lib/git-diff.ts +530 -0
  127. package/lib/git-identity.ts +715 -0
  128. package/lib/github-git-auth.ts +207 -0
  129. package/lib/github-origin.ts +28 -0
  130. package/lib/github-tokens.ts +2439 -0
  131. package/lib/host-cloud-url.ts +62 -0
  132. package/lib/host-config.ts +279 -0
  133. package/lib/host-env.ts +424 -0
  134. package/lib/host-logs.ts +965 -0
  135. package/lib/internal-connections.ts +5 -0
  136. package/lib/keyed-promise-tail.ts +23 -0
  137. package/lib/launch-mode.ts +25 -0
  138. package/lib/lease.ts +239 -0
  139. package/lib/legacy-host.ts +769 -0
  140. package/lib/legacy-runtime-retirement.ts +424 -0
  141. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  142. package/lib/machine-exec.ts +238 -0
  143. package/lib/machine-gateway-tunnel.ts +344 -0
  144. package/lib/machine-keys.ts +65 -0
  145. package/lib/machine-project-env-loader.ts +152 -0
  146. package/lib/machine-provider-aws.ts +923 -0
  147. package/lib/machine-provider-local.ts +338 -0
  148. package/lib/machine-provider.ts +90 -0
  149. package/lib/managed-activation-watcher.ts +72 -0
  150. package/lib/managed-install-owner-watcher.ts +55 -0
  151. package/lib/managed-operation-drain.ts +57 -0
  152. package/lib/managed-runtime.ts +4296 -0
  153. package/lib/managed-update-scheduler.ts +125 -0
  154. package/lib/mcp-capture.ts +865 -0
  155. package/lib/mcp-config-lock.ts +2 -0
  156. package/lib/mcp-connections.ts +959 -0
  157. package/lib/mcp-gateway.ts +2594 -0
  158. package/lib/obs.ts +512 -0
  159. package/lib/openrouter-budget.ts +247 -0
  160. package/lib/orchestrator.ts +11409 -0
  161. package/lib/person-capture.ts +327 -0
  162. package/lib/preview-ports.ts +85 -0
  163. package/lib/preview-sidecar.ts +331 -0
  164. package/lib/project-env-authority.ts +1319 -0
  165. package/lib/project-env-feature.ts +74 -0
  166. package/lib/project-env-legacy-tags.ts +130 -0
  167. package/lib/project-env-loader.ts +242 -0
  168. package/lib/project-env-replica.ts +2094 -0
  169. package/lib/project-env-schema-capture.ts +342 -0
  170. package/lib/project-env-staging.ts +148 -0
  171. package/lib/project-env-task-policy.ts +176 -0
  172. package/lib/project-env-task-replica.ts +1752 -0
  173. package/lib/release-feed.ts +61 -0
  174. package/lib/release-manifest.ts +710 -0
  175. package/lib/release-trust.ts +46 -0
  176. package/lib/repo-clone.ts +38 -0
  177. package/lib/routing/shadow.ts +261 -0
  178. package/lib/runner-boot.ts +10 -0
  179. package/lib/runtime-activation-tail.ts +279 -0
  180. package/lib/runtime-archive.ts +1216 -0
  181. package/lib/runtime-authority.ts +166 -0
  182. package/lib/runtime-guard.ts +36 -0
  183. package/lib/runtime-provider-state.ts +169 -0
  184. package/lib/runtime-reconnect.ts +86 -0
  185. package/lib/runtime-state.ts +1033 -0
  186. package/lib/safe-http.ts +364 -0
  187. package/lib/secrets-proxy/address.ts +95 -0
  188. package/lib/secrets-proxy/audit.ts +30 -0
  189. package/lib/secrets-proxy/ca-store.ts +200 -0
  190. package/lib/secrets-proxy/ca.ts +470 -0
  191. package/lib/secrets-proxy/container-steps.ts +138 -0
  192. package/lib/secrets-proxy/der.ts +273 -0
  193. package/lib/secrets-proxy/engine.ts +1633 -0
  194. package/lib/secrets-proxy/host.ts +93 -0
  195. package/lib/secrets-proxy/hostname.ts +57 -0
  196. package/lib/secrets-proxy/placeholders.ts +196 -0
  197. package/lib/secrets-proxy/policy.ts +150 -0
  198. package/lib/secrets-proxy/readiness.ts +148 -0
  199. package/lib/secrets-proxy/runtime.ts +582 -0
  200. package/lib/secrets-proxy/scrub.ts +559 -0
  201. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  202. package/lib/secrets-proxy/task-scripts.ts +262 -0
  203. package/lib/secrets-proxy/task-trust.ts +605 -0
  204. package/lib/secrets-proxy/trust-watch.ts +80 -0
  205. package/lib/secrets.ts +71 -0
  206. package/lib/shared-files-s3.ts +976 -0
  207. package/lib/shared-files.ts +156 -0
  208. package/lib/sidecar-assign.ts +49 -0
  209. package/lib/sidecar-guard.ts +58 -0
  210. package/lib/sidecar-resume.ts +178 -0
  211. package/lib/sidecar.ts +69 -0
  212. package/lib/signin-box-credentials.ts +465 -0
  213. package/lib/signin-box.ts +114 -0
  214. package/lib/skill-index-example.fixture.ts +56 -0
  215. package/lib/skill-layout.ts +417 -0
  216. package/lib/skills.ts +489 -0
  217. package/lib/ssh.ts +401 -0
  218. package/lib/standard-image-cleanup.ts +117 -0
  219. package/lib/standard-image-pin.ts +207 -0
  220. package/lib/standard-image.ts +1697 -0
  221. package/lib/stopped-task-status-queue.ts +120 -0
  222. package/lib/task-container-cli.ts +269 -0
  223. package/lib/task-credential-cleanup.ts +752 -0
  224. package/lib/task-diff-sandbox.ts +464 -0
  225. package/lib/task-diff.ts +91 -0
  226. package/lib/task-env-forwarder.ts +228 -0
  227. package/lib/task-env-source.ts +249 -0
  228. package/lib/task-environment/anchor.ts +31 -0
  229. package/lib/task-environment/apple-container.ts +860 -0
  230. package/lib/task-environment/docker.ts +1344 -0
  231. package/lib/task-environment/index.ts +466 -0
  232. package/lib/task-environment/legacy-adoption.ts +467 -0
  233. package/lib/task-environment/machine-environment.ts +61 -0
  234. package/lib/task-environment/machine-task-up.ts +1227 -0
  235. package/lib/task-environment/machine.ts +974 -0
  236. package/lib/task-environment/registry.ts +59 -0
  237. package/lib/task-environment/self-identity.ts +20 -0
  238. package/lib/task-environment/self.ts +566 -0
  239. package/lib/task-environment/types.ts +642 -0
  240. package/lib/task-environment/workspace-files.ts +685 -0
  241. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  242. package/lib/task-files-machine-retirement-outbox.ts +592 -0
  243. package/lib/task-files-machine-retirement.ts +116 -0
  244. package/lib/task-files-readiness.ts +127 -0
  245. package/lib/task-gateway-port.ts +22 -0
  246. package/lib/task-gateway-protocol.ts +190 -0
  247. package/lib/task-gateway-recovery.ts +47 -0
  248. package/lib/task-gateway-state.ts +512 -0
  249. package/lib/task-identity.ts +19 -0
  250. package/lib/task-inventory.ts +682 -0
  251. package/lib/task-machine-credentials-activation.ts +95 -0
  252. package/lib/task-machine-swap-safety.ts +729 -0
  253. package/lib/task-person-credentials.ts +76 -0
  254. package/lib/task-project-ops-host.ts +147 -0
  255. package/lib/task-project-ops.ts +1062 -0
  256. package/lib/task-secret-staging.ts +16 -0
  257. package/lib/task-status.ts +46 -0
  258. package/lib/task-token.ts +46 -0
  259. package/lib/token-broker-adopt.ts +344 -0
  260. package/lib/token-broker-lambda.ts +105 -0
  261. package/lib/token-broker-local.ts +392 -0
  262. package/lib/token-broker-revision.ts +310 -0
  263. package/lib/token-broker-ssm.ts +525 -0
  264. package/lib/token-broker.ts +218 -0
  265. package/lib/token-lanes/openai-oauth.ts +123 -0
  266. package/lib/token-lanes/registry.ts +45 -0
  267. package/lib/token-lanes/types.ts +163 -0
  268. package/lib/token-lanes/xai-oauth.ts +165 -0
  269. package/lib/transcript.ts +128 -0
  270. package/lib/tunnel-registry.ts +220 -0
  271. package/lib/tunnel-runtime.ts +235 -0
  272. package/lib/ulid.ts +16 -0
  273. package/lib/user-bus-env.ts +116 -0
  274. package/lib/user-credentials-store.ts +435 -0
  275. package/lib/vnode-pressure.ts +142 -0
  276. package/package.json +115 -8
  277. package/runner/runner.mjs +442 -0
  278. package/scripts/agent/_common.sh +633 -0
  279. package/scripts/agent/env-hook.sh +626 -0
  280. package/scripts/agent/moondeck-cli.sh +5 -0
  281. package/scripts/agent/task-down.sh +291 -0
  282. package/scripts/agent/task-status.sh +70 -0
  283. package/scripts/agent/task-up.sh +3761 -0
  284. package/scripts/agent/uai-env-exec.sh +26 -0
  285. package/scripts/install/darwin.ts +1048 -0
  286. package/scripts/install/linux.ts +958 -0
  287. package/scripts/install/types.ts +78 -0
  288. package/scripts/install/util.ts +254 -0
  289. package/scripts/install/win.ts +143 -0
  290. package/src/activation-respawn.ts +155 -0
  291. package/src/apple-tunnel-route.ts +104 -0
  292. package/src/bridge-diagnostics.ts +39 -0
  293. package/src/capture-wire.ts +102 -0
  294. package/src/cli.ts +2585 -0
  295. package/src/cloud-credentials.ts +2144 -0
  296. package/src/cloud-origins.ts +23 -0
  297. package/src/credential-location.ts +248 -0
  298. package/src/credential-wire-validation.ts +25 -0
  299. package/src/custom-connections.ts +237 -0
  300. package/src/engine-account-paths.ts +11 -0
  301. package/src/engine-models.ts +39 -0
  302. package/src/engine-usage.ts +104 -0
  303. package/src/enrollment-next-steps.ts +24 -0
  304. package/src/event-outbox.ts +185 -0
  305. package/src/index.ts +2907 -0
  306. package/src/load-env.ts +97 -0
  307. package/src/machine-tunnel-route.ts +156 -0
  308. package/src/main.ts +6363 -0
  309. package/src/never-proxied-domains.ts +31 -0
  310. package/src/outbound-frame.ts +31 -0
  311. package/src/paths.ts +81 -0
  312. package/src/project-env-latest.ts +130 -0
  313. package/src/project-env-policy.ts +396 -0
  314. package/src/project-env-wording.ts +53 -0
  315. package/src/protocol.ts +5550 -0
  316. package/src/runtime-bootstrap.ts +165 -0
  317. package/src/station-identity.ts +96 -0
  318. package/src/task-env-names.ts +28 -0
  319. package/src/task-secrets.ts +75 -0
  320. package/src/tunnel-dial.ts +102 -0
  321. package/src/ui/server.ts +852 -0
  322. package/src/ui/types.ts +173 -0
  323. package/src/workspace-path-policy.ts +41 -0
  324. package/ui/app.js +1102 -0
  325. package/ui/index.html +90 -0
  326. package/ui/style.css +685 -0
  327. package/ui/uai-favicon.svg +21 -0
  328. package/ui/uai-logo-black.svg +9 -0
  329. package/ui/uai-wheel.svg +9 -0
  330. package/bin/moondeck-station.mjs +0 -3
@@ -0,0 +1,1537 @@
1
+ /**
2
+ * ADR-053: in-container browser for agents — Playwright MCP wiring.
3
+ *
4
+ * When a task's project opted into browser testing, session start calls
5
+ * `setupBrowserTesting`, which installs Chromium and points both engines at
6
+ * it. Idempotent, and safe to call repeatedly:
7
+ *
8
+ * - `/workspace/.mcp.json` — Claude Code's project-scoped MCP config,
9
+ * declaring the `browser` server (headFUL on DISPLAY :99).
10
+ * - `/workspace/.claude/settings.json` — `enableAllProjectMcpServers` so
11
+ * headless sessions load it without an approval prompt.
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.
24
+ *
25
+ * Phase 2 (the WATCHABLE browser): Chromium runs headful under Xvfb (:99),
26
+ * mirrored by x11vnc → websockify/noVNC on container port 6080 — which the
27
+ * cloud surfaces as the synthetic "browser" preview, so the human can watch
28
+ * the agents click around from the preview menu. The viewer stack is
29
+ * (re)started on every setup call, lockfile/pidfile-guarded, so it survives
30
+ * container restarts.
31
+ *
32
+ * Browser binaries land on the host-wide `uai-playwright` volume
33
+ * (PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers, mounted by the generated
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.
39
+ *
40
+ * Hard-won asdf rules (2026-07-07): npx resolves ONLY where a .tool-versions
41
+ * applies → always `-w /workspace`; and root has no ~/.tool-versions →
42
+ * root execs also need `-e HOME=/home/node`.
43
+ */
44
+ import { createHash } from "node:crypto";
45
+
46
+ import {
47
+ taskAppContainerName,
48
+ taskContainerBackend,
49
+ taskContainerCli,
50
+ } from "./task-container-cli";
51
+ import { MCP_CONFIG_LOCK_PATH } from "./mcp-config-lock";
52
+ import {
53
+ MACHINE_RUNTIME_AUTHORITY_ENV,
54
+ RUNTIME_AUTHORITY_ENV,
55
+ runtimeAuthorityDockerExecArgs,
56
+ } from "./runtime-authority";
57
+ import { STANDARD_DEFAULT_NODE_VERSION } from "./standard-image";
58
+ import type { TaskEnvironmentHandle } from "./task-environment/types";
59
+ import { isEnvironmentTransportProvider } from "./task-environment/types";
60
+
61
+ const SERVER_DISPLAY = ":99";
62
+ /**
63
+ * The virtual screen, and the ONLY place its size is written.
64
+ *
65
+ * Chromium's window has to be told the same numbers (see CHROMIUM_ARGS). There
66
+ * is no window manager on this display — nothing to maximise a window or
67
+ * service a resize — so the window is whatever size it was created at, forever.
68
+ * Two independent copies of "1440x900" would drift, and the failure is quiet:
69
+ * the page renders at the size you asked for while the window shows part of it.
70
+ */
71
+ const SCREEN_W = 1440;
72
+ const SCREEN_H = 900;
73
+ const XVFB_CMD = `Xvfb ${SERVER_DISPLAY} -screen 0 ${SCREEN_W}x${SCREEN_H}x24 -nolisten tcp`;
74
+
75
+ /**
76
+ * The MCP server package — PINNED, deliberately, and the single source of
77
+ * truth for the whole browser stack.
78
+ *
79
+ * Every Playwright release wants one exact Chromium *build number*, and
80
+ * treats any other build as "not installed". So the pre-warm and the runtime
81
+ * must resolve the same `playwright-core`, or the browser never launches.
82
+ * `@playwright/mcp@latest` cannot promise that: it floats, and its pinned
83
+ * `playwright-core` floats with it.
84
+ *
85
+ * That is not hypothetical — it was the live bug (task 01kye1b8..., Jul 2026,
86
+ * fixed here). Provisioning ran `playwright@latest install chromium` (build
87
+ * 1234) while the MCP resolved to 0.0.78 → build 1232, so EVERY container
88
+ * started with a dead browser and a black VNC preview, forever: a version
89
+ * mismatch never heals on retry. A follow-up isolated diagnostic also proved
90
+ * that `playwright install` garbage-collects builds it does not recognise and
91
+ * can delete another revision from the shared host volume. That eviction was
92
+ * deliberately induced while diagnosing the incident, not observed as part
93
+ * of the original outage, but it makes an unflagged pre-warm unsafe.
94
+ *
95
+ * The fix is structural: browsers are installed THROUGH this exact package
96
+ * (below), so provisioning and runtime cannot disagree by construction. To
97
+ * upgrade, change this one string — the pre-warm follows automatically.
98
+ */
99
+ export const MCP_PKG = "@playwright/mcp@0.0.78";
100
+
101
+ /**
102
+ * The browser to launch AND to pre-warm. One constant feeding both
103
+ * `--browser` and `install-browser`, so the two can never name different
104
+ * browsers either. (In current playwright-core `chromium` is an alias for the
105
+ * `chrome-for-testing` channel — hence the error text agents used to hit.)
106
+ */
107
+ export const BROWSER = "chromium";
108
+
109
+ /**
110
+ * Install the exact Chromium build `MCP_PKG`'s bundled playwright-core wants.
111
+ *
112
+ * `install-browser` is the MCP's own passthrough to that playwright-core, so
113
+ * the build is right by construction, and it is idempotent and fast (~0.5s)
114
+ * once present.
115
+ *
116
+ * `PLAYWRIGHT_SKIP_BROWSER_GC=1` is load-bearing, not decoration.
117
+ * `install-browser` is NOT inherently additive — `@playwright/mcp`'s `cli.js`
118
+ * literally rewrites the word to `install`, and Playwright's registry install
119
+ * then garbage-collects: it keeps a `.links` entry per installed
120
+ * playwright-core under the browsers path and deletes any build no LIVE link
121
+ * still wants. Since `/opt/pw-browsers` is a host-WIDE volume, an unflagged
122
+ * install in one container evicts browsers other tasks are mid-use of; that
123
+ * is measured, not theorised (a stale build vanishes on the very next run).
124
+ * The flag is honoured by the pinned core and makes the install genuinely
125
+ * additive, so provisioning can never be the thing that breaks another task.
126
+ *
127
+ * The cost is that dead builds are never reclaimed automatically. That is not
128
+ * the ~300 MB of download it sounds like: unpacked, one build set is ~980 MB
129
+ * (measured — 641 MB Chromium + 340 MB headless shell), so every pin bump
130
+ * strands about a gigabyte per host until someone prunes `/opt/pw-browsers`.
131
+ * Still the right trade against deleting a browser out from under a running
132
+ * agent, but it is a real cost and the prune is a real chore.
133
+ *
134
+ * Bounded IN the container too. `dockerCli`'s own timeout SIGKILLs the local
135
+ * `docker exec` client, and docker does not forward that to the process
136
+ * inside (moby/moby#9098) — so a host-side bound alone would let a wedged
137
+ * installer keep running, and keep holding Playwright's registry lock, while
138
+ * we conclude it failed and spawn agents anyway. Kept under the host-side
139
+ * bound so this is the one that fires.
140
+ */
141
+ const INSTALL_TIMEOUT_S = 280;
142
+
143
+ // The env assignment leads: `timeout N VAR=1 cmd` makes timeout try to exec
144
+ // "VAR=1" as a program and exit 127 without running anything. As a shell
145
+ // prefix it applies to `timeout` and is inherited straight through.
146
+ // `--kill-after` because plain `timeout` only sends TERM: an installer wedged
147
+ // in an uninterruptible or signal-ignoring state would absorb it and keep the
148
+ // registry lock, which is the whole thing this is here to prevent.
149
+ const INSTALL_BROWSER =
150
+ `PLAYWRIGHT_SKIP_BROWSER_GC=1 timeout --kill-after=10 ${INSTALL_TIMEOUT_S} ` +
151
+ `npx -y ${MCP_PKG} install-browser ${BROWSER}`;
152
+ const INSTALL_LOG = "/tmp/uai-pw-browser.log";
153
+
154
+ /**
155
+ * Chromium flags the MCP has no CLI passthrough for, so they ride a config file
156
+ * (`--config`, `browser.launchOptions.args`).
157
+ *
158
+ * `--window-size`/`--window-position` because there is NO WINDOW MANAGER on the
159
+ * virtual display. Chromium opens at its own default — 1050x880 inside a
160
+ * 1440x900 screen — and with nothing to service resize requests, stays there. A
161
+ * later `browser_resize` then only APPEARS to work: Playwright falls back to a
162
+ * CDP device-metrics override, so the page renders at the requested width while
163
+ * the window still shows the leftmost 1050px. Everything past that edge is
164
+ * painted where nobody can see it and nothing reports an error. Window creation
165
+ * is the only moment the size can be set.
166
+ *
167
+ * `--test-type` suppresses "You are using an unsupported command-line flag:
168
+ * --no-sandbox", which we cannot avoid triggering — no user namespaces in the
169
+ * container, so the sandbox has to go. Measured on this exact launch path
170
+ * (persistent context): the infobar costs 56px of viewport, permanently, in a
171
+ * window a human is watching.
172
+ */
173
+ const CHROMIUM_ARGS = [
174
+ "--test-type",
175
+ "--window-position=0,0",
176
+ `--window-size=${SCREEN_W},${SCREEN_H}`,
177
+ ];
178
+ const MCP_LAUNCH_CONFIG = "/tmp/uai-playwright-mcp.json";
179
+ /**
180
+ * Base64, not raw JSON. This command is embedded three ways — a JSON value in
181
+ * `.mcp.json`, a TOML LITERAL in Codex's config, and an `sh -lc` argument — and
182
+ * a literal TOML string cannot contain a single quote at all (`tomlString`
183
+ * throws rather than emit one). Base64's alphabet has no quotes, so the same
184
+ * bytes survive every layer with no escaping rules to get wrong.
185
+ */
186
+ const MCP_LAUNCH_CONFIG_B64 = Buffer.from(
187
+ JSON.stringify({ browser: { launchOptions: { args: CHROMIUM_ARGS } } }),
188
+ ).toString("base64");
189
+ /** Host-side backstop, deliberately looser than the in-container `timeout`. */
190
+ const INSTALL_TIMEOUT_MS = 300_000;
191
+ const MCP_CONFIG_PATH = "/workspace/.mcp.json";
192
+ export const DEFAULT_CODEX_HOME = "/home/node/.codex";
193
+ const CLAUDE_SETTINGS_PATH = "/workspace/.claude/settings.json";
194
+ const CONFIG_RESULT_TOKEN = "__UAI_BROWSER_CONFIG__";
195
+
196
+ /**
197
+ * The pre-warm run at session start. No `|| true`: it is awaited now, so its
198
+ * exit status is the readiness signal we log on.
199
+ */
200
+ export const PREWARM_CMD = `${INSTALL_BROWSER} >${INSTALL_LOG} 2>&1`;
201
+
202
+ /**
203
+ * The MCP server launcher — self-sufficient by design (learned live: a
204
+ * session that spawns while the apt installs are still running got a DEAD
205
+ * mcp server, and the agent had to improvise). It ensures its own display
206
+ * when Xvfb exists (headful → watchable), and falls back to a HEADLESS
207
+ * browser while the viewer packages are still installing — the agent always
208
+ * gets working tools. `sh -lc` + cd /workspace for the asdf shims. No
209
+ * single quotes in here: it's embedded in single-quoted JSON/TOML strings.
210
+ */
211
+ // X's own lockfile is the display-up signal — no pgrep (pgrep -f guards
212
+ // self-matched their launcher's cmdline and skipped every start; found
213
+ // live 2026-07-08), and it also detects displays an AGENT started itself.
214
+ const X_LOCK = `/tmp/.X${SERVER_DISPLAY.slice(1)}-lock`;
215
+
216
+ // NOTHING that can download belongs on this path. An MCP client gives the
217
+ // server a fixed budget to complete its handshake — Codex 10s, Claude Code
218
+ // 30s — and neither config raises it, so a ~90s install here doesn't delay
219
+ // the first session, it LOSES it. Readiness is established before agents
220
+ // spawn instead (see setupBrowserTesting), where a slow install is merely
221
+ // slow. Ensuring the display is fine: Xvfb is local and instant.
222
+ const SERVER_LAUNCHER =
223
+ `cd /workspace; ` +
224
+ // Writing a local file is not a download — it stays off the handshake budget.
225
+ `echo ${MCP_LAUNCH_CONFIG_B64} | base64 -d > ${MCP_LAUNCH_CONFIG}; ` +
226
+ `if command -v Xvfb >/dev/null 2>&1; then ` +
227
+ `[ -e ${X_LOCK} ] || (nohup ${XVFB_CMD} >>/tmp/uai-xvfb.log 2>&1 &); ` +
228
+ `sleep 1; export DISPLAY=${SERVER_DISPLAY}; ` +
229
+ `exec npx -y ${MCP_PKG} --config ${MCP_LAUNCH_CONFIG} ` +
230
+ `--browser ${BROWSER} --no-sandbox; ` +
231
+ `else ` +
232
+ `exec npx -y ${MCP_PKG} --config ${MCP_LAUNCH_CONFIG} ` +
233
+ `--browser ${BROWSER} --no-sandbox --headless; ` +
234
+ `fi`;
235
+
236
+ const SERVER_COMMAND = "sh";
237
+ /** Exported for the test that locks the pin/browser invariants. */
238
+ export const SERVER_ARGS: [string, string] = ["-lc", SERVER_LAUNCHER];
239
+
240
+ /** The `browser` server definition every engine gets. */
241
+ const SERVER_DEF = { command: SERVER_COMMAND, args: SERVER_ARGS };
242
+
243
+ /** Cursor reads stdio servers from ~/.cursor/mcp.json (the adapter passes
244
+ * `--approve-mcps`); same JSON shape as Claude's project file. */
245
+ const CURSOR_MCP_PATH = "/home/node/.cursor/mcp.json";
246
+
247
+ /** Grok: `grok mcp add` (add-or-update) in USER scope, once per GROK_HOME a
248
+ * task may run under (the default ~/.grok and each ~/.grok-acct-<id>) —
249
+ * `GROK_HOME` redirects the user config. PROJECT scope looked simpler but
250
+ * grok refuses repo-local servers in an untrusted folder, and /workspace is
251
+ * not a git repo so nothing ever trusts it (live 2026-09-08: "folder
252
+ * untrusted … re-run with --trust", no such flag headless). The launcher has
253
+ * no single quotes by construction (see SERVER_LAUNCHER). */
254
+ const GROK_BIN = "/home/node/.grok/bin/grok";
255
+ export const DEFAULT_GROK_HOME = "/home/node/.grok";
256
+ export function grokBrowserAddCommand(home: string): string {
257
+ if (!/^\/home\/node\/\.grok(-acct-[A-Za-z0-9_-]+)?$/.test(home)) {
258
+ throw new Error("invalid Grok home");
259
+ }
260
+ return (
261
+ `GROK_HOME=${home} ${GROK_BIN} mcp add browser -s user -t stdio ` +
262
+ `${SERVER_COMMAND} -- ${SERVER_ARGS.map((arg) => `'${arg}'`).join(" ")}`
263
+ );
264
+ }
265
+
266
+ const CLAUDE_SETTINGS_JSON = JSON.stringify(
267
+ { enableAllProjectMcpServers: true },
268
+ null,
269
+ 2,
270
+ );
271
+
272
+ const MANAGED_PACKAGE = "@playwright/mcp@<uai-version>";
273
+
274
+ /**
275
+ * Every browser-server shape uai has shipped. Ownership is an exact semantic
276
+ * allowlist, with only the package version normalised: a pin bump is ours,
277
+ * but one extra flag, env var, timeout, cwd, or tool filter is a human's
278
+ * customisation and must survive untouched.
279
+ */
280
+ const HISTORICAL_SERVER_DEFS: Array<Record<string, unknown>> = [
281
+ {
282
+ command: "npx",
283
+ args: [
284
+ "-y",
285
+ "@playwright/mcp@latest",
286
+ "--headless",
287
+ "--browser",
288
+ "chromium",
289
+ "--no-sandbox",
290
+ ],
291
+ },
292
+ {
293
+ command: "npx",
294
+ args: [
295
+ "-y",
296
+ "@playwright/mcp@latest",
297
+ "--browser",
298
+ "chromium",
299
+ "--no-sandbox",
300
+ ],
301
+ env: { DISPLAY: SERVER_DISPLAY },
302
+ },
303
+ {
304
+ command: "sh",
305
+ args: [
306
+ "-lc",
307
+ `cd /workspace; if command -v Xvfb >/dev/null 2>&1; then ` +
308
+ `pgrep -f "Xvfb ${SERVER_DISPLAY}" >/dev/null 2>&1 || ` +
309
+ `(${XVFB_CMD} >>/tmp/uai-xvfb.log 2>&1 &); ` +
310
+ `sleep 1; export DISPLAY=${SERVER_DISPLAY}; ` +
311
+ `exec npx -y @playwright/mcp@latest --browser chromium --no-sandbox; ` +
312
+ `else exec npx -y @playwright/mcp@latest --browser chromium --no-sandbox --headless; fi`,
313
+ ],
314
+ },
315
+ {
316
+ command: "sh",
317
+ args: [
318
+ "-lc",
319
+ `cd /workspace; if command -v Xvfb >/dev/null 2>&1; then ` +
320
+ `[ -e ${X_LOCK} ] || (nohup ${XVFB_CMD} >>/tmp/uai-xvfb.log 2>&1 &); ` +
321
+ `sleep 1; export DISPLAY=${SERVER_DISPLAY}; ` +
322
+ `exec npx -y @playwright/mcp@latest --browser chromium --no-sandbox; ` +
323
+ `else exec npx -y @playwright/mcp@latest --browser chromium --no-sandbox --headless; fi`,
324
+ ],
325
+ },
326
+ {
327
+ command: "sh",
328
+ args: [
329
+ "-lc",
330
+ `cd /workspace; npx -y @playwright/mcp@0.0.78 install-browser chromium ` +
331
+ `>>${INSTALL_LOG} 2>&1 || true; ` +
332
+ `if command -v Xvfb >/dev/null 2>&1; then ` +
333
+ `[ -e ${X_LOCK} ] || (nohup ${XVFB_CMD} >>/tmp/uai-xvfb.log 2>&1 &); ` +
334
+ `sleep 1; export DISPLAY=${SERVER_DISPLAY}; ` +
335
+ `exec npx -y @playwright/mcp@0.0.78 --browser chromium --no-sandbox; ` +
336
+ `else exec npx -y @playwright/mcp@0.0.78 --browser chromium --no-sandbox --headless; fi`,
337
+ ],
338
+ },
339
+ {
340
+ // The pinned launcher BEFORE `--config`: no window geometry and no
341
+ // `--test-type`, so its Chromium opened at 1050x880 on a 1440x900 screen,
342
+ // clipping anything wider, under an infobar. Listed so a container still
343
+ // running it is rewritten rather than left as it is.
344
+ command: "sh",
345
+ args: [
346
+ "-lc",
347
+ `cd /workspace; ` +
348
+ `if command -v Xvfb >/dev/null 2>&1; then ` +
349
+ `[ -e ${X_LOCK} ] || (nohup ${XVFB_CMD} >>/tmp/uai-xvfb.log 2>&1 &); ` +
350
+ `sleep 1; export DISPLAY=${SERVER_DISPLAY}; ` +
351
+ `exec npx -y ${MCP_PKG} --browser ${BROWSER} --no-sandbox; ` +
352
+ `else ` +
353
+ `exec npx -y ${MCP_PKG} --browser ${BROWSER} --no-sandbox --headless; ` +
354
+ `fi`,
355
+ ],
356
+ },
357
+ SERVER_DEF,
358
+ ];
359
+
360
+ function normaliseManagedPackage(value: unknown): unknown {
361
+ if (typeof value === "string") {
362
+ return value.replace(
363
+ /@playwright\/mcp@(?:latest|\d+\.\d+\.\d+)(?=[\s'"]|$)/g,
364
+ MANAGED_PACKAGE,
365
+ );
366
+ }
367
+ if (Array.isArray(value)) return value.map(normaliseManagedPackage);
368
+ if (value && typeof value === "object") {
369
+ return Object.fromEntries(
370
+ Object.entries(value).map(([key, item]) => [key, normaliseManagedPackage(item)]),
371
+ );
372
+ }
373
+ return value;
374
+ }
375
+
376
+ /** Exported so ownership tests lock the historical allowlist itself. */
377
+ export const MANAGED_BROWSER_DEFS = HISTORICAL_SERVER_DEFS.map(
378
+ normaliseManagedPackage,
379
+ );
380
+
381
+ const CODEX_MARKER = "# uai ADR-053: in-container browser (Playwright MCP)";
382
+
383
+ function tomlString(value: string, quote: "'" | '"'): string {
384
+ if (quote === "'") {
385
+ if (value.includes("'")) {
386
+ throw new Error("Moondeck browser launcher cannot be represented as TOML literal text");
387
+ }
388
+ return `'${value}'`;
389
+ }
390
+ return JSON.stringify(value);
391
+ }
392
+
393
+ function codexBlock(
394
+ definition: Record<string, unknown>,
395
+ quote: "'" | '"',
396
+ ): string {
397
+ const command = String(definition.command);
398
+ const args = (definition.args as unknown[]).map(String);
399
+ const lines = [
400
+ "",
401
+ CODEX_MARKER,
402
+ "[mcp_servers.browser]",
403
+ `command = ${tomlString(command, quote)}`,
404
+ `args = [${args.map((arg) => tomlString(arg, quote)).join(", ")}]`,
405
+ ];
406
+ if (definition.env && typeof definition.env === "object") {
407
+ const pairs = Object.entries(definition.env).map(
408
+ ([key, value]) => `${key} = ${tomlString(String(value), quote)}`,
409
+ );
410
+ lines.push(`env = { ${pairs.join(", ")} }`);
411
+ }
412
+ lines.push("");
413
+ return lines.join("\n");
414
+ }
415
+
416
+ /** Exact text shapes Uai wrote, used only after Codex confirms the semantics. */
417
+ export const MANAGED_CODEX_BLOCKS = [
418
+ codexBlock(HISTORICAL_SERVER_DEFS[0]!, '"'),
419
+ codexBlock(HISTORICAL_SERVER_DEFS[1]!, '"'),
420
+ ...HISTORICAL_SERVER_DEFS.slice(2).map((definition) =>
421
+ codexBlock(definition, "'"),
422
+ ),
423
+ ]
424
+ .map((block) => String(normaliseManagedPackage(block)))
425
+ .filter((block, index, blocks) => blocks.indexOf(block) === index);
426
+
427
+ const CODEX_TOML = codexBlock(SERVER_DEF, "'");
428
+
429
+ /**
430
+ * The active migrator. Claude JSON is parsed and atomically replaced. The
431
+ * shipped Codex CLI is used only as the read-only semantic parser; a Codex
432
+ * write is allowed only when the raw block exactly matches text Uai shipped.
433
+ * That preserves neighboring tables/comments byte-for-byte and declines
434
+ * custom fields the CLI's JSON view may omit.
435
+ */
436
+ export const MIGRATE_JS = String.raw`
437
+ const fs = require("node:fs");
438
+ const path = require("node:path");
439
+ const crypto = require("node:crypto");
440
+ const child = require("node:child_process");
441
+
442
+ const def = JSON.parse(process.env.UAI_BROWSER_DEF);
443
+ const settingsDef = JSON.parse(process.env.UAI_CLAUDE_SETTINGS_DEF);
444
+ const managedDefs = ${JSON.stringify(MANAGED_BROWSER_DEFS)};
445
+ const packageToken = ${JSON.stringify(MANAGED_PACKAGE)};
446
+ const managedCodexBlocks = ${JSON.stringify(MANAGED_CODEX_BLOCKS)};
447
+ const desiredCodexBlock = ${JSON.stringify(CODEX_TOML)};
448
+ const notes = [];
449
+ let changed = false;
450
+ let configured = true;
451
+ let codexHomes = [];
452
+ try {
453
+ const parsed = JSON.parse(process.env.UAI_CODEX_HOMES || "[]");
454
+ if (!Array.isArray(parsed) ||
455
+ !parsed.every(function (home) {
456
+ return typeof home === "string" &&
457
+ (home === ${JSON.stringify(DEFAULT_CODEX_HOME)} ||
458
+ /^\/home\/node\/\.codex-acct-[A-Za-z0-9_-]+$/.test(home));
459
+ })) {
460
+ throw new Error("invalid Codex home list");
461
+ }
462
+ codexHomes = Array.from(new Set(parsed));
463
+ } catch (error) {
464
+ notes.push("Codex home list is invalid: " + String(error.message || error));
465
+ configured = false;
466
+ }
467
+
468
+ function plainObject(value) {
469
+ return value !== null && typeof value === "object" && !Array.isArray(value);
470
+ }
471
+
472
+ function canonical(value) {
473
+ if (Array.isArray(value)) return value.map(canonical);
474
+ if (plainObject(value)) {
475
+ const out = {};
476
+ for (const key of Object.keys(value).sort()) out[key] = canonical(value[key]);
477
+ return out;
478
+ }
479
+ return value;
480
+ }
481
+
482
+ function equal(left, right) {
483
+ return JSON.stringify(canonical(left)) === JSON.stringify(canonical(right));
484
+ }
485
+
486
+ function normalise(value) {
487
+ if (typeof value === "string") {
488
+ return value.replace(
489
+ /@playwright\/mcp@(?:latest|\d+\.\d+\.\d+)(?=[\s'"]|$)/g,
490
+ packageToken
491
+ );
492
+ }
493
+ if (Array.isArray(value)) return value.map(normalise);
494
+ if (plainObject(value)) {
495
+ const out = {};
496
+ for (const [key, item] of Object.entries(value)) out[key] = normalise(item);
497
+ return out;
498
+ }
499
+ return value;
500
+ }
501
+
502
+ function isManaged(value) {
503
+ const normalised = normalise(value);
504
+ return managedDefs.some(function (candidate) {
505
+ return equal(candidate, normalised);
506
+ });
507
+ }
508
+
509
+ function atomicWrite(file, text, expected) {
510
+ fs.mkdirSync(path.dirname(file), { recursive: true });
511
+ const mode = fs.existsSync(file) ? fs.statSync(file).mode & 0o777 : 0o644;
512
+ const tmp = path.join(
513
+ path.dirname(file),
514
+ "." + path.basename(file) + "." + process.pid + "." +
515
+ crypto.randomBytes(8).toString("hex") + ".tmp"
516
+ );
517
+ try {
518
+ fs.writeFileSync(tmp, text, { encoding: "utf8", flag: "wx", mode: mode });
519
+ if (arguments.length >= 3) {
520
+ const current = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null;
521
+ if (current !== expected) return false;
522
+ }
523
+ fs.renameSync(tmp, file);
524
+ return true;
525
+ } finally {
526
+ try { fs.unlinkSync(tmp); } catch {}
527
+ }
528
+ }
529
+
530
+ function atomicCreate(file, text) {
531
+ fs.mkdirSync(path.dirname(file), { recursive: true });
532
+ const tmp = path.join(
533
+ path.dirname(file),
534
+ "." + path.basename(file) + "." + process.pid + "." +
535
+ crypto.randomBytes(8).toString("hex") + ".tmp"
536
+ );
537
+ try {
538
+ fs.writeFileSync(tmp, text, { encoding: "utf8", flag: "wx", mode: 0o644 });
539
+ try {
540
+ fs.linkSync(tmp, file);
541
+ return true;
542
+ } catch (error) {
543
+ if (error && error.code === "EEXIST") return false;
544
+ throw error;
545
+ }
546
+ } finally {
547
+ try { fs.unlinkSync(tmp); } catch {}
548
+ }
549
+ }
550
+
551
+ function ensureClaudeBrowser() {
552
+ const file = process.env.UAI_MCP_PATH;
553
+ if (!file) return { configured: true, changed: false };
554
+ const exists = fs.existsSync(file);
555
+ let original = null;
556
+ let document;
557
+ try {
558
+ original = exists ? fs.readFileSync(file, "utf8") : null;
559
+ document = exists ? JSON.parse(original) : { mcpServers: {} };
560
+ } catch {
561
+ notes.push("mcp.json is unparseable - left alone");
562
+ return { configured: false, changed: false };
563
+ }
564
+ if (!plainObject(document)) {
565
+ notes.push("mcp.json root is not an object - left alone");
566
+ return { configured: false, changed: false };
567
+ }
568
+ if (document.mcpServers === undefined) document.mcpServers = {};
569
+ if (!plainObject(document.mcpServers)) {
570
+ notes.push("mcp.json mcpServers is not an object - left alone");
571
+ return { configured: false, changed: false };
572
+ }
573
+ const current = document.mcpServers.browser;
574
+ if (current !== undefined && equal(current, def)) {
575
+ return { configured: true, changed: false };
576
+ }
577
+ if (current !== undefined && !isManaged(current)) {
578
+ notes.push("mcp.json browser entry is not uai-managed - left alone");
579
+ return { configured: true, changed: false };
580
+ }
581
+ document.mcpServers.browser = def;
582
+ if (!atomicWrite(file, JSON.stringify(document, null, 2) + "\n", original)) {
583
+ notes.push("mcp.json changed concurrently - retrying later");
584
+ return { configured: false, changed: false };
585
+ }
586
+ return { configured: true, changed: true };
587
+ }
588
+
589
+ function ensureCursorBrowser() {
590
+ const file = process.env.UAI_CURSOR_MCP_PATH;
591
+ if (!file) return { configured: true, changed: false };
592
+ const exists = fs.existsSync(file);
593
+ let original = null;
594
+ let document;
595
+ try {
596
+ original = exists ? fs.readFileSync(file, "utf8") : null;
597
+ document = exists ? JSON.parse(original) : { mcpServers: {} };
598
+ } catch {
599
+ notes.push("cursor mcp.json is unparseable - left alone");
600
+ return { configured: false, changed: false };
601
+ }
602
+ if (!plainObject(document)) {
603
+ notes.push("cursor mcp.json root is not an object - left alone");
604
+ return { configured: false, changed: false };
605
+ }
606
+ if (document.mcpServers === undefined) document.mcpServers = {};
607
+ if (!plainObject(document.mcpServers)) {
608
+ notes.push("cursor mcp.json mcpServers is not an object - left alone");
609
+ return { configured: false, changed: false };
610
+ }
611
+ const current = document.mcpServers.browser;
612
+ if (current !== undefined && equal(current, def)) {
613
+ return { configured: true, changed: false };
614
+ }
615
+ if (current !== undefined && !isManaged(current)) {
616
+ notes.push("cursor mcp.json browser entry is not uai-managed - left alone");
617
+ return { configured: true, changed: false };
618
+ }
619
+ document.mcpServers.browser = def;
620
+ fs.mkdirSync(path.dirname(file), { recursive: true });
621
+ if (!atomicWrite(file, JSON.stringify(document, null, 2) + "\n", original)) {
622
+ notes.push("cursor mcp.json changed concurrently - retrying later");
623
+ return { configured: false, changed: false };
624
+ }
625
+ return { configured: true, changed: true };
626
+ }
627
+
628
+ function ensureClaudeSettings() {
629
+ const file = process.env.UAI_CLAUDE_SETTINGS_PATH;
630
+ if (!file || fs.existsSync(file)) return { configured: true, changed: false };
631
+ try {
632
+ const created = atomicCreate(file, JSON.stringify(settingsDef, null, 2) + "\n");
633
+ return { configured: true, changed: created };
634
+ } catch (error) {
635
+ notes.push("Claude settings write failed: " + String(error.message || error));
636
+ return { configured: false, changed: false };
637
+ }
638
+ }
639
+
640
+ function exactKeys(value, keys) {
641
+ return plainObject(value) &&
642
+ Object.keys(value).sort().join("\0") === keys.slice().sort().join("\0");
643
+ }
644
+
645
+ const codexTopKeys = [
646
+ "disabled_reason", "disabled_tools", "enabled", "enabled_tools", "name",
647
+ "startup_timeout_sec", "tool_timeout_sec", "transport"
648
+ ];
649
+
650
+ function stringArrayOrNull(value) {
651
+ return value === null ||
652
+ (Array.isArray(value) &&
653
+ value.every(function (item) { return typeof item === "string"; }));
654
+ }
655
+
656
+ function validCodexEnvelope(value) {
657
+ if (!exactKeys(value, codexTopKeys) || value.name !== "browser" ||
658
+ typeof value.enabled !== "boolean" ||
659
+ !(value.disabled_reason === null ||
660
+ typeof value.disabled_reason === "string") ||
661
+ !stringArrayOrNull(value.enabled_tools) ||
662
+ !stringArrayOrNull(value.disabled_tools) ||
663
+ !(value.startup_timeout_sec === null ||
664
+ typeof value.startup_timeout_sec === "number") ||
665
+ !(value.tool_timeout_sec === null ||
666
+ typeof value.tool_timeout_sec === "number") ||
667
+ !plainObject(value.transport) ||
668
+ typeof value.transport.type !== "string") {
669
+ return false;
670
+ }
671
+ if (value.transport.type === "stdio") {
672
+ return exactKeys(
673
+ value.transport,
674
+ ["args", "command", "cwd", "env", "env_vars", "type"]
675
+ ) && typeof value.transport.command === "string" &&
676
+ Array.isArray(value.transport.args) &&
677
+ value.transport.args.every(function (item) {
678
+ return typeof item === "string";
679
+ }) &&
680
+ (value.transport.cwd === null ||
681
+ typeof value.transport.cwd === "string") &&
682
+ (value.transport.env === null || plainObject(value.transport.env)) &&
683
+ Array.isArray(value.transport.env_vars);
684
+ }
685
+ if (value.transport.type === "streamable_http" ||
686
+ value.transport.type === "sse") {
687
+ return exactKeys(
688
+ value.transport,
689
+ [
690
+ "bearer_token_env_var", "env_http_headers", "http_headers", "type",
691
+ "url"
692
+ ]
693
+ ) && typeof value.transport.url === "string";
694
+ }
695
+ return false;
696
+ }
697
+
698
+ /**
699
+ * Convert Codex's normalised JSON to the stdio shape Claude stores. Checking
700
+ * every default makes an added timeout, cwd, env-var mapping, tool filter, or
701
+ * disabled state foreign rather than accidentally uai-owned.
702
+ */
703
+ function codexDefinition(value) {
704
+ if (!validCodexEnvelope(value) ||
705
+ value.enabled !== true || value.disabled_reason !== null ||
706
+ value.enabled_tools !== null || value.disabled_tools !== null ||
707
+ value.startup_timeout_sec !== null || value.tool_timeout_sec !== null) {
708
+ return null;
709
+ }
710
+ const transport = value.transport;
711
+ const transportKeys = ["args", "command", "cwd", "env", "env_vars", "type"];
712
+ if (!exactKeys(transport, transportKeys) || transport.type !== "stdio" ||
713
+ typeof transport.command !== "string" ||
714
+ !Array.isArray(transport.args) ||
715
+ !transport.args.every(function (arg) { return typeof arg === "string"; }) ||
716
+ transport.cwd !== null || !Array.isArray(transport.env_vars) ||
717
+ transport.env_vars.length !== 0 ||
718
+ !(transport.env === null || plainObject(transport.env))) {
719
+ return null;
720
+ }
721
+ if (transport.env !== null &&
722
+ !Object.values(transport.env).every(function (item) {
723
+ return typeof item === "string";
724
+ })) {
725
+ return null;
726
+ }
727
+ const result = { command: transport.command, args: transport.args };
728
+ if (transport.env !== null) result.env = transport.env;
729
+ return result;
730
+ }
731
+
732
+ function runCodex(home, args) {
733
+ return child.spawnSync("codex", args, {
734
+ encoding: "utf8",
735
+ env: Object.assign({}, process.env, { CODEX_HOME: home }),
736
+ maxBuffer: 1024 * 1024,
737
+ // Codex's FIRST run on a fresh machine initialises its state (sqlite)
738
+ // before answering; 5 s timed out on every hostless task machine
739
+ // (smoke 10, 2026-09-10: ETIMEDOUT for both homes) and the direct
740
+ // config.toml fallback did the work with an error in the log. A minute
741
+ // is generous for a probe that normally answers in well under a second.
742
+ timeout: 60000,
743
+ killSignal: "SIGKILL",
744
+ });
745
+ }
746
+
747
+ function getCodex(home) {
748
+ const result = runCodex(home, ["mcp", "get", "browser", "--json"]);
749
+ if (result.status === 0) {
750
+ try {
751
+ return { kind: "found", value: JSON.parse(result.stdout) };
752
+ } catch {
753
+ return { kind: "error", detail: "codex mcp get returned invalid JSON" };
754
+ }
755
+ }
756
+ const lastLine = String(result.stderr || "")
757
+ .split(/\r?\n/)
758
+ .map(function (line) { return line.trim(); })
759
+ .filter(Boolean)
760
+ .pop();
761
+ if (lastLine === "Error: No MCP server named 'browser' found.") {
762
+ return { kind: "missing" };
763
+ }
764
+ return {
765
+ kind: "error",
766
+ detail: "codex mcp get failed: " + String(lastLine || result.error || result.status),
767
+ };
768
+ }
769
+
770
+ function classifyCodex(state) {
771
+ if (state.kind !== "found") return state;
772
+ if (!validCodexEnvelope(state.value)) {
773
+ return { kind: "error", detail: "codex mcp get returned malformed browser JSON" };
774
+ }
775
+ const definition = codexDefinition(state.value);
776
+ if (definition && equal(definition, def)) {
777
+ return { kind: "current", definition: definition };
778
+ }
779
+ if (definition && isManaged(definition)) {
780
+ return { kind: "managed", definition: definition };
781
+ }
782
+ return {
783
+ kind: "foreign",
784
+ configured: true,
785
+ };
786
+ }
787
+
788
+ function tableHeaderPath(line) {
789
+ let index = 0;
790
+ const skipSpace = function () {
791
+ while (
792
+ line[index] === " " ||
793
+ line[index] === "\t" ||
794
+ line[index] === "\r"
795
+ ) index += 1;
796
+ };
797
+ skipSpace();
798
+ const array = line.startsWith("[[", index);
799
+ if (!array && line[index] !== "[") return null;
800
+ index += array ? 2 : 1;
801
+ const close = array ? "]]" : "]";
802
+ const segments = [];
803
+
804
+ while (index < line.length) {
805
+ skipSpace();
806
+ if (line.startsWith(close, index)) {
807
+ index += close.length;
808
+ skipSpace();
809
+ return index === line.length || line[index] === "#" ? segments : null;
810
+ }
811
+
812
+ let segment;
813
+ if (line[index] === '"') {
814
+ const start = index;
815
+ index += 1;
816
+ while (index < line.length) {
817
+ if (line[index] === "\\") {
818
+ index += 2;
819
+ continue;
820
+ }
821
+ if (line[index] === '"') {
822
+ index += 1;
823
+ break;
824
+ }
825
+ index += 1;
826
+ }
827
+ try {
828
+ const jsonString = line.slice(start, index).replace(
829
+ /\\U([0-9A-Fa-f]{8})/g,
830
+ function (_whole, hex) {
831
+ const point = Number.parseInt(hex, 16);
832
+ if (point <= 0xffff) {
833
+ return "\\u" + point.toString(16).padStart(4, "0");
834
+ }
835
+ const shifted = point - 0x10000;
836
+ const high = 0xd800 + (shifted >> 10);
837
+ const low = 0xdc00 + (shifted & 0x3ff);
838
+ return "\\u" + high.toString(16) + "\\u" + low.toString(16);
839
+ }
840
+ );
841
+ segment = JSON.parse(jsonString);
842
+ } catch {
843
+ return null;
844
+ }
845
+ } else if (line[index] === "'") {
846
+ const end = line.indexOf("'", index + 1);
847
+ if (end === -1) return null;
848
+ segment = line.slice(index + 1, end);
849
+ index = end + 1;
850
+ } else {
851
+ const bare = /^[A-Za-z0-9_-]+/.exec(line.slice(index));
852
+ if (!bare) return null;
853
+ segment = bare[0];
854
+ index += bare[0].length;
855
+ }
856
+ segments.push(segment);
857
+ skipSpace();
858
+ if (line[index] === ".") {
859
+ index += 1;
860
+ continue;
861
+ }
862
+ if (!line.startsWith(close, index)) return null;
863
+ }
864
+ return null;
865
+ }
866
+
867
+ function managedBlockEdit(text) {
868
+ const pattern = new RegExp(
869
+ "\\r?\\n# uai ADR-053: in-container browser \\(Playwright MCP\\)\\r?\\n" +
870
+ "\\[mcp_servers\\.browser\\]\\r?\\n" +
871
+ "command = [^\\r\\n]+\\r?\\n" +
872
+ "args = [^\\r\\n]+\\r?\\n" +
873
+ "(?:env = [^\\r\\n]+\\r?\\n)?",
874
+ "g"
875
+ );
876
+ // A descendant table can carry browser settings that Codex's normalised
877
+ // JSON omits. Reject the whole file if ANY browser table other than the
878
+ // single Uai-authored root exists, even when another table sits between the
879
+ // managed block and that descendant.
880
+ const browserHeaders = Array.from(
881
+ text.matchAll(/^[ \t]*\[\[?.+\]\]?[ \t]*(?:#.*)?\r?$/gm)
882
+ ).map(function (match) {
883
+ return tableHeaderPath(match[0]);
884
+ }).filter(function (segments) {
885
+ return segments &&
886
+ segments.length >= 2 &&
887
+ segments[0] === "mcp_servers" &&
888
+ segments[1] === "browser";
889
+ });
890
+ if (browserHeaders.length !== 1 || browserHeaders[0].length !== 2) {
891
+ return null;
892
+ }
893
+ const matches = Array.from(text.matchAll(pattern)).filter(function (match) {
894
+ const normalised = normalise(match[0].replace(/\r\n/g, "\n"));
895
+ if (!managedCodexBlocks.includes(normalised)) return false;
896
+ const after = (match.index || 0) + match[0].length;
897
+ const remainder = text.slice(after);
898
+ const nextHeader = remainder.search(
899
+ /^[ \t]*\[\[?.+\]\]?[ \t]*(?:#.*)?\r?$/m
900
+ );
901
+ const gap = nextHeader === -1 ? remainder : remainder.slice(0, nextHeader);
902
+ return gap.split(/\r?\n/).every(function (line) {
903
+ const trimmed = line.trim();
904
+ return trimmed === "" || trimmed.startsWith("#");
905
+ });
906
+ });
907
+ if (matches.length !== 1) return null;
908
+ const match = matches[0];
909
+ const start = match.index || 0;
910
+ const newline = match[0].includes("\r\n") ? "\r\n" : "\n";
911
+ const replacement = desiredCodexBlock.replace(/\n/g, newline);
912
+ return text.slice(0, start) + replacement + text.slice(start + match[0].length);
913
+ }
914
+
915
+ function verifyCodexWrite(home) {
916
+ const observed = classifyCodex(getCodex(home));
917
+ if (observed.kind === "current") {
918
+ return { configured: true, changed: true };
919
+ }
920
+ if (observed.kind === "foreign") {
921
+ notes.push(home + ": browser definition changed concurrently - left alone");
922
+ return { configured: observed.configured, changed: true };
923
+ }
924
+ if (observed.kind === "error") notes.push(home + ": " + observed.detail);
925
+ else notes.push(home + ": browser rewrite did not produce the pinned definition");
926
+ return { configured: false, changed: true };
927
+ }
928
+
929
+ function ensureCodexBrowser(home) {
930
+ const file = path.join(home, "config.toml");
931
+ try {
932
+ fs.mkdirSync(home, { recursive: true });
933
+ } catch (error) {
934
+ notes.push(home + ": could not create Codex home: " + String(error.message || error));
935
+ return { configured: false, changed: false };
936
+ }
937
+
938
+ // Read before asking Codex to parse. The compare-before-rename below then
939
+ // rejects any writer that lands after this snapshot.
940
+ const original = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null;
941
+ const state = classifyCodex(getCodex(home));
942
+ if (state.kind === "current") return { configured: true, changed: false };
943
+ if (state.kind === "foreign") {
944
+ notes.push(home + ": browser definition is not uai-managed - left alone");
945
+ return { configured: state.configured, changed: false };
946
+ }
947
+ if (state.kind === "error") {
948
+ notes.push(home + ": " + state.detail);
949
+ return { configured: false, changed: false };
950
+ }
951
+
952
+ let next;
953
+ if (state.kind === "missing") {
954
+ const base = original || "";
955
+ const newline = base.includes("\r\n") ? "\r\n" : "\n";
956
+ next = base + desiredCodexBlock.replace(/\n/g, newline);
957
+ } else {
958
+ next = original === null ? null : managedBlockEdit(original);
959
+ if (next === null) {
960
+ notes.push(home + ": managed semantics have a foreign text shape - left alone");
961
+ return { configured: true, changed: false };
962
+ }
963
+ }
964
+
965
+ if (!atomicWrite(file, next, original)) {
966
+ notes.push(home + ": config.toml changed concurrently - retrying later");
967
+ return { configured: false, changed: false };
968
+ }
969
+ return verifyCodexWrite(home);
970
+ }
971
+
972
+ function safeOutcome(label, ensure) {
973
+ try {
974
+ return ensure();
975
+ } catch (error) {
976
+ notes.push(label + ": setup failed: " + String(error.message || error));
977
+ return { configured: false, changed: false };
978
+ }
979
+ }
980
+
981
+ for (const outcome of [
982
+ safeOutcome("Claude browser config", ensureClaudeBrowser),
983
+ safeOutcome("Claude settings", ensureClaudeSettings),
984
+ ...(process.env.UAI_CURSOR_MCP_PATH
985
+ ? [safeOutcome("Cursor browser config", ensureCursorBrowser)]
986
+ : []),
987
+ ...codexHomes.map(function (home) {
988
+ return safeOutcome(home, function () { return ensureCodexBrowser(home); });
989
+ }),
990
+ ]) {
991
+ configured = configured && outcome.configured;
992
+ changed = changed || outcome.changed;
993
+ }
994
+
995
+ console.log(
996
+ ${JSON.stringify(CONFIG_RESULT_TOKEN)} +
997
+ JSON.stringify({ configured: configured, changed: changed, notes: notes })
998
+ );
999
+ `;
1000
+
1001
+ /**
1002
+ * Start the watchable-browser stack (Xvfb → x11vnc → noVNC on :6080) if its
1003
+ * binaries are installed and it isn't already up. x11vnc and websockify run
1004
+ * under tiny restart loops — x11vnc EXITS whenever the X server it watches
1005
+ * isn't up yet (the race that killed the first live run), and the loops
1006
+ * also reattach after the display owner changes. Safe to run repeatedly:
1007
+ * every piece is guarded by the X lockfile or a pidfile, loops included.
1008
+ */
1009
+ /**
1010
+ * The viewer daemons, one docker-exec each (the single-line nohup shape is
1011
+ * the only one that reliably survives `docker exec -d`). Guards use the X
1012
+ * lockfile and PIDFILES — never pgrep: a pgrep -f guard sharing a cmdline
1013
+ * with its own payload self-matches and skips the start (this exact bug
1014
+ * kept the stack down in every container until 2026-07-08).
1015
+ *
1016
+ * The pidfile tracks the LOOP SHELL, not the port. When a loop is orphaned
1017
+ * — the display owner changes, or two ensures race, and the single pidfile
1018
+ * only remembers the latest — the previous loop's websockify keeps holding
1019
+ * 6080, and SO_REUSEADDR lets the new one bind alongside it. The kernel
1020
+ * then round-robins connections across both, so the viewer lands on a live
1021
+ * proxy only SOMETIMES and reconnects the rest (live 2026-09-02). So the
1022
+ * start branch — reached only when the tracked loop is already dead — first
1023
+ * reaps anything still holding the port, guaranteeing exactly one listener.
1024
+ *
1025
+ * That reap MUST be `pkill -A` (--ignore-ancestors). The step runs as
1026
+ * `sh -c "<step>"`, so the launching shell's own cmdline carries the launch
1027
+ * line and matches the reap pattern: plain `pkill -f` SIGTERMed that shell
1028
+ * before it reached the nohup, and neither daemon ever started — host
1029
+ * 0.9.94–0.9.96, every Linux runtime (the pgrep self-match above, wearing a
1030
+ * kill; reproduced in the uai-linux VM: step exit 143, no pidfile). Both
1031
+ * shipped images run procps-ng 4.0.2 (Debian bookworm), which has -A; an
1032
+ * older procps rejects the flag with a usage error and reaps nothing, which
1033
+ * merely restores the pre-reap behaviour and never the self-kill.
1034
+ * `-A` protects the launching chain only: the apt worker below inlines
1035
+ * these steps into `su -c` / `nohup sh -c` wrappers whose argv also
1036
+ * matches, and a per-session reap that lands inside that worker's ~1 s
1037
+ * viewer window can kill its node-uid shell. That only costs the worker
1038
+ * its done-marker (set -e), so it re-runs on the next ensure and finds the
1039
+ * pidfiles alive — self-healing, and no worse than the pre-reap world.
1040
+ */
1041
+ const VIEWER_STEPS: string[] = [
1042
+ // Xvfb — one-shot; the X lockfile is the truth (also set when an agent
1043
+ // started the display itself). If it dies cleanly the lock clears and the
1044
+ // next session ensure relaunches it. A lock whose pid is DEAD is the
1045
+ // leftover of a SIGKILLed server — a sidecar restart kills its whole
1046
+ // cgroup (live 2026-09-10, smoke 10: Xvfb gone, lock present, x11vnc
1047
+ // looping on a display that never came back) — so clear it and the
1048
+ // socket first, then launch as usual.
1049
+ `[ -x /usr/bin/Xvfb ] || exit 0; if [ -e ${X_LOCK} ] && ! /usr/bin/kill -0 "$(/usr/bin/tr -d ' \\n' < ${X_LOCK})" 2>/dev/null; then /bin/rm -f ${X_LOCK} /tmp/.X11-unix/X${SERVER_DISPLAY.slice(1)}; fi; [ -e ${X_LOCK} ] || /usr/bin/nohup /usr/bin/${XVFB_CMD} >>/tmp/uai-xvfb.log 2>&1 &`,
1050
+ // x11vnc under a restart loop (it exits whenever the X server isn't up
1051
+ // yet). Pidfile-guarded.
1052
+ `[ -x /usr/bin/x11vnc ] || exit 0; [ -f /tmp/uai-x11vnc.pid ] && /usr/bin/kill -0 "$(/usr/bin/cat /tmp/uai-x11vnc.pid)" 2>/dev/null && exit 0; /usr/bin/pkill -A -f 'x11vnc -display' 2>/dev/null; /usr/bin/nohup /bin/sh -c 'echo $$ > /tmp/uai-x11vnc.pid; while true; do /usr/bin/x11vnc -display ${SERVER_DISPLAY} -forever -shared -nopw -quiet >>/tmp/uai-x11vnc.log 2>&1; /bin/sleep 2; done' >/dev/null 2>&1 &`,
1053
+ // websockify/noVNC under the same pattern.
1054
+ `[ -x /usr/bin/websockify ] || exit 0; [ -f /tmp/uai-novnc.pid ] && /usr/bin/kill -0 "$(/usr/bin/cat /tmp/uai-novnc.pid)" 2>/dev/null && exit 0; /usr/bin/pkill -A -f 'websockify.*6080' 2>/dev/null; /usr/bin/nohup /bin/sh -c 'echo $$ > /tmp/uai-novnc.pid; while true; do /usr/bin/websockify --web=/usr/share/novnc 0.0.0.0:6080 localhost:5900 >>/tmp/uai-websockify.log 2>&1; /bin/sleep 2; done' >/dev/null 2>&1 &`,
1055
+ ];
1056
+
1057
+ const DEFAULT_NODE_BIN =
1058
+ `/opt/asdf-data/installs/nodejs/${STANDARD_DEFAULT_NODE_VERSION}/bin`;
1059
+ const ROOT_RUNTIME_PATH =
1060
+ `${DEFAULT_NODE_BIN}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`;
1061
+ // `npx` through ROOT_RUNTIME_PATH, not an absolute asdf path: the machine
1062
+ // image (ADR-121) carries no asdf — node is /usr/bin/node there — and the
1063
+ // absolute path made this worker exit 127 on every task machine, so its
1064
+ // done-marker was never written and each ensure re-ran it (live 2026-09-09).
1065
+ // The path lists the asdf bin first and never the shims, so a container
1066
+ // resolves exactly what it did before.
1067
+ const APT_DEPS_CMD =
1068
+ `PATH=${ROOT_RUNTIME_PATH} npx ` +
1069
+ "-y playwright@latest install-deps chromium";
1070
+ const APT_VIEWER_CMD =
1071
+ "/usr/bin/apt-get install -y --no-install-recommends xvfb x11vnc novnc websockify";
1072
+
1073
+ function aptGuardPaths(): { done: string; lock: string } {
1074
+ const digest = createHash("sha256")
1075
+ .update(JSON.stringify([APT_DEPS_CMD, APT_VIEWER_CMD, VIEWER_STEPS]))
1076
+ .digest("hex")
1077
+ .slice(0, 12);
1078
+ const base = `/tmp/.uai-browser-apt-${digest}`;
1079
+ return { done: `${base}.done`, lock: `${base}.lock` };
1080
+ }
1081
+
1082
+ /** What a setup pass established. */
1083
+ export interface BrowserSetup {
1084
+ /** The browser is installed and will launch. Retry while false. */
1085
+ ready: boolean;
1086
+ /** Every required engine has a usable browser MCP definition. */
1087
+ configured: boolean;
1088
+ /** A config had to be rewritten, so running agents hold a stale one. */
1089
+ changed: boolean;
1090
+ }
1091
+
1092
+ /**
1093
+ * Wire the browser for a task.
1094
+ *
1095
+ * Reports readiness so the caller can retry a failed install instead of
1096
+ * leaving live sessions pointed at a browser that will never launch, and
1097
+ * reports whether a config was rewritten so they can be recycled onto it.
1098
+ */
1099
+ export async function setupBrowserTesting(
1100
+ taskId: string,
1101
+ containerName: string,
1102
+ hasCodex: boolean,
1103
+ codexHomes: readonly string[] = hasCodex ? [DEFAULT_CODEX_HOME] : [],
1104
+ environment?: Pick<TaskEnvironmentHandle, "exec" | "launchDetachedSession"> &
1105
+ Partial<Pick<TaskEnvironmentHandle, "descriptor">>,
1106
+ /** The roster's agent kinds: cursor and grok get the browser server too
1107
+ * (2026-09-08 — until then only Claude/Codex were wired and both answered
1108
+ * "no browser MCP in this session" on the Studio). */
1109
+ engineKinds: readonly string[] = [],
1110
+ /** Every GROK_HOME the task may run under (default + account homes). */
1111
+ grokHomes: readonly string[] = [DEFAULT_GROK_HOME],
1112
+ ): Promise<BrowserSetup> {
1113
+ const hasCursor = engineKinds.includes("cursor");
1114
+ const hasGrok = engineKinds.includes("grok");
1115
+ const selectedGrokHomes = hasGrok
1116
+ ? [...new Set(grokHomes.length > 0 ? grokHomes : [DEFAULT_GROK_HOME])]
1117
+ : [];
1118
+ // Machine sessions run under MACHINE_RUNTIME_AUTHORITY_ENV, so every setup
1119
+ // exec here must too — the prewarm in particular. Warming npx under the
1120
+ // CONTAINER authority (private NPM_CONFIG_CACHE) leaves the session-default
1121
+ // npm cache cold, and Codex's 10s MCP startup deadline then kills the
1122
+ // browser (and every npx-launched server) for the session's whole lifetime
1123
+ // while Claude's 30s survives — live 2026-08-28, first codex machine task.
1124
+ const authorityEnv =
1125
+ isEnvironmentTransportProvider(environment?.descriptor?.locator.provider)
1126
+ ? MACHINE_RUNTIME_AUTHORITY_ENV
1127
+ : RUNTIME_AUTHORITY_ENV;
1128
+ let ready = false;
1129
+ let configured = false;
1130
+ let changed = false;
1131
+ // The no-handle fallbacks below exec by container name. The passed name is
1132
+ // the Docker Compose replica shape; on the Apple backend the container has
1133
+ // the provider's fixed name and the `docker` binary does not exist (live
1134
+ // 2026-08-18: every fallback died with spawn docker ENOENT, which is why
1135
+ // sessions never saw their browser MCP tools). Normalize both here.
1136
+ const container = taskContainerBackend().apple
1137
+ ? taskAppContainerName(taskId)
1138
+ : containerName;
1139
+ try {
1140
+ const selectedCodexHomes = hasCodex
1141
+ ? [...new Set(codexHomes.length > 0 ? codexHomes : [DEFAULT_CODEX_HOME])]
1142
+ : [];
1143
+ if (
1144
+ selectedCodexHomes.some(
1145
+ (home) =>
1146
+ home !== DEFAULT_CODEX_HOME &&
1147
+ !/^\/home\/node\/\.codex-acct-[A-Za-z0-9_-]+$/.test(home),
1148
+ )
1149
+ ) {
1150
+ throw new Error("invalid Codex account home");
1151
+ }
1152
+
1153
+ // The viewer stack restarts whenever it died (container restart, crash) —
1154
+ // outside the marker guard on purpose. No-op until its packages install.
1155
+ // ONE exec per daemon: the single-line nohup shape is the only one that
1156
+ // reliably survives `docker exec -d`.
1157
+ for (const step of VIEWER_STEPS) {
1158
+ if (environment) {
1159
+ await environment.launchDetachedSession({
1160
+ projectEnvProfile: "maintenance",
1161
+ argv: ["sh", "-c", step],
1162
+ env: authorityEnv,
1163
+ maxOutputBytes: 64 * 1024,
1164
+ launchTimeoutMs: 10_000,
1165
+ });
1166
+ } else {
1167
+ await taskContainerCli(
1168
+ [
1169
+ "exec",
1170
+ "-d",
1171
+ ...runtimeAuthorityDockerExecArgs(),
1172
+ container,
1173
+ "sh",
1174
+ "-c",
1175
+ step,
1176
+ ],
1177
+ { timeoutMs: 10_000 },
1178
+ );
1179
+ }
1180
+ }
1181
+
1182
+ // Browser readiness — AWAITED, and deliberately OUTSIDE the marker guard.
1183
+ //
1184
+ // Awaited because the caller runs this before spawning agents, so this is
1185
+ // the one place a slow install is merely slow. Detaching it (as this used
1186
+ // to) hands the download to whoever hits the browser first, and the only
1187
+ // candidate is the MCP server's own startup — which has a hard client
1188
+ // deadline of 10s (Codex) / 30s (Claude Code). A cold host would lose its
1189
+ // first session every time.
1190
+ //
1191
+ // Outside the guard because it is the only thing here that can be undone
1192
+ // from outside: the volume is host-wide, so another container's Playwright
1193
+ // can evict our build mid-task. Re-running it is how that heals — and it
1194
+ // costs ~0.5s once the build is present.
1195
+ const installed = environment
1196
+ ? await (async () => {
1197
+ const chown = await environment.exec({
1198
+ projectEnvProfile: "maintenance",
1199
+ argv: ["/usr/bin/chown", "node:node", "/opt/pw-browsers"],
1200
+ user: "root",
1201
+ env: authorityEnv,
1202
+ timeoutMs: 5_000,
1203
+ maxOutputBytes: 64 * 1024,
1204
+ });
1205
+ if (chown.exitCode !== 0) {
1206
+ // Same non-fatal stance as the CLI path below.
1207
+ console.warn(
1208
+ `[browser] task ${taskId}: chown /opt/pw-browsers failed (status ${chown.exitCode}); ` +
1209
+ `continuing with the prewarm. ${Buffer.from(chown.stderr).toString("utf8").slice(0, 200)}`,
1210
+ );
1211
+ }
1212
+ return environment.exec({
1213
+ projectEnvProfile: "maintenance",
1214
+ argv: ["sh", "-lc", PREWARM_CMD],
1215
+ cwd: "/workspace",
1216
+ env: authorityEnv,
1217
+ timeoutMs: INSTALL_TIMEOUT_MS,
1218
+ maxOutputBytes: 256 * 1024,
1219
+ });
1220
+ })()
1221
+ : await taskContainerCli(
1222
+ [
1223
+ "exec",
1224
+ "-u",
1225
+ "root",
1226
+ ...runtimeAuthorityDockerExecArgs(),
1227
+ container,
1228
+ "/usr/bin/chown",
1229
+ "node:node",
1230
+ "/opt/pw-browsers",
1231
+ ],
1232
+ { timeoutMs: 5_000 },
1233
+ ).then(async (chown) => {
1234
+ if (chown.status !== 0) {
1235
+ // Not fatal. On apple-container /opt/pw-browsers is a host-wide
1236
+ // virtiofs volume whose ownership the guest cannot change (EPERM)
1237
+ // although it is already node-owned inside the container; the
1238
+ // prewarm below is the real test of writability, so let it decide.
1239
+ console.warn(
1240
+ `[browser] task ${taskId}: chown /opt/pw-browsers failed (status ${chown.status}); ` +
1241
+ `continuing with the prewarm. ${chown.stderr.slice(0, 200)}`,
1242
+ );
1243
+ }
1244
+ return taskContainerCli(
1245
+ [
1246
+ "exec",
1247
+ "-w",
1248
+ "/workspace",
1249
+ ...runtimeAuthorityDockerExecArgs(),
1250
+ container,
1251
+ "sh",
1252
+ "-lc",
1253
+ PREWARM_CMD,
1254
+ ],
1255
+ { timeoutMs: INSTALL_TIMEOUT_MS },
1256
+ );
1257
+ });
1258
+ const installedStatus =
1259
+ "exitCode" in installed ? installed.exitCode : installed.status;
1260
+ const installedStderr =
1261
+ "stderr" in installed && installed.stderr instanceof Uint8Array
1262
+ ? Buffer.from(installed.stderr).toString("utf8")
1263
+ : typeof installed.stderr === "string"
1264
+ ? installed.stderr
1265
+ : "";
1266
+ ready = installedStatus === 0;
1267
+ if (!ready) {
1268
+ // Agents still spawn — a task without a browser beats no task — but the
1269
+ // caller retries on this, and it is logged loudly, because the symptom
1270
+ // downstream (a browser that won't launch) reads as anything but an
1271
+ // install that quietly failed up here.
1272
+ console.warn(
1273
+ `[browser] task ${taskId}: browser install failed (status ${installedStatus}); ` +
1274
+ `see ${INSTALL_LOG} in the container. ${installedStderr.slice(0, 200)}`,
1275
+ );
1276
+ }
1277
+
1278
+ // Configs are re-asserted on EVERY call — no marker gate.
1279
+ //
1280
+ // A marker records what we once wrote, which is a different claim from
1281
+ // what is on disk now. Recovery/start credential injection and account
1282
+ // provisioning can replace config.toml before this serialized boundary
1283
+ // (live auth refresh now excludes it). The marker lives in /workspace and
1284
+ // survives those copies, so a gated setup could skip the rewrite and
1285
+ // report ready while Codex had no browser at all. The migration is cheap
1286
+ // and byte-idempotent, so the honest fix is to re-assert.
1287
+ const writeTimeoutMs =
1288
+ (10 + Math.max(45, 15 + selectedCodexHomes.length * 12) + 5 + 10) *
1289
+ 1_000;
1290
+ const writeEnv = {
1291
+ ...authorityEnv,
1292
+ UAI_BROWSER_DEF: JSON.stringify(SERVER_DEF),
1293
+ UAI_MCP_PATH: MCP_CONFIG_PATH,
1294
+ UAI_CLAUDE_SETTINGS_PATH: CLAUDE_SETTINGS_PATH,
1295
+ UAI_CLAUDE_SETTINGS_DEF: CLAUDE_SETTINGS_JSON,
1296
+ ...(selectedCodexHomes.length > 0
1297
+ ? { UAI_CODEX_HOMES: JSON.stringify(selectedCodexHomes) }
1298
+ : {}),
1299
+ ...(hasCursor ? { UAI_CURSOR_MCP_PATH: CURSOR_MCP_PATH } : {}),
1300
+ };
1301
+ const writeArgv = [
1302
+ "flock",
1303
+ "-w",
1304
+ "10",
1305
+ MCP_CONFIG_LOCK_PATH,
1306
+ "timeout",
1307
+ "--kill-after=5",
1308
+ String(Math.max(45, 15 + selectedCodexHomes.length * 12)),
1309
+ "node",
1310
+ "-e",
1311
+ MIGRATE_JS,
1312
+ ] as [string, ...string[]];
1313
+ const wrote = environment
1314
+ ? await environment.exec({
1315
+ projectEnvProfile: "maintenance",
1316
+ argv: writeArgv,
1317
+ cwd: "/workspace",
1318
+ env: writeEnv,
1319
+ timeoutMs: writeTimeoutMs,
1320
+ maxOutputBytes: 256 * 1024,
1321
+ })
1322
+ : await taskContainerCli(
1323
+ [
1324
+ "exec",
1325
+ "-w",
1326
+ "/workspace",
1327
+ "-e",
1328
+ `UAI_BROWSER_DEF=${JSON.stringify(SERVER_DEF)}`,
1329
+ "-e",
1330
+ `UAI_MCP_PATH=${MCP_CONFIG_PATH}`,
1331
+ "-e",
1332
+ `UAI_CLAUDE_SETTINGS_PATH=${CLAUDE_SETTINGS_PATH}`,
1333
+ "-e",
1334
+ `UAI_CLAUDE_SETTINGS_DEF=${CLAUDE_SETTINGS_JSON}`,
1335
+ ...(selectedCodexHomes.length > 0
1336
+ ? ["-e", `UAI_CODEX_HOMES=${JSON.stringify(selectedCodexHomes)}`]
1337
+ : []),
1338
+ ...(hasCursor ? ["-e", `UAI_CURSOR_MCP_PATH=${CURSOR_MCP_PATH}`] : []),
1339
+ ...runtimeAuthorityDockerExecArgs(),
1340
+ container,
1341
+ ...writeArgv,
1342
+ ],
1343
+ { timeoutMs: writeTimeoutMs },
1344
+ );
1345
+ const wroteStatus = "exitCode" in wrote ? wrote.exitCode : wrote.status;
1346
+ const wroteStdout =
1347
+ "stdout" in wrote && wrote.stdout instanceof Uint8Array
1348
+ ? Buffer.from(wrote.stdout).toString("utf8")
1349
+ : typeof wrote.stdout === "string"
1350
+ ? wrote.stdout
1351
+ : "";
1352
+ const wroteStderr =
1353
+ "stderr" in wrote && wrote.stderr instanceof Uint8Array
1354
+ ? Buffer.from(wrote.stderr).toString("utf8")
1355
+ : typeof wrote.stderr === "string"
1356
+ ? wrote.stderr
1357
+ : "";
1358
+ if (wroteStatus !== 0) {
1359
+ console.warn(
1360
+ `[browser] task ${taskId}: MCP config write failed: ${wroteStderr.slice(0, 300)}`,
1361
+ );
1362
+ } else {
1363
+ const resultLine = wroteStdout
1364
+ .split(/\r?\n/)
1365
+ .find((line) => line.startsWith(CONFIG_RESULT_TOKEN));
1366
+ if (!resultLine) {
1367
+ console.warn(
1368
+ `[browser] task ${taskId}: MCP config write returned no result`,
1369
+ );
1370
+ } else {
1371
+ try {
1372
+ const result = JSON.parse(
1373
+ resultLine.slice(CONFIG_RESULT_TOKEN.length),
1374
+ ) as {
1375
+ configured: boolean;
1376
+ changed: boolean;
1377
+ notes?: string[];
1378
+ };
1379
+ configured = result.configured === true;
1380
+ changed = result.changed === true;
1381
+ if (result.notes?.length) {
1382
+ console.warn(`[browser] task ${taskId}: ${result.notes.join("; ")}`);
1383
+ }
1384
+ } catch (err) {
1385
+ console.warn(
1386
+ `[browser] task ${taskId}: invalid MCP config result`,
1387
+ err,
1388
+ );
1389
+ }
1390
+ }
1391
+ }
1392
+
1393
+ // Grok registers through its own read-modify-write CLI (user scope,
1394
+ // see grokBrowserAddCommand), under the same lock as gateway reconciliation
1395
+ // so two successful CLI processes cannot silently lose each other's entry.
1396
+ // A failure leaves the task configured-false so the next ensure retries.
1397
+ if (hasGrok) {
1398
+ const grokArgv = [
1399
+ "flock",
1400
+ "-w",
1401
+ "10",
1402
+ MCP_CONFIG_LOCK_PATH,
1403
+ "sh",
1404
+ "-lc",
1405
+ selectedGrokHomes.map(grokBrowserAddCommand).join(" && "),
1406
+ ] as [string, ...string[]];
1407
+ const registered = environment
1408
+ ? await environment.exec({
1409
+ projectEnvProfile: "maintenance",
1410
+ argv: grokArgv,
1411
+ cwd: "/workspace",
1412
+ env: authorityEnv,
1413
+ timeoutMs: 30_000,
1414
+ maxOutputBytes: 64 * 1024,
1415
+ })
1416
+ : await taskContainerCli(
1417
+ [
1418
+ "exec",
1419
+ "-w",
1420
+ "/workspace",
1421
+ ...runtimeAuthorityDockerExecArgs(),
1422
+ container,
1423
+ ...grokArgv,
1424
+ ],
1425
+ { timeoutMs: 30_000 },
1426
+ );
1427
+ const grokStatus =
1428
+ "exitCode" in registered ? registered.exitCode : registered.status;
1429
+ if (grokStatus !== 0) {
1430
+ configured = false;
1431
+ console.warn(
1432
+ `[browser] task ${taskId}: grok browser MCP registration failed (exit ${String(grokStatus)})`,
1433
+ );
1434
+ }
1435
+ }
1436
+
1437
+ // Chromium apt libs + the viewer stack packages, then start the stack.
1438
+ // The lock is a container-local flock acquired BEFORE detach. The worker
1439
+ // inherits its open file description, so failures and even SIGKILL release
1440
+ // it in the kernel and the next ensure can retry; no stale mkdir lock can
1441
+ // strand the container forever.
1442
+ //
1443
+ // `install-deps` stays on floating `playwright@latest` on purpose: it
1444
+ // installs apt SYSTEM LIBRARIES (libnss3, libatk, …), which are shared
1445
+ // across Chromium builds and carry no build number, so they cannot
1446
+ // participate in the mismatch MCP_PKG exists to prevent. It also touches
1447
+ // no browser binaries, so it cannot GC the shared volume. Pinning it
1448
+ // would mean a second version constant to keep in sync — reintroducing
1449
+ // exactly the drift class this change removes.
1450
+ const aptGuard = aptGuardPaths();
1451
+ const viewerSetup = VIEWER_STEPS.map((step) => `( ${step} )`).join("; ");
1452
+ const aptWorker = [
1453
+ "set -e",
1454
+ APT_DEPS_CMD,
1455
+ APT_VIEWER_CMD,
1456
+ // Close lock fd 9 before starting long-lived viewer grandchildren, or
1457
+ // they inherit it and keep the apt lock forever after this worker exits.
1458
+ `/usr/bin/su -s /bin/sh node -c ${shellQuote(viewerSetup)} 9>&-`,
1459
+ "/usr/bin/chown -R node:node /home/node/.npm 2>/dev/null || true",
1460
+ `/usr/bin/touch ${aptGuard.done}`,
1461
+ ].join("; ");
1462
+ const guardedAptStart = [
1463
+ `[ -f ${aptGuard.done} ] && exit 0`,
1464
+ `exec 9>${aptGuard.lock}`,
1465
+ "/usr/bin/flock -n 9 || exit 0",
1466
+ // A different worker may have completed between the first check and
1467
+ // this lock acquisition.
1468
+ `[ -f ${aptGuard.done} ] && exit 0`,
1469
+ `/usr/bin/nohup /bin/sh -c ${shellQuote(aptWorker)} 9>&9 </dev/null ` +
1470
+ ">/tmp/uai-pw-deps.log 2>&1 &",
1471
+ ].join("; ");
1472
+
1473
+ const rootWorkerEnv = {
1474
+ ...authorityEnv,
1475
+ NPM_CONFIG_CACHE: "/tmp/uai-root-npm-cache",
1476
+ npm_config_cache: "/tmp/uai-root-npm-cache",
1477
+ NPM_CONFIG_LOGS_DIR: "/tmp/uai-root-npm-cache/_logs",
1478
+ npm_config_logs_dir: "/tmp/uai-root-npm-cache/_logs",
1479
+ };
1480
+ if (environment) {
1481
+ await environment.exec({
1482
+ projectEnvProfile: "maintenance",
1483
+ argv: ["/bin/sh", "-c", guardedAptStart],
1484
+ user: "root",
1485
+ cwd: "/workspace",
1486
+ env: rootWorkerEnv,
1487
+ timeoutMs: 10_000,
1488
+ maxOutputBytes: 64 * 1024,
1489
+ });
1490
+ } else {
1491
+ await taskContainerCli(
1492
+ [
1493
+ "exec",
1494
+ "-u",
1495
+ "root",
1496
+ // HOME=/home/node is for asdf version resolution ONLY — without the
1497
+ // cache redirect below, root's npx writes ROOT-OWNED entries into
1498
+ // node's ~/.npm and breaks the node user's own npx (which launches
1499
+ // the MCP server). Found live 2026-07-08.
1500
+ "-w",
1501
+ "/workspace",
1502
+ ...runtimeAuthorityDockerExecArgs(),
1503
+ // These root-worker exceptions must follow the common authority args:
1504
+ // Docker/npm use the final value when a spelling is repeated. Keep
1505
+ // both cases aligned so npm's case-insensitive config lookup cannot
1506
+ // fall back to node's private cache or logs directory.
1507
+ "-e",
1508
+ "NPM_CONFIG_CACHE=/tmp/uai-root-npm-cache",
1509
+ "-e",
1510
+ "npm_config_cache=/tmp/uai-root-npm-cache",
1511
+ "-e",
1512
+ "NPM_CONFIG_LOGS_DIR=/tmp/uai-root-npm-cache/_logs",
1513
+ "-e",
1514
+ "npm_config_logs_dir=/tmp/uai-root-npm-cache/_logs",
1515
+ container,
1516
+ "/bin/sh",
1517
+ "-c",
1518
+ guardedAptStart,
1519
+ ],
1520
+ { timeoutMs: 10_000 },
1521
+ );
1522
+ }
1523
+ console.log(
1524
+ `[browser] task ${taskId}: Playwright MCP wired (browser ${ready ? "ready" : "NOT ready"}; apt deps backgrounded)`,
1525
+ );
1526
+ return { ready, configured, changed };
1527
+ } catch (err) {
1528
+ // Best-effort by design — a task without a browser still runs.
1529
+ console.warn(`[browser] task ${taskId}: setup failed`, err);
1530
+ return { ready, configured, changed };
1531
+ }
1532
+ }
1533
+
1534
+ /** Single-quote for `sh -c` (POSIX-safe). */
1535
+ function shellQuote(text: string): string {
1536
+ return `'${text.replace(/'/g, `'\\''`)}'`;
1537
+ }