diff --git a/README.md b/README.md index df0c9cb6b..1b1590857 100644 --- a/README.md +++ b/README.md @@ -66,10 +66,12 @@ 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 use a cloud Linux desktop, an isolated Local VM, or—where the platform - safety boundary is currently certified—your own computer, plus 500+ apps through Composio. Host control is - available on macOS and Ubuntu Xorg after explicit opt-in. Ubuntu Wayland host control remains disabled while - issue #345 is resolved. +- **Agents with hands.** Each bot can use a cloud Linux desktop, an isolated Local VM, a machine you own + through a named worker—a [Windows PC](docs/byo-windows.md) or a [macOS guest](docs/byo-macos.md)—or, where + the platform safety boundary is currently certified, your own computer, plus 500+ apps through Composio. + Workers are named and leased independently, so one bot can hold a macOS desktop while another holds a + Windows one. Host control is available on macOS and Ubuntu Xorg after explicit opt-in. Ubuntu Wayland host + control remains disabled while issue #345 is resolved. ## Features diff --git a/docs/byo-macos.md b/docs/byo-macos.md new file mode 100644 index 000000000..efc06c8aa --- /dev/null +++ b/docs/byo-macos.md @@ -0,0 +1,185 @@ +# Bring your own macOS worker + +OpenMausBot keeps its control plane on one Mac and connects a bot to a macOS +machine you already run — a guest VM on the same Apple silicon Mac, or a +second physical Mac. It does not create the guest, manage a hypervisor, store +SSH credentials, open a TCP listener, mount the control plane's workspace, or +fall back to another computer when the worker fails. + +A macOS worker pairs with [a Windows worker](byo-windows.md) rather than +replacing it: workers are named independently and lease independently, so one +bot can hold a macOS desktop while another holds a Windows desktop. + +## Why a guest and not this Mac + +The `local` computer beta drives the Mac OpenMausBot is running on. That Mac +is also yours — the bot shares your screen, your keyboard and your files. A +guest gives the bot its own login session, its own home directory and its own +Accessibility grants, and it can be rebuilt from scratch when something goes +wrong. + +Apple's software licence allows up to two macOS guests on one Apple silicon +host, so a single worker guest leaves headroom. + +## Before you start + +- **Apple silicon.** macOS guests use Virtualization.framework; an Intel Mac + cannot host one. +- **Disk.** Budget 80–100 GB: a restore image is roughly 16 GB (deletable + after install) plus the guest's own disk. +- **A hypervisor.** [`tart`](https://tart.run) is the easiest to keep + reproducible — it is CLI-driven, pulls prebuilt Apple silicon images, and + `tart ip` gives you an address to put in your SSH config. UTM works too if + you would rather click through the install. + +## Create the guest + +```bash +brew install cirruslabs/cli/tart +tart clone ghcr.io/cirruslabs/macos-sequoia-base:latest omb-worker +tart set omb-worker --cpu 4 --memory 8192 --disk-size 80 +tart run omb-worker +``` + +Then, inside the guest: + +1. Create a **dedicated standard (non-administrator) account** for the worker. + Readiness refuses an account in the `admin` group: an administrator could + rewrite the very base policy that bounds it, so installing the tools as an + admin does not make that account an eligible worker. +2. Log in as the worker account and turn on **Users & Groups → automatic + login** for it. An Aqua session must exist at all times; readiness checks + that the worker account owns `/dev/console`. +3. Turn **off** screen lock and sleep (Lock Screen → *Require password … + Never*, *Turn display off … Never*). A locked screen reads as not ready. +4. Turn on **General → Sharing → Remote Login** for that account only. + +On the control-plane Mac, add the guest to your SSH config with key-only +authentication and confirm it works before going further: + +```bash +ssh omb-worker true +``` + +OpenMausBot stores only that alias. + +## Install the tools + +Inside the guest, as the worker account: + +```bash +cua-driver --version # must print exactly 0.20.0 +node --version # 24 or newer +openmausbot-worker-companion --version +``` + +Install the pinned CUA Driver release with the official instructions — do not +use an unreviewed wrapper or an ambient alternate binary. Build the companion +from the exact OpenMausBot source commit on the control-plane Mac, copy only +its `package.json` and `dist/` into a private directory owned by the worker +account, and put its `openmausbot-worker-companion` bin on that account's +`PATH`. + +The driver listens on a unix socket at `~/.openmausbot/run/cua.sock`. Both the +socket and its directory must be owned by the worker account and private to +it; readiness refuses a socket it cannot read and write. + +## Grant Accessibility and Screen Recording + +This is the one step nobody can script for you. macOS grants both permissions +**per binary**, System Integrity Protection prevents writing the permission +database, and replacing the driver binary silently revokes them. + +In the guest, open **System Settings → Privacy & Security** and add the CUA +Driver binary under both **Accessibility** and **Screen Recording**. Then +confirm the driver itself sees them: + +```bash +openmausbot-worker-companion --permissions +``` + +It prints `{"accessibility":true,"screenRecording":true}` when both are live. + +Readiness re-reads this on every poll rather than trusting that you did it +once, so a driver upgrade that drops the grants surfaces as +`worker_accessibility_denied` instead of as mysterious failures mid-task. + +## Pin the base policy + +Copy [`macos-base-policy.yaml`](macos-base-policy.yaml) into the guest at +`~/Library/Application Support/OpenMausBot/macos-policy.yaml`, then record its +digest: + +```bash +shasum -a 256 ~/Library/Application\ Support/OpenMausBot/macos-policy.yaml +``` + +Enter that digest in OpenMausBot when you add the worker. Until you do, the +worker stays *unconfigured*: without a pinned digest the driver's tool ceiling +would be whatever happens to be on the guest's disk. + +Note that a matching file is not sufficient on its own. CUA loads its policy +once at daemon start, and an unset policy variable disables enforcement +entirely, so readiness requires the daemon to *report* the same digest it +finds on disk. + +## Install the parked capability manifest + +The base policy is the stable ceiling; a **capability manifest** is the +short-lived, per-task boundary that intersects it. Between tasks the guest +should hold the parked manifest, which grants no tools at all: + +```bash +cp macos-parked-capabilities.yaml \ + ~/Library/Application\ Support/OpenMausBot/active-capabilities.yaml +``` + +Readiness requires the daemon to report a loaded capability manifest, so a +guest without one never becomes ready. With the parked manifest in place the +worker is reachable and provably bounded, and can do nothing until a task +capability is approved — the correct resting state. + +## Add the worker + +In OpenMausBot, open **Settings → Workers**, add a worker with: + +- an id (lowercase, e.g. `mac-guest`) +- platform **macOS** +- the SSH alias +- the base-policy digest + +Then assign a bot to it from that bot's Computer panel. Two workers may not +share one SSH alias — that would take two independent leases against a single +real desktop, and each would believe it held the screen exclusively. + +## What the bot can and cannot do + +One bot leases a macOS worker at a time; a second turn aimed at the same +desktop waits rather than interleaving real mouse and keyboard input. Work on +another worker, and on Linux Local VMs, continues in parallel. + +Auto mode is unavailable on a worker. Every task is bounded by three +independent fences — the stable base policy, a short-lived CUA capability +manifest, and the task manifest — so there is nothing for auto mode to +approve on its own. + +At a sign-in, password, MFA or CAPTCHA step the bot stops and asks you to +complete it on the visible screen. + +## When it is not ready + +Readiness reports the first thing that is actually wrong: + +| Code | What to fix | +| --- | --- | +| `worker_offline` | SSH cannot reach the guest | +| `worker_driver_missing` / `worker_driver_wrong_version` | CUA Driver absent, off `PATH`, or not 0.20.0 | +| `worker_companion_missing` | the companion is not installed for the worker account | +| `worker_privileged_account` | the SSH account is in the `admin` group | +| `worker_no_interactive_session` | nobody is logged in at the guest's console | +| `worker_locked` | the guest's screen is locked | +| `worker_channel_missing` / `worker_channel_access_denied` | the driver socket is absent or not private to the worker account | +| `worker_policy_missing` / `worker_policy_mismatch` | the base policy is absent, unloaded, or not the pinned digest | +| `worker_permission_mode_mismatch` | CUA Driver is not running in bounded mode | +| `worker_accessibility_denied` / `worker_screen_recording_denied` | grant the permission to the driver binary in the guest | +| `worker_busy` | another turn holds this desktop | diff --git a/docs/byo-windows.md b/docs/byo-windows.md new file mode 100644 index 000000000..433ce3bf4 --- /dev/null +++ b/docs/byo-windows.md @@ -0,0 +1,199 @@ +# Bring your own Windows worker + +OpenMausBot keeps its control plane on the Mac and connects a bot to an +already-running Windows physical machine or VM. It does not provision +Windows, manage a hypervisor, store SSH credentials, expose a TCP control +listener, mount the Mac workspace, or fall back to Linux when Windows fails. + +Docker remains the recommended Local VM and protocol-test path for Linux. A +Linux container cannot supply the interactive Windows desktop, Session 1+ +window station, Windows UI Automation, registry, and named-pipe behavior this +backend must verify. Use a real Windows installation in Parallels, VMware, +UTM, another local hypervisor, or a physical PC. + +## Security model + +The operator owns the Windows installation and the macOS OpenSSH alias. The +Windows account must be a dedicated non-administrator user. OpenMausBot stores +only the alias and expected public configuration digests. + +Readiness checks the live SSH token and refuses an account in the local +Administrators group. Installing the driver under an administrator account +does not make that account an eligible worker. + +Every task has three independent fences: + +1. A stable, deny-by-default CUA YAML policy limits the total tool ceiling. +2. A short-lived native CUA version-3 capability manifest limits one task to + either typed browser tools and exact origins, or generic input against VS + Code and File Explorer under the staged task root. CUA does not permit + browser origins and generic desktop input in one runtime; OpenMausBot keeps + those task surfaces separate. +3. A version-1 OpenMausBot task manifest binds the target, expiry, idle + timeout, staged file hashes, exact non-GUI commands, argv, working + directories, origins, results, and base-policy digest. The agent-facing + `windows_run` tool accepts only a task ID and command ID. + +Only one bot can lease a given Windows target. Work on another worker — a +[macOS guest](byo-macos.md), for instance — and on Linux Local VMs continues +in parallel: workers are named independently and lease independently. + +Returned files land in a private Mac review directory and do not overwrite the +canonical workspace. + +## Windows prerequisites + +Use Windows 11 or a currently supported Windows Server desktop with: + +- Windows OpenSSH Server configured by the operator; +- Node.js 24 or newer; +- official CUA Driver 0.20.0; +- Chrome and VS Code at the paths entered in OpenMausBot; +- a dedicated Chrome profile named **OpenMaus Windows Worker**, with Sync, + personal accounts, and unrelated extensions disabled. + +Install the driver and verify its exact version from an interactive PowerShell +session. Follow the official CUA install instructions for the pinned release; +do not use an unreviewed wrapper or ambient alternate binary. + +```powershell +cua-driver --version +node --version +``` + +## Install the companion + +Build the companion from the exact OpenMausBot source commit on the Mac: + +```bash +pnpm build:worker-companion +``` + +Copy only `worker-companion/package.json` and `worker-companion/dist/` to a +private directory owned by the Windows worker user, then expose the package's +`openmausbot-worker-companion` bin on that user's `PATH` (for example with +`npm link` from that copied directory). Verify protocol 1: + +```powershell +openmausbot-worker-companion --version +``` + +The companion has no listener. Its stdio protocol accepts only reset, +validate, activate, pause, resume, and run. Activation derives the CUA capability YAML from +the already-approved manifest, restarts the fixed official CUA autostart task, +rechecks that the executable is Driver 0.20.0, and requires `cua-driver status` +to report both bounded mode and the exact capability digest. It never accepts a remote executable, argv, environment, +working directory, policy body, capability body, or arbitrary command. +Validation freezes an immutable source baseline outside the CUA-granted task +root. Later VS Code edits are allowed within the bounded task root; +`windows_run` revalidates the untouched baseline and current path/size limits, +then generates `changes.patch` against that original snapshot. + +## Install the native policy stack + +Create `%LOCALAPPDATA%\OpenMausBot` for the Windows worker user. Copy +`docs/windows-base-policy.yaml` to +`%LOCALAPPDATA%\OpenMausBot\windows-policy.yaml` and copy +`docs/windows-parked-capabilities.yaml` to +`%LOCALAPPDATA%\OpenMausBot\active-capabilities.yaml`. + +Set the trusted launch environment for that user from an interactive +PowerShell session. These variables are read when the daemon starts; they are +not agent-controlled tool arguments. + +```powershell +$root = Join-Path $env:LOCALAPPDATA 'OpenMausBot' +[Environment]::SetEnvironmentVariable('CUA_DRIVER_POLICY_FILE', (Join-Path $root 'windows-policy.yaml'), 'User') +[Environment]::SetEnvironmentVariable('CUA_DRIVER_PERMISSION_MODE', 'bounded', 'User') +[Environment]::SetEnvironmentVariable('CUA_DRIVER_CAPABILITY_MANIFEST_FILE', (Join-Path $root 'active-capabilities.yaml'), 'User') +[Environment]::SetEnvironmentVariable('CUA_DRIVER_CAPABILITY_MANIFEST_APPROVED', '1', 'User') +``` + +Log out and back in so the Scheduled Task receives the trusted environment. +Then register and start the official interactive-user task: + +```powershell +cua-driver autostart enable +cua-driver autostart kick +query session +cua-driver status --socket \\.\pipe\cua-driver +``` + +The session must be `Active` or `Disc`, never Session 0. The status output must +show bounded mode and hashes for the loaded policy and capability file. An +unset policy variable means policy enforcement is disabled; OpenMausBot checks +the loaded digest, not merely the file on disk. Readiness also requires the +daemon's reported session ID to match an Explorer desktop owned by the SSH +user; another user's interactive session cannot satisfy the gate. + +Compute the stable base-policy digest and enter it in App Settings → Windows +Worker: + +```powershell +(Get-FileHash -Algorithm SHA256 (Join-Path $env:LOCALAPPDATA 'OpenMausBot\windows-policy.yaml')).Hash.ToLowerInvariant() +``` + +## Configure the Mac + +Create a normal OpenSSH config alias outside OpenMausBot. Authentication, +host-key policy, keys, passwords, and agent state remain owned by macOS and +must not be pasted into OpenMausBot. Enter only the validated alias, policy +digest, application paths, and profile name in App Settings → Windows Worker. + +The backend invokes only these fixed remote surfaces: + +```text +ssh ... ALIAS cua-driver mcp --socket \\.\pipe\cua-driver +ssh ... ALIAS openmausbot-worker-companion stdio +sftp ... ALIAS +``` + +It does not accept a hostname, SSH options, shell string, or remote command +from a bot or task manifest. The local SSH/SFTP child environment is an +allow-list containing only PATH, the operator home/user metadata, locale, +temporary-directory metadata, and `SSH_AUTH_SOCK`; ambient API keys and the +OpenMausBot control token are excluded. + +## Transport spike and acceptance + +Before assigning real work, use the Settings connection check and a bounded +test task to prove: + +- SSH authentication through the named alias; +- Driver 0.20.0 and companion protocol 1; +- an unlocked Session 1+ desktop; +- named-pipe access from OpenSSH; +- loaded base-policy and native capability digests; +- one allowed screenshot/state read, click, and type on the correct surface; +- rejection of a disallowed tool, application, origin, and staged-path escape; +- result collection to the Mac review directory; +- no non-loopback listener introduced by OpenMausBot or the companion. + +If named-pipe access returns `Access is denied`, stop. Do not loosen the CUA +pipe ACL and do not switch to an unrestricted daemon. The optional same-user, +same-session relay described by the design is intentionally not enabled until +the failure is reproduced and the relay passes its isolation suite. Without +that proof, Windows remains unavailable and never falls back to Linux. + +## Browser boundary + +Browser tasks expose typed browser tools only. Every navigation and input is +checked against the exact scheme, host, and port in the task manifest. Generic +desktop screenshots, window trees, clicks, and keystrokes are absent from that +runtime because they could read or operate a different tab without crossing +the origin check. Credentials, MFA, CAPTCHA, and consequential accounts remain +operator actions. The native capability binds the configured Chrome executable +and the `existing_profile` attachment class. CUA intentionally does not return +the profile's identity; the operator must verify that the selected native +window is the dedicated **OpenMaus Windows Worker** profile during the transport +spike and before each consequential browser task. + +Desktop tasks expose VS Code and File Explorer only. Chrome is not an allowed +application on that surface. A workflow that needs both must use two visible +tasks/handoffs; it cannot combine the permissions in one manifest. + +`windows_run` confines its working directory and prevents executable, argv, or +environment substitution after approval. It is not an operating-system sandbox +around the approved executable: that exact program still has the ordinary +rights of the dedicated Windows user. Approve only purpose-built build/test +binaries whose behavior is appropriate for that account. diff --git a/docs/macos-base-policy.yaml b/docs/macos-base-policy.yaml new file mode 100644 index 000000000..55b579213 --- /dev/null +++ b/docs/macos-base-policy.yaml @@ -0,0 +1,44 @@ +# OpenMausBot macOS base policy for CUA Driver 0.20.0. +# +# This is the stable tool ceiling. The active version-3 capability manifest +# intersects it at runtime and supplies the per-task application, file, and +# browser-origin boundary. A tool must pass both layers. +allow: + tools: + - start_session + - end_session + - launch_app + - list_windows + - get_window_state + - click + - double_click + - right_click + - drag + - scroll + - press_key + - hotkey + - set_value + - wait + - bring_to_front + - browser_prepare + - get_browser_state + - browser_navigate + - browser_click + - browser_type + rules: + - tool: type_text + constraints: + text: + max_length: 4096 +deny: + tools: + - shell_execute + - run_javascript + - execute_javascript + - page + - get_desktop_state + - get_accessibility_tree + - browser_download + - install_ffmpeg + - kill_app + - start_recording diff --git a/docs/macos-parked-capabilities.yaml b/docs/macos-parked-capabilities.yaml new file mode 100644 index 000000000..0fd03ac55 --- /dev/null +++ b/docs/macos-parked-capabilities.yaml @@ -0,0 +1,17 @@ +# Safe bootstrap state for the interactive macOS CUA daemon. +# The worker companion replaces this file atomically with a short-lived, +# approved task capability before OpenMausBot mounts the CUA MCP bridge. +# +# It grants no tools at all. A worker running this manifest is reachable and +# provably bounded, and can do nothing until a task capability is approved — +# which is the correct resting state between tasks. +version: 3 +expires_after: 8760h +idle_timeout: 20m + +allow: + tools: [] + +resources: + desktop: + display: false diff --git a/docs/windows-base-policy.yaml b/docs/windows-base-policy.yaml new file mode 100644 index 000000000..675c0a627 --- /dev/null +++ b/docs/windows-base-policy.yaml @@ -0,0 +1,44 @@ +# OpenMausBot Windows base policy for CUA Driver 0.20.0. +# +# This is the stable tool ceiling. The active version-3 capability manifest +# intersects it at runtime and supplies the per-task application, file, and +# browser-origin boundary. A tool must pass both layers. +allow: + tools: + - start_session + - end_session + - launch_app + - list_windows + - get_window_state + - click + - double_click + - right_click + - drag + - scroll + - press_key + - hotkey + - set_value + - wait + - bring_to_front + - browser_prepare + - get_browser_state + - browser_navigate + - browser_click + - browser_type + rules: + - tool: type_text + constraints: + text: + max_length: 4096 +deny: + tools: + - shell_execute + - run_javascript + - execute_javascript + - page + - get_desktop_state + - get_accessibility_tree + - browser_download + - install_ffmpeg + - kill_app + - start_recording diff --git a/docs/windows-parked-capabilities.yaml b/docs/windows-parked-capabilities.yaml new file mode 100644 index 000000000..363cc385e --- /dev/null +++ b/docs/windows-parked-capabilities.yaml @@ -0,0 +1,13 @@ +# Safe bootstrap state for the interactive Windows CUA Scheduled Task. +# The Windows companion replaces this file atomically with a short-lived, +# approved task capability before OpenMausBot mounts the CUA MCP bridge. +version: 3 +expires_after: 8760h +idle_timeout: 20m + +allow: + tools: [] + +resources: + desktop: + display: false diff --git a/scripts/bundle-server.mjs b/scripts/bundle-server.mjs index db58e4580..e3ea0066f 100644 --- a/scripts/bundle-server.mjs +++ b/scripts/bundle-server.mjs @@ -51,6 +51,7 @@ const ENTRY_POINTS = [ "computer-proxy.ts", "container-mcp.ts", "vps-container-mcp.ts", + "worker-mcp.ts", "permission-proxy.ts", "connector-proxy.ts", "drivers/agents-proxy.ts", diff --git a/server/auto-approve.ts b/server/auto-approve.ts index bf83565fa..e6d619e48 100644 --- a/server/auto-approve.ts +++ b/server/auto-approve.ts @@ -10,6 +10,8 @@ // backstop for the obvious catastrophes. Real containment is the // sandbox and the bot's own computer, not a regex. +import type { ApprovalScope } from "./contracts.ts"; + const DESTRUCTIVE = [ /\brm\s+(-[a-z]*\s+)*-[a-z]*[rf]/i, // rm -rf, rm -fr, rm -r -f /\bmkfs\b|\bdiskutil\s+erase|\bdd\s+[^|]*\bof=\/dev\//i, @@ -58,7 +60,7 @@ 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, scope?: "local-computer"): string { +export function approvalKey(tool: string, summary: string, scope?: ApprovalScope): string { const bare = tool.replace(/^mcp__[^_]+__/, "").toLowerCase(); if (!COMMAND_TOOLS.has(bare)) return scope ? `${scope}:${tool}` : tool; // first bare word of the command, skipping env assignments and sudo @@ -110,8 +112,9 @@ export function autoVerdict( 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"; + /** the request drives a real interactive desktop — the user's own, or a + * worker machine they own */ + scope?: ApprovalScope; }, ): AutoVerdict { // the guards outrank the grants, so an "always allow" can never widen @@ -144,8 +147,9 @@ export function autoVerdict( if (sensitive) return { approve: null, source: "sensitive-guard", rule: sensitive }; return { approve: null, source: "no-grant" }; } - if (context?.scope === "local-computer" && !bot.autoApprove) { - // Host control is not covered by a remembered always-allow grant. + if (context?.scope !== undefined && !bot.autoApprove) { + // Desktop control is not covered by a remembered always-allow grant — + // neither the user's own screen nor a worker machine they own. // After the Auto-on-this-computer warning, unclassified GUI actions // (click/type) may auto-approve; destructive/sensitive still card. if (grant) return { approve: null, source: "local-computer-block", rule: grant.rule }; @@ -167,8 +171,9 @@ 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"; + /** the request drives a real interactive desktop — the user's own, or a + * worker machine they own */ + scope?: ApprovalScope; }, ): string | null { return autoVerdict(bot, tool, summary, context).approve; diff --git a/server/computer-workers.test.ts b/server/computer-workers.test.ts new file mode 100644 index 000000000..e0e9fad96 --- /dev/null +++ b/server/computer-workers.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; + +import { + findWorker, + isSafeWorkerExecutable, + listWorkers, + publicWorker, + workerConfigMapSchema, + WORKER_DRIVER_VERSION, +} from "./computer-workers.ts"; +import { parseStoredConfig } from "./config.ts"; + +const policy = "a".repeat(64); + +const twoWorkers = { + "mac-guest": { platform: "macos", sshAlias: "macguest", expectedBasePolicySha256: policy }, + "win-box": { platform: "windows", sshAlias: "winbox", expectedBasePolicySha256: policy }, +} as const; + +describe("worker registry", () => { + it("accepts one Windows and one macOS worker side by side", () => { + const parsed = workerConfigMapSchema.safeParse(twoWorkers); + expect(parsed.success).toBe(true); + const workers = listWorkers(parsed.success ? parsed.data : undefined); + expect(workers.map((worker) => [worker.id, worker.platform])).toEqual([ + ["mac-guest", "macos"], + ["win-box", "windows"], + ]); + expect(workers.every((worker) => worker.configured)).toBe(true); + expect(workers.every((worker) => worker.expectedDriverVersion === WORKER_DRIVER_VERSION)).toBe(true); + }); + + it("rejects two workers pointed at one machine", () => { + // Two ids on one alias would take two independent leases against a single + // desktop, and each would believe it held the screen exclusively. + const parsed = workerConfigMapSchema.safeParse({ + "mac-a": { platform: "macos", sshAlias: "macguest", expectedBasePolicySha256: policy }, + "mac-b": { platform: "macos", sshAlias: "macguest", expectedBasePolicySha256: policy }, + }); + expect(parsed.success).toBe(false); + expect(JSON.stringify(parsed.error?.issues)).toContain("distinct SSH alias"); + }); + + it("rejects an alias that could smuggle extra ssh arguments", () => { + const parsed = workerConfigMapSchema.safeParse({ + evil: { platform: "macos", sshAlias: "host -o ProxyCommand=curl", expectedBasePolicySha256: policy }, + }); + expect(parsed.success).toBe(false); + }); + + it("holds a worker unconfigured until its base policy is pinned", () => { + // Without a pinned digest the driver's tool ceiling is whatever happens to + // be on the worker's disk, so this must never read as usable. + const workers = listWorkers({ "mac-guest": { platform: "macos", sshAlias: "macguest" } }); + expect(workers[0].configured).toBe(false); + expect(workers[0].expectedBasePolicySha256).toBeNull(); + }); + + it("validates executable paths against the worker's own platform", () => { + expect(isSafeWorkerExecutable("windows", "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe")).toBe(true); + expect(isSafeWorkerExecutable("windows", "/Applications/Safari.app/Contents/MacOS/Safari")).toBe(false); + expect(isSafeWorkerExecutable("macos", "/Applications/Safari.app/Contents/MacOS/Safari")).toBe(true); + expect(isSafeWorkerExecutable("macos", "C:\\Windows\\explorer.exe")).toBe(false); + expect(isSafeWorkerExecutable("macos", "/usr/bin/open\u0000")).toBe(false); + }); + + it("rejects a POSIX executable configured on a Windows worker", () => { + const parsed = workerConfigMapSchema.safeParse({ + "win-box": { + platform: "windows", + sshAlias: "winbox", + expectedBasePolicySha256: policy, + ideExecutable: "/usr/local/bin/code", + }, + }); + expect(parsed.success).toBe(false); + }); + + it("keeps the SSH alias out of anything a bot or device can see", () => { + const worker = findWorker(twoWorkers, "mac-guest"); + expect(worker?.sshAlias).toBe("macguest"); + expect(JSON.stringify(publicWorker(worker!))).not.toContain("macguest"); + }); + + it("round-trips through the stored app config", () => { + const cfg = parseStoredConfig({ workers: twoWorkers }); + expect(Object.keys(cfg.workers ?? {})).toEqual(["mac-guest", "win-box"]); + }); +}); diff --git a/server/computer-workers.ts b/server/computer-workers.ts new file mode 100644 index 000000000..d33c51ae9 --- /dev/null +++ b/server/computer-workers.ts @@ -0,0 +1,190 @@ +// Named remote CUA workers. +// +// A worker is one operator-owned interactive machine — a Windows PC or a +// macOS guest — reached through the operator's own SSH config. OpenMausBot +// never provisions a worker, never stores a key, a password or a bearer +// value, and never opens a listener on it: it persists only the SSH alias +// plus public configuration digests, and authentication stays entirely with +// SSH. +// +// This registry exists because the single app-level `vps.sshAlias` shape +// cannot express two targets at once. Bots address a worker by id, and the +// per-alias lease in ./remote-worker.ts keeps two workers independent, so a +// bot on Windows and a bot on macOS can hold their desktops at the same time. +import { z } from "zod"; +import type { JsonValue } from "./schema.ts"; + +/** Pinned across every worker platform; the driver's wire protocol and its + * policy/capability digests are only comparable within one exact version. */ +export const WORKER_DRIVER_VERSION = "0.20.0"; + +export const MAX_WORKERS = 8; + +const WORKER_ID = /^[a-z0-9][a-z0-9-]{0,63}$/; +const SSH_ALIAS = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/; +const SHA256 = /^[a-f0-9]{64}$/i; +const WINDOWS_ABSOLUTE = /^[A-Za-z]:\\/; +const POSIX_ABSOLUTE = /^\//; + +export type WorkerPlatform = "windows" | "macos"; + +export const WORKER_PLATFORMS: readonly WorkerPlatform[] = ["windows", "macos"]; + +/** Per-platform defaults. A fresh worker only needs an alias and a base-policy + * digest; everything else has a conventional value the operator can override + * when their install differs. */ +export const WORKER_DEFAULTS = { + windows: { + browserExecutable: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe", + browserProfile: "OpenMaus Windows Worker", + ideExecutable: "C:\\Program Files\\Microsoft VS Code\\Code.exe", + }, + macos: { + browserExecutable: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + browserProfile: "OpenMaus macOS Worker", + ideExecutable: "/Applications/Visual Studio Code.app/Contents/MacOS/Electron", + }, +} satisfies Record; + +export function isValidWorkerId(value: string): boolean { + return WORKER_ID.test(value); +} + +export function isValidWorkerSshAlias(value: string): boolean { + return SSH_ALIAS.test(value); +} + +export function isWorkerPlatform(value: JsonValue): value is WorkerPlatform { + return value === "windows" || value === "macos"; +} + +/** The id as it arrives from config, a bot record, or an HTTP body. */ +const workerIdSchema = z.string().regex(WORKER_ID); + +/** Executable paths reach a shell-free spawn and the CUA capability YAML, but + * they are still operator input echoed into a manifest the daemon enforces. + * Reject control characters and the shell metacharacters that would make a + * quoted YAML scalar ambiguous, and require the platform's absolute form so a + * relative path can never resolve against an unexpected working directory. */ +export function isSafeWorkerExecutable(platform: WorkerPlatform, value: string): boolean { + if (value.length === 0 || value.length > 512) return false; + if (/[\u0000-\u001f"|<>]/.test(value)) return false; + return platform === "windows" ? WINDOWS_ABSOLUTE.test(value) : POSIX_ABSOLUTE.test(value); +} + +const workerConfigSchema = z.object({ + platform: z.enum(["windows", "macos"]), + sshAlias: z.string().refine(isValidWorkerSshAlias, { + message: "must be a simple SSH config alias", + }), + displayName: z.string().max(100).refine((value) => !/[\u0000-\u001f]/.test(value), { + message: "must not contain control characters", + }).optional(), + expectedDriverVersion: z.string().max(32).refine((value) => value === "" || /^\d+\.\d+\.\d+$/.test(value), { + message: "must be an exact CUA Driver version", + }).optional(), + expectedBasePolicySha256: z.string().refine((value) => value === "" || SHA256.test(value), { + message: "must be a SHA-256 digest", + }).optional(), + browserExecutable: z.string().max(512).optional(), + browserProfile: z.string().max(100).refine((value) => !/[\u0000-\u001f]/.test(value), { + message: "must not contain control characters", + }).optional(), + ideExecutable: z.string().max(512).optional(), + paused: z.boolean().optional(), +}).strict().superRefine((worker, ctx) => { + // Path grammar depends on the sibling `platform` field, so it cannot be + // expressed on the individual string schemas above. + for (const key of ["browserExecutable", "ideExecutable"] as const) { + const value = worker[key]; + if (value === undefined || value === "") continue; + if (!isSafeWorkerExecutable(worker.platform, value)) { + ctx.addIssue({ + code: "custom", + path: [key], + message: worker.platform === "windows" + ? "must be an absolute Windows executable path without control or shell characters" + : "must be an absolute POSIX executable path without control or shell characters", + }); + } + } +}); + +export const workerConfigMapSchema = z + .record(z.string().regex(WORKER_ID, "must be a lowercase worker id"), workerConfigSchema) + .refine((workers) => Object.keys(workers).length <= MAX_WORKERS, { + message: `at most ${MAX_WORKERS} workers may be configured`, + }) + .refine( + (workers) => { + const aliases = Object.values(workers).map((worker) => worker.sshAlias); + return new Set(aliases).size === aliases.length; + }, + // Two ids sharing one alias would take two independent leases against one + // real machine, and each would believe it held the desktop exclusively. + { message: "each worker must use a distinct SSH alias" }, + ); + +export type WorkerConfig = z.output; +export type WorkerConfigMap = Record; + +export interface ResolvedWorker { + id: string; + platform: WorkerPlatform; + displayName: string; + sshAlias: string; + expectedDriverVersion: string; + expectedBasePolicySha256: string | null; + browserExecutable: string; + browserProfile: string; + ideExecutable: string; + paused: boolean; + /** False until the operator supplies the base-policy digest. An unpinned + * policy means the driver's tool ceiling is whatever happens to be on the + * worker's disk, so an unconfigured worker is never treated as usable. */ + configured: boolean; +} + +export function resolveWorker(id: string, raw: WorkerConfig): ResolvedWorker { + const defaults = WORKER_DEFAULTS[raw.platform]; + const digest = raw.expectedBasePolicySha256 && SHA256.test(raw.expectedBasePolicySha256) + ? raw.expectedBasePolicySha256.toLowerCase() + : null; + return { + id, + platform: raw.platform, + displayName: raw.displayName || id, + sshAlias: raw.sshAlias, + expectedDriverVersion: raw.expectedDriverVersion || WORKER_DRIVER_VERSION, + expectedBasePolicySha256: digest, + browserExecutable: raw.browserExecutable || defaults.browserExecutable, + browserProfile: raw.browserProfile || defaults.browserProfile, + ideExecutable: raw.ideExecutable || defaults.ideExecutable, + paused: raw.paused === true, + configured: digest !== null, + }; +} + +export function listWorkers(workers: WorkerConfigMap | undefined): ResolvedWorker[] { + if (!workers) return []; + return Object.keys(workers) + .filter(isValidWorkerId) + .sort() + .map((id) => resolveWorker(id, workers[id])); +} + +export function findWorker(workers: WorkerConfigMap | undefined, id: JsonValue): ResolvedWorker | null { + const parsed = workerIdSchema.safeParse(id); + if (!workers || !parsed.success) return null; + const raw = workers[parsed.data]; + return raw ? resolveWorker(parsed.data, raw) : null; +} + +/** Redacts the transport identity before a worker is described to a bot, a + * device client, or a task event. The alias names a host in the operator's + * own SSH config; nothing downstream of the control plane needs it, and #508 + * requires it stay out of snapshots, events, logs and exports. */ +export function publicWorker(worker: ResolvedWorker): Omit { + const { sshAlias: _sshAlias, ...rest } = worker; + return rest; +} diff --git a/server/config.ts b/server/config.ts index a6758bbd1..507745dc9 100644 --- a/server/config.ts +++ b/server/config.ts @@ -9,6 +9,13 @@ import { z } from "zod"; import { writeFileAtomic } from "./atomic.ts"; import type { InstanceConfigMap } from "./contracts.ts"; import { parseJson, schemaIssue, type JsonObject, type JsonValue } from "./schema.ts"; +import { + findWorker, + listWorkers, + workerConfigMapSchema, + type ResolvedWorker, + type WorkerConfigMap, +} from "./computer-workers.ts"; const optionalText = z.string().optional(); const SSH_ALIAS = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/; @@ -96,6 +103,8 @@ const appConfigSchema = z.object({ rooms: roomConfigSchema.optional(), localVm: localVmConfigSchema.optional(), features: featureConfigSchema.optional(), + /** Operator-owned Windows and macOS desktops, keyed by worker id. */ + workers: workerConfigMapSchema.optional(), instances: instanceConfigMapSchema.optional(), }); const appConfigPatchSchema = appConfigSchema.omit({ instances: true }); @@ -118,6 +127,9 @@ export interface AppConfig { localVm?: { mode?: "shared" | "per-bot"; maxInstances?: number }; /** Opt-in product experiments. Every flag defaults to disabled. */ features?: { skillRecorder?: boolean; showToolCalls?: boolean }; + /** Named remote CUA workers. Only the SSH alias and public digests are + * persisted; authentication stays with the operator's SSH config. */ + workers?: WorkerConfigMap; instances?: InstanceConfigMap; } export type ConfigPatch = z.output; @@ -140,6 +152,14 @@ export function vpsSshAlias(cfg: AppConfig): string | null { return isValidSshAlias(cfg.vps?.sshAlias) ? cfg.vps.sshAlias : null; } +export function configuredWorkers(cfg: AppConfig): ResolvedWorker[] { + return listWorkers(cfg.workers); +} + +export function workerById(cfg: AppConfig, id: JsonValue): ResolvedWorker | null { + return findWorker(cfg.workers, id); +} + export function roomTurnTimeoutMinutes(cfg: AppConfig): number { return cfg.rooms?.turnTimeoutMinutes ?? DEFAULT_ROOM_TURN_TIMEOUT_MINUTES; } diff --git a/server/contracts.ts b/server/contracts.ts index 6098de4ba..46efd029d 100644 --- a/server/contracts.ts +++ b/server/contracts.ts @@ -11,6 +11,11 @@ export type ThreadId = string; export type TurnId = string; export type CloudBackend = "box" | "vps"; +/** A tool call that drives a real interactive desktop, rather than a + * disposable container. Both values mean the same thing to the approval + * rules: a remembered always-allow grant does not cover it. */ +export type ApprovalScope = "local-computer" | "remote-worker-computer"; + export type ProviderErrorCode = | "missing_cli" | "invalid_credentials" @@ -117,7 +122,7 @@ export type RuntimeEvent = RuntimeEventBase & tool: string; summary: string; choices?: string[]; - approvalScope?: "local-computer"; + approvalScope?: ApprovalScope; } | { type: "request.resolved"; @@ -126,7 +131,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"; + approvalScope?: ApprovalScope; } | { type: "thread.token-usage.updated"; input: number; output: number; cachedInput?: number } // `setup: true` marks a failure the user fixes by installing or @@ -183,7 +188,7 @@ export interface SendTurnInput { env: Record; platform?: "darwin" | "linux" | "win32"; generation?: string; - scope?: "local-computer"; + scope?: ApprovalScope; }; /** 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 diff --git a/server/index.ts b/server/index.ts index 4e1048d94..e551eb0c2 100644 --- a/server/index.ts +++ b/server/index.ts @@ -63,6 +63,8 @@ import { syncCredentialEnv, withInstanceCli, vpsSshAlias, + configuredWorkers, + workerById, DATA_DIR, EVENTS_DIR, NATIVE_DIR, @@ -129,6 +131,9 @@ import { fetchSkillFromSource } from "./skill-fetch.ts"; import { readCuaConnection } from "./local-computer.ts"; import { LocalVmIdleTimer } from "./local-vm-idle.ts"; import { LocalVmLease, LocalVmLeasePool } from "./local-vm-lease.ts"; +import { publicWorker, type ResolvedWorker } from "./computer-workers.ts"; +import { RemoteWorkerLease, remoteWorkerMcp } from "./remote-worker.ts"; +import { allWorkerStatuses, workerStatus } from "./worker-status.ts"; import { RepeatDetector, callKey } from "./repeat-detector.ts"; import * as vps from "./vps-computer.ts"; import { RoutineManager, type RoutineRunOn, type RoutineRunTrigger } from "./routines.ts"; @@ -760,6 +765,19 @@ function localVmIdleFor(target: LocalVmTarget): LocalVmIdleTimer { return idle; } +/** One lease pool for every named worker. Records key on the SSH alias, so + * a macOS bot and a Windows bot never contend, while two turns aimed at one + * desktop still serialize. */ +const workerLease = new RemoteWorkerLease(); +const workerThreadAliases = new Map(); + +function releaseWorkerThread(threadId: string): void { + const alias = workerThreadAliases.get(threadId); + if (!alias) return; + workerLease.release(threadId); + workerThreadAliases.delete(threadId); +} + function releaseLocalVmThread(threadId: string): void { const target = localVmThreadTargets.get(threadId); if (!target) return; @@ -786,8 +804,10 @@ bus.subscribe((event: RuntimeEvent) => { localVmLeaseFor(localVmTarget).touch(event.threadId); localVmIdleFor(localVmTarget).touch(); } + if (workerThreadAliases.has(event.threadId)) workerLease.touch(event.threadId); if (event.type === "turn.completed") { releaseLocalVmThread(event.threadId); + releaseWorkerThread(event.threadId); } broadcast({ kind: "runtime", event }); const routineRun = routines?.handleRuntimeEvent(event) ?? null; @@ -1550,7 +1570,8 @@ async function startTurn( 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; + let computerKind: "box" | "vps" | "vm" | "local" | "worker" | null = null; + let workerTarget: ResolvedWorker | null = null; let autoVpsProblem: string | null = null; // Explicit destinations are strict. In particular, Local VM must never @@ -1594,6 +1615,34 @@ async function startTurn( if (!cua) throw new Error("CUA Driver is not ready for this computer — check permissions and restart OpenMausBot"); integrations.localComputer = cua; computerKind = "local"; + } else if (wants === "worker") { + if (!mountsComputerMcp || instance.driverKind === "boxAgent") { + throw new Error("this model engine cannot use a remote worker — choose Claude or an ACP engine, or select another computer destination"); + } + const worker = workerById(cfg, bot.workerId ?? null); + if (!worker) { + throw new Error("this bot is not assigned to a configured worker (App Settings → Workers)"); + } + // Claim before the first await, exactly as Local VM does: otherwise + // two turns could both pass the readiness check and then both mount + // the same physical desktop, interleaving real keyboard and mouse + // input on one screen. + if (!workerLease.claim(worker.sshAlias, threadId, bot.id, (id) => store.bot(id)?.busy === true)) { + throw new Error(`the ${worker.displayName} desktop is already being used by another turn — wait for that turn to finish`); + } + workerThreadAliases.set(threadId, worker.sshAlias); + const status = await workerStatus(worker); + if (!status.ready || !status.channelPath) { + throw new Error(`${status.problem ?? "this worker is not ready"} (App Settings → Workers)`); + } + integrations.localComputer = remoteWorkerMcp( + worker, + status.channelPath, + controlIntegration(bot.id), + status.capabilityDigest ?? undefined, + ); + workerTarget = worker; + computerKind = "worker"; } // A VPS is a local-agent computer mount, never a remote agent runner. @@ -1767,6 +1816,8 @@ async function startTurn( ? " You have your own self-hosted remote Linux computer through the official Cua tools. Its filesystem is disposable: everything on it is wiped whenever its container is recreated, so keep long-lived work somewhere durable — push it to a remote, or hand the results back in chat — instead of leaving it only on that computer. Inspect the desktop state before acting, prefer accessibility targets over raw coordinates, and act carefully." : computerKind === "local" ? " You can act on the user's computer through the computer tools — take a screenshot or read the desktop state first, prefer accessibility actions over raw coordinates, and act carefully." + : computerKind === "worker" + ? ` You have your own ${workerTarget?.platform === "windows" ? "Windows" : "macOS"} computer — a separate machine the user owns, reached through the official Cua tools. It is not disposable and it is not the user's own desktop: treat its files as real, do not reconfigure the machine, and stay inside the task's approved surface. Inspect the desktop state before acting, prefer accessibility targets over raw coordinates, and act carefully.` : "") + (computerKind ? " At a sign-in, password, MFA, CAPTCHA, or other protected-input step, stop and ask the user to complete it on the visible computer. Never type their password or ask them to paste a password or one-time code into chat." @@ -1806,6 +1857,7 @@ async function startTurn( } } catch (e) { releaseLocalVmThread(threadId); + releaseWorkerThread(threadId); if (activeVpsThreads.get(bot.id) === threadId) activeVpsThreads.delete(bot.id); watchdog.settle(threadId); turnUsage.delete(threadId); @@ -4068,9 +4120,41 @@ const server = createServer(async (req, res) => { } if ( body.computer !== undefined && - !["cloud", "vm", "local", "off"].includes(String(body.computer)) + !["cloud", "vm", "local", "worker", "off"].includes(String(body.computer)) ) { - return json(res, 400, { error: "computer must be cloud, vm, local, or off" }); + return json(res, 400, { error: "computer must be cloud, vm, local, worker, or off" }); + } + // Tracked in its own typed local: `patch` is a Record, + // so reading the id back out of it would lose the type the check below + // needs. + let assignedWorkerId: string | null | undefined; + if (body.workerId !== undefined) { + if (body.workerId === null || body.workerId === "") { + patch.workerId = undefined; + assignedWorkerId = null; + } else if (!workerById(cfg, body.workerId)) { + return json(res, 400, { error: "workerId must name a configured worker (App Settings → Workers)" }); + } else { + assignedWorkerId = String(body.workerId); + patch.workerId = assignedWorkerId; + } + } + { + // Assignment and destination are checked together: either field can + // arrive alone, and "worker" without a resolvable id would fail only + // at the start of the next turn, long after the person left Settings. + const nextComputer = body.computer !== undefined ? body.computer : existingBot?.computer; + const nextWorkerId = assignedWorkerId !== undefined ? assignedWorkerId : (existingBot?.workerId ?? null); + if (nextComputer === "worker" && !workerById(cfg, nextWorkerId)) { + return json(res, 400, { error: "choose a configured worker for this bot first (App Settings → Workers)" }); + } + // Every worker task is explicitly approved through the three fences, + // so auto mode has nothing to approve on its own and must not look + // like it does. + const nextAuto = body.autoApprove !== undefined ? body.autoApprove : existingBot?.autoApprove === true; + if (nextComputer === "worker" && nextAuto === true) { + return json(res, 400, { error: "Auto mode is unavailable while this bot uses a remote worker" }); + } } if (body.cloudBackend !== undefined && !["box", "vps"].includes(String(body.cloudBackend))) { return json(res, 400, { error: "cloudBackend must be box or vps" }); @@ -4840,6 +4924,25 @@ const server = createServer(async (req, res) => { } } + // ── named remote workers (Windows PCs and macOS guests) ── + if (method === "GET" && path === "/api/workers") { + const workers = configuredWorkers(cfg); + // Probed concurrently: an unreachable worker must not delay the + // healthy one, and each adapter already fails closed on its own. + const statuses = await allWorkerStatuses(workers, { + lease: workerLease, + isBotBusy: (botId) => store.bot(botId)?.busy === true, + }); + // The SSH alias names a host in the operator's own config. Nothing + // downstream of the control plane needs it, so it never leaves here. + return json(res, 200, { + workers: workers.map((worker, index) => ({ + ...publicWorker(worker), + status: statuses[index], + })), + }); + } + // ── app config (API keys — never echoed back, booleans only) ── if (method === "GET" && path === "/api/config") { return json(res, 200, configStatus()); @@ -4849,6 +4952,25 @@ const server = createServer(async (req, res) => { const patch = parseConfigPatch(body); if (!Object.keys(patch).length) return json(res, 400, { error: "nothing to save" }); if (providerConfigBusy) return json(res, 409, { error: "provider settings are already being updated" }); + if (patch.workers !== undefined) { + const nextAliases = new Set(configuredWorkers({ ...cfg, workers: patch.workers }).map((w) => w.sshAlias)); + const heldAndGone = configuredWorkers(cfg).filter( + (worker) => + !nextAliases.has(worker.sshAlias) + && workerLease.current(worker.sshAlias, (botId) => store.bot(botId)?.busy === true) !== null, + ); + if (heldAndGone.length > 0) { + return json(res, 409, { + error: `wait for the turn using ${heldAndGone[0].displayName} to finish before changing that worker`, + }); + } + // A worker that survived the edit unchanged keeps its lease; one that + // was removed or repointed must not leave a record reporting `busy` + // for a machine the control plane no longer addresses. + for (const worker of configuredWorkers(cfg)) { + if (!nextAliases.has(worker.sshAlias)) workerLease.releaseAlias(worker.sshAlias); + } + } if (patch.vps !== undefined) { const currentAlias = vpsSshAlias(cfg); const nextAlias = vpsSshAlias({ ...cfg, vps: patch.vps }); diff --git a/server/mac-worker.ts b/server/mac-worker.ts new file mode 100644 index 000000000..050c36613 --- /dev/null +++ b/server/mac-worker.ts @@ -0,0 +1,179 @@ +// macOS adapter for a named remote CUA worker. +// +// The transport, lease and shared readiness ladder live in +// ./remote-worker.ts. This module owns only what Windows has no counterpart +// for: the POSIX health probe, the Aqua console session, the unix-socket +// control channel, the admin-group rule, and TCC. +// +// TCC is the one check with no Windows analogue and the one an operator +// cannot script away: Accessibility and Screen Recording are granted +// per-binary, System Integrity Protection blocks writing the TCC database, +// and replacing the driver binary silently revokes them. So the probe reads +// the live grant on every poll rather than trusting a setup step that +// happened once. `currentMacOsPermissionStatus()` in the pinned CUA SDK is +// the non-prompting read; the worker companion surfaces it as JSON because +// TCC state belongs to the driver's own binary, not to whatever process the +// SSH session happens to start. +import { + applyHealthReport, + baseWorkerStatus, + defaultRemoteWorkerRunner, + evaluateSharedHealth, + failWorker, + finishWorkerStatus, + remoteWorkerSshBaseArgs, + WORKER_SSH_TIMEOUT_MS, + type RemoteWorkerLease, + type RemoteWorkerSshRunner, + type RemoteWorkerStatus, +} from "./remote-worker.ts"; +import type { ResolvedWorker } from "./computer-workers.ts"; +import { parseJson, type JsonValue } from "./schema.ts"; + +/** Fixed by convention under the worker account's own home so the socket and + * its directory can both be owner-private. The probe reports the resolved + * absolute path and the control plane pins it into the MCP generation. */ +export const MAC_CUA_SOCKET_RELATIVE = ".openmausbot/run/cua.sock"; +export const MAC_SUPPORT_RELATIVE = "Library/Application Support/OpenMausBot"; +export const MAC_POLICY_RELATIVE = `${MAC_SUPPORT_RELATIVE}/macos-policy.yaml`; +export const MAC_CAPABILITY_RELATIVE = `${MAC_SUPPORT_RELATIVE}/active-capabilities.yaml`; + +// POSIX sh, no bashisms: the worker account's login shell is the operator's +// choice, so this runs under `/bin/sh -s` with the script on stdin. Every +// probe is read-only and none of them prompt. +const MAC_HEALTH_SCRIPT = String.raw` +set -u +support="$HOME/Library/Application Support/OpenMausBot" +sock="$HOME/.openmausbot/run/cua.sock" + +json_str() { + if [ -z "$1" ]; then printf 'null'; else printf '"%s"' "$(printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g')"; fi +} +json_bool() { if [ "$1" = "1" ]; then printf 'true'; else printf 'false'; fi; } + +driver_version=$(cua-driver --version 2>/dev/null | sed -n 's/.*\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\).*/\1/p' | head -n 1) +companion_version=$(openmausbot-worker-companion --version 2>/dev/null | sed -n 's/.*[^0-9]\([0-9][0-9]*\)$/\1/p' | head -n 1) +[ -n "$companion_version" ] || companion_version=null + +# An admin worker account could rewrite the very policy that bounds it. +privileged=0 +if id -Gn 2>/dev/null | tr ' ' '\n' | grep -qx admin; then privileged=1; fi + +# The Aqua session that owns /dev/console is the only one with a real screen. +# Its uid doubles as the session identifier: macOS has no Windows-style +# numeric window-station id, and "whose login session" is the fact that +# actually matters for driving a desktop. +console_user=$(stat -f%Su /dev/console 2>/dev/null) +current_user=$(id -un 2>/dev/null) +interactive=0 +interactive_session_id=null +if [ -n "$console_user" ] && [ "$console_user" = "$current_user" ] && launchctl print "gui/$(id -u)" >/dev/null 2>&1; then + interactive=1 + interactive_session_id=$(id -u) +fi + +# CGSSessionScreenIsLocked is absent entirely while unlocked. +locked=0 +if ioreg -n Root -d1 -a 2>/dev/null | grep -q CGSSessionScreenIsLocked; then locked=1; fi + +channel_available=0 +channel_access=missing +if [ -S "$sock" ]; then + channel_available=1 + if [ -r "$sock" ] && [ -w "$sock" ]; then channel_access=ok; else channel_access=denied; channel_available=0; fi +fi + +digest_of() { + if [ -f "$1" ]; then shasum -a 256 "$1" 2>/dev/null | awk '{print $1}'; fi +} +policy_digest=$(digest_of "$support/macos-policy.yaml") +capability_digest=$(digest_of "$support/active-capabilities.yaml") + +status_text=$(cua-driver status --socket "$sock" 2>/dev/null | tr '[:upper:]' '[:lower:]') +policy_loaded=0 +if [ -n "$policy_digest" ] && printf '%s' "$status_text" | grep -qF "$policy_digest"; then policy_loaded=1; fi +capability_loaded=0 +if [ -n "$capability_digest" ] && printf '%s' "$status_text" | grep -qF "$capability_digest"; then capability_loaded=1; fi +permission_mode=unknown +if printf '%s' "$status_text" | grep -qw bounded; then permission_mode=bounded +elif printf '%s' "$status_text" | grep -qw standard; then permission_mode=standard +elif printf '%s' "$status_text" | grep -qw unrestricted; then permission_mode=unrestricted +fi + +# The companion reports the driver binary's own TCC grants. Absent or +# unparseable output stays false, which fails the ladder closed. +tcc=$(openmausbot-worker-companion --permissions 2>/dev/null) +accessibility=0 +screen_recording=0 +if printf '%s' "$tcc" | grep -q '"accessibility"[[:space:]]*:[[:space:]]*true'; then accessibility=1; fi +if printf '%s' "$tcc" | grep -q '"screenRecording"[[:space:]]*:[[:space:]]*true'; then screen_recording=1; fi + +printf '{' +printf '"driverVersion":%s,' "$(json_str "$driver_version")" +printf '"companionVersion":%s,' "$companion_version" +printf '"privileged":%s,' "$(json_bool "$privileged")" +printf '"interactiveSession":%s,' "$(json_bool "$interactive")" +printf '"interactiveSessionId":%s,' "$interactive_session_id" +printf '"locked":%s,' "$(json_bool "$locked")" +printf '"channelPath":%s,' "$(json_str "$sock")" +printf '"channelAvailable":%s,' "$(json_bool "$channel_available")" +printf '"channelAccess":%s,' "$(json_str "$channel_access")" +printf '"policyDigest":%s,' "$(json_str "$policy_digest")" +printf '"policyLoaded":%s,' "$(json_bool "$policy_loaded")" +printf '"permissionMode":%s,' "$(json_str "$permission_mode")" +printf '"capabilityDigest":%s,' "$(json_str "$capability_digest")" +printf '"capabilityLoaded":%s,' "$(json_bool "$capability_loaded")" +printf '"accessibilityGranted":%s,' "$(json_bool "$accessibility")" +printf '"screenRecordingGranted":%s' "$(json_bool "$screen_recording")" +printf '}' +`; + +export function macWorkerHealthArgs(sshAlias: string): string[] { + // Keep the fixed probe off argv and on stdin, matching the Windows adapter: + // one short, inspectable command in the worker's process listing. + return [...remoteWorkerSshBaseArgs(sshAlias), "/bin/sh", "-s"]; +} + +/** The macOS-only tail of the readiness ladder. Runs after the shared checks + * so a missing driver or an unlocked-screen fault is reported before TCC. */ +export function evaluateMacHealth(status: RemoteWorkerStatus): RemoteWorkerStatus | null { + if (status.accessibilityGranted !== true) { + return failWorker(status, "policy_mismatch", "worker_accessibility_denied", + "Grant Accessibility to CUA Driver in the guest's System Settings > Privacy & Security"); + } + if (status.screenRecordingGranted !== true) { + return failWorker(status, "policy_mismatch", "worker_screen_recording_denied", + "Grant Screen Recording to CUA Driver in the guest's System Settings > Privacy & Security"); + } + return null; +} + +export async function macWorkerStatus( + worker: ResolvedWorker, + options: { + runner?: RemoteWorkerSshRunner; + lease?: RemoteWorkerLease; + isBotBusy?: (botId: string) => boolean; + } = {}, +): Promise { + const status = baseWorkerStatus(worker); + if (!worker.configured) return status; + if (worker.paused) return failWorker(status, "paused", "worker_paused", "This worker is paused"); + + const runner = options.runner ?? defaultRemoteWorkerRunner; + let report: JsonValue; + try { + const result = await runner(macWorkerHealthArgs(worker.sshAlias), WORKER_SSH_TIMEOUT_MS, MAC_HEALTH_SCRIPT); + report = parseJson(result.stdout.trim()); + } catch (error) { + return failWorker(status, "offline", "worker_offline", + `Worker SSH is offline: ${error instanceof Error ? error.message.slice(0, 200) : "unknown error"}`); + } + + applyHealthReport(status, report); + const shared = evaluateSharedHealth(status); + if (shared) return shared; + const mac = evaluateMacHealth(status); + if (mac) return mac; + return finishWorkerStatus(status, worker.sshAlias, options); +} diff --git a/server/mcp-bridge.ts b/server/mcp-bridge.ts index 68ef573fd..af240acc5 100644 --- a/server/mcp-bridge.ts +++ b/server/mcp-bridge.ts @@ -37,6 +37,8 @@ const PROBE_TIMEOUT_MS = 10_000; export interface BridgeLiveness { command: string; args: string[]; + /** Optional child environment for a transport with a stricter boundary. */ + env?: NodeJS.ProcessEnv; } /** Run the liveness command; alive means "exited 0 within the timeout". The @@ -46,7 +48,7 @@ export function runLivenessProbe(probe: BridgeLiveness, timeoutMs = PROBE_TIMEOU return new Promise((resolve) => { const child = spawn(probe.command, probe.args, { shell: false, - env: { ...process.env, PATH: augmentedPath() }, + env: probe.env ?? { ...process.env, PATH: augmentedPath() }, stdio: ["ignore", "ignore", "ignore"], }); const timer = setTimeout(() => { @@ -125,6 +127,10 @@ export function createInactivityWatchdog(options: { export interface BridgeOptions { command: string; args: string[]; + /** Optional child environment. The default preserves existing local/VPS + * behavior; a remote worker supplies an allow-listed SSH environment so no + * API key or loopback control token can reach the ssh child. */ + env?: NodeJS.ProcessEnv; /** Names the far end in stderr messages, e.g. "Cua Driver". */ label: string; /** Enables the dead-transport watchdog. Omitted for the Local VM, whose @@ -208,7 +214,7 @@ export function createGateInterceptor(options: { export function runMcpBridge(options: BridgeOptions): void { const child = spawn(options.command, options.args, { shell: false, - env: { ...process.env, PATH: augmentedPath() }, + env: options.env ?? { ...process.env, PATH: augmentedPath() }, stdio: ["pipe", "pipe", "pipe"], }); diff --git a/server/proxy-paths.ts b/server/proxy-paths.ts index 5a67582af..3b50b22aa 100644 --- a/server/proxy-paths.ts +++ b/server/proxy-paths.ts @@ -38,6 +38,7 @@ export const SPAWNED_PROXIES = { permission: resolveProxy("permission-proxy"), containerMcp: resolveProxy("container-mcp"), vpsContainerMcp: resolveProxy("vps-container-mcp"), + workerMcp: resolveProxy("worker-mcp"), agents: resolveProxy("drivers/agents-proxy"), dweb: resolveProxy("drivers/dweb-proxy"), connectors: resolveProxy("connector-proxy"), diff --git a/server/remote-worker.ts b/server/remote-worker.ts new file mode 100644 index 000000000..58c8296c8 --- /dev/null +++ b/server/remote-worker.ts @@ -0,0 +1,469 @@ +// Shared transport and readiness contract for named remote CUA workers. +// +// Everything here is platform-neutral: the SSH invocation, the environment +// allow-list, the per-alias lease, and the fail-closed readiness ladder. Each +// platform adapter (./windows-worker.ts, ./mac-worker.ts) supplies only its +// own health probe and the checks that have no counterpart on the other OS. +import { spawn } from "node:child_process"; + +import { z } from "zod"; + +import { + isValidWorkerSshAlias, + type ResolvedWorker, + type WorkerPlatform, +} from "./computer-workers.ts"; +import { augmentedPath } from "./env-path.ts"; +import type { JsonValue } from "./schema.ts"; +import { SPAWNED_PROXIES } from "./proxy-paths.ts"; + +export const WORKER_COMPANION_PROTOCOL_VERSION = 1; +export const WORKER_SSH_TIMEOUT_MS = 15_000; +const LEASE_TTL_MS = 30 * 60_000; + +const SHA256 = /^[a-f0-9]{64}$/i; +/** Control characters and the shell metacharacters that would make a quoted + * YAML scalar or an argv element ambiguous. */ +const UNSAFE_PATH = /[\u0000-\u001f"|<>]/; + +export type RemoteWorkerState = + | "unconfigured" + | "offline" + | "wrong_driver_version" + | "no_interactive_session" + | "locked" + | "policy_mismatch" + | "ready" + | "busy" + | "paused"; + +export type RemoteWorkerErrorCode = + | "worker_unconfigured" + | "worker_offline" + | "worker_driver_missing" + | "worker_driver_wrong_version" + | "worker_companion_missing" + | "worker_privileged_account" + | "worker_no_interactive_session" + | "worker_channel_missing" + | "worker_channel_access_denied" + | "worker_locked" + | "worker_policy_missing" + | "worker_policy_mismatch" + | "worker_permission_mode_mismatch" + | "worker_capability_missing" + | "worker_capability_mismatch" + | "worker_accessibility_denied" + | "worker_screen_recording_denied" + | "worker_busy" + | "worker_paused"; + +export interface RemoteWorkerLeaseRecord { + sshAlias: string; + threadId: string; + botId: string; + expiresAt: number; +} + +/** One interactive desktop admits one task at a time: two concurrent turns + * would interleave real mouse and keyboard input on the same screen. Leases + * are keyed by SSH alias rather than by worker id, so distinct workers stay + * fully independent and a Windows bot and a macOS bot hold their desktops at + * the same time. `computer-workers.ts` rejects two ids sharing one alias, + * which is what makes the key sound. */ +export class RemoteWorkerLease { + private readonly records = new Map(); + private readonly ttlMs: number; + + constructor(ttlMs = LEASE_TTL_MS) { + if (!Number.isFinite(ttlMs) || ttlMs <= 0) throw new Error("remote worker lease TTL must be positive"); + this.ttlMs = ttlMs; + } + + current(sshAlias: string, isBotBusy: (botId: string) => boolean, now = Date.now()): RemoteWorkerLeaseRecord | null { + const record = this.records.get(sshAlias); + // A lease outlives neither its TTL nor its owner's turn. Dropping it when + // the bot goes idle is what stops a crashed turn from parking a desktop + // for the whole TTL. + if (record && (record.expiresAt <= now || !isBotBusy(record.botId))) this.records.delete(sshAlias); + const current = this.records.get(sshAlias); + return current ? { ...current } : null; + } + + claim( + sshAlias: string, + threadId: string, + botId: string, + isBotBusy: (botId: string) => boolean, + now = Date.now(), + ): boolean { + if (!isValidWorkerSshAlias(sshAlias)) return false; + const current = this.current(sshAlias, isBotBusy, now); + if (current && current.threadId !== threadId) return false; + this.records.set(sshAlias, { sshAlias, threadId, botId, expiresAt: now + this.ttlMs }); + return true; + } + + touch(threadId: string, now = Date.now()): void { + for (const [alias, record] of this.records) { + if (record.expiresAt <= now) this.records.delete(alias); + else if (record.threadId === threadId) record.expiresAt = now + this.ttlMs; + } + } + + release(threadId: string): void { + for (const [alias, record] of this.records) { + if (record.threadId === threadId) this.records.delete(alias); + } + } + + /** Releases every lease on one alias regardless of owner. Used when a worker + * is removed or repointed, so a stale record cannot keep reporting `busy` + * for a machine the control plane no longer addresses. */ + releaseAlias(sshAlias: string): void { + this.records.delete(sshAlias); + } +} + +/** The exact JSON a platform health probe returns, parsed at its I/O + * boundary. This payload crosses a trust edge — it is whatever a remote + * machine's shell printed — so every field is validated here rather than + * narrowed at each use. + * + * Each field carries `.catch(undefined)` so one malformed value degrades to + * "not proven" instead of discarding the whole report. That matters: a probe + * from a half-configured worker is exactly the case the operator needs + * diagnostics for, and a report that failed to parse wholesale would surface + * as a bare offline error naming nothing. */ +const healthReportSchema = z.object({ + driverVersion: z.string().max(64).nullish().catch(undefined), + companionVersion: z.number().int().nullish().catch(undefined), + /** True when the SSH account can administer the machine. A worker account + * with admin rights can rewrite the very policy that bounds it. */ + privileged: z.boolean().optional().catch(undefined), + interactiveSession: z.boolean().optional().catch(undefined), + interactiveSessionId: z.number().int().positive().nullish().catch(undefined), + locked: z.boolean().optional().catch(undefined), + channelPath: z.string().min(1).max(512) + .refine((value) => !UNSAFE_PATH.test(value)) + .nullish().catch(undefined), + channelAvailable: z.boolean().optional().catch(undefined), + channelAccess: z.enum(["ok", "missing", "denied", "unknown"]).optional().catch(undefined), + policyDigest: z.string().regex(SHA256).transform((value) => value.toLowerCase()).nullish().catch(undefined), + policyLoaded: z.boolean().optional().catch(undefined), + permissionMode: z.enum(["bounded", "standard", "unrestricted", "unknown"]).optional().catch(undefined), + capabilityDigest: z.string().regex(SHA256).transform((value) => value.toLowerCase()).nullish().catch(undefined), + capabilityLoaded: z.boolean().optional().catch(undefined), + /** macOS only. Windows has no TCC analogue and leaves these undefined, + * which the macOS ladder treats as denied. */ + accessibilityGranted: z.boolean().optional().catch(undefined), + screenRecordingGranted: z.boolean().optional().catch(undefined), +}).loose(); + +export type RemoteWorkerHealthReport = z.output; + +/** Never throws: every field catches, so an unparseable payload yields a + * report in which nothing is proven. */ +export function parseHealthReport(raw: JsonValue): RemoteWorkerHealthReport { + const parsed = healthReportSchema.safeParse(raw); + return parsed.success ? parsed.data : {}; +} + +export interface RemoteWorkerStatus { + workerId: string; + platform: WorkerPlatform; + displayName: string; + configured: boolean; + state: RemoteWorkerState; + ready: boolean; + paused: boolean; + expectedDriverVersion: string; + driverVersion: string | null; + companionVersion: number | null; + privileged: boolean; + interactiveSession: boolean; + interactiveSessionId: number | null; + locked: boolean; + channelPath: string | null; + channelAvailable: boolean; + channelAccess: "ok" | "missing" | "denied" | "unknown"; + policyDigest: string | null; + /** True only when the driver reports the same digest as the on-disk policy. + * A matching file is not enough: the driver loads policy once at daemon + * start, and an unset policy variable disables enforcement entirely. */ + policyLoaded: boolean; + expectedPolicyDigest: string | null; + policyMatches: boolean; + permissionMode: "bounded" | "standard" | "unrestricted" | "unknown"; + capabilityDigest: string | null; + capabilityLoaded: boolean; + accessibilityGranted: boolean | null; + screenRecordingGranted: boolean | null; + lease: { botId: string; threadId: string; expiresAt: number } | null; + errorCode: RemoteWorkerErrorCode | null; + problem: string | null; +} + +export type RemoteWorkerSshRunner = ( + args: string[], + timeoutMs?: number, + stdin?: string, +) => Promise<{ stdout: string; stderr: string }>; + +export function remoteWorkerSshBaseArgs(sshAlias: string): string[] { + if (!isValidWorkerSshAlias(sshAlias)) throw new Error("invalid worker SSH config alias"); + return ["-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "-T", sshAlias]; +} + +/** SSH needs the operator's home directory, agent socket, locale and PATH, + * but it never needs API keys or OpenMausBot's loopback control token. Build + * an allow-list rather than trying to enumerate every possible secret. */ +export function remoteWorkerSshEnvironment(source: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { PATH: augmentedPath() }; + for (const name of ["HOME", "USER", "LOGNAME", "SSH_AUTH_SOCK", "TMPDIR", "LANG", "LC_ALL", "LC_CTYPE", "TERM"]) { + const value = source[name]; + if (value !== undefined) env[name] = value; + } + return env; +} + +export function defaultRemoteWorkerRunner( + args: string[], + timeoutMs = WORKER_SSH_TIMEOUT_MS, + stdin = "", +): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn("ssh", args, { + shell: false, + env: remoteWorkerSshEnvironment(), + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let settled = false; + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + fn(); + }; + const timer = setTimeout(() => { + child.kill("SIGKILL"); + finish(() => reject(new Error("worker SSH health check timed out"))); + }, timeoutMs); + timer.unref?.(); + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdin.on("error", () => { + // A fast remote failure may close stdin before Node finishes writing. + // The child close/error path below remains the authoritative result. + }); + child.stdin.end(stdin); + child.stdout.on("data", (chunk: string) => { stdout = (stdout + chunk).slice(-1024 * 1024); }); + child.stderr.on("data", (chunk: string) => { stderr = (stderr + chunk).slice(-64 * 1024); }); + child.on("error", (error) => finish(() => reject(new Error(`worker SSH could not start: ${error.message}`)))); + child.on("close", (code) => finish(() => { + if (code === 0) resolve({ stdout, stderr }); + else reject(new Error(stderr.trim().slice(-500) || `worker SSH exited ${code ?? "without a status"}`)); + })); + }); +} + +export function isSafeChannelPath(value: string): boolean { + return value.length > 0 && value.length <= 512 && !UNSAFE_PATH.test(value); +} + +export function baseWorkerStatus(worker: ResolvedWorker): RemoteWorkerStatus { + return { + workerId: worker.id, + platform: worker.platform, + displayName: worker.displayName, + configured: worker.configured, + state: "unconfigured", + ready: false, + paused: worker.paused, + expectedDriverVersion: worker.expectedDriverVersion, + driverVersion: null, + companionVersion: null, + privileged: false, + interactiveSession: false, + interactiveSessionId: null, + locked: false, + channelPath: null, + channelAvailable: false, + channelAccess: "unknown", + policyDigest: null, + policyLoaded: false, + expectedPolicyDigest: worker.expectedBasePolicySha256, + policyMatches: false, + permissionMode: "unknown", + capabilityDigest: null, + capabilityLoaded: false, + accessibilityGranted: null, + screenRecordingGranted: null, + lease: null, + errorCode: "worker_unconfigured", + problem: "Configure this worker's SSH alias and expected base-policy SHA-256", + }; +} + +export function failWorker( + status: RemoteWorkerStatus, + state: RemoteWorkerState, + code: RemoteWorkerErrorCode, + problem: string, +): RemoteWorkerStatus { + status.state = state; + status.errorCode = code; + status.problem = problem; + return status; +} + +/** Copies a probe result onto the status without deciding readiness. Split + * from the ladder below so a caller can render a diagnostic panel for a + * worker that will never become ready. */ +export function applyHealthReport(status: RemoteWorkerStatus, raw: JsonValue): RemoteWorkerStatus { + const report = parseHealthReport(raw); + status.driverVersion = report.driverVersion ?? null; + status.companionVersion = report.companionVersion ?? null; + status.privileged = report.privileged === true; + status.interactiveSessionId = report.interactiveSessionId ?? null; + // A session flag without an id is not a proven session. + status.interactiveSession = report.interactiveSession === true && status.interactiveSessionId !== null; + status.locked = report.locked === true; + status.channelPath = report.channelPath ?? null; + status.channelAvailable = report.channelAvailable === true; + status.channelAccess = report.channelAccess ?? "unknown"; + status.policyDigest = report.policyDigest ?? null; + status.policyLoaded = report.policyLoaded === true; + status.policyMatches = status.policyLoaded + && status.policyDigest !== null + && status.policyDigest === status.expectedPolicyDigest; + status.permissionMode = report.permissionMode ?? "unknown"; + status.capabilityDigest = report.capabilityDigest ?? null; + status.capabilityLoaded = report.capabilityLoaded === true; + status.accessibilityGranted = report.accessibilityGranted ?? null; + status.screenRecordingGranted = report.screenRecordingGranted ?? null; + return status; +} + +/** The readiness ladder every platform shares, ordered so the operator sees + * the most actionable failure first. Returns a failed status, or null when + * every shared check passed and the adapter should run its own. */ +export function evaluateSharedHealth(status: RemoteWorkerStatus): RemoteWorkerStatus | null { + if (!status.driverVersion) { + return failWorker(status, "wrong_driver_version", "worker_driver_missing", + "CUA Driver is not installed or not on PATH for the worker's SSH user"); + } + if (status.driverVersion !== status.expectedDriverVersion) { + return failWorker(status, "wrong_driver_version", "worker_driver_wrong_version", + `Worker CUA Driver ${status.driverVersion} does not match required ${status.expectedDriverVersion}`); + } + if (status.companionVersion !== WORKER_COMPANION_PROTOCOL_VERSION) { + return failWorker(status, "wrong_driver_version", "worker_companion_missing", + `Worker companion protocol ${WORKER_COMPANION_PROTOCOL_VERSION} is not installed`); + } + if (status.privileged) { + return failWorker(status, "policy_mismatch", "worker_privileged_account", + "The worker's SSH account must be a dedicated non-administrator user"); + } + if (!status.interactiveSession) { + return failWorker(status, "no_interactive_session", "worker_no_interactive_session", + "No interactive desktop session is running on the worker"); + } + if (status.locked) return failWorker(status, "locked", "worker_locked", "The worker's desktop is locked"); + if (status.channelAccess === "denied") { + return failWorker(status, "no_interactive_session", "worker_channel_access_denied", + "The worker's SSH user cannot reach the interactive CUA control channel"); + } + if (!status.channelAvailable || !status.channelPath) { + return failWorker(status, "no_interactive_session", "worker_channel_missing", + "The interactive CUA control channel is not available on the worker"); + } + if (!status.policyDigest) { + return failWorker(status, "policy_mismatch", "worker_policy_missing", "The worker's base policy is missing"); + } + if (!status.policyLoaded) { + return failWorker(status, "policy_mismatch", "worker_policy_mismatch", + "CUA Driver did not report the configured base policy as loaded"); + } + if (!status.policyMatches) { + return failWorker(status, "policy_mismatch", "worker_policy_mismatch", + "The worker's base-policy digest does not match the approved configuration"); + } + if (status.permissionMode !== "bounded") { + return failWorker(status, "policy_mismatch", "worker_permission_mode_mismatch", + "CUA Driver must run in bounded permission mode"); + } + if (!status.capabilityDigest) { + return failWorker(status, "policy_mismatch", "worker_capability_missing", + "The active CUA capability manifest is missing on the worker"); + } + if (!status.capabilityLoaded) { + return failWorker(status, "policy_mismatch", "worker_capability_mismatch", + "CUA Driver did not report the active capability manifest as loaded"); + } + return null; +} + +/** Applies the lease last, so `busy` never masks a configuration fault the + * operator still has to fix. */ +export function finishWorkerStatus( + status: RemoteWorkerStatus, + sshAlias: string, + options: { lease?: RemoteWorkerLease; isBotBusy?: (botId: string) => boolean }, +): RemoteWorkerStatus { + const lease = options.lease?.current(sshAlias, options.isBotBusy ?? (() => true)) ?? null; + status.lease = lease ? { botId: lease.botId, threadId: lease.threadId, expiresAt: lease.expiresAt } : null; + if (lease) return failWorker(status, "busy", "worker_busy", "This desktop is leased by another active task"); + status.state = "ready"; + status.ready = true; + status.errorCode = null; + status.problem = null; + return status; +} + +export function remoteWorkerCuaMcpSshArgs(sshAlias: string, channelPath: string): string[] { + if (!isSafeChannelPath(channelPath)) throw new Error("invalid worker CUA control channel path"); + return [...remoteWorkerSshBaseArgs(sshAlias), "cua-driver", "mcp", "--socket", channelPath]; +} + +/** The generation string pins every fact the connection depends on. Any drift + * — a driver upgrade, a re-approved policy, a new capability manifest, a moved + * control channel — produces a different generation and forces a reconnect + * rather than silently reusing a bridge bound to the old guarantees. */ +export interface RemoteWorkerMcpDescriptor { + command: string; + args: string[]; + env: Record; + /** The integration contract speaks Node's platform names. The worker's own + * spelling travels in argv instead, where the bridge needs it to pick a + * liveness command. */ + platform: "darwin" | "win32"; + generation: string; + scope: "remote-worker-computer"; +} + +export function remoteWorkerMcp( + worker: ResolvedWorker, + channelPath: string, + control?: { url: string; token: string }, + capabilityDigest?: string, +): RemoteWorkerMcpDescriptor { + if (!worker.sshAlias) throw new Error("worker SSH alias is not configured"); + // Throws before any bridge is spawned when the channel path is unsafe. + remoteWorkerCuaMcpSshArgs(worker.sshAlias, channelPath); + return { + command: SPAWNED_PROXIES.workerMcp, + args: [worker.sshAlias, channelPath, worker.platform], + env: control ? { OMB_CONTROL_URL: control.url, OMB_CONTROL_TOKEN: control.token } : {}, + platform: worker.platform === "windows" ? "win32" : "darwin", + generation: [ + worker.expectedDriverVersion, + worker.expectedBasePolicySha256 ?? "no-policy", + capabilityDigest ?? "parked", + channelPath, + ].join(":"), + scope: "remote-worker-computer", + }; +} diff --git a/server/store.ts b/server/store.ts index d028f050d..710d9add8 100644 --- a/server/store.ts +++ b/server/store.ts @@ -10,7 +10,8 @@ import { peerAllowKey, type PeerAction } from "./peer-approval-key.ts"; import { DATA_DIR } from "./config.ts"; import * as mdb from "./message-db.ts"; import { workspaceDir } from "./workspace.ts"; -import { newId, type CloudBackend, type ModelSelection, type ThreadId } from "./contracts.ts"; +import { newId, type ApprovalScope, type CloudBackend, type ModelSelection, type ThreadId } from "./contracts.ts"; +import { isValidWorkerId } from "./computer-workers.ts"; import { pickBotName } from "./names.ts"; import { redactSecretsInText } from "./redact.ts"; import { botAvatarProfile, type BotAvatarCrop } from "../shared/bot-avatar.ts"; @@ -50,7 +51,7 @@ export interface OptionCardData { /** 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"; + approvalScope?: ApprovalScope; } export interface ConnectorCardData { @@ -299,9 +300,14 @@ export interface BotRecord { modelSelection: ModelSelection; /** provider-native continuation per instance (e.g. claude session id) */ resumeCursors: Record; - /** which computer the bot acts on: its cloud box, this Mac (local CUA), - * or none. Unset = auto (box when it exists, else local when available). */ - computer?: "cloud" | "vm" | "local" | "off"; + /** which computer the bot acts on: its cloud box, this Mac (local CUA), a + * named remote worker (an operator-owned Windows PC or macOS guest), or + * none. Unset = auto (box when it exists, else local when available). */ + computer?: "cloud" | "vm" | "local" | "worker" | "off"; + /** Which named worker backs `computer: "worker"`. Workers are configured + * app-wide in `config.workers`; the bot stores only the id, never the + * transport identity. */ + workerId?: string; /** Which cloud computer backs `computer: "cloud"`; absent means Box. */ cloudBackend?: CloudBackend; /** Auto mode may prepare/start this bot's managed VPS container. Off by @@ -523,6 +529,13 @@ export class Store { delete b.autoStartVps; botsMigrated = true; } + // A worker id that is no longer a legal id could never resolve, and + // leaving it set would render a bot as assigned to a machine that + // cannot be looked up. + if (b.workerId !== undefined && !isValidWorkerId(b.workerId)) { + delete b.workerId; + botsMigrated = true; + } const avatar = botAvatarProfile(b); if (b.avatarUrl !== undefined && avatar.avatarUrl !== b.avatarUrl) { delete b.avatarUrl; diff --git a/server/windows-worker.ts b/server/windows-worker.ts new file mode 100644 index 000000000..d51c43b7c --- /dev/null +++ b/server/windows-worker.ts @@ -0,0 +1,144 @@ +// Windows adapter for a named remote CUA worker. +// +// Everything transport-shaped lives in ./remote-worker.ts. This module owns +// only what has no macOS counterpart: the PowerShell health probe, the +// interactive Session 1+ window station, the named-pipe control channel, and +// the Administrators-group rule. +import { + applyHealthReport, + baseWorkerStatus, + defaultRemoteWorkerRunner, + evaluateSharedHealth, + failWorker, + finishWorkerStatus, + remoteWorkerSshBaseArgs, + WORKER_SSH_TIMEOUT_MS, + type RemoteWorkerLease, + type RemoteWorkerSshRunner, + type RemoteWorkerStatus, +} from "./remote-worker.ts"; +import type { ResolvedWorker } from "./computer-workers.ts"; +import { parseJson, type JsonValue } from "./schema.ts"; + +export const WINDOWS_CUA_PIPE = "\\\\.\\pipe\\cua-driver"; +export const WINDOWS_POLICY_PATH = "%LOCALAPPDATA%\\OpenMausBot\\windows-policy.yaml"; + +const WINDOWS_HEALTH_SCRIPT = String.raw` +$ErrorActionPreference = 'Stop' +$driverVersion = $null +try { + $versionText = (& cua-driver --version 2>&1 | Out-String).Trim() + if ($versionText -match '(\d+\.\d+\.\d+)') { $driverVersion = $Matches[1] } +} catch {} +$companionVersion = $null +try { + $companionText = (& openmausbot-worker-companion --version 2>&1 | Out-String).Trim() + if ($companionText -match '(\d+)$') { $companionVersion = [int]$Matches[1] } +} catch {} +$privileged = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name +$explorers = @(Get-Process explorer -IncludeUserName -ErrorAction SilentlyContinue | Where-Object { $_.UserName -ieq $currentUser }) +$interactiveSessions = @($explorers | ForEach-Object { $_.SessionId } | Select-Object -Unique) +$locked = @(Get-Process LogonUI -ErrorAction SilentlyContinue | Where-Object { $interactiveSessions -contains $_.SessionId }).Count -gt 0 +$channelAvailable = $false +$channelAccess = 'unknown' +try { + $pipe = [System.IO.Pipes.NamedPipeClientStream]::new('.', 'cua-driver', [System.IO.Pipes.PipeDirection]::InOut, [System.IO.Pipes.PipeOptions]::Asynchronous) + try { $pipe.Connect(1000); $channelAvailable = $pipe.IsConnected; $channelAccess = if ($channelAvailable) { 'ok' } else { 'missing' } } + finally { $pipe.Dispose() } +} catch [System.UnauthorizedAccessException] { $channelAccess = 'denied' } +catch [System.TimeoutException] { $channelAccess = 'missing' } +catch { $channelAccess = 'missing' } +$policyPath = Join-Path $env:LOCALAPPDATA 'OpenMausBot\windows-policy.yaml' +$policyDigest = $null +if (Test-Path -LiteralPath $policyPath -PathType Leaf) { $policyDigest = (Get-FileHash -Algorithm SHA256 -LiteralPath $policyPath).Hash.ToLowerInvariant() } +$daemonStatus = '' +try { $daemonStatus = (& cua-driver status --socket '\\.\pipe\cua-driver' 2>&1 | Out-String).ToLowerInvariant() } catch {} +$interactiveSessionId = $null +if ($daemonStatus -match 'session:\s*(\d+)') { $interactiveSessionId = [int]$Matches[1] } +$interactive = $interactiveSessionId -ne $null -and $interactiveSessions -contains $interactiveSessionId +$policyLoaded = $false +if ($policyDigest) { $policyLoaded = $daemonStatus.Contains($policyDigest) } +$permissionMode = 'unknown' +if ($daemonStatus -match '\bbounded\b') { $permissionMode = 'bounded' } +elseif ($daemonStatus -match '\bstandard\b') { $permissionMode = 'standard' } +elseif ($daemonStatus -match '\bunrestricted\b') { $permissionMode = 'unrestricted' } +$capabilityPath = Join-Path $env:LOCALAPPDATA 'OpenMausBot\active-capabilities.yaml' +$capabilityDigest = $null +if (Test-Path -LiteralPath $capabilityPath -PathType Leaf) { $capabilityDigest = (Get-FileHash -Algorithm SHA256 -LiteralPath $capabilityPath).Hash.ToLowerInvariant() } +$capabilityLoaded = $false +if ($capabilityDigest) { $capabilityLoaded = $daemonStatus.Contains($capabilityDigest) } +[ordered]@{ + driverVersion = $driverVersion + companionVersion = $companionVersion + privileged = $privileged + interactiveSession = $interactive + interactiveSessionId = $interactiveSessionId + locked = $locked + channelPath = '\\.\pipe\cua-driver' + channelAvailable = $channelAvailable + channelAccess = $channelAccess + policyDigest = $policyDigest + policyLoaded = $policyLoaded + permissionMode = $permissionMode + capabilityDigest = $capabilityDigest + capabilityLoaded = $capabilityLoaded +} | ConvertTo-Json -Compress +`; + +// Windows PowerShell's `-Command -` reads stdin interactively and does not +// reliably assemble multiline blocks. Keep argv short and fixed by encoding +// only this tiny bootstrap; the full fixed probe stays on stdin and is parsed +// as one script block. +const HEALTH_STDIN_WRAPPER_BASE64 = Buffer.from( + "$source = [Console]::In.ReadToEnd(); & ([ScriptBlock]::Create($source))", + "utf16le", +).toString("base64"); + +export function windowsWorkerHealthArgs(sshAlias: string): string[] { + return [ + ...remoteWorkerSshBaseArgs(sshAlias), + "powershell.exe", + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-EncodedCommand", + HEALTH_STDIN_WRAPPER_BASE64, + ]; +} + +export async function windowsWorkerStatus( + worker: ResolvedWorker, + options: { + runner?: RemoteWorkerSshRunner; + lease?: RemoteWorkerLease; + isBotBusy?: (botId: string) => boolean; + } = {}, +): Promise { + const status = baseWorkerStatus(worker); + if (!worker.configured) return status; + if (worker.paused) return failWorker(status, "paused", "worker_paused", "This worker is paused"); + + const runner = options.runner ?? defaultRemoteWorkerRunner; + let report: JsonValue; + try { + // Keep the fixed health program off argv. Windows OpenSSH invokes the + // user's command through cmd.exe, whose command-line ceiling is lower + // than PowerShell's encoded form of this probe. Stdin also keeps process + // listings limited to one fixed, inspectable command. + const result = await runner( + windowsWorkerHealthArgs(worker.sshAlias), + WORKER_SSH_TIMEOUT_MS, + WINDOWS_HEALTH_SCRIPT, + ); + report = parseJson(result.stdout.trim()); + } catch (error) { + return failWorker(status, "offline", "worker_offline", + `Worker SSH is offline: ${error instanceof Error ? error.message.slice(0, 200) : "unknown error"}`); + } + + applyHealthReport(status, report); + const failed = evaluateSharedHealth(status); + if (failed) return failed; + return finishWorkerStatus(status, worker.sshAlias, options); +} diff --git a/server/worker-mcp.ts b/server/worker-mcp.ts new file mode 100644 index 000000000..93e1c7d03 --- /dev/null +++ b/server/worker-mcp.ts @@ -0,0 +1,49 @@ +// Transparent stdio bridge to the official CUA Driver in a remote worker's +// interactive session. Authentication is owned by the operator's OpenSSH +// config; the only remote command is the pinned CUA MCP invocation, and the +// child environment is the allow-list from ./remote-worker.ts rather than +// this process's own environment. +import { isWorkerPlatform, type WorkerPlatform } from "./computer-workers.ts"; +import { runMcpBridge } from "./mcp-bridge.ts"; +import { + remoteWorkerCuaMcpSshArgs, + remoteWorkerSshBaseArgs, + remoteWorkerSshEnvironment, +} from "./remote-worker.ts"; + +const [alias = "", channelPath = "", rawPlatform = ""] = process.argv.slice(2); + +/** A no-op that exits 0 through each platform's default SSH shell. Windows + * OpenSSH hands the command to cmd.exe, which has no `true`. */ +function livenessCommand(platform: WorkerPlatform): string[] { + return platform === "windows" + ? ["powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "exit 0"] + : ["/bin/sh", "-c", "exit 0"]; +} + +let args: string[]; +let livenessArgs: string[]; +try { + if (!isWorkerPlatform(rawPlatform)) throw new Error("unknown worker platform"); + args = remoteWorkerCuaMcpSshArgs(alias, channelPath); + livenessArgs = [...remoteWorkerSshBaseArgs(alias), ...livenessCommand(rawPlatform)]; +} catch { + process.stderr.write("invalid worker MCP connection\n"); + process.exit(2); +} + +const gate = (() => { + const url = process.env.OMB_CONTROL_URL ?? ""; + const token = process.env.OMB_CONTROL_TOKEN ?? ""; + return url && token ? { gate: { url, token } } : {}; +})(); +const sshEnv = remoteWorkerSshEnvironment(); + +runMcpBridge({ + command: "ssh", + args, + env: sshEnv, + label: "Worker CUA Driver", + liveness: { command: "ssh", args: livenessArgs, env: sshEnv }, + ...gate, +}); diff --git a/server/worker-status.test.ts b/server/worker-status.test.ts new file mode 100644 index 000000000..c3e1e1788 --- /dev/null +++ b/server/worker-status.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, it } from "vitest"; + +import { listWorkers, type ResolvedWorker } from "./computer-workers.ts"; +import { macWorkerHealthArgs } from "./mac-worker.ts"; +import { RemoteWorkerLease, remoteWorkerMcp, remoteWorkerSshEnvironment } from "./remote-worker.ts"; +import { windowsWorkerHealthArgs } from "./windows-worker.ts"; +import { allWorkerStatuses, workerStatus } from "./worker-status.ts"; + +const policy = "a".repeat(64); +const capability = "c".repeat(64); + +const [macWorker, winWorker] = listWorkers({ + "mac-guest": { platform: "macos", sshAlias: "macguest", expectedBasePolicySha256: policy }, + "win-box": { platform: "windows", sshAlias: "winbox", expectedBasePolicySha256: policy }, +}) as [ResolvedWorker, ResolvedWorker]; + +const MAC_SOCKET = "/Users/worker/.openmausbot/run/cua.sock"; +const WIN_PIPE = "\\\\.\\pipe\\cua-driver"; + +function healthy(platform: "macos" | "windows", overrides: Record = {}) { + return JSON.stringify({ + driverVersion: "0.20.0", + companionVersion: 1, + privileged: false, + interactiveSession: true, + interactiveSessionId: platform === "macos" ? 501 : 2, + locked: false, + channelPath: platform === "macos" ? MAC_SOCKET : WIN_PIPE, + channelAvailable: true, + channelAccess: "ok", + policyDigest: policy, + policyLoaded: true, + permissionMode: "bounded", + capabilityDigest: capability, + capabilityLoaded: true, + ...(platform === "macos" ? { accessibilityGranted: true, screenRecordingGranted: true } : {}), + ...overrides, + }); +} + +/** A fake worker: no SSH, no guest, just the probe's exact stdout. */ +const runnerFor = (platform: "macos" | "windows", overrides: Record = {}) => + async () => ({ stdout: healthy(platform, overrides), stderr: "" }); + +describe("remote worker readiness", () => { + it("reports a fully configured worker of either platform as ready", async () => { + const mac = await workerStatus(macWorker, { runner: runnerFor("macos") }); + const win = await workerStatus(winWorker, { runner: runnerFor("windows") }); + expect([mac.ready, win.ready]).toEqual([true, true]); + expect([mac.state, win.state]).toEqual(["ready", "ready"]); + expect(mac.channelPath).toBe(MAC_SOCKET); + expect(win.channelPath).toBe(WIN_PIPE); + }); + + it("sends each platform its own fixed probe over stdin", () => { + expect(macWorkerHealthArgs("macguest")).toEqual([ + "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "-T", "macguest", "/bin/sh", "-s", + ]); + const win = windowsWorkerHealthArgs("winbox"); + expect(win.slice(0, 6)).toEqual(["-o", "BatchMode=yes", "-o", "ConnectTimeout=10", "-T", "winbox"]); + expect(win.slice(6, -1)).toEqual([ + "powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-EncodedCommand", + ]); + // Windows OpenSSH runs the command through cmd.exe, whose command-line + // ceiling is far below the encoded form of the whole probe. + expect(win.at(-1)!.length).toBeLessThan(1_024); + expect(() => macWorkerHealthArgs("host -o ProxyCommand=curl")).toThrow(/invalid worker SSH/); + }); + + it("refuses an administrator worker account on both platforms", async () => { + // An admin worker could rewrite the very base policy that bounds it. + for (const [worker, platform] of [[macWorker, "macos"], [winWorker, "windows"]] as const) { + const status = await workerStatus(worker, { runner: runnerFor(platform, { privileged: true }) }); + expect(status.ready).toBe(false); + expect(status.errorCode).toBe("worker_privileged_account"); + } + }); + + it("refuses a driver whose version is not the pinned one", async () => { + const status = await workerStatus(macWorker, { runner: runnerFor("macos", { driverVersion: "0.19.3" }) }); + expect(status.errorCode).toBe("worker_driver_wrong_version"); + }); + + it("refuses a policy file that the daemon never loaded", async () => { + // A matching file on disk is not enough: the driver reads policy once at + // start, and an unset policy variable disables enforcement entirely. + const status = await workerStatus(winWorker, { runner: runnerFor("windows", { policyLoaded: false }) }); + expect(status.errorCode).toBe("worker_policy_mismatch"); + expect(status.policyMatches).toBe(false); + }); + + it("refuses a locked desktop and a missing control channel", async () => { + const locked = await workerStatus(macWorker, { runner: runnerFor("macos", { locked: true }) }); + expect(locked.errorCode).toBe("worker_locked"); + const noChannel = await workerStatus(macWorker, { + runner: runnerFor("macos", { channelAvailable: false, channelAccess: "missing" }), + }); + expect(noChannel.errorCode).toBe("worker_channel_missing"); + }); + + it("fails closed when macOS TCC grants are absent", async () => { + // Accessibility and Screen Recording are per-binary and are silently + // revoked when the driver binary is replaced, so an absent grant must + // never read as ready. + const noAx = await workerStatus(macWorker, { runner: runnerFor("macos", { accessibilityGranted: false }) }); + expect(noAx.errorCode).toBe("worker_accessibility_denied"); + const noScreen = await workerStatus(macWorker, { runner: runnerFor("macos", { screenRecordingGranted: false }) }); + expect(noScreen.errorCode).toBe("worker_screen_recording_denied"); + // A probe that omits the fields entirely is "not proven", not "fine". + const silent = await workerStatus(macWorker, { + runner: async () => { + const report = JSON.parse(healthy("macos")); + delete report.accessibilityGranted; + return { stdout: JSON.stringify(report), stderr: "" }; + }, + }); + expect(silent.ready).toBe(false); + expect(silent.errorCode).toBe("worker_accessibility_denied"); + }); + + it("degrades a malformed probe field to unproven instead of discarding the report", async () => { + // A half-configured worker is exactly the case the operator needs + // diagnostics for, so one bad field must not collapse the whole report + // into a bare offline error that names nothing. + const status = await workerStatus(winWorker, { + runner: async () => ({ + stdout: JSON.stringify({ + driverVersion: "0.20.0", + companionVersion: 1, + privileged: false, + interactiveSession: true, + interactiveSessionId: 2, + locked: false, + channelPath: WIN_PIPE, + channelAvailable: true, + channelAccess: "ok", + policyDigest: "not-a-digest", + policyLoaded: true, + permissionMode: "bounded", + capabilityDigest: capability, + capabilityLoaded: true, + }), + stderr: "", + }), + }); + expect(status.driverVersion).toBe("0.20.0"); + expect(status.interactiveSession).toBe(true); + expect(status.policyDigest).toBeNull(); + expect(status.errorCode).toBe("worker_policy_missing"); + }); + + it("proves nothing when the probe returns something that is not a report", async () => { + const status = await workerStatus(macWorker, { + runner: async () => ({ stdout: '"not an object"', stderr: "" }), + }); + expect(status.ready).toBe(false); + expect(status.errorCode).toBe("worker_driver_missing"); + }); + + it("treats an unreachable worker as offline rather than throwing", async () => { + const status = await workerStatus(winWorker, { + runner: async () => { throw new Error("ssh: connect to host winbox port 22: Host is down"); }, + }); + expect(status.state).toBe("offline"); + expect(status.errorCode).toBe("worker_offline"); + expect(status.problem).toContain("Host is down"); + }); + + it("keeps one dead worker from taking the healthy one down with it", async () => { + // #508 acceptance 6: disconnect either worker and the other stays usable. + const statuses = await allWorkerStatuses([macWorker, winWorker], { + runner: async (args) => { + if (args.includes("winbox")) throw new Error("Host is down"); + return { stdout: healthy("macos"), stderr: "" }; + }, + }); + const byId = Object.fromEntries(statuses.map((status) => [status.workerId, status])); + expect(byId["mac-guest"].ready).toBe(true); + expect(byId["win-box"].state).toBe("offline"); + }); +}); + +describe("worker leases", () => { + it("lets a macOS bot and a Windows bot hold their desktops at the same time", async () => { + // The whole point of the registry: two OS-different desktops, two bots, + // one control plane, concurrently. + const lease = new RemoteWorkerLease(); + const busy = new Set(["bot-mac", "bot-win"]); + const isBotBusy = (botId: string) => busy.has(botId); + + expect(lease.claim(macWorker.sshAlias, "thread-mac", "bot-mac", isBotBusy)).toBe(true); + expect(lease.claim(winWorker.sshAlias, "thread-win", "bot-win", isBotBusy)).toBe(true); + + const mac = await workerStatus(macWorker, { runner: runnerFor("macos"), lease, isBotBusy }); + const win = await workerStatus(winWorker, { runner: runnerFor("windows"), lease, isBotBusy }); + expect(mac.lease?.botId).toBe("bot-mac"); + expect(win.lease?.botId).toBe("bot-win"); + // Each desktop reports busy to *other* callers while its own turn runs; + // neither lease blocks the other. + expect([mac.errorCode, win.errorCode]).toEqual(["worker_busy", "worker_busy"]); + }); + + it("admits one task per desktop and releases it with the turn", async () => { + const lease = new RemoteWorkerLease(); + const busy = new Set(["bot-a"]); + const isBotBusy = (botId: string) => busy.has(botId); + + expect(lease.claim(macWorker.sshAlias, "thread-a", "bot-a", isBotBusy)).toBe(true); + expect(lease.claim(macWorker.sshAlias, "thread-b", "bot-b", isBotBusy)).toBe(false); + // Re-claiming from the same thread is a renewal, not a conflict. + expect(lease.claim(macWorker.sshAlias, "thread-a", "bot-a", isBotBusy)).toBe(true); + + lease.release("thread-a"); + const free = await workerStatus(macWorker, { runner: runnerFor("macos"), lease, isBotBusy }); + expect(free.ready).toBe(true); + }); + + it("drops a lease whose owning turn ended without releasing it", () => { + const lease = new RemoteWorkerLease(); + let busy = true; + const isBotBusy = () => busy; + lease.claim(macWorker.sshAlias, "thread-a", "bot-a", isBotBusy); + busy = false; + // Otherwise a crashed turn parks the desktop for the full TTL. + expect(lease.current(macWorker.sshAlias, isBotBusy)).toBeNull(); + }); + + it("expires a lease at its TTL", () => { + const lease = new RemoteWorkerLease(1_000); + const isBotBusy = () => true; + lease.claim(macWorker.sshAlias, "thread-a", "bot-a", isBotBusy, 0); + expect(lease.current(macWorker.sshAlias, isBotBusy, 999)).not.toBeNull(); + expect(lease.current(macWorker.sshAlias, isBotBusy, 1_001)).toBeNull(); + }); + + it("frees an alias outright when its worker is removed or repointed", () => { + const lease = new RemoteWorkerLease(); + const isBotBusy = () => true; + lease.claim(macWorker.sshAlias, "thread-a", "bot-a", isBotBusy); + lease.releaseAlias(macWorker.sshAlias); + expect(lease.current(macWorker.sshAlias, isBotBusy)).toBeNull(); + }); +}); + +describe("worker MCP boundary", () => { + it("pins driver, policy, capability and channel into the generation", () => { + const descriptor = remoteWorkerMcp(macWorker, MAC_SOCKET, undefined, capability); + // The integration contract speaks Node platform names; the worker's own + // spelling travels in argv for the bridge's liveness command. + expect(descriptor.platform).toBe("darwin"); + expect(descriptor.scope).toBe("remote-worker-computer"); + expect(descriptor.args).toEqual(["macguest", MAC_SOCKET, "macos"]); + expect(descriptor.generation).toBe(`0.20.0:${policy}:${capability}:${MAC_SOCKET}`); + // A parked capability must not look the same as an approved one. + expect(remoteWorkerMcp(macWorker, MAC_SOCKET).generation).toContain(":parked:"); + }); + + it("refuses a control channel path that could smuggle shell syntax", () => { + expect(() => remoteWorkerMcp(macWorker, "/tmp/a b|nc evil 1")).toThrow(/control channel/); + expect(() => remoteWorkerMcp(macWorker, "")).toThrow(/control channel/); + }); + + it("allows only SSH runtime metadata into the bridge process", () => { + // The bridge must never inherit provider credentials or the loopback + // control token, so the environment is an allow-list, not a deny-list. + const marker = "must-not-be-forwarded"; + const env = remoteWorkerSshEnvironment({ + HOME: "/Users/gus", + SSH_AUTH_SOCK: "/tmp/agent.sock", + LANG: "en_US.UTF-8", + EXAMPLE_PROVIDER_CREDENTIAL: marker, + OMB_CONTROL_TOKEN: marker, + SOME_OTHER_SETTING: marker, + }); + expect(Object.keys(env).sort()).toEqual(["HOME", "LANG", "PATH", "SSH_AUTH_SOCK"]); + expect(JSON.stringify(env)).not.toContain(marker); + }); +}); diff --git a/server/worker-status.ts b/server/worker-status.ts new file mode 100644 index 000000000..56a3b2661 --- /dev/null +++ b/server/worker-status.ts @@ -0,0 +1,26 @@ +// One entry point for reading a named worker's readiness, so callers never +// branch on platform themselves. +import type { ResolvedWorker } from "./computer-workers.ts"; +import { macWorkerStatus } from "./mac-worker.ts"; +import type { RemoteWorkerLease, RemoteWorkerSshRunner, RemoteWorkerStatus } from "./remote-worker.ts"; +import { windowsWorkerStatus } from "./windows-worker.ts"; + +export interface WorkerStatusOptions { + runner?: RemoteWorkerSshRunner; + lease?: RemoteWorkerLease; + isBotBusy?: (botId: string) => boolean; +} + +export function workerStatus(worker: ResolvedWorker, options: WorkerStatusOptions = {}): Promise { + return worker.platform === "windows" ? windowsWorkerStatus(worker, options) : macWorkerStatus(worker, options); +} + +/** Reads every configured worker concurrently. One unreachable worker must + * never delay or fail the others: #508 requires a dead worker to degrade to + * unavailable while healthy desktops keep serving. */ +export async function allWorkerStatuses( + workers: ResolvedWorker[], + options: WorkerStatusOptions = {}, +): Promise { + return Promise.all(workers.map((worker) => workerStatus(worker, options))); +} diff --git a/src/components/ComputerPanel.tsx b/src/components/ComputerPanel.tsx index 3f6928e84..5b2e472db 100644 --- a/src/components/ComputerPanel.tsx +++ b/src/components/ComputerPanel.tsx @@ -32,6 +32,7 @@ import { LocalScreenPreview } from "./LocalScreenPreview"; import { LinuxLocalControl } from "./LinuxLocalControl"; import { MacLocalControl } from "./MacLocalControl"; import { LocalComputerAutoWarning } from "./LocalComputerAutoWarning"; +import { WorkerPicker } from "./WorkerPicker"; import { autoSelectsLocalComputer, instanceSupportsLocalComputer, @@ -1060,6 +1061,7 @@ export function ComputerPanel({ bot }: { bot: Bot }) { ["cloud", "Cloud"], ["vm", "Local VM"], ["local", "This computer"], + ["worker", "Worker"], ["off", "Off"], ] as const ).map(([mode, label], i) => ( @@ -1067,9 +1069,12 @@ export function ComputerPanel({ bot }: { bot: Bot }) { const disabled = (mode === "cloud" && !cloudSupported) || (mode === "vm" && !vmSupported) || + (mode === "worker" && !vmSupported) || (mode === "local" && !localSelectable); const unavailableTitle = - mode === "vm" && !vmSupported + mode === "worker" && !vmSupported + ? "This model engine cannot use a remote worker" + : mode === "vm" && !vmSupported ? "This model engine cannot use the Local VM" : mode === "cloud" && !cloudSupported ? "This model engine cannot use cloud computer tools" @@ -1084,7 +1089,9 @@ export function ComputerPanel({ bot }: { bot: Bot }) { onClick={() => { if (mode === bot.computer) return; if (mode === "local" && bot.autoApprove) setLocalAutoWarning(true); - else dispatch({ type: "updateBot", botId: bot.id, patch: { computer: mode } }); + else if (mode === "worker" && bot.autoApprove) { + dispatch({ type: "updateBot", botId: bot.id, patch: { computer: mode, autoApprove: false } }); + } else dispatch({ type: "updateBot", botId: bot.id, patch: { computer: mode } }); }} className={cn( "flex-1 py-1.5 text-[13px]", @@ -1101,6 +1108,12 @@ export function ComputerPanel({ bot }: { bot: Bot }) { })() ))} + {bot.computer === "worker" && ( + dispatch({ type: "updateBot", botId: bot.id, patch: { workerId } })} + /> + )} {(!bot.computer || bot.computer === "cloud") && ( <> void; +}) { + const [workers, setWorkers] = useState(null); + const [error, setError] = useState(null); + const visible = usePageVisible(); + + useEffect(() => { + if (!visible) return; + let alive = true; + const load = async () => { + try { + const res = await fetch("/api/workers", { headers: { "content-type": "application/json" } }); + const body = await res.json().catch(() => ({})); + if (!alive) return; + if (!res.ok) throw new Error(body.error ?? `${res.status} ${res.statusText}`); + setWorkers(Array.isArray(body.workers) ? body.workers : []); + setError(null); + } catch (e) { + if (!alive) return; + setError(e instanceof Error ? e.message : String(e)); + } + }; + void load(); + // Each poll re-probes every worker over SSH, so keep it slow and stop it + // entirely while the window is hidden. + const timer = window.setInterval(() => void load(), REFRESH_MS); + return () => { + alive = false; + window.clearInterval(timer); + }; + }, [visible]); + + if (error) { + return
{error}
; + } + if (workers === null) { + return
Checking workers…
; + } + if (workers.length === 0) { + return ( +
+
No workers configured
+
+ Add a Windows PC or a macOS guest in Settings → Workers, then choose it here. +
+
+ ); + } + + return ( +
+ {workers.map((worker) => { + const selected = worker.id === selectedWorkerId; + return ( + + ); + })} +
+ ); +} diff --git a/src/lib/workers.ts b/src/lib/workers.ts new file mode 100644 index 000000000..c8d59f5f9 --- /dev/null +++ b/src/lib/workers.ts @@ -0,0 +1,50 @@ +// Client view of the named remote workers. +// +// The server never sends the SSH alias: it names a host in the operator's own +// SSH config and nothing in the renderer needs it. What arrives is the +// worker's identity plus the readiness the control plane just probed. + +export type WorkerPlatform = "windows" | "macos"; + +export interface WorkerStatus { + workerId: string; + platform: WorkerPlatform; + displayName: string; + configured: boolean; + state: + | "unconfigured" + | "offline" + | "wrong_driver_version" + | "no_interactive_session" + | "locked" + | "policy_mismatch" + | "ready" + | "busy" + | "paused"; + ready: boolean; + paused: boolean; + lease: { botId: string; threadId: string; expiresAt: number } | null; + errorCode: string | null; + problem: string | null; +} + +export interface WorkerSummary { + id: string; + platform: WorkerPlatform; + displayName: string; + configured: boolean; + paused: boolean; + status: WorkerStatus; +} + +export function workerPlatformLabel(platform: WorkerPlatform): string { + return platform === "windows" ? "Windows" : "macOS"; +} + +/** What to show under a worker's name in the picker. `problem` already names + * the first thing that is actually wrong, so prefer it over a state word. */ +export function workerStatusLine(worker: WorkerSummary): string { + if (worker.status.ready) return "Ready"; + if (worker.status.lease) return "In use by another turn"; + return worker.status.problem ?? "Not ready"; +} diff --git a/src/state/bot-patch-queue.ts b/src/state/bot-patch-queue.ts index 220a165f5..10b12b03a 100644 --- a/src/state/bot-patch-queue.ts +++ b/src/state/bot-patch-queue.ts @@ -9,6 +9,7 @@ export type BotUpdatePatch = Partial< | "description" | "notifications" | "computer" + | "workerId" | "cloudBackend" | "autoStartVps" | "color" diff --git a/src/state/store.tsx b/src/state/store.tsx index d43b33bba..96729dabd 100644 --- a/src/state/store.tsx +++ b/src/state/store.tsx @@ -40,7 +40,7 @@ export interface OptionCardData { held?: string; /** the narrow grant "always allow" remembers, e.g. "Bash:git" */ allowKey?: string; - approvalScope?: "local-computer"; + approvalScope?: "local-computer" | "remote-worker-computer"; } export interface ConnectorCardData { @@ -191,7 +191,9 @@ export interface Bot { activity?: "working" | "waiting-on-you" | "idle" | "no-signal" | "dead"; modelSelection: ModelSelection; /** Where this bot's computer runs; unset = auto (cloud box if one exists, else local). */ - computer?: "cloud" | "vm" | "local" | "off"; + computer?: "cloud" | "vm" | "local" | "worker" | "off"; + /** Which named remote worker backs `computer: "worker"`. */ + workerId?: string; /** Which cloud computer backs `computer: "cloud"`; absent means Box. */ cloudBackend?: CloudBackend; /** Allow Auto to prepare/start the managed VPS container. Off by default. */