@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.
- package/LICENSE +21 -0
- package/README.md +149 -2
- package/bin/uai-host.mjs +130 -0
- package/db/migrations/0000_host_tasks.sql +12 -0
- package/db/migrations/0001_host_ui.sql +11 -0
- package/db/migrations/0002_host_github_tokens.sql +8 -0
- package/db/migrations/0003_host_ssh_keys.sql +8 -0
- package/db/migrations/0004_host_owner_name.sql +1 -0
- package/db/migrations/0005_host_env_vars.sql +5 -0
- package/db/migrations/0006_host_project_env.sql +9 -0
- package/db/migrations/0007_host_github_token_kind.sql +7 -0
- package/db/migrations/0008_host_mcp_connections.sql +18 -0
- package/db/migrations/0009_host_agent_sessions.sql +15 -0
- package/db/migrations/0010_host_engine_accounts.sql +13 -0
- package/db/migrations/0011_codex_thread_id.sql +5 -0
- package/db/migrations/0012_agent_session_attach_key.sql +4 -0
- package/db/migrations/0013_github_credential_generations.sql +11 -0
- package/db/migrations/0014_host_inventory_event_index.sql +1 -0
- package/db/migrations/0015_host_settings.sql +9 -0
- package/db/migrations/0016_task_environment.sql +2 -0
- package/db/migrations/0017_engine_account_scope.sql +7 -0
- package/db/migrations/0018_engine_account_position.sql +3 -0
- package/db/migrations/0019_engine_account_store_ref.sql +3 -0
- package/db/migrations/0020_user_credential_store_ref.sql +6 -0
- package/db/migrations/0021_project_env_store_ref.sql +3 -0
- package/db/migrations/0022_task_project_ids.sql +5 -0
- package/db/migrations/0023_project_env_org.sql +4 -0
- package/db/migrations/0024_task_env_mode.sql +9 -0
- package/db/migrations/0025_project_env_authority.sql +147 -0
- package/db/migrations/0026_project_env_replica.sql +150 -0
- package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/0029_task_shared_files_mode.sql +5 -0
- package/db/migrations/0030_credential_cleanup_restops.sql +12 -0
- package/db/migrations/0031_first_turn_receipts.sql +9 -0
- package/db/migrations/meta/_journal.json +230 -0
- package/db/schema.ts +871 -0
- package/images/standard/Dockerfile +347 -0
- package/images/standard/README.md +167 -0
- package/images/standard/container/code-server-settings.json +63 -0
- package/images/standard/container/corepack-version +1 -0
- package/images/standard/container/uai-init +704 -0
- package/images/standard/container/uai-materialize-runtimes +1527 -0
- package/images/standard/container/uai-task-forwarder.mjs +303 -0
- package/images/standard/tool-versions +2 -0
- package/lib/__fixtures__/skill-index/SKILL.md +10 -0
- package/lib/__fixtures__/skill-index/skills/accessibility-checklist.md +7 -0
- package/lib/__fixtures__/skill-index/skills/brand-voice.md +9 -0
- package/lib/__fixtures__/skill-index/skills/design-tokens.md +9 -0
- package/lib/agent-cli.ts +1507 -0
- package/lib/agent-errors.ts +22 -0
- package/lib/agent.ts +672 -0
- package/lib/agents/attach-compatibility.ts +51 -0
- package/lib/agents/claude.ts +918 -0
- package/lib/agents/codex-token-usage.fixture.json +130 -0
- package/lib/agents/codex.ts +1392 -0
- package/lib/agents/cursor-period-usage.ts +253 -0
- package/lib/agents/cursor.ts +562 -0
- package/lib/agents/dispatch.ts +78 -0
- package/lib/agents/durable-proc.ts +334 -0
- package/lib/agents/factory.ts +57 -0
- package/lib/agents/grok.ts +381 -0
- package/lib/agents/kimi.ts +329 -0
- package/lib/agents/machine-durable.ts +604 -0
- package/lib/agents/mock.ts +133 -0
- package/lib/agents/mode.ts +64 -0
- package/lib/agents/opencode.ts +508 -0
- package/lib/agents/output-source.ts +3 -0
- package/lib/agents/proc.ts +221 -0
- package/lib/agents/rate-limit.ts +337 -0
- package/lib/agents/registry.ts +182 -0
- package/lib/agents/session-state.ts +197 -0
- package/lib/agents/skill-index-preamble.fixture.ts +26 -0
- package/lib/agents/transport.ts +741 -0
- package/lib/agents/types.ts +254 -0
- package/lib/agents/uai.ts +106 -0
- package/lib/agents/usage.ts +44 -0
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +237 -0
- package/lib/apple-uninstall-teardown.ts +224 -0
- package/lib/assignment-evidence.ts +94 -0
- package/lib/attachments.ts +48 -0
- package/lib/browser-testing.ts +1537 -0
- package/lib/capture-flow.ts +332 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2587 -0
- package/lib/cloud-credential-runtime.ts +1357 -0
- package/lib/cloud-state.ts +56 -0
- package/lib/codex-auth.ts +456 -0
- package/lib/command-db.ts +310 -0
- package/lib/compose-network-recovery.ts +283 -0
- package/lib/connection-secret-redaction.ts +25 -0
- package/lib/container-runtime.ts +1470 -0
- package/lib/credential-cleanup-inventory.ts +490 -0
- package/lib/credential-consumers/engine-anchor.ts +1880 -0
- package/lib/credential-consumers/engines.ts +797 -0
- package/lib/credential-consumers/files.ts +934 -0
- package/lib/credential-consumers/github.ts +702 -0
- package/lib/credential-consumers/mcp.ts +389 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-consumers/task-secrets.ts +10 -0
- package/lib/credential-store-pickup.ts +149 -0
- package/lib/credential-store-scope.ts +112 -0
- package/lib/credential-store.ts +932 -0
- package/lib/credentials-activation-authority.ts +709 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/custom-connection-capture.ts +395 -0
- package/lib/custom-connection-launch.ts +384 -0
- package/lib/custom-connection-runtime.ts +141 -0
- package/lib/db.ts +80 -0
- package/lib/disk-pressure.ts +42 -0
- package/lib/docker-exec.ts +156 -0
- package/lib/engine-accounts.ts +1970 -0
- package/lib/engine-connect.ts +321 -0
- package/lib/engine-credential-env.ts +52 -0
- package/lib/engine-login.ts +2933 -0
- package/lib/engines.ts +1342 -0
- package/lib/enrollment-state.ts +251 -0
- package/lib/env-file.ts +246 -0
- package/lib/env-schema.ts +937 -0
- package/lib/env.ts +195 -0
- package/lib/first-turn-receipts.ts +62 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +530 -0
- package/lib/git-identity.ts +715 -0
- package/lib/github-git-auth.ts +207 -0
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +2439 -0
- package/lib/host-cloud-url.ts +62 -0
- package/lib/host-config.ts +279 -0
- package/lib/host-env.ts +424 -0
- package/lib/host-logs.ts +965 -0
- package/lib/internal-connections.ts +5 -0
- package/lib/keyed-promise-tail.ts +23 -0
- package/lib/launch-mode.ts +25 -0
- package/lib/lease.ts +239 -0
- package/lib/legacy-host.ts +769 -0
- package/lib/legacy-runtime-retirement.ts +424 -0
- package/lib/legacy-runtime-v1.fixture.ts +627 -0
- package/lib/machine-exec.ts +238 -0
- package/lib/machine-gateway-tunnel.ts +344 -0
- package/lib/machine-keys.ts +65 -0
- package/lib/machine-project-env-loader.ts +152 -0
- package/lib/machine-provider-aws.ts +923 -0
- package/lib/machine-provider-local.ts +338 -0
- package/lib/machine-provider.ts +90 -0
- package/lib/managed-activation-watcher.ts +72 -0
- package/lib/managed-install-owner-watcher.ts +55 -0
- package/lib/managed-operation-drain.ts +57 -0
- package/lib/managed-runtime.ts +4296 -0
- package/lib/managed-update-scheduler.ts +125 -0
- package/lib/mcp-capture.ts +865 -0
- package/lib/mcp-config-lock.ts +2 -0
- package/lib/mcp-connections.ts +959 -0
- package/lib/mcp-gateway.ts +2594 -0
- package/lib/obs.ts +512 -0
- package/lib/openrouter-budget.ts +247 -0
- package/lib/orchestrator.ts +11409 -0
- package/lib/person-capture.ts +327 -0
- package/lib/preview-ports.ts +85 -0
- package/lib/preview-sidecar.ts +331 -0
- package/lib/project-env-authority.ts +1319 -0
- package/lib/project-env-feature.ts +74 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-loader.ts +242 -0
- package/lib/project-env-replica.ts +2094 -0
- package/lib/project-env-schema-capture.ts +342 -0
- package/lib/project-env-staging.ts +148 -0
- package/lib/project-env-task-policy.ts +176 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/release-feed.ts +61 -0
- package/lib/release-manifest.ts +710 -0
- package/lib/release-trust.ts +46 -0
- package/lib/repo-clone.ts +38 -0
- package/lib/routing/shadow.ts +261 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +279 -0
- package/lib/runtime-archive.ts +1216 -0
- package/lib/runtime-authority.ts +166 -0
- package/lib/runtime-guard.ts +36 -0
- package/lib/runtime-provider-state.ts +169 -0
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +1033 -0
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/address.ts +95 -0
- package/lib/secrets-proxy/audit.ts +30 -0
- package/lib/secrets-proxy/ca-store.ts +200 -0
- package/lib/secrets-proxy/ca.ts +470 -0
- package/lib/secrets-proxy/container-steps.ts +138 -0
- package/lib/secrets-proxy/der.ts +273 -0
- package/lib/secrets-proxy/engine.ts +1633 -0
- package/lib/secrets-proxy/host.ts +93 -0
- package/lib/secrets-proxy/hostname.ts +57 -0
- package/lib/secrets-proxy/placeholders.ts +196 -0
- package/lib/secrets-proxy/policy.ts +150 -0
- package/lib/secrets-proxy/readiness.ts +148 -0
- package/lib/secrets-proxy/runtime.ts +582 -0
- package/lib/secrets-proxy/scrub.ts +559 -0
- package/lib/secrets-proxy/sidecar-view.ts +139 -0
- package/lib/secrets-proxy/task-scripts.ts +262 -0
- package/lib/secrets-proxy/task-trust.ts +605 -0
- package/lib/secrets-proxy/trust-watch.ts +80 -0
- package/lib/secrets.ts +71 -0
- package/lib/shared-files-s3.ts +976 -0
- package/lib/shared-files.ts +156 -0
- package/lib/sidecar-assign.ts +49 -0
- package/lib/sidecar-guard.ts +58 -0
- package/lib/sidecar-resume.ts +178 -0
- package/lib/sidecar.ts +69 -0
- package/lib/signin-box-credentials.ts +465 -0
- package/lib/signin-box.ts +114 -0
- package/lib/skill-index-example.fixture.ts +56 -0
- package/lib/skill-layout.ts +417 -0
- package/lib/skills.ts +489 -0
- package/lib/ssh.ts +401 -0
- package/lib/standard-image-cleanup.ts +117 -0
- package/lib/standard-image-pin.ts +207 -0
- package/lib/standard-image.ts +1697 -0
- package/lib/stopped-task-status-queue.ts +120 -0
- package/lib/task-container-cli.ts +269 -0
- package/lib/task-credential-cleanup.ts +752 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +91 -0
- package/lib/task-env-forwarder.ts +228 -0
- package/lib/task-env-source.ts +249 -0
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +860 -0
- package/lib/task-environment/docker.ts +1344 -0
- package/lib/task-environment/index.ts +466 -0
- package/lib/task-environment/legacy-adoption.ts +467 -0
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +1227 -0
- package/lib/task-environment/machine.ts +974 -0
- package/lib/task-environment/registry.ts +59 -0
- package/lib/task-environment/self-identity.ts +20 -0
- package/lib/task-environment/self.ts +566 -0
- package/lib/task-environment/types.ts +642 -0
- package/lib/task-environment/workspace-files.ts +685 -0
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-files-machine-retirement-outbox.ts +592 -0
- package/lib/task-files-machine-retirement.ts +116 -0
- package/lib/task-files-readiness.ts +127 -0
- package/lib/task-gateway-port.ts +22 -0
- package/lib/task-gateway-protocol.ts +190 -0
- package/lib/task-gateway-recovery.ts +47 -0
- package/lib/task-gateway-state.ts +512 -0
- package/lib/task-identity.ts +19 -0
- package/lib/task-inventory.ts +682 -0
- package/lib/task-machine-credentials-activation.ts +95 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/task-person-credentials.ts +76 -0
- package/lib/task-project-ops-host.ts +147 -0
- package/lib/task-project-ops.ts +1062 -0
- package/lib/task-secret-staging.ts +16 -0
- package/lib/task-status.ts +46 -0
- package/lib/task-token.ts +46 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +105 -0
- package/lib/token-broker-local.ts +392 -0
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +525 -0
- package/lib/token-broker.ts +218 -0
- package/lib/token-lanes/openai-oauth.ts +123 -0
- package/lib/token-lanes/registry.ts +45 -0
- package/lib/token-lanes/types.ts +163 -0
- package/lib/token-lanes/xai-oauth.ts +165 -0
- package/lib/transcript.ts +128 -0
- package/lib/tunnel-registry.ts +220 -0
- package/lib/tunnel-runtime.ts +235 -0
- package/lib/ulid.ts +16 -0
- package/lib/user-bus-env.ts +116 -0
- package/lib/user-credentials-store.ts +435 -0
- package/lib/vnode-pressure.ts +142 -0
- package/package.json +115 -8
- package/runner/runner.mjs +442 -0
- package/scripts/agent/_common.sh +633 -0
- package/scripts/agent/env-hook.sh +626 -0
- package/scripts/agent/moondeck-cli.sh +5 -0
- package/scripts/agent/task-down.sh +291 -0
- package/scripts/agent/task-status.sh +70 -0
- package/scripts/agent/task-up.sh +3761 -0
- package/scripts/agent/uai-env-exec.sh +26 -0
- package/scripts/install/darwin.ts +1048 -0
- package/scripts/install/linux.ts +958 -0
- package/scripts/install/types.ts +78 -0
- package/scripts/install/util.ts +254 -0
- package/scripts/install/win.ts +143 -0
- package/src/activation-respawn.ts +155 -0
- package/src/apple-tunnel-route.ts +104 -0
- package/src/bridge-diagnostics.ts +39 -0
- package/src/capture-wire.ts +102 -0
- package/src/cli.ts +2585 -0
- package/src/cloud-credentials.ts +2144 -0
- package/src/cloud-origins.ts +23 -0
- package/src/credential-location.ts +248 -0
- package/src/credential-wire-validation.ts +25 -0
- package/src/custom-connections.ts +237 -0
- package/src/engine-account-paths.ts +11 -0
- package/src/engine-models.ts +39 -0
- package/src/engine-usage.ts +104 -0
- package/src/enrollment-next-steps.ts +24 -0
- package/src/event-outbox.ts +185 -0
- package/src/index.ts +2907 -0
- package/src/load-env.ts +97 -0
- package/src/machine-tunnel-route.ts +156 -0
- package/src/main.ts +6363 -0
- package/src/never-proxied-domains.ts +31 -0
- package/src/outbound-frame.ts +31 -0
- package/src/paths.ts +81 -0
- package/src/project-env-latest.ts +130 -0
- package/src/project-env-policy.ts +396 -0
- package/src/project-env-wording.ts +53 -0
- package/src/protocol.ts +5550 -0
- package/src/runtime-bootstrap.ts +165 -0
- package/src/station-identity.ts +96 -0
- package/src/task-env-names.ts +28 -0
- package/src/task-secrets.ts +75 -0
- package/src/tunnel-dial.ts +102 -0
- package/src/ui/server.ts +852 -0
- package/src/ui/types.ts +173 -0
- package/src/workspace-path-policy.ts +41 -0
- package/ui/app.js +1102 -0
- package/ui/index.html +90 -0
- package/ui/style.css +685 -0
- package/ui/uai-favicon.svg +21 -0
- package/ui/uai-logo-black.svg +9 -0
- package/ui/uai-wheel.svg +9 -0
- 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
|
+
}
|