diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04efa2023..a4d5caa4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,10 +52,15 @@ jobs: with: node-version: 24 cache: pnpm - - name: Install package validation tools - run: sudo apt-get update && sudo apt-get install -y desktop-file-utils xvfb dbus-x11 + - name: Install package validation and native smoke tools + run: >- + sudo apt-get update && sudo apt-get install -y + at-spi2-core dbus-x11 desktop-file-utils libxi6 libxkbcommon0 squashfs-tools xvfb - run: pnpm install --frozen-lockfile - - run: pnpm package:linux + - name: Stage the pinned CUA runtime + run: pnpm build:cua:linux + - name: Package from the verified offline CUA stage + run: pnpm package:linux:offline - run: node scripts/verify-linux-package.mjs - name: Configure Chromium sandbox for the unpacked app run: | @@ -63,7 +68,20 @@ jobs: sudo chmod 4755 release/linux-unpacked/chrome-sandbox test "$(stat -c '%U:%G %a' release/linux-unpacked/chrome-sandbox)" = "root:root 4755" - name: Launch packaged app and verify lifecycle - run: dbus-run-session -- xvfb-run -a node scripts/smoke-linux-package.mjs + env: + OMB_KEEP_SMOKE_DIR: "1" + run: pnpm smoke:linux-package + - name: Upload smoke diagnostics on failure + uses: actions/upload-artifact@v4 + if: failure() + with: + name: openmausbot-ubuntu-smoke-diagnostics + path: | + ${{ runner.temp }}/omb-linux-smoke-* + ${{ runner.temp }}/omb-linux-smoke-runtime-* + if-no-files-found: warn + include-hidden-files: true + retention-days: 7 - uses: actions/upload-artifact@v4 if: always() with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bbf715ad6..421028213 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -101,8 +101,19 @@ The SPI in [`server/contracts.ts`](server/contracts.ts) is deliberately small. A - Renderer code must consume the desktop capability contract rather than infer support from Electron, the user agent, or the presence of a preload bridge. Screen preview, dictation, and local control are independent capabilities. -- Test Ubuntu platform claims on a real GNOME session. Xvfb proves packaging and lifecycle, not Wayland - portal behavior or local computer control. +- Test Ubuntu platform claims on a real GNOME session. Xvfb proves packaging and fake-driver orchestration, not + Wayland portal behavior or real CUA inspection/input delivery. +- Linux local control must remain explicit: global opt-in plus per-bot **This computer**. Linux Auto, provider + full-auto/bypass modes, remembered grants, and cloud approvals must never authorize the user's desktop. +- Keep CUA discovery shell-free and pin accepted archive, inner-file, manifest, and driver contracts. Packaged Linux + builds must prefer their reviewed outside-ASAR runtime and fail closed instead of executing ambient PATH code; + source/dev builds may use the validated explicit/user-local paths. Never add a runtime downloader/self-updater or + silently install GNOME extensions. GNOME/Wayland readiness must require its exact compositor/helper/portal health + contract; never infer it from `WAYLAND_DISPLAY` or XWayland. +- Native release changes must update the checked-in Cua license report/SBOM, preserve MIT/OFL/MPL notices, pass the + malicious-archive tests, and prove identical hashes in `linux-unpacked`, `.deb`, and AppImage artifacts. AppImage + must additionally prove post-copy hashing in its private `0700` execution stage; never weaken the general path + validator to accept a root-owned group-writable SquashFS path when its toolchain emits `0775` rather than `0755`. - **Never build command strings for a shell.** No `shell: true`, no spawning through `cmd.exe` with quoted strings — model names, personas, and MCP config JSON travel through argv, and cmd.exe metacharacter expansion is a real injection class. On Windows, resolve `.cmd` shims to their JS diff --git a/README.md b/README.md index cff4d0c0b..dd574ccca 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,8 @@ already have: custom CLI binary (a versioned build or wrapper) in **Settings → Engines**. - **Local first.** One small harness server on `127.0.0.1` owns every agent process. Transcripts, keys, and events live in `~/.openmausbot`, not a cloud. -- **Agents with hands.** Each bot can get a real computer — a cloud Linux desktop it drives while you watch - live, or your own Mac — plus 500+ apps through Composio. +- **Agents with hands.** Each bot can use a cloud Linux desktop, an isolated Local VM, or your own computer, + plus 500+ apps through Composio. Host control is available on macOS and as an explicit Ubuntu GNOME beta. ## Features @@ -173,7 +173,7 @@ flowchart LR | API | `server/index.ts` | Bots, turns, approvals, model catalog, computer lifecycle, connectors, config — HTTP + SSE. | | Voice | `server/tts/` | ElevenLabs, bring your own key. Runs on the harness so the key never reaches the UI; markdown is rewritten into something worth hearing before it is spoken. | | App | `src/` | The chat shell. Server-backed store, one reducer, zero client-side transports. | -| Desktop | `electron/` | macOS, Windows, and Ubuntu shells with an embedded harness and explicit platform capabilities; Apple speech, local screen capture, and the current CUA bridge remain macOS-only. | +| Desktop | `electron/` | macOS, Windows, and Ubuntu shells with an embedded harness and platform capabilities; Apple speech stays macOS-only, while a release-pinned bundled CUA runtime enables guarded Ubuntu GNOME local control. | ## Quick start @@ -208,7 +208,7 @@ Package the desktop application: ```sh pnpm package:mac # macOS: DMG + ZIP; requires Swift/Xcode tools pnpm package:win # Windows: installer + ZIP -pnpm package:linux # Ubuntu x64: .deb + AppImage; no Swift required +pnpm package:linux # Ubuntu x64: .deb + AppImage + verified CUA runtime ``` ### Desktop capability status @@ -217,12 +217,24 @@ pnpm package:linux # Ubuntu x64: .deb + AppImage; no Swift required |---|---|---|---| | Packaged app, embedded harness, local agent CLIs | Supported | Beta | Beta | | Composio and Box/cloud computers | Supported | Beta | Beta | -| Local screen preview and computer control | Supported | Planned | Planned after compositor validation | +| Explicit preview-only local screen capture | Supported | Beta | Beta | +| Bot control of this computer | Supported | Beta: opt-in, bundled Cua 0.19.3 | Beta: GNOME only, opt-in, bundled Cua 0.19.3; separately installed WinRects v8 helper | | Native on-device dictation | Supported | Planned | Planned | -Unavailable native features fail closed on Ubuntu without blocking chat or cloud features. Linux local computer -control, Wayland capture/automation, dictation, and ARM64 are tracked in -[#29](https://github.com/milind-soni/OpenMausBot/issues/29) and are not claimed by the baseline package. +The Linux preview is user-initiated and never enables local bot control or Auto routing. Packaged Linux builds ship +the exact Cua Driver 0.19.3 runtime outside ASAR; control still requires explicit app opt-in and an explicit per-bot +**This computer** selection, and every local action asks for approval. GNOME/Wayland additionally requires the +versioned WinRects v8 helper and a +passing prompt-free AT-SPI/capture/portal health report. Other Wayland compositors fail closed without blocking +chat or cloud features. See the [Ubuntu Desktop guide](docs/linux-desktop.md) and +tracking issues [#29](https://github.com/milind-soni/OpenMausBot/issues/29) and +[#79](https://github.com/milind-soni/OpenMausBot/issues/79) / [#109](https://github.com/milind-soni/OpenMausBot/issues/109) / [#113](https://github.com/milind-soni/OpenMausBot/issues/113). + +The Linux packager downloads only the tag-pinned upstream archive during the build, verifies its size, SHA-256, +complete member allowlist, and inner executable hashes, then packages only the CLI and cursor-theme sidecar. The +installed app never downloads or self-updates native automation code. Cua's MIT notice, Inter's SIL OFL, a generated +third-party license report, and a CycloneDX inventory ship with the runtime. See +[`third_party/cua-driver/`](third_party/cua-driver/) for the reviewed provenance record. These credentials are optional — local chat works without them. Paste a key once in **App Settings** (gear in the sidebar footer) when you want to enable its integration: @@ -275,6 +287,10 @@ small; adding a provider is one file in [`server/drivers/`](server/drivers/) plu [MIT](LICENSE) © 2026 Milind Soni and contributors. +Packaged Cua Driver components retain their upstream MIT, SIL OFL 1.1, MPL-2.0, and other dependency terms; +the corresponding notices, license texts, source locations, and SBOM are in +[`third_party/cua-driver/`](third_party/cua-driver/) and ship beside the native runtime. + OpenMausBot is an independent, open-source project inspired by Grok Bot. It is not affiliated with, endorsed by, or associated with xAI; "Grok" is a trademark of its respective owner. diff --git a/docs/computer-use-integration.md b/docs/computer-use-integration.md index 66b031619..6a5ca05ae 100644 --- a/docs/computer-use-integration.md +++ b/docs/computer-use-integration.md @@ -1,16 +1,18 @@ # Computer use & browser use in OpenMausBot Decision doc, 2026-08-12. How bots in OpenMausBot get local computer use and -browser use, out of the box, with no separate installs. Based on a survey of -OSS chat-app MCP hosts, macOS control servers, browser-automation stacks, and -the local `cua` / `axstream` code on this machine. +browser use. macOS and packaged Ubuntu x64 builds use an out-of-the-box, +release-pinned provider; source/dev Ubuntu may use a separately installed provider. Based +on a survey of OSS chat-app MCP hosts, macOS control servers, +browser-automation stacks, and the local `cua` / `axstream` code on this +machine. ## TL;DR architecture -``` +```text Electron main process -├── EmbeddedCuaDriverHost ──spawns──▶ cua-driver (bundled Rust binary, Resources/) -│ one TCC prompt, named OpenMausBot │ unix socket (private) +├── CUA host ──spawns──▶ cua-driver (bundled on macOS and packaged Ubuntu x64) +│ platform permission boundary │ unix socket (private) ├── WebContentsView pool (embedded browser, persist: partitions per bot) │ driven via webContents.debugger (CDP) — zero-install browser use └── server/ harness (drivers spawn agent CLIs with --mcp-config) @@ -21,8 +23,11 @@ Electron main process - **Plugins = MCP servers over stdio.** The Plugins panel toggles which MCP servers get injected into each bot's `--mcp-config`. Same pattern as Claude Desktop / Cherry Studio / LibreChat. -- **Computer use = bundled `cua-driver`** (Rust, single static Mach-O, - 23MB arm64 / 48MB universal — from `mywork/cua/libs/cua-driver/rust`). +- **Local desktop use = `cua-driver`**. macOS packages the Rust Mach-O in app + Resources; Ubuntu x64 packages the certified 0.19.3 ELF plus its cursor-theme + sidecar outside ASAR. Both remain paired with the application release. This + applies to the Ubuntu 24.04 GNOME/Xorg beta and guarded GNOME/Wayland beta; + remote/cloud boxes and the isolated Local VM remain separate providers. NOT Swift — the Swift file everyone remembers (`examples/embedded-host-macos/ExampleAgentHarness.swift`) is a 165-line reference host showing the embedding pattern, not the driver. @@ -31,18 +36,29 @@ Electron main process `webContents.debugger` CDP transport. No Chrome dependency, no 281MB Playwright download, and the user watches the bot browse inside the chat. -## Computer use: CUA only — bundle cua-driver, spawn from Electron main +## Local desktop use: CUA only — Electron owns the driver lifecycle -**Decision (Milind, 2026-08-12): CUA is the ONLY computer-use provider. +**Decision (Milind, 2026-08-12): CUA is the ONLY local desktop-control provider. No cliclick, no robotjs/nut.js, no Python computer-server, no fallbacks.** -Everything that touches the user's screen/mouse/keyboard goes through the -bundled `cua-driver` binary. Alternatives evaluated and rejected: +All local desktop-control and input actions go through the validated +`cua-driver` binary. Linux screen preview uses the supported Xorg or +user-initiated XDG portal capture path and is not a control provider. This rule +does not replace remote/cloud boxes or the isolated Local VM provider. Local +alternatives evaluated and rejected: + +The Ubuntu GNOME beta uses the same official CUA provider with the Phase 5 +supply-chain contract tracked in [#113](https://github.com/milind-soni/OpenMausBot/issues/113): pinned archive +and inner hashes, exact archive allowlist, outside-ASAR resources, full notices/SBOM, no runtime download/update, +and fail-closed packaged discovery. Electron still owns a private embedded daemon/socket, and the harness only +receives the validated MCP proxy contract. Xorg is tracked in [#79](https://github.com/milind-soni/OpenMausBot/issues/79); +GNOME/Wayland additionally requires WinRects v8 plus the exact Cua health-report contract tracked in +[#109](https://github.com/milind-soni/OpenMausBot/issues/109). | Option | Verdict | | --- | --- | | cua `computer-server` (Python/FastAPI) | ✗ 200MB+ frozen Python, second TCC prompt under wrong identity | | axstream / cliclick / robotjs-class | ✗ rejected — CUA-only policy | -| **cua-driver binary, embedded mode** | ✓ THE provider: zero deps, 20+ tools, its own stdio MCP proxy + socket daemon + TS SDK (`@trycua/cua-driver`), agent-cursor overlay, permission tooling | +| **cua-driver binary, embedded mode** | ✓ THE provider: one contract, 20+ tools, its own stdio MCP proxy + socket daemon + TS SDK (`@trycua/cua-driver`), agent-cursor overlay, permission tooling | ### The rules (from `cua/libs/cua-driver/rust/Skills/cua-driver/EMBEDDING.md` — read it end to end) @@ -64,7 +80,7 @@ bundled `cua-driver` binary. Alternatives evaluated and rejected: grant change, destroy clients → `restart()` → reconnect (macOS caches TCC per process). -### Packaging +### macOS packaging target - Ship the binary at `OpenMausBot.app/Contents/Resources/cua-driver`, **outside the ASAR**, executable bit preserved (electron-builder diff --git a/docs/linux-desktop.md b/docs/linux-desktop.md index be829fdc7..71daadd20 100644 --- a/docs/linux-desktop.md +++ b/docs/linux-desktop.md @@ -11,11 +11,17 @@ of Linux desktop on your own server instead of this machine, see [byo-vps.md](by - Chat, streaming turns, approvals, bot-to-bot communication, and local data storage. - Composio connected apps and Box cloud computers. - External documentation and OAuth links in the default browser. - -The first beta intentionally does **not** claim Linux dictation, local screen preview, or control of this -computer. Those controls are unavailable in the UI and fail closed in the Electron and server layers. Use a -Cloud box when a bot needs a computer. Xorg computer control, Wayland validation, bundled CUA, dictation, and -ARM64 are follow-ups in [issue #29](https://github.com/milind-soni/OpenMausBot/issues/29). +- An explicit, view-only local screen preview on GNOME Xorg and GNOME Wayland. The Wayland path uses the + native portal chooser and keeps the selected PipeWire stream open until the user stops sharing. +- An explicit local-computer control beta on GNOME/Xorg and guarded GNOME/Wayland with bundled Cua Driver + 0.19.3 and an approval-capable Claude or ACP provider. + +The local preview does **not** give the bot control of this computer by itself. Local control is a separate, +off-by-default beta. Automatic Wayland helper installation, Linux dictation, and ARM64 remain unavailable and +fail closed; follow their progress in [issue #29](https://github.com/milind-soni/OpenMausBot/issues/29). Bundled +CUA supply-chain work is tracked in [issue #113](https://github.com/milind-soni/OpenMausBot/issues/113). Xorg is tracked in +[issue #79](https://github.com/milind-soni/OpenMausBot/issues/79), and guarded GNOME/Wayland support in +[issue #109](https://github.com/milind-soni/OpenMausBot/issues/109). ## Build packages @@ -106,13 +112,116 @@ Restart OpenMausBot after installing or signing in to a CLI. ## Xorg and Wayland -The baseline shell, chat, cloud computers, and connected apps work in both GNOME session types. OpenMausBot -detects Wayland before XWayland when both `WAYLAND_DISPLAY` and `DISPLAY` exist, so future capture features do -not accidentally bypass portal-mediated behavior. +The shell, chat, cloud computers, connected apps, and preview-only capture work in both GNOME session types. +The Wayland chooser/select/persistent-stream/cancel/end/retry lifecycle has been validated in a real Ubuntu +24.04 GNOME Wayland session. OpenMausBot detects Wayland before XWayland when both `WAYLAND_DISPLAY` and +`DISPLAY` exist, so capture cannot accidentally bypass portal-mediated behavior. + +Open the Computer panel and use the separate **Preview this computer** card. Capture never starts when the app +or panel opens. + +- **Xorg:** **Start preview** captures the primary monitor directly. +- **Wayland:** **Choose a screen** opens the GNOME portal chooser once. The selected stream stays open until + you press **Stop preview**, close the panel, end sharing from GNOME, or quit the app. + +Cancelling or ending Wayland sharing returns to a calm **Try again** state and never reopens the chooser +automatically. OpenMausBot does not capture screen audio, remember the selected monitor after restart, or +offer an **Open Settings** action on Linux. + +Local computer control is a separate opt-in. On Wayland, OpenMausBot recognizes only GNOME/Mutter and requires +the certified Cua health report to pass AT-SPI, portal capture, and the portal/libei input backend with verified +WinRects target activation. Other Wayland compositors remain unavailable. XWayland's `DISPLAY` never bypasses +these checks. + +## Enable local control + +Installed `.deb` and AppImage builds include the certified **Cua Driver 0.19.3** CLI and cursor-theme sidecar. +You do not need to install Cua separately for GNOME/Xorg. OpenMausBot starts its own private daemon only after +you enable the beta; it never starts, updates, or stops a global Cua daemon. + +The upstream release has no signature or GitHub artifact attestation and is not immutable, so the build uses an +explicit reviewed digest as its trust anchor: + +- source commit: `a1672e7b11951275ecfba3384264d4530185d0db`; +- archive SHA-256: `3db9d4257d84bacaf7eb104d225f85613ce67edbb20d6eeb83c1384b6d8a5b10`; +- packaged driver SHA-256: `ed5844fadf07b9b72c4a3b3802e1c47233c166d66d6198608d5991f807aab4ac`; +- packaged cursor-theme SHA-256: `e589b2b7521bbfeaf9e2bfce668a38e80ed1b9790b1327b13d374fc331d8312a`. + +Packaging verifies the exact archive size, checksum, member names/types/sizes, and inner hashes before extracting +only those two executables. The app performs no runtime driver download or self-update. Cua's MIT license, the +embedded Inter font's SIL OFL 1.1 notice, full dependency license texts, MPL source locations, and a CycloneDX +inventory ship beside the binary; the reviewed source records live in [`third_party/cua-driver`](../third_party/cua-driver/). +The reviewed native runtime adds roughly 11–13 MiB to a compressed Ubuntu artifact. The ELF +requires glibc 2.30 or newer plus the standard Ubuntu X11/XInput/xkbcommon libraries already present on the supported +Ubuntu 24.04 desktop; the package verifier executes the exact binary from every artifact layout. + +AppImage's pinned SquashFS toolchain can emit root-owned directories as `0755` or `0775`; the package verifier +requires one of those modes consistently across the reviewed resource tree. Because `0775` is correctly rejected by +the normal executable-path policy, AppImage launch always copies only the two pinned files into a fresh private +`0700` temporary directory, verifies both hashes after the copy, executes from there, and removes that directory on +quit. DEB and unpacked builds keep their package path at `0755` and execute directly. This exception does not relax +validation for an explicit override, `PATH`, or any other group-writable location. + +An explicit absolute `CUA_DRIVER_PATH` remains an advanced override for development and incident response. A +packaged app otherwise uses only its bundled driver and fails closed if it is missing, unsafe, changed, or +incompatible—it never silently executes `~/.local/bin/cua-driver` or a PATH candidate. Source/dev runs retain the +validated user-local discovery described by the [official Cua installation guide](https://cua.ai/docs/how-to-guides/driver/install). + +GNOME/Wayland still needs the privileged WinRects v8 Shell helper. OpenMausBot does not install or enable a Shell +extension silently. If it is not already active, download the same pinned archive, verify it, extract only the helper, +review its installer, and run it explicitly: + +```sh +version="0.19.3" +asset="cua-driver-rs-${version}-linux-x86_64-binary.tar.gz" +download_dir="$(mktemp -d)" +curl --fail --location --proto '=https' --tlsv1.2 \ + --output "$download_dir/$asset" \ + "https://github.com/trycua/cua/releases/download/cua-driver-rs-v${version}/$asset" +printf '%s %s\n' \ + '3db9d4257d84bacaf7eb104d225f85613ce67edbb20d6eeb83c1384b6d8a5b10' \ + "$download_dir/$asset" | sha256sum --check --strict +tar --extract --gzip --no-same-owner --no-same-permissions \ + --file "$download_dir/$asset" --directory "$download_dir" wayland-helper +sed -n '1,240p' "$download_dir/wayland-helper/install.sh" +"$download_dir/wayland-helper/install.sh" +``` + +Sign out and back in once, then verify that GNOME loaded exactly the expected helper: + +```sh +gnome-extensions info winrects@cua +``` -Local computer control remains disabled on both session types in this beta. Future Xorg support will require a -validated `cua-driver`; Wayland support will remain disabled until the exact GNOME/Mutter action surface has -real capture, input, scaling, permission, and lifecycle evidence. +The output must include `Version: 8`, `Enabled: Yes`, and `State: ACTIVE`. OpenMausBot never installs or enables +this GNOME extension silently. The helper exposes window identity, geometry, capture, cursor, and verified target +activation to Cua; foreground pointer or keyboard delivery remains scoped by GNOME's Remote Desktop portal and +may ask for session consent. + +Then: + +1. Open a bot's **Computer** panel. +2. In **Local control**, choose **Enable local control (Beta)** and review the warning. +3. Wait until the card shows **Ready**, including the verified driver path and version. +4. Select **This computer** for that bot. Enabling the global capability never assigns a bot automatically. + +Linux **Auto** never falls back to the user's desktop. **This computer** is available only when the current +provider advertises an interactive approval channel. Claude `bypassPermissions`, ACP full-auto, Codex's current +app-server adapter, non-GNOME/headless sessions, missing diagnostics, and stale/crashed runtimes fail closed. + +OpenMausBot starts one private embedded daemon with a private socket for its own app generation. It never touches +Cua's default/global daemon. On GNOME/Wayland, the app also rechecks the prompt-free health contract while the +runtime is active and revokes readiness if the helper or backend disappears. Disabling local control or quitting +stops the owned daemon and active proxies. + +The driver uses Cua's `standard` permission mode. Cua routine actions are promptless at the driver layer, while +OpenMausBot requires its own **Allow** or **Deny** decision before every local action. Bot Auto mode, persistent +**Always allow** grants, and cloud-computer approvals cannot authorize the local desktop in this beta. + +Cua Driver has content-free telemetry and an update check enabled by default. OpenMausBot disables both for every +Cua process it owns and does not change any separately installed Cua preferences. Driver updates arrive only with an +OpenMausBot application release; rolling back the app rolls back the paired driver. Review the upstream behavior in +the [official telemetry documentation](https://cua.ai/docs/reference/cua-driver/telemetry). ## Validate a package change @@ -120,15 +229,23 @@ real capture, input, scaling, permission, and lifecycle evidence. pnpm typecheck pnpm test pnpm check:electron -pnpm package:linux +pnpm build:cua:linux # networked, checksum-pinned staging +pnpm package:linux:offline # CUA staging is offline; builder caches must already be available node scripts/verify-linux-package.mjs -dbus-run-session -- xvfb-run -a node scripts/smoke-linux-package.mjs +pnpm smoke:linux-package ``` -The verifier checks `.deb` metadata, desktop identity, resources, artifact permissions, and the absence of -unsupported native binaries. The smoke test launches the unpacked production app without `--no-sandbox`, -validates the renderer/preload capabilities and embedded health endpoint, then proves clean shutdown. It is not -a substitute for manual testing on a real GNOME Xorg and Wayland desktop. +The verifier checks `.deb` metadata, desktop identity, the exact Cua resource tree and provenance, SquashFS/DEB +directory modes, runtime path policy, and matching binary hashes across all artifacts. The smoke test launches the +unpacked production app and AppImage without `--no-sandbox` and validates the renderer/preload, embedded health +endpoint, packaged bundled-driver resolution, strict MCP environment, and process cleanup. It starts the real +bundled driver under Xvfb/D-Bus to prove packaged launch, private-daemon readiness, and cleanup, then uses a fake +explicit override to prove diagnostics, +private-daemon readiness, crash invalidation, explicit retry, and clean shutdown in separate Xorg and simulated +GNOME/Wayland contract lanes. The Wayland lane also requires the opt-in environment and certified health report. +Its wrapper isolates the temporary D-Bus/AT-SPI runtime so it cannot replace the live desktop session's +accessibility socket. Real inspection, input actions, and portal behavior still require evidence from real GNOME +Xorg and GNOME Wayland sessions; the CI lanes are not a substitute for that evidence. ## Troubleshooting @@ -140,8 +257,54 @@ considered for automatic discovery. ### A bot needs computer tools -Choose **Cloud box** in the Computer panel and add a Box token in App Settings. **This computer** is disabled on -Linux until local CUA control is implemented and validated. +Choose **Cloud box** and add a Box token in App Settings, or complete the local-control opt-in above on a supported +GNOME session. A missing driver/helper, unsupported compositor or provider keeps **This computer** disabled with +an explanation. + +### Local control is not ready + +The in-app card is the primary diagnostic because packaged builds do not add Cua Driver to `PATH`. For a DEB +installation, run the bundled executable directly in a terminal launched inside the same GNOME session: + +```sh +echo "$XDG_SESSION_TYPE" # x11 or wayland +driver=/opt/OpenMausBot/resources/cua-linux-x64/cua-driver +export CUA_DRIVER_RS_UPDATE_CHECK=false +export CUA_DRIVER_RS_TELEMETRY_ENABLED=false +"$driver" --version # must be 0.19.3 for this beta +"$driver" doctor --json +``` + +For an AppImage, prefer the in-app diagnostic; its verified read-only mount path exists only while the app is +running. Maintainers testing an unpacked build can use +`release/linux-unpacked/resources/cua-linux-x64/cua-driver`. + +On Wayland, also run: + +```sh +echo "$XDG_CURRENT_DESKTOP" # must include GNOME +gnome-extensions info winrects@cua +CUA_DRIVER_RS_UPDATE_CHECK=false \ +CUA_DRIVER_RS_TELEMETRY_ENABLED=false \ +CUA_DRIVER_RS_ENABLE_WAYLAND=1 \ + /opt/OpenMausBot/resources/cua-linux-x64/cua-driver doctor --json +``` + +If the helper is installed but not `ACTIVE`, sign out and back in once. If the app reports a portal error, confirm +that `xdg-desktop-portal` and `xdg-desktop-portal-gnome` are running in the user session. OpenMausBot's readiness +probe never opens a consent prompt; GNOME may prompt when the first approved foreground input action starts. + +Repair any display, session bus, or AT-SPI diagnostic before choosing **Try again**. If the path shown in the app +is unexpected, close OpenMausBot and launch it with an absolute `CUA_DRIVER_PATH`. An invalid explicit override +fails without silently selecting another executable. For `unsafe-driver-permissions`, use the bounded +permission-hardening commands in **Enable local control**; do not make the driver executable or its +directories world-writable. + +### Screen preview does not start + +On Xorg, confirm the session has an active display with `echo "$XDG_SESSION_TYPE"`; it should print `x11`. +On Wayland, confirm `xdg-desktop-portal` and the GNOME portal backend are running, then click **Try again** to +open a new chooser. Cancelling or stopping sharing never causes an automatic second prompt. ### The AppImage does not start diff --git a/electron-builder.yml b/electron-builder.yml index 250019ecd..e1442861f 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -18,6 +18,10 @@ directories: buildResources: build output: release +# electron-builder makes copied directories group-writable by default. The +# Linux CUA trust boundary requires root-owned package ancestors to be 0755. +afterPack: ./scripts/after-pack.mjs + # The app is three self-contained pieces, none needing node_modules at # runtime: the Electron main/preload (plain .mjs/.cjs), the built UI # (Vite → ui/), and the compiled harness server (tsc → server/), both @@ -126,6 +130,11 @@ linux: maintainer: Milind Soni <46266943+milind-soni@users.noreply.github.com> vendor: OpenMausBot syncDesktopName: true + # The reviewed x64 CLI runtime is staged before packaging and remains + # outside ASAR so executable modes and adjacent sidecars are preserved. + extraResources: + - from: dist-native/cua-linux-x64 + to: cua-linux-x64 desktop: entry: Name: OpenMausBot diff --git a/electron/capabilities.cjs b/electron/capabilities.cjs index f919a999b..7812f52b5 100644 --- a/electron/capabilities.cjs +++ b/electron/capabilities.cjs @@ -8,22 +8,46 @@ function normalizedPlatform(platform) { return DESKTOP_PLATFORMS.has(platform) ? platform : "other"; } +function nativeDesktopActions(platform) { + const appleNative = normalizedPlatform(platform) === "darwin"; + return Object.freeze({ + appleMediaPermissions: appleNative, + applePrivacySettings: appleNative, + appleSpeech: appleNative, + }); +} + function linuxSession(platform, env) { if (platform !== "linux") return "unknown"; const declared = String(env.XDG_SESSION_TYPE ?? "").toLowerCase(); - if (declared === "wayland") return "wayland"; - if (declared === "x11" || declared === "xorg") return "x11"; // A Wayland user session may also expose DISPLAY for XWayland. Prefer the // Wayland signal so the UI never bypasses portal-mediated behavior. - if (env.WAYLAND_DISPLAY) return "wayland"; + if (declared === "wayland" || env.WAYLAND_DISPLAY) return "wayland"; + if (declared === "x11" || declared === "xorg") return "x11"; if (env.DISPLAY) return "x11"; return "headless"; } function localComputerReady(platform, connection) { + if (platform === "darwin") { + return connection?.mode === "embedded" || connection?.mode === "standalone"; + } + if ( + platform !== "linux" || + connection?.schemaVersion !== 1 || + connection?.platform !== "linux" || + connection?.enabled !== true || + connection?.status !== "ready" + ) { + return false; + } + if (connection.mode === "linux-x11-supervised") { + return connection.session === "x11"; + } return ( - platform === "darwin" && - (connection?.mode === "embedded" || connection?.mode === "standalone") + connection.mode === "linux-wayland-gnome-supervised" && + connection.session === "wayland" && + connection.compositor === "gnome-mutter" ); } @@ -36,12 +60,22 @@ function desktopCapabilities({ } = {}) { const hostPlatform = normalizedPlatform(platform); const isMac = hostPlatform === "darwin"; + const hostSession = linuxSession(hostPlatform, env); + const linuxPreview = hostPlatform === "linux" && hostSession !== "headless"; const localAvailable = localComputerReady(hostPlatform, localConnection); const screenPreview = { - available: isMac, - interaction: isMac ? "direct" : "none", + available: isMac || linuxPreview, + interaction: + isMac || hostSession === "x11" + ? "direct" + : hostSession === "wayland" + ? "portal-picker" + : "none", }; - if (!isMac) screenPreview.reasonCode = "unsupported-platform"; + if (!(isMac || linuxPreview)) { + screenPreview.reasonCode = + hostPlatform === "linux" ? "headless-session" : "unsupported-platform"; + } const dictation = { available: isMac, engine: isMac ? "apple-speech" : "none", @@ -50,11 +84,37 @@ function desktopCapabilities({ if (!isMac) dictation.reasonCode = "unsupported-platform"; const localComputer = { available: localAvailable, - support: localAvailable ? "supported" : "unsupported", + support: + localAvailable && hostPlatform === "linux" + ? "limited" + : localAvailable + ? "supported" + : "unsupported", + enabled: connectionEnabled(hostPlatform, localConnection), + status: localAvailable ? "ready" : localConnection?.status ?? "unavailable", }; + if (typeof localConnection?.message === "string") { + localComputer.message = localConnection.message; + } + if (typeof localConnection?.driver?.path === "string") { + localComputer.driverPath = localConnection.driver.path; + } + if (typeof localConnection?.driver?.version === "string") { + localComputer.driverVersion = localConnection.driver.version; + } + if (typeof localConnection?.driver?.source === "string") { + localComputer.driverSource = localConnection.driver.source; + } + if (typeof localConnection?.session === "string") { + localComputer.session = localConnection.session; + } + if (typeof localConnection?.compositor === "string") { + localComputer.compositor = localConnection.compositor; + } if (!localAvailable) { localComputer.reasonCode = - hostPlatform === "darwin" ? "cua-driver-unavailable" : "unsupported-platform"; + localConnection?.reasonCode ?? + (hostPlatform === "darwin" ? "cua-driver-unavailable" : "unsupported-platform"); } return { @@ -68,7 +128,7 @@ function desktopCapabilities({ : hostPlatform === "win32" ? "Windows" : "Desktop", - session: linuxSession(hostPlatform, env), + session: hostSession, packaged: Boolean(packaged), // so the renderer can show paths as ~/… without a Node builtin in // the sandboxed preload @@ -81,4 +141,15 @@ function desktopCapabilities({ }; } -module.exports = { desktopCapabilities, linuxSession, localComputerReady }; +function connectionEnabled(platform, connection) { + if (platform === "darwin") return localComputerReady(platform, connection); + return platform === "linux" && connection?.enabled === true; +} + +module.exports = { + connectionEnabled, + desktopCapabilities, + linuxSession, + localComputerReady, + nativeDesktopActions, +}; diff --git a/electron/capabilities.test.mjs b/electron/capabilities.test.mjs index a51f0f122..e19f5bbca 100644 --- a/electron/capabilities.test.mjs +++ b/electron/capabilities.test.mjs @@ -2,9 +2,28 @@ import { createRequire } from "node:module"; import { describe, expect, it } from "vitest"; const require = createRequire(import.meta.url); -const { desktopCapabilities, linuxSession, localComputerReady } = require("./capabilities.cjs"); +const { + desktopCapabilities, + linuxSession, + localComputerReady, + nativeDesktopActions, +} = require("./capabilities.cjs"); describe("desktop capabilities", () => { + it("keeps Apple permissions, Settings, and speech actions unreachable on Linux", () => { + expect(nativeDesktopActions("linux")).toEqual({ + appleMediaPermissions: false, + applePrivacySettings: false, + appleSpeech: false, + }); + expect(nativeDesktopActions("win32")).toEqual(nativeDesktopActions("linux")); + expect(nativeDesktopActions("darwin")).toEqual({ + appleMediaPermissions: true, + applePrivacySettings: true, + appleSpeech: true, + }); + }); + it("keeps macOS native features behind a ready CUA connection", () => { const capabilities = desktopCapabilities({ platform: "darwin", @@ -17,11 +36,11 @@ describe("desktop capabilities", () => { windowChrome: "mac-inset", screenPreview: { available: true, interaction: "direct" }, dictation: { available: true, engine: "apple-speech", onDevice: true }, - localComputer: { available: true, support: "supported" }, + localComputer: { available: true, support: "supported", enabled: true, status: "ready" }, }); }); - it.each(["linux", "win32", "freebsd"])("fails closed on %s", (platform) => { + it.each(["win32", "freebsd"])("fails closed on %s", (platform) => { const capabilities = desktopCapabilities({ platform, env: { DISPLAY: ":0" }, @@ -38,8 +57,40 @@ describe("desktop capabilities", () => { }); }); + it("offers direct Xorg preview without enabling local control", () => { + const capabilities = desktopCapabilities({ + platform: "linux", + env: { XDG_SESSION_TYPE: "x11", DISPLAY: ":0" }, + localConnection: { mode: "embedded" }, + }); + + expect(capabilities.screenPreview).toEqual({ available: true, interaction: "direct" }); + expect(capabilities.localComputer.available).toBe(false); + }); + + it("offers portal-mediated Wayland preview and fails closed when headless", () => { + expect( + desktopCapabilities({ + platform: "linux", + env: { XDG_SESSION_TYPE: "wayland", WAYLAND_DISPLAY: "wayland-0", DISPLAY: ":0" }, + }).screenPreview, + ).toEqual({ available: true, interaction: "portal-picker" }); + expect(desktopCapabilities({ platform: "linux", env: {} }).screenPreview).toEqual({ + available: false, + interaction: "none", + reasonCode: "headless-session", + }); + }); + it("detects Wayland before XWayland and distinguishes X11 and headless Linux", () => { expect(linuxSession("linux", { WAYLAND_DISPLAY: "wayland-0", DISPLAY: ":0" })).toBe("wayland"); + expect( + linuxSession("linux", { + XDG_SESSION_TYPE: "x11", + WAYLAND_DISPLAY: "wayland-0", + DISPLAY: ":0", + }), + ).toBe("wayland"); expect(linuxSession("linux", { XDG_SESSION_TYPE: "x11", DISPLAY: ":0" })).toBe("x11"); expect(linuxSession("linux", {})).toBe("headless"); }); @@ -49,4 +100,64 @@ describe("desktop capabilities", () => { expect(localComputerReady("darwin", { mode: "unavailable" })).toBe(false); expect(localComputerReady("darwin", { mode: "standalone" })).toBe(true); }); + + it("enables limited Linux control only for the complete supervised X11 contract", () => { + const connection = { + schemaVersion: 1, + mode: "linux-x11-supervised", + platform: "linux", + session: "x11", + enabled: true, + status: "ready", + driver: { + path: "/home/test/.local/bin/cua-driver", + version: "0.19.3", + source: "user-local", + }, + }; + expect( + desktopCapabilities({ + platform: "linux", + env: { XDG_SESSION_TYPE: "x11", DISPLAY: ":0" }, + localConnection: connection, + }).localComputer, + ).toMatchObject({ + available: true, + support: "limited", + enabled: true, + status: "ready", + driverVersion: "0.19.3", + driverSource: "user-local", + }); + expect(localComputerReady("linux", { ...connection, session: "wayland" })).toBe(false); + expect(localComputerReady("linux", { ...connection, status: "starting" })).toBe(false); + expect(localComputerReady("linux", { ...connection, schemaVersion: 2 })).toBe(false); + }); + + it("enables GNOME Wayland control only for the exact supervised contract", () => { + const connection = { + schemaVersion: 1, + mode: "linux-wayland-gnome-supervised", + platform: "linux", + session: "wayland", + compositor: "gnome-mutter", + enabled: true, + status: "ready", + }; + expect(localComputerReady("linux", connection)).toBe(true); + expect(localComputerReady("linux", { ...connection, compositor: undefined })).toBe(false); + expect(localComputerReady("linux", { ...connection, session: "x11" })).toBe(false); + expect( + desktopCapabilities({ + platform: "linux", + env: { XDG_SESSION_TYPE: "wayland", WAYLAND_DISPLAY: "wayland-0" }, + localConnection: connection, + }).localComputer, + ).toMatchObject({ + available: true, + support: "limited", + session: "wayland", + compositor: "gnome-mutter", + }); + }); }); diff --git a/electron/cua-connection.cjs b/electron/cua-connection.cjs index ed16944be..099bb3123 100644 --- a/electron/cua-connection.cjs +++ b/electron/cua-connection.cjs @@ -20,11 +20,31 @@ function createCuaConnectionStore({ fileSystem.mkdirSync(userData, { recursive: true }); const descriptorPath = path.join(userData, "cua-connection.json"); const temporaryPath = `${descriptorPath}.${processId}.${temporaryId()}.tmp`; + let handle; try { - fileSystem.writeFileSync(temporaryPath, JSON.stringify(next, null, 2)); + try { + fileSystem.chmodSync(userData, 0o700); + } catch { + // Windows does not expose meaningful POSIX directory modes. + } + handle = fileSystem.openSync(temporaryPath, "wx", 0o600); + fileSystem.writeFileSync(handle, `${JSON.stringify(next, null, 2)}\n`, "utf8"); + fileSystem.fsyncSync(handle); + fileSystem.closeSync(handle); + handle = undefined; fileSystem.renameSync(temporaryPath, descriptorPath); + try { + fileSystem.chmodSync(descriptorPath, 0o600); + } catch { + // Windows does not expose meaningful POSIX file modes. + } } catch (error) { + if (handle !== undefined) { + try { + fileSystem.closeSync(handle); + } catch {} + } try { fileSystem.unlinkSync(temporaryPath); } catch { diff --git a/electron/cua-connection.test.mjs b/electron/cua-connection.test.mjs index 245d0d028..81365dd87 100644 --- a/electron/cua-connection.test.mjs +++ b/electron/cua-connection.test.mjs @@ -39,6 +39,12 @@ describe("CUA connection persistence", () => { expect( fs.existsSync(path.join(userData, "cua-connection.json.123.test.tmp")), ).toBe(false); + // Keep replacement/rollback coverage on Windows, where Node accepts + // chmod but NTFS does not expose the requested POSIX mode bits. + if (process.platform !== "win32") { + expect(fs.statSync(path.join(userData, "cua-connection.json")).mode & 0o777).toBe(0o600); + expect(fs.statSync(userData).mode & 0o777).toBe(0o700); + } } finally { rmSync(userData, { recursive: true, force: true }); } diff --git a/electron/cua-linux-bundle.cjs b/electron/cua-linux-bundle.cjs new file mode 100644 index 000000000..66ba9faf0 --- /dev/null +++ b/electron/cua-linux-bundle.cjs @@ -0,0 +1,83 @@ +const { createHash } = require("node:crypto"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const STAGE_PREFIX = "openmausbot-cua-linux-x64-"; +const FILES = Object.freeze({ + "cua-driver": "ed5844fadf07b9b72c4a3b3802e1c47233c166d66d6198608d5991f807aab4ac", + "cua-cursor-theme": "e589b2b7521bbfeaf9e2bfce668a38e80ed1b9790b1327b13d374fc331d8312a", +}); + +function sha256(file, fileSystem = fs) { + return createHash("sha256").update(fileSystem.readFileSync(file)).digest("hex"); +} + +function stageAppImageCuaBundle({ + resourcesPath, + temporaryRoot = os.tmpdir(), + fileSystem = fs, + files = FILES, +} = {}) { + if (!path.isAbsolute(resourcesPath) || !path.isAbsolute(temporaryRoot)) { + throw new Error("AppImage CUA staging paths must be absolute"); + } + const sourceRoot = path.join(resourcesPath, "cua-linux-x64"); + const stageDirectory = fileSystem.mkdtempSync(path.join(temporaryRoot, STAGE_PREFIX)); + fileSystem.chmodSync(stageDirectory, 0o700); + try { + for (const [name, expectedHash] of Object.entries(files)) { + const source = path.join(sourceRoot, name); + const sourceDetails = fileSystem.lstatSync(source); + if (!sourceDetails.isFile() || sourceDetails.isSymbolicLink()) { + throw new Error(`AppImage CUA source must be a regular file: ${name}`); + } + const destination = path.join(stageDirectory, name); + fileSystem.copyFileSync(source, destination, fs.constants.COPYFILE_EXCL); + fileSystem.chmodSync(destination, 0o755); + const destinationDetails = fileSystem.lstatSync(destination); + if ( + !destinationDetails.isFile() || + destinationDetails.isSymbolicLink() || + (destinationDetails.mode & 0o777) !== 0o755 || + sha256(destination, fileSystem) !== expectedHash + ) { + throw new Error(`AppImage CUA staged file failed integrity validation: ${name}`); + } + } + return Object.freeze({ + directory: stageDirectory, + driverPath: path.join(stageDirectory, "cua-driver"), + }); + } catch (error) { + fileSystem.rmSync(stageDirectory, { recursive: true, force: true }); + throw error; + } +} + +function cleanupAppImageCuaBundle(stage, { + temporaryRoot = os.tmpdir(), + fileSystem = fs, +} = {}) { + const directory = stage?.directory; + if ( + !path.isAbsolute(directory ?? "") || + path.dirname(directory) !== path.resolve(temporaryRoot) || + !path.basename(directory).startsWith(STAGE_PREFIX) + ) { + throw new Error("refusing to clean an unexpected AppImage CUA stage"); + } + const details = fileSystem.lstatSync(directory, { throwIfNoEntry: false }); + if (!details) return; + if (!details.isDirectory() || details.isSymbolicLink()) { + throw new Error("refusing to clean a replaced AppImage CUA stage"); + } + fileSystem.rmSync(directory, { recursive: true, force: false }); +} + +module.exports = { + FILES, + STAGE_PREFIX, + cleanupAppImageCuaBundle, + stageAppImageCuaBundle, +}; diff --git a/electron/cua-linux-bundle.test.mjs b/electron/cua-linux-bundle.test.mjs new file mode 100644 index 000000000..4838d88bd --- /dev/null +++ b/electron/cua-linux-bundle.test.mjs @@ -0,0 +1,66 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createHash } from "node:crypto"; +import { afterEach, describe, expect, it } from "vitest"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { + FILES, + cleanupAppImageCuaBundle, + stageAppImageCuaBundle, +} = require("./cua-linux-bundle.cjs"); + +const temporaryDirectories = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +function fixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "omb-appimage-bundle-test-")); + temporaryDirectories.push(root); + const resourcesPath = path.join(root, "resources"); + const source = path.join(resourcesPath, "cua-linux-x64"); + fs.mkdirSync(source, { recursive: true, mode: 0o775 }); + return { root, resourcesPath, source }; +} + +describe.skipIf(process.platform === "win32")("AppImage CUA private staging", () => { + it("copies exact bytes to a private executable stage and removes only that stage", () => { + const { root, resourcesPath, source } = fixture(); + const files = {}; + for (const [name, bytes] of [ + ["cua-driver", Buffer.from("driver")], + ["cua-cursor-theme", Buffer.from("theme")], + ]) { + fs.writeFileSync(path.join(source, name), bytes); + files[name] = createHash("sha256").update(bytes).digest("hex"); + } + const stage = stageAppImageCuaBundle({ resourcesPath, temporaryRoot: root, files }); + expect(fs.lstatSync(stage.directory).mode & 0o777).toBe(0o700); + expect(fs.lstatSync(stage.driverPath).mode & 0o777).toBe(0o755); + cleanupAppImageCuaBundle(stage, { temporaryRoot: root }); + expect(fs.existsSync(stage.directory)).toBe(false); + expect(fs.existsSync(resourcesPath)).toBe(true); + }); + + it("rejects bytes that do not match the pinned runtime", () => { + const { root, resourcesPath, source } = fixture(); + for (const name of Object.keys(FILES)) fs.writeFileSync(path.join(source, name), "wrong"); + expect(() => stageAppImageCuaBundle({ resourcesPath, temporaryRoot: root })).toThrow( + "failed integrity validation", + ); + expect(fs.readdirSync(root).filter((name) => name.startsWith("openmausbot-cua"))).toEqual([]); + }); + + it("refuses cleanup outside its exact private stage namespace", () => { + const { root } = fixture(); + expect(() => + cleanupAppImageCuaBundle({ directory: root }, { temporaryRoot: path.dirname(root) }), + ).toThrow("unexpected AppImage CUA stage"); + }); +}); diff --git a/electron/cua-linux-runtime.cjs b/electron/cua-linux-runtime.cjs new file mode 100644 index 000000000..f77e59a03 --- /dev/null +++ b/electron/cua-linux-runtime.cjs @@ -0,0 +1,910 @@ +const { spawn } = require("node:child_process"); +const { randomUUID } = require("node:crypto"); +const fs = require("node:fs"); +const net = require("node:net"); +const os = require("node:os"); +const path = require("node:path"); +const { + CERTIFIED_DRIVER_VERSION, + CERTIFIED_MANIFEST_SCHEMA, + desktopCommandEnvironment, + inspectLinuxCuaDriver, + sameDriverFileIdentity, + validateDriverCandidate, +} = require("./cua-linux.cjs"); + +const CONNECTION_SCHEMA_VERSION = 1; +const SETTINGS_SCHEMA_VERSION = 1; +const HOST_BUNDLE_ID = "com.openmausbot.app"; +const CERTIFIED_CONTRACT_VERSION = "0.6.0"; +const CERTIFIED_TOOLS_LIST_SCHEMA_VERSION = "1"; +const CERTIFIED_CAPABILITY_VERSION = "1"; +const CERTIFIED_MCP_PROTOCOL_VERSION = "2025-06-18"; +const REQUIRED_TOOLS = ["click", "get_window_state", "list_apps", "type_text"]; +const REQUIRED_WAYLAND_HEALTH_CHECKS = [ + "ax_capability", + "screen_capture_capability", + "wayland_backend", +]; + +function ensurePrivateDirectory(directory, fileSystem = fs, currentUid = process.getuid?.() ?? os.userInfo().uid) { + fileSystem.mkdirSync(directory, { recursive: true, mode: 0o700 }); + const stat = fileSystem.lstatSync(directory); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw Object.assign(new Error(`Private CUA path is not a directory: ${directory}`), { + code: "unsafe-runtime-directory", + }); + } + if (stat.uid !== currentUid && stat.uid !== 0) { + throw Object.assign(new Error(`Private CUA directory has an unexpected owner: ${directory}`), { + code: "unsafe-runtime-directory", + }); + } + fileSystem.chmodSync(directory, 0o700); + return directory; +} + +function cleanupStaleRuntimeDirectories(root, { + fileSystem = fs, + currentUid = process.getuid?.() ?? os.userInfo().uid, + isProcessAlive = (pid) => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code !== "ESRCH"; + } + }, +} = {}) { + let entries; + try { + entries = fileSystem.readdirSync(root, { withFileTypes: true }); + } catch { + return 0; + } + let removed = 0; + for (const entry of entries) { + const match = /^([1-9][0-9]*)-([0-9a-f]{8}-[0-9a-f]{3})$/.exec(entry.name); + if (!match || !entry.isDirectory() || entry.isSymbolicLink()) continue; + const ownerPid = Number(match[1]); + if (!Number.isSafeInteger(ownerPid) || isProcessAlive(ownerPid)) continue; + const directory = path.join(root, entry.name); + try { + const directoryStat = fileSystem.lstatSync(directory); + if ( + !directoryStat.isDirectory() || + directoryStat.isSymbolicLink() || + directoryStat.uid !== currentUid || + (directoryStat.mode & 0o077) !== 0 + ) { + continue; + } + const children = fileSystem.readdirSync(directory, { withFileTypes: true }); + if (children.some((child) => !["driver.pid", "driver.sock"].includes(child.name))) continue; + const removable = []; + let safe = true; + for (const child of children) { + const childPath = path.join(directory, child.name); + const childStat = fileSystem.lstatSync(childPath); + const expectedType = + child.name === "driver.pid" ? childStat.isFile() : childStat.isSocket(); + if (childStat.isSymbolicLink() || childStat.uid !== currentUid || !expectedType) { + safe = false; + break; + } + removable.push(childPath); + } + if (!safe) continue; + for (const childPath of removable) fileSystem.unlinkSync(childPath); + fileSystem.rmdirSync(directory); + removed += 1; + } catch { + // Runtime cleanup is best-effort and strictly scoped. A suspicious or + // concurrently changing entry is left untouched for manual inspection. + } + } + return removed; +} + +function writePrivateJson(file, value, { + fileSystem = fs, + temporaryId = randomUUID, + processId = process.pid, +} = {}) { + const directory = ensurePrivateDirectory(path.dirname(file), fileSystem); + const temporary = path.join(directory, `.${path.basename(file)}.${processId}.${temporaryId()}.tmp`); + let descriptor; + try { + descriptor = fileSystem.openSync(temporary, "wx", 0o600); + fileSystem.writeFileSync(descriptor, `${JSON.stringify(value, null, 2)}\n`, "utf8"); + fileSystem.fsyncSync(descriptor); + fileSystem.closeSync(descriptor); + descriptor = undefined; + fileSystem.renameSync(temporary, file); + fileSystem.chmodSync(file, 0o600); + const directoryHandle = fileSystem.openSync(directory, "r"); + try { + try { + fileSystem.fsyncSync(directoryHandle); + } catch { + // The file itself is already flushed and atomically renamed. Some + // otherwise-supported filesystems reject fsync on a directory. + } + } finally { + fileSystem.closeSync(directoryHandle); + } + } catch (error) { + if (descriptor !== undefined) { + try { + fileSystem.closeSync(descriptor); + } catch {} + } + try { + fileSystem.unlinkSync(temporary); + } catch {} + throw error; + } +} + +function createLinuxCuaPreferenceStore({ getUserData, fileSystem = fs } = {}) { + const preferencePath = () => path.join(getUserData(), "cua-local-control.json"); + return Object.freeze({ + read() { + try { + const stat = fileSystem.lstatSync(preferencePath()); + if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) return false; + const value = JSON.parse(fileSystem.readFileSync(preferencePath(), "utf8")); + return ( + value?.schemaVersion === SETTINGS_SCHEMA_VERSION && + value?.linuxLocalControlEnabled === true && + Object.keys(value).every((key) => + ["schemaVersion", "linuxLocalControlEnabled"].includes(key), + ) + ); + } catch { + return false; + } + }, + write(enabled) { + writePrivateJson( + preferencePath(), + { schemaVersion: SETTINGS_SCHEMA_VERSION, linuxLocalControlEnabled: Boolean(enabled) }, + { fileSystem }, + ); + }, + }); +} + +function requestSocket(socketPath, request, { timeoutMs = 1_000, maxBytes = 256 * 1024 } = {}) { + return new Promise((resolve, reject) => { + const socket = net.createConnection(socketPath); + let settled = false; + let data = Buffer.alloc(0); + const finish = (fn, value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + socket.destroy(); + fn(value); + }; + socket.once("connect", () => socket.write(`${JSON.stringify(request)}\n`)); + socket.on("data", (chunk) => { + if (data.length + chunk.length > maxBytes) { + finish(reject, Object.assign(new Error("Cua Driver handshake was too large."), { code: "handshake-too-large" })); + return; + } + data = Buffer.concat([data, chunk]); + const newline = data.indexOf(0x0a); + if (newline === -1) return; + try { + finish(resolve, JSON.parse(data.subarray(0, newline).toString("utf8"))); + } catch { + finish(reject, Object.assign(new Error("Cua Driver returned an invalid handshake."), { code: "invalid-handshake" })); + } + }); + socket.once("error", (error) => finish(reject, error)); + const timer = setTimeout( + () => finish(reject, Object.assign(new Error("Cua Driver handshake timed out."), { code: "handshake-timeout" })), + timeoutMs, + ); + timer.unref?.(); + }); +} + +function validateDaemonMetadata(response, { childPid } = {}) { + const metadata = response?.ok === true ? response.result : null; + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) { + throw Object.assign(new Error("Cua Driver daemon identity could not be verified."), { + code: "invalid-daemon-metadata", + }); + } + const expected = { + driver_version: CERTIFIED_DRIVER_VERSION, + contract_version: CERTIFIED_CONTRACT_VERSION, + tools_list_schema_version: CERTIFIED_TOOLS_LIST_SCHEMA_VERSION, + capability_version: CERTIFIED_CAPABILITY_VERSION, + mcp_protocol_version: CERTIFIED_MCP_PROTOCOL_VERSION, + embedded: true, + host_bundle_id: HOST_BUNDLE_ID, + }; + for (const [key, value] of Object.entries(expected)) { + if (metadata[key] !== value) { + throw Object.assign(new Error(`Cua Driver daemon reported an incompatible ${key}.`), { + code: "incompatible-daemon", + }); + } + } + if (!Number.isInteger(metadata.pid) || metadata.pid <= 0 || (childPid && metadata.pid !== childPid)) { + throw Object.assign(new Error("Cua Driver daemon PID does not match the owned process."), { + code: "invalid-daemon-metadata", + }); + } + return metadata; +} + +function validateToolSurface(response) { + const manifest = response?.ok === true ? response.result : null; + if ( + !manifest || + typeof manifest !== "object" || + Array.isArray(manifest) || + manifest.schema_version !== CERTIFIED_TOOLS_LIST_SCHEMA_VERSION || + manifest.capability_version !== CERTIFIED_CAPABILITY_VERSION || + !Array.isArray(manifest.tools) + ) { + throw Object.assign(new Error("Cua Driver tool surface could not be verified."), { + code: "invalid-tool-surface", + }); + } + const tools = manifest.tools; + const names = new Set(tools.map((tool) => tool?.name).filter((name) => typeof name === "string")); + const missing = REQUIRED_TOOLS.filter((name) => !names.has(name)); + if (missing.length) { + throw Object.assign(new Error(`Cua Driver is missing required tools: ${missing.join(", ")}.`), { + code: "incompatible-tool-surface", + }); + } + return [...names].sort(); +} + +function healthFailure(check) { + const detail = `${check?.message ?? ""} ${check?.hint ?? ""}`.toLowerCase(); + if (check?.name === "ax_capability") { + return Object.assign( + new Error("Cua Driver could not reach the AT-SPI accessibility bus in this Wayland session."), + { code: "at-spi-unavailable" }, + ); + } + if (check?.name === "screen_capture_capability") { + return Object.assign( + new Error("Cua Driver could not reach a supported screen capture backend in this Wayland session."), + { code: "wayland-capture-unavailable" }, + ); + } + if (detail.includes("winrects") || detail.includes("target-activation")) { + return Object.assign( + new Error("The Cua WinRects helper is not active. Install it, then sign out and back in once."), + { code: "wayland-helper-required" }, + ); + } + if (detail.includes("portal")) { + return Object.assign( + new Error("The GNOME Remote Desktop portal is not available for local control."), + { code: "wayland-portal-unavailable" }, + ); + } + return Object.assign( + new Error("Cua Driver could not verify the GNOME Wayland control backend."), + { code: "wayland-health-failed" }, + ); +} + +function validateWaylandHealthReport(response) { + const result = response?.ok === true ? response.result : null; + const report = result?.structuredContent ?? result?.structured_content; + if ( + !report || + typeof report !== "object" || + Array.isArray(report) || + report.schema_version !== "1" || + report.platform !== "linux" || + report.driver_version !== CERTIFIED_DRIVER_VERSION || + !["ok", "degraded", "failed"].includes(report.overall) || + !Array.isArray(report.checks) + ) { + throw Object.assign(new Error("Cua Driver returned an invalid Wayland health report."), { + code: "invalid-health-report", + }); + } + const checks = new Map(); + for (const check of report.checks) { + if ( + !check || + typeof check !== "object" || + Array.isArray(check) || + typeof check.name !== "string" || + !["pass", "fail", "skip"].includes(check.status) || + typeof check.message !== "string" || + (check.status === "fail" && typeof check.hint !== "string") || + checks.has(check.name) + ) { + throw Object.assign(new Error("Cua Driver returned an invalid Wayland health check."), { + code: "invalid-health-report", + }); + } + checks.set(check.name, check); + } + for (const name of REQUIRED_WAYLAND_HEALTH_CHECKS) { + const check = checks.get(name); + if (check?.status !== "pass") throw healthFailure(check ?? { name }); + } + if (report.overall !== "ok") { + const failed = [...checks.values()].find((check) => check.status === "fail"); + throw healthFailure(failed); + } + return Object.freeze({ + schemaVersion: report.schema_version, + overall: report.overall, + requiredChecks: Object.freeze([...REQUIRED_WAYLAND_HEALTH_CHECKS]), + }); +} + +async function probeWaylandHealth(socketPath, { request = requestSocket } = {}) { + return validateWaylandHealthReport( + await request(socketPath, { method: "call", name: "health_report", args: {} }), + ); +} + +async function probePrivateDaemon(socketPath, { + childPid, + session = "x11", + timeoutMs = 10_000, + request = requestSocket, +} = {}) { + const deadline = Date.now() + timeoutMs; + let lastError = null; + while (Date.now() < deadline) { + try { + const metadata = validateDaemonMetadata( + await request(socketPath, { method: "metadata" }), + { childPid }, + ); + const tools = validateToolSurface(await request(socketPath, { method: "list" })); + const health = + session === "wayland" ? await probeWaylandHealth(socketPath, { request }) : undefined; + return { metadata, tools, ...(health ? { health } : {}) }; + } catch (error) { + lastError = error; + await new Promise((resolve) => setTimeout(resolve, 75)); + } + } + throw Object.assign(new Error(lastError?.message ?? "Cua Driver daemon did not become ready."), { + code: lastError?.code ?? "daemon-start-timeout", + }); +} + +function waitForChildExit(child, timeoutMs) { + const exited = + (child.exitCode !== null && child.exitCode !== undefined) || + (child.signalCode !== null && child.signalCode !== undefined); + if (exited) return Promise.resolve(true); + return new Promise((resolve) => { + let settled = false; + const finish = (value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(value); + }; + child.once("exit", () => finish(true)); + const timer = setTimeout(() => finish(false), timeoutMs); + timer.unref?.(); + }); +} + +async function stopOwnedChild(child) { + if (!child) return; + try { + child.stdin?.end(); + } catch {} + if (await waitForChildExit(child, 2_000)) return; + try { + child.kill("SIGTERM"); + } catch {} + if (await waitForChildExit(child, 1_000)) return; + try { + child.kill("SIGKILL"); + } catch {} + await waitForChildExit(child, 500); +} + +function publicRuntimeStatus(connection) { + return { + enabled: connection.enabled === true, + status: + connection.status ?? + (["linux-x11-supervised", "linux-wayland-gnome-supervised"].includes(connection.mode) + ? "ready" + : "unavailable"), + reasonCode: connection.reasonCode, + message: connection.message ?? connection.reason, + driverPath: connection.driver?.path, + driverVersion: connection.driver?.version, + driverSource: connection.driver?.source, + session: connection.session, + compositor: connection.compositor, + warnings: connection.doctorWarnings ?? [], + }; +} + +function createUnavailableLinuxRuntime({ + connectionStore, + onChange = () => {}, + processId = process.pid, + reasonCode = "bundled-driver-invalid", + message = "The bundled Cua Driver failed integrity validation.", +} = {}) { + const connection = { + schemaVersion: CONNECTION_SCHEMA_VERSION, + mode: "unavailable", + platform: "linux", + enabled: false, + status: "unavailable", + reasonCode, + message, + ownerPid: processId, + }; + try { + connectionStore?.persist(connection); + } catch (error) { + console.error("[cua] Failed to persist unavailable Linux runtime state:", error); + } + onChange(connection); + + return Object.freeze({ + async initialize() { + return connection; + }, + async enable() { + return connection; + }, + async retry() { + return connection; + }, + async disable() { + return connection; + }, + async shutdown() { + return connection; + }, + getConnection() { + return connection; + }, + getStatus() { + return publicRuntimeStatus(connection); + }, + }); +} + +function createLinuxCuaRuntime({ + getUserData, + connectionStore, + preferenceStore = createLinuxCuaPreferenceStore({ getUserData }), + platform = process.platform, + arch = process.arch, + env = process.env, + bundledDriverPath, + inspect = inspectLinuxCuaDriver, + spawnProcess = spawn, + probe = probePrivateDaemon, + healthProbe = probeWaylandHealth, + healthCheckIntervalMs = 30_000, + setRecurring = setInterval, + clearRecurring = clearInterval, + identifier = randomUUID, + processId = process.pid, + onChange = () => {}, +} = {}) { + let active = null; + let startPromise = null; + let enabled = false; + let quitting = false; + let connection = { + schemaVersion: CONNECTION_SCHEMA_VERSION, + mode: "unavailable", + platform: "linux", + session: String(env.XDG_SESSION_TYPE ?? "unknown").toLowerCase(), + enabled: false, + status: "disabled", + reasonCode: "opt-in-required", + message: "Local control is off until you enable the beta.", + ownerPid: processId, + }; + + const publish = (next) => { + connection = connectionStore.persist(next); + onChange(connection); + return connection; + }; + + const unavailable = (status, reasonCode, message, extra = {}) => + publish({ + schemaVersion: CONNECTION_SCHEMA_VERSION, + mode: "unavailable", + platform: "linux", + session: String(env.XDG_SESSION_TYPE ?? "unknown").toLowerCase(), + enabled, + status, + reasonCode, + message, + ownerPid: processId, + ...extra, + }); + + const runtimeRoot = () => { + const configured = env.XDG_RUNTIME_DIR; + if (configured && path.isAbsolute(configured)) { + try { + const stat = fs.lstatSync(configured); + const currentUid = process.getuid?.() ?? os.userInfo().uid; + if ( + stat.isDirectory() && + !stat.isSymbolicLink() && + stat.uid === currentUid && + (stat.mode & 0o077) === 0 + ) { + const root = ensurePrivateDirectory(path.join(configured, "openmausbot-cua")); + cleanupStaleRuntimeDirectories(root); + return root; + } + } catch {} + } + // Unix sockets have a short path limit. A private, uid-scoped directory + // directly under the system temp root keeps the fallback deterministic + // and short when XDG_RUNTIME_DIR is missing or unsafe. + const currentUid = process.getuid?.() ?? os.userInfo().uid; + const root = ensurePrivateDirectory(path.join(os.tmpdir(), `openmausbot-cua-${currentUid}`)); + cleanupStaleRuntimeDirectories(root); + return root; + }; + + const cleanupRuntimeFiles = (owned) => { + if (owned?.healthTimer) { + clearRecurring(owned.healthTimer); + owned.healthTimer = null; + } + if (!owned?.runtimeDirectory) return; + for (const file of [owned.socketPath, owned.pidFile]) { + try { + fs.unlinkSync(file); + } catch { + // A failure for one owned path must not prevent cleanup of the other. + } + } + try { + fs.rmdirSync(owned.runtimeDirectory); + } catch {} + }; + + const markUnexpectedExit = (owned, code, signal) => { + if (active !== owned || owned.stopping || quitting) return; + active = null; + cleanupRuntimeFiles(owned); + unavailable( + "error", + "daemon-exited", + "Cua Driver stopped unexpectedly. Try again before using this computer.", + { generation: owned.generation, exitCode: code, exitSignal: signal }, + ); + }; + + const markWaylandHealthLost = (owned, error) => { + if (active !== owned || owned.stopping || quitting) return; + owned.stopping = true; + active = null; + if (owned.healthTimer) { + clearRecurring(owned.healthTimer); + owned.healthTimer = null; + } + unavailable( + "error", + error?.code ?? "wayland-health-lost", + error?.message ?? "The GNOME Wayland control backend is no longer available.", + { generation: owned.generation }, + ); + void stopOwnedChild(owned.child).finally(() => cleanupRuntimeFiles(owned)); + }; + + const startWaylandHealthMonitor = (owned) => { + if (!Number.isFinite(healthCheckIntervalMs) || healthCheckIntervalMs <= 0) return; + owned.healthTimer = setRecurring(() => { + if (active !== owned || owned.stopping || owned.healthChecking) return; + owned.healthChecking = true; + void healthProbe(owned.socketPath) + .catch((error) => markWaylandHealthLost(owned, error)) + .finally(() => { + owned.healthChecking = false; + }); + }, healthCheckIntervalMs); + owned.healthTimer.unref?.(); + }; + + const start = async () => { + if (platform !== "linux") return connection; + if (!enabled) { + return unavailable( + "disabled", + "opt-in-required", + "Local control is off until you enable the beta.", + ); + } + if (active?.ready) return connection; + if (startPromise) return startPromise; + + startPromise = (async () => { + unavailable("checking", "checking-driver", "Checking Cua Driver and the desktop session…"); + let inspected; + try { + inspected = await inspect({ platform, arch, env, bundledDriverPath }); + } catch (error) { + return unavailable( + "error", + error?.code ?? "driver-inspection-failed", + "Cua Driver could not be inspected. Check the installation and try again.", + ); + } + if (inspected.status !== "ready") { + return unavailable("error", inspected.reasonCode, inspected.message, { + ...(inspected.path + ? { driver: { path: inspected.path, version: inspected.driverVersion } } + : {}), + doctorProbes: inspected.probes ?? [], + }); + } + if (!enabled || quitting) return connection; + const runtimeSession = inspected.session === "wayland" ? "wayland" : "x11"; + + const generation = identifier(); + const root = runtimeRoot(); + const runtimeDirectory = path.join(root, `${processId}-${generation.slice(0, 12)}`); + ensurePrivateDirectory(runtimeDirectory); + const socketPath = path.join(runtimeDirectory, "driver.sock"); + const pidFile = path.join(runtimeDirectory, "driver.pid"); + if (Buffer.byteLength(socketPath) > 100) { + cleanupRuntimeFiles({ runtimeDirectory, socketPath, pidFile }); + return unavailable( + "error", + "socket-path-too-long", + "The private Cua Driver socket path is too long for this Linux installation.", + ); + } + + const childEnv = desktopCommandEnvironment(env, { + CUA_DRIVER_EMBEDDED: "1", + CUA_DRIVER_HOST_BUNDLE_ID: HOST_BUNDLE_ID, + CUA_DRIVER_PARENT_LIVENESS_STDIN: "1", + ...(runtimeSession === "wayland" ? { CUA_DRIVER_RS_ENABLE_WAYLAND: "1" } : {}), + }); + const args = [ + "serve", + "--embedded", + "--socket", + socketPath, + "--pid-file", + pidFile, + "--permission-mode", + "standard", + ]; + // Pin the exact file inspected above, not merely its path. Keep this + // directly adjacent to spawn so an update/replacement during doctor or + // runtime preparation fails closed instead of launching uninspected code. + const revalidated = validateDriverCandidate(inspected.path); + if ( + revalidated.status !== "found" || + revalidated.path !== inspected.path || + !sameDriverFileIdentity(inspected.fileIdentity, revalidated.fileIdentity) + ) { + cleanupRuntimeFiles({ runtimeDirectory, socketPath, pidFile }); + return unavailable( + "error", + "driver-changed", + "Cua Driver changed after validation. Check the installation and try again.", + ); + } + const child = spawnProcess(inspected.path, args, { + env: childEnv, + shell: false, + stdio: ["pipe", "ignore", "pipe"], + windowsHide: true, + }); + const owned = { + child, + generation, + runtimeDirectory, + socketPath, + pidFile, + ready: false, + stopping: false, + healthTimer: null, + healthChecking: false, + }; + active = owned; + child.stderr?.on("data", () => {}); + child.once("exit", (code, signal) => markUnexpectedExit(owned, code, signal)); + child.once("error", (error) => markUnexpectedExit(owned, null, error?.code ?? "spawn-error")); + unavailable("starting", "starting-daemon", "Starting the private Cua Driver runtime…", { + generation, + driver: { path: inspected.path, version: inspected.driverVersion }, + }); + + try { + const handshake = await probe(socketPath, { + childPid: child.pid, + session: runtimeSession, + }); + if (active !== owned || owned.stopping || !enabled) { + await stopOwnedChild(child); + cleanupRuntimeFiles(owned); + return connection; + } + owned.ready = true; + const readyConnection = publish({ + schemaVersion: CONNECTION_SCHEMA_VERSION, + mode: + runtimeSession === "wayland" + ? "linux-wayland-gnome-supervised" + : "linux-x11-supervised", + platform: "linux", + session: runtimeSession, + ...(inspected.compositor ? { compositor: inspected.compositor } : {}), + enabled: true, + status: "ready", + ownerPid: processId, + generation, + driver: { + path: inspected.path, + version: inspected.driverVersion, + source: inspected.source, + manifestSchema: inspected.manifestSchema, + // Private descriptor-only pin. The renderer consumes getStatus(), + // which deliberately omits this internal file identity. + fileIdentity: inspected.fileIdentity, + }, + daemon: { + socketPath, + pid: handshake.metadata.pid, + contractVersion: handshake.metadata.contract_version, + toolsListSchemaVersion: handshake.metadata.tools_list_schema_version, + capabilityVersion: handshake.metadata.capability_version, + mcpProtocolVersion: handshake.metadata.mcp_protocol_version, + }, + mcp: { + command: inspected.path, + args: ["mcp", "--embedded", "--socket", socketPath], + env: { + CUA_DRIVER_EMBEDDED: "1", + CUA_DRIVER_HOST_BUNDLE_ID: HOST_BUNDLE_ID, + CUA_DRIVER_RS_UPDATE_CHECK: "false", + CUA_DRIVER_RS_TELEMETRY_ENABLED: "false", + ...(runtimeSession === "wayland" + ? { CUA_DRIVER_RS_ENABLE_WAYLAND: "1" } + : {}), + }, + }, + toolNames: handshake.tools, + doctorWarnings: inspected.doctor.warnings, + }); + if (runtimeSession === "wayland") startWaylandHealthMonitor(owned); + return readyConnection; + } catch (error) { + owned.stopping = true; + const stillOwned = active === owned; + if (stillOwned) active = null; + await stopOwnedChild(child); + cleanupRuntimeFiles(owned); + if (!stillOwned || !enabled || quitting) return connection; + return unavailable( + "error", + error?.code ?? "daemon-start-failed", + error?.message ?? "Cua Driver could not start.", + { generation, driver: { path: inspected.path, version: inspected.driverVersion } }, + ); + } + })() + .catch((error) => { + if (!enabled || quitting) return connection; + return unavailable( + "error", + error?.code ?? "runtime-start-failed", + "The private Cua Driver runtime could not start. Check the installation and try again.", + ); + }) + .finally(() => { + startPromise = null; + }); + return startPromise; + }; + + const stop = async ({ disable = false, quit = false } = {}) => { + if (disable) { + enabled = false; + preferenceStore.write(false); + } + if (quit) quitting = true; + unavailable( + disable ? "disabled" : "stopped", + disable ? "opt-in-required" : quit ? "app-stopped" : "runtime-stopped", + disable ? "Local control is disabled." : "Local control is not running.", + active ? { generation: active.generation } : {}, + ); + const owned = active; + if (owned) { + owned.stopping = true; + active = null; + await stopOwnedChild(owned.child); + cleanupRuntimeFiles(owned); + } + return connection; + }; + + return Object.freeze({ + async initialize() { + enabled = preferenceStore.read(); + return enabled + ? start() + : unavailable( + "disabled", + "opt-in-required", + "Local control is off until you enable the beta.", + ); + }, + async enable() { + enabled = true; + preferenceStore.write(true); + return start(); + }, + async retry() { + if (!enabled) return connection; + if (startPromise) await startPromise; + await stop(); + return start(); + }, + async disable() { + return stop({ disable: true }); + }, + async shutdown() { + return stop({ quit: true }); + }, + getConnection() { + return connection; + }, + getStatus() { + return publicRuntimeStatus(connection); + }, + }); +} + +module.exports = { + CERTIFIED_CAPABILITY_VERSION, + CERTIFIED_CONTRACT_VERSION, + CERTIFIED_MCP_PROTOCOL_VERSION, + CERTIFIED_TOOLS_LIST_SCHEMA_VERSION, + CONNECTION_SCHEMA_VERSION, + HOST_BUNDLE_ID, + REQUIRED_TOOLS, + REQUIRED_WAYLAND_HEALTH_CHECKS, + cleanupStaleRuntimeDirectories, + createLinuxCuaPreferenceStore, + createLinuxCuaRuntime, + createUnavailableLinuxRuntime, + ensurePrivateDirectory, + probePrivateDaemon, + probeWaylandHealth, + publicRuntimeStatus, + requestSocket, + stopOwnedChild, + validateDaemonMetadata, + validateToolSurface, + validateWaylandHealthReport, + writePrivateJson, +}; diff --git a/electron/cua-linux-runtime.test.mjs b/electron/cua-linux-runtime.test.mjs new file mode 100644 index 000000000..293b80095 --- /dev/null +++ b/electron/cua-linux-runtime.test.mjs @@ -0,0 +1,654 @@ +import { EventEmitter } from "node:events"; +import { createRequire } from "node:module"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const require = createRequire(import.meta.url); +const { createCuaConnectionStore } = require("./cua-connection.cjs"); +const { validateDriverCandidate } = require("./cua-linux.cjs"); +const { + cleanupStaleRuntimeDirectories, + createLinuxCuaPreferenceStore, + createLinuxCuaRuntime, + createUnavailableLinuxRuntime, + probePrivateDaemon, + validateDaemonMetadata, + validateToolSurface, + validateWaylandHealthReport, + writePrivateJson, +} = require("./cua-linux-runtime.cjs"); + +const temporaryDirectories = []; + +function temporaryDirectory() { + const base = process.platform === "win32" ? os.tmpdir() : fs.realpathSync("/tmp"); + const directory = fs.mkdtempSync(path.join(base, "omb-cua-runtime-")); + temporaryDirectories.push(directory); + return directory; +} + +function executable(directory) { + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + const binary = path.join(directory, "cua-driver"); + fs.writeFileSync(binary, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + return binary; +} + +function fakeChild(pid = 4321) { + const child = new EventEmitter(); + child.pid = pid; + child.exitCode = null; + child.signalCode = null; + child.stderr = new EventEmitter(); + child.stdin = { + end: vi.fn(() => { + if (child.exitCode !== null) return; + child.exitCode = 0; + queueMicrotask(() => child.emit("exit", 0, null)); + }), + }; + child.kill = vi.fn((signal) => { + if (child.exitCode !== null) return true; + child.exitCode = signal === "SIGKILL" ? 137 : 0; + queueMicrotask(() => child.emit("exit", child.exitCode, signal)); + return true; + }); + child.crash = () => { + child.exitCode = 1; + child.emit("exit", 1, null); + }; + return child; +} + +function handshake(pid = 4321) { + return { + metadata: { + driver_version: "0.19.3", + contract_version: "0.6.0", + tools_list_schema_version: "1", + capability_version: "1", + mcp_protocol_version: "2025-06-18", + pid, + embedded: true, + host_bundle_id: "com.openmausbot.app", + }, + tools: ["click", "get_window_state", "list_apps", "type_text"], + }; +} + +function healthyWaylandHealth() { + return { + ok: true, + result: { + structuredContent: { + schema_version: "1", + platform: "linux", + driver_version: "0.19.3", + overall: "ok", + checks: [ + { name: "binary_version", status: "pass", message: "cua-driver 0.19.3" }, + { name: "platform_supported", status: "pass", message: "Ubuntu 24.04" }, + { name: "session_active", status: "pass", message: "MCP session is active." }, + { name: "ax_capability", status: "pass", message: "AT-SPI is reachable." }, + { + name: "screen_capture_capability", + status: "pass", + message: "Screenshot portal is reachable.", + }, + { + name: "wayland_backend", + status: "pass", + message: "Portal/libei and verified target activation are reachable.", + }, + ], + }, + }, + }; +} + +function harness({ + preferenceEnabled = false, + afterIdentityCaptured, + session = "x11", + runtimeOptions = {}, +} = {}) { + const userData = temporaryDirectory(); + const runtimeRoot = path.join(userData, "session"); + fs.mkdirSync(runtimeRoot, { mode: 0o700 }); + const binary = executable(path.join(userData, "driver")); + const connectionStore = createCuaConnectionStore({ getUserData: () => userData }); + const preferenceStore = createLinuxCuaPreferenceStore({ getUserData: () => userData }); + if (preferenceEnabled) preferenceStore.write(true); + const child = fakeChild(); + const fileIdentity = validateDriverCandidate(binary).fileIdentity; + const inspect = vi.fn(async () => { + afterIdentityCaptured?.(binary); + return { + status: "ready", + path: binary, + fileIdentity, + source: "environment", + driverVersion: "0.19.3", + manifestSchema: "1", + mcp: { command: binary, args: ["mcp"] }, + doctor: { ok: true, probes: [], warnings: [] }, + session, + ...(session === "wayland" ? { compositor: "gnome-mutter" } : {}), + }; + }); + const spawnProcess = vi.fn(() => child); + const probe = vi.fn(async () => handshake(child.pid)); + const changes = []; + const runtime = createLinuxCuaRuntime({ + getUserData: () => userData, + connectionStore, + preferenceStore, + platform: "linux", + env: { + HOME: userData, + PATH: "/usr/bin", + DISPLAY: ":0", + XDG_SESSION_TYPE: session, + ...(session === "wayland" + ? { + WAYLAND_DISPLAY: "wayland-0", + XDG_CURRENT_DESKTOP: "ubuntu:GNOME", + DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/1000/bus", + } + : {}), + XDG_RUNTIME_DIR: runtimeRoot, + OPENAI_API_KEY: "must-not-leak", + }, + inspect, + spawnProcess, + probe, + healthCheckIntervalMs: 0, + identifier: () => "01234567-89ab-cdef-0123-456789abcdef", + processId: 1234, + onChange: (connection) => changes.push(connection), + ...runtimeOptions, + }); + return { + binary, + changes, + child, + connectionStore, + inspect, + preferenceStore, + probe, + runtime, + spawnProcess, + userData, + }; +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe("unavailable Linux CUA runtime", () => { + it("fails closed without rejecting any IPC-facing operation", async () => { + const persisted = []; + const changed = []; + const runtime = createUnavailableLinuxRuntime({ + connectionStore: { persist: (connection) => persisted.push(connection) }, + onChange: (connection) => changed.push(connection), + processId: 1234, + }); + + for (const operation of ["initialize", "enable", "retry", "disable", "shutdown"]) { + await expect(runtime[operation]()).resolves.toMatchObject({ + mode: "unavailable", + status: "unavailable", + reasonCode: "bundled-driver-invalid", + }); + } + expect(runtime.getStatus()).toEqual({ + enabled: false, + status: "unavailable", + reasonCode: "bundled-driver-invalid", + message: "The bundled Cua Driver failed integrity validation.", + driverPath: undefined, + driverVersion: undefined, + driverSource: undefined, + session: undefined, + compositor: undefined, + warnings: [], + }); + expect(persisted).toHaveLength(1); + expect(changed).toHaveLength(1); + }); + + it("still returns a typed status if persisting the failure is unavailable", () => { + const runtime = createUnavailableLinuxRuntime({ + connectionStore: { + persist() { + throw new Error("read-only user data"); + }, + }, + }); + expect(runtime.getStatus()).toMatchObject({ + enabled: false, + status: "unavailable", + reasonCode: "bundled-driver-invalid", + }); + }); +}); + +// Windows does not provide the POSIX executable and Unix-socket semantics this +// lifecycle contract exercises. Canonical short temp paths keep it portable +// across Linux and macOS. +describe.skipIf(process.platform === "win32")("Linux CUA opt-in and lifecycle", () => { + it("cleans only known private runtime files from a dead owner", () => { + const root = temporaryDirectory(); + const stale = path.join(root, "99999999-01234567-89a"); + const suspicious = path.join(root, "99999998-01234567-89a"); + fs.mkdirSync(stale, { mode: 0o700 }); + fs.writeFileSync(path.join(stale, "driver.pid"), "99999999\n", { mode: 0o600 }); + fs.mkdirSync(suspicious, { mode: 0o700 }); + fs.writeFileSync(path.join(suspicious, "unexpected"), "preserve", { mode: 0o600 }); + + expect( + cleanupStaleRuntimeDirectories(root, { isProcessAlive: () => false }), + ).toBe(1); + expect(fs.existsSync(stale)).toBe(false); + expect(fs.readFileSync(path.join(suspicious, "unexpected"), "utf8")).toBe("preserve"); + }); + + it("preserves a certified runtime directory while its owner is alive", () => { + const root = temporaryDirectory(); + const live = path.join(root, "1234-01234567-89a"); + fs.mkdirSync(live, { mode: 0o700 }); + fs.writeFileSync(path.join(live, "driver.pid"), "1234\n", { mode: 0o600 }); + + expect(cleanupStaleRuntimeDirectories(root, { isProcessAlive: () => true })).toBe(0); + expect(fs.existsSync(path.join(live, "driver.pid"))).toBe(true); + }); + + it("does not inspect or execute a driver before explicit opt-in", async () => { + const context = harness(); + await context.runtime.initialize(); + expect(context.inspect).not.toHaveBeenCalled(); + expect(context.spawnProcess).not.toHaveBeenCalled(); + expect(context.runtime.getStatus()).toMatchObject({ + enabled: false, + status: "disabled", + reasonCode: "opt-in-required", + }); + }); + + it("passes the exact packaged candidate and architecture into inspection", async () => { + const bundledDriverPath = "/opt/OpenMausBot/resources/cua-linux-x64/cua-driver"; + const context = harness({ + runtimeOptions: { bundledDriverPath, arch: "x64" }, + }); + await context.runtime.enable(); + expect(context.inspect).toHaveBeenCalledWith( + expect.objectContaining({ + platform: "linux", + arch: "x64", + bundledDriverPath, + }), + ); + }); + + it("publishes a retryable error when driver inspection throws", async () => { + const inspectError = Object.assign(new Error("probe failed"), { + code: "driver-inspection-failed", + }); + const context = harness({ + runtimeOptions: { inspect: vi.fn(async () => Promise.reject(inspectError)) }, + }); + + await expect(context.runtime.enable()).resolves.toMatchObject({ + status: "error", + reasonCode: "driver-inspection-failed", + }); + expect(context.runtime.getStatus()).toMatchObject({ + status: "error", + reasonCode: "driver-inspection-failed", + }); + expect(context.spawnProcess).not.toHaveBeenCalled(); + }); + + it("coalesces starts, verifies a private daemon, and publishes a strict ready descriptor", async () => { + const context = harness(); + const [first, second] = await Promise.all([context.runtime.enable(), context.runtime.enable()]); + expect(first).toEqual(second); + expect(context.inspect).toHaveBeenCalledTimes(1); + expect(context.spawnProcess).toHaveBeenCalledTimes(1); + expect(context.spawnProcess).toHaveBeenCalledWith( + context.binary, + expect.arrayContaining(["serve", "--embedded", "--socket", "--permission-mode", "standard"]), + expect.objectContaining({ shell: false, stdio: ["pipe", "ignore", "pipe"] }), + ); + const spawnOptions = context.spawnProcess.mock.calls[0][2]; + expect(spawnOptions.env).toMatchObject({ + CUA_DRIVER_EMBEDDED: "1", + CUA_DRIVER_PARENT_LIVENESS_STDIN: "1", + CUA_DRIVER_HOST_BUNDLE_ID: "com.openmausbot.app", + CUA_DRIVER_RS_UPDATE_CHECK: "false", + CUA_DRIVER_RS_TELEMETRY_ENABLED: "false", + }); + expect(spawnOptions.env.OPENAI_API_KEY).toBeUndefined(); + expect(context.probe).toHaveBeenCalledWith(expect.stringMatching(/driver\.sock$/), { + childPid: context.child.pid, + session: "x11", + }); + expect(context.runtime.getConnection()).toMatchObject({ + schemaVersion: 1, + mode: "linux-x11-supervised", + platform: "linux", + session: "x11", + enabled: true, + status: "ready", + ownerPid: 1234, + generation: "01234567-89ab-cdef-0123-456789abcdef", + driver: { + path: context.binary, + version: "0.19.3", + source: "environment", + manifestSchema: "1", + fileIdentity: validateDriverCandidate(context.binary).fileIdentity, + }, + daemon: { pid: 4321, contractVersion: "0.6.0" }, + mcp: { + command: context.binary, + args: ["mcp", "--embedded", "--socket", expect.stringMatching(/driver\.sock$/)], + env: { + CUA_DRIVER_EMBEDDED: "1", + CUA_DRIVER_HOST_BUNDLE_ID: "com.openmausbot.app", + CUA_DRIVER_RS_UPDATE_CHECK: "false", + CUA_DRIVER_RS_TELEMETRY_ENABLED: "false", + }, + }, + }); + const descriptor = path.join(context.userData, "cua-connection.json"); + expect(fs.statSync(descriptor).mode & 0o777).toBe(0o600); + expect(fs.statSync(context.userData).mode & 0o777).toBe(0o700); + expect(JSON.parse(fs.readFileSync(descriptor, "utf8"))).toMatchObject({ + driver: { fileIdentity: validateDriverCandidate(context.binary).fileIdentity }, + }); + expect(context.runtime.getStatus()).not.toHaveProperty("fileIdentity"); + expect(context.runtime.getStatus()).not.toHaveProperty("driver.fileIdentity"); + }); + + it("refuses to spawn when the inspected executable identity changes", async () => { + const context = harness({ + afterIdentityCaptured(binary) { + fs.appendFileSync(binary, "# changed after inspection\n"); + }, + }); + await context.runtime.enable(); + expect(context.spawnProcess).not.toHaveBeenCalled(); + expect(context.probe).not.toHaveBeenCalled(); + expect(context.runtime.getConnection()).toMatchObject({ + mode: "unavailable", + status: "error", + reasonCode: "driver-changed", + }); + expect(fs.readFileSync(path.join(context.userData, "cua-connection.json"), "utf8")).not.toContain( + "fileIdentity", + ); + }); + + it("invalidates readiness immediately when the owned daemon exits", async () => { + const context = harness(); + await context.runtime.enable(); + context.child.crash(); + expect(context.runtime.getConnection()).toMatchObject({ + mode: "unavailable", + status: "error", + reasonCode: "daemon-exited", + generation: "01234567-89ab-cdef-0123-456789abcdef", + }); + expect( + JSON.parse(fs.readFileSync(path.join(context.userData, "cua-connection.json"), "utf8")), + ).toMatchObject({ mode: "unavailable", reasonCode: "daemon-exited" }); + }); + + it("closes the parent-liveness pipe on shutdown without clearing durable opt-in", async () => { + const context = harness(); + await context.runtime.enable(); + await context.runtime.shutdown(); + expect(context.child.stdin.end).toHaveBeenCalledOnce(); + expect(context.child.kill).not.toHaveBeenCalled(); + expect(context.preferenceStore.read()).toBe(true); + expect(context.runtime.getConnection()).toMatchObject({ + mode: "unavailable", + status: "stopped", + reasonCode: "app-stopped", + }); + }); + + it("does not wait again for a child that was already reaped by a signal", async () => { + const context = harness(); + await context.runtime.enable(); + context.child.signalCode = "SIGTERM"; + context.child.stdin.end = vi.fn(); + vi.useFakeTimers(); + try { + const shutdown = context.runtime.shutdown(); + await vi.advanceTimersByTimeAsync(0); + await expect(shutdown).resolves.toMatchObject({ + status: "stopped", + reasonCode: "app-stopped", + }); + expect(context.child.kill).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("serializes an in-flight start before retrying with a fresh runtime", async () => { + let releaseFirstProbe; + const firstProbeGate = new Promise((resolve) => { + releaseFirstProbe = resolve; + }); + const firstChild = fakeChild(4321); + const children = [firstChild, fakeChild(4322)]; + const spawnProcess = vi.fn(() => children.shift()); + const probe = vi + .fn() + .mockImplementationOnce(async () => { + await firstProbeGate; + return handshake(4321); + }) + .mockResolvedValueOnce(handshake(4322)); + const context = harness({ runtimeOptions: { spawnProcess, probe } }); + + const firstStart = context.runtime.enable(); + await vi.waitFor(() => expect(probe).toHaveBeenCalledTimes(1)); + const retry = context.runtime.retry(); + releaseFirstProbe(); + + await expect(firstStart).resolves.toMatchObject({ status: "ready" }); + await expect(retry).resolves.toMatchObject({ status: "ready", daemon: { pid: 4322 } }); + expect(spawnProcess).toHaveBeenCalledTimes(2); + expect(firstChild.exitCode !== null || firstChild.signalCode !== null).toBe(true); + }); + + it("starts on launch only after a durable prior opt-in and supports explicit disable", async () => { + const context = harness({ preferenceEnabled: true }); + await context.runtime.initialize(); + expect(context.runtime.getConnection().mode).toBe("linux-x11-supervised"); + await context.runtime.disable(); + expect(context.preferenceStore.read()).toBe(false); + expect(context.runtime.getStatus()).toMatchObject({ enabled: false, status: "disabled" }); + }); + + it("publishes the distinct GNOME Wayland contract and propagates the opt-in environment", async () => { + const context = harness({ session: "wayland" }); + await context.runtime.enable(); + expect(context.probe).toHaveBeenCalledWith(expect.stringMatching(/driver\.sock$/), { + childPid: context.child.pid, + session: "wayland", + }); + expect(context.spawnProcess.mock.calls[0][2].env.CUA_DRIVER_RS_ENABLE_WAYLAND).toBe("1"); + expect(context.runtime.getConnection()).toMatchObject({ + mode: "linux-wayland-gnome-supervised", + session: "wayland", + compositor: "gnome-mutter", + mcp: { + env: { + CUA_DRIVER_RS_ENABLE_WAYLAND: "1", + CUA_DRIVER_RS_TELEMETRY_ENABLED: "false", + CUA_DRIVER_RS_UPDATE_CHECK: "false", + }, + }, + }); + }); + + it("revokes Wayland readiness when a prompt-free health recheck fails", async () => { + let healthTick = null; + const timer = { unref: vi.fn() }; + const clearRecurring = vi.fn(); + const context = harness({ + session: "wayland", + runtimeOptions: { + healthCheckIntervalMs: 30_000, + healthProbe: vi.fn(async () => { + throw Object.assign(new Error("The Cua WinRects helper is no longer active."), { + code: "wayland-helper-required", + }); + }), + setRecurring: vi.fn((callback) => { + healthTick = callback; + return timer; + }), + clearRecurring, + }, + }); + await context.runtime.enable(); + expect(context.runtime.getConnection().status).toBe("ready"); + healthTick(); + await vi.waitFor(() => { + expect(context.runtime.getConnection()).toMatchObject({ + mode: "unavailable", + status: "error", + reasonCode: "wayland-helper-required", + }); + }); + expect(clearRecurring).toHaveBeenCalledWith(timer); + expect(context.child.stdin.end).toHaveBeenCalledOnce(); + }); +}); + +// POSIX modes and symlink guarantees are available on Linux and macOS. +describe.skipIf(process.platform === "win32")("Linux CUA private data", () => { + it("uses strict preference schema and private atomic files", () => { + const userData = temporaryDirectory(); + const store = createLinuxCuaPreferenceStore({ getUserData: () => userData }); + store.write(true); + const file = path.join(userData, "cua-local-control.json"); + expect(store.read()).toBe(true); + expect(fs.statSync(file).mode & 0o777).toBe(0o600); + fs.writeFileSync(file, JSON.stringify({ schemaVersion: 1, linuxLocalControlEnabled: true, extra: true }), { + mode: 0o600, + }); + expect(store.read()).toBe(false); + }); + + it("does not follow a symlink when creating private state", () => { + const root = temporaryDirectory(); + const target = path.join(root, "target.json"); + const link = path.join(root, "state.json"); + fs.writeFileSync(target, "untouched", { mode: 0o600 }); + fs.symlinkSync(target, link); + writePrivateJson(link, { ok: true }); + expect(fs.readFileSync(target, "utf8")).toBe("untouched"); + expect(JSON.parse(fs.readFileSync(link, "utf8"))).toEqual({ ok: true }); + }); +}); + +describe("Linux CUA handshake validation", () => { + it("pins metadata to the certified child and contract", () => { + const valid = { ok: true, result: handshake(99).metadata }; + expect(validateDaemonMetadata(valid, { childPid: 99 })).toEqual(valid.result); + expect(() => validateDaemonMetadata(valid, { childPid: 100 })).toThrow(/PID/); + expect(() => + validateDaemonMetadata({ ok: true, result: { ...valid.result, contract_version: "9" } }), + ).toThrow(/contract_version/); + }); + + it("requires the inspect and mutation tool surface", () => { + const tools = handshake().tools.map((name) => ({ name })); + const manifest = { schema_version: "1", capability_version: "1", tools }; + expect(validateToolSurface({ ok: true, result: manifest })).toEqual([...handshake().tools].sort()); + expect(() => + validateToolSurface({ + ok: true, + result: { + ...manifest, + tools: tools.filter((tool) => tool.name !== "type_text"), + }, + }), + ).toThrow(/type_text/); + expect(() => + validateToolSurface({ ok: true, result: { ...manifest, capability_version: "2" } }), + ).toThrow(/could not be verified/); + }); + + it("accepts only a healthy certified Wayland report", () => { + expect(validateWaylandHealthReport(healthyWaylandHealth())).toEqual({ + schemaVersion: "1", + overall: "ok", + requiredChecks: ["ax_capability", "screen_capture_capability", "wayland_backend"], + }); + const unhealthy = healthyWaylandHealth(); + const check = unhealthy.result.structuredContent.checks.find( + (entry) => entry.name === "wayland_backend", + ); + check.status = "fail"; + check.message = "The compositor has no verified target-activation adapter."; + check.hint = "Install and enable the bundled WinRects Shell helper."; + unhealthy.result.structuredContent.overall = "degraded"; + expect(() => validateWaylandHealthReport(unhealthy)).toThrowError( + expect.objectContaining({ code: "wayland-helper-required" }), + ); + expect(() => + validateWaylandHealthReport({ + ...healthyWaylandHealth(), + result: { + structuredContent: { + ...healthyWaylandHealth().result.structuredContent, + schema_version: "2", + }, + }, + }), + ).toThrowError(expect.objectContaining({ code: "invalid-health-report" })); + }); + + it("calls health_report during the private daemon handshake only on Wayland", async () => { + const calls = []; + const request = vi.fn(async (_socket, payload) => { + calls.push(payload); + if (payload.method === "metadata") return { ok: true, result: handshake(99).metadata }; + if (payload.method === "list") { + return { + ok: true, + result: { + schema_version: "1", + capability_version: "1", + tools: handshake().tools.map((name) => ({ name })), + }, + }; + } + return healthyWaylandHealth(); + }); + + await expect( + probePrivateDaemon("/tmp/cua.sock", { + childPid: 99, + session: "wayland", + request, + timeoutMs: 100, + }), + ).resolves.toMatchObject({ health: { overall: "ok" } }); + expect(calls.at(-1)).toEqual({ method: "call", name: "health_report", args: {} }); + }); +}); diff --git a/electron/cua-linux.cjs b/electron/cua-linux.cjs new file mode 100644 index 000000000..d965776ab --- /dev/null +++ b/electron/cua-linux.cjs @@ -0,0 +1,760 @@ +const { spawn, spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const CERTIFIED_DRIVER_VERSION = "0.19.3"; +const CERTIFIED_MANIFEST_SCHEMA = "1"; +const DEFAULT_TIMEOUT_MS = 8_000; +const DEFAULT_MAX_OUTPUT_BYTES = 512 * 1024; +const GETENT_BINARY = "/usr/bin/getent"; +const GETENT_TIMEOUT_MS = 1_500; +const GETENT_MAX_OUTPUT_BYTES = 256 * 1024; +// Keep this exact field set synchronized with DRIVER_FILE_IDENTITY_KEYS in +// server/local-computer.ts; Electron publishes it and the server revalidates it. +const DRIVER_FILE_IDENTITY_KEYS = Object.freeze([ + "dev", + "ino", + "uid", + "gid", + "mode", + "size", + "mtimeNs", + "ctimeNs", +]); + +const SESSION_ENV_KEYS = new Set([ + "AT_SPI_BUS", + "DBUS_SESSION_BUS_ADDRESS", + "DISPLAY", + "HOME", + "LANG", + "LANGUAGE", + "LC_ALL", + "PATH", + "USER", + "WAYLAND_DISPLAY", + "XAUTHORITY", + "XDG_CURRENT_DESKTOP", + "XDG_RUNTIME_DIR", + "XDG_SESSION_DESKTOP", + "XDG_SESSION_TYPE", +]); + +function unavailable(reasonCode, message, details = {}) { + return { status: "unavailable", reasonCode, message, ...details }; +} + +function sanitizePath(value) { + const seen = new Set(); + const entries = []; + for (const entry of String(value ?? "").split(path.delimiter)) { + if (!entry || !path.isAbsolute(entry)) continue; + const normalized = path.normalize(entry); + if (seen.has(normalized)) continue; + seen.add(normalized); + entries.push(normalized); + } + return entries.join(path.delimiter); +} + +function desktopCommandEnvironment(source = process.env, additions = {}) { + const env = {}; + for (const [key, value] of Object.entries(source)) { + if (value == null) continue; + if (SESSION_ENV_KEYS.has(key) || key.startsWith("LC_")) env[key] = String(value); + } + env.PATH = sanitizePath(source.PATH); + // Keep every Cua child owned by OpenMausBot deterministic and local-only. + // Bundled native code must not independently update itself or opt users into + // upstream telemetry. This does not change the user's persisted preferences. + env.CUA_DRIVER_RS_UPDATE_CHECK = "false"; + env.CUA_DRIVER_RS_TELEMETRY_ENABLED = "false"; + for (const [key, value] of Object.entries(additions)) { + if (value != null) env[key] = String(value); + } + return env; +} + +function commandFailure(code, message, details = {}) { + const error = new Error(message); + error.code = code; + Object.assign(error, details); + return error; +} + +function runCuaCommand(binary, args, { + env = desktopCommandEnvironment(), + timeoutMs = DEFAULT_TIMEOUT_MS, + maxOutputBytes = DEFAULT_MAX_OUTPUT_BYTES, +} = {}) { + return new Promise((resolve, reject) => { + let settled = false; + let timedOut = false; + let overflowed = false; + let stdout = Buffer.alloc(0); + let stderr = Buffer.alloc(0); + const child = spawn(binary, args, { + env, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + + const finish = (fn, value) => { + if (settled) return; + settled = true; + clearTimeout(timer); + fn(value); + }; + const stop = () => { + try { + child.kill("SIGKILL"); + } catch {} + }; + const collect = (current, chunk) => { + const next = Buffer.concat([current, chunk]); + if (stdout.length + stderr.length + chunk.length > maxOutputBytes) { + overflowed = true; + stop(); + } + return next.subarray(0, maxOutputBytes); + }; + + child.stdout?.on("data", (chunk) => { + stdout = collect(stdout, chunk); + }); + child.stderr?.on("data", (chunk) => { + stderr = collect(stderr, chunk); + }); + child.once("error", (error) => + finish(reject, commandFailure("spawn-failed", `Could not start Cua Driver: ${error.message}`)), + ); + child.once("close", (exitCode, signal) => { + if (timedOut) { + finish( + reject, + commandFailure("command-timeout", "Cua Driver did not respond in time.", { timeoutMs }), + ); + return; + } + if (overflowed) { + finish( + reject, + commandFailure("output-too-large", "Cua Driver returned too much diagnostic output."), + ); + return; + } + finish(resolve, { + exitCode, + signal, + stdout: stdout.toString("utf8"), + stderr: stderr.toString("utf8"), + }); + }); + + const timer = setTimeout(() => { + timedOut = true; + stop(); + }, timeoutMs); + timer.unref?.(); + }); +} + +function pathComponents(target) { + const resolved = path.resolve(target); + const root = path.parse(resolved).root; + const relative = resolved.slice(root.length).split(path.sep).filter(Boolean); + const components = [root]; + let current = root; + for (const part of relative) { + current = path.join(current, part); + components.push(current); + } + return components; +} + +function safeOwner(stat, currentUid) { + const uid = Number(stat.uid); + return uid === currentUid || uid === 0; +} + +function driverFileIdentityFromStat(stat) { + return Object.freeze({ + dev: String(stat.dev), + ino: String(stat.ino), + uid: String(stat.uid), + gid: String(stat.gid), + mode: String(stat.mode), + size: String(stat.size), + mtimeNs: String(stat.mtimeNs), + ctimeNs: String(stat.ctimeNs), + }); +} + +function captureDriverFileIdentity(target, fileSystem = fs) { + return driverFileIdentityFromStat(fileSystem.statSync(target, { bigint: true })); +} + +function sameDriverFileIdentity(expected, actual) { + if (!expected || !actual || typeof expected !== "object" || typeof actual !== "object") { + return false; + } + const expectedKeys = Object.keys(expected).sort(); + const actualKeys = Object.keys(actual).sort(); + const requiredKeys = [...DRIVER_FILE_IDENTITY_KEYS].sort(); + if ( + expectedKeys.length !== requiredKeys.length || + actualKeys.length !== requiredKeys.length || + !requiredKeys.every((key, index) => expectedKeys[index] === key && actualKeys[index] === key) + ) { + return false; + } + return DRIVER_FILE_IDENTITY_KEYS.every( + (key) => typeof expected[key] === "string" && expected[key] === actual[key], + ); +} + +function runGetent(args, { + spawnCommand = spawnSync, + timeoutMs = GETENT_TIMEOUT_MS, + maxOutputBytes = GETENT_MAX_OUTPUT_BYTES, +} = {}) { + const result = spawnCommand(GETENT_BINARY, args, { + encoding: "utf8", + env: { LANG: "C", LC_ALL: "C" }, + maxBuffer: maxOutputBytes, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + timeout: timeoutMs, + windowsHide: true, + }); + if (result?.error || result?.status !== 0 || typeof result?.stdout !== "string") { + return { + ok: false, + reason: result?.error?.code === "ETIMEDOUT" ? "lookup-timeout" : "lookup-failed", + }; + } + if (Buffer.byteLength(result.stdout) > maxOutputBytes) { + return { ok: false, reason: "lookup-output-too-large" }; + } + return { ok: true, stdout: result.stdout }; +} + +function privatePrimaryGroup(identity, { getent = runGetent } = {}) { + if ( + !Number.isSafeInteger(identity?.uid) || + !Number.isSafeInteger(identity?.gid) || + typeof identity?.username !== "string" || + !identity.username + ) { + return { exclusive: false, reason: "identity-unavailable" }; + } + + const groupResult = getent(["group", String(identity.gid)]); + if (!groupResult?.ok) { + return { exclusive: false, reason: groupResult?.reason ?? "lookup-failed" }; + } + + const groupLines = groupResult.stdout.split(/\r?\n/).filter(Boolean); + const groupRecords = groupLines.map((line) => line.split(":")); + if ( + groupRecords.length !== 1 || + groupRecords[0].length !== 4 || + !/^\d+$/.test(groupRecords[0][2]) || + Number(groupRecords[0][2]) !== identity.gid || + groupRecords[0][0] !== identity.username + ) { + return { exclusive: false, reason: "primary-group-not-private" }; + } + const explicitMembers = groupRecords[0][3].split(",").filter(Boolean); + if (explicitMembers.some((member) => member !== identity.username)) { + return { exclusive: false, reason: "primary-group-shared" }; + } + + const passwdResult = getent(["passwd"]); + if (!passwdResult?.ok) { + return { exclusive: false, reason: passwdResult?.reason ?? "lookup-failed" }; + } + const passwdLines = passwdResult.stdout.split(/\r?\n/).filter(Boolean); + const passwdRecords = passwdLines.map((line) => line.split(":")); + if ( + passwdRecords.length === 0 || + passwdRecords.some( + (fields) => + fields.length !== 7 || !/^\d+$/.test(fields[2]) || !/^\d+$/.test(fields[3]), + ) + ) { + return { exclusive: false, reason: "lookup-malformed" }; + } + const primaryMembers = passwdRecords + .filter((fields) => Number(fields[3]) === identity.gid) + .map((fields) => ({ username: fields[0], uid: Number(fields[2]) })); + if ( + primaryMembers.length !== 1 || + primaryMembers[0].username !== identity.username || + primaryMembers[0].uid !== identity.uid + ) { + return { exclusive: false, reason: "primary-group-shared" }; + } + return { exclusive: true, gid: identity.gid, name: identity.username }; +} + +function driverIdentity({ currentUid, currentGid, currentUsername } = {}) { + let info = {}; + try { + info = os.userInfo(); + } catch { + // Group-writable paths will fail closed when identity cannot be proven. + } + return { + uid: currentUid ?? process.getuid?.() ?? info.uid, + gid: currentGid ?? process.getgid?.() ?? info.gid, + username: currentUsername ?? info.username, + }; +} + +function createPermissionCheck(identity, lookupPrivateGroup) { + let groupProof; + const groupWriteAllowed = (stat) => { + if (Number(stat.uid) !== identity.uid || Number(stat.gid) !== identity.gid) return false; + if (groupProof === undefined) { + try { + groupProof = lookupPrivateGroup(identity); + } catch { + groupProof = { exclusive: false, reason: "lookup-failed" }; + } + } + return ( + groupProof?.exclusive === true && + groupProof.gid === identity.gid && + groupProof.name === identity.username + ); + }; + const failure = (component, stat) => { + const worldWritable = (Number(stat.mode) & 0o002) !== 0; + return unavailable( + "unsafe-driver-permissions", + worldWritable + ? `Cua Driver path is world-writable: ${component}` + : `Cua Driver path is group-writable and its group could not be proven private: ${component}`, + { + affectedPaths: [component], + ...(groupProof?.reason ? { permissionReason: groupProof.reason } : {}), + }, + ); + }; + return { failure, groupWriteAllowed }; +} + +function writablePermissionError(component, stat, permissionCheck, { allowRootSticky = false } = {}) { + const mode = Number(stat.mode); + const rootOwnedStickyDirectory = + allowRootSticky && stat.isDirectory() && Number(stat.uid) === 0 && (mode & 0o1000) !== 0; + if (rootOwnedStickyDirectory) return null; + if ((mode & 0o002) !== 0) return permissionCheck.failure(component, stat); + if ((mode & 0o020) !== 0 && !permissionCheck.groupWriteAllowed(stat)) { + return permissionCheck.failure(component, stat); + } + return null; +} + +function validatePathComponents(target, currentUid, permissionCheck) { + for (const component of pathComponents(path.dirname(target))) { + const stat = fs.lstatSync(component); + if (!safeOwner(stat, currentUid)) { + return unavailable( + "unsafe-driver-owner", + `Cua Driver path component is owned by an unexpected user: ${component}`, + { affectedPaths: [component] }, + ); + } + const permissionError = writablePermissionError(component, stat, permissionCheck, { + allowRootSticky: true, + }); + if (permissionError) return permissionError; + } + return null; +} + +function validateDriverCandidate(candidate, { + currentUid, + currentGid, + currentUsername, + lookupPrivateGroup = privatePrimaryGroup, +} = {}) { + const identity = driverIdentity({ currentUid, currentGid, currentUsername }); + const permissionCheck = createPermissionCheck(identity, lookupPrivateGroup); + if (!path.isAbsolute(candidate)) { + return unavailable("driver-path-not-absolute", "Cua Driver path must be absolute.", { + candidate, + }); + } + + let linkStat; + let canonicalPath; + let targetStat; + let fileIdentity; + try { + linkStat = fs.lstatSync(candidate); + canonicalPath = fs.realpathSync(candidate); + // This single stat is the authority for type, owner, mode, and the + // identity later pinned by the runtime. A second stat at the end proves + // the file did not change while parent permissions were being checked. + targetStat = fs.statSync(canonicalPath, { bigint: true }); + fileIdentity = driverFileIdentityFromStat(targetStat); + } catch (error) { + return unavailable("driver-not-found", `Cua Driver was not found at ${candidate}.`, { + candidate, + cause: error?.code, + }); + } + + if (!safeOwner(linkStat, identity.uid) || !safeOwner(targetStat, identity.uid)) { + return unavailable( + "unsafe-driver-owner", + "Cua Driver must be owned by the current user or root.", + { candidate, canonicalPath, affectedPaths: [canonicalPath] }, + ); + } + if (!targetStat.isFile()) { + return unavailable("driver-not-file", "Cua Driver must resolve to a regular file.", { + candidate, + canonicalPath, + }); + } + if ((Number(targetStat.mode) & 0o111) === 0) { + return unavailable("driver-not-executable", "Cua Driver is not executable.", { + candidate, + canonicalPath, + }); + } + const targetPermissionError = writablePermissionError(canonicalPath, targetStat, permissionCheck); + if (targetPermissionError) return { ...targetPermissionError, candidate, canonicalPath }; + + try { + const lexicalError = validatePathComponents(candidate, identity.uid, permissionCheck); + if (lexicalError) return { ...lexicalError, candidate, canonicalPath }; + const canonicalError = validatePathComponents(canonicalPath, identity.uid, permissionCheck); + if (canonicalError) return { ...canonicalError, candidate, canonicalPath }; + fs.accessSync(canonicalPath, fs.constants.X_OK); + } catch (error) { + return unavailable("driver-not-executable", "Cua Driver cannot be executed.", { + candidate, + canonicalPath, + cause: error?.code, + }); + } + + try { + const finalIdentity = captureDriverFileIdentity(canonicalPath); + if (!sameDriverFileIdentity(fileIdentity, finalIdentity)) { + return unavailable("driver-changed", "Cua Driver changed while it was being validated.", { + candidate, + canonicalPath, + }); + } + } catch (error) { + return unavailable("driver-not-found", "Cua Driver changed while it was being validated.", { + candidate, + canonicalPath, + cause: error?.code, + }); + } + + return { status: "found", path: canonicalPath, fileIdentity }; +} + +function discoverLinuxCuaDriver({ + env = process.env, + homeDir = os.homedir(), + bundledDriverPath, + currentUid, + currentGid, + currentUsername, + lookupPrivateGroup, +} = {}) { + const validationOptions = { currentUid, currentGid, currentUsername, lookupPrivateGroup }; + const explicit = env.CUA_DRIVER_PATH; + if (explicit) { + const result = validateDriverCandidate(explicit, validationOptions); + return result.status === "found" ? { ...result, source: "environment" } : result; + } + + // A packaged build has one reviewed driver paired with the app release. + // Never fall through to ambient user/PATH code when that bundle is missing + // or unsafe: a damaged package must fail closed. CUA_DRIVER_PATH above is + // the sole intentional override for development and incident response. + if (bundledDriverPath) { + const result = validateDriverCandidate(bundledDriverPath, validationOptions); + return result.status === "found" ? { ...result, source: "bundled" } : result; + } + + const localCandidate = path.join(homeDir, ".local", "bin", "cua-driver"); + if (fs.existsSync(localCandidate)) { + const result = validateDriverCandidate(localCandidate, validationOptions); + if (result.status === "found") return { ...result, source: "user-local" }; + return result; + } + + let firstUnsafe = null; + for (const directory of sanitizePath(env.PATH).split(path.delimiter).filter(Boolean)) { + const candidate = path.join(directory, "cua-driver"); + if (!fs.existsSync(candidate)) continue; + const result = validateDriverCandidate(candidate, validationOptions); + if (result.status === "found") return { ...result, source: "path" }; + firstUnsafe ??= result; + } + return ( + firstUnsafe ?? + unavailable( + "driver-not-found", + "Cua Driver was not found. Install it, then try again.", + ) + ); +} + +function parseJsonObject(value, label) { + let parsed; + try { + parsed = JSON.parse(value); + } catch { + throw commandFailure("invalid-json", `${label} returned invalid JSON.`); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw commandFailure("invalid-json", `${label} returned an invalid JSON object.`); + } + return parsed; +} + +function parseVersion(stdout) { + const match = String(stdout).trim().match(/(?:cua-driver\s+)?(\d+\.\d+\.\d+)/i); + return match?.[1] ?? null; +} + +function validateManifest(manifest, binaryPath) { + if (manifest.schema_version !== CERTIFIED_MANIFEST_SCHEMA) { + throw commandFailure( + "unsupported-manifest", + `Cua Driver manifest schema ${String(manifest.schema_version)} is not supported.`, + ); + } + if (manifest.binary_version !== CERTIFIED_DRIVER_VERSION) { + throw commandFailure( + "unsupported-driver-version", + `Cua Driver ${String(manifest.binary_version)} is not supported; install ${CERTIFIED_DRIVER_VERSION}.`, + ); + } + const invocation = manifest.mcp_invocation; + if ( + !invocation || + typeof invocation.command !== "string" || + !Array.isArray(invocation.args) || + invocation.args.length !== 1 || + invocation.args[0] !== "mcp" + ) { + throw commandFailure("unsupported-manifest", "Cua Driver returned an unsupported MCP contract."); + } + let invocationPath; + try { + invocationPath = fs.realpathSync(invocation.command); + } catch { + throw commandFailure("unsupported-manifest", "Cua Driver MCP command could not be verified."); + } + if (invocationPath !== binaryPath) { + throw commandFailure("unsupported-manifest", "Cua Driver MCP command does not match the verified binary."); + } + return { command: binaryPath, args: ["mcp"] }; +} + +function validateDoctor(report, { session = "x11" } = {}) { + if (typeof report.ok !== "boolean" || !Array.isArray(report.probes)) { + throw commandFailure("invalid-doctor-report", "Cua Driver returned an invalid doctor report."); + } + const probes = report.probes.map((probe) => { + if ( + !probe || + typeof probe.label !== "string" || + !["ok", "warn", "err"].includes(probe.status) || + typeof probe.message !== "string" + ) { + throw commandFailure("invalid-doctor-report", "Cua Driver returned an invalid doctor probe."); + } + return { + label: probe.label, + status: probe.status, + message: probe.message, + ...(typeof probe.detail === "string" ? { detail: probe.detail } : {}), + }; + }); + const byLabel = new Map(probes.map((probe) => [probe.label, probe])); + const display = byLabel.get("display server"); + const x11 = byLabel.get("X11 connection"); + const atSpi = byLabel.get("AT-SPI"); + if (!report.ok || probes.some((probe) => probe.status === "err")) { + throw commandFailure("doctor-failed", "Cua Driver diagnostics reported an error.", { probes }); + } + if (session === "wayland") { + if (display?.status !== "ok" || !display.message.startsWith("Wayland")) { + throw commandFailure( + "wayland-session-unavailable", + "Cua Driver did not confirm an active Wayland display.", + { probes }, + ); + } + } else { + if (display?.status !== "ok" || !display.message.startsWith("X11 ")) { + throw commandFailure("x11-unavailable", "Cua Driver did not confirm an Xorg display.", { probes }); + } + if (!x11 || x11.status === "err") { + throw commandFailure("x11-unavailable", "Cua Driver could not verify the Xorg session.", { probes }); + } + } + if (atSpi?.status !== "ok") { + throw commandFailure("at-spi-unavailable", "Cua Driver could not reach the AT-SPI accessibility bus.", { + probes, + }); + } + return { ok: true, probes, warnings: probes.filter((probe) => probe.status === "warn") }; +} + +async function inspectLinuxCuaDriver({ + platform = process.platform, + arch = process.arch, + env = process.env, + homeDir = os.homedir(), + bundledDriverPath, + currentUid, + currentGid, + currentUsername, + lookupPrivateGroup, + run = runCuaCommand, +} = {}) { + const declaredSession = String(env.XDG_SESSION_TYPE ?? "").toLowerCase(); + const session = + declaredSession === "wayland" || env.WAYLAND_DISPLAY + ? "wayland" + : declaredSession === "x11" || declaredSession === "xorg" + ? "x11" + : "unknown"; + if (platform !== "linux") { + return unavailable("unsupported-platform", "Linux local control is only available on Ubuntu."); + } + if (arch !== "x64") { + return unavailable( + "unsupported-architecture", + "Bundled Linux local control is currently available only on x64 Ubuntu.", + ); + } + if (session === "wayland") { + const desktops = [env.XDG_CURRENT_DESKTOP, env.XDG_SESSION_DESKTOP] + .flatMap((value) => String(value ?? "").toLowerCase().split(":")) + .filter(Boolean); + if (!desktops.includes("gnome")) { + return unavailable( + "wayland-compositor-unsupported", + "Wayland local control is currently limited to GNOME.", + ); + } + if (!env.WAYLAND_DISPLAY || !env.DBUS_SESSION_BUS_ADDRESS) { + return unavailable( + "wayland-session-unavailable", + "Local control requires an active GNOME Wayland desktop session.", + ); + } + } else if (session !== "x11") { + return unavailable( + "desktop-session-required", + "Local control requires an interactive GNOME desktop session.", + ); + } + if (session === "x11" && !env.DISPLAY) { + return unavailable("display-unavailable", "Local control requires an active Xorg display."); + } + + const discovered = discoverLinuxCuaDriver({ + env, + homeDir, + bundledDriverPath, + currentUid, + currentGid, + currentUsername, + lookupPrivateGroup, + }); + if (discovered.status !== "found") return discovered; + const commandEnv = desktopCommandEnvironment( + env, + session === "wayland" ? { CUA_DRIVER_RS_ENABLE_WAYLAND: "1" } : {}, + ); + + try { + const versionResult = await run(discovered.path, ["--version"], { env: commandEnv }); + const driverVersion = parseVersion(versionResult.stdout || versionResult.stderr); + if (versionResult.exitCode !== 0 || driverVersion !== CERTIFIED_DRIVER_VERSION) { + return unavailable( + "unsupported-driver-version", + `Cua Driver ${driverVersion ?? "unknown"} is not supported; install ${CERTIFIED_DRIVER_VERSION}.`, + { path: discovered.path, source: discovered.source, driverVersion }, + ); + } + + const manifestResult = await run(discovered.path, ["manifest"], { env: commandEnv }); + if (manifestResult.exitCode !== 0) { + return unavailable("manifest-failed", "Cua Driver manifest validation failed.", { + path: discovered.path, + source: discovered.source, + }); + } + const manifest = parseJsonObject(manifestResult.stdout, "Cua Driver manifest"); + const mcp = validateManifest(manifest, discovered.path); + + const doctorResult = await run(discovered.path, ["doctor", "--json"], { env: commandEnv }); + const doctorReport = parseJsonObject(doctorResult.stdout, "Cua Driver doctor"); + if (doctorResult.exitCode !== 0 && doctorReport.ok !== false) { + return unavailable("doctor-failed", "Cua Driver diagnostics failed.", { + path: discovered.path, + source: discovered.source, + }); + } + const doctor = validateDoctor(doctorReport, { session }); + + return { + status: "ready", + path: discovered.path, + source: discovered.source, + fileIdentity: discovered.fileIdentity, + driverVersion, + manifestSchema: manifest.schema_version, + mcp, + doctor, + commandEnv, + session, + ...(session === "wayland" ? { compositor: "gnome-mutter" } : {}), + }; + } catch (error) { + return unavailable(error?.code ?? "diagnostics-failed", error?.message ?? String(error), { + path: discovered.path, + source: discovered.source, + ...(error?.probes ? { probes: error.probes } : {}), + }); + } +} + +module.exports = { + CERTIFIED_DRIVER_VERSION, + CERTIFIED_MANIFEST_SCHEMA, + DRIVER_FILE_IDENTITY_KEYS, + captureDriverFileIdentity, + desktopCommandEnvironment, + discoverLinuxCuaDriver, + inspectLinuxCuaDriver, + parseVersion, + privatePrimaryGroup, + runGetent, + runCuaCommand, + sameDriverFileIdentity, + sanitizePath, + validateDoctor, + validateDriverCandidate, + validateManifest, +}; diff --git a/electron/cua-linux.test.mjs b/electron/cua-linux.test.mjs new file mode 100644 index 000000000..71dc3d611 --- /dev/null +++ b/electron/cua-linux.test.mjs @@ -0,0 +1,577 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const { + desktopCommandEnvironment, + discoverLinuxCuaDriver, + inspectLinuxCuaDriver, + privatePrimaryGroup, + runCuaCommand, + runGetent, + sameDriverFileIdentity, + sanitizePath, + validateDriverCandidate, +} = require("./cua-linux.cjs"); + +const temporaryDirectories = []; + +function temporaryDirectory() { + const base = process.platform === "win32" ? os.tmpdir() : fs.realpathSync("/tmp"); + const directory = fs.mkdtempSync(path.join(base, "omb-cua-linux-")); + temporaryDirectories.push(directory); + return directory; +} + +function executable(directory, name = "cua-driver") { + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + const file = path.join(directory, name); + fs.writeFileSync(file, "#!/bin/sh\nexit 0\n", { mode: 0o700 }); + return file; +} + +function healthyDoctor() { + return { + ok: true, + probes: [ + { label: "binary", status: "ok", message: "cua-driver 0.19.3" }, + { label: "display server", status: "ok", message: "X11 (DISPLAY=:0)" }, + { label: "X11 connection", status: "ok", message: "connected, 1 visible top-level window" }, + { label: "AT-SPI", status: "ok", message: "org.a11y.Bus reachable via session bus" }, + { label: "telemetry", status: "warn", message: "test warning" }, + ], + }; +} + +function healthyWaylandDoctor() { + return { + ok: true, + probes: [ + { label: "binary", status: "ok", message: "cua-driver 0.19.3" }, + { + label: "display server", + status: "ok", + message: "Wayland+XWayland (WAYLAND_DISPLAY=wayland-0, DISPLAY=:0)", + }, + { label: "X11 connection", status: "warn", message: "no top-level windows returned" }, + { label: "AT-SPI", status: "ok", message: "org.a11y.Bus reachable via session bus" }, + ], + }; +} + +function successfulRunner(binary, { doctor = healthyDoctor() } = {}) { + return vi.fn(async (_command, args, options) => { + expect(_command).toBe(binary); + expect(options.env.OPENAI_API_KEY).toBeUndefined(); + if (args[0] === "--version") return { exitCode: 0, stdout: "cua-driver 0.19.3\n", stderr: "" }; + if (args[0] === "manifest") { + return { + exitCode: 0, + stdout: JSON.stringify({ + schema_version: "1", + binary_version: "0.19.3", + binary_path: binary, + mcp_invocation: { command: binary, args: ["mcp"] }, + }), + stderr: "", + }; + } + return { exitCode: 0, stdout: JSON.stringify(doctor), stderr: "" }; + }); +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +// Windows does not expose the POSIX executable and ownership semantics used by +// discovery. Canonical temp paths make the same contract useful on macOS. +describe.skipIf(process.platform === "win32")("Linux CUA discovery", () => { + it("rejects an invalid explicit override without falling through", () => { + const root = temporaryDirectory(); + const fallback = executable(path.join(root, "bin")); + const result = discoverLinuxCuaDriver({ + env: { CUA_DRIVER_PATH: path.join(root, "missing"), PATH: path.dirname(fallback) }, + homeDir: root, + }); + expect(result).toMatchObject({ status: "unavailable", reasonCode: "driver-not-found" }); + }); + + it("keeps an explicit valid override ahead of the packaged driver", () => { + const root = temporaryDirectory(); + const explicit = executable(path.join(root, "explicit")); + const bundled = executable(path.join(root, "resources")); + expect( + discoverLinuxCuaDriver({ + env: { CUA_DRIVER_PATH: explicit, PATH: "" }, + homeDir: root, + bundledDriverPath: bundled, + }), + ).toMatchObject({ status: "found", path: explicit, source: "environment" }); + }); + + it("uses the packaged driver ahead of user-local and PATH candidates", () => { + const root = temporaryDirectory(); + const bundled = executable(path.join(root, "resources")); + executable(path.join(root, ".local", "bin")); + const pathCandidate = executable(path.join(root, "path")); + expect( + discoverLinuxCuaDriver({ + env: { PATH: path.dirname(pathCandidate) }, + homeDir: root, + bundledDriverPath: bundled, + }), + ).toMatchObject({ status: "found", path: bundled, source: "bundled" }); + }); + + it("fails closed when a packaged driver is missing instead of using ambient code", () => { + const root = temporaryDirectory(); + executable(path.join(root, ".local", "bin")); + const pathCandidate = executable(path.join(root, "path")); + const missingBundle = path.join(root, "resources", "cua-driver"); + expect( + discoverLinuxCuaDriver({ + env: { PATH: path.dirname(pathCandidate) }, + homeDir: root, + bundledDriverPath: missingBundle, + }), + ).toMatchObject({ + status: "unavailable", + reasonCode: "driver-not-found", + candidate: missingBundle, + }); + }); + + it("resolves the official user-local symlink to its canonical executable", () => { + const root = temporaryDirectory(); + const release = executable(path.join(root, ".cua-driver", "packages", "releases", "0.19.3")); + const localBin = path.join(root, ".local", "bin"); + fs.mkdirSync(localBin, { recursive: true, mode: 0o700 }); + fs.symlinkSync(release, path.join(localBin, "cua-driver")); + + expect(discoverLinuxCuaDriver({ env: { PATH: "" }, homeDir: root })).toMatchObject({ + status: "found", + path: release, + source: "user-local", + fileIdentity: expect.objectContaining({ + dev: expect.any(String), + ino: expect.any(String), + mtimeNs: expect.any(String), + ctimeNs: expect.any(String), + }), + }); + }); + + it("ignores empty and relative PATH entries and preserves literal metacharacters", () => { + const root = temporaryDirectory(); + const safeDirectory = path.join(root, "driver $; directory"); + const binary = executable(safeDirectory); + const value = ["", ".", "relative/bin", safeDirectory, safeDirectory].join(path.delimiter); + expect(sanitizePath(value)).toBe(safeDirectory); + expect(discoverLinuxCuaDriver({ env: { PATH: value }, homeDir: path.join(root, "home") })).toMatchObject({ + status: "found", + path: binary, + source: "path", + }); + }); + + it.skipIf(process.platform !== "linux")( + "accepts the official 0775 layout only for a proven user-private primary group", + () => { + const root = temporaryDirectory(); + const releaseDirectory = path.join(root, ".cua-driver", "packages", "releases", "0.19.3"); + const release = executable(releaseDirectory); + const localBin = path.join(root, ".local", "bin"); + fs.mkdirSync(localBin, { recursive: true, mode: 0o775 }); + fs.symlinkSync(release, path.join(localBin, "cua-driver")); + for (const component of [ + path.join(root, ".local"), + localBin, + path.join(root, ".cua-driver"), + path.join(root, ".cua-driver", "packages"), + path.join(root, ".cua-driver", "packages", "releases"), + releaseDirectory, + release, + ]) { + fs.chmodSync(component, 0o775); + } + const identity = os.userInfo(); + const lookupPrivateGroup = vi.fn(() => ({ + exclusive: true, + gid: identity.gid, + name: identity.username, + })); + + expect( + discoverLinuxCuaDriver({ + env: { PATH: "" }, + homeDir: root, + currentUid: identity.uid, + currentGid: identity.gid, + currentUsername: identity.username, + lookupPrivateGroup, + }), + ).toMatchObject({ status: "found", path: release, source: "user-local" }); + expect(lookupPrivateGroup).toHaveBeenCalledTimes(1); + }, + ); + + it.skipIf(process.platform !== "linux")( + "rejects a group-writable executable when the group is shared or unverifiable", + () => { + const root = temporaryDirectory(); + const binary = executable(path.join(root, "bin")); + fs.chmodSync(binary, 0o720); + expect( + validateDriverCandidate(binary, { + lookupPrivateGroup: () => ({ exclusive: false, reason: "primary-group-shared" }), + }), + ).toMatchObject({ + status: "unavailable", + reasonCode: "unsafe-driver-permissions", + affectedPaths: [binary], + permissionReason: "primary-group-shared", + }); + }, + ); + + it.skipIf(process.platform !== "linux")( + "contains a failed group lookup and keeps the exact affected path", + () => { + const root = temporaryDirectory(); + const binary = executable(path.join(root, "bin")); + fs.chmodSync(binary, 0o720); + expect( + validateDriverCandidate(binary, { + lookupPrivateGroup: () => { + throw new Error("NSS unavailable"); + }, + }), + ).toMatchObject({ + status: "unavailable", + reasonCode: "unsafe-driver-permissions", + affectedPaths: [binary], + permissionReason: "lookup-failed", + }); + }, + ); + + it("always rejects world-writable paths and reports the exact component", () => { + const root = temporaryDirectory(); + const directory = path.join(root, "world-writable"); + const binary = executable(directory); + fs.chmodSync(directory, 0o707); + expect( + validateDriverCandidate(binary, { + lookupPrivateGroup: () => { + throw new Error("world-write must not query group membership"); + }, + }), + ).toMatchObject({ + status: "unavailable", + reasonCode: "unsafe-driver-permissions", + affectedPaths: [directory], + }); + }); + + it("does not need group lookup for ordinary 0755 paths", () => { + const root = temporaryDirectory(); + const binary = executable(path.join(root, "bin")); + fs.chmodSync(path.dirname(binary), 0o755); + fs.chmodSync(binary, 0o755); + const lookupPrivateGroup = vi.fn(() => ({ exclusive: false, reason: "lookup-failed" })); + expect(validateDriverCandidate(binary, { lookupPrivateGroup })).toMatchObject({ + status: "found", + path: binary, + }); + expect(lookupPrivateGroup).not.toHaveBeenCalled(); + }); + + it("rejects a non-x64 Linux runtime before executing diagnostics", async () => { + const run = vi.fn(); + await expect( + inspectLinuxCuaDriver({ + platform: "linux", + arch: "arm64", + env: { DISPLAY: ":0", XDG_SESSION_TYPE: "x11" }, + run, + }), + ).resolves.toMatchObject({ + status: "unavailable", + reasonCode: "unsupported-architecture", + }); + expect(run).not.toHaveBeenCalled(); + }); + + it("captures a strict file identity and detects metadata or content changes", () => { + const root = temporaryDirectory(); + const binary = executable(path.join(root, "bin")); + const first = validateDriverCandidate(binary); + const unchanged = validateDriverCandidate(binary); + expect(first).toMatchObject({ status: "found", fileIdentity: expect.any(Object) }); + expect(sameDriverFileIdentity(first.fileIdentity, unchanged.fileIdentity)).toBe(true); + + fs.appendFileSync(binary, "# replacement\n"); + const changed = validateDriverCandidate(binary); + expect(changed.status).toBe("found"); + expect(sameDriverFileIdentity(first.fileIdentity, changed.fileIdentity)).toBe(false); + expect(sameDriverFileIdentity({}, {})).toBe(false); + }); +}); + +describe("Linux private primary group proof", () => { + const identity = { uid: 1000, gid: 1000, username: "kesleydev" }; + const getent = ({ group = "kesleydev:x:1000:", passwd = "kesleydev:x:1000:1000::/home/kesleydev:/bin/bash" } = {}) => + vi.fn((args) => ({ + ok: true, + stdout: args[0] === "group" ? `${group}\n` : `${passwd}\n`, + })); + + it("accepts an exclusive user-private primary group", () => { + expect(privatePrimaryGroup(identity, { getent: getent() })).toEqual({ + exclusive: true, + gid: 1000, + name: "kesleydev", + }); + }); + + it("rejects explicit supplementary members and another primary-GID account", () => { + expect( + privatePrimaryGroup(identity, { + getent: getent({ group: "kesleydev:x:1000:other" }), + }), + ).toMatchObject({ exclusive: false, reason: "primary-group-shared" }); + expect( + privatePrimaryGroup(identity, { + getent: getent({ + passwd: [ + "kesleydev:x:1000:1000::/home/kesleydev:/bin/bash", + "other:x:1001:1000::/home/other:/bin/bash", + ].join("\n"), + }), + }), + ).toMatchObject({ exclusive: false, reason: "primary-group-shared" }); + }); + + it("fails closed when NSS lookup cannot prove membership", () => { + expect( + privatePrimaryGroup(identity, { + getent: vi.fn(() => ({ ok: false, reason: "lookup-timeout" })), + }), + ).toEqual({ exclusive: false, reason: "lookup-timeout" }); + }); + + it("fails closed on malformed NSS enumeration", () => { + expect( + privatePrimaryGroup(identity, { + getent: getent({ + passwd: [ + "kesleydev:x:1000:1000::/home/kesleydev:/bin/bash", + "malformed-entry", + ].join("\n"), + }), + }), + ).toEqual({ exclusive: false, reason: "lookup-malformed" }); + }); + + it("uses absolute getent argv without a shell and with bounded resources", () => { + const spawnCommand = vi.fn(() => ({ status: 0, stdout: "kesleydev:x:1000:\n", stderr: "" })); + expect(runGetent(["group", "1000"], { spawnCommand })).toEqual({ + ok: true, + stdout: "kesleydev:x:1000:\n", + }); + expect(spawnCommand).toHaveBeenCalledWith( + "/usr/bin/getent", + ["group", "1000"], + expect.objectContaining({ + maxBuffer: expect.any(Number), + shell: false, + timeout: expect.any(Number), + }), + ); + }); +}); + +describe.skipIf(process.platform === "win32")("Linux CUA diagnostics", () => { + it("passes only the minimal desktop environment and returns a certified contract", async () => { + const root = temporaryDirectory(); + const binary = executable(path.join(root, "bin")); + const run = successfulRunner(binary); + const result = await inspectLinuxCuaDriver({ + platform: "linux", + arch: "x64", + homeDir: path.join(root, "home"), + env: { + CUA_DRIVER_PATH: binary, + XDG_SESSION_TYPE: "x11", + DISPLAY: ":0", + DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/1000/bus", + PATH: "/usr/bin", + HOME: root, + OPENAI_API_KEY: "must-not-leak", + }, + run, + }); + + expect(result).toMatchObject({ + status: "ready", + path: binary, + fileIdentity: validateDriverCandidate(binary).fileIdentity, + driverVersion: "0.19.3", + manifestSchema: "1", + mcp: { command: binary, args: ["mcp"] }, + }); + expect(result.doctor.warnings).toHaveLength(1); + expect(run).toHaveBeenCalledTimes(3); + }); + + it("fails before discovery or execution on a non-GNOME Wayland compositor", async () => { + const run = vi.fn(); + const result = await inspectLinuxCuaDriver({ + platform: "linux", + arch: "x64", + env: { XDG_SESSION_TYPE: "wayland", WAYLAND_DISPLAY: "wayland-0", DISPLAY: ":0" }, + run, + }); + expect(result).toMatchObject({ + status: "unavailable", + reasonCode: "wayland-compositor-unsupported", + }); + expect(run).not.toHaveBeenCalled(); + }); + + it("does not infer a supported local-control session from DISPLAY alone", async () => { + const run = vi.fn(); + const result = await inspectLinuxCuaDriver({ + platform: "linux", + arch: "x64", + env: { DISPLAY: ":0" }, + run, + }); + expect(result).toMatchObject({ + status: "unavailable", + reasonCode: "desktop-session-required", + }); + expect(run).not.toHaveBeenCalled(); + }); + + it("certifies GNOME Wayland diagnostics with the native backend explicitly enabled", async () => { + const root = temporaryDirectory(); + const binary = executable(path.join(root, "bin")); + const run = successfulRunner(binary, { doctor: healthyWaylandDoctor() }); + const result = await inspectLinuxCuaDriver({ + platform: "linux", + arch: "x64", + homeDir: root, + env: { + CUA_DRIVER_PATH: binary, + XDG_SESSION_TYPE: "wayland", + XDG_CURRENT_DESKTOP: "ubuntu:GNOME", + WAYLAND_DISPLAY: "wayland-0", + DISPLAY: ":0", + DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/1000/bus", + }, + run, + }); + + expect(result).toMatchObject({ + status: "ready", + session: "wayland", + compositor: "gnome-mutter", + commandEnv: { CUA_DRIVER_RS_ENABLE_WAYLAND: "1" }, + }); + for (const call of run.mock.calls) { + expect(call[2].env.CUA_DRIVER_RS_ENABLE_WAYLAND).toBe("1"); + } + }); + + it("rejects version and manifest drift", async () => { + const root = temporaryDirectory(); + const binary = executable(path.join(root, "bin")); + const run = vi.fn(async (_command, args) => { + if (args[0] === "--version") return { exitCode: 0, stdout: "cua-driver 0.20.0", stderr: "" }; + throw new Error("should not continue"); + }); + const result = await inspectLinuxCuaDriver({ + platform: "linux", + arch: "x64", + homeDir: root, + env: { CUA_DRIVER_PATH: binary, XDG_SESSION_TYPE: "x11", DISPLAY: ":0" }, + run, + }); + expect(result).toMatchObject({ status: "unavailable", reasonCode: "unsupported-driver-version" }); + expect(run).toHaveBeenCalledTimes(1); + }); + + it("requires healthy X11 and AT-SPI probes", async () => { + const root = temporaryDirectory(); + const binary = executable(path.join(root, "bin")); + const run = successfulRunner(binary); + run.mockImplementationOnce(async () => ({ exitCode: 0, stdout: "cua-driver 0.19.3", stderr: "" })); + run.mockImplementationOnce(async () => ({ + exitCode: 0, + stdout: JSON.stringify({ + schema_version: "1", + binary_version: "0.19.3", + mcp_invocation: { command: binary, args: ["mcp"] }, + }), + stderr: "", + })); + run.mockImplementationOnce(async () => ({ + exitCode: 0, + stdout: JSON.stringify({ + ...healthyDoctor(), + probes: healthyDoctor().probes.map((probe) => + probe.label === "AT-SPI" ? { ...probe, status: "warn" } : probe, + ), + }), + stderr: "", + })); + const result = await inspectLinuxCuaDriver({ + platform: "linux", + arch: "x64", + homeDir: root, + env: { CUA_DRIVER_PATH: binary, XDG_SESSION_TYPE: "x11", DISPLAY: ":0" }, + run, + }); + expect(result).toMatchObject({ status: "unavailable", reasonCode: "at-spi-unavailable" }); + }); +}); + +describe("bounded command execution", () => { + it("times out and bounds output", async () => { + await expect( + runCuaCommand(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { timeoutMs: 20 }), + ).rejects.toMatchObject({ code: "command-timeout" }); + + await expect( + runCuaCommand(process.execPath, ["-e", "process.stdout.write('x'.repeat(10000))"], { + maxOutputBytes: 64, + }), + ).rejects.toMatchObject({ code: "output-too-large" }); + }); +}); + +describe.skipIf(process.platform === "win32")("minimal child environment", () => { + it("keeps desktop session values but drops application secrets", () => { + expect( + desktopCommandEnvironment({ + HOME: "/home/test", + DISPLAY: ":0", + PATH: ":relative:/usr/bin:/usr/bin", + OPENAI_API_KEY: "secret", + }), + ).toEqual({ + HOME: "/home/test", + DISPLAY: ":0", + PATH: "/usr/bin", + CUA_DRIVER_RS_UPDATE_CHECK: "false", + CUA_DRIVER_RS_TELEMETRY_ENABLED: "false", + }); + }); +}); diff --git a/electron/cua.mjs b/electron/cua.mjs index 4454e0db0..50c035c9f 100644 --- a/electron/cua.mjs +++ b/electron/cua.mjs @@ -24,6 +24,11 @@ import { pathToFileURL } from "node:url"; const require = createRequire(import.meta.url); const { createCuaConnectionStore } = require("./cua-connection.cjs"); +const { + createLinuxCuaRuntime, + createUnavailableLinuxRuntime, +} = require("./cua-linux-runtime.cjs"); +const { cleanupAppImageCuaBundle, stageAppImageCuaBundle } = require("./cua-linux-bundle.cjs"); const INSTALLED_DRIVER = "/Applications/CuaDriver.app/Contents/MacOS/cua-driver"; const STANDALONE_SOCKET = path.join( @@ -35,10 +40,49 @@ const CUA_ENV = { CUA_DRIVER_RS_TELEMETRY_ENABLED: "0" }; process.env.CUA_DRIVER_RS_TELEMETRY_ENABLED ??= "0"; let embeddedHost = null; // EmbeddedCuaDriverHost | null +let linuxRuntime = null; +let linuxBundleStage = null; +let stateListener = () => {}; const connectionStore = createCuaConnectionStore({ getUserData: () => app.getPath("userData"), }); +function ensureLinuxRuntime() { + if (!linuxRuntime) { + try { + let bundledDriverPath; + if (app.isPackaged && !process.env.CUA_DRIVER_PATH) { + bundledDriverPath = path.join(process.resourcesPath, "cua-linux-x64", "cua-driver"); + // AppImage builders may normalize the read-only resource tree to 0755 + // or 0775. Always copy only the pinned binaries to a fresh 0700 + // process-owned directory and verify their hashes after the copy, so + // every AppImage follows the same execution invariant. + if (process.env.APPIMAGE) { + linuxBundleStage ??= stageAppImageCuaBundle({ resourcesPath: process.resourcesPath }); + bundledDriverPath = linuxBundleStage.driverPath; + } + } + linuxRuntime = createLinuxCuaRuntime({ + getUserData: () => app.getPath("userData"), + connectionStore, + bundledDriverPath, + onChange: (connection) => stateListener(connection), + }); + } catch (error) { + console.error("[cua] Bundled Linux driver failed integrity validation:", error); + linuxRuntime = createUnavailableLinuxRuntime({ + connectionStore, + onChange: (connection) => stateListener(connection), + }); + } + } + return linuxRuntime; +} + +export function setCuaStateListener(listener) { + stateListener = typeof listener === "function" ? listener : () => {}; +} + export function resolveDriverBinary() { if (process.env.CUA_DRIVER_PATH) return process.env.CUA_DRIVER_PATH; if (app.isPackaged) { @@ -107,6 +151,7 @@ async function startEmbedded(binary) { } export async function startCua() { + if (process.platform === "linux") return ensureLinuxRuntime().initialize(); const binary = resolveDriverBinary(); if (!binary) { return connectionStore.persist({ @@ -164,6 +209,14 @@ export function cuaPermissionsStatus() { } export async function stopCua() { + if (linuxRuntime) { + await linuxRuntime.shutdown(); + if (linuxBundleStage) { + cleanupAppImageCuaBundle(linuxBundleStage); + linuxBundleStage = null; + } + return; + } if (embeddedHost) { try { await embeddedHost.stop(); @@ -181,4 +234,42 @@ export async function stopCua() { export function registerCuaIpc() { ipcMain.handle("cua:connection", () => connectionStore.get()); ipcMain.handle("cua:permissions", () => cuaPermissionsStatus()); + ipcMain.handle("cua:linux-status", () => + process.platform === "linux" + ? ensureLinuxRuntime().getStatus() + : { enabled: false, status: "unavailable", reasonCode: "unsupported-platform" }, + ); + ipcMain.handle("cua:linux-enable", async () => { + if (process.platform !== "linux") { + return { enabled: false, status: "unavailable", reasonCode: "unsupported-platform" }; + } + try { + await ensureLinuxRuntime().enable(); + } catch (error) { + console.error("[cua] Linux enable failed:", error); + } + return ensureLinuxRuntime().getStatus(); + }); + ipcMain.handle("cua:linux-disable", async () => { + if (process.platform !== "linux") { + return { enabled: false, status: "unavailable", reasonCode: "unsupported-platform" }; + } + try { + await ensureLinuxRuntime().disable(); + } catch (error) { + console.error("[cua] Linux disable failed:", error); + } + return ensureLinuxRuntime().getStatus(); + }); + ipcMain.handle("cua:linux-retry", async () => { + if (process.platform !== "linux") { + return { enabled: false, status: "unavailable", reasonCode: "unsupported-platform" }; + } + try { + await ensureLinuxRuntime().retry(); + } catch (error) { + console.error("[cua] Linux retry failed:", error); + } + return ensureLinuxRuntime().getStatus(); + }); } diff --git a/electron/main.mjs b/electron/main.mjs index 1426b301f..8329bf494 100644 --- a/electron/main.mjs +++ b/electron/main.mjs @@ -1,15 +1,22 @@ -import { app, BrowserWindow, clipboard, desktopCapturer, dialog, ipcMain, safeStorage, session, shell, systemPreferences, utilityProcess } from "electron"; +import { app, BrowserWindow, clipboard, desktopCapturer, dialog, ipcMain, safeStorage, screen, session, shell, systemPreferences, utilityProcess } from "electron"; +import { createRequire } from "node:module"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { startCua, stopCua, registerCuaIpc } from "./cua.mjs"; +import { startCua, stopCua, registerCuaIpc, setCuaStateListener } from "./cua.mjs"; import { createAndroidDeviceController } from "./android-device.mjs"; import { finishSpeech, startSpeech, stopSpeech } from "./speech.mjs"; import { openBlankTerminal } from "./terminal-launch.mjs"; import { startUpdater, registerUpdaterIpc } from "./updater.mjs"; import capabilitiesModule from "./capabilities.cjs"; -const { desktopCapabilities } = capabilitiesModule; +const { desktopCapabilities, nativeDesktopActions } = capabilitiesModule; +const nativeActions = nativeDesktopActions(process.platform); +const require = createRequire(import.meta.url); +const { createDisplayMediaGuard, invokeDisplayMediaCallback, selectCaptureSource } = require( + "./screen-preview.cjs", +); +const { STAGE_PREFIX: APPIMAGE_CUA_STAGE_PREFIX } = require("./cua-linux-bundle.cjs"); const __dirname = path.dirname(fileURLToPath(import.meta.url)); // 127.0.0.1 explicitly — vite binds IPv4; a bare "localhost" here can @@ -252,6 +259,26 @@ const ERROR_PAGE = let cuaReady = Promise.resolve({ mode: "unavailable", reason: "not-started" }); const androidDevice = createAndroidDeviceController({ resourcesPath: process.resourcesPath }); +const displayMediaGuard = createDisplayMediaGuard(); +let displayMediaRequestCount = 0; + +function rendererOrigin() { + return new URL(app.isPackaged ? `http://127.0.0.1:${SERVER_PORT}` : DEV_URL).origin; +} + +function respondToDisplayMediaRequest(callback, response) { + const error = invokeDisplayMediaCallback(callback, response); + // An empty response intentionally rejects the renderer request, and Electron + // can surface that rejection by throwing from the callback. A selected + // source should never fail delivery, so keep that path visible in logs. + if (error && response.video) { + console.error("[screen-preview] failed to deliver selected source:", error); + } +} + +ipcMain.on("screen:preview-intent", (event) => { + event.returnValue = displayMediaGuard.begin(event.senderFrame); +}); function createWindow() { const isMac = process.platform === "darwin"; @@ -297,7 +324,22 @@ function createWindow() { const result = await win.webContents.executeJavaScript(` (async () => { if (!window.ogb?.getCapabilities) throw new Error("desktop preload bridge is unavailable"); - const [capabilities, healthResponse] = await Promise.all([ + let crashPromise = null; + if (${JSON.stringify(process.env.OMB_SMOKE_CUA === "1")}) { + crashPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + unsubscribe?.(); + reject(new Error("timed out waiting for CUA crash invalidation")); + }, 10000); + const unsubscribe = window.ogb.onCapabilitiesChanged((next) => { + if (next.localComputer.reasonCode !== "daemon-exited") return; + clearTimeout(timeout); + unsubscribe(); + resolve(next.localComputer.reasonCode); + }); + }); + } + const [initialCapabilities, healthResponse] = await Promise.all([ window.ogb.getCapabilities(), fetch("/api/health"), ]); @@ -305,7 +347,26 @@ function createWindow() { throw new Error(\`health request failed: \${healthResponse.status} \${healthResponse.statusText}\`); } const health = await healthResponse.json(); - return { capabilities, health, location: window.location.href, title: document.title }; + let capabilities = initialCapabilities; + let cuaCrashReason = null; + let cuaRetryStatus = null; + if (crashPromise) { + if (!initialCapabilities.localComputer.available) { + throw new Error("CUA was not ready before the simulated crash"); + } + cuaCrashReason = await crashPromise; + cuaRetryStatus = await window.ogb.localControl.retry(); + capabilities = await window.ogb.getCapabilities(); + } + return { + initialCapabilities, + capabilities, + cuaCrashReason, + cuaRetryStatus, + health, + location: window.location.href, + title: document.title, + }; })() `); const expectedLocation = `http://127.0.0.1:${SERVER_PORT}/`; @@ -314,11 +375,44 @@ function createWindow() { `unexpected packaged renderer URL: ${result.location} (expected ${expectedLocation})`, ); } + if (process.env.OMB_SMOKE_BUNDLED_CUA === "1") { + const connection = await cuaReady; + const expectedDriver = path.join( + process.resourcesPath, + "cua-linux-x64", + "cua-driver", + ); + let exactBundledPath = false; + try { + exactBundledPath = + Boolean(connection?.driver?.path) && + fs.realpathSync(connection.driver.path) === fs.realpathSync(expectedDriver); + } catch {} + result.cuaRuntime = { + driverSource: connection?.driver?.source, + exactBundledPath, + appImagePrivateStage: + Boolean(process.env.APPIMAGE) && + connection?.driver?.path !== expectedDriver && + path.basename(path.dirname(connection?.driver?.path ?? "")).startsWith( + APPIMAGE_CUA_STAGE_PREFIX, + ), + driverPath: connection?.driver?.path, + driverVersion: connection?.driver?.version, + daemonPid: connection?.daemon?.pid, + socketPath: connection?.daemon?.socketPath, + pidFile: connection?.daemon?.socketPath + ? path.join(path.dirname(connection.daemon.socketPath), "driver.pid") + : undefined, + mcpEnv: connection?.mcp?.env, + }; + } + result.displayMediaRequests = displayMediaRequestCount; console.log(`[smoke] renderer-ready ${JSON.stringify(result)}`); } catch (error) { console.error(`[smoke] renderer-failed ${error?.stack ?? error}`); } finally { - win.close(); + if (process.env.OMB_SMOKE_KEEP_OPEN !== "1") win.close(); } }); } @@ -331,7 +425,7 @@ function createWindow() { return win; } -// "This Mac" screen preview — served from the main process so the Screen +// Local-control screen preview — served from the main process so the Screen // Recording permission prompt attributes to the app, never the server ipcMain.handle("screen:frame", async () => { if (process.platform !== "darwin") return null; @@ -398,12 +492,12 @@ ipcMain.handle("desktop:open-external", async (_event, rawUrl) => { ipcMain.handle("perm:status", () => ({ mic: - process.platform === "darwin" + nativeActions.appleMediaPermissions ? systemPreferences.getMediaAccessStatus?.("microphone") ?? "unknown" : "unsupported", })); ipcMain.handle("perm:request-mic", async () => { - if (process.platform !== "darwin") return false; + if (!nativeActions.appleMediaPermissions) return false; try { return await systemPreferences.askForMediaAccess("microphone"); } catch { @@ -414,7 +508,7 @@ ipcMain.handle("perm:request-mic", async () => { // macOS never re-prompts a denied permission — the only path is System // Settings; deep-link straight to the right privacy pane. ipcMain.handle("perm:open-settings", (_event, pane) => { - if (process.platform !== "darwin") return false; + if (!nativeActions.applePrivacySettings) return false; const panes = { mic: "Privacy_Microphone", screen: "Privacy_ScreenCapture", @@ -429,17 +523,17 @@ ipcMain.handle("perm:open-settings", (_event, pane) => { ipcMain.handle("speech:start", (event, options) => { const win = BrowserWindow.fromWebContents(event.sender); if (!win) return; - if (process.platform !== "darwin") { + if (!nativeActions.appleSpeech) { win.webContents.send("speech:end", { code: 2, reason: "unsupported-platform" }); return; } startSpeech(win, options); }); ipcMain.handle("speech:stop", () => { - if (process.platform === "darwin") stopSpeech(); + if (nativeActions.appleSpeech) stopSpeech(); }); ipcMain.handle("speech:finish", () => { - if (process.platform === "darwin") finishSpeech(); + if (nativeActions.appleSpeech) finishSpeech(); }); // ── companion sidecar ────────────────────────────────────────────────── @@ -504,6 +598,25 @@ ipcMain.handle("credential:set", async (_event, name, value) => { return body; }); +async function broadcastDesktopCapabilities() { + const capabilities = desktopCapabilities({ + platform: process.platform, + env: process.env, + packaged: app.isPackaged, + localConnection: await cuaReady, + }); + for (const window of BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) window.webContents.send("desktop:capabilities-changed", capabilities); + } +} + +setCuaStateListener((connection) => { + cuaReady = Promise.resolve(connection); + void broadcastDesktopCapabilities().catch((error) => { + console.error("[desktop] capability broadcast failed:", error); + }); +}); + app.whenReady().then(async () => { if (process.platform === "darwin") app.dock.setIcon(APP_ICON); if (app.isPackaged) { @@ -511,17 +624,53 @@ app.whenReady().then(async () => { await secureComposioConfig(); await ensureManagedComposioCredentials(); } - // getDisplayMedia in the renderer → this handler → ScreenCaptureKit, all - // inside the app's own processes — the one capture path macOS reliably - // attributes to the app (registers it in the Screen Recording pane and - // prompts). Used by the onboarding "Enable screen preview" button. - if (process.platform === "darwin") { + // Display capture remains user-initiated. The renderer first sends a + // short-lived one-shot intent, then calls getDisplayMedia in the same click. + // The handler binds that request to the same frame/origin, rejects audio, + // and requires Electron's active user-gesture signal. + if (process.platform === "darwin" || process.platform === "linux") { session.defaultSession.setDisplayMediaRequestHandler( - (_request, callback) => { + (request, callback) => { + displayMediaRequestCount += 1; + if (!displayMediaGuard.consume(request, rendererOrigin())) { + respondToDisplayMediaRequest(callback, {}); + return; + } + + const capabilities = desktopCapabilities({ + platform: process.platform, + env: process.env, + packaged: app.isPackaged, + }); + const captureHost = + process.platform === "darwin" ? "darwin" : capabilities.host.session; + if (!capabilities.screenPreview.available) { + respondToDisplayMediaRequest(callback, {}); + return; + } + desktopCapturer - .getSources({ types: ["screen"] }) - .then((sources) => callback(sources[0] ? { video: sources[0] } : {})) - .catch(() => callback({})); + .getSources({ types: ["screen"], thumbnailSize: { width: 0, height: 0 } }) + .then((sources) => { + const source = selectCaptureSource({ + sources, + host: captureHost, + primaryDisplayId: + process.platform === "linux" && captureHost === "x11" + ? screen.getPrimaryDisplay().id + : null, + }); + if (!source) { + console.warn( + `[screen-preview] rejected ${captureHost} source set (${sources.length} candidates)`, + ); + } + respondToDisplayMediaRequest(callback, source ? { video: source } : {}); + }) + .catch((error) => { + console.warn("[screen-preview] source discovery failed:", error); + respondToDisplayMediaRequest(callback, {}); + }); }, { useSystemPicker: false }, ); @@ -533,7 +682,7 @@ app.whenReady().then(async () => { // connection descriptor on first render. Never blocks window creation on // failure — computer use degrades to "unavailable", the rest still works. cuaReady = - process.platform === "darwin" + process.platform === "darwin" || process.platform === "linux" ? startCua().catch((e) => { console.error("[cua] start failed:", e); return { mode: "unavailable", reason: String(e) }; @@ -577,7 +726,7 @@ app.on("before-quit", (e) => { void stopCompanion(); // a live dictation session runs its own helper child that holds the mic — // stop it here so quitting never orphans a recording process - stopSpeech(); + if (nativeActions.appleSpeech) stopSpeech(); const cleanup = Promise.race([ stopCua().catch(() => {}), new Promise((resolve) => setTimeout(resolve, CUA_STOP_TIMEOUT_MS).unref()), diff --git a/electron/preload.cjs b/electron/preload.cjs index a63347233..a613f2580 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -6,6 +6,11 @@ contextBridge.exposeInMainWorld("ogb", { /** Host platform ("darwin" | "win32" | "linux") — for platform-aware UI. */ platform: process.platform, getCapabilities: () => ipcRenderer.invoke("desktop:capabilities"), + onCapabilitiesChanged: (cb) => { + const handler = (_event, capabilities) => cb(capabilities); + ipcRenderer.on("desktop:capabilities-changed", handler); + return () => ipcRenderer.removeListener("desktop:capabilities-changed", handler); + }, /** The companion sidecar: the one part of this app that listens off the * machine, so it runs as its own process and is off until switched on. * Every call answers with the whole state, so the panel never has to @@ -18,6 +23,14 @@ contextBridge.exposeInMainWorld("ogb", { cloudDesktop: (deviceId, allowed) => ipcRenderer.invoke("companion:cloud-desktop", deviceId, allowed), revoke: (deviceId) => ipcRenderer.invoke("companion:revoke", deviceId), }, + localControl: { + status: () => ipcRenderer.invoke("cua:linux-status"), + enable: () => ipcRenderer.invoke("cua:linux-enable"), + disable: () => ipcRenderer.invoke("cua:linux-disable"), + retry: () => ipcRenderer.invoke("cua:linux-retry"), + }, + /** Arms exactly one display-media request from the current renderer frame. */ + beginScreenPreviewIntent: () => ipcRenderer.sendSync("screen:preview-intent"), /** One frame of this computer's screen as a data: URL when supported. */ screenFrame: () => ipcRenderer.invoke("screen:frame"), /** Physical USB Android devices. Network ADB is deliberately excluded. */ diff --git a/electron/screen-preview.cjs b/electron/screen-preview.cjs new file mode 100644 index 000000000..9bfd1d69f --- /dev/null +++ b/electron/screen-preview.cjs @@ -0,0 +1,94 @@ +function frameKey(frame) { + if (!Number.isInteger(frame?.processId) || !Number.isInteger(frame?.routingId)) return null; + return `${frame.processId}:${frame.routingId}`; +} + +function originOf(value) { + try { + return new URL(value).origin; + } catch { + return null; + } +} + +function createDisplayMediaGuard({ now = Date.now, ttlMs = 5_000 } = {}) { + const intents = new Map(); + + function prune() { + const current = now(); + for (const [key, expiresAt] of intents) { + if (expiresAt < current) intents.delete(key); + } + } + + return Object.freeze({ + begin(frame) { + const key = frameKey(frame); + if (!key) return false; + prune(); + intents.set(key, now() + ttlMs); + return true; + }, + + consume(request, expectedOrigin) { + const key = frameKey(request?.frame); + if (!key) return false; + const expiresAt = intents.get(key); + intents.delete(key); + const requestOrigin = originOf(request.securityOrigin); + const allowedOrigin = originOf(expectedOrigin); + + return Boolean( + expiresAt !== undefined && + expiresAt >= now() && + request.userGesture === true && + request.videoRequested === true && + request.audioRequested === false && + requestOrigin !== null && + allowedOrigin !== null && + requestOrigin === allowedOrigin, + ); + }, + }); +} + +function selectCaptureSource({ sources, host, primaryDisplayId }) { + if (!Array.isArray(sources) || sources.length === 0) return null; + if (host === "wayland") return sources.length === 1 ? sources[0] : null; + if (host === "x11") { + const exact = sources.find( + (source) => + source.display_id !== undefined && + primaryDisplayId !== undefined && + String(source.display_id) === String(primaryDisplayId), + ); + // Some X11 backends omit or misreport display_id. A single enumerated + // source is still unambiguous; never guess when multiple sources remain. + return exact ?? (sources.length === 1 ? sources[0] : null); + } + if (host === "darwin") return sources[0]; + return null; +} + +// Electron may throw synchronously from the display-media callback when an +// empty response rejects a video request. That rejection is expected after a +// portal cancellation, but allowing it to escape from a Promise catch creates +// an unhandled rejection in the main process. Return the error to the caller so +// successful-response failures can still be logged without destabilizing the +// cancellation path. +function invokeDisplayMediaCallback(callback, response) { + try { + callback(response); + return null; + } catch (error) { + return error; + } +} + +module.exports = { + createDisplayMediaGuard, + frameKey, + invokeDisplayMediaCallback, + originOf, + selectCaptureSource, +}; diff --git a/electron/screen-preview.test.mjs b/electron/screen-preview.test.mjs new file mode 100644 index 000000000..5532a02bd --- /dev/null +++ b/electron/screen-preview.test.mjs @@ -0,0 +1,109 @@ +import { createRequire } from "node:module"; +import { describe, expect, it } from "vitest"; + +const require = createRequire(import.meta.url); +const { createDisplayMediaGuard, invokeDisplayMediaCallback, selectCaptureSource } = require( + "./screen-preview.cjs", +); + +const frame = { processId: 10, routingId: 20 }; +const validRequest = { + frame, + securityOrigin: "http://127.0.0.1:8799", + videoRequested: true, + audioRequested: false, + userGesture: true, +}; + +describe("display media request guard", () => { + it("allows one trusted, video-only request from the frame that declared intent", () => { + const guard = createDisplayMediaGuard({ now: () => 1_000 }); + + expect(guard.begin(frame)).toBe(true); + expect(guard.consume(validRequest, "http://127.0.0.1:8799/")).toBe(true); + expect(guard.consume(validRequest, "http://127.0.0.1:8799/")).toBe(false); + }); + + it.each([ + ["missing user gesture", { userGesture: false }], + ["audio capture", { audioRequested: true }], + ["missing video", { videoRequested: false }], + ["untrusted origin", { securityOrigin: "https://example.com" }], + ["unparseable origin", { securityOrigin: "not a URL" }], + ["different frame", { frame: { processId: 10, routingId: 21 } }], + ])("rejects %s", (_name, change) => { + const guard = createDisplayMediaGuard({ now: () => 1_000 }); + guard.begin(frame); + + expect( + guard.consume({ ...validRequest, ...change }, "http://127.0.0.1:8799"), + ).toBe(false); + }); + + it("rejects an expired intent", () => { + let current = 1_000; + const guard = createDisplayMediaGuard({ now: () => current, ttlMs: 500 }); + guard.begin(frame); + current = 1_501; + + expect(guard.consume(validRequest, "http://127.0.0.1:8799")).toBe(false); + }); + + it("rejects a request when both origins are missing or invalid", () => { + const guard = createDisplayMediaGuard({ now: () => 1_000 }); + guard.begin(frame); + expect(guard.consume({ ...validRequest, securityOrigin: undefined }, undefined)).toBe(false); + }); +}); + +describe("display source selection", () => { + const sources = [ + { id: "first", display_id: "41" }, + { id: "primary", display_id: "42" }, + ]; + + it("matches the Xorg primary display instead of choosing the first source", () => { + expect(selectCaptureSource({ sources, host: "x11", primaryDisplayId: 42 })).toEqual( + sources[1], + ); + expect(selectCaptureSource({ sources, host: "x11", primaryDisplayId: 99 })).toBeNull(); + }); + + it("uses an unambiguous Xorg source when display_id is absent or mismatched", () => { + const onlySource = { id: "only", display_id: "" }; + expect( + selectCaptureSource({ sources: [onlySource], host: "x11", primaryDisplayId: 42 }), + ).toEqual(onlySource); + expect(selectCaptureSource({ sources: [], host: "x11", primaryDisplayId: 42 })).toBeNull(); + expect( + selectCaptureSource({ + sources: [{ id: "first" }, { id: "second" }], + host: "x11", + primaryDisplayId: undefined, + }), + ).toBeNull(); + }); + + it("accepts only the single portal-selected Wayland source", () => { + expect( + selectCaptureSource({ sources: [sources[0]], host: "wayland", primaryDisplayId: 42 }), + ).toEqual(sources[0]); + expect(selectCaptureSource({ sources, host: "wayland", primaryDisplayId: 42 })).toBeNull(); + }); +}); + +describe("display media callback", () => { + it("contains Electron's synchronous rejection for an empty portal response", () => { + const rejection = new TypeError("Video was requested, but no video stream was provided"); + const callback = () => { + throw rejection; + }; + + expect(() => invokeDisplayMediaCallback(callback, {})).not.toThrow(); + expect(invokeDisplayMediaCallback(callback, {})).toBe(rejection); + }); + + it("returns null after delivering a selected source", () => { + expect(invokeDisplayMediaCallback(() => {}, { video: { id: "screen:0" } })).toBeNull(); + }); +}); diff --git a/package.json b/package.json index 2ec32c75c..009f86703 100644 --- a/package.json +++ b/package.json @@ -35,19 +35,23 @@ "test:watch": "vitest", "test:cua": "pnpm build:cua && node scripts/smoke-cua.mjs", "test:cua-container": "node scripts/smoke-cua-container.mjs", - "check:electron": "node --check electron/main.mjs && node --check electron/companion.mjs && node --check electron/companion-entry.mjs && node --check electron/terminal-launch.mjs && node --check electron/preload.cjs && node --check electron/capabilities.cjs && node --check electron/cua-connection.cjs && node --check electron/cua.mjs && node --check electron/android-device.mjs && node --check electron/speech.mjs", + "check:electron": "node scripts/check-electron.mjs", "preview": "vite preview", "build:server": "tsc -p tsconfig.server.build.json && node scripts/bundle-server.mjs", "build:companion": "tsc -p tsconfig.companion.build.json", "build:speech": "node electron/build-speech-helper.mjs", "build:cua": "node scripts/prepare-cua.mjs", "build:android-tools": "node scripts/prepare-android-tools.mjs", + "build:cua:linux": "node scripts/prepare-cua-linux.mjs", + "build:cua:linux:offline": "node scripts/prepare-cua-linux.mjs --offline", "build:updater": "node scripts/bundle-updater.mjs", "package:prepare": "pnpm build && pnpm build:server && pnpm build:companion && pnpm build:updater && pnpm build:android-tools", "package:mac": "pnpm package:prepare && pnpm build:speech && pnpm build:cua && electron-builder --mac --publish never", "package:win": "pnpm package:prepare && electron-builder --win --publish never", - "package:linux": "pnpm package:prepare && electron-builder --linux --x64 --publish never", - "package:linux:dir": "pnpm package:prepare && electron-builder --linux dir --x64 --publish never", + "package:linux": "pnpm package:prepare && pnpm build:cua:linux && electron-builder --linux --x64 --publish never", + "package:linux:offline": "pnpm package:prepare && pnpm build:cua:linux:offline && electron-builder --linux --x64 --publish never", + "smoke:linux-package": "node scripts/run-linux-package-smoke.mjs", + "package:linux:dir": "pnpm package:prepare && pnpm build:cua:linux && electron-builder --linux dir --x64 --publish never", "package": "pnpm package:mac", "test:packaged-server": "pnpm build:server && node scripts/smoke-packaged-server.mjs", "broker:types": "wrangler types --config cloudflare/composio-broker/wrangler.jsonc cloudflare/composio-broker/worker-configuration.d.ts", diff --git a/scripts/after-pack.mjs b/scripts/after-pack.mjs new file mode 100644 index 000000000..36c7e8bf6 --- /dev/null +++ b/scripts/after-pack.mjs @@ -0,0 +1,41 @@ +import { chmod, lstat } from "node:fs/promises"; +import path from "node:path"; +import { LICENSE_FILES } from "./cua-linux-release.mjs"; + +async function requireRealDirectory(directory) { + const details = await lstat(directory); + if (!details.isDirectory() || details.isSymbolicLink()) { + throw new Error(`Linux package resource must be a real directory: ${directory}`); + } + await chmod(directory, 0o755); +} + +async function requireRegularFile(file, mode) { + const details = await lstat(file); + if (!details.isFile() || details.isSymbolicLink()) { + throw new Error(`Linux package resource must be a regular file: ${file}`); + } + await chmod(file, mode); +} + +// electron-builder normalizes copied resource directories to 0775. That is +// unsafe for a root-owned executable path after DEB/AppImage installation, so +// repair and revalidate the exact tree after resources are copied and before +// either artifact target is assembled. +export default async function afterPack(context) { + if (context.electronPlatformName !== "linux") return; + + const resources = path.join(context.appOutDir, "resources"); + const cuaRoot = path.join(resources, "cua-linux-x64"); + const licenses = path.join(cuaRoot, "licenses"); + for (const directory of [context.appOutDir, resources, cuaRoot, licenses]) { + await requireRealDirectory(directory); + } + for (const executable of ["cua-driver", "cua-cursor-theme"]) { + await requireRegularFile(path.join(cuaRoot, executable), 0o755); + } + await requireRegularFile(path.join(cuaRoot, "release.json"), 0o644); + for (const license of LICENSE_FILES) { + await requireRegularFile(path.join(licenses, license), 0o644); + } +} diff --git a/scripts/after-pack.test.mjs b/scripts/after-pack.test.mjs new file mode 100644 index 000000000..8d1f23c16 --- /dev/null +++ b/scripts/after-pack.test.mjs @@ -0,0 +1,76 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import afterPack from "./after-pack.mjs"; +import { LICENSE_FILES } from "./cua-linux-release.mjs"; + +const temporaryDirectories = []; + +function fixture() { + const appOutDir = fs.mkdtempSync(path.join(os.tmpdir(), "omb-after-pack-")); + temporaryDirectories.push(appOutDir); + const resources = path.join(appOutDir, "resources"); + const cua = path.join(resources, "cua-linux-x64"); + const licenses = path.join(cua, "licenses"); + fs.mkdirSync(licenses, { recursive: true, mode: 0o775 }); + for (const directory of [appOutDir, resources, cua, licenses]) fs.chmodSync(directory, 0o775); + for (const name of ["cua-driver", "cua-cursor-theme", "release.json"]) { + fs.writeFileSync(path.join(cua, name), "fixture", { mode: 0o664 }); + fs.chmodSync(path.join(cua, name), 0o664); + } + for (const name of LICENSE_FILES) { + fs.writeFileSync(path.join(licenses, name), "fixture", { mode: 0o664 }); + fs.chmodSync(path.join(licenses, name), 0o664); + } + return { appOutDir, resources, cua, licenses }; +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +describe.skipIf(process.platform === "win32")("Linux afterPack permissions", () => { + it("repairs every packaged CUA ancestor and resource mode", async () => { + const { appOutDir, resources, cua, licenses } = fixture(); + + await afterPack({ electronPlatformName: "linux", appOutDir }); + + for (const directory of [appOutDir, resources, cua, licenses]) { + expect(fs.lstatSync(directory).mode & 0o777).toBe(0o755); + } + for (const name of ["cua-driver", "cua-cursor-theme"]) { + expect(fs.lstatSync(path.join(cua, name)).mode & 0o777).toBe(0o755); + } + expect(fs.lstatSync(path.join(cua, "release.json")).mode & 0o777).toBe(0o644); + for (const name of fs.readdirSync(licenses)) { + expect(fs.lstatSync(path.join(licenses, name)).mode & 0o777).toBe(0o644); + } + }); + + it("fails closed when the runtime root is replaced by a symlink", async () => { + const { appOutDir, cua } = fixture(); + const replacement = path.join(appOutDir, "replacement"); + fs.mkdirSync(replacement); + fs.rmSync(cua, { recursive: true }); + fs.symlinkSync(replacement, cua, "dir"); + await expect(afterPack({ electronPlatformName: "linux", appOutDir })).rejects.toThrow( + "must be a real directory", + ); + }); + + it("fails closed when the release manifest is missing", async () => { + const { appOutDir, cua } = fixture(); + fs.unlinkSync(path.join(cua, "release.json")); + await expect(afterPack({ electronPlatformName: "linux", appOutDir })).rejects.toThrow(); + }); + + it("leaves non-Linux package modes unchanged", async () => { + const { appOutDir, cua } = fixture(); + await afterPack({ electronPlatformName: "darwin", appOutDir }); + expect(fs.lstatSync(cua).mode & 0o777).toBe(0o775); + expect(fs.lstatSync(path.join(cua, "cua-driver")).mode & 0o777).toBe(0o664); + }); +}); diff --git a/scripts/check-electron.mjs b/scripts/check-electron.mjs new file mode 100644 index 000000000..c94a23811 --- /dev/null +++ b/scripts/check-electron.mjs @@ -0,0 +1,23 @@ +import { execFileSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const electronDirectory = fileURLToPath(new URL("../electron/", import.meta.url)); +const require = createRequire(import.meta.url); +const electronExecutable = require("electron"); +const modules = readdirSync(electronDirectory, { withFileTypes: true }) + .filter((entry) => entry.isFile() && /\.(?:cjs|mjs)$/.test(entry.name)) + .map((entry) => path.join(electronDirectory, entry.name)) + .sort(); + +if (modules.length === 0) throw new Error("No Electron modules were found to syntax-check"); +for (const modulePath of modules) { + execFileSync(electronExecutable, ["--check", modulePath], { + env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }, + stdio: "inherit", + }); +} + +console.log(`Syntax-checked ${modules.length} Electron modules.`); diff --git a/scripts/cua-linux-release.mjs b/scripts/cua-linux-release.mjs new file mode 100644 index 000000000..c15cc96a5 --- /dev/null +++ b/scripts/cua-linux-release.mjs @@ -0,0 +1,555 @@ +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import { + chmod, + copyFile, + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + realpath, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; +import { gunzipSync } from "node:zlib"; + +const run = promisify(execFile); +const KIB = 1024; +const MIB = KIB * KIB; +const TAR_BLOCK_SIZE = 512; +export const LICENSE_FILES = Object.freeze([ + "LICENSE.md", + "Inter-OFL-1.1.txt", + "THIRD_PARTY_LICENSES.html", + "THIRD_PARTY_NOTICES.md", + "SBOM.cdx.json", +]); + +export const LINUX_CUA_RELEASE = Object.freeze({ + version: "0.19.3", + archiveName: "cua-driver-rs-0.19.3-linux-x86_64-binary.tar.gz", + archiveSize: 27_248_614, + archiveSha256: "3db9d4257d84bacaf7eb104d225f85613ce67edbb20d6eeb83c1384b6d8a5b10", + driverSha256: "ed5844fadf07b9b72c4a3b3802e1c47233c166d66d6198608d5991f807aab4ac", + cursorThemeSha256: "e589b2b7521bbfeaf9e2bfce668a38e80ed1b9790b1327b13d374fc331d8312a", + url: + "https://github.com/trycua/cua/releases/download/cua-driver-rs-v0.19.3/" + + "cua-driver-rs-0.19.3-linux-x86_64-binary.tar.gz", + maxArchiveBytes: 64 * MIB, + maxExpandedBytes: 128 * MIB, +}); + +// Pin the complete upstream archive shape, even though OpenMausBot extracts +// only the two CLI runtime files. A release that silently grows a new native +// payload must receive an explicit review and checksum update first. +const ARCHIVE_MEMBERS = Object.freeze({ + "cua-driver": Object.freeze({ + kind: "file", + size: 42_676_880, + sha256: LINUX_CUA_RELEASE.driverSha256, + staged: true, + mode: 0o755, + }), + "cua-cursor-theme": Object.freeze({ + kind: "file", + size: 3_160_088, + sha256: LINUX_CUA_RELEASE.cursorThemeSha256, + staged: true, + mode: 0o755, + }), + "libcua_driver_sdk.so": Object.freeze({ + kind: "file", + size: 37_879_784, + sha256: "31c142f5c67443a1fa933160bfa20d93b9914220fb9a47f2884d38df20ab0671", + }), + "cua_driver_node_runtime.node": Object.freeze({ + kind: "file", + size: 966_152, + sha256: "52b70432d2eb167e69632246a38d895c9e7aa61618a31638d393ad6838117293", + }), + "cua_driver_abi.h": Object.freeze({ + kind: "file", + size: 7_762, + sha256: "e952620e41ac81b2d900886c7b0a24ebc6268a4edb8df88cc9971ae34af4ba0d", + }), + "wayland-helper/": Object.freeze({ kind: "directory", size: 0 }), + "wayland-helper/winrects@cua/": Object.freeze({ kind: "directory", size: 0 }), + "wayland-helper/winrects@cua/extension.js": Object.freeze({ + kind: "file", + size: 30_209, + sha256: "27aac56799574ecd201e810d32772d3695d8b6ace5ab4a68d026648009004eed", + }), + "wayland-helper/winrects@cua/metadata.json": Object.freeze({ + kind: "file", + size: 236, + sha256: "3f6624c882bde9d611848201e2f245c813fbc322a57a003d52847afc8e6f4c50", + }), + "wayland-helper/install.sh": Object.freeze({ + kind: "file", + size: 1_201, + sha256: "e13fc5700d281fed547fbb529a31bc1a0df250e6f9811c9cdddc99d465e219a0", + }), + "wayland-helper/README.md": Object.freeze({ + kind: "file", + size: 3_841, + sha256: "0c38155388bdb5b3a276c4d434fbc5311ed7bb775ae6b04969f50de81c8c2703", + }), +}); + +export function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +function tarString(buffer, start, length) { + const field = buffer.subarray(start, start + length); + const nul = field.indexOf(0); + return field.subarray(0, nul === -1 ? field.length : nul).toString("utf8"); +} + +function tarOctal(buffer, start, length, label) { + const raw = tarString(buffer, start, length).trim(); + if (!raw) return 0; + if (!/^[0-7]+$/.test(raw)) throw new Error(`invalid tar ${label}`); + const value = Number.parseInt(raw, 8); + if (!Number.isSafeInteger(value) || value < 0) throw new Error(`invalid tar ${label}`); + return value; +} + +function tarHeaderChecksum(header) { + const copy = Buffer.from(header); + copy.fill(0x20, 148, 156); + return copy.reduce((sum, value) => sum + value, 0); +} + +function safeArchivePath(name) { + if ( + !name || + name.includes("\\") || + name.includes("\0") || + path.posix.isAbsolute(name) || + name.split("/").some((component) => component === ".." || component === ".") + ) { + throw new Error(`unsafe CUA archive path: ${JSON.stringify(name)}`); + } + return name; +} + +/** + * Parse the pinned tarball without delegating extraction to a general-purpose + * archive tool. Only regular files and directories from the reviewed manifest + * are accepted; links, PAX/GNU extensions, devices and duplicate names fail. + */ +export function parseLinuxCuaArchive( + archiveBytes, + release = LINUX_CUA_RELEASE, + archiveMembers = ARCHIVE_MEMBERS, +) { + if (!Buffer.isBuffer(archiveBytes)) archiveBytes = Buffer.from(archiveBytes); + if (archiveBytes.length > release.maxArchiveBytes) { + throw new Error("CUA archive exceeds the compressed size limit"); + } + if (release.archiveSize != null && archiveBytes.length !== release.archiveSize) { + throw new Error("CUA archive size does not match the pinned release"); + } + if (sha256(archiveBytes) !== release.archiveSha256) { + throw new Error("CUA archive checksum mismatch"); + } + + let tar; + try { + tar = gunzipSync(archiveBytes, { maxOutputLength: release.maxExpandedBytes }); + } catch (error) { + throw new Error(`CUA archive could not be decompressed safely: ${error?.message ?? error}`); + } + if (tar.length > release.maxExpandedBytes) { + throw new Error("CUA archive exceeds the expanded size limit"); + } + + const seen = new Set(); + const staged = new Map(); + let offset = 0; + let ended = false; + while (offset + TAR_BLOCK_SIZE <= tar.length) { + const header = tar.subarray(offset, offset + TAR_BLOCK_SIZE); + if (header.every((value) => value === 0)) { + ended = true; + break; + } + + const expectedChecksum = tarOctal(header, 148, 8, "checksum"); + if (expectedChecksum !== tarHeaderChecksum(header)) { + throw new Error("CUA archive contains an invalid tar header checksum"); + } + const prefix = tarString(header, 345, 155); + const basename = tarString(header, 0, 100); + const name = safeArchivePath(prefix ? `${prefix}/${basename}` : basename); + if (seen.has(name)) throw new Error(`duplicate CUA archive member: ${name}`); + seen.add(name); + + const expected = archiveMembers[name]; + if (!expected) throw new Error(`unexpected CUA archive member: ${name}`); + const typeByte = header[156]; + const kind = typeByte === 0 || typeByte === 0x30 ? "file" : typeByte === 0x35 ? "directory" : "special"; + if (kind !== expected.kind) { + throw new Error(`unsupported CUA archive member type for ${name}`); + } + if (tarString(header, 157, 100)) { + throw new Error(`CUA archive links are not allowed: ${name}`); + } + + const size = tarOctal(header, 124, 12, "member size"); + if (size !== expected.size) throw new Error(`unexpected CUA archive member size for ${name}`); + const dataOffset = offset + TAR_BLOCK_SIZE; + const paddedSize = Math.ceil(size / TAR_BLOCK_SIZE) * TAR_BLOCK_SIZE; + const nextOffset = dataOffset + paddedSize; + if (nextOffset > tar.length) throw new Error(`truncated CUA archive member: ${name}`); + + if (kind === "file") { + const bytes = Buffer.from(tar.subarray(dataOffset, dataOffset + size)); + if (sha256(bytes) !== expected.sha256) { + throw new Error(`CUA archive member checksum mismatch: ${name}`); + } + if (expected.staged) staged.set(name, bytes); + } + offset = nextOffset; + } + + if (!ended || !tar.subarray(offset).every((value) => value === 0)) { + throw new Error("CUA archive has invalid trailing data"); + } + for (const name of Object.keys(archiveMembers)) { + if (!seen.has(name)) throw new Error(`CUA archive is missing reviewed member: ${name}`); + } + return staged; +} + +function releaseManifest() { + return Object.freeze({ + schemaVersion: 1, + version: LINUX_CUA_RELEASE.version, + platform: "linux", + arch: "x64", + archive: { + name: LINUX_CUA_RELEASE.archiveName, + url: LINUX_CUA_RELEASE.url, + size: LINUX_CUA_RELEASE.archiveSize, + sha256: LINUX_CUA_RELEASE.archiveSha256, + }, + sourceCommit: "a1672e7b11951275ecfba3384264d4530185d0db", + files: { + "cua-driver": { + sha256: LINUX_CUA_RELEASE.driverSha256, + mode: "0755", + }, + "cua-cursor-theme": { + sha256: LINUX_CUA_RELEASE.cursorThemeSha256, + mode: "0755", + }, + }, + }); +} + +async function binaryVersion(binary) { + const { stdout } = await run(binary, ["--version"], { + encoding: "utf8", + timeout: 5_000, + maxBuffer: 256 * KIB, + env: { + LANG: "C", + LC_ALL: "C", + CUA_DRIVER_RS_UPDATE_CHECK: "false", + CUA_DRIVER_RS_TELEMETRY_ENABLED: "false", + }, + }); + return stdout.trim(); +} + +async function validateBinaryManifest(binary) { + const { stdout } = await run(binary, ["manifest"], { + encoding: "utf8", + timeout: 5_000, + maxBuffer: 256 * KIB, + env: { + LANG: "C", + LC_ALL: "C", + CUA_DRIVER_RS_UPDATE_CHECK: "false", + CUA_DRIVER_RS_TELEMETRY_ENABLED: "false", + }, + }); + const manifest = JSON.parse(stdout); + const invocationCommand = manifest.mcp_invocation?.command; + const invocationPath = + typeof invocationCommand === "string" && invocationCommand.length > 0 + ? await realpath(invocationCommand).catch(() => null) + : null; + if ( + manifest.schema_version !== "1" || + manifest.binary_version !== LINUX_CUA_RELEASE.version || + invocationPath !== (await realpath(binary)) || + JSON.stringify(manifest.mcp_invocation?.args) !== JSON.stringify(["mcp"]) + ) { + throw new Error("staged CUA Driver returned an incompatible manifest"); + } +} + +export async function validateStagedLayout(stageDirectory) { + const stageDetails = await lstat(stageDirectory); + if ( + !stageDetails.isDirectory() || + stageDetails.isSymbolicLink() || + (stageDetails.mode & 0o777) !== 0o755 + ) { + throw new Error("staged CUA release root must be a real 0755 directory"); + } + const rootEntries = (await readdir(stageDirectory)).sort(); + const expectedRootEntries = ["cua-cursor-theme", "cua-driver", "licenses", "release.json"]; + if (JSON.stringify(rootEntries) !== JSON.stringify(expectedRootEntries)) { + throw new Error("staged CUA release contains unexpected or missing entries"); + } + const licensesDirectory = path.join(stageDirectory, "licenses"); + const licenseDirectoryDetails = await lstat(licensesDirectory); + if ( + !licenseDirectoryDetails.isDirectory() || + licenseDirectoryDetails.isSymbolicLink() || + (licenseDirectoryDetails.mode & 0o777) !== 0o755 + ) { + throw new Error("staged CUA licenses must be a real 0755 directory"); + } + const licenseEntries = (await readdir(licensesDirectory)).sort(); + if (JSON.stringify(licenseEntries) !== JSON.stringify([...LICENSE_FILES].sort())) { + throw new Error("staged CUA licenses contain unexpected or missing entries"); + } + return { licensesDirectory }; +} + +async function validateStagedRuntime(stageDirectory, { licenseDirectory }) { + const { licensesDirectory } = await validateStagedLayout(stageDirectory); + const manifestDetails = await lstat(path.join(stageDirectory, "release.json")); + if ( + !manifestDetails.isFile() || + manifestDetails.isSymbolicLink() || + (manifestDetails.mode & 0o777) !== 0o644 + ) { + throw new Error("staged CUA release manifest must be a regular 0644 file"); + } + const manifest = JSON.parse( + await readFile(path.join(stageDirectory, "release.json"), "utf8"), + ); + if (JSON.stringify(manifest) !== JSON.stringify(releaseManifest())) { + throw new Error("staged CUA release manifest does not match the pinned release"); + } + for (const [name, expected] of Object.entries(releaseManifest().files)) { + const file = path.join(stageDirectory, name); + const details = await lstat(file); + if ( + !details.isFile() || + details.isSymbolicLink() || + (details.mode & 0o777) !== Number.parseInt(expected.mode, 8) + ) { + throw new Error(`staged CUA runtime has an invalid mode: ${name}`); + } + if (sha256(await readFile(file)) !== expected.sha256) { + throw new Error(`staged CUA runtime checksum mismatch: ${name}`); + } + } + for (const name of LICENSE_FILES) { + const details = await lstat(path.join(licensesDirectory, name)); + if (!details.isFile() || details.isSymbolicLink() || (details.mode & 0o777) !== 0o644) { + throw new Error(`staged CUA license resource has an invalid type or mode: ${name}`); + } + const expected = await readFile(path.join(licenseDirectory, name)); + const actual = await readFile(path.join(licensesDirectory, name)); + if (!actual.equals(expected)) throw new Error(`staged CUA license resource mismatch: ${name}`); + } + if ((await binaryVersion(path.join(stageDirectory, "cua-driver"))) !== `cua-driver ${LINUX_CUA_RELEASE.version}`) { + throw new Error(`staged CUA Driver does not report version ${LINUX_CUA_RELEASE.version}`); + } + await validateBinaryManifest(path.join(stageDirectory, "cua-driver")); + return manifest; +} + +export async function readBoundedResponseBody(response, maxBytes) { + const declaredLength = Number(response.headers?.get?.("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + await response.body?.cancel?.(); + throw new Error("downloaded CUA archive exceeds the compressed size limit"); + } + if (!response.body) throw new Error("CUA Driver download returned no response body"); + const chunks = []; + let total = 0; + try { + for await (const chunk of response.body) { + const bytes = Buffer.from(chunk); + total += bytes.length; + if (total > maxBytes) { + throw new Error("downloaded CUA archive exceeds the compressed size limit"); + } + chunks.push(bytes); + } + } catch (error) { + try { + await response.body.cancel?.(); + } catch {} + throw error; + } + return Buffer.concat(chunks, total); +} + +async function readVerifiedArchive({ + archivePath, + cachePath, + fetchImpl, + offline, + downloadTimeoutMs, +}) { + for (const candidate of [archivePath, cachePath].filter(Boolean)) { + try { + const bytes = await readFile(candidate); + if ( + bytes.length === LINUX_CUA_RELEASE.archiveSize && + sha256(bytes) === LINUX_CUA_RELEASE.archiveSha256 + ) { + return bytes; + } + if (candidate === archivePath) throw new Error("provided CUA archive checksum mismatch"); + } catch (error) { + if (candidate === archivePath || error?.code !== "ENOENT") throw error; + } + } + if (offline) throw new Error("offline CUA staging requires an existing verified stage or archive cache"); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), downloadTimeoutMs); + timeout.unref?.(); + let bytes; + try { + const response = await fetchImpl(LINUX_CUA_RELEASE.url, { + headers: { "user-agent": "OpenMausBot-packager" }, + signal: controller.signal, + }); + if (!response?.ok) { + throw new Error(`CUA Driver download failed: HTTP ${response?.status ?? "unknown"}`); + } + bytes = await readBoundedResponseBody(response, LINUX_CUA_RELEASE.maxArchiveBytes); + } catch (error) { + if (controller.signal.aborted) throw new Error("CUA Driver download timed out"); + throw error; + } finally { + clearTimeout(timeout); + } + if (bytes.length !== LINUX_CUA_RELEASE.archiveSize) { + throw new Error("downloaded CUA archive size does not match the pinned release"); + } + if (sha256(bytes) !== LINUX_CUA_RELEASE.archiveSha256) { + throw new Error("downloaded CUA archive checksum mismatch"); + } + const cacheDirectory = path.dirname(cachePath); + await mkdir(cacheDirectory, { recursive: true }); + const temporaryDirectory = await mkdtemp(path.join(cacheDirectory, ".cua-archive-")); + try { + const temporaryArchive = path.join(temporaryDirectory, LINUX_CUA_RELEASE.archiveName); + await writeFile(temporaryArchive, bytes, { mode: 0o600, flag: "wx" }); + await rename(temporaryArchive, cachePath); + } finally { + await rm(temporaryDirectory, { recursive: true, force: true }); + } + return bytes; +} + +export async function stageLinuxCua({ + rootDirectory, + platform = process.platform, + arch = process.arch, + offline = false, + archivePath, + fetchImpl = globalThis.fetch, + downloadTimeoutMs = 120_000, +} = {}) { + if (platform !== "linux" || arch !== "x64") { + throw new Error(`bundled CUA staging supports only linux/x64, received ${platform}/${arch}`); + } + if (!rootDirectory || !path.isAbsolute(rootDirectory)) { + throw new Error("rootDirectory must be an absolute path"); + } + // Linux local control spawns this reviewed CLI directly and deliberately + // excludes the npm SDK's native .node/.so files. Do not couple the Linux + // release pin to @trycua/cua-driver, which serves the separate macOS SDK + // path and may advance independently. + + const stageDirectory = path.join(rootDirectory, "dist-native", "cua-linux-x64"); + const licenseDirectory = path.join(rootDirectory, "third_party", "cua-driver"); + if (offline) { + try { + await validateStagedRuntime(stageDirectory, { licenseDirectory }); + return { stageDirectory, source: "existing-stage", manifest: releaseManifest() }; + } catch { + // An invalid stage is never trusted. A verified cache may still satisfy + // an explicitly offline release build below. + } + } + + const cachePath = path.join( + rootDirectory, + "node_modules", + ".cache", + "openmausbot", + LINUX_CUA_RELEASE.archiveName, + ); + const archive = await readVerifiedArchive({ + archivePath, + cachePath, + fetchImpl, + offline, + downloadTimeoutMs, + }); + const files = parseLinuxCuaArchive(archive); + + const stageParent = path.dirname(stageDirectory); + await mkdir(stageParent, { recursive: true }); + const temporary = await mkdtemp(path.join(stageParent, ".cua-linux-x64-")); + const backup = `${stageDirectory}.previous`; + let previousMoved = false; + try { + await chmod(temporary, 0o755); + for (const [name, bytes] of files) { + const expected = ARCHIVE_MEMBERS[name]; + await writeFile(path.join(temporary, name), bytes, { mode: expected.mode, flag: "wx" }); + await chmod(path.join(temporary, name), expected.mode); + } + await mkdir(path.join(temporary, "licenses"), { mode: 0o755 }); + await chmod(path.join(temporary, "licenses"), 0o755); + for (const name of LICENSE_FILES) { + await copyFile(path.join(licenseDirectory, name), path.join(temporary, "licenses", name)); + await chmod(path.join(temporary, "licenses", name), 0o644); + } + await writeFile(path.join(temporary, "release.json"), `${JSON.stringify(releaseManifest(), null, 2)}\n`, { + mode: 0o644, + flag: "wx", + }); + await validateStagedRuntime(temporary, { licenseDirectory }); + + await rm(backup, { recursive: true, force: true }); + try { + await rename(stageDirectory, backup); + previousMoved = true; + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + await rename(temporary, stageDirectory); + if (previousMoved) await rm(backup, { recursive: true, force: true }); + } catch (error) { + await rm(temporary, { recursive: true, force: true }); + if (previousMoved) { + try { + await rename(backup, stageDirectory); + } catch {} + } + throw error; + } + + return { stageDirectory, source: archivePath ? "archive" : "official-release", manifest: releaseManifest() }; +} diff --git a/scripts/cua-linux-release.test.mjs b/scripts/cua-linux-release.test.mjs new file mode 100644 index 000000000..ca7a22abe --- /dev/null +++ b/scripts/cua-linux-release.test.mjs @@ -0,0 +1,277 @@ +import { gzipSync } from "node:zlib"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + parseLinuxCuaArchive, + readBoundedResponseBody, + sha256, + stageLinuxCua, + validateStagedLayout, +} from "./cua-linux-release.mjs"; + +const BLOCK = 512; +const temporaryDirectories = []; + +function temporaryProject() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "omb-cua-stage-test-")); + temporaryDirectories.push(root); + fs.writeFileSync( + path.join(root, "package.json"), + JSON.stringify({ dependencies: { "@trycua/cua-driver": "0.20.0" } }), + ); + return root; +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + fs.rmSync(directory, { recursive: true, force: true }); + } +}); + +function octal(value, length) { + return `${value.toString(8).padStart(length - 1, "0")}\0`; +} + +function entry(name, bytes = Buffer.alloc(0), { type = "0", link = "", mode = 0o755 } = {}) { + bytes = Buffer.from(bytes); + const header = Buffer.alloc(BLOCK); + header.write(name, 0, 100, "utf8"); + header.write(octal(mode, 8), 100, 8, "ascii"); + header.write(octal(0, 8), 108, 8, "ascii"); + header.write(octal(0, 8), 116, 8, "ascii"); + header.write(octal(bytes.length, 12), 124, 12, "ascii"); + header.write(octal(0, 12), 136, 12, "ascii"); + header.fill(0x20, 148, 156); + header.write(type, 156, 1, "ascii"); + header.write(link, 157, 100, "utf8"); + header.write("ustar\0", 257, 6, "binary"); + header.write("00", 263, 2, "ascii"); + const checksum = header.reduce((sum, value) => sum + value, 0); + header.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, 8, "ascii"); + const padding = Buffer.alloc(Math.ceil(bytes.length / BLOCK) * BLOCK - bytes.length); + return Buffer.concat([header, bytes, padding]); +} + +function fixture(entries) { + return gzipSync(Buffer.concat([...entries, Buffer.alloc(BLOCK * 2)]), { mtime: 0 }); +} + +function contract(archive, members, overrides = {}) { + return { + archiveSha256: sha256(archive), + maxArchiveBytes: 64 * 1024, + maxExpandedBytes: 128 * 1024, + ...overrides, + members, + }; +} + +function fileContract(bytes, staged = true) { + return { kind: "file", size: bytes.length, sha256: sha256(bytes), staged }; +} + +describe("Linux CUA release staging", () => { + it("extracts only explicitly staged regular files from an exact reviewed archive", () => { + const driver = Buffer.from("driver"); + const ignoredSdk = Buffer.from("unused sdk"); + const archive = fixture([ + entry("cua-driver", driver), + entry("sdk.so", ignoredSdk), + entry("helper/", Buffer.alloc(0), { type: "5" }), + ]); + const members = { + "cua-driver": fileContract(driver), + "sdk.so": fileContract(ignoredSdk, false), + "helper/": { kind: "directory", size: 0 }, + }; + const extracted = parseLinuxCuaArchive(archive, contract(archive, members), members); + expect([...extracted]).toEqual([["cua-driver", driver]]); + }); + + it("rejects a checksum mismatch before decompression", () => { + const archive = fixture([entry("cua-driver", "driver")]); + const members = { "cua-driver": fileContract(Buffer.from("driver")) }; + const changed = Buffer.from(archive); + changed[changed.length - 1] ^= 1; + expect(() => parseLinuxCuaArchive(changed, contract(archive, members), members)).toThrow( + "archive checksum mismatch", + ); + }); + + it.each([ + ["a traversal path", "../cua-driver", "0", "unsafe CUA archive path"], + ["an absolute path", "/cua-driver", "0", "unsafe CUA archive path"], + ["a symlink", "cua-driver", "2", "unsupported CUA archive member type"], + ["a hardlink", "cua-driver", "1", "unsupported CUA archive member type"], + ["a FIFO", "cua-driver", "6", "unsupported CUA archive member type"], + ["a device", "cua-driver", "3", "unsupported CUA archive member type"], + ])("rejects %s", (_label, name, type, message) => { + const bytes = Buffer.from("driver"); + const archive = fixture([entry(name, bytes, { type, link: type === "2" ? "/tmp/evil" : "" })]); + const members = { [name]: fileContract(bytes) }; + expect(() => parseLinuxCuaArchive(archive, contract(archive, members), members)).toThrow(message); + }); + + it("rejects duplicate and unknown members", () => { + const bytes = Buffer.from("driver"); + const duplicate = fixture([entry("cua-driver", bytes), entry("cua-driver", bytes)]); + const members = { "cua-driver": fileContract(bytes) }; + expect(() => parseLinuxCuaArchive(duplicate, contract(duplicate, members), members)).toThrow( + "duplicate CUA archive member", + ); + + const unknown = fixture([entry("surprise", bytes)]); + expect(() => parseLinuxCuaArchive(unknown, contract(unknown, members), members)).toThrow( + "unexpected CUA archive member", + ); + }); + + it("rejects missing members and mismatched inner hashes", () => { + const bytes = Buffer.from("driver"); + const missing = fixture([entry("cua-driver", bytes)]); + const members = { + "cua-driver": fileContract(bytes), + "cua-cursor-theme": fileContract(Buffer.from("theme")), + }; + expect(() => parseLinuxCuaArchive(missing, contract(missing, members), members)).toThrow( + "missing reviewed member", + ); + + const changed = fixture([entry("cua-driver", bytes)]); + const wrongInnerHash = { + "cua-driver": { ...fileContract(bytes), sha256: sha256(Buffer.from("different")) }, + }; + expect(() => + parseLinuxCuaArchive(changed, contract(changed, wrongInnerHash), wrongInnerHash), + ).toThrow("member checksum mismatch"); + }); + + it("bounds expanded archive bytes", () => { + const bytes = Buffer.alloc(4_096, 0x61); + const archive = fixture([entry("cua-driver", bytes)]); + const members = { "cua-driver": fileContract(bytes) }; + expect(() => + parseLinuxCuaArchive( + archive, + contract(archive, members, { maxExpandedBytes: 1_024 }), + members, + ), + ).toThrow("decompressed safely"); + }); + + it("bounds both declared and streamed download sizes", async () => { + const declared = new Response("small", { headers: { "content-length": "100" } }); + await expect(readBoundedResponseBody(declared, 5)).rejects.toThrow("compressed size limit"); + + const streamed = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(Buffer.from("1234")); + controller.enqueue(Buffer.from("5678")); + controller.close(); + }, + }), + ); + await expect(readBoundedResponseBody(streamed, 7)).rejects.toThrow("compressed size limit"); + }); + + it("rejects unsupported build platforms before reading project state", async () => { + await expect( + stageLinuxCua({ rootDirectory: "/not/read", platform: "darwin", arch: "arm64" }), + ).rejects.toThrow("supports only linux/x64"); + await expect( + stageLinuxCua({ rootDirectory: "/not/read", platform: "linux", arch: "arm64" }), + ).rejects.toThrow("supports only linux/x64"); + }); + + it("leaves an existing stage untouched when archive verification fails", async () => { + const root = temporaryProject(); + const stage = path.join(root, "dist-native", "cua-linux-x64"); + fs.mkdirSync(stage, { recursive: true }); + fs.writeFileSync(path.join(stage, "marker"), "previous valid stage"); + const archive = path.join(root, "corrupt.tar.gz"); + fs.writeFileSync(archive, "not the pinned archive"); + + await expect( + stageLinuxCua({ rootDirectory: root, platform: "linux", arch: "x64", archivePath: archive }), + ).rejects.toThrow("checksum mismatch"); + expect(fs.readFileSync(path.join(stage, "marker"), "utf8")).toBe("previous valid stage"); + }); + + it("never calls fetch in offline mode and rejects an unverified cache", async () => { + const root = temporaryProject(); + const cache = path.join( + root, + "node_modules", + ".cache", + "openmausbot", + "cua-driver-rs-0.19.3-linux-x86_64-binary.tar.gz", + ); + fs.mkdirSync(path.dirname(cache), { recursive: true }); + fs.writeFileSync(cache, "cua-driver 0.19.3 but not the release bytes"); + const fetchImpl = vi.fn(); + + await expect( + stageLinuxCua({ + rootDirectory: root, + platform: "linux", + arch: "x64", + offline: true, + fetchImpl, + }), + ).rejects.toThrow("offline CUA staging requires an existing verified stage or archive cache"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("bounds a stalled upstream download", async () => { + const root = temporaryProject(); + const fetchImpl = vi.fn((_url, { signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError"))); + }), + ); + await expect( + stageLinuxCua({ + rootDirectory: root, + platform: "linux", + arch: "x64", + fetchImpl, + downloadTimeoutMs: 10, + }), + ).rejects.toThrow("download timed out"); + }); + + it.skipIf(process.platform === "win32")( + "accepts only the exact staged tree and safe directory modes", + async () => { + const root = temporaryProject(); + const stage = path.join(root, "stage"); + const licenses = path.join(stage, "licenses"); + fs.mkdirSync(licenses, { recursive: true, mode: 0o755 }); + fs.chmodSync(stage, 0o755); + fs.chmodSync(licenses, 0o755); + for (const name of ["cua-driver", "cua-cursor-theme", "release.json"]) { + fs.writeFileSync(path.join(stage, name), "fixture"); + } + for (const name of [ + "LICENSE.md", + "Inter-OFL-1.1.txt", + "THIRD_PARTY_LICENSES.html", + "THIRD_PARTY_NOTICES.md", + "SBOM.cdx.json", + ]) { + fs.writeFileSync(path.join(licenses, name), "fixture"); + } + + await expect(validateStagedLayout(stage)).resolves.toEqual({ licensesDirectory: licenses }); + + fs.writeFileSync(path.join(stage, "unexpected.so"), "payload"); + await expect(validateStagedLayout(stage)).rejects.toThrow("unexpected or missing entries"); + fs.rmSync(path.join(stage, "unexpected.so")); + fs.chmodSync(licenses, 0o775); + await expect(validateStagedLayout(stage)).rejects.toThrow("real 0755 directory"); + }, + ); +}); diff --git a/scripts/generate-cua-sbom.mjs b/scripts/generate-cua-sbom.mjs new file mode 100644 index 000000000..844d51aba --- /dev/null +++ b/scripts/generate-cua-sbom.mjs @@ -0,0 +1,697 @@ +// Maintainer tool: merge cargo-about's root-scoped views of the two shipped +// executables into deterministic notices, a license report, and a CycloneDX +// inventory. Raw workspace-wide `cargo metadata` must not be used here: its +// feature-unified resolve graph includes dependencies enabled only by other +// workspace members. +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const [ + driverReportPath, + cursorThemeReportPath, + cargoLockPath, + outputDirectory, +] = process.argv.slice(2); +if ( + !driverReportPath || + !cursorThemeReportPath || + !cargoLockPath || + !outputDirectory +) { + throw new Error( + "usage: node scripts/generate-cua-sbom.mjs " + + " " + + " ", + ); +} + +const SOURCE_COMMIT = "a1672e7b11951275ecfba3384264d4530185d0db"; +const SOURCE_ROOT = `https://github.com/trycua/cua/tree/${SOURCE_COMMIT}/libs/cua-driver/rust`; +const RELEASE_VERSION = "0.19.3"; +const ARCHIVE_SHA256 = + "3db9d4257d84bacaf7eb104d225f85613ce67edbb20d6eeb83c1384b6d8a5b10"; +const CARGO_LOCK_SHA256 = + "c1a8df7f4bedd554f6fc90c852c3625c91a89b28d9f2c642d966279e9e372362"; +const INTER_LICENSE_SHA256 = + "ecfea75d8a36217d19528567070745a516072ffe907c9fffecbab92eb3b6fc59"; +const INTER_FONT_SHA256 = + "29160a80ff49ddcab2c97711247e08b1fab27a484a329ce8b813d820dc559031"; +const INTER_VERSION = "4.001"; +const INTER_COMMIT = "66647c0bbbe41a850d79d9c76fb13add3378940f"; +const REGISTRY_SOURCE_PREFIX = + "registry+https://github.com/rust-lang/crates.io-index"; + +const EXPECTED_DRIVER_REGISTRY_COUNT = 325; +const EXPECTED_CURSOR_THEME_REGISTRY_COUNT = 113; +const EXPECTED_REGISTRY_UNION_COUNT = 330; +const EXPECTED_DRIVER_LOCAL = Object.freeze([ + "cua-driver@0.19.3", + "cursor-overlay@0.19.3", + "pip-preview@0.19.3", + "platform-linux@0.19.3", +]); +const EXPECTED_CURSOR_THEME_LOCAL = Object.freeze(["cursor-overlay@0.19.3"]); +const EXPECTED_CURSOR_THEME_ONLY = Object.freeze([ + "bumpalo@3.20.2", + "typed-path@0.12.3", + "zip@8.6.0", + "zlib-rs@0.6.6", + "zopfli@0.8.3", +]); +const EXPECTED_MPL_COMPONENTS = Object.freeze([ + "option-ext@0.2.0", + "uniffi@0.31.0", + "uniffi_core@0.31.0", + "uniffi_internal_macros@0.31.0", + "uniffi_macros@0.31.0", + "uniffi_meta@0.31.0", + "uniffi_pipeline@0.31.0", +]); +const TRYCUA_COMPONENTS = Object.freeze([ + "cua-driver", + "cua-driver-contract", + "cua-driver-core", + "cua-driver-sdk", + "cursor-overlay", + "cursor-theme-cli", + "pip-preview", + "platform-linux", +]); + +function sha256(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +function packageKey(pkg) { + return `${pkg.name}@${pkg.version}`; +} + +function sorted(values) { + return [...values].sort((left, right) => left.localeCompare(right)); +} + +function assertExactSet(actual, expected, label) { + const actualSorted = sorted(actual); + const expectedSorted = sorted(expected); + if (JSON.stringify(actualSorted) !== JSON.stringify(expectedSorted)) { + throw new Error( + `${label} differs; expected ${expectedSorted.join(", ")}; found ${actualSorted.join(", ")}`, + ); + } +} + +function isRegistryPackage(pkg) { + return ( + typeof pkg?.source === "string" && + pkg.source.startsWith(REGISTRY_SOURCE_PREFIX) + ); +} + +function inspectCargoAboutReport(report, label) { + if (!Array.isArray(report?.crates) || !Array.isArray(report?.licenses)) { + throw new Error(`${label} is not cargo-about JSON output`); + } + + const discovered = new Map(); + const local = new Set(); + for (const entry of report.crates) { + const pkg = entry?.package; + if (!pkg?.id || !pkg?.name || !pkg?.version) { + throw new Error(`${label} contains incomplete package metadata`); + } + if (isRegistryPackage(pkg)) { + if (discovered.has(pkg.id)) + throw new Error(`${label} contains duplicate ${pkg.id}`); + discovered.set(pkg.id, { + ...pkg, + resolvedLicense: entry.license ?? pkg.license, + }); + } else if (pkg.source == null) { + local.add(packageKey(pkg)); + } else { + throw new Error( + `${label} contains an unreviewed package source: ${pkg.source}`, + ); + } + } + + const licensed = new Set(); + for (const license of report.licenses) { + for (const usage of license.used_by ?? []) { + const pkg = usage.crate; + if (isRegistryPackage(pkg)) licensed.add(pkg.id); + } + } + assertExactSet( + licensed, + discovered.keys(), + `${label} licensed registry package set`, + ); + return { report, discovered, local }; +} + +function mergeRegistryPackages(left, right) { + const merged = new Map(); + for (const pkg of [...left.values(), ...right.values()]) { + const prior = merged.get(pkg.id); + if ( + prior && + JSON.stringify({ + name: prior.name, + version: prior.version, + source: prior.source, + license: prior.resolvedLicense, + }) !== + JSON.stringify({ + name: pkg.name, + version: pkg.version, + source: pkg.source, + license: pkg.resolvedLicense, + }) + ) { + throw new Error(`cargo-about reports disagree about ${pkg.id}`); + } + merged.set(pkg.id, prior ?? pkg); + } + return merged; +} + +function parseCargoLock(lockText) { + const packages = new Map(); + for (const block of lockText.split(/(?=^\[\[package\]\]\s*$)/m)) { + if (!block.startsWith("[[package]]")) continue; + const value = (name) => { + const match = block.match( + new RegExp(`^${name} = ("(?:[^"\\\\]|\\\\.)*")$`, "m"), + ); + return match ? JSON.parse(match[1]) : undefined; + }; + const name = value("name"); + const version = value("version"); + const source = value("source"); + const checksum = value("checksum"); + if (!name || !version || !source?.startsWith(REGISTRY_SOURCE_PREFIX)) + continue; + if (!/^[a-f0-9]{64}$/.test(checksum ?? "")) { + throw new Error( + `Cargo.lock has no SHA-256 checksum for ${name}@${version}`, + ); + } + const key = `${name}@${version}`; + const existing = packages.get(key); + if (existing && existing.checksum !== checksum) { + throw new Error(`Cargo.lock contains ambiguous sources for ${key}`); + } + packages.set(key, { checksum, source }); + } + return packages; +} + +function normalizeLicense(expression, label) { + if (!expression) throw new Error(`missing license expression for ${label}`); + return expression.replaceAll("/", " OR ").replace(/\s+/g, " ").trim(); +} + +function cargoReference(name, version) { + return `pkg:cargo/${encodeURIComponent(name)}@${encodeURIComponent(version)}`; +} + +function registryComponent(pkg, cargoLockPackages) { + const key = packageKey(pkg); + const locked = cargoLockPackages.get(key); + if (!locked) + throw new Error(`Cargo.lock does not contain selected package ${key}`); + const distribution = `https://crates.io/api/v1/crates/${encodeURIComponent(pkg.name)}/${encodeURIComponent(pkg.version)}/download`; + return { + type: "library", + "bom-ref": cargoReference(pkg.name, pkg.version), + group: "crates.io", + name: pkg.name, + version: pkg.version, + purl: cargoReference(pkg.name, pkg.version), + hashes: [{ alg: "SHA-256", content: locked.checksum }], + licenses: [ + { expression: normalizeLicense(pkg.license ?? pkg.resolvedLicense, key) }, + ], + ...(pkg.description ? { description: pkg.description } : {}), + externalReferences: [ + { type: "distribution", url: distribution }, + ...(typeof pkg.repository === "string" && + /^https?:\/\//.test(pkg.repository) + ? [{ type: "vcs", url: pkg.repository }] + : []), + ], + properties: [ + { name: "openmausbot:cargo:package-id", value: pkg.id }, + { name: "openmausbot:cargo:crate-sha256", value: locked.checksum }, + ], + }; +} + +function trycuaComponent(name) { + return { + type: "library", + "bom-ref": cargoReference(name, RELEASE_VERSION), + group: "trycua", + name, + version: RELEASE_VERSION, + purl: cargoReference(name, RELEASE_VERSION), + licenses: [{ license: { id: "MIT" } }], + externalReferences: [{ type: "vcs", url: `${SOURCE_ROOT}/crates/${name}` }], + }; +} + +function interComponent() { + return { + type: "file", + "bom-ref": `pkg:generic/inter@${INTER_VERSION}`, + group: "rsms", + name: "Inter", + version: INTER_VERSION, + purl: `pkg:generic/inter@${INTER_VERSION}`, + hashes: [{ alg: "SHA-256", content: INTER_FONT_SHA256 }], + licenses: [{ license: { id: "OFL-1.1" } }], + copyright: + "Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)", + externalReferences: [ + { + type: "vcs", + url: `https://github.com/rsms/inter/commit/${INTER_COMMIT}`, + }, + { + type: "distribution", + url: `${SOURCE_ROOT}/crates/cursor-overlay/assets/Inter.ttf`, + }, + ], + properties: [ + { + name: "openmausbot:font:name-table-version", + value: "Version 4.001;git-66647c0bb", + }, + { name: "openmausbot:font:embedded-sha256", value: INTER_FONT_SHA256 }, + ], + }; +} + +function mergeLicenseRecords(reports, selectedRegistry, interLicenseText) { + const records = new Map(); + const registryCoverage = new Set(); + const mplComponents = new Set(); + + for (const report of reports) { + for (const license of report.licenses) { + const usedBy = (license.used_by ?? []) + .map((usage) => usage.crate) + .filter( + (pkg) => isRegistryPackage(pkg) && selectedRegistry.has(pkg.id), + ); + if (!usedBy.length) continue; + const recordKey = sha256( + `${license.id}\0${license.name}\0${license.text}`, + ); + const record = records.get(recordKey) ?? { + id: license.id, + name: license.name, + text: license.text, + usedBy: new Map(), + }; + for (const pkg of usedBy) { + record.usedBy.set(pkg.id, pkg); + registryCoverage.add(pkg.id); + if (license.id === "MPL-2.0") mplComponents.add(packageKey(pkg)); + } + records.set(recordKey, record); + } + } + assertExactSet( + registryCoverage, + selectedRegistry.keys(), + "merged license report coverage", + ); + assertExactSet( + mplComponents, + EXPECTED_MPL_COMPONENTS, + "MPL-2.0 component set", + ); + + records.set("inter-ofl-1.1", { + id: "OFL-1.1", + name: "SIL Open Font License 1.1", + text: interLicenseText, + usedBy: new Map([ + [ + `pkg:generic/inter@${INTER_VERSION}`, + { + id: `pkg:generic/inter@${INTER_VERSION}`, + name: "Inter", + version: INTER_VERSION, + source: `https://github.com/rsms/inter/commit/${INTER_COMMIT}`, + }, + ], + ]), + }); + + return [...records.values()].sort( + (left, right) => + left.id.localeCompare(right.id) || + left.name.localeCompare(right.name) || + sha256(left.text).localeCompare(sha256(right.text)), + ); +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function normalizeEmbeddedText(value) { + return String(value) + .replaceAll("\r\n", "\n") + .replaceAll("\r", "\n") + .replace(/[ \t]+$/gm, ""); +} + +function componentLink(pkg) { + if (pkg.name === "Inter") + return `https://github.com/rsms/inter/commit/${INTER_COMMIT}`; + return `https://crates.io/crates/${encodeURIComponent(pkg.name)}/${encodeURIComponent(pkg.version)}`; +} + +function renderLicenseHtml(records, registryPackages) { + const overview = new Map(); + for (const record of records) { + const entry = overview.get(record.id) ?? { + name: record.name, + components: new Set(), + }; + for (const key of record.usedBy.keys()) entry.components.add(key); + overview.set(record.id, entry); + } + const overviewHtml = [...overview] + .sort(([left], [right]) => left.localeCompare(right)) + .map( + ([id, entry]) => + `
  • ${escapeHtml(entry.name)} (${entry.components.size})
  • `, + ) + .join(""); + const inventoryHtml = [...registryPackages.values()] + .sort((left, right) => packageKey(left).localeCompare(packageKey(right))) + .map( + (pkg) => + `${escapeHtml(pkg.name)}${escapeHtml(pkg.version)}${escapeHtml(normalizeLicense(pkg.license ?? pkg.resolvedLicense, packageKey(pkg)))}`, + ) + .join("\n"); + let previousId; + const licensesHtml = records + .map((record) => { + const heading = + record.id === previousId + ? "" + : `

    ${escapeHtml(record.name)}

    `; + previousId = record.id; + const usedBy = [...record.usedBy.values()] + .sort((left, right) => + packageKey(left).localeCompare(packageKey(right)), + ) + .map( + (pkg) => + `
  • ${escapeHtml(pkg.name)} ${escapeHtml(pkg.version)}
  • `, + ) + .join(""); + return `
    ${heading}

    ${escapeHtml(record.name)}

    Used by:

      ${usedBy}
    ${escapeHtml(normalizeEmbeddedText(record.text))}
    `; + }) + .join("\n"); + + return ` + + + + + OpenMausBot — Cua Driver third-party licenses + + + +

    Cua Driver ${RELEASE_VERSION} third-party licenses

    +

    This report covers all ${EXPECTED_REGISTRY_UNION_COUNT} crates.io packages in the root-scoped release build graphs for cua-driver and cua-cursor-theme, plus the embedded Inter font. Cua's eight workspace packages are covered by the accompanying MIT LICENSE.md.

    +

    Source commit: ${SOURCE_COMMIT}. Cargo.lock SHA-256: ${CARGO_LOCK_SHA256}.

    +

    License overview

    +
      ${overviewHtml}
    +

    Package inventory

    + +${inventoryHtml} +
    PackageVersionDeclared license
    +

    License texts and attribution

    +${licensesHtml} + + +`; +} + +const [driverBytes, cursorThemeBytes, cargoLockBytes] = await Promise.all([ + readFile(driverReportPath), + readFile(cursorThemeReportPath), + readFile(cargoLockPath), +]); +if (sha256(cargoLockBytes) !== CARGO_LOCK_SHA256) { + throw new Error( + "Cargo.lock does not match the reviewed Cua 0.19.3 source commit", + ); +} + +const repositoryRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const recordsDirectory = path.join(repositoryRoot, "third_party", "cua-driver"); +const [interLicenseBytes, cuaLicenseBytes] = await Promise.all([ + readFile(path.join(recordsDirectory, "Inter-OFL-1.1.txt")), + readFile(path.join(recordsDirectory, "LICENSE.md")), +]); +if (sha256(interLicenseBytes) !== INTER_LICENSE_SHA256) { + throw new Error("Inter license file differs from the reviewed OFL-1.1 text"); +} +if ( + sha256(cuaLicenseBytes) !== + "c0779290c1d4783169aa3dbfb55feb505e563ef8a004bbf55298ceffcfbda8d9" +) { + throw new Error("Cua MIT license differs from the reviewed upstream source"); +} + +const driver = inspectCargoAboutReport( + JSON.parse(driverBytes), + "cua-driver cargo-about report", +); +const cursorTheme = inspectCargoAboutReport( + JSON.parse(cursorThemeBytes), + "cursor-theme cargo-about report", +); +if (driver.discovered.size !== EXPECTED_DRIVER_REGISTRY_COUNT) { + throw new Error( + `cua-driver report must contain ${EXPECTED_DRIVER_REGISTRY_COUNT} registry packages`, + ); +} +if (cursorTheme.discovered.size !== EXPECTED_CURSOR_THEME_REGISTRY_COUNT) { + throw new Error( + `cursor-theme report must contain ${EXPECTED_CURSOR_THEME_REGISTRY_COUNT} registry packages`, + ); +} +assertExactSet( + driver.local, + EXPECTED_DRIVER_LOCAL, + "cua-driver local package roots", +); +assertExactSet( + cursorTheme.local, + EXPECTED_CURSOR_THEME_LOCAL, + "cursor-theme local package roots", +); + +const registryPackages = mergeRegistryPackages( + driver.discovered, + cursorTheme.discovered, +); +if (registryPackages.size !== EXPECTED_REGISTRY_UNION_COUNT) { + throw new Error( + `root-scoped registry union must contain ${EXPECTED_REGISTRY_UNION_COUNT} packages`, + ); +} +assertExactSet( + [...cursorTheme.discovered.values()] + .filter((pkg) => !driver.discovered.has(pkg.id)) + .map(packageKey), + EXPECTED_CURSOR_THEME_ONLY, + "cursor-theme-only registry package set", +); + +const cargoLockPackages = parseCargoLock(cargoLockBytes.toString("utf8")); +const registryComponents = [...registryPackages.values()].map((pkg) => + registryComponent(pkg, cargoLockPackages), +); +const components = [ + ...registryComponents, + ...TRYCUA_COMPONENTS.map(trycuaComponent), + interComponent(), +].sort( + (left, right) => + left.name.localeCompare(right.name) || + left.version.localeCompare(right.version) || + left.group.localeCompare(right.group), +); +const componentReferences = new Set( + components.map((component) => component["bom-ref"]), +); +if (componentReferences.size !== components.length) + throw new Error("SBOM contains duplicate bom-ref values"); +if ( + components.length !== + EXPECTED_REGISTRY_UNION_COUNT + TRYCUA_COMPONENTS.length + 1 +) { + throw new Error( + "SBOM component count is not the reviewed 330 + 8 + 1 inventory", + ); +} + +const licenseRecords = mergeLicenseRecords( + [driver.report, cursorTheme.report], + registryPackages, + interLicenseBytes.toString("utf8"), +); +const html = renderLicenseHtml(licenseRecords, registryPackages); + +const rootReference = `pkg:generic/cua-driver-linux-x64@${RELEASE_VERSION}`; +const bom = { + bomFormat: "CycloneDX", + specVersion: "1.5", + serialNumber: "urn:uuid:3db9d425-7d84-5aca-b7eb-104d225f8561", + version: 1, + metadata: { + component: { + type: "application", + "bom-ref": rootReference, + name: "Cua Driver Linux x64 runtime", + version: RELEASE_VERSION, + licenses: [{ license: { id: "MIT" } }], + externalReferences: [ + { + type: "distribution", + url: `https://github.com/trycua/cua/releases/tag/cua-driver-rs-v${RELEASE_VERSION}`, + }, + { + type: "vcs", + url: `https://github.com/trycua/cua/commit/${SOURCE_COMMIT}`, + }, + ], + properties: [ + { name: "openmausbot:archive:sha256", value: ARCHIVE_SHA256 }, + { name: "openmausbot:cargo-lock:sha256", value: CARGO_LOCK_SHA256 }, + { name: "openmausbot:target", value: "x86_64-unknown-linux-gnu" }, + { name: "openmausbot:feature", value: "cua-driver/portal-input" }, + { + name: "openmausbot:registry-component-count", + value: String(EXPECTED_REGISTRY_UNION_COUNT), + }, + { + name: "openmausbot:trycua-component-count", + value: String(TRYCUA_COMPONENTS.length), + }, + { + name: "openmausbot:file:cua-driver:sha256", + value: + "ed5844fadf07b9b72c4a3b3802e1c47233c166d66d6198608d5991f807aab4ac", + }, + { + name: "openmausbot:file:cua-cursor-theme:sha256", + value: + "e589b2b7521bbfeaf9e2bfce668a38e80ed1b9790b1327b13d374fc331d8312a", + }, + ], + }, + }, + components, + dependencies: [ + { ref: rootReference, dependsOn: sorted(componentReferences) }, + ], +}; + +const registryRows = [...registryPackages.values()] + .sort((left, right) => packageKey(left).localeCompare(packageKey(right))) + .map((pkg) => { + const checksum = cargoLockPackages.get(packageKey(pkg)).checksum; + return `| ${pkg.name.replaceAll("|", "\\|")} | ${pkg.version} | ${normalizeLicense(pkg.license ?? pkg.resolvedLicense, packageKey(pkg)).replaceAll("|", "\\|")} | \`${checksum}\` |`; + }); +const trycuaRows = TRYCUA_COMPONENTS.map( + (name) => `| ${name} | ${RELEASE_VERSION} | MIT |`, +); +const mplSourceRows = EXPECTED_MPL_COMPONENTS.map((component) => { + const separator = component.lastIndexOf("@"); + const name = component.slice(0, separator); + const version = component.slice(separator + 1); + const checksum = cargoLockPackages.get(component)?.checksum; + if (!checksum) + throw new Error( + `Cargo.lock has no checksum for MPL component ${component}`, + ); + return `- [${component} source](https://crates.io/api/v1/crates/${encodeURIComponent(name)}/${encodeURIComponent(version)}/download) — crate SHA-256 \`${checksum}\`;`; +}); +const notices = `# Cua Driver ${RELEASE_VERSION} third-party notices + +OpenMausBot redistributes two executables from the official Cua Driver ${RELEASE_VERSION} Linux x64 release: \`cua-driver\` and \`cua-cursor-theme\`. + +- Upstream source commit: [\`${SOURCE_COMMIT}\`](https://github.com/trycua/cua/commit/${SOURCE_COMMIT}) +- Upstream \`Cargo.lock\` SHA-256: \`${CARGO_LOCK_SHA256}\` +- Release archive SHA-256: \`${ARCHIVE_SHA256}\` +- Cua's eight workspace components remain under the accompanying MIT \`LICENSE.md\`. +- The embedded Inter ${INTER_VERSION} font remains under the accompanying SIL OFL 1.1 \`Inter-OFL-1.1.txt\`. +- Full license texts and attribution for all ${EXPECTED_REGISTRY_UNION_COUNT} crates.io packages and Inter are in \`THIRD_PARTY_LICENSES.html\`. +- The machine-readable inventory contains ${EXPECTED_REGISTRY_UNION_COUNT} registry packages, ${TRYCUA_COMPONENTS.length} Cua packages, and Inter in \`SBOM.cdx.json\`. + +## MPL-2.0 source availability + +The shipped build graph contains exactly the following MPL-2.0 components. Their corresponding Source Code Form is available from the versioned crates below: + +${mplSourceRows.join("\n")} + +The complete MPL-2.0 text and package copyright notices appear in \`THIRD_PARTY_LICENSES.html\`. These files remain under their original licenses; OpenMausBot's and Cua's MIT licenses do not replace them. + +## Scope and method + +The registry inventory is the union of two root-scoped \`cargo-about 0.8.4\` graphs: \`cua-driver\` with \`portal-input\` for \`x86_64-unknown-linux-gnu\`, and \`cursor-theme-cli\` for the same target. Development-only dependencies are excluded; build dependencies are retained. Raw workspace-unified Cargo metadata is not used as the inventory because features enabled only by unrelated workspace members would overstate the shipped graph. + +### Cua workspace components + +| Package | Version | License | +|---|---:|---| +${trycuaRows.join("\n")} + +### crates.io release-build inventory + +| Package | Version | Declared license expression | Crate SHA-256 | +|---|---:|---|---| +${registryRows.join("\n")} + +### Embedded non-Cargo asset + +| Component | Version | License | Embedded file SHA-256 | Source | +|---|---:|---|---|---| +| Inter | ${INTER_VERSION} | OFL-1.1 | \`${INTER_FONT_SHA256}\` | [Inter commit ${INTER_COMMIT.slice(0, 9)}](https://github.com/rsms/inter/commit/${INTER_COMMIT}) | +`; + +await mkdir(outputDirectory, { recursive: true }); +await Promise.all([ + writeFile( + path.join(outputDirectory, "SBOM.cdx.json"), + `${JSON.stringify(bom, null, 2)}\n`, + ), + writeFile(path.join(outputDirectory, "THIRD_PARTY_NOTICES.md"), notices), + writeFile(path.join(outputDirectory, "THIRD_PARTY_LICENSES.html"), html), +]); +console.log( + `Generated CUA compliance records: ${registryPackages.size} registry + ` + + `${TRYCUA_COMPONENTS.length} Cua + 1 Inter component; ${EXPECTED_MPL_COMPONENTS.length} MPL components.`, +); diff --git a/scripts/prepare-cua-linux.mjs b/scripts/prepare-cua-linux.mjs new file mode 100644 index 000000000..a0f71aa05 --- /dev/null +++ b/scripts/prepare-cua-linux.mjs @@ -0,0 +1,12 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { stageLinuxCua } from "./cua-linux-release.mjs"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const result = await stageLinuxCua({ + rootDirectory: root, + offline: process.argv.includes("--offline") || process.env.OPENMAUSBOT_CUA_OFFLINE === "1", + archivePath: process.env.OPENMAUSBOT_CUA_ARCHIVE_PATH, +}); + +console.log(`Staged CUA Driver ${result.manifest.version} from ${result.source} at ${result.stageDirectory}`); diff --git a/scripts/run-linux-package-smoke.mjs b/scripts/run-linux-package-smoke.mjs new file mode 100644 index 000000000..64fecc62f --- /dev/null +++ b/scripts/run-linux-package-smoke.mjs @@ -0,0 +1,95 @@ +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdtempSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const prefixName = "omb-linux-smoke-runtime-"; +const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +async function cleanupRuntime(directory) { + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + rmSync(directory, { recursive: true, force: true }); + return; + } catch { + // dbus-run-session can exit just before a portal-owned runtime socket + // disappears. Give that bounded teardown a moment before treating a + // persistent runtime as a lifecycle failure. + await delay(100); + } + } + throw new Error(`[run-linux-package-smoke] could not clean released runtime ${directory}`); +} +const appImages = readdirSync(path.join(root, "release")).filter((name) => name.endsWith(".AppImage")); +if (appImages.length !== 1) { + throw new Error(`[run-linux-package-smoke] expected exactly one AppImage, found ${appImages.length}`); +} +const [appImage] = appImages; + +for (const executable of [ + path.join(root, "release", "linux-unpacked", "openmausbot"), + path.join(root, "release", appImage), +]) { + const runtimeDirectory = mkdtempSync(path.join(tmpdir(), prefixName)); + chmodSync(runtimeDirectory, 0o700); + const bundled = spawnSync( + "dbus-run-session", + ["--", "xvfb-run", "-a", process.execPath, path.join(root, "scripts", "smoke-linux-package.mjs")], + { + cwd: root, + env: { + ...process.env, + XDG_RUNTIME_DIR: runtimeDirectory, + OMB_SMOKE_BUNDLED_CUA: "1", + OMB_SMOKE_EXECUTABLE: executable, + }, + stdio: "inherit", + }, + ); + if (bundled.error) throw bundled.error; + if (bundled.status !== 0) { + console.error(`[run-linux-package-smoke] bundled runtime kept at ${runtimeDirectory}`); + process.exitCode = bundled.status ?? 1; + break; + } + await cleanupRuntime(runtimeDirectory); +} + +if (process.exitCode === undefined) for (const lane of [ + { name: "x11", wayland: false, hardDeath: false }, + { name: "wayland", wayland: true, hardDeath: false }, + { name: "x11-hard-death", wayland: false, hardDeath: true }, +]) { + const runtimeDirectory = mkdtempSync(path.join(tmpdir(), prefixName)); + if ( + path.dirname(runtimeDirectory) !== path.resolve(tmpdir()) || + !path.basename(runtimeDirectory).startsWith(prefixName) + ) { + throw new Error(`[run-linux-package-smoke] unexpected temporary path: ${runtimeDirectory}`); + } + + chmodSync(runtimeDirectory, 0o700); + const result = spawnSync( + "dbus-run-session", + ["--", "xvfb-run", "-a", process.execPath, path.join(root, "scripts", "smoke-linux-package.mjs")], + { + cwd: root, + env: { + ...process.env, + XDG_RUNTIME_DIR: runtimeDirectory, + OMB_SMOKE_WAYLAND: lane.wayland ? "1" : "0", + OMB_SMOKE_HARD_DEATH: lane.hardDeath ? "1" : "0", + }, + stdio: "inherit", + }, + ); + if (result.error) throw result.error; + if (result.status !== 0) { + console.error(`[run-linux-package-smoke] ${lane.name} runtime kept at ${runtimeDirectory}`); + process.exitCode = result.status ?? 1; + break; + } + await cleanupRuntime(runtimeDirectory); +} diff --git a/scripts/smoke-linux-package.mjs b/scripts/smoke-linux-package.mjs index 08161c67d..e8afbcafe 100644 --- a/scripts/smoke-linux-package.mjs +++ b/scripts/smoke-linux-package.mjs @@ -1,10 +1,22 @@ import { spawn } from "node:child_process"; -import { chmodSync, existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const wayland = process.env.OMB_SMOKE_WAYLAND === "1"; +const hardDeath = process.env.OMB_SMOKE_HARD_DEATH === "1"; +const bundled = process.env.OMB_SMOKE_BUNDLED_CUA === "1"; +if (hardDeath && bundled) throw new Error("hard-death and bundled smoke modes are mutually exclusive"); const executable = path.resolve( process.env.OMB_SMOKE_EXECUTABLE ?? path.join(root, "release", "linux-unpacked", "openmausbot"), ); @@ -13,29 +25,163 @@ if (!existsSync(executable)) throw new Error(`[smoke-linux-package] missing exec const sandbox = mkdtempSync(path.join(tmpdir(), "omb-linux-smoke-")); const home = path.join(sandbox, "home"); const xdgConfig = path.join(sandbox, "config"); -const marker = path.join(sandbox, "cua-was-executed"); +const xdgRuntime = path.join(sandbox, "runtime"); +const marker = path.join(sandbox, "cua-invocations.ndjson"); +const fakeState = path.join(sandbox, "cua-serve-count"); const sentinel = path.join(sandbox, "cua-driver"); mkdirSync(path.join(home, ".openmausbot"), { recursive: true }); mkdirSync(xdgConfig, { recursive: true }); +mkdirSync(xdgRuntime, { recursive: true, mode: 0o700 }); +chmodSync(xdgRuntime, 0o700); writeFileSync( path.join(home, ".openmausbot", "config.json"), JSON.stringify({ instances: { ghost: { driver: "not-a-real-driver", displayName: "Ghost" } } }), ); -writeFileSync(sentinel, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\nexit 99\n`); +for (const appName of ["openmausbot", "OpenMausBot"]) { + const userData = path.join(xdgConfig, appName); + mkdirSync(userData, { recursive: true, mode: 0o700 }); + chmodSync(userData, 0o700); + writeFileSync( + path.join(userData, "cua-local-control.json"), + JSON.stringify({ schemaVersion: 1, linuxLocalControlEnabled: true }), + { mode: 0o600 }, + ); +} +writeFileSync( + sentinel, + `#!${process.execPath} +const { appendFileSync, chmodSync, existsSync, readFileSync, realpathSync, unlinkSync, writeFileSync } = require("node:fs"); +const net = require("node:net"); +const marker = ${JSON.stringify(marker)}; +const state = ${JSON.stringify(fakeState)}; +const wayland = ${JSON.stringify(wayland)}; +const args = process.argv.slice(2); +appendFileSync(marker, JSON.stringify({ + pid: process.pid, + args, + telemetryEnabled: process.env.CUA_DRIVER_RS_TELEMETRY_ENABLED, + updateCheck: process.env.CUA_DRIVER_RS_UPDATE_CHECK, + waylandEnabled: process.env.CUA_DRIVER_RS_ENABLE_WAYLAND === "1", +}) + "\\n"); +const after = (flag) => { const index = args.indexOf(flag); return index === -1 ? null : args[index + 1]; }; +if (args.includes("--version")) { + process.stdout.write("cua-driver 0.19.3\\n"); + process.exit(0); +} +if (args[0] === "manifest") { + const binary = realpathSync(process.argv[1]); + process.stdout.write(JSON.stringify({ + schema_version: "1", + binary_version: "0.19.3", + binary_path: binary, + mcp_invocation: { command: binary, args: ["mcp"] }, + }) + "\\n"); + process.exit(0); +} +if (args[0] === "doctor" && args.includes("--json")) { + process.stdout.write(JSON.stringify({ ok: true, probes: [ + { label: "binary", status: "ok", message: "cua-driver 0.19.3" }, + { label: "display server", status: "ok", message: wayland + ? "Wayland+XWayland (WAYLAND_DISPLAY=wayland-smoke, DISPLAY=:99)" + : "X11 (DISPLAY=:99)" }, + { label: "X11 connection", status: "warn", message: "no top-level windows in Xvfb" }, + { label: "AT-SPI", status: "ok", message: "fixture bus available" }, + ] }) + "\\n"); + process.exit(0); +} +if (args[0] !== "serve") process.exit(64); +const socketPath = after("--socket"); +const pidFile = after("--pid-file"); +if (!socketPath || !pidFile || !args.includes("--embedded") || after("--permission-mode") !== "standard") { + process.exit(64); +} +if ((process.env.CUA_DRIVER_RS_ENABLE_WAYLAND === "1") !== wayland) process.exit(64); +const count = existsSync(state) ? Number(readFileSync(state, "utf8")) + 1 : 1; +writeFileSync(state, String(count)); +writeFileSync(pidFile, String(process.pid), { mode: 0o600 }); +const metadata = { + driver_version: "0.19.3", + contract_version: "0.6.0", + tools_list_schema_version: "1", + capability_version: "1", + mcp_protocol_version: "2025-06-18", + pid: process.pid, + embedded: true, + host_bundle_id: "com.openmausbot.app", +}; +const tools = ["click", "get_window_state", "list_apps", "type_text"].map((name) => ({ name })); +const toolManifest = { schema_version: "1", capability_version: "1", tools }; +const healthReport = { + schema_version: "1", + platform: "linux", + driver_version: "0.19.3", + overall: "ok", + checks: [ + { name: "binary_version", status: "pass", message: "cua-driver 0.19.3" }, + { name: "platform_supported", status: "pass", message: "Ubuntu 24.04" }, + { name: "session_active", status: "pass", message: "MCP session is active." }, + { name: "ax_capability", status: "pass", message: "AT-SPI fixture is reachable." }, + { name: "screen_capture_capability", status: "pass", message: "Portal fixture is reachable." }, + { name: "wayland_backend", status: "pass", message: "WinRects and portal/libei fixtures are reachable." }, + ], +}; +const server = net.createServer((socket) => { + let input = ""; + socket.on("data", (chunk) => { + input += chunk; + const newline = input.indexOf("\\n"); + if (newline === -1) return; + const request = JSON.parse(input.slice(0, newline)); + const result = request.method === "metadata" + ? metadata + : request.method === "list" + ? toolManifest + : request.method === "call" && request.name === "health_report" && wayland + ? { structuredContent: healthReport } + : null; + socket.end(JSON.stringify(result ? { ok: true, result } : { ok: false, error: "unknown" }) + "\\n"); + if (count === 1 && request.method === "list") setTimeout(() => server.close(() => process.exit(17)), 5000); + }); +}); +server.listen(socketPath, () => chmodSync(socketPath, 0o600)); +const shutdown = () => server.close(() => { + for (const file of [socketPath, pidFile]) { try { unlinkSync(file); } catch {} } + process.exit(0); +}); +process.stdin.on("end", shutdown); +process.on("SIGTERM", shutdown); +`, +); chmodSync(sentinel, 0o755); +const desktopEnv = { + ...process.env, + HOME: home, + XDG_CONFIG_HOME: xdgConfig, + XDG_RUNTIME_DIR: xdgRuntime, + XDG_SESSION_TYPE: wayland ? "wayland" : "x11", + XDG_CURRENT_DESKTOP: "GNOME", + CUA_DRIVER_PATH: sentinel, + OMB_SMOKE_TEST: "1", + OMB_SMOKE_CUA: hardDeath || bundled ? "0" : "1", + OMB_SMOKE_BUNDLED_CUA: bundled ? "1" : "0", + ...(hardDeath ? { OMB_SMOKE_KEEP_OPEN: "1" } : {}), +}; +if (bundled) delete desktopEnv.CUA_DRIVER_PATH; +if (wayland) desktopEnv.WAYLAND_DISPLAY = "wayland-smoke"; +else delete desktopEnv.WAYLAND_DISPLAY; + let output = ""; let smokeResult = null; -const child = spawn(executable, [], { +// The smoke runs with a disposable HOME and no interactive keyring. Keep +// Electron's credential backend inside that sandbox so a GNOME Keyring unlock +// prompt cannot block the headless renderer before did-finish-load. +const electronArgs = ["--password-store=basic"]; +if (wayland) electronArgs.push("--ozone-platform=x11"); +const child = spawn(executable, electronArgs, { cwd: root, detached: true, - env: { - ...process.env, - HOME: home, - XDG_CONFIG_HOME: xdgConfig, - CUA_DRIVER_PATH: sentinel, - OMB_SMOKE_TEST: "1", - }, + env: desktopEnv, stdio: ["ignore", "pipe", "pipe"], }); @@ -49,13 +195,18 @@ for (const stream of [child.stdout, child.stderr]) { } const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const processRunning = (processHandle) => + processHandle.exitCode === null && processHandle.signalCode === null; +const childRunning = () => processRunning(child); async function until(probe, description) { const deadline = Date.now() + 30_000; while (Date.now() < deadline) { const value = await probe().catch(() => null); if (value) return value; - if (child.exitCode !== null) { - throw new Error(`Electron exited ${child.exitCode} while waiting for ${description}.\n${output}`); + if (!childRunning()) { + throw new Error( + `Electron exited ${child.exitCode ?? child.signalCode} while waiting for ${description}.\n${output}`, + ); } await delay(100); } @@ -64,42 +215,287 @@ async function until(probe, description) { async function waitForExit() { const deadline = Date.now() + 10_000; - while (child.exitCode === null && Date.now() < deadline) await delay(50); - if (child.exitCode === null) throw new Error(`Electron did not exit after its window closed.\n${output}`); + while (childRunning() && Date.now() < deadline) await delay(50); + if (childRunning()) throw new Error(`Electron did not exit after its window closed.\n${output}`); } -async function stopProcess() { - if (child.exitCode !== null) return; +async function stopDetached(processHandle) { + if (!processRunning(processHandle)) return; try { - process.kill(-child.pid, "SIGTERM"); + process.kill(-processHandle.pid, "SIGTERM"); } catch {} const stopDeadline = Date.now() + 5_000; - while (child.exitCode === null && Date.now() < stopDeadline) await delay(50); - if (child.exitCode === null) { + while (processRunning(processHandle) && Date.now() < stopDeadline) await delay(50); + if (processRunning(processHandle)) { try { - process.kill(-child.pid, "SIGKILL"); + process.kill(-processHandle.pid, "SIGKILL"); } catch {} } } +async function stopProcess() { + await stopDetached(child); +} + try { const result = await until(async () => smokeResult, "the packaged renderer smoke result"); - const { capabilities, health, location, title } = result; + const { + capabilities, + cuaRuntime, + cuaCrashReason, + cuaRetryStatus, + displayMediaRequests, + health, + initialCapabilities, + location, + title, + } = result; if (health?.app !== "openmausbot" || health.static !== true) { throw new Error(`unexpected embedded health response: ${JSON.stringify(health)}`); } if (!String(title).includes("OpenMausBot")) throw new Error(`unexpected renderer title: ${title}`); if (capabilities.host.platform !== "linux") throw new Error("renderer did not report Linux"); + if (capabilities.host.session !== (wayland ? "wayland" : "x11")) { + throw new Error(`renderer did not report the ${wayland ? "Wayland" : "X11"} contract`); + } + const expectedPreview = wayland ? "portal-picker" : "direct"; + if (!capabilities.screenPreview.available || capabilities.screenPreview.interaction !== expectedPreview) { + throw new Error(`${wayland ? "Wayland" : "X11"} screen preview capability was not available`); + } if (capabilities.dictation.available) throw new Error("dictation must be unavailable on Linux"); - if (capabilities.localComputer.available) throw new Error("local control must be unavailable on Linux"); - if (existsSync(marker)) throw new Error("Linux executed the CUA sentinel"); + if (!initialCapabilities.localComputer.available) throw new Error("initial Linux CUA runtime was not ready"); + if (initialCapabilities.localComputer.support !== "limited") throw new Error("Linux CUA was not marked beta/limited"); + if (wayland && ( + initialCapabilities.localComputer.session !== "wayland" || + initialCapabilities.localComputer.compositor !== "gnome-mutter" + )) { + throw new Error("initial Linux CUA runtime did not publish the guarded GNOME Wayland contract"); + } + if (!bundled && !hardDeath) { + if (cuaCrashReason !== "daemon-exited") { + throw new Error("daemon crash did not invalidate local control"); + } + if (cuaRetryStatus?.status !== "ready" || !capabilities.localComputer.available) { + throw new Error("explicit CUA retry did not create a ready generation"); + } + } + if (displayMediaRequests !== 0) throw new Error("launch triggered display capture without user intent"); + if (bundled) { + await waitForExit(); + const staleHealth = await fetch(new URL("/api/health", location)).catch(() => null); + if (staleHealth?.ok) throw new Error("embedded harness remained reachable after Electron quit"); + const appImage = executable.endsWith(".AppImage"); + if (cuaCrashReason !== null || cuaRetryStatus !== null) { + throw new Error("bundled smoke unexpectedly entered the fake crash/retry lane"); + } + if ( + cuaRuntime?.driverSource !== "bundled" || + cuaRuntime.driverVersion !== "0.19.3" || + (appImage + ? cuaRuntime.appImagePrivateStage !== true || cuaRuntime.exactBundledPath !== false + : cuaRuntime.exactBundledPath !== true || + !String(cuaRuntime.driverPath).endsWith("/resources/cua-linux-x64/cua-driver")) + ) { + throw new Error(`packaged Electron did not select its bundled driver: ${JSON.stringify(cuaRuntime)}`); + } + if ( + cuaRuntime.mcpEnv?.CUA_DRIVER_RS_UPDATE_CHECK !== "false" || + cuaRuntime.mcpEnv?.CUA_DRIVER_RS_TELEMETRY_ENABLED !== "false" + ) { + throw new Error("packaged MCP descriptor did not disable update checks and telemetry"); + } + if (existsSync(cuaRuntime.socketPath) || existsSync(cuaRuntime.pidFile)) { + throw new Error("packaged CUA runtime files remained after Electron quit"); + } + if (appImage && existsSync(path.dirname(cuaRuntime.driverPath))) { + throw new Error("AppImage private CUA stage remained after Electron quit"); + } + if (existsSync(marker)) { + throw new Error(`packaged app invoked the ambient driver:\n${readFileSync(marker, "utf8")}`); + } + try { + process.kill(cuaRuntime.daemonPid, 0); + throw new Error(`packaged CUA daemon remained alive after quit: ${cuaRuntime.daemonPid}`); + } catch (error) { + if (error?.code !== "ESRCH") throw error; + } + console.log( + `[smoke-linux-package] OK (bundled ${path.basename(executable)}): packaged resolver, descriptor, harness, and cleanup`, + ); + } else { + const invocations = readFileSync(marker, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + const commands = invocations.map((entry) => entry.args.join(" ")); + for (const expected of ["--version", "manifest", "doctor --json"]) { + if (!commands.some((command) => command === expected)) throw new Error(`missing CUA probe: ${expected}`); + } + if (invocations.some((entry) => entry.waylandEnabled !== wayland)) { + throw new Error("CUA Wayland opt-in escaped its certified smoke lane"); + } + if ( + invocations.some( + (entry) => entry.updateCheck !== "false" || entry.telemetryEnabled !== "false", + ) + ) { + throw new Error("a CUA child escaped the local-only update/telemetry environment"); + } + const daemons = invocations.filter((entry) => entry.args[0] === "serve"); + const expectedDaemonCount = hardDeath ? 1 : 2; + if (daemons.length !== expectedDaemonCount) { + throw new Error(`expected ${expectedDaemonCount} daemon generation(s), found ${daemons.length}`); + } + + if (hardDeath) { + child.kill("SIGKILL"); + await waitForExit(); + const cleanupDeadline = Date.now() + 10_000; + while (Date.now() < cleanupDeadline) { + const stillAlive = daemons.some((daemon) => { + try { + process.kill(daemon.pid, 0); + return true; + } catch (error) { + return error?.code !== "ESRCH"; + } + }); + const runtimeFilesRemain = daemons.some((daemon) => { + const socketIndex = daemon.args.indexOf("--socket"); + const pidFileIndex = daemon.args.indexOf("--pid-file"); + return ( + (socketIndex !== -1 && existsSync(daemon.args[socketIndex + 1])) || + (pidFileIndex !== -1 && existsSync(daemon.args[pidFileIndex + 1])) + ); + }); + if (!stillAlive && !runtimeFilesRemain) break; + await delay(50); + } + for (const daemon of daemons) { + try { + process.kill(daemon.pid, 0); + throw new Error(`owned CUA daemon survived hard Electron death: ${daemon.pid}`); + } catch (error) { + if (error?.code !== "ESRCH") throw error; + } + } + const serverDeadline = Date.now() + 10_000; + let staleHealth = null; + while (Date.now() < serverDeadline) { + staleHealth = await fetch(new URL("/api/health", location)).catch(() => null); + if (!staleHealth?.ok) break; + await delay(50); + } + if (staleHealth?.ok) throw new Error("embedded harness survived hard Electron death"); + const userData = ["openmausbot", "OpenMausBot"] + .map((name) => path.join(xdgConfig, name)) + .find((directory) => existsSync(path.join(directory, "cua-connection.json"))); + if (!userData) throw new Error("hard-death smoke could not locate the CUA descriptor"); + const { readCuaConnection } = await import( + new URL("../dist-server/local-computer.js", import.meta.url) + ); + if (readCuaConnection({ platform: "linux", userData }) !== null) { + throw new Error("stale hard-death CUA descriptor remained usable"); + } - await waitForExit(); - const staleHealth = await fetch(new URL("/api/health", location)).catch(() => null); - if (staleHealth?.ok) throw new Error("embedded harness remained reachable after Electron quit"); - if (existsSync(marker)) throw new Error("Linux executed the CUA sentinel during shutdown"); + let restartOutput = ""; + let restartResult = null; + const restart = spawn(executable, wayland ? ["--ozone-platform=x11"] : [], { + cwd: root, + detached: true, + env: { ...desktopEnv, OMB_SMOKE_KEEP_OPEN: "0" }, + stdio: ["ignore", "pipe", "pipe"], + }); + try { + for (const stream of [restart.stdout, restart.stderr]) { + stream.setEncoding("utf8"); + stream.on("data", (chunk) => { + restartOutput += chunk; + const match = restartOutput.match(/\[smoke\] renderer-ready (\{.*\})\r?\n/); + if (match && !restartResult) restartResult = JSON.parse(match[1]); + }); + } + const restartDeadline = Date.now() + 30_000; + while (!restartResult && Date.now() < restartDeadline) { + if (restart.exitCode !== null || restart.signalCode !== null) { + throw new Error(`Electron restart exited before renderer readiness.\n${restartOutput}`); + } + await delay(100); + } + if (!restartResult?.initialCapabilities?.localComputer?.available) { + throw new Error(`Electron restart did not create a ready CUA generation.\n${restartOutput}`); + } + const restartExitDeadline = Date.now() + 10_000; + while ( + restart.exitCode === null && + restart.signalCode === null && + Date.now() < restartExitDeadline + ) { + await delay(50); + } + if (restart.exitCode === null && restart.signalCode === null) { + throw new Error(`Electron restart did not close normally.\n${restartOutput}`); + } + if (restart.exitCode !== 0) { + throw new Error( + `Electron restart exited with ${restart.exitCode ?? restart.signalCode}.\n${restartOutput}`, + ); + } - console.log("[smoke-linux-package] OK: renderer, capabilities, embedded harness, and shutdown"); + const allDaemons = readFileSync(marker, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)) + .filter((entry) => entry.args[0] === "serve"); + const restartedDaemons = allDaemons; + if (restartedDaemons.length !== 2) { + throw new Error( + `hard-death restart expected two cumulative generations (pre-kill and restart), found ${restartedDaemons.length}`, + ); + } + const socketPaths = new Set( + restartedDaemons.map((daemon) => daemon.args[daemon.args.indexOf("--socket") + 1]), + ); + if (socketPaths.size !== 2) throw new Error("hard-death restart reused a stale CUA generation"); + for (const daemon of restartedDaemons) { + try { + process.kill(daemon.pid, 0); + throw new Error(`CUA daemon survived restart shutdown: ${daemon.pid}`); + } catch (error) { + if (error?.code !== "ESRCH") throw error; + } + for (const flag of ["--socket", "--pid-file"]) { + const index = daemon.args.indexOf(flag); + if (index !== -1 && existsSync(daemon.args[index + 1])) { + throw new Error(`stale CUA runtime file survived restart: ${daemon.args[index + 1]}`); + } + } + } + if (readCuaConnection({ platform: "linux", userData }) !== null) { + throw new Error("CUA descriptor remained usable after restart shutdown"); + } + } finally { + await stopDetached(restart); + } + console.log(`[smoke-linux-package] OK (${wayland ? "GNOME/Wayland" : "GNOME/X11"} hard death): restart replaced the generation and left no daemon, runtime file, server, or usable descriptor`); + } else { + await waitForExit(); + const staleHealth = await fetch(new URL("/api/health", location)).catch(() => null); + if (staleHealth?.ok) throw new Error("embedded harness remained reachable after Electron quit"); + } + + for (const daemon of daemons) { + try { + process.kill(daemon.pid, 0); + throw new Error(`owned CUA daemon remained alive after quit: ${daemon.pid}`); + } catch (error) { + if (error?.code !== "ESRCH") throw error; + } + } + if (!hardDeath) { + console.log(`[smoke-linux-package] OK (${wayland ? "GNOME/Wayland" : "GNOME/X11"}): renderer, private CUA crash/retry, harness, and shutdown`); + } + } } finally { await stopProcess(); if (process.env.OMB_KEEP_SMOKE_DIR !== "1") rmSync(sandbox, { recursive: true, force: true }); diff --git a/scripts/verify-linux-package.mjs b/scripts/verify-linux-package.mjs index c3bfc295d..2660ccc83 100644 --- a/scripts/verify-linux-package.mjs +++ b/scripts/verify-linux-package.mjs @@ -1,8 +1,24 @@ import { execFileSync } from "node:child_process"; -import { accessSync, constants, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { + accessSync, + constants, + lstatSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; +import { LICENSE_FILES } from "./cua-linux-release.mjs"; + +const require = createRequire(import.meta.url); +const { validateDriverCandidate } = require("../electron/cua-linux.cjs"); const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const releaseDir = path.resolve(process.argv[2] ?? path.join(root, "release")); @@ -32,6 +48,278 @@ function requireExecutable(file) { } } +function sha256(file) { + return createHash("sha256").update(readFileSync(file)).digest("hex"); +} + +function requireRegularMode(file, mode) { + const details = lstatSync(file, { throwIfNoEntry: false }); + if (!details?.isFile() || details.isSymbolicLink()) fail(`expected a regular file: ${file}`); + if ((details.mode & 0o777) !== mode) { + fail(`expected mode ${mode.toString(8)} for ${file}, found ${(details.mode & 0o777).toString(8)}`); + } +} + +function requireDirectoryMode(directory, mode) { + const details = lstatSync(directory, { throwIfNoEntry: false }); + if (!details?.isDirectory() || details.isSymbolicLink()) { + fail(`expected a real directory: ${directory}`); + } + if ((details.mode & 0o777) !== mode) { + fail( + `expected mode ${mode.toString(8)} for ${directory}, found ${(details.mode & 0o777).toString(8)}`, + ); + } +} + +function requireExactEntries(directory, expected) { + const actual = readdirSync(directory).sort(); + const wanted = [...expected].sort(); + if (JSON.stringify(actual) !== JSON.stringify(wanted)) { + fail(`${directory} entries differ: expected ${wanted.join(", ")}; found ${actual.join(", ")}`); + } +} + +function requireContained(root, target) { + const canonicalRoot = realpathSync(root); + const canonicalTarget = realpathSync(target); + if (canonicalTarget !== canonicalRoot && !canonicalTarget.startsWith(`${canonicalRoot}${path.sep}`)) { + fail(`${target} resolves outside ${root}`); + } +} + +function verifyCompliance(licenses, label) { + const notices = readFileSync(path.join(licenses, "THIRD_PARTY_NOTICES.md"), "utf8"); + const html = readFileSync(path.join(licenses, "THIRD_PARTY_LICENSES.html"), "utf8"); + const sbom = JSON.parse(readFileSync(path.join(licenses, "SBOM.cdx.json"), "utf8")); + const components = sbom.components ?? []; + if (components.length !== 339) { + fail(`${label} SBOM must contain 330 registry packages, 8 Cua packages, and Inter`); + } + const componentRefs = new Set(); + for (const component of components) { + const reference = component["bom-ref"]; + if (typeof reference !== "string" || reference.length === 0) { + fail(`${label} SBOM component has no bom-ref: ${component.name ?? "unknown"}`); + } + if (componentRefs.has(reference)) fail(`${label} SBOM repeats bom-ref ${reference}`); + componentRefs.add(reference); + } + const registry = components.filter((component) => component.group === "crates.io"); + const trycua = components.filter((component) => component.group === "trycua"); + const fonts = components.filter((component) => component.group === "rsms"); + if (registry.length !== 330 || trycua.length !== 8 || fonts.length !== 1) { + fail(`${label} SBOM component groups do not match the reviewed runtime graph`); + } + const trycuaNames = trycua.map((component) => component.name).sort(); + if ( + JSON.stringify(trycuaNames) !== + JSON.stringify([ + "cua-driver", + "cua-driver-contract", + "cua-driver-core", + "cua-driver-sdk", + "cursor-overlay", + "cursor-theme-cli", + "pip-preview", + "platform-linux", + ]) + ) { + fail(`${label} SBOM Cua workspace allowlist changed`); + } + if (fonts[0]?.name !== "Inter" || fonts[0]?.version !== "4.001") { + fail(`${label} SBOM is missing the reviewed Inter font component`); + } + const registryIds = new Set(); + for (const component of registry) { + const packageId = component.properties?.find( + (property) => property.name === "openmausbot:cargo:package-id", + )?.value; + if (typeof packageId !== "string" || !packageId.startsWith("registry+")) { + fail(`${label} SBOM registry component has no exact Cargo package ID`); + } + if (registryIds.has(packageId)) fail(`${label} SBOM repeats ${packageId}`); + registryIds.add(packageId); + if (!component.hashes?.some((hash) => hash.alg === "SHA-256" && /^[a-f0-9]{64}$/.test(hash.content))) { + fail(`${label} SBOM registry component has no Cargo.lock checksum: ${component.name}`); + } + if (!notices.includes(`| ${component.name.replaceAll("|", "\\|")} | ${component.version} |`)) { + fail(`${label} notices are missing SBOM attribution for ${component.name}@${component.version}`); + } + } + const attributed = new Set( + [...html.matchAll(//g)].map((match) => match[1]), + ); + if (attributed.size !== 330) { + fail(`${label} license report does not cover the reviewed 330 registry packages`); + } + for (const packageId of attributed) { + if (!registryIds.has(packageId)) { + fail(`${label} license report contains an unknown Cargo package: ${packageId}`); + } + } + const mpls = components + .filter((component) => JSON.stringify(component.licenses).includes("MPL-2.0")) + .map((component) => `${component.name}@${component.version}`) + .sort(); + if ( + JSON.stringify(mpls) !== + JSON.stringify([ + "option-ext@0.2.0", + "uniffi@0.31.0", + "uniffi_core@0.31.0", + "uniffi_internal_macros@0.31.0", + "uniffi_macros@0.31.0", + "uniffi_meta@0.31.0", + "uniffi_pipeline@0.31.0", + ]) + ) { + fail(`${label} MPL component set changed`); + } + const rootDependency = sbom.dependencies?.find( + (dependency) => dependency.ref === "pkg:generic/cua-driver-linux-x64@0.19.3", + ); + const rootReferences = rootDependency?.dependsOn; + const uniqueRootReferences = new Set(rootReferences ?? []); + if ( + !Array.isArray(rootReferences) || + rootReferences.length !== componentRefs.size || + uniqueRootReferences.size !== rootReferences.length || + [...uniqueRootReferences].some((reference) => !componentRefs.has(reference)) + ) { + fail(`${label} SBOM root must reference every reviewed component`); + } + for (const expected of [ + "Mozilla Public License 2.0", + "SIL Open Font License 1.1", + "bumpalo@3.20.2", + "typed-path@0.12.3", + "zip@8.6.0", + "zlib-rs@0.6.6", + "zopfli@0.8.3", + ]) { + if (!(html + notices.replaceAll(" | ", "@")).includes(expected)) { + fail(`${label} compliance records are missing ${expected}`); + } + } +} + +function verifyCuaResources(resources, label, { + directoryMode = 0o755, + validateRuntimePath = true, +} = {}) { + requireDirectoryMode(resources, directoryMode); + const cuaRoot = path.join(resources, "cua-linux-x64"); + const licenses = path.join(cuaRoot, "licenses"); + requireDirectoryMode(cuaRoot, directoryMode); + requireDirectoryMode(licenses, directoryMode); + requireExactEntries(cuaRoot, ["cua-driver", "cua-cursor-theme", "licenses", "release.json"]); + requireExactEntries(licenses, [ + "LICENSE.md", + "Inter-OFL-1.1.txt", + "THIRD_PARTY_LICENSES.html", + "THIRD_PARTY_NOTICES.md", + "SBOM.cdx.json", + ]); + requireContained(resources, cuaRoot); + requireContained(cuaRoot, licenses); + const driver = path.join(cuaRoot, "cua-driver"); + const cursorTheme = path.join(cuaRoot, "cua-cursor-theme"); + requireRegularMode(driver, 0o755); + requireRegularMode(cursorTheme, 0o755); + requireContained(cuaRoot, driver); + requireContained(cuaRoot, cursorTheme); + const expectedHashes = new Map([ + [driver, "ed5844fadf07b9b72c4a3b3802e1c47233c166d66d6198608d5991f807aab4ac"], + [cursorTheme, "e589b2b7521bbfeaf9e2bfce668a38e80ed1b9790b1327b13d374fc331d8312a"], + ]); + for (const [file, expected] of expectedHashes) { + const actual = sha256(file); + if (actual !== expected) fail(`${label} has the wrong hash for ${path.basename(file)}: ${actual}`); + } + + const commandEnvironment = { + LANG: "C", + LC_ALL: "C", + CUA_DRIVER_RS_UPDATE_CHECK: "false", + CUA_DRIVER_RS_TELEMETRY_ENABLED: "false", + }; + const version = execFileSync(driver, ["--version"], { + encoding: "utf8", + env: commandEnvironment, + timeout: 5_000, + }).trim(); + if (version !== "cua-driver 0.19.3") fail(`${label} CUA version is ${JSON.stringify(version)}`); + const manifest = JSON.parse( + execFileSync(driver, ["manifest"], { + encoding: "utf8", + env: commandEnvironment, + timeout: 5_000, + maxBuffer: 256 * 1024, + }), + ); + const invocationCommand = manifest.mcp_invocation?.command; + let invocationPath = null; + if (typeof invocationCommand === "string" && invocationCommand.length > 0) { + try { + invocationPath = realpathSync(invocationCommand); + } catch {} + } + if ( + manifest.schema_version !== "1" || + manifest.binary_version !== "0.19.3" || + invocationPath !== realpathSync(driver) || + JSON.stringify(manifest.mcp_invocation?.args) !== JSON.stringify(["mcp"]) + ) { + fail(`${label} CUA manifest does not match the packaged executable`); + } + + for (const name of LICENSE_FILES) { + requireRegularMode(path.join(licenses, name), 0o644); + requireContained(licenses, path.join(licenses, name)); + } + requireRegularMode(path.join(cuaRoot, "release.json"), 0o644); + const release = JSON.parse(readFileSync(path.join(cuaRoot, "release.json"), "utf8")); + const expectedRelease = { + schemaVersion: 1, + version: "0.19.3", + platform: "linux", + arch: "x64", + archive: { + name: "cua-driver-rs-0.19.3-linux-x86_64-binary.tar.gz", + url: + "https://github.com/trycua/cua/releases/download/cua-driver-rs-v0.19.3/" + + "cua-driver-rs-0.19.3-linux-x86_64-binary.tar.gz", + size: 27_248_614, + sha256: "3db9d4257d84bacaf7eb104d225f85613ce67edbb20d6eeb83c1384b6d8a5b10", + }, + sourceCommit: "a1672e7b11951275ecfba3384264d4530185d0db", + files: { + "cua-driver": { sha256: expectedHashes.get(driver), mode: "0755" }, + "cua-cursor-theme": { sha256: expectedHashes.get(cursorTheme), mode: "0755" }, + }, + }; + if (JSON.stringify(release) !== JSON.stringify(expectedRelease)) { + fail(`${label} release.json does not match the reviewed Linux CUA release`); + } + const sbom = JSON.parse(readFileSync(path.join(licenses, "SBOM.cdx.json"), "utf8")); + const components = new Set( + (sbom.components ?? []).map((component) => `${component.name}@${component.version}`), + ); + for (const expected of ["uniffi_core@0.31.0", "option-ext@0.2.0"]) { + if (!components.has(expected)) fail(`${label} SBOM is missing ${expected}`); + } + verifyCompliance(licenses, label); + + if (validateRuntimePath) { + const validation = validateDriverCandidate(driver); + if (validation.status !== "found" || validation.path !== realpathSync(driver)) { + fail(`${label} packaged CUA path fails the runtime trust policy: ${JSON.stringify(validation)}`); + } + } + return expectedHashes; +} + const appImage = exactlyOne(".AppImage"); const deb = exactlyOne(".deb"); const unpacked = path.join(releaseDir, "linux-unpacked"); @@ -40,14 +328,16 @@ const resources = path.join(unpacked, "resources"); requireExecutable(appImage); requireExecutable(executable); +requireDirectoryMode(unpacked, 0o755); for (const relative of ["app.asar", "ui/index.html", "server/index.js"]) { requireFile(path.join(resources, relative)); } -for (const forbidden of ["speech-helper", "cua-driver"]) { +for (const forbidden of ["speech-helper", "cua-driver", "cua-sdk"]) { if (statSync(path.join(resources, forbidden), { throwIfNoEntry: false })) { fail(`unsupported Linux resource was bundled: ${forbidden}`); } } +const unpackedCuaHashes = verifyCuaResources(resources, "linux-unpacked"); const fields = execFileSync( "dpkg-deb", @@ -67,6 +357,14 @@ for (const expected of [ const extracted = mkdtempSync(path.join(tmpdir(), "omb-deb-verify-")); try { execFileSync("dpkg-deb", ["--extract", deb, extracted]); + const debAppRoot = path.join(extracted, "opt", "OpenMausBot"); + requireDirectoryMode(debAppRoot, 0o755); + const debResources = path.join(debAppRoot, "resources"); + const debHashes = verifyCuaResources(debResources, "DEB"); + for (const [unpackedFile, expected] of unpackedCuaHashes) { + const packaged = path.join(debResources, "cua-linux-x64", path.basename(unpackedFile)); + if (debHashes.get(packaged) !== expected) fail(`DEB and linux-unpacked CUA hashes differ`); + } const desktopFile = path.join( extracted, "usr", @@ -101,4 +399,42 @@ try { rmSync(extracted, { recursive: true, force: true }); } +const appImageExtracted = mkdtempSync(path.join(tmpdir(), "omb-appimage-verify-")); +try { + const offset = execFileSync(appImage, ["--appimage-offset"], { + encoding: "utf8", + timeout: 10_000, + }).trim(); + if (!/^\d+$/.test(offset)) fail(`AppImage returned an invalid SquashFS offset: ${offset}`); + const squashRoot = path.join(appImageExtracted, "squashfs-root"); + execFileSync( + "unsquashfs", + ["-no-progress", "-offset", offset, "-d", squashRoot, appImage], + { stdio: ["ignore", "ignore", "inherit"], timeout: 60_000 }, + ); + const appImageDirectoryMode = lstatSync(squashRoot).mode & 0o777; + if (![0o755, 0o775].includes(appImageDirectoryMode)) { + fail( + `expected AppImage directory mode 755 or 775 for ${squashRoot}, found ${appImageDirectoryMode.toString(8)}`, + ); + } + const appImageResources = path.join(squashRoot, "resources"); + // Depending on the pinned appimagetool runtime, SquashFS directories are + // emitted as root:root 0755 or 0775. Require one mode consistently across + // the reviewed resource tree. The app never executes through that path: + // Electron stages and re-hashes the two pinned binaries into a fresh 0700 + // directory first. The AppImage smoke below proves that packaged path, + // while this extraction verifies inputs. + const appImageHashes = verifyCuaResources(appImageResources, "AppImage", { + directoryMode: appImageDirectoryMode, + validateRuntimePath: false, + }); + for (const [unpackedFile, expected] of unpackedCuaHashes) { + const packaged = path.join(appImageResources, "cua-linux-x64", path.basename(unpackedFile)); + if (appImageHashes.get(packaged) !== expected) fail(`AppImage and linux-unpacked CUA hashes differ`); + } +} finally { + rmSync(appImageExtracted, { recursive: true, force: true }); +} + console.log(`[verify-linux-package] OK\n- ${path.basename(appImage)}\n- ${path.basename(deb)}`); diff --git a/server/auto-approve.test.ts b/server/auto-approve.test.ts index 0c91da91b..a015b60bd 100644 --- a/server/auto-approve.test.ts +++ b/server/auto-approve.test.ts @@ -74,6 +74,13 @@ describe("approvalKey", () => { expect(approvalKey("mcp__ogb__computer_batch", "click 5,5")).toBe("mcp__ogb__computer_batch"); }); + it("names local and cloud grants in different scopes", () => { + expect(approvalKey("mcp__computer__click", "click", "local-computer")).toBe( + "local-computer:mcp__computer__click", + ); + expect(approvalKey("mcp__computer__click", "click")).toBe("mcp__computer__click"); + }); + it("grants one program, not the whole shell", () => { const bot = { alwaysAllow: [approvalKey("Bash", "git status")] }; expect(autoDecision(bot, "Bash", "git log --oneline")).toBeTruthy(); @@ -104,6 +111,18 @@ describe("autoDecision", () => { it("never lets always-allow override the destructive guard", () => { expect(autoDecision({ alwaysAllow: ["Bash"] }, "Bash", "sudo rm -rf /var")).toBeNull(); }); + + it("never delegates a local-computer request to auto or remembered grants", () => { + const bot = { + autoApprove: true, + alwaysAllow: ["mcp__computer__click", "local-computer:mcp__computer__click"], + }; + expect( + autoDecision(bot, "mcp__computer__click", "Click the Submit button", { + scope: "local-computer", + }), + ).toBeNull(); + }); }); describe("unattended turns", () => { diff --git a/server/auto-approve.ts b/server/auto-approve.ts index 7d2975636..f6220ac2e 100644 --- a/server/auto-approve.ts +++ b/server/auto-approve.ts @@ -49,15 +49,16 @@ export function looksDestructive(text: string): boolean { * client so the two sides can never disagree about what was granted. */ const COMMAND_TOOLS = new Set(["bash", "shell", "execute", "run_command", "computer_exec", "terminal"]); -export function approvalKey(tool: string, summary: string): string { +export function approvalKey(tool: string, summary: string, scope?: "local-computer"): string { const bare = tool.replace(/^mcp__[^_]+__/, "").toLowerCase(); - if (!COMMAND_TOOLS.has(bare)) return tool; + if (!COMMAND_TOOLS.has(bare)) return scope ? `${scope}:${tool}` : tool; // first bare word of the command, skipping env assignments and sudo const words = summary.trim().split(/\s+/); let i = 0; while (i < words.length && (/^[A-Z_][A-Z0-9_]*=/.test(words[i]) || words[i] === "sudo")) i += 1; const program = (words[i] ?? "").split("/").pop()?.replace(/[^\w.-]/g, "") ?? ""; - return program ? `${tool}:${program}` : tool; + const key = program ? `${tool}:${program}` : tool; + return scope ? `${scope}:${key}` : key; } export interface AutoApprover { @@ -75,6 +76,8 @@ export function autoDecision( context?: { /** the turn was started by an outside event, with nobody at the keyboard */ unattended?: boolean; + /** the request controls the user's active desktop */ + scope?: "local-computer"; }, ): string | null { // Auto mode is something a person switched on for turns they are present @@ -83,10 +86,13 @@ export function autoDecision( // pattern list its own comment calls "not a security boundary", and it // must not stand in for a human at 3am. if (context?.unattended) return null; + // The user's active desktop is never delegated to bot auto mode or a + // remembered cloud/tool grant in the Linux beta. + if (context?.scope === "local-computer") return null; // the guards come first, so an "always allow" can never widen into them if (looksDestructive(summary) || looksDestructive(tool)) return null; if (looksSensitive(summary)) return null; - const key = approvalKey(tool, summary); + const key = approvalKey(tool, summary, context?.scope); if (bot.alwaysAllow?.includes(key)) return `auto-approved ${key} (always allowed)`; if (bot.autoApprove) return `auto-approved ${tool}`; return null; diff --git a/server/contracts.ts b/server/contracts.ts index 6abd22684..0ba6958b2 100644 --- a/server/contracts.ts +++ b/server/contracts.ts @@ -109,6 +109,7 @@ export type RuntimeEvent = RuntimeEventBase & tool: string; summary: string; choices?: string[]; + approvalScope?: "local-computer"; } | { type: "request.resolved"; @@ -117,6 +118,7 @@ export type RuntimeEvent = RuntimeEventBase & * harness (turn ended / settings changed), or nobody — the answerer * was already gone and the action never ran */ source: "user" | "auto" | "timeout" | "system" | "unavailable" | "peer"; + approvalScope?: "local-computer"; } | { type: "thread.token-usage.updated"; input: number; output: number } // `setup: true` marks a failure the user fixes by installing or @@ -155,8 +157,18 @@ export interface SendTurnInput { composio?: { command: string; args: string[]; env: Record }; /** Cloud computer, reached through OpenMausBot's REST-to-MCP adapter. */ computer?: { kind?: "box"; boxId: string; token: string }; - /** Direct stdio connection to a Cua Driver MCP server (host, sandbox, or VPS). */ - localComputer?: { command: string; args: string[]; env: Record }; + /** Direct stdio connection to a Cua Driver MCP server (host, sandbox, or + * VPS). `scope` is set only for the user's host desktop; isolated and + * remote computers intentionally omit it so host-only approval rules + * cannot change their semantics. */ + localComputer?: { + command: string; + args: string[]; + env: Record; + platform?: "darwin" | "linux" | "win32"; + generation?: string; + scope?: "local-computer"; + }; /** Peer-agent comms: an MCP proxy (list_bots / ask_bot) that routes back * through the harness so this bot can message other bots. The harness * owns turns, permissions, and recursion limits; the proxy only forwards. */ @@ -197,6 +209,9 @@ export interface ProviderAdapter { * the driver cannot set effort, so the app never offers the control — * same rule as computerMcp: never show a knob the driver cannot turn. */ effortLevels?: readonly EffortLevel[]; + /** True only when local MCP calls can reach the human approval channel. + * Full-auto/bypass provider instances must leave this false. */ + localComputerMcp?: boolean; }; sendTurn(input: SendTurnInput): Promise; interruptTurn(threadId: ThreadId, turnId?: TurnId): Promise; diff --git a/server/drivers/acp/acp.test.ts b/server/drivers/acp/acp.test.ts index 3d6a994a9..aca67e5ac 100644 --- a/server/drivers/acp/acp.test.ts +++ b/server/drivers/acp/acp.test.ts @@ -132,6 +132,33 @@ describe("ACP decodeConfig", () => { expect(GrokAgentDriver.decodeConfig({ fullAuto: "yes" }).fullAuto).toBe(false); expect(GrokAgentDriver.decodeConfig({ fullAuto: true }).fullAuto).toBe(true); }); + + it("does not advertise or accept local CUA in full-auto mode", async () => { + const fullAuto = await GrokAgentDriver.create({ + instanceId: "grok-full-auto", + displayName: "Grok Full Auto", + environment: {}, + enabled: true, + config: { cli: FAKE_CLI, fullAuto: true }, + }); + expect(fullAuto.adapter.capabilities.localComputerMcp).toBe(false); + await expect( + fullAuto.adapter.sendTurn({ + threadId: "t-full-auto-local", + text: "click", + integrations: { + localComputer: { + command: "/cua-driver", + args: ["mcp"], + env: {}, + platform: "linux", + scope: "local-computer", + }, + }, + }), + ).rejects.toThrow(/interactive provider approvals/); + await fullAuto.dispose(); + }); }); describe("ACP turns (fake CLI)", () => { @@ -330,15 +357,64 @@ describe("ACP turns (fake CLI)", () => { expect(recorder.events.some((e) => e.type === "session.started")).toBe(true); }); + it("mounts local CUA only on an approval-capable ACP instance", async () => { + await create(); + const dump = join(scratch, "local-dump.json"); + process.env.FAKE_ACP_DUMP = dump; + await instance.adapter.sendTurn({ + threadId: "t-local", + text: "inspect", + integrations: { + localComputer: { + command: "/opt/cua driver/cua-driver", + args: ["mcp", "--embedded", "--socket", "/run/user/1000/driver.sock"], + env: { CUA_DRIVER_EMBEDDED: "1" }, + platform: "linux", + generation: "generation-1", + scope: "local-computer", + }, + }, + }); + await recorder.until((event) => event.type === "turn.completed"); + const seen = JSON.parse(readFileSync(dump, "utf8")); + expect(seen.mcpServers).toContainEqual({ + name: "computer", + command: "/opt/cua driver/cua-driver", + args: ["mcp", "--embedded", "--socket", "/run/user/1000/driver.sock"], + env: [{ name: "CUA_DRIVER_EMBEDDED", value: "1" }], + }); + expect(instance.adapter.capabilities.localComputerMcp).toBe(true); + }); + it("surfaces a permission ask as request.opened and completes once allowed", async () => { await create(GrokAgentDriver, "permission"); - await instance.adapter.sendTurn({ threadId: "t-perm", text: "go" }); + await instance.adapter.sendTurn({ + threadId: "t-perm", + text: "go", + integrations: { + localComputer: { + command: "/cua-driver", + args: ["mcp"], + env: {}, + platform: "linux", + scope: "local-computer", + }, + }, + }); const opened = await recorder.until((e) => e.type === "request.opened"); - expect(opened).toMatchObject({ requestType: "permission", tool: "shell" }); + expect(opened).toMatchObject({ + requestType: "permission", + tool: "shell", + approvalScope: "local-computer", + }); await instance.adapter.respondToRequest("t-perm", (opened as any).requestId, { behavior: "allow" }); const resolved = await recorder.until((e) => e.type === "request.resolved"); - expect(resolved).toMatchObject({ behavior: "allow", source: "user" }); + expect(resolved).toMatchObject({ + behavior: "allow", + source: "user", + approvalScope: "local-computer", + }); const done = await recorder.until((e) => e.type === "turn.completed"); expect(done).toMatchObject({ ok: true }); }); diff --git a/server/drivers/acp/core.ts b/server/drivers/acp/core.ts index 0bbc999ea..5c2a6e6cf 100644 --- a/server/drivers/acp/core.ts +++ b/server/drivers/acp/core.ts @@ -254,6 +254,10 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver const sendTurn = async (turn: SendTurnInput) => { const { threadId } = turn; if (active.has(threadId)) throw new Error("a turn is already running on this thread"); + const controlsHost = turn.integrations?.localComputer?.scope === "local-computer"; + if (controlsHost && config.fullAuto) { + throw new Error("local computer control requires interactive provider approvals"); + } const turnId = newId(); const cwd = turn.cwd ?? config.workspace ?? homedir(); const env = childEnv(); @@ -370,6 +374,7 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver requestId, behavior: optionId && behavior === "allow" ? "allow" : "deny", source: optionId ? source : "system", + approvalScope: controlsHost ? "local-computer" : undefined, }); }; const timer = setTimeout(() => { @@ -385,6 +390,7 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver requestType: "permission", tool, summary, + approvalScope: controlsHost ? "local-computer" : undefined, }); }; @@ -675,6 +681,7 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver computerMcp: true, composioMcp: true, effortLevels: support.effortLevels, + localComputerMcp: !config.fullAuto, }, sendTurn, interruptTurn: async (threadId) => active.get(threadId)?.interrupt(), diff --git a/server/drivers/claude.test.ts b/server/drivers/claude.test.ts index e67c4ea2b..adf553001 100644 --- a/server/drivers/claude.test.ts +++ b/server/drivers/claude.test.ts @@ -93,6 +93,33 @@ describe("ClaudeDriver.decodeConfig", () => { // it, Windows pipes for these two threads would collide and race expect(permissionSocketPath("t-perm-dup-1")).not.toBe(permissionSocketPath("t-perm-dup-2")); }); + + it("does not advertise or accept local CUA in bypassPermissions mode", async () => { + const bypass = await ClaudeDriver.create({ + instanceId: "claude-bypass", + displayName: "Claude Bypass", + environment: {}, + enabled: true, + config: { cli: FAKE_CLI, permissionMode: "bypassPermissions" }, + }); + expect(bypass.adapter.capabilities.localComputerMcp).toBe(false); + await expect( + bypass.adapter.sendTurn({ + threadId: "t-bypass-local", + text: "click", + integrations: { + localComputer: { + command: "/cua-driver", + args: ["mcp"], + env: {}, + platform: "linux", + scope: "local-computer", + }, + }, + }), + ).rejects.toThrow(/interactive approval broker/); + await bypass.dispose(); + }); }); describe("ClaudeDriver turns (fake CLI)", () => { @@ -324,6 +351,37 @@ describe("ClaudeDriver turns (fake CLI)", () => { expect(existsSync(dirname(configPath))).toBe(false); }); + it("mounts local CUA without pre-allowing its computer namespace", async () => { + await create(); + const dump = join(scratch, "local-dump.json"); + process.env.FAKE_CLAUDE_DUMP = dump; + await instance.adapter.sendTurn({ + threadId: "t-local", + text: "inspect the desktop", + integrations: { + localComputer: { + command: "/opt/cua driver/cua-driver", + args: ["mcp", "--embedded", "--socket", "/run/user/1000/driver.sock"], + env: { CUA_DRIVER_EMBEDDED: "1" }, + platform: "linux", + generation: "generation-1", + scope: "local-computer", + }, + }, + }); + await recorder.until((event) => event.type === "turn.completed"); + + const seen = JSON.parse(readFileSync(dump, "utf8")); + expect(seen.mcpConfig.mcpServers.computer).toEqual({ + command: "/opt/cua driver/cua-driver", + args: ["mcp", "--embedded", "--socket", "/run/user/1000/driver.sock"], + env: { CUA_DRIVER_EMBEDDED: "1" }, + }); + const allowed = seen.argv[seen.argv.indexOf("--allowedTools") + 1]; + expect(allowed).not.toContain("mcp__computer"); + expect(instance.adapter.capabilities.localComputerMcp).toBe(true); + }); + it("resumes with --resume when a cursor exists and reports that session id", async () => { await create(); const dump = join(scratch, "dump.json"); @@ -393,7 +451,19 @@ describe("ClaudeDriver turns (fake CLI)", () => { it("brokers a permission ask into request.opened and answers over the socket", async () => { await create("hang"); - await instance.adapter.sendTurn({ threadId: "t-perm-abc", text: "go" }); + await instance.adapter.sendTurn({ + threadId: "t-perm-abc", + text: "go", + integrations: { + localComputer: { + command: "/cua-driver", + args: ["mcp"], + env: {}, + platform: "linux", + scope: "local-computer", + }, + }, + }); await recorder.until((e) => e.type === "session.started"); // connect as the MCP proxy would and raise an ask — unix socket on @@ -419,13 +489,18 @@ describe("ClaudeDriver turns (fake CLI)", () => { tool: "Bash", summary: "rm -rf scratch", requestId: "ask-1", + approvalScope: "local-computer", }); // the outcome names exactly what was granted: this action, once await expect(instance.adapter.respondToRequest("t-perm-abc", "ask-1", { behavior: "allow" })).resolves.toBe("allowed-once"); expect(await answered).toMatchObject({ behavior: "allow" }); const resolved = await recorder.until((e) => e.type === "request.resolved"); - expect(resolved).toMatchObject({ behavior: "allow", source: "user" }); + expect(resolved).toMatchObject({ + behavior: "allow", + source: "user", + approvalScope: "local-computer", + }); conn.end(); await instance.adapter.interruptTurn("t-perm-abc"); diff --git a/server/drivers/claude.ts b/server/drivers/claude.ts index ce208a907..2d1df973e 100644 --- a/server/drivers/claude.ts +++ b/server/drivers/claude.ts @@ -378,6 +378,10 @@ export const ClaudeDriver: ProviderDriver = { const sendTurn = async (turn: SendTurnInput) => { const { threadId } = turn; if (active.has(threadId)) throw new Error("a turn is already running on this thread"); + const controlsHost = turn.integrations?.localComputer?.scope === "local-computer"; + if (controlsHost && config.permissionMode === "bypassPermissions") { + throw new Error("local computer control requires the interactive approval broker"); + } const turnId = newId(); const sessionId = typeof turn.resumeCursor === "string" ? turn.resumeCursor : null; const newSessionId = sessionId ? null : newId(); @@ -416,11 +420,15 @@ export const ClaudeDriver: ProviderDriver = { }; allowed.push("mcp__computer"); } else if (turn.integrations?.localComputer) { - // A direct Cua Driver MCP connection. This can be the Electron-owned - // host daemon or the isolated Local VM; the agent sees the same - // "computer" server either way. - mcpServers.computer = { ...turn.integrations.localComputer }; - allowed.push("mcp__computer"); + const local = turn.integrations.localComputer; + mcpServers.computer = { + command: local.command, + args: local.args, + env: local.env, + }; + // The isolated Local VM preserves the established pre-allow behavior. + // Host tools always route through OpenMausBot's permission broker. + if (!controlsHost) allowed.push("mcp__computer"); } // peer-agent comms (list_bots/ask_bot) — the harness builds the whole // spawn contract (command/args/env incl. the boot token) in @@ -463,6 +471,7 @@ export const ClaudeDriver: ProviderDriver = { requestType: ask.kind, tool: ask.tool, summary: askSummary(ask), + approvalScope: controlsHost ? "local-computer" : undefined, choices: Array.isArray(ask.input?.choices) ? (ask.input.choices as string[]).slice(0, 5) : undefined, }), onResolve: (resolved) => @@ -472,6 +481,7 @@ export const ClaudeDriver: ProviderDriver = { requestId: resolved.id, behavior: resolved.behavior, source: resolved.source, + approvalScope: controlsHost ? "local-computer" : undefined, }), }); args.push("--permission-prompt-tool", "mcp__ogb__approve"); @@ -691,6 +701,7 @@ export const ClaudeDriver: ProviderDriver = { composioMcp: true, phoneMcp: true, effortLevels: ["low", "medium", "high", "xhigh", "max"], + localComputerMcp: config.permissionMode !== "bypassPermissions", }, sendTurn, interruptTurn: async (threadId) => active.get(threadId)?.stop(), diff --git a/server/harness/registry.ts b/server/harness/registry.ts index 31dcfdf7f..b0de25062 100644 --- a/server/harness/registry.ts +++ b/server/harness/registry.ts @@ -141,7 +141,7 @@ export class ProviderRegistry { displayName: entry.shadow.displayName ?? entry.shadow.driverKind, snapshot: { state: "unavailable", reason: entry.shadow.reason } satisfies ProviderSnapshot, models: { default: "", options: [] }, - capabilities: { computerMcp: false, agentsMcp: false }, + capabilities: { computerMcp: false, agentsMcp: false, localComputerMcp: false }, // an unknown driver has no driver record, hence no install path access: driver?.metadata.access ?? "subscription", install: driver?.install, @@ -172,6 +172,7 @@ export class ProviderRegistry { composioMcp: inst.adapter.capabilities.composioMcp === true, phoneMcp: inst.adapter.capabilities.phoneMcp === true, effortLevels: inst.adapter.capabilities.effortLevels, + localComputerMcp: inst.adapter.capabilities.localComputerMcp === true, }, access: driver?.metadata.access ?? "subscription", install: driver?.install, diff --git a/server/index.test.ts b/server/index.test.ts index 7274f70cb..3d8b6f7b1 100644 --- a/server/index.test.ts +++ b/server/index.test.ts @@ -496,6 +496,39 @@ describe("harness HTTP API", () => { expect(after.modelSelection.effort).toBeUndefined(); }); + it("turns off bot Auto mode when local computer beta is selected", async () => { + const created = await api("POST", "/api/bots"); + const bot = created.body.bot; + expect((await api("PATCH", `/api/bots/${bot.id}`, { autoApprove: true })).body.bot.autoApprove).toBe( + true, + ); + const local = await api("PATCH", `/api/bots/${bot.id}`, { computer: "local" }); + expect(local.body.bot).toMatchObject({ computer: "local", autoApprove: false }); + const rejected = await api("PATCH", `/api/bots/${bot.id}`, { autoApprove: true }); + expect(rejected.status).toBe(400); + expect(rejected.body.error).toContain("local computer beta"); + + const cloud = await api("PATCH", `/api/bots/${bot.id}`, { computer: "cloud" }); + expect(cloud.body.bot.computer).toBe("cloud"); + const simultaneous = await api("PATCH", `/api/bots/${bot.id}`, { + computer: "local", + autoApprove: true, + }); + expect(simultaneous.status).toBe(400); + expect(simultaneous.body.error).toContain("local computer beta"); + await api("DELETE", `/api/bots/${bot.id}`); + }); + + it("offers an idempotent stop boundary for active local turns", async () => { + const unsupported = await api("POST", "/api/local-computer/interrupt"); + expect(unsupported).toEqual({ + status: 415, + body: { error: "content-type must be application/json" }, + }); + const stopped = await api("POST", "/api/local-computer/interrupt", {}); + expect(stopped).toEqual({ status: 200, body: { ok: true } }); + }); + it("persists an answered onboarding card", async () => { const { body } = await api("GET", "/api/bots"); const bot = body.bots[0]; diff --git a/server/index.ts b/server/index.ts index 4b94dfd35..bd2f30d9b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -84,6 +84,7 @@ import { memberTurnSelection } from "./member-turn.ts"; import { WebhookManager } from "./webhooks.ts"; import { SPAWNED_PROXIES } from "./proxy-paths.ts"; import { loadBundledSkills, renderSkillInstructions, selectBundledSkills } from "./skill-library.ts"; +import { shouldMountLocalComputer } from "./local-routing.ts"; const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799); const WEBHOOK_PORT = Number(process.env.OMB_WEBHOOK_PORT || PORT + 1); @@ -664,6 +665,7 @@ bus.subscribe((event: RuntimeEvent) => { const settled = permission && asker && event.requestId ? autoDecision(asker, event.tool, event.summary, { unattended: isUnattended(asker.id), + scope: event.approvalScope, }) : null; if (settled && asker && event.requestId) { @@ -698,8 +700,14 @@ bus.subscribe((event: RuntimeEvent) => { options: ["Allow", "Deny"], requestId, tool, - allowKey: approvalKey(tool, summary), - held: "Auto mode couldn't answer this one.", + allowKey: event.approvalScope + ? undefined + : approvalKey(tool, summary, event.approvalScope), + held: + event.approvalScope === "local-computer" + ? "Local computer actions always require your approval in this beta." + : "Auto mode couldn't answer this one.", + approvalScope: event.approvalScope, }, }); askMessageByRequest.set(`${event.threadId}:${requestId}`, card.id); @@ -711,16 +719,30 @@ bus.subscribe((event: RuntimeEvent) => { role: "bot", kind: "options", card: { - title: permission ? "Approval needed" : "Your bot has a question", + title: + permission && event.approvalScope === "local-computer" + ? "Local computer approval" + : permission + ? "Approval needed" + : "Your bot has a question", subtitle: event.summary, options: event.choices?.length ? event.choices : permission ? ["Allow", "Deny"] : [], requestId: event.requestId, tool: permission ? event.tool : undefined, // the exact grant "always allow" would remember, decided here so // client and server can never derive it differently - allowKey: permission ? approvalKey(event.tool, event.summary) : undefined, + allowKey: + permission && !event.approvalScope + ? approvalKey(event.tool, event.summary, event.approvalScope) + : undefined, // in auto mode a card can only mean the guard stopped it — say so - held: permission && asker?.autoApprove ? "This looked destructive, so auto mode stopped to ask." : undefined, + held: + permission && event.approvalScope === "local-computer" + ? "Local computer actions always require your approval in this beta." + : permission && asker?.autoApprove + ? "This looked destructive, so auto mode stopped to ask." + : undefined, + approvalScope: event.approvalScope, }, }); if (event.requestId) askMessageByRequest.set(`${event.threadId}:${event.requestId}`, message.id); @@ -1252,6 +1274,7 @@ async function startTurn( const cloudBackend = opts?.runOn === "cloud" || bot.cloudBackend !== "vps" ? "box" : "vps"; const mountsComputerMcp = instance.adapter.capabilities.computerMcp === true; const mountsCloudComputer = mountsComputerMcp || instance.driverKind === "boxAgent"; + const mountsLocalComputer = instance.adapter.capabilities.localComputerMcp === true; let previewCapture: (() => Promise<{ png: string; format: string }>) | null = null; let computerKind: "box" | "vps" | "vm" | "local" | null = null; @@ -1279,7 +1302,11 @@ async function startTurn( integrations.localComputer = containerComputerMcp(localVm.runtime); computerKind = "vm"; } else if (wants === "local") { - if (!mountsComputerMcp) { + if (!shouldMountLocalComputer({ + requested: "local", + hostPlatform: process.platform, + providerSupportsLocal: mountsLocalComputer, + })) { throw new Error("this model engine cannot control this computer — choose Claude or an ACP engine, or select another destination"); } const cua = readCuaConnection(); @@ -1352,7 +1379,16 @@ async function startTurn( // Auto-only host fallback. Electron owns cua-driver/TCC attribution; // the harness only reads its already-running connection descriptor. - if (!integrations.computer && !integrations.localComputer && wants === undefined && mountsComputerMcp) { + if ( + !integrations.computer && + !integrations.localComputer && + wants === undefined && + shouldMountLocalComputer({ + requested: undefined, + hostPlatform: process.platform, + providerSupportsLocal: mountsLocalComputer, + }) + ) { const cua = readCuaConnection(); if (cua) { integrations.localComputer = cua; @@ -2826,7 +2862,7 @@ const server = createServer(async (req, res) => { m = path.match(/^\/api\/bots\/([\w-]+)$/); if (m && method === "PATCH") { const body = await readBody(req); - const existing = store.bot(m[1]); + const existingBot = store.bot(m[1]); // Neither Codex (free-form string field) nor Grok (lazy, logs-only) // rejects an unknown effort level at their own boundary — this is the // only real gate, so it stays. But it fires only when the target @@ -2845,7 +2881,7 @@ const server = createServer(async (req, res) => { if (!isEffortLevel(nextSelection.effort)) { return json(res, 400, { error: `effort "${String(nextSelection.effort)}" is not recognized` }); } - const target = registry.get(nextSelection.instanceId ?? existing?.modelSelection.instanceId ?? ""); + const target = registry.get(nextSelection.instanceId ?? existingBot?.modelSelection.instanceId ?? ""); // typed as strings, not levels: this is the boundary that decides // whether the value *is* a level, so it must not assert that it is const allowed: readonly string[] = target?.adapter.capabilities.effortLevels ?? []; @@ -2884,11 +2920,12 @@ const server = createServer(async (req, res) => { if (body.cloudBackend !== undefined && !["box", "vps"].includes(String(body.cloudBackend))) { return json(res, 400, { error: "cloudBackend must be box or vps" }); } + const effectiveComputer = body.computer ?? existingBot?.computer; if (body.chiefOfStaff !== undefined && typeof body.chiefOfStaff !== "boolean") { return json(res, 400, { error: "chiefOfStaff must be true or false" }); } if (body.cloudBackend !== undefined) { - const backendError = cloudBackendChangeError(Boolean(existing?.busy), activeVpsThreads.has(m[1])); + const backendError = cloudBackendChangeError(Boolean(existingBot?.busy), activeVpsThreads.has(m[1])); if (backendError) return json(res, 409, { error: backendError }); } if (body.cwd !== undefined) { @@ -2896,7 +2933,7 @@ const server = createServer(async (req, res) => { if (!checked.ok) return json(res, 400, { error: checked.error }); patch.cwd = checked.cwd ?? undefined; } - if (body.hidden === true && existing?.chiefOfStaff && body.chiefOfStaff !== false) { + if (body.hidden === true && existingBot?.chiefOfStaff && body.chiefOfStaff !== false) { return json(res, 400, { error: "choose another Chief of Staff before hiding this bot" }); } // the permission fields decide what runs unattended, so they are @@ -2904,6 +2941,9 @@ const server = createServer(async (req, res) => { // still answer .includes() — with substring matches, not tool names if (body.autoApprove !== undefined) { if (typeof body.autoApprove !== "boolean") return json(res, 400, { error: "autoApprove must be true or false" }); + if (body.autoApprove === true && effectiveComputer === "local") { + return json(res, 400, { error: "Auto mode is unavailable while this bot uses the local computer beta" }); + } patch.autoApprove = body.autoApprove; } if (body.approvePeerComms !== undefined) { @@ -2918,6 +2958,15 @@ const server = createServer(async (req, res) => { } patch.alwaysAllow = [...new Set(body.alwaysAllow as string[])].slice(0, 200); } + if (effectiveComputer === "local" && body.autoApprove === undefined && existingBot?.autoApprove) { + patch.autoApprove = false; + } + if (existingBot?.computer === "local" && body.computer !== undefined && body.computer !== "local") { + await registry + .get(existingBot.modelSelection.instanceId) + ?.adapter.interruptTurn(existingBot.threadId) + .catch(() => {}); + } const bot = store.patchBot(m[1], patch); if (!bot) return json(res, 404, { error: "no such bot" }); const chiefChanges = @@ -2931,6 +2980,21 @@ const server = createServer(async (req, res) => { for (const changedBot of chiefChanges) changed.set(changedBot.id, changedBot); return json(res, 200, { bot: wireBot(bot) }); } + + if (method === "POST" && path === "/api/local-computer/interrupt") { + if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) { + return json(res, 415, { error: "content-type must be application/json" }); + } + await Promise.allSettled( + store.bots + .filter((bot) => bot.computer === "local") + .map((bot) => + registry.get(bot.modelSelection.instanceId)?.adapter.interruptTurn(bot.threadId), + ) + .filter((turn): turn is Promise => Boolean(turn)), + ); + return json(res, 200, { ok: true }); + } m = path.match(/^\/api\/bots\/([\w-]+)$/); if (m && method === "DELETE") { const bot = store.bot(m[1]); diff --git a/server/local-computer.test.ts b/server/local-computer.test.ts index 8116e72dc..b7b2aec24 100644 --- a/server/local-computer.test.ts +++ b/server/local-computer.test.ts @@ -1,13 +1,248 @@ -import { mkdirSync, writeFileSync } from "node:fs"; +import { + appendFileSync, + chmodSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { createServer } from "node:net"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; -import { readCuaConnection } from "./local-computer.ts"; +import { + DRIVER_FILE_IDENTITY_KEYS, + REQUIRED_LINUX_TOOLS, + decodeLinuxDescriptor, + readCuaConnection, + validateLinuxDescriptorRuntime, +} from "./local-computer.ts"; + +const require = createRequire(import.meta.url); +const { DRIVER_FILE_IDENTITY_KEYS: ELECTRON_DRIVER_FILE_IDENTITY_KEYS } = require( + "../electron/cua-linux.cjs", +); +const { REQUIRED_TOOLS: ELECTRON_REQUIRED_TOOLS } = require("../electron/cua-linux-runtime.cjs"); + +function linuxDescriptor(userData: string, { session = "x11" }: { session?: "x11" | "wayland" } = {}) { + const binary = join(userData, "cua-driver"); + const socket = join(userData, "runtime", "driver.sock"); + writeFileSync(binary, "fake", { mode: 0o700 }); + const stat = statSync(binary, { bigint: true }); + const fileIdentity = { + dev: String(stat.dev), + ino: String(stat.ino), + uid: String(stat.uid), + gid: String(stat.gid), + mode: String(stat.mode), + size: String(stat.size), + mtimeNs: String(stat.mtimeNs), + ctimeNs: String(stat.ctimeNs), + }; + return { + schemaVersion: 1, + mode: session === "wayland" ? "linux-wayland-gnome-supervised" : "linux-x11-supervised", + platform: "linux", + session, + ...(session === "wayland" ? { compositor: "gnome-mutter" } : {}), + enabled: true, + status: "ready", + ownerPid: process.pid, + generation: "01234567-89ab-cdef-0123-456789abcdef", + driver: { + path: binary, + version: "0.19.3", + source: "environment", + manifestSchema: "1", + fileIdentity, + }, + daemon: { + socketPath: socket, + pid: process.pid, + contractVersion: "0.6.0", + toolsListSchemaVersion: "1", + capabilityVersion: "1", + mcpProtocolVersion: "2025-06-18", + }, + mcp: { + command: binary, + args: ["mcp", "--embedded", "--socket", socket], + env: { + CUA_DRIVER_EMBEDDED: "1", + CUA_DRIVER_HOST_BUNDLE_ID: "com.openmausbot.app", + CUA_DRIVER_RS_UPDATE_CHECK: "false", + CUA_DRIVER_RS_TELEMETRY_ENABLED: "false", + ...(session === "wayland" ? { CUA_DRIVER_RS_ENABLE_WAYLAND: "1" } : {}), + }, + }, + toolNames: ["click", "get_window_state", "list_apps", "type_text"], + doctorWarnings: [], + }; +} + +describe("local computer descriptor contract", () => { + it("stays synchronized with the Electron producer", () => { + expect(DRIVER_FILE_IDENTITY_KEYS).toEqual([...ELECTRON_DRIVER_FILE_IDENTITY_KEYS]); + expect(REQUIRED_LINUX_TOOLS).toEqual([...ELECTRON_REQUIRED_TOOLS]); + }); +}); + +const temporaryDirectories: string[] = []; + +function privateUserData(name: string) { + const base = process.platform === "win32" ? tmpdir() : realpathSync("/tmp"); + const root = mkdtempSync(join(base, "omb-local-computer-")); + temporaryDirectories.push(root); + const userData = join(root, name); + mkdirSync(userData, { recursive: true, mode: 0o700 }); + chmodSync(userData, 0o700); + return userData; +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); describe("local computer descriptor", () => { - it("fails closed on Linux even when a valid-looking descriptor exists", () => { - const userData = join(process.env.HOME!, "linux-user-data"); - mkdirSync(userData, { recursive: true }); + it("accepts only the exact certified Linux X11 descriptor", () => { + const userData = privateUserData("linux-user-data"); + const descriptor = linuxDescriptor(userData); + writeFileSync(join(userData, "cua-connection.json"), JSON.stringify(descriptor), { mode: 0o600 }); + + expect( + readCuaConnection({ + platform: "linux", + userData, + validateLinuxRuntime: () => true, + }), + ).toEqual({ + command: descriptor.driver.path, + args: descriptor.mcp.args, + env: descriptor.mcp.env, + platform: "linux", + generation: descriptor.generation, + scope: "local-computer", + }); + }); + + it("accepts the exact GNOME Wayland descriptor without weakening the X11 contract", () => { + const userData = privateUserData("linux-wayland-user-data"); + const descriptor = linuxDescriptor(userData, { session: "wayland" }); + expect(decodeLinuxDescriptor(descriptor)).toEqual({ + command: descriptor.driver.path, + args: descriptor.mcp.args, + env: descriptor.mcp.env, + platform: "linux", + generation: descriptor.generation, + scope: "local-computer", + }); + expect(decodeLinuxDescriptor({ ...descriptor, compositor: "kde-kwin" })).toBeNull(); + const { CUA_DRIVER_RS_ENABLE_WAYLAND: _missing, ...x11OnlyEnv } = descriptor.mcp.env; + expect( + decodeLinuxDescriptor({ ...descriptor, mcp: { ...descriptor.mcp, env: x11OnlyEnv } }), + ).toBeNull(); + const x11Descriptor = linuxDescriptor(userData); + expect( + decodeLinuxDescriptor({ + ...x11Descriptor, + mcp: { + ...x11Descriptor.mcp, + env: { ...x11Descriptor.mcp.env, CUA_DRIVER_RS_ENABLE_WAYLAND: "1" }, + }, + }), + ).toBeNull(); + }); + + it("rejects unknown fields, stale modes, arbitrary argv, and incomplete tool surfaces", () => { + const userData = privateUserData("linux-invalid-user-data"); + const descriptor = linuxDescriptor(userData); + expect(decodeLinuxDescriptor({ ...descriptor, unexpected: true })).toBeNull(); + expect(decodeLinuxDescriptor({ ...descriptor, status: "starting" })).toBeNull(); + const { CUA_DRIVER_RS_TELEMETRY_ENABLED: _telemetry, ...telemetryMissing } = + descriptor.mcp.env; + expect( + decodeLinuxDescriptor({ + ...descriptor, + mcp: { ...descriptor.mcp, env: telemetryMissing }, + }), + ).toBeNull(); + expect( + decodeLinuxDescriptor({ + ...descriptor, + mcp: { ...descriptor.mcp, args: ["mcp", "--socket", descriptor.daemon.socketPath, "--evil"] }, + }), + ).toBeNull(); + expect(decodeLinuxDescriptor({ ...descriptor, toolNames: ["list_apps"] })).toBeNull(); + expect( + decodeLinuxDescriptor({ + ...descriptor, + mcp: { + ...descriptor.mcp, + env: { ...descriptor.mcp.env, CUA_DRIVER_RS_TELEMETRY_ENABLED: "true" }, + }, + }), + ).toBeNull(); + expect( + decodeLinuxDescriptor({ + ...descriptor, + driver: { ...descriptor.driver, fileIdentity: { ...descriptor.driver.fileIdentity, extra: "1" } }, + }), + ).toBeNull(); + const { fileIdentity: _missingIdentity, ...driverWithoutIdentity } = descriptor.driver; + expect(decodeLinuxDescriptor({ ...descriptor, driver: driverWithoutIdentity })).toBeNull(); + }); + + it("fails closed when runtime ownership or liveness validation fails", () => { + const userData = privateUserData("linux-stale-user-data"); + const descriptor = linuxDescriptor(userData); + writeFileSync(join(userData, "cua-connection.json"), JSON.stringify(descriptor), { mode: 0o600 }); + expect( + readCuaConnection({ platform: "linux", userData, validateLinuxRuntime: () => false }), + ).toBeNull(); + }); + + // Runtime ownership includes a real Linux Unix-domain socket and POSIX + // permission checks. Keep the schema/decoder cases above cross-platform, + // but run this host-filesystem proof only on the authoritative Linux lane. + it.skipIf(process.platform !== "linux")( + "validates private descriptor, executable, socket, and live owned processes", + async () => { + const userData = privateUserData("linux-runtime-security"); + const runtimeDirectory = join(userData, "runtime"); + mkdirSync(runtimeDirectory, { mode: 0o700 }); + const descriptor = linuxDescriptor(userData); + const file = join(userData, "cua-connection.json"); + writeFileSync(file, JSON.stringify(descriptor), { mode: 0o600 }); + const server = createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(descriptor.daemon.socketPath, resolve); + }); + try { + chmodSync(descriptor.daemon.socketPath, 0o600); + expect(validateLinuxDescriptorRuntime(file, descriptor)).toBe(true); + expect(readCuaConnection({ platform: "linux", userData })).not.toBeNull(); + chmodSync(file, 0o644); + expect(validateLinuxDescriptorRuntime(file, descriptor)).toBe(false); + chmodSync(file, 0o600); + expect(validateLinuxDescriptorRuntime(file, descriptor)).toBe(true); + appendFileSync(descriptor.driver.path, "changed after descriptor publication"); + expect(validateLinuxDescriptorRuntime(file, descriptor)).toBe(false); + expect(readCuaConnection({ platform: "linux", userData })).toBeNull(); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }, + ); + + it("still rejects an old embedded-looking Linux descriptor", () => { + const userData = privateUserData("linux-forged-user-data"); writeFileSync( join(userData, "cua-connection.json"), JSON.stringify({ @@ -16,14 +251,13 @@ describe("local computer descriptor", () => { mcpArgs: ["mcp", "--embedded"], mcpEnv: { CUA_DRIVER_EMBEDDED: "1" }, }), + { mode: 0o600 }, ); - expect(readCuaConnection({ platform: "linux", userData })).toBeNull(); }); - it("reads and validates an exact platform userData descriptor", () => { - const userData = join(process.env.HOME!, "windows-user-data"); - mkdirSync(userData, { recursive: true }); + it("preserves the selected Windows descriptor contract", () => { + const userData = privateUserData("windows-user-data"); writeFileSync( join(userData, "cua-connection.json"), JSON.stringify({ @@ -33,37 +267,21 @@ describe("local computer descriptor", () => { mcpEnv: { CUA_DRIVER_EMBEDDED: "1" }, }), ); - expect(readCuaConnection({ platform: "win32", userData })).toEqual({ command: "C:\\cua-driver.exe", args: ["mcp"], env: { CUA_DRIVER_EMBEDDED: "1" }, + platform: "win32", + scope: "local-computer", }); }); - it("rejects malformed argv and environment values", () => { - const userData = join(process.env.HOME!, "invalid-user-data"); - mkdirSync(userData, { recursive: true }); + it("rejects malformed legacy argv and environment values", () => { + const userData = privateUserData("invalid-user-data"); writeFileSync( join(userData, "cua-connection.json"), JSON.stringify({ mode: "embedded", mcpCommand: "cua-driver", mcpArgs: "mcp" }), ); - - expect(readCuaConnection({ platform: "win32", userData })).toBeNull(); - }); - - it("rejects an array environment descriptor", () => { - const userData = join(process.env.HOME!, "array-environment-user-data"); - mkdirSync(userData, { recursive: true }); - writeFileSync( - join(userData, "cua-connection.json"), - JSON.stringify({ - mode: "embedded", - mcpCommand: "cua-driver", - mcpEnv: ["CUA_DRIVER_EMBEDDED=1"], - }), - ); - expect(readCuaConnection({ platform: "win32", userData })).toBeNull(); }); }); diff --git a/server/local-computer.ts b/server/local-computer.ts index 0cf25c626..0a0f2671c 100644 --- a/server/local-computer.ts +++ b/server/local-computer.ts @@ -1,22 +1,91 @@ -import { readFileSync } from "node:fs"; +import { lstatSync, readFileSync, realpathSync, statSync } from "node:fs"; +import type { Stats } from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { dirname, isAbsolute, join } from "node:path"; + +export const REQUIRED_LINUX_TOOLS = ["click", "get_window_state", "list_apps", "type_text"]; +// Keep this exact field set synchronized with DRIVER_FILE_IDENTITY_KEYS in +// electron/cua-linux.cjs; Electron publishes it and the server revalidates it. +export const DRIVER_FILE_IDENTITY_KEYS = [ + "dev", + "ino", + "uid", + "gid", + "mode", + "size", + "mtimeNs", + "ctimeNs", +] as const; export type LocalComputerConnection = { command: string; args: string[]; env: Record; + platform: "darwin" | "linux" | "win32"; + generation?: string; + scope: "local-computer"; }; -type ConnectionDescriptor = { +type LegacyConnectionDescriptor = { mode?: string; mcpCommand?: unknown; mcpArgs?: unknown; mcpEnv?: unknown; }; -function decodeDescriptor(value: ConnectionDescriptor): LocalComputerConnection | null { - if (!value || value.mode === "unavailable" || typeof value.mcpCommand !== "string") return null; +type LinuxConnectionDescriptor = Record; + +function exactKeys(value: Record, keys: readonly string[]): boolean { + const expected = new Set(keys); + return Object.keys(value).length === expected.size && Object.keys(value).every((key) => expected.has(key)); +} + +function legacyPlatform(platform: NodeJS.Platform): "darwin" | "win32" | null { + if (platform === "darwin" || platform === "win32") return platform; + return null; +} + +function validDriverFileIdentity(value: unknown): value is Record { + return ( + Boolean(value) && + typeof value === "object" && + !Array.isArray(value) && + exactKeys(value as Record, DRIVER_FILE_IDENTITY_KEYS) && + DRIVER_FILE_IDENTITY_KEYS.every( + (key) => typeof (value as Record)[key] === "string" && /^\d+$/.test((value as Record)[key]), + ) + ); +} + +function currentDriverFileIdentity(file: string): Record { + const stat = statSync(file, { bigint: true }); + return { + dev: String(stat.dev), + ino: String(stat.ino), + uid: String(stat.uid), + gid: String(stat.gid), + mode: String(stat.mode), + size: String(stat.size), + mtimeNs: String(stat.mtimeNs), + ctimeNs: String(stat.ctimeNs), + }; +} + +function sameDriverFileIdentity(expected: unknown, actual: Record): boolean { + return ( + validDriverFileIdentity(expected) && + DRIVER_FILE_IDENTITY_KEYS.every((key) => expected[key] === actual[key]) + ); +} + +function decodeLegacyDescriptor( + value: LegacyConnectionDescriptor, + platform: NodeJS.Platform, +): LocalComputerConnection | null { + const supportedPlatform = legacyPlatform(platform); + if (!supportedPlatform || !value || value.mode === "unavailable" || typeof value.mcpCommand !== "string") { + return null; + } if (value.mcpArgs !== undefined && !Array.isArray(value.mcpArgs)) return null; if ( value.mcpEnv !== undefined && @@ -24,48 +93,253 @@ function decodeDescriptor(value: ConnectionDescriptor): LocalComputerConnection ) { return null; } - const args = value.mcpArgs ?? ["mcp"]; if (!args.every((arg) => typeof arg === "string")) return null; - const env = value.mcpEnv ?? {}; if (!Object.values(env).every((entry) => typeof entry === "string")) return null; - return { command: value.mcpCommand, args, env: env as Record, + platform: supportedPlatform, + scope: "local-computer", }; } +export function decodeLinuxDescriptor(value: LinuxConnectionDescriptor): LocalComputerConnection | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const x11 = value.mode === "linux-x11-supervised" && value.session === "x11"; + const wayland = + value.mode === "linux-wayland-gnome-supervised" && + value.session === "wayland" && + value.compositor === "gnome-mutter"; + const descriptorKeys = [ + "schemaVersion", + "mode", + "platform", + "session", + "enabled", + "status", + "ownerPid", + "generation", + "driver", + "daemon", + "mcp", + "toolNames", + "doctorWarnings", + ...(wayland ? ["compositor"] : []), + ]; + if ( + (!x11 && !wayland) || + !exactKeys(value, descriptorKeys) || + value.schemaVersion !== 1 || + value.platform !== "linux" || + value.enabled !== true || + value.status !== "ready" || + !Number.isInteger(value.ownerPid) || + (value.ownerPid as number) <= 0 || + typeof value.generation !== "string" || + !/^[0-9a-f-]{32,64}$/i.test(value.generation) + ) { + return null; + } + + const driver = value.driver as Record; + const daemon = value.daemon as Record; + const mcp = value.mcp as Record; + if ( + !driver || + !daemon || + !mcp || + Array.isArray(driver) || + Array.isArray(daemon) || + Array.isArray(mcp) || + !exactKeys(driver, ["path", "version", "source", "manifestSchema", "fileIdentity"]) || + !exactKeys(daemon, [ + "socketPath", + "pid", + "contractVersion", + "toolsListSchemaVersion", + "capabilityVersion", + "mcpProtocolVersion", + ]) || + !exactKeys(mcp, ["command", "args", "env"]) + ) { + return null; + } + if ( + typeof driver.path !== "string" || + !isAbsolute(driver.path) || + driver.version !== "0.19.3" || + !["bundled", "environment", "user-local", "path"].includes(String(driver.source)) || + driver.manifestSchema !== "1" || + !validDriverFileIdentity(driver.fileIdentity) || + typeof daemon.socketPath !== "string" || + !isAbsolute(daemon.socketPath) || + !Number.isInteger(daemon.pid) || + (daemon.pid as number) <= 0 || + daemon.contractVersion !== "0.6.0" || + daemon.toolsListSchemaVersion !== "1" || + daemon.capabilityVersion !== "1" || + daemon.mcpProtocolVersion !== "2025-06-18" || + mcp.command !== driver.path || + !Array.isArray(mcp.args) || + mcp.args.length !== 4 || + mcp.args[0] !== "mcp" || + mcp.args[1] !== "--embedded" || + mcp.args[2] !== "--socket" || + mcp.args[3] !== daemon.socketPath || + !mcp.env || + typeof mcp.env !== "object" || + Array.isArray(mcp.env) || + !exactKeys(mcp.env as Record, [ + "CUA_DRIVER_EMBEDDED", + "CUA_DRIVER_HOST_BUNDLE_ID", + "CUA_DRIVER_RS_UPDATE_CHECK", + "CUA_DRIVER_RS_TELEMETRY_ENABLED", + ...(wayland ? ["CUA_DRIVER_RS_ENABLE_WAYLAND"] : []), + ]) || + (mcp.env as Record).CUA_DRIVER_EMBEDDED !== "1" || + (mcp.env as Record).CUA_DRIVER_HOST_BUNDLE_ID !== "com.openmausbot.app" || + (mcp.env as Record).CUA_DRIVER_RS_UPDATE_CHECK !== "false" || + (mcp.env as Record).CUA_DRIVER_RS_TELEMETRY_ENABLED !== "false" || + (wayland && (mcp.env as Record).CUA_DRIVER_RS_ENABLE_WAYLAND !== "1") + ) { + return null; + } + + if ( + !Array.isArray(value.toolNames) || + value.toolNames.some((name) => typeof name !== "string") || + REQUIRED_LINUX_TOOLS.some((name) => !(value.toolNames as string[]).includes(name)) || + !Array.isArray(value.doctorWarnings) + ) { + return null; + } + for (const warning of value.doctorWarnings) { + if ( + !warning || + typeof warning !== "object" || + Array.isArray(warning) || + ![3, 4].includes(Object.keys(warning).length) || + !Object.keys(warning).every((key) => ["label", "status", "message", "detail"].includes(key)) || + typeof warning.label !== "string" || + warning.status !== "warn" || + typeof warning.message !== "string" || + (warning.detail !== undefined && typeof warning.detail !== "string") + ) { + return null; + } + } + + return { + command: driver.path, + args: [...(mcp.args as string[])], + env: { ...(mcp.env as Record) }, + platform: "linux", + generation: value.generation, + scope: "local-computer", + }; +} + +function processAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function ownedPrivate(stat: Stats, uid: number): boolean { + return (stat.uid === uid || stat.uid === 0) && (stat.mode & 0o077) === 0; +} + +export function validateLinuxDescriptorRuntime( + descriptorFile: string, + raw: LinuxConnectionDescriptor, + { + uid = process.getuid?.() ?? -1, + isProcessAlive = processAlive, + }: { uid?: number; isProcessAlive?: (pid: number) => boolean } = {}, +): boolean { + try { + const descriptorStat = lstatSync(descriptorFile); + const descriptorDirectoryStat = lstatSync(dirname(descriptorFile)); + if ( + !descriptorStat.isFile() || + descriptorStat.isSymbolicLink() || + !ownedPrivate(descriptorStat, uid) || + !descriptorDirectoryStat.isDirectory() || + descriptorDirectoryStat.isSymbolicLink() || + !ownedPrivate(descriptorDirectoryStat, uid) + ) { + return false; + } + + const driver = raw.driver as Record; + const daemon = raw.daemon as Record; + const binaryPath = driver.path as string; + const socketPath = daemon.socketPath as string; + const binaryStat = statSync(binaryPath); + const currentFileIdentity = currentDriverFileIdentity(binaryPath); + const socketStat = lstatSync(socketPath); + const socketDirectoryStat = lstatSync(dirname(socketPath)); + if ( + realpathSync(binaryPath) !== binaryPath || + !sameDriverFileIdentity(driver.fileIdentity, currentFileIdentity) || + !binaryStat.isFile() || + (binaryStat.uid !== uid && binaryStat.uid !== 0) || + (binaryStat.mode & 0o111) === 0 || + (binaryStat.mode & 0o022) !== 0 || + !socketStat.isSocket() || + socketStat.isSymbolicLink() || + !ownedPrivate(socketStat, uid) || + !socketDirectoryStat.isDirectory() || + socketDirectoryStat.isSymbolicLink() || + !ownedPrivate(socketDirectoryStat, uid) || + !isProcessAlive(raw.ownerPid as number) || + !isProcessAlive(daemon.pid as number) + ) { + return false; + } + return true; + } catch { + return false; + } +} + export function readCuaConnection({ platform = process.platform, userData = process.env.OMB_USER_DATA, home = homedir(), + validateLinuxRuntime = validateLinuxDescriptorRuntime, }: { platform?: NodeJS.Platform; userData?: string; home?: string; + validateLinuxRuntime?: (file: string, raw: LinuxConnectionDescriptor) => boolean; } = {}): LocalComputerConnection | null { - // Linux local automation is deliberately outside the Ubuntu baseline. - // Ignore even a forged or stale descriptor until the CUA follow-up adds - // session-aware readiness and end-to-end evidence. - if (platform === "linux") return null; - const candidates = userData ? [join(userData, "cua-connection.json")] : []; if (platform === "darwin") { // Legacy/dev fallback. Packaged Electron passes its exact userData path. - for (const dir of ["OpenMausBot", "openmausbot", "OpenGrokBot", "opengrokbot"]) { - candidates.push(join(home, "Library", "Application Support", dir, "cua-connection.json")); + for (const directory of ["OpenMausBot", "openmausbot", "OpenGrokBot", "opengrokbot"]) { + candidates.push(join(home, "Library", "Application Support", directory, "cua-connection.json")); } } for (const file of [...new Set(candidates)]) { try { - const decoded = decodeDescriptor(JSON.parse(readFileSync(file, "utf8"))); - if (decoded) return decoded; + const raw = JSON.parse(readFileSync(file, "utf8")); + if (platform === "linux") { + const decoded = decodeLinuxDescriptor(raw); + if (decoded && validateLinuxRuntime(file, raw)) return decoded; + } else { + const decoded = decodeLegacyDescriptor(raw, platform); + if (decoded) return decoded; + } } catch { - // Missing, invalid, or stale descriptors are simply unavailable. + // Missing, invalid, tampered, or stale descriptors are unavailable. } } return null; diff --git a/server/local-routing.test.ts b/server/local-routing.test.ts new file mode 100644 index 000000000..bd9e7b82a --- /dev/null +++ b/server/local-routing.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { shouldMountLocalComputer } from "./local-routing.ts"; + +describe("local computer routing", () => { + it("never lets Linux Auto fall back to the user's desktop", () => { + expect( + shouldMountLocalComputer({ + requested: undefined, + hostPlatform: "linux", + providerSupportsLocal: true, + }), + ).toBe(false); + }); + + it("requires an explicit local selection and an approval-capable provider on Linux", () => { + expect( + shouldMountLocalComputer({ + requested: "local", + hostPlatform: "linux", + providerSupportsLocal: true, + }), + ).toBe(true); + expect( + shouldMountLocalComputer({ + requested: "local", + hostPlatform: "linux", + providerSupportsLocal: false, + }), + ).toBe(false); + }); + + it("preserves the established macOS Auto fallback", () => { + expect( + shouldMountLocalComputer({ + requested: undefined, + hostPlatform: "darwin", + providerSupportsLocal: true, + }), + ).toBe(true); + }); + + it("never mounts the local desktop for explicit cloud/off or on an unsupported host", () => { + for (const requested of ["cloud", "off"] as const) { + expect( + shouldMountLocalComputer({ + requested, + hostPlatform: "darwin", + providerSupportsLocal: true, + }), + ).toBe(false); + } + expect( + shouldMountLocalComputer({ + requested: "local", + hostPlatform: "win32", + providerSupportsLocal: true, + }), + ).toBe(false); + }); +}); diff --git a/server/local-routing.ts b/server/local-routing.ts new file mode 100644 index 000000000..ee13e88c1 --- /dev/null +++ b/server/local-routing.ts @@ -0,0 +1,15 @@ +export function shouldMountLocalComputer({ + requested, + hostPlatform = process.platform, + providerSupportsLocal, +}: { + requested: "cloud" | "local" | "off" | undefined; + hostPlatform?: NodeJS.Platform; + providerSupportsLocal: boolean; +}): boolean { + if (!providerSupportsLocal) return false; + if (requested === "local") return hostPlatform === "darwin" || hostPlatform === "linux"; + // Preserve the established macOS Auto behavior. Linux local control is a + // beta and can only be selected explicitly per bot. + return requested === undefined && hostPlatform === "darwin"; +} diff --git a/server/store.ts b/server/store.ts index 045e156b7..906f301c0 100644 --- a/server/store.ts +++ b/server/store.ts @@ -48,6 +48,8 @@ export interface OptionCardData { held?: string; /** the narrow grant "always allow" remembers, e.g. "Bash:git" */ allowKey?: string; + /** Local actions never share remembered grants with cloud/tool approvals. */ + approvalScope?: "local-computer"; } export interface ConnectorCardData { diff --git a/server/testing/fake-acp-cli.ts b/server/testing/fake-acp-cli.ts index b845093b4..19851a0e0 100755 --- a/server/testing/fake-acp-cli.ts +++ b/server/testing/fake-acp-cli.ts @@ -55,24 +55,25 @@ const configOptions = () => ] : null; const argv = process.argv.slice(2); +const dumpEnv = Object.fromEntries( + [ + "PATH", + "HOME", + "USERPROFILE", + "SystemRoot", + "FAKE_ACP_MODE", + "FAKE_ACP_RPC_DUMP", + "TEST_POLICY", + "OPENCODE_API_KEY", + "OPENAI_API_KEY", + "OPENROUTER_API_KEY", + "ANTHROPIC_API_KEY", + "XAI_API_KEY", + "UNSLOTH_STUDIO_AUTH_TOKEN", + ].flatMap((key) => (process.env[key] === undefined ? [] : [[key, process.env[key]]] as const)), +); +const dumpState: Record = { argv, env: dumpEnv }; if (process.env.FAKE_ACP_DUMP) { - const dumpEnv = Object.fromEntries( - [ - "PATH", - "HOME", - "USERPROFILE", - "SystemRoot", - "FAKE_ACP_MODE", - "FAKE_ACP_RPC_DUMP", - "TEST_POLICY", - "OPENCODE_API_KEY", - "OPENAI_API_KEY", - "OPENROUTER_API_KEY", - "ANTHROPIC_API_KEY", - "XAI_API_KEY", - "UNSLOTH_STUDIO_AUTH_TOKEN", - ].flatMap((key) => (process.env[key] === undefined ? [] : [[key, process.env[key]]] as const)), - ); writeFileSync(process.env.FAKE_ACP_DUMP, JSON.stringify({ argv, env: dumpEnv }, null, 2)); } if (argv.includes("--version")) { @@ -206,6 +207,10 @@ function handle(msg: any) { break; } const servers: McpEntry[] = Array.isArray(msg.params?.mcpServers) ? msg.params.mcpServers : []; + if (process.env.FAKE_ACP_DUMP) { + dumpState.mcpServers = servers; + writeFileSync(process.env.FAKE_ACP_DUMP, JSON.stringify(dumpState, null, 2)); + } agentsMcp = servers.find((s: any) => s?.name === "agents") ?? null; if (process.env.FAKE_ACP_DUMP) { writeFileSync(`${process.env.FAKE_ACP_DUMP}.mcp.json`, JSON.stringify(servers, null, 2)); diff --git a/src/components/ComputerPanel.tsx b/src/components/ComputerPanel.tsx index 2a1b5bafe..18e4fd585 100644 --- a/src/components/ComputerPanel.tsx +++ b/src/components/ComputerPanel.tsx @@ -1,9 +1,9 @@ // The bot's computer, in the right-side slot. Where it runs decides the // whole flow: cloud → provision the box on open (idempotent) and preview -// via SSE frames or a ~4s screenshot poll; local ("This Mac") → frames -// come from the Electron main process (desktopCapturer over the preload -// bridge — box endpoints are never touched); off → parked. Auto (unset) -// prefers the cloud box when one exists, else local inside the app. +// via SSE frames or a ~4s screenshot poll. macOS local mode keeps the legacy +// in-panel capture. Linux local mode is an automation readiness state and its +// separate preview remains explicitly user-initiated. Auto never selects a +// Linux user's desktop. import { useEffect, useRef, useState } from "react"; import { CalendarDays, @@ -26,6 +26,14 @@ import { CloudBackendPicker } from "./CloudBackendPicker"; import { useDesktopCapabilities } from "./DesktopCapabilities"; import { RoutineEditor } from "./RoutinesPage"; import { AndroidDevicePanel, useAndroidUsbDevices } from "./AndroidDevicePanel"; +import { LocalScreenPreview } from "./LocalScreenPreview"; +import { LinuxLocalControl } from "./LinuxLocalControl"; +import { + autoSelectsLocalComputer, + instanceSupportsLocalComputer, + linuxAutoDescription, + localComputerDisabledReason, +} from "@/lib/local-computer"; async function api(path: string, init?: RequestInit): Promise { const res = await fetch(path, { headers: { "content-type": "application/json" }, ...init }); @@ -80,6 +88,10 @@ export function ComputerPanel({ bot }: { bot: Bot }) { const { state, dispatch } = useStore(); const { capabilities, ready: capabilitiesReady } = useDesktopCapabilities(); const localAvailable = capabilities.localComputer.available; + const isLinux = capabilities.host.platform === "linux"; + const providerSupportsLocal = instanceSupportsLocalComputer(state.instances, bot); + const localSelectable = localAvailable && providerSupportsLocal; + const localDisabledReason = localComputerDisabledReason({ capabilities, providerSupportsLocal }); const [phase, setPhase] = useState("checking"); const [boxState, setBoxState] = useState(null); const [polledFrame, setPolledFrame] = useState<{ png: string; mime: string } | null>(null); @@ -148,8 +160,10 @@ export function ComputerPanel({ bot }: { bot: Bot }) { return; } if (bot.computer === "local") { - if (!computerToolSupported) setError("This model engine cannot control this computer. Choose Claude or an ACP engine."); - setPhase(capabilitiesReady && localAvailable && computerToolSupported ? "local" : "local-unavailable"); + if (!providerSupportsLocal) { + setError("This model engine cannot control this computer. Choose Claude or an ACP engine."); + } + setPhase(capabilitiesReady && localSelectable ? "local" : "local-unavailable"); return; } if (bot.computer === "vm") { @@ -183,7 +197,8 @@ export function ComputerPanel({ bot }: { bot: Bot }) { } if (bot.computer !== "cloud" && !capabilitiesReady) return; if (cloudBackend === "vps") { - const autoLocal = bot.computer !== "cloud" && capabilitiesReady && localAvailable && computerToolSupported; + const autoLocal = + !isLinux && bot.computer !== "cloud" && capabilitiesReady && localSelectable; if (!vpsSupported) { if (autoLocal) setPhase("local"); else { @@ -241,7 +256,12 @@ export function ComputerPanel({ bot }: { bot: Bot }) { api(`/api/bots/${bot.id}/computer`) .then((status) => { if (!alive) return; - const autoLocal = bot.computer !== "cloud" && capabilitiesReady && localAvailable && computerToolSupported; + const autoLocal = autoSelectsLocalComputer({ + platform: capabilities.host.platform, + computer: bot.computer, + capabilitiesReady, + localSelectable, + }); if (!status.configured) { setPhase(autoLocal ? "local" : "unconfigured"); return; @@ -265,7 +285,20 @@ export function ComputerPanel({ bot }: { bot: Bot }) { return () => { alive = false; }; - }, [bot.id, bot.computer, retry, capabilitiesReady, localAvailable, vmSupported, computerToolSupported, cloudSupported, cloudBackend, vpsSupported, state.config?.vps?.sshAlias]); + }, [ + bot.id, + bot.computer, + cloudBackend, + retry, + capabilitiesReady, + localSelectable, + isLinux, + providerSupportsLocal, + vmSupported, + cloudSupported, + vpsSupported, + state.config?.vps?.sshAlias, + ]); // cloud preview: SSE frames win while the bot works; otherwise poll const live = state.screens[bot.id]; @@ -326,7 +359,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { // the user denied — surface the Settings repair path instead of spinning. const [localMisses, setLocalMisses] = useState(0); useEffect(() => { - if (phase !== "local" || !window.ogb) return; + if (phase !== "local" || !window.ogb || isLinux) return; let alive = true; setLocalMisses(0); const shoot = async () => { @@ -344,7 +377,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { alive = false; clearInterval(timer); }; - }, [phase]); + }, [phase, isLinux]); const lastScreenMessage = [...bot.messages].reverse().find((m) => m.kind === "screen" && m.png); const cloudFrame = @@ -354,7 +387,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { const frameSrc = phase === "vm" ? vmFrame - : phase === "local" + : phase === "local" && !isLinux ? localFrame : phase === "ready" || phase === "starting" ? cloudFrame && `data:${cloudFrame.mime};base64,${cloudFrame.png}` @@ -399,12 +432,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { unconfigured: "No cloud computer configured", "vps-unconfigured": "No managed VPS computer is configured for this bot", "vps-stopped": "The managed VPS computer is stopped", - "local-unavailable": - capabilities.host.platform === "linux" - ? "Local computer control isn't available on Linux yet. Use a cloud box instead." - : capabilities.host.label === "Browser" - ? "Local computer control requires the desktop app." - : "CUA Driver isn't ready for local computer control.", + "local-unavailable": localDisabledReason ?? "Local computer control isn't ready.", "vm-unavailable": "The Local VM isn't available for this bot", off: "This bot's computer is off", error: "Couldn't reach the computer", @@ -473,7 +501,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { {`${bot.name}'s ) : (
    - {phase === "checking" || phase === "starting" || phase === "local" || phase === "vm" ? ( + {phase === "checking" || phase === "starting" || phase === "vm" || (phase === "local" && !isLinux) ? ( ) : phase === "off" ? ( @@ -486,12 +514,14 @@ export function ComputerPanel({ bot }: { bot: Bot }) { : phase === "vm" ? "Capturing the Local VM screen…" : phase === "local" - ? localMisses >= 3 + ? isLinux + ? "Ready for approved bot actions. Start the separate preview below when you want to watch the screen." + : localMisses >= 3 ? "No frames yet — the preview needs Screen Recording permission. After granting, relaunch the app." : "Capturing this computer's screen…" : emptyState[phase]} - {phase === "local" && localMisses >= 3 && ( + {phase === "local" && !isLinux && localMisses >= 3 && (
    )} + + + {/* Computer source */}
    Runs on
    {!bot.computer && - (localAvailable + (isLinux || !localSelectable ? cloudBackend === "vps" - ? "Auto reuses a ready VPS when one exists, otherwise this computer. " - : "Auto uses a cloud box when one exists, otherwise this computer. " - : cloudBackend === "vps" ? "Auto reuses a ready VPS when one is configured; otherwise computer use stays off. " - : "Auto uses a cloud box when one is configured; otherwise computer use stays off. ")} + : `${linuxAutoDescription()} ` + : cloudBackend === "vps" + ? "Auto reuses a ready VPS when one exists, otherwise this computer. " + : "Auto uses a cloud box when one exists, otherwise this computer. ")} Pick where this bot's computer lives. Local VM is a Cua-controlled Linux desktop in a container on this machine — free and separate from your own desktop. Set it up in App Settings → Local VM. @@ -615,20 +648,14 @@ export function ComputerPanel({ bot }: { bot: Bot }) { const disabled = (mode === "cloud" && !cloudSupported) || (mode === "vm" && !vmSupported) || - (mode === "local" && (!localAvailable || !computerToolSupported)); + (mode === "local" && !localSelectable); const unavailableTitle = mode === "vm" && !vmSupported ? "This model engine cannot use the Local VM" : mode === "cloud" && !cloudSupported ? "This model engine cannot use cloud computer tools" - : mode === "local" && !computerToolSupported - ? "This model engine cannot control this computer" - : mode === "local" && !localAvailable - ? capabilities.host.platform === "linux" - ? "Local computer control isn't available on Linux yet" - : capabilities.host.label === "Browser" - ? "Local computer control requires the desktop app" - : "CUA Driver isn't ready" + : mode === "local" && !localSelectable + ? localDisabledReason ?? "Local computer control isn't ready" : undefined; return ( + ) : ( + <> + {!ready && ( + + )} + + + )} +
    + + + + ); +} diff --git a/src/components/LocalScreenPreview.tsx b/src/components/LocalScreenPreview.tsx new file mode 100644 index 000000000..00dcf6c38 --- /dev/null +++ b/src/components/LocalScreenPreview.tsx @@ -0,0 +1,205 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Loader2, Monitor, RotateCcw, Square } from "lucide-react"; + +import { requestScreenPreview, stopScreenPreview } from "@/lib/screen-preview"; +import { useDesktopCapabilities } from "./DesktopCapabilities"; + +type PreviewPhase = + | "idle" + | "requesting" + | "streaming" + | "cancelled" + | "ended" + | "unavailable" + | "error"; + +const phaseCopy: Record, string> = { + idle: "Start a private, view-only preview when you need it.", + cancelled: "Screen selection was cancelled. Nothing is being shared.", + ended: "Screen sharing ended. Nothing is being shared.", + unavailable: "Screen preview isn't available in this desktop session.", + error: "Couldn't start screen preview.", +}; + +export function LocalScreenPreview() { + const { capabilities, ready } = useDesktopCapabilities(); + const preview = capabilities.screenPreview; + const isLinux = capabilities.host.platform === "linux"; + const [phase, setPhase] = useState("idle"); + const [message, setMessage] = useState(phaseCopy.idle); + const [sourceLabel, setSourceLabel] = useState("Selected screen"); + const videoRef = useRef(null); + const streamRef = useRef(null); + const requestId = useRef(0); + + const releaseStream = useCallback((nextPhase: PreviewPhase, nextMessage: string) => { + requestId.current += 1; + const stream = streamRef.current; + streamRef.current = null; + if (videoRef.current) videoRef.current.srcObject = null; + stopScreenPreview(stream); + setPhase(nextPhase); + setMessage(nextMessage); + }, []); + + useEffect( + () => () => { + requestId.current += 1; + const stream = streamRef.current; + streamRef.current = null; + stopScreenPreview(stream); + }, + [], + ); + + const start = async () => { + if ( + !preview.available || + !window.ogb?.beginScreenPreviewIntent || + !navigator.mediaDevices?.getDisplayMedia + ) { + setPhase("unavailable"); + setMessage(phaseCopy.unavailable); + return; + } + + releaseStream("requesting", "Waiting for screen selection…"); + const currentRequest = requestId.current; + const result = await requestScreenPreview({ + beginIntent: () => window.ogb!.beginScreenPreviewIntent(), + getDisplayMedia: (constraints) => navigator.mediaDevices.getDisplayMedia(constraints), + }); + + if (currentRequest !== requestId.current) { + if (result.ok) stopScreenPreview(result.stream); + return; + } + if (!result.ok) { + setPhase(result.phase); + setMessage(result.message); + return; + } + + const stream = result.stream; + const videoTrack = stream.getVideoTracks()[0]; + streamRef.current = stream; + setSourceLabel(videoTrack.label || "Selected screen"); + videoTrack.addEventListener( + "ended", + () => { + if (streamRef.current !== stream) return; + releaseStream("ended", phaseCopy.ended); + }, + { once: true }, + ); + const video = videoRef.current; + if (!video) { + releaseStream("error", "Couldn't display screen preview."); + return; + } + video.srcObject = stream; + try { + await video.play(); + } catch { + if (currentRequest === requestId.current && streamRef.current === stream) { + releaseStream("error", "Couldn't display screen preview."); + } + return; + } + if (currentRequest !== requestId.current || streamRef.current !== stream) return; + setPhase("streaming"); + setMessage( + "Preview active. Previewing does not grant local control; local actions still require approval.", + ); + }; + + if (!isLinux) return null; + const retry = + phase === "cancelled" || phase === "ended" || phase === "unavailable" || phase === "error"; + + return ( +
    +
    +
    +
    + Preview this computer +
    +
    + Preview only — starting a preview does not grant local control. +
    +
    + + Preview only + +
    + +
    +
    + + {phase === "streaming" && ( +
    + + {preview.interaction === "portal-picker" ? sourceLabel : "This computer"} + + + Sharing + +
    + )} + + +
    + ); +} diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 578f8f769..008271887 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -15,6 +15,7 @@ import { cn } from "@/lib/cn"; import { requestNotificationPermission } from "@/lib/notify"; import { botUsage, costCaption, formatTokens, formatUsd } from "@/lib/usage"; import { shortPath } from "@/lib/short-path"; +import { instanceSupportsLocalComputer, localComputerDisabledReason } from "@/lib/local-computer"; function Field({ label, @@ -319,6 +320,10 @@ export function SettingsPanel({ bot }: { bot: Bot }) { const { state, dispatch } = useStore(); const [voices, setVoices] = useState>([]); const [voicesLoading, setVoicesLoading] = useState(false); + const { capabilities } = useDesktopCapabilities(); + const providerSupportsLocal = instanceSupportsLocalComputer(state.instances, bot); + const localSelectable = capabilities.localComputer.available && providerSupportsLocal; + const localDisabledReason = localComputerDisabledReason({ capabilities, providerSupportsLocal }); const patch = ( p: Partial< Pick< @@ -656,10 +661,15 @@ export function SettingsPanel({ bot }: { bot: Bot }) { ] as const).map(([mode, label], i) => (