From 8caabdbaa9449dde7daa37bf0b3fb7ca22fb65df Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 00:31:06 +0100 Subject: [PATCH] feat: add interactive desktop VNC support (#11) --- CHANGELOG.md | 16 + README.md | 4 +- docs/cli.md | 8 +- docs/commands/README.md | 1 + docs/commands/run.md | 22 +- docs/commands/vnc.md | 91 ++++ docs/commands/warmup.md | 19 +- docs/features/README.md | 1 + docs/features/interactive-desktop-vnc.md | 51 +- docs/features/runner-bootstrap.md | 4 + docs/plan/vnc.md | 480 ++++++++++++++++++ docs/source-map.md | 11 +- internal/cli/app.go | 8 + internal/cli/bootstrap.go | 157 +++++- internal/cli/bootstrap_test.go | 49 ++ internal/cli/capabilities.go | 251 +++++++++ internal/cli/config.go | 16 + internal/cli/coordinator.go | 12 + internal/cli/coordinator_capabilities_test.go | 28 + internal/cli/provider_labels.go | 6 + internal/cli/provider_labels_test.go | 5 + internal/cli/run.go | 47 +- internal/cli/ssh_test.go | 39 ++ internal/cli/sync_windows_target.go | 27 +- internal/cli/version.go | 2 +- internal/cli/vnc.go | 217 ++++++++ internal/cli/vnc_test.go | 41 ++ package.json | 2 +- worker/package-lock.json | 4 +- worker/package.json | 2 +- worker/src/bootstrap.ts | 154 ++++++ worker/src/config.ts | 4 + worker/src/fleet.ts | 2 + worker/src/provider-labels.ts | 6 + worker/src/types.ts | 4 + worker/test/bootstrap.test.ts | 36 ++ worker/test/config.test.ts | 12 + worker/test/provider-labels.test.ts | 49 ++ 38 files changed, 1850 insertions(+), 38 deletions(-) create mode 100644 docs/commands/vnc.md create mode 100644 docs/plan/vnc.md create mode 100644 internal/cli/capabilities.go create mode 100644 internal/cli/coordinator_capabilities_test.go create mode 100644 internal/cli/vnc.go create mode 100644 internal/cli/vnc_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 4179e2e..d337aa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## 0.5.0 - Unreleased + +### Added + +- Added `--desktop`, `--browser`, and `crabbox vnc` for optional Linux UI/browser leases, including loopback-only VNC with per-lease passwords and headless browser support without a desktop. +- Added static macOS/Windows VNC endpoint discovery, including SSH-tunneled loopback VNC and trusted static direct VNC on `host:5900`. +- Added `crabbox vnc --open` to start the SSH tunnel and launch the local VNC client for managed desktop leases. +- Added a minimal XFCE desktop profile with panel/window manager for managed VNC leases. +- Clarified static macOS/Windows VNC as existing-host access, not Crabbox-created boxes, so `--open` no longer launches an OS credential prompt unless `--host-managed` is passed. + +### Fixed + +- Quoted `crabbox vnc` tunnel key paths so macOS `Application Support` lease keys can be pasted directly into a shell. +- Fixed native Windows `--shell` runs so multi-statement PowerShell scripts keep their quotes instead of being re-parsed by a nested PowerShell process. +- Skipped Linux-only GitHub Actions hydration stop markers on native Windows static targets. + ## 0.4.0 - 2026-05-03 ### Added diff --git a/README.md b/README.md index cf35967..7faf46b 100644 --- a/README.md +++ b/README.md @@ -76,12 +76,12 @@ For the full mental model, see [How Crabbox Works](docs/how-it-works.md). For th - **Stable timing records.** `--timing-json` on `run`, `warmup`, and `actions hydrate` gives scripts one machine-readable sync/command/total timing schema across AWS, Hetzner, and Blacksmith Testboxes. - **Local-first sync.** No clean-checkout requirement. Tracked + nonignored files only, fingerprint skip on no-op runs, sanity checks against suspicious mass deletions, optional shallow base-ref hydration for changed-test workflows. - **Brokered cloud.** Maintainers and agents share infra without sharing provider tokens. Hetzner and AWS EC2 Spot are first-class; both fall back across instance families when capacity or quota rejects a request. -- **macOS and Windows targets.** `provider: ssh` reuses existing SSH hosts. macOS and Windows WSL2 use the POSIX rsync path; native Windows uses PowerShell plus tar archive sync. +- **macOS and Windows static hosts.** `provider: ssh` reuses existing machines; it does not create macOS or Windows Crabbox boxes. macOS and Windows WSL2 use the POSIX rsync path; native Windows uses PowerShell plus tar archive sync. - **Blacksmith Testbox wrapper.** Set `provider: blacksmith-testbox` to delegate warmup/run/list/status/stop to the Blacksmith CLI while Crabbox keeps local slugs, repo claims, timing summaries, and config conventions. - **Trusted AWS images.** Operators can create AMIs from active brokered AWS leases and promote a known-good image as the coordinator default. - **Cost guardrails.** Per-lease and monthly spend caps. Live pricing from EC2 Spot history or Hetzner server-type prices, with static fallbacks. `crabbox usage` summarizes spend by user, org, provider, and type. - **GitHub Actions hydration.** `crabbox actions hydrate` registers a leased box as an ephemeral Actions runner, so the repo's own workflow installs runtimes, services, and secrets. Crabbox does not parse Actions YAML. -- **Planned interactive desktop leases.** VNC/browser takeover belongs in Crabbox as an optional Linux desktop profile, while QA systems such as Mantis own scenario logic, screenshots, and PR evidence. +- **Interactive desktop and browser leases.** `--browser` provisions Chrome or Chromium for headless automation, `--desktop` provisions Xvfb/Openbox/x11vnc for visible UI and tunnel-only VNC takeover, and QA systems such as Mantis own scenario logic, screenshots, and PR evidence. - **Hardened coordinator auth.** GitHub browser login, owner-scoped leases, admin-only routes, optional GitHub team allowlists, Cloudflare Access JWT verification, and service-token support keep normal use and operator automation separate. - **OpenClaw plugin.** The repo root is a native OpenClaw plugin for box lifecycle operations: `crabbox_run`, `crabbox_warmup`, `crabbox_status`, `crabbox_list`, and `crabbox_stop`. Run inspection stays in the CLI and Crabbox skill. - **Operator surface.** `doctor`, `init`, `status`, `inspect`, `list`, `usage`, `history`, `logs`, `results`, `cache`, `admin`, `cleanup`, plus `--json` output where it matters. diff --git a/docs/cli.md b/docs/cli.md index 084eda0..dfb8b43 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -33,8 +33,8 @@ crabbox init [--force] crabbox config show [--json] crabbox config path crabbox config set-broker --url --token-stdin [--provider hetzner|aws] -crabbox warmup [--provider hetzner|aws|ssh|blacksmith-testbox] [--target linux|macos|windows] [--profile ] [--idle-timeout ] [--timing-json] -crabbox run [--id ] [--provider hetzner|aws|ssh|blacksmith-testbox] [--target linux|macos|windows] [--windows-mode normal|wsl2] [--shell] [--checksum] [--debug] [--force-sync-large] [--timing-json] [--blacksmith-workflow ] -- +crabbox warmup [--provider hetzner|aws|ssh|blacksmith-testbox] [--target linux|macos|windows] [--desktop] [--browser] [--profile ] [--idle-timeout ] [--timing-json] +crabbox run [--id ] [--provider hetzner|aws|ssh|blacksmith-testbox] [--target linux|macos|windows] [--windows-mode normal|wsl2] [--desktop] [--browser] [--shell] [--checksum] [--debug] [--force-sync-large] [--timing-json] [--blacksmith-workflow ] -- crabbox sync-plan [--limit ] crabbox history [--lease ] [--owner ] [--org ] [--limit ] [--json] crabbox logs [--json] @@ -54,6 +54,7 @@ crabbox admin leases [--state active|released|expired|failed] [--owner ] crabbox admin release [--delete] crabbox admin delete --force crabbox ssh --id +crabbox vnc --id [--open] crabbox inspect --id [--json] crabbox stop crabbox cleanup [--dry-run] @@ -77,7 +78,9 @@ Warm a box, then reuse it: ```sh crabbox warmup --profile project-check +crabbox warmup --desktop --browser crabbox run --id blue-lobster -- pnpm test:changed +crabbox vnc --id blue-lobster --open crabbox run --id blue-lobster --shell 'pnpm install --frozen-lockfile && pnpm test' crabbox stop blue-lobster ``` @@ -228,6 +231,7 @@ Flags: --checksum use checksum rsync instead of size/time --debug print sync timing and itemized rsync output --junit comma-separated remote JUnit XML paths to attach to run history +--open open local VNC client for `crabbox vnc` --reclaim claim an existing lease for the current repo --timing-json print a final JSON timing record --blacksmith-org Blacksmith organization diff --git a/docs/commands/README.md b/docs/commands/README.md index 0a1cd05..0f1bb34 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -25,6 +25,7 @@ Command docs live here, one file per top-level command. Keep `docs/cli.md` as th - [admin](admin.md) - [actions](actions.md) - [ssh](ssh.md) +- [vnc](vnc.md) - [inspect](inspect.md) - [stop](stop.md) - [cleanup](cleanup.md) diff --git a/docs/commands/run.md b/docs/commands/run.md index bc71861..7918df9 100644 --- a/docs/commands/run.md +++ b/docs/commands/run.md @@ -6,11 +6,14 @@ crabbox run --id blue-lobster -- pnpm test:changed:max crabbox run --class beast -- pnpm check crabbox run --provider aws --class beast --market on-demand -- pnpm check +crabbox run --browser -- google-chrome --headless --version +crabbox run --desktop --browser --shell 'echo "$DISPLAY"; "$BROWSER" --version' crabbox run --id blue-lobster --shell 'pnpm install --frozen-lockfile && pnpm test' crabbox run --id cbx_abcdef123456 --junit junit.xml -- go test ./... crabbox run --provider blacksmith-testbox --blacksmith-workflow .github/workflows/ci-check-testbox.yml --blacksmith-job test -- pnpm test crabbox run --provider ssh --target macos --static-host mac-studio.local -- xcodebuild test -crabbox run --provider ssh --target windows --windows-mode normal --static-host win-dev.local -- pwsh -NoProfile -Command "dotnet test" +crabbox run --provider ssh --target windows --windows-mode normal --static-host win-dev.local -- dotnet test +crabbox run --provider ssh --target windows --windows-mode normal --static-host win-dev.local --shell 'Write-Output ("BROWSER=" + $env:BROWSER)' crabbox run --provider ssh --target windows --windows-mode wsl2 --static-host win-dev.local -- pnpm test ``` @@ -22,6 +25,17 @@ When the lease has been hydrated by `crabbox actions hydrate`, `run` reads the r If a configured Actions hydration workflow exists and a package-manager command such as `pnpm`, `npm`, `node`, or `corepack` is run before a hydration marker exists, Crabbox warns that the raw box may not have the project runtime installed. Hydrate first for CI-like setup, or include the runtime setup explicitly in the command. +`--browser` provisions or requires a known browser binary and injects +`CRABBOX_BROWSER=1`, `BROWSER`, and `CHROME_BIN` into the remote command. It +does not imply `--desktop`; use it alone for headless browser automation. +Browser login/profile state is not managed by Crabbox; use a scenario-owned +profile directory or app-specific auth artifact when tests need a logged-in +browser. + +`--desktop` provisions or requires a visible Linux display and injects +`CRABBOX_DESKTOP=1` plus `DISPLAY=:99`. It does not imply a browser. Use +`--desktop --browser` for headed browser automation in the VNC-visible session. + Sync uses `git ls-files --cached --others --exclude-standard` to build a file manifest, then feeds that manifest to rsync over SSH. That means tracked files plus nonignored untracked files sync, while `.git`, ignored local build output, dependency folders, and common caches stay out of the transfer. Crabbox records a local/remote sync fingerprint and skips rsync when the tracked commit plus manifest and dirty metadata have not changed. Use `--checksum` when you need a paranoid checksum scan, and `--debug` to print sync timing, progress, and itemized rsync output. For `provider=ssh`, `target=macos` and `target=windows windows.mode=wsl2` @@ -29,6 +43,10 @@ use the same POSIX rsync flow. Native Windows mode uses PowerShell over OpenSSH and sends the manifest as a tar archive into `static.workRoot`; cache purge and GitHub Actions runner registration remain Linux-only. +On native Windows, plain argv is best for one executable such as `dotnet test`. +Use `--shell` for multi-statement PowerShell snippets, env inspection, or +commands that need PowerShell expression syntax. + Before rsync starts, Crabbox prints the candidate file count and byte estimate. Large syncs warn or fail according to `sync.warnFiles`, `sync.warnBytes`, `sync.failFiles`, and `sync.failBytes`; use `--force-sync-large` or `sync.allowLarge: true` only when the transfer size is intentional. Quiet rsync runs print a heartbeat, and `sync.timeout` kills stalled syncs. At the end of every command, `run` prints a one-line summary with sync duration, command duration, total duration, whether sync was skipped by fingerprint, and the remote exit code. @@ -62,6 +80,8 @@ Flags: --market spot|on-demand --ttl --idle-timeout +--desktop +--browser --keep --no-sync --sync-only diff --git a/docs/commands/vnc.md b/docs/commands/vnc.md new file mode 100644 index 0000000..eb2a72c --- /dev/null +++ b/docs/commands/vnc.md @@ -0,0 +1,91 @@ +# vnc + +`crabbox vnc` prints a tunnel command and connection details for a +desktop-capable Crabbox lease or an explicitly configured static host. + +```sh +crabbox warmup --desktop +crabbox vnc --id blue-lobster +crabbox vnc --id blue-lobster --open +crabbox vnc --provider ssh --target macos --static-host mac-studio.local +``` + +The command resolves the lease like `crabbox ssh`, claims and touches it like +manual use, verifies that VNC is bound to runner loopback, and prints: + +```text +lease: cbx_... slug=blue-lobster provider=aws target=linux +managed: true +display: :99 +ssh tunnel: + ssh -i ... -p 2222 -N -L 5901:127.0.0.1:5900 crabbox@203.0.113.10 +vnc: + localhost:5901 +password: ... +Keep the tunnel process running while connected. +``` + +Run the tunnel command in another terminal, then connect your VNC client to the +printed `localhost:` endpoint. Managed Linux desktop leases use a +per-lease VNC password stored on the runner under `/var/lib/crabbox`; the +password is retrieved over SSH only when `vnc` is called. It is not stored in +provider labels or run history. + +Use `--open` to let Crabbox start the SSH tunnel, open the local VNC URL, and +print the tunnel process ID. Keep that tunnel process alive while connected. + +Static hosts are existing machines, not Crabbox-created boxes. For static +hosts, Crabbox first tries the same SSH tunnel to +`127.0.0.1:5900` on the target. If a static host exposes VNC directly on +`host:5900`, Crabbox prints that endpoint instead. Direct static VNC is +operator-managed and should be limited to a trusted network such as Tailscale or +LAN. + +Static host credentials are host-managed. On macOS, the built-in Screen Sharing +server uses the host's Screen Sharing or macOS account authentication. On +Windows, the prompt belongs to the installed VNC server. Crabbox does not print +or synthesize those passwords. + +`--open` refuses host-managed static VNC by default so a host OS password prompt +is not mistaken for a Crabbox-created box. Pass `--host-managed` only when you +intentionally want to open that existing host's VNC login prompt. + +Security boundary: + +- VNC is never exposed directly to the public internet. +- Managed Linux binds x11vnc to `127.0.0.1:5900` on the runner. +- Crabbox does not add provider firewall or security-group ingress for VNC. +- Brokered leases use SSH tunnels only. Static hosts may also use direct + operator-managed VNC when `host:5900` is already reachable. + +Provider behavior: + +- Brokered and direct AWS/Hetzner Linux leases support `vnc` only when created + with `--desktop`. +- Static Linux can participate if the operator already configured Xvfb and + loopback-bound x11vnc. +- Static macOS can participate when Screen Sharing or another VNC-compatible + service is already available on `127.0.0.1:5900` over SSH or directly on + `host:5900`. This reuses an existing Mac; it does not create a macOS Crabbox. + Credentials are host-managed. +- Static native Windows can participate when a VNC server is already available + on `127.0.0.1:5900` over SSH or directly on `host:5900`. Crabbox does not + create a Windows Crabbox, or install or configure the Windows VNC server. +- Blacksmith Testbox does not support managed VNC in this release. + +Flags: + +```text +--id +--provider hetzner|aws|ssh +--target linux|macos|windows +--windows-mode normal|wsl2 +--static-host +--static-user +--static-port +--static-work-root +--local-port +--open +--host-managed +--reclaim +``` diff --git a/docs/commands/warmup.md b/docs/commands/warmup.md index fdc8e09..4d28eff 100644 --- a/docs/commands/warmup.md +++ b/docs/commands/warmup.md @@ -5,9 +5,12 @@ ```sh crabbox warmup --class beast crabbox warmup --provider aws --class beast --market on-demand +crabbox warmup --browser +crabbox warmup --desktop --browser crabbox warmup --actions-runner crabbox warmup --provider blacksmith-testbox --blacksmith-workflow .github/workflows/ci-check-testbox.yml --blacksmith-job test crabbox warmup --provider ssh --target macos --static-host mac-studio.local +crabbox warmup --provider ssh --target windows --windows-mode normal --static-host win-dev.local --static-work-root 'C:\crabbox' --browser ``` The command returns a stable `cbx_...` lease ID and a friendly slug. Reuse either for subsequent `run`, `status`, `ssh`, `inspect`, and `stop` commands; scripts should keep using the canonical ID. @@ -17,7 +20,10 @@ With `--provider blacksmith-testbox`, the canonical ID is the Blacksmith `tbx_.. With `--provider ssh`, warmup claims an existing static SSH host instead of creating cloud capacity. Use `--target macos`, `--target windows --windows-mode normal`, or `--target windows --windows-mode wsl2` to select the -remote command/sync contract. +remote command/sync contract. Native Windows static hosts must already have +OpenSSH Server reachable, PowerShell, Git, `tar`, and a writable +`static.workRoot`. Restart `sshd` after installing Git so new SSH sessions see +the updated PATH. On success, `warmup` prints a concise total duration line. Add `--timing-json` to emit a final JSON timing record with provider, lease ID, slug, total duration, and exit code. @@ -37,6 +43,8 @@ Flags: --market spot|on-demand --ttl --idle-timeout +--desktop +--browser --keep --actions-runner --reclaim @@ -50,6 +58,15 @@ Flags: `--idle-timeout` releases the lease after no touch for that duration, default `30m`. `--ttl` remains the maximum wall-clock lifetime, default `90m`. Warmup records a local claim tying the lease to the current repo; `--reclaim` overwrites an existing local claim for that lease. +`--browser` provisions a known browser binary and records it in +`/var/lib/crabbox/browser.env`. It can be used without `--desktop` for headless +browser automation. Managed Linux tries Google Chrome stable first, then a +Chromium package fallback. + +`--desktop` provisions Xvfb, Openbox, and loopback-bound x11vnc for visible UI +automation and operator takeover. It does not imply a browser. Use +`--desktop --browser` when a headed browser should run in the visible display. + For AWS, `--market` overrides `capacity.market` for this lease. Use `--market on-demand` when Spot capacity is blocked or when a quota request was approved only for the standard On-Demand quota. Explicit `--type` still means diff --git a/docs/features/README.md b/docs/features/README.md index b2d9fec..ab93eca 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -42,6 +42,7 @@ Command docs: - [list](../commands/list.md) - [usage](../commands/usage.md) - [ssh](../commands/ssh.md) +- [vnc](../commands/vnc.md) - [inspect](../commands/inspect.md) - [stop](../commands/stop.md) - [actions](../commands/actions.md) diff --git a/docs/features/interactive-desktop-vnc.md b/docs/features/interactive-desktop-vnc.md index 89dc6a1..d1989b6 100644 --- a/docs/features/interactive-desktop-vnc.md +++ b/docs/features/interactive-desktop-vnc.md @@ -8,31 +8,52 @@ Read when: Interactive desktop support belongs in Crabbox. Crabbox owns machine lifecycle, network reachability, SSH keys, lease expiry, and provider-specific setup. -Scenario systems such as Mantis should ask for a desktop-capable lease and then -drive browser automation, screenshots, artifacts, and PR comments from inside -that lease. +Scenario systems such as Mantis should ask for the needed machine capability +and then drive browser automation, screenshots, artifacts, and PR comments from +inside that lease. The intended contract is: - `crabbox warmup --desktop` leases or reuses a Linux machine with the normal Crabbox SSH contract plus a desktop profile; +- `crabbox warmup --browser` leases or reuses a Linux machine with a known + browser binary for headless automation; +- `crabbox warmup --desktop --browser` combines a visible session with a browser + for headed automation; - `crabbox vnc --id ` prints a tunnel command and connection metadata for - operator takeover; + operator takeover, including `managed: true` for Crabbox-created desktops and + `managed: false` for static host services; - `crabbox run --id --desktop -- ` runs UI automation in the desktop session; +- `crabbox run --id --browser -- ` injects browser env + without requiring a desktop; - desktop services bind to loopback on the runner and are reachable through SSH tunnels only; - screenshots, traces, videos, and browser profiles remain regular command artifacts owned by the caller or repository workflow. +Login and browser profile state are caller-owned. `--browser` only guarantees a +browser binary and env such as `BROWSER` and `CHROME_BIN`; it does not create, +sync, unlock, or migrate a logged-in profile. On managed Linux, a manual login +through VNC persists only for that lease and disappears with the machine unless +the caller stores a profile artifact intentionally. On static macOS or Windows, +the target may already have a logged-in OS browser profile, but Crabbox does not +copy Keychain, DPAPI, cookies, or Chrome sync state across hosts or operating +systems. + +For repeatable logged-in tests, the scenario layer should create a named +profile or import app-specific auth state, for example a Playwright storage +state file, from the repository's normal secret flow. Avoid syncing full browser +profile directories between operating systems; browser credentials are often +machine- and user-encrypted. + Crabbox should provision the reusable machine capability: - Xvfb or a lightweight compositor/display manager; - a small window manager suitable for browser automation; -- Chromium or Chrome when the repository did not install one already; +- Chrome stable or a Chromium fallback when `--browser` is requested; - x11vnc or an equivalent VNC server bound to `127.0.0.1`; -- optional noVNC/websockify when browser-based takeover is needed; -- a persistent browser profile root under the lease work area. +- a per-lease VNC password retrieved over SSH by `crabbox vnc`. Crabbox should not own product-specific scenario logic: @@ -48,7 +69,7 @@ Security rules: - never expose VNC directly to the public internet; - prefer SSH local forwarding such as `localhost:5901 -> 127.0.0.1:5900`; -- generate per-lease VNC passwords only when a VNC server requires them; +- generate per-lease VNC passwords for managed Linux desktop leases; - redact passwords from logs and run records; - stop desktop services when the lease stops; - keep the normal TTL and idle-timeout lifecycle in force. @@ -59,11 +80,21 @@ Provider notes: controls cloud-init and firewall shape there. - Static SSH Linux hosts can participate when the operator accepts responsibility for packages and display services. +- Static macOS hosts are existing Macs, not Crabbox-created boxes. They can + participate when Screen Sharing or another + VNC-compatible service is already available on `127.0.0.1:5900` over SSH or + directly on `host:5900`. Credentials are host-managed because Apple Remote + Desktop authentication still belongs to the target host. +- Static Windows hosts are existing Windows machines, not Crabbox-created boxes. + They can participate only when the operator already provides a VNC-compatible + service on `127.0.0.1:5900` for SSH tunneling or, for trusted static networks, + directly on `host:5900`. Opening Windows requires `--host-managed` because the + password prompt belongs to the target OS, not Crabbox. - Blacksmith Testbox can run headless browser automation today, but VNC takeover needs a Blacksmith-supported SSH tunnel or connection-info API before Crabbox can offer the same `vnc` command there. -- macOS and Windows are static-host concerns, not first-pass Crabbox desktop - provisioning. +- Crabbox-managed macOS and Windows VNC installers are still out of scope for + this release. For Mantis, the first consumer should be a Discord QA lane: diff --git a/docs/features/runner-bootstrap.md b/docs/features/runner-bootstrap.md index b26660b..622130d 100644 --- a/docs/features/runner-bootstrap.md +++ b/docs/features/runner-bootstrap.md @@ -38,6 +38,10 @@ operator-managed: - native Windows targets need OpenSSH, PowerShell, `git`, and `tar`; - `static.workRoot` must point at a writable directory for that target mode. +For native Windows, install Git before the Crabbox check or restart OpenSSH +Server afterward so new non-interactive SSH sessions inherit Git and `tar` on +PATH. + The CLI prefers the configured SSH port and can fall back through `ssh.fallbackPorts` during early bootstrap or operator-network egress restrictions. Set `ssh.fallbackPorts: []` or `CRABBOX_SSH_FALLBACK_PORTS=none` when the fallback should be disabled. Long term, snapshots or provider images can replace slow cloud-init once the bootstrap contract is stable. Related docs: diff --git a/docs/plan/vnc.md b/docs/plan/vnc.md new file mode 100644 index 0000000..d05dd89 --- /dev/null +++ b/docs/plan/vnc.md @@ -0,0 +1,480 @@ +# Interactive Desktop, VNC, And Browser Plan + +Read when: + +- implementing `--desktop`, `--browser`, or `crabbox vnc`; +- changing Linux UI bootstrap or browser provisioning; +- deciding how static macOS/Windows hosts participate in interactive QA; +- reviewing the security boundary for desktop takeover. + +## Goal + +Implement the first real Crabbox interactive-desktop vertical slice so +Mantis/OpenClaw can request a UI-capable machine, run browser automation in a +visible session, and let Peter take over through a tunnel. + +Crabbox owns machine capability: + +- lease lifecycle, TTL, idle touch, cleanup, and claims; +- provider-specific bootstrap and SSH connection details; +- desktop services, browser installation/probing, and connection metadata; +- tunnel-only VNC instructions. + +Mantis/OpenClaw own scenario logic: + +- Discord or app credentials; +- browser profiles, Playwright/Selenium scripts, assertions, screenshots, and + videos; +- PR comments, artifacts, and pass/fail reporting. + +## Capability Flags + +Use two explicit capability flags: + +```sh +crabbox warmup --desktop +crabbox warmup --desktop --browser +crabbox run --desktop --browser -- +``` + +`--desktop` means the lease should expose a visible UI session and takeover +path. On managed Linux this provisions desktop/VNC services. On static targets +it probes existing operator-managed services. + +`--browser` means the target should have a known browser binary for automation. +It is separate because browser installation is heavier and more provider/OS +specific than a basic display session. + +For `run`, `--browser` never implies `--desktop`. It supports headless browser +automation on a machine with a known browser binary. Use `--desktop --browser` +only when the browser should run in the visible VNC session. + +Store both capabilities on leases: + +```json +{ + "desktop": true, + "browser": true +} +``` + +Provider labels/tags should include: + +```text +desktop=true +browser=true +``` + +## CLI Surface + +Add: + +```sh +crabbox warmup --desktop [--browser] +crabbox run --desktop [--browser] -- +crabbox vnc --id +``` + +`crabbox vnc` should resolve a lease like `crabbox ssh`, claim/touch it like +manual use, and print a concise connection block: + +```text +lease: cbx_... slug=blue-lobster provider=aws target=linux +display: :99 +ssh tunnel: + ssh -i ... -p 2222 -N -L 5901:127.0.0.1:5900 crabbox@203.0.113.10 +vnc: + localhost:5901 + +Keep the tunnel process running while connected. +``` + +JSON output can come later. Text output is enough for v0. + +If noVNC is implemented later, extend the block with a local browser URL. Do +not implement public noVNC in this slice. + +## Security Boundary + +Hard requirements: + +- never expose VNC/noVNC to the public internet; +- bind runner-side VNC to `127.0.0.1`; +- do not add provider firewall/security-group ingress for VNC; +- print SSH tunnel commands only; +- do not put VNC passwords in command-line arguments, provider labels, run + history, or logs; +- keep TTL and idle-timeout behavior unchanged; +- cleanup remains VM deletion or static-host no-op, as today. + +For Linux v0, use loopback-bound x11vnc with a per-lease password: + +```sh +x11vnc -display :99 -localhost -rfbport 5900 -forever -shared -rfbauth /var/lib/crabbox/vnc.pass +``` + +Generate a per-lease remote password file, do not log it, and have +`crabbox vnc` retrieve and print it only when needed. + +## Managed Linux Bootstrap + +Default bootstrap must remain tiny. Desktop/browser packages are installed only +when requested. + +### `--desktop` + +Install the smallest useful visible-session stack: + +```text +xvfb +openbox +x11vnc +xauth +dbus-x11 +fonts-dejavu +fonts-liberation +ca-certificates +``` + +Use systemd units so the desktop survives command boundaries on kept leases: + +- `crabbox-xvfb.service` +- `crabbox-openbox.service` +- `crabbox-x11vnc.service` + +Suggested unit behavior: + +```text +crabbox-xvfb: + Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp -ac + +crabbox-openbox: + DISPLAY=:99 openbox + +crabbox-x11vnc: + x11vnc -display :99 -localhost -rfbport 5900 -forever -shared -nopw +``` + +`crabbox-ready` should check desktop readiness only when `desktop=true`: + +```sh +systemctl is-active --quiet crabbox-xvfb.service +systemctl is-active --quiet crabbox-openbox.service +systemctl is-active --quiet crabbox-x11vnc.service +ss -ltn | grep -q '127.0.0.1:5900' +``` + +Normal non-desktop leases must not run these checks. + +### `--browser` + +Browser support should be opt-in. + +For managed Linux, install Chrome stable if feasible and fall back to Chromium +when the distro package path is available. Prefer Chrome stable over Ubuntu +`chromium-browser` because Ubuntu Chromium commonly routes through Snap, which +is awkward in minimal cloud images, but a verified Chromium fallback is +acceptable. + +Preferred managed Linux path: + +1. install Google signing key into `/etc/apt/keyrings`; +2. add the Chrome apt source; +3. install `google-chrome-stable`; +4. write a small metadata file with the discovered browser path. + +Example metadata: + +```text +/var/lib/crabbox/browser.env +``` + +Content: + +```sh +CHROME_BIN=/usr/bin/google-chrome +BROWSER=/usr/bin/google-chrome +``` + +`crabbox-ready` should check the browser only when `browser=true`: + +```sh +test -x /usr/bin/google-chrome +/usr/bin/google-chrome --version +``` + +## Runtime Environment + +When `run --desktop` executes on a Linux desktop-capable target, inject: + +```sh +DISPLAY=:99 +CRABBOX_DESKTOP=1 +``` + +When `run --desktop --browser` knows a browser path, also inject: + +```sh +CRABBOX_BROWSER=1 +CHROME_BIN=/usr/bin/google-chrome +BROWSER=/usr/bin/google-chrome +``` + +This should merge with the existing allowed-env and Actions env-file behavior. +Do not leak secrets; these values are static machine metadata. + +If `--desktop` is requested against an existing lease that was not provisioned +with `desktop=true`, fail clearly before running: + +```text +lease cbx_... was not created with desktop=true; warm a new lease with --desktop +``` + +Static Linux can instead probe services and proceed if they are already present. + +## Provider Behavior + +### Brokered AWS/Hetzner + +Support both `--desktop` and `--browser`. + +Flow: + +1. CLI sends `desktop` and `browser` in the lease request. +2. Worker validates Linux-only target as today. +3. Worker stores both booleans on `LeaseRecord`. +4. Worker labels/tags cloud machines with `desktop` and `browser`. +5. Worker cloud-init appends optional desktop/browser bootstrap blocks. +6. CLI receives the booleans back from `CoordinatorLease`. +7. `run` and `vnc` enforce/probe the capability before use. + +Do not change AWS security group ingress. SSH remains the only public ingress. + +### Direct AWS/Hetzner + +Support both `--desktop` and `--browser` with the same optional cloud-init path +as the Worker. + +Direct labels should include the booleans so `findLease` can detect whether an +existing lease is desktop/browser-capable. + +### Static Linux + +Support `crabbox vnc` if services already exist. Do not install packages on +static hosts in v0. + +Probe: + +```sh +test "${DISPLAY:-:99}" = ":99" || true +pgrep -f 'Xvfb :99' +pgrep -f x11vnc +ss -ltn | grep -q '127.0.0.1:5900' +``` + +For browser: + +```sh +command -v google-chrome || command -v chromium || command -v chromium-browser +``` + +If missing, fail with clear operator instructions. + +### Static macOS + +Do not install or enable services in v0. + +Support browser probing: + +```sh +/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome --version +``` + +For takeover, macOS Screen Sharing uses VNC-compatible port `5900`, but enabling +it requires administrator configuration. `crabbox vnc` can print a tunnel only +if port `127.0.0.1:5900` or `localhost:5900` is reachable on the host. + +If not reachable: + +```text +target=macos does not expose a localhost VNC service; enable Screen Sharing or use a preconfigured VNC server +``` + +### Static Windows + +Do not install or enable services in v0. + +Support browser probing for common paths or `where`: + +```powershell +where chrome.exe +where msedge.exe +``` + +Windows native takeover is RDP, not VNC. For v0, `crabbox vnc` should fail +unless a VNC server is already bound to loopback and reachable through SSH. + +Clear failure: + +```text +target=windows does not support managed VNC in v0; configure a loopback VNC server or use an OS-native remote desktop path +``` + +Do not open firewall rules or install a VNC server automatically. + +### Blacksmith Testbox + +`--desktop` and `crabbox vnc` are unsupported until Blacksmith exposes a stable +tunnel/connection API. + +Headless browser automation can remain possible through Blacksmith-owned +workflow setup, but Crabbox should fail clearly for desktop takeover: + +```text +desktop/VNC is not supported for provider=blacksmith-testbox; Blacksmith owns machine connectivity +``` + +## Implementation Files + +CLI: + +- `internal/cli/app.go`: route `vnc`, top-level help. +- `internal/cli/config.go`: `Desktop`, `Browser`, YAML/env parsing. +- `internal/cli/run.go`: `--desktop`, `--browser`, lease acquisition, existing + lease enforcement, run env injection. +- `internal/cli/bootstrap.go`: optional desktop/browser cloud-init blocks. +- `internal/cli/coordinator.go`: request/response structs and lease conversion. +- `internal/cli/provider_labels.go`: direct provider labels. +- `internal/cli/static.go`: static target probe behavior. +- `internal/cli/ssh_cmd.go`: reuse patterns for claim/touch. +- `internal/cli/vnc.go`: new command. +- `internal/cli/target.go`: provider/target validation helpers. + +Worker: + +- `worker/src/types.ts`: `desktop`, `browser` on request/record. +- `worker/src/config.ts`: config coercion/defaults. +- `worker/src/bootstrap.ts`: optional desktop/browser bootstrap. +- `worker/src/provider-labels.ts`: cloud labels. +- `worker/src/fleet.ts`: persist booleans and return them in leases. + +Docs: + +- `docs/features/interactive-desktop-vnc.md` +- `docs/features/runner-bootstrap.md` +- `docs/commands/warmup.md` +- `docs/commands/run.md` +- `docs/commands/vnc.md` +- `docs/commands/README.md` +- `docs/features/README.md` +- `README.md` +- `docs/source-map.md` + +## Tests + +Go tests: + +- `cloudInit(baseConfig())` does not include desktop/browser packages or units. +- `cloudInit(Config{Desktop:true})` includes desktop packages, units, and + desktop readiness checks. +- `cloudInit(Config{Desktop:true, Browser:true})` includes Chrome setup and + browser readiness checks. +- `--desktop` and `--browser` parse for `warmup` and `run`. +- `run --desktop` injects `DISPLAY=:99` and `CRABBOX_DESKTOP=1`. +- `run --desktop --browser` injects `CHROME_BIN`, `BROWSER`, and + `CRABBOX_BROWSER=1` when metadata exists or managed Linux defaults apply. +- `crabbox vnc --id ` prints SSH tunnel, VNC endpoint, display, and + tunnel warning. +- `crabbox vnc` rejects Blacksmith and unsupported static macOS/Windows cases + with clear messages. +- Existing `warmup` and `run` tests confirm default behavior remains unchanged. + +Worker tests: + +- `leaseConfig` defaults `desktop=false`, `browser=false`. +- `leaseConfig({ desktop:true, browser:true })` preserves both. +- Worker cloud-init excludes desktop/browser blocks by default. +- Worker cloud-init includes desktop/browser blocks only when requested. +- Fleet create response stores `desktop` and `browser`. +- Provider labels include `desktop=true` and `browser=true` only when requested + or include explicit false values if label consistency is preferred. + +Docs tests: + +- `npm run docs:check` must pass after adding `docs/commands/vnc.md`. + +## Gates + +Focused during implementation: + +```sh +go test ./internal/cli +npm test --prefix worker -- bootstrap config provider-labels fleet +npm run docs:check +``` + +Before handoff: + +```sh +gofmt -w $(git ls-files '*.go') +go vet ./... +go test -race ./... +scripts/check-go-coverage.sh 85.0 +npm run check +npm run docs:check +npm run format:check --prefix worker +npm run lint --prefix worker +npm run check --prefix worker +npm test --prefix worker +npm run build --prefix worker +git diff --check +``` + +Live proof: + +```sh +go build -trimpath -o bin/crabbox ./cmd/crabbox +bin/crabbox warmup --provider aws --type t3.small --desktop --browser --ttl 20m --idle-timeout 5m +bin/crabbox run --id --desktop --browser -- google-chrome --version +bin/crabbox run --id --desktop --browser --shell 'echo "$DISPLAY"; echo "$CHROME_BIN"' +bin/crabbox vnc --id +bin/crabbox stop +bin/crabbox admin leases --state active --json +``` + +For the first live run, also verify over SSH that VNC is loopback-bound: + +```sh +ss -ltn | grep 5900 +``` + +Expected remote bind: + +```text +127.0.0.1:5900 +``` + +## Acceptance Criteria + +1. Existing `warmup` and `run` behavior is unchanged without `--desktop` or + `--browser`. +2. `warmup --desktop` requests and provisions a Linux lease with desktop + bootstrap. +3. `warmup --desktop --browser` additionally provisions a known browser binary. +4. `run --desktop --browser -- ` runs with `DISPLAY=:99` and browser env. +5. `crabbox vnc --id ` prints a usable SSH tunnel command and endpoint. +6. VNC is never exposed publicly; no provider firewall ingress is added. +7. Static Linux can participate if services already exist. +8. Static macOS/Windows fail clearly when VNC/browser prerequisites are missing. +9. Blacksmith desktop/VNC fails clearly. +10. Docs and tests are updated. +11. The repo is clean except for intentional commits. + +## Deferred + +- noVNC/websockify. +- Automatic static macOS Screen Sharing enablement. +- Automatic Windows VNC/RDP service installation. +- Browser profile lifecycle management. +- Scenario screenshots, videos, assertions, and PR comments. +- Blacksmith Testbox desktop integration. diff --git a/docs/source-map.md b/docs/source-map.md index cb4c4e7..d1e1dce 100644 --- a/docs/source-map.md +++ b/docs/source-map.md @@ -39,9 +39,16 @@ This page maps user-facing behavior back to implementation files. Keep docs desc - Worker AWS AMI create/read/promote routes: `worker/src/fleet.ts`, `worker/src/aws.ts` - CLI cloud-init bootstrap: `internal/cli/bootstrap.go` - Worker cloud-init bootstrap: `worker/src/bootstrap.ts` -- Planned interactive desktop/VNC contract: `docs/features/interactive-desktop-vnc.md` +- Desktop/browser capability flags, env injection, and VNC checks: `internal/cli/capabilities.go`, `internal/cli/run.go` +- VNC tunnel command: `internal/cli/vnc.go` +- Interactive desktop/VNC contract: `docs/features/interactive-desktop-vnc.md` -Bootstrap is intentionally tiny: OpenSSH, CA certificates, curl, Git, rsync, jq, `/work/crabbox`, cache directories, and `crabbox-ready`. Project runtimes such as Go, Node, pnpm, Docker, databases, and services are repository-owned setup, usually through Actions hydration or repo scripts. +Bootstrap is intentionally tiny unless optional lease capabilities are requested: +OpenSSH, CA certificates, curl, Git, rsync, jq, `/work/crabbox`, cache +directories, and `crabbox-ready`. `--desktop` adds Xvfb/Openbox/x11vnc and +loopback VNC. `--browser` adds Chrome stable or a Chromium fallback. Project +runtimes such as Go, Node, pnpm, Docker, databases, and services are +repository-owned setup, usually through Actions hydration or repo scripts. ## Sync, Execution, Actions, Cache, And Results diff --git a/internal/cli/app.go b/internal/cli/app.go index 1ffdc9d..164af70 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -90,6 +90,8 @@ func (a App) Run(ctx context.Context, args []string) error { return a.status(ctx, args[1:]) case "ssh": return a.ssh(ctx, args[1:]) + case "vnc": + return a.vnc(ctx, args[1:]) case "inspect": return a.inspect(ctx, args[1:]) case "stop", "release": @@ -117,6 +119,8 @@ Start Here: Lease a reusable box and print a cbx_... id plus friendly slug. crabbox run --id blue-lobster -- pnpm test:changed Sync this checkout to the box and run a command. + crabbox warmup --desktop --browser + Lease a UI-capable box with a browser. Commands: init Onboard the current repo for Crabbox @@ -140,6 +144,7 @@ Commands: admin Lease admin controls for trusted operators actions Register GitHub Actions runners or dispatch workflows ssh Print the SSH command for a lease + vnc Print or open VNC connection details for a desktop lease inspect Print lease/provider details; add --json for scripts stop Release a lease or delete a direct-provider machine cleanup Sweep expired direct-provider machines @@ -151,6 +156,7 @@ Common Flows: crabbox status --id blue-lobster --wait crabbox run --id blue-lobster --shell 'pnpm install --frozen-lockfile && pnpm test' crabbox ssh --id blue-lobster + crabbox vnc --id blue-lobster --open crabbox inspect --id blue-lobster --json crabbox history --lease cbx_abcdef123456 crabbox logs run_123 @@ -189,6 +195,8 @@ Environment: CRABBOX_PROVIDER hetzner, aws, ssh, or blacksmith-testbox CRABBOX_TARGET linux, macos, or windows CRABBOX_WINDOWS_MODE normal or wsl2 + CRABBOX_DESKTOP Provision or require desktop/VNC capability + CRABBOX_BROWSER Provision or require browser capability CRABBOX_STATIC_HOST Static SSH host for provider=ssh CRABBOX_OWNER Usage owner override CRABBOX_ORG Usage org override diff --git a/internal/cli/bootstrap.go b/internal/cli/bootstrap.go index 8337447..9e97790 100644 --- a/internal/cli/bootstrap.go +++ b/internal/cli/bootstrap.go @@ -1,12 +1,18 @@ package cli -import "fmt" +import ( + "fmt" + "strings" +) func cloudInit(cfg Config, publicKey string) string { portLines := "" for _, port := range sshPortCandidates(cfg.SSHPort, cfg.SSHFallbackPorts) { portLines += fmt.Sprintf(" Port %s\n", port) } + readyChecks := cloudInitOptionalReadyChecks(cfg) + writeFiles := cloudInitOptionalWriteFiles(cfg) + bootstrap := cloudInitOptionalBootstrap(cfg) return fmt.Sprintf(`#cloud-config package_update: false package_upgrade: false @@ -34,6 +40,8 @@ write_files: jq --version >/dev/null test -f /var/lib/crabbox/bootstrapped test -w %[3]s +%[5]s +%[6]s runcmd: - | bash -euxo pipefail <<'BOOT' @@ -55,6 +63,7 @@ runcmd: } retry apt-get update retry apt-get install -y --no-install-recommends openssh-server ca-certificates curl git rsync jq +%[7]s mkdir -p %[3]s /var/cache/crabbox/pnpm /var/cache/crabbox/npm chown -R %[1]s:%[1]s %[3]s /var/cache/crabbox install -d /var/lib/crabbox @@ -63,5 +72,149 @@ runcmd: systemctl restart ssh crabbox-ready BOOT -`, cfg.SSHUser, publicKey, cfg.WorkRoot, portLines) +`, cfg.SSHUser, publicKey, cfg.WorkRoot, portLines, readyChecks, writeFiles, bootstrap) +} + +func cloudInitOptionalReadyChecks(cfg Config) string { + var b strings.Builder + if cfg.Desktop { + b.WriteString(" systemctl is-active --quiet crabbox-xvfb.service\n") + b.WriteString(" systemctl is-active --quiet crabbox-desktop.service\n") + b.WriteString(" systemctl is-active --quiet crabbox-desktop-session.service\n") + b.WriteString(" systemctl is-active --quiet crabbox-x11vnc.service\n") + b.WriteString(" ss -ltn | grep -q '127.0.0.1:5900'\n") + } + if cfg.Browser { + b.WriteString(" test -s /var/lib/crabbox/browser.env\n") + b.WriteString(" . /var/lib/crabbox/browser.env\n") + b.WriteString(" test -x \"$BROWSER\"\n") + b.WriteString(" \"$BROWSER\" --version >/dev/null\n") + } + return strings.TrimRight(b.String(), "\n") +} + +func cloudInitOptionalWriteFiles(cfg Config) string { + if !cfg.Desktop { + return "" + } + return ` - path: /etc/systemd/system/crabbox-xvfb.service + permissions: '0644' + content: | + [Unit] + Description=Crabbox Xvfb display + After=network.target + + [Service] + User=crabbox + ExecStart=/usr/bin/Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp -ac + Restart=always + + [Install] + WantedBy=multi-user.target + - path: /etc/systemd/system/crabbox-desktop.service + permissions: '0644' + content: | + [Unit] + Description=Crabbox XFCE desktop session + After=crabbox-xvfb.service + Requires=crabbox-xvfb.service + + [Service] + User=crabbox + Environment=DISPLAY=:99 + ExecStart=/usr/bin/startxfce4 + Restart=always + + [Install] + WantedBy=multi-user.target + - path: /usr/local/bin/crabbox-desktop-session + permissions: '0755' + content: | + #!/bin/sh + set -eu + export DISPLAY="${DISPLAY:-:99}" + if command -v xsetroot >/dev/null 2>&1; then + xsetroot -solid '#20242b' || true + fi + if command -v xterm >/dev/null 2>&1 && ! pgrep -u "$(id -u)" -f 'xterm -title Crabbox Desktop' >/dev/null 2>&1; then + xterm -title 'Crabbox Desktop' -geometry 110x32+48+48 -bg '#111827' -fg '#e5e7eb' & + fi + tail -f /dev/null + - path: /etc/systemd/system/crabbox-desktop-session.service + permissions: '0644' + content: | + [Unit] + Description=Crabbox visible desktop helper + After=crabbox-desktop.service + Requires=crabbox-xvfb.service crabbox-desktop.service + + [Service] + User=crabbox + Environment=DISPLAY=:99 + ExecStart=/usr/local/bin/crabbox-desktop-session + Restart=always + + [Install] + WantedBy=multi-user.target + - path: /etc/systemd/system/crabbox-x11vnc.service + permissions: '0644' + content: | + [Unit] + Description=Crabbox loopback VNC server + After=crabbox-xvfb.service + Requires=crabbox-xvfb.service + + [Service] + User=crabbox + ExecStart=/usr/bin/x11vnc -display :99 -localhost -rfbport 5900 -forever -shared -rfbauth /var/lib/crabbox/vnc.pass + Restart=always + + [Install] + WantedBy=multi-user.target +` +} + +func cloudInitOptionalBootstrap(cfg Config) string { + var parts []string + if cfg.Desktop { + parts = append(parts, ` retry apt-get install -y --no-install-recommends xvfb xfce4 xfce4-terminal x11vnc xauth dbus-x11 x11-xserver-utils xterm fonts-dejavu-core fonts-liberation iproute2 openssl + install -d -m 0750 -o crabbox -g crabbox /var/lib/crabbox + if [ ! -s /var/lib/crabbox/vnc.password ]; then + (umask 077 && openssl rand -base64 18 > /var/lib/crabbox/vnc.password) + fi + x11vnc -storepasswd "$(cat /var/lib/crabbox/vnc.password)" /var/lib/crabbox/vnc.pass >/dev/null + chown crabbox:crabbox /var/lib/crabbox/vnc.password /var/lib/crabbox/vnc.pass + chmod 0600 /var/lib/crabbox/vnc.password /var/lib/crabbox/vnc.pass + systemctl daemon-reload + systemctl enable --now crabbox-xvfb.service crabbox-desktop.service crabbox-desktop-session.service crabbox-x11vnc.service`) + } + if cfg.Browser { + parts = append(parts, ` retry apt-get install -y --no-install-recommends gnupg + browser_path="" + if [ "$(dpkg --print-architecture)" = "amd64" ]; then + install -d -m 0755 /etc/apt/trusted.gpg.d + curl -fsSL https://dl.google.com/linux/linux_signing_key.pub > /etc/apt/trusted.gpg.d/google.asc + chmod 0644 /etc/apt/trusted.gpg.d/google.asc + echo "deb [arch=amd64] https://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list + if apt-get update && retry apt-get install -y --no-install-recommends google-chrome-stable; then + browser_path="$(command -v google-chrome || true)" + else + rm -f /etc/apt/sources.list.d/google-chrome.list + retry apt-get update || true + fi + fi + if [ -z "$browser_path" ]; then + if apt-cache show chromium >/dev/null 2>&1 && retry apt-get install -y --no-install-recommends chromium; then + browser_path="$(command -v chromium || true)" + elif apt-cache show chromium-browser >/dev/null 2>&1 && retry apt-get install -y --no-install-recommends chromium-browser; then + browser_path="$(command -v chromium-browser || true)" + fi + fi + if [ -n "$browser_path" ]; then + printf 'CHROME_BIN=%s\nBROWSER=%s\n' "$browser_path" "$browser_path" > /var/lib/crabbox/browser.env + chown crabbox:crabbox /var/lib/crabbox/browser.env + chmod 0644 /var/lib/crabbox/browser.env + fi`) + } + return strings.Join(parts, "\n") } diff --git a/internal/cli/bootstrap_test.go b/internal/cli/bootstrap_test.go index 5fea89e..e49a901 100644 --- a/internal/cli/bootstrap_test.go +++ b/internal/cli/bootstrap_test.go @@ -32,3 +32,52 @@ func TestCloudInitUsesRetryingBootstrap(t *testing.T) { } } } + +func TestCloudInitDesktopProfile(t *testing.T) { + cfg := baseConfig() + cfg.Desktop = true + got := cloudInit(cfg, "ssh-ed25519 test") + for _, want := range []string{ + "xvfb xfce4 xfce4-terminal x11vnc xauth dbus-x11", + "x11-xserver-utils xterm", + "/etc/systemd/system/crabbox-xvfb.service", + "/etc/systemd/system/crabbox-desktop.service", + "/usr/local/bin/crabbox-desktop-session", + "/etc/systemd/system/crabbox-desktop-session.service", + "/etc/systemd/system/crabbox-x11vnc.service", + "ExecStart=/usr/bin/startxfce4", + "systemctl is-active --quiet crabbox-desktop.service", + "systemctl is-active --quiet crabbox-desktop-session.service", + "xsetroot -solid '#20242b'", + "xterm -title 'Crabbox Desktop'", + "(umask 077 && openssl rand -base64 18 > /var/lib/crabbox/vnc.password)", + "x11vnc -storepasswd", + "-rfbauth /var/lib/crabbox/vnc.pass", + "ss -ltn | grep -q '127.0.0.1:5900'", + } { + if !strings.Contains(got, want) { + t.Fatalf("cloudInit(desktop) missing %q", want) + } + } +} + +func TestCloudInitBrowserProfile(t *testing.T) { + cfg := baseConfig() + cfg.Browser = true + got := cloudInit(cfg, "ssh-ed25519 test") + for _, want := range []string{ + "https://dl.google.com/linux/linux_signing_key.pub", + "chmod 0644 /etc/apt/trusted.gpg.d/google.asc", + "https://dl.google.com/linux/chrome/deb/", + "google-chrome-stable", + "apt-cache show chromium", + "apt-cache show chromium-browser", + "/var/lib/crabbox/browser.env", + "test -x \"$BROWSER\"", + "\"$BROWSER\" --version >/dev/null", + } { + if !strings.Contains(got, want) { + t.Fatalf("cloudInit(browser) missing %q", want) + } + } +} diff --git a/internal/cli/capabilities.go b/internal/cli/capabilities.go new file mode 100644 index 0000000..aecbf42 --- /dev/null +++ b/internal/cli/capabilities.go @@ -0,0 +1,251 @@ +package cli + +import ( + "context" + "fmt" + "net" + "strings" + "time" +) + +const ( + desktopDisplay = ":99" + managedVNCPort = "5900" + vncPasswordPath = "/var/lib/crabbox/vnc.password" + browserEnvPath = "/var/lib/crabbox/browser.env" +) + +type vncEndpoint struct { + Direct bool + Host string + Port string + Managed bool +} + +func applyCapabilityFlags(cfg *Config, desktop, browser bool) { + cfg.Desktop = desktop + cfg.Browser = browser +} + +func validateRequestedCapabilities(cfg Config) error { + if cfg.Desktop && isBlacksmithProvider(cfg.Provider) { + return exit(2, "desktop/VNC is not supported for provider=%s; Blacksmith owns machine connectivity", cfg.Provider) + } + if cfg.Browser && isBlacksmithProvider(cfg.Provider) { + return exit(2, "browser provisioning is not supported for provider=%s; use Blacksmith workflow setup for headless browser automation", cfg.Provider) + } + return nil +} + +func enforceManagedLeaseCapabilities(cfg Config, server Server, leaseID string) error { + if isStaticProvider(cfg.Provider) || server.Provider == staticProvider { + return nil + } + if cfg.Desktop && !labelBool(server.Labels["desktop"]) { + return exit(2, "lease %s was not created with desktop=true; warm a new lease with --desktop", leaseID) + } + if cfg.Browser && !labelBool(server.Labels["browser"]) { + return exit(2, "lease %s was not created with browser=true; warm a new lease with --browser", leaseID) + } + return nil +} + +func labelBool(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +func requestedCapabilityEnv(ctx context.Context, cfg Config, target SSHTarget) (map[string]string, error) { + env := map[string]string{} + if cfg.Desktop { + if isStaticProvider(cfg.Provider) { + if err := ensureStaticDesktop(ctx, cfg, target); err != nil { + return nil, err + } + } + env["DISPLAY"] = desktopDisplay + env["CRABBOX_DESKTOP"] = "1" + } + if cfg.Browser { + browserEnv, err := probeBrowserEnv(ctx, cfg, target) + if err != nil { + return nil, err + } + env["CRABBOX_BROWSER"] = "1" + for key, value := range browserEnv { + env[key] = value + } + } + return env, nil +} + +func mergeEnv(base map[string]string, extra map[string]string) map[string]string { + if len(extra) == 0 { + return base + } + out := make(map[string]string, len(base)+len(extra)) + for key, value := range base { + out[key] = value + } + for key, value := range extra { + out[key] = value + } + return out +} + +func ensureStaticDesktop(ctx context.Context, _ Config, target SSHTarget) error { + return probeStaticDesktop(ctx, target) +} + +func probeStaticDesktop(ctx context.Context, target SSHTarget) error { + if isWindowsNativeTarget(target) { + if err := probeLoopbackVNC(ctx, target, "10", "3"); err != nil { + return exit(2, "target=windows does not expose a localhost VNC service; install a VNC server bound to 127.0.0.1:5900 or expose static VNC on host:5900") + } + return nil + } + if target.TargetOS == targetMacOS { + if err := probeLoopbackVNC(ctx, target, "10", "3"); err != nil { + return exit(2, "target=macos does not expose a localhost VNC service; enable Screen Sharing or use a preconfigured VNC server") + } + return nil + } + check := "pgrep -f 'Xvfb :99' >/dev/null && pgrep -f x11vnc >/dev/null && " + vncLoopbackCheckCommand(target) + if err := runSSHQuiet(ctx, target, check); err != nil { + return exit(2, "target=linux does not expose a loopback VNC desktop; start Xvfb :99 and x11vnc on 127.0.0.1:5900") + } + return nil +} + +func probeBrowserEnv(ctx context.Context, cfg Config, target SSHTarget) (map[string]string, error) { + var script string + if isWindowsNativeTarget(target) { + script = powershellCommand(`$ErrorActionPreference = "SilentlyContinue" +$paths = @() +$cmd = Get-Command chrome.exe -ErrorAction SilentlyContinue +if ($cmd) { $paths += $cmd.Source } +$cmd = Get-Command msedge.exe -ErrorAction SilentlyContinue +if ($cmd) { $paths += $cmd.Source } +$paths += @( + "$Env:ProgramFiles\Google\Chrome\Application\chrome.exe", + "${Env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe", + "$Env:ProgramFiles\Microsoft\Edge\Application\msedge.exe", + "${Env:ProgramFiles(x86)}\Microsoft\Edge\Application\msedge.exe" +) +$path = $paths | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -First 1 +if (-not $path) { exit 1 } +Write-Output ("BROWSER=" + $path) +Write-Output ("CHROME_BIN=" + $path)`) + } else if cfg.TargetOS == targetMacOS || target.TargetOS == targetMacOS { + script = `path="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; test -x "$path" || exit 1; printf 'BROWSER=%s\nCHROME_BIN=%s\n' "$path" "$path"` + } else { + script = `if [ -f ` + shellQuote(browserEnvPath) + ` ]; then . ` + shellQuote(browserEnvPath) + `; fi +for candidate in "${BROWSER:-}" "${CHROME_BIN:-}" google-chrome chromium chromium-browser; do + [ -n "$candidate" ] || continue + if [ -x "$candidate" ]; then path="$candidate"; break; fi + if path="$(command -v "$candidate" 2>/dev/null)"; then break; fi +done +[ -n "${path:-}" ] || exit 1 +"$path" --version >/dev/null +printf 'BROWSER=%s\nCHROME_BIN=%s\n' "$path" "$path"` + } + out, err := runSSHOutput(ctx, target, script) + if err != nil { + return nil, exit(2, "browser=true requested but no supported browser was found on target") + } + env := parseEnvLines(out) + if env["BROWSER"] == "" { + return nil, exit(2, "browser=true requested but target did not report BROWSER") + } + if env["CHROME_BIN"] == "" { + env["CHROME_BIN"] = env["BROWSER"] + } + return env, nil +} + +func parseEnvLines(input string) map[string]string { + env := map[string]string{} + for _, line := range strings.Split(input, "\n") { + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + if key == "" { + continue + } + env[key] = strings.TrimSpace(value) + } + return env +} + +func availableLocalVNCPort() string { + for port := 5901; port <= 5999; port++ { + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + continue + } + _ = ln.Close() + return fmt.Sprint(port) + } + return "5901" +} + +func resolveVNCEndpoint(ctx context.Context, cfg Config, target SSHTarget) (vncEndpoint, error) { + if isStaticProvider(cfg.Provider) { + if err := probeLoopbackVNC(ctx, target, "2", "1"); err == nil { + return vncEndpoint{Host: "127.0.0.1", Port: managedVNCPort}, nil + } + if tcpReachable(ctx, target.Host, managedVNCPort, 2*time.Second) { + return vncEndpoint{Direct: true, Host: target.Host, Port: managedVNCPort}, nil + } + return vncEndpoint{}, exit(5, "target does not expose VNC through SSH loopback 127.0.0.1:5900 or direct %s:%s", target.Host, managedVNCPort) + } + if err := waitForLoopbackVNC(ctx, target); err != nil { + return vncEndpoint{}, err + } + return vncEndpoint{Host: "127.0.0.1", Port: managedVNCPort, Managed: true}, nil +} + +func waitForLoopbackVNC(ctx context.Context, target SSHTarget) error { + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + if err := probeLoopbackVNC(ctx, target, "2", "1"); err == nil { + return nil + } + time.Sleep(2 * time.Second) + } + return exit(5, "target does not expose VNC on 127.0.0.1:5900") +} + +func probeLoopbackVNC(ctx context.Context, target SSHTarget, connectTimeout, connectionAttempts string) error { + return runSSHQuietWithOptions(ctx, target, vncLoopbackCheckCommand(target), connectTimeout, connectionAttempts) +} + +func vncLoopbackCheckCommand(target SSHTarget) string { + if isWindowsNativeTarget(target) { + return powershellCommand(`$result = Test-NetConnection -ComputerName 127.0.0.1 -Port 5900 -WarningAction SilentlyContinue +if (-not $result.TcpTestSucceeded) { exit 1 }`) + } + if target.TargetOS == targetMacOS { + return "nc -z 127.0.0.1 5900" + } + return "ss -ltn | grep -q '127.0.0.1:5900'" +} + +func tcpReachable(ctx context.Context, host, port string, timeout time.Duration) bool { + if host == "" || port == "" { + return false + } + dialer := net.Dialer{Timeout: timeout} + conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(host, port)) + if err != nil { + return false + } + _ = conn.Close() + return true +} diff --git a/internal/cli/config.go b/internal/cli/config.go index ebb1075..98a0f9f 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -17,6 +17,8 @@ type Config struct { Provider string TargetOS string WindowsMode string + Desktop bool + Browser bool Class string ServerType string ServerTypeExplicit bool @@ -213,6 +215,8 @@ type fileConfig struct { Target string `yaml:"target,omitempty"` TargetOS string `yaml:"targetOS,omitempty"` Windows *fileWindowsConfig `yaml:"windows,omitempty"` + Desktop *bool `yaml:"desktop,omitempty"` + Browser *bool `yaml:"browser,omitempty"` Class string `yaml:"class,omitempty"` ServerType string `yaml:"serverType,omitempty"` Coordinator string `yaml:"coordinator,omitempty"` @@ -463,6 +467,12 @@ func applyFileConfig(cfg *Config, file fileConfig) { if file.Windows != nil && file.Windows.Mode != "" { cfg.WindowsMode = file.Windows.Mode } + if file.Desktop != nil { + cfg.Desktop = *file.Desktop + } + if file.Browser != nil { + cfg.Browser = *file.Browser + } if file.Class != "" { cfg.Class = file.Class } @@ -719,6 +729,12 @@ func applyEnv(cfg *Config) { cfg.Provider = getenv("CRABBOX_PROVIDER", cfg.Provider) cfg.TargetOS = getenv("CRABBOX_TARGET", getenv("CRABBOX_TARGET_OS", cfg.TargetOS)) cfg.WindowsMode = getenv("CRABBOX_WINDOWS_MODE", cfg.WindowsMode) + if value, ok := getenvBool("CRABBOX_DESKTOP"); ok { + cfg.Desktop = value + } + if value, ok := getenvBool("CRABBOX_BROWSER"); ok { + cfg.Browser = value + } cfg.Class = getenv("CRABBOX_DEFAULT_CLASS", cfg.Class) if os.Getenv("CRABBOX_SERVER_TYPE") != "" { cfg.ServerTypeExplicit = true diff --git a/internal/cli/coordinator.go b/internal/cli/coordinator.go index 2975bf2..a687d6f 100644 --- a/internal/cli/coordinator.go +++ b/internal/cli/coordinator.go @@ -30,6 +30,8 @@ type CoordinatorLease struct { Provider string `json:"provider"` TargetOS string `json:"target,omitempty"` WindowsMode string `json:"windowsMode,omitempty"` + Desktop bool `json:"desktop,omitempty"` + Browser bool `json:"browser,omitempty"` Owner string `json:"owner"` Org string `json:"org"` Profile string `json:"profile"` @@ -304,6 +306,8 @@ func (c *CoordinatorClient) CreateLease(ctx context.Context, cfg Config, publicK "provider": cfg.Provider, "target": cfg.TargetOS, "windowsMode": cfg.WindowsMode, + "desktop": cfg.Desktop, + "browser": cfg.Browser, "class": cfg.Class, "serverType": cfg.ServerType, "serverTypeExplicit": cfg.ServerTypeExplicit, @@ -815,6 +819,8 @@ func leaseToServerTarget(lease CoordinatorLease, cfg Config) (Server, SSHTarget, "keep": fmt.Sprint(lease.Keep), "target": blank(lease.TargetOS, cfg.TargetOS), "windows_mode": blank(lease.WindowsMode, cfg.WindowsMode), + "desktop": fmt.Sprint(lease.Desktop), + "browser": fmt.Sprint(lease.Browser), "expires_at": lease.ExpiresAt, "last_touched_at": lease.LastTouchedAt, "idle_timeout_secs": fmt.Sprint(lease.IdleTimeoutSeconds), @@ -828,6 +834,12 @@ func leaseToServerTarget(lease CoordinatorLease, cfg Config) (Server, SSHTarget, if lease.SSHFallbackPorts != nil { cfg.SSHFallbackPorts = lease.SSHFallbackPorts } + if lease.TargetOS != "" { + cfg.TargetOS = lease.TargetOS + } + if lease.WindowsMode != "" { + cfg.WindowsMode = lease.WindowsMode + } target := sshTargetForLease(cfg, lease.Host, lease.SSHUser, lease.SSHPort) useStoredTestboxKey(&target, lease.ID) return server, target, lease.ID diff --git a/internal/cli/coordinator_capabilities_test.go b/internal/cli/coordinator_capabilities_test.go new file mode 100644 index 0000000..2440229 --- /dev/null +++ b/internal/cli/coordinator_capabilities_test.go @@ -0,0 +1,28 @@ +package cli + +import "testing" + +func TestValidateCoordinatorLeaseCapabilitiesRequiresDesktopEcho(t *testing.T) { + err := validateCoordinatorLeaseCapabilities(Config{Desktop: true}, CoordinatorLease{ID: "cbx_test"}) + if err == nil { + t.Fatal("expected desktop capability mismatch") + } +} + +func TestValidateCoordinatorLeaseCapabilitiesRequiresBrowserEcho(t *testing.T) { + err := validateCoordinatorLeaseCapabilities(Config{Browser: true}, CoordinatorLease{ID: "cbx_test"}) + if err == nil { + t.Fatal("expected browser capability mismatch") + } +} + +func TestValidateCoordinatorLeaseCapabilitiesAcceptsRequestedCapabilities(t *testing.T) { + err := validateCoordinatorLeaseCapabilities(Config{Desktop: true, Browser: true}, CoordinatorLease{ + ID: "cbx_test", + Desktop: true, + Browser: true, + }) + if err != nil { + t.Fatalf("validateCoordinatorLeaseCapabilities error: %v", err) + } +} diff --git a/internal/cli/provider_labels.go b/internal/cli/provider_labels.go index b8e17e8..2db0d7b 100644 --- a/internal/cli/provider_labels.go +++ b/internal/cli/provider_labels.go @@ -35,6 +35,12 @@ func directLeaseLabels(cfg Config, leaseID, slug, provider, market string, keep if cfg.TargetOS == targetWindows { labels["windows_mode"] = cfg.WindowsMode } + if cfg.Desktop { + labels["desktop"] = "true" + } + if cfg.Browser { + labels["browser"] = "true" + } return sanitizeProviderLabels(labels) } diff --git a/internal/cli/provider_labels_test.go b/internal/cli/provider_labels_test.go index dba6614..49c7c4c 100644 --- a/internal/cli/provider_labels_test.go +++ b/internal/cli/provider_labels_test.go @@ -13,6 +13,8 @@ func TestDirectLeaseLabelsAreProviderSafe(t *testing.T) { Profile: "default", ProviderKey: "crabbox-cbx-abcdef123456", ServerType: "cpx62", + Desktop: true, + Browser: true, TTL: 15 * time.Minute, IdleTimeout: 4 * time.Minute, } @@ -32,6 +34,9 @@ func TestDirectLeaseLabelsAreProviderSafe(t *testing.T) { if labels["ttl_secs"] != "900" { t.Fatalf("ttl_secs=%q want 900", labels["ttl_secs"]) } + if labels["desktop"] != "true" || labels["browser"] != "true" { + t.Fatalf("capability labels missing: %#v", labels) + } if labels["expires_at"] != "1777637040" { t.Fatalf("expires_at=%q want idle expiry", labels["expires_at"]) } diff --git a/internal/cli/run.go b/internal/cli/run.go index 1a9391f..d0554fc 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -34,6 +34,8 @@ func (a App) warmup(ctx context.Context, args []string) error { market := fs.String("market", defaults.Capacity.Market, "capacity market: spot or on-demand") ttl := fs.Duration("ttl", defaults.TTL, "maximum lease lifetime") idleTimeout := fs.Duration("idle-timeout", defaults.IdleTimeout, "idle timeout") + desktop := fs.Bool("desktop", defaults.Desktop, "provision or require a visible desktop/VNC session") + browser := fs.Bool("browser", defaults.Browser, "provision or require a browser binary") keep := fs.Bool("keep", true, "keep server after warmup") actionsRunner := fs.Bool("actions-runner", false, "register this box as an ephemeral GitHub Actions runner") reclaim := fs.Bool("reclaim", false, "claim this lease for the current repo") @@ -50,6 +52,7 @@ func (a App) warmup(ctx context.Context, args []string) error { cfg.Provider = *provider cfg.Profile = *profile cfg.Class = *class + applyCapabilityFlags(&cfg, *desktop, *browser) if err := applyTargetFlagOverrides(&cfg, fs, targetFlags); err != nil { return err } @@ -73,6 +76,9 @@ func (a App) warmup(ctx context.Context, args []string) error { if err := validateProviderTarget(cfg); err != nil { return err } + if err := validateRequestedCapabilities(cfg); err != nil { + return err + } if cfg.TTL <= 0 { return exit(2, "ttl must be positive") } @@ -147,6 +153,8 @@ func (a App) runCommand(ctx context.Context, args []string) (err error) { market := fs.String("market", defaults.Capacity.Market, "capacity market: spot or on-demand") ttl := fs.Duration("ttl", defaults.TTL, "maximum lease lifetime") idleTimeout := fs.Duration("idle-timeout", defaults.IdleTimeout, "idle timeout") + desktop := fs.Bool("desktop", defaults.Desktop, "provision or require a visible desktop/VNC session") + browser := fs.Bool("browser", defaults.Browser, "provision or require a browser binary") leaseIDFlag := fs.String("id", "", "existing lease or server id") keep := fs.Bool("keep", false, "keep server after command") noSync := fs.Bool("no-sync", false, "skip rsync") @@ -178,6 +186,7 @@ func (a App) runCommand(ctx context.Context, args []string) (err error) { cfg.Provider = *provider cfg.Profile = *profile cfg.Class = *class + applyCapabilityFlags(&cfg, *desktop, *browser) if err := applyTargetFlagOverrides(&cfg, fs, targetFlags); err != nil { return err } @@ -207,6 +216,9 @@ func (a App) runCommand(ctx context.Context, args []string) (err error) { if err := validateProviderTarget(cfg); err != nil { return err } + if err := validateRequestedCapabilities(cfg); err != nil { + return err + } if cfg.TTL <= 0 { return exit(2, "ttl must be positive") } @@ -283,6 +295,9 @@ func (a App) runCommand(ctx context.Context, args []string) (err error) { return recordFailure(err) } applyResolvedServerConfig(&cfg, server) + if err := enforceManagedLeaseCapabilities(cfg, server, leaseID); err != nil { + return recordFailure(err) + } if useCoordinator { recorder.AttachLease(leaseID, serverSlug(server), cfg) } @@ -512,14 +527,19 @@ afterSync: } fmt.Fprintf(a.Stderr, "running on %s %s\n", target.Host, strings.Join(command, " ")) recorder.Event("command.started", "command", strings.Join(command, " ")) - remote := remoteCommandWithEnvFile(workdir, allowedEnv(cfg.EnvAllow), actionsEnvFile, command) + capabilityEnv, err := requestedCapabilityEnv(ctx, cfg, target) + if err != nil { + return recordFailure(err) + } + runEnv := mergeEnv(allowedEnv(cfg.EnvAllow), capabilityEnv) + remote := remoteCommandWithEnvFile(workdir, runEnv, actionsEnvFile, command) if *shellMode || shouldUseShell(command) { - remote = remoteShellCommandWithEnvFile(workdir, allowedEnv(cfg.EnvAllow), actionsEnvFile, strings.Join(command, " ")) + remote = remoteShellCommandWithEnvFile(workdir, runEnv, actionsEnvFile, strings.Join(command, " ")) } if isWindowsNativeTarget(target) { - remote = windowsRemoteCommandWithEnvFile(workdir, allowedEnv(cfg.EnvAllow), actionsEnvFile, command) + remote = windowsRemoteCommandWithEnvFile(workdir, runEnv, actionsEnvFile, command) if *shellMode || shouldUseShell(command) { - remote = windowsRemoteShellCommandWithEnvFile(workdir, allowedEnv(cfg.EnvAllow), actionsEnvFile, strings.Join(command, " ")) + remote = windowsRemoteShellCommandWithEnvFile(workdir, runEnv, actionsEnvFile, strings.Join(command, " ")) } } var logBuffer runLogBuffer @@ -698,6 +718,12 @@ func (a App) acquireCoordinator(ctx context.Context, cfg Config, coord *Coordina fmt.Fprintf(a.Stderr, "warning: could not move local key from %s to %s: %v\n", leaseID, lease.ID, err) } } + if err := validateCoordinatorLeaseCapabilities(cfg, lease); err != nil { + if releaseErr := releaseCoordinatorLease(context.Background(), coord, blank(lease.ID, leaseID)); releaseErr != nil { + fmt.Fprintf(a.Stderr, "warning: release failed after capability mismatch for %s: %v\n", blank(lease.ID, leaseID), releaseErr) + } + return Server{}, SSHTarget{}, "", err + } server, target, leaseID := leaseToServerTarget(lease, cfg) fmt.Fprintf(a.Stderr, "leased %s slug=%s server=%d type=%s ip=%s via coordinator\n", leaseID, blank(lease.Slug, "-"), server.ID, server.ServerType.Name, target.Host) if summary := coordinatorFallbackSummary(lease); summary != "" { @@ -720,6 +746,16 @@ func (a App) acquireCoordinator(ctx context.Context, cfg Config, coord *Coordina return server, target, leaseID, nil } +func validateCoordinatorLeaseCapabilities(cfg Config, lease CoordinatorLease) error { + if cfg.Desktop && !lease.Desktop { + return exit(5, "coordinator did not provision desktop=true for lease %s; deploy the coordinator with desktop/VNC support", blank(lease.ID, "-")) + } + if cfg.Browser && !lease.Browser { + return exit(5, "coordinator did not provision browser=true for lease %s; deploy the coordinator with browser support", blank(lease.ID, "-")) + } + return nil +} + func applyResolvedServerConfig(cfg *Config, server Server) { if server.Provider != "" { cfg.Provider = server.Provider @@ -1291,6 +1327,9 @@ func (a App) writeActionsHydrationStopBestEffort(ctx context.Context, target SSH if leaseID == "" || target.Host == "" { return } + if isWindowsNativeTarget(target) { + return + } stopCtx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() if err := writeActionsHydrationStop(stopCtx, target, leaseID); err != nil { diff --git a/internal/cli/ssh_test.go b/internal/cli/ssh_test.go index 2401cbc..645cb53 100644 --- a/internal/cli/ssh_test.go +++ b/internal/cli/ssh_test.go @@ -3,12 +3,14 @@ package cli import ( "bytes" "context" + "encoding/base64" "os" "os/exec" "path/filepath" "strings" "testing" "time" + "unicode/utf16" ) func TestVersion(t *testing.T) { @@ -70,6 +72,43 @@ func TestWindowsNativeRemoteCommandUsesPowerShell(t *testing.T) { } } +func TestWindowsNativeRemoteShellRunsScriptDirectly(t *testing.T) { + got := windowsRemoteShellCommandWithEnvFile(`C:\crabbox\cbx\repo`, map[string]string{"CRABBOX_BROWSER": "1"}, "", `Write-Output ("COMPUTER=" + $env:COMPUTERNAME)`) + decoded := decodePowerShellCommand(t, got) + for _, want := range []string{ + `Set-Location -LiteralPath 'C:\crabbox\cbx\repo'`, + `$env:CRABBOX_BROWSER = '1'`, + `Write-Output ("COMPUTER=" + $env:COMPUTERNAME)`, + } { + if !strings.Contains(decoded, want) { + t.Fatalf("windows shell command missing %q in %q", want, decoded) + } + } + if strings.Contains(decoded, `& 'powershell.exe'`) { + t.Fatalf("windows shell command should not spawn nested powershell: %q", decoded) + } +} + +func decodePowerShellCommand(t *testing.T, command string) string { + t.Helper() + const prefix = "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand " + if !strings.HasPrefix(command, prefix) { + t.Fatalf("command missing encoded powershell prefix: %q", command) + } + raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(command, prefix)) + if err != nil { + t.Fatal(err) + } + if len(raw)%2 != 0 { + t.Fatalf("odd UTF-16LE byte length: %d", len(raw)) + } + units := make([]uint16, len(raw)/2) + for i := range units { + units[i] = uint16(raw[i*2]) | uint16(raw[i*2+1])<<8 + } + return string(utf16.Decode(units)) +} + func TestWSL2WrapsRemoteCommand(t *testing.T) { target := SSHTarget{TargetOS: targetWindows, WindowsMode: windowsModeWSL2} got := wrapRemoteForTarget(target, "echo ok") diff --git a/internal/cli/sync_windows_target.go b/internal/cli/sync_windows_target.go index c9262d0..2db1667 100644 --- a/internal/cli/sync_windows_target.go +++ b/internal/cli/sync_windows_target.go @@ -102,14 +102,7 @@ if (-not (Test-Path -LiteralPath (Join-Path $workdir ".git"))) { func windowsRemoteCommandWithEnvFile(workdir string, env map[string]string, envFile string, command []string) string { var b bytes.Buffer - b.WriteString(`$ErrorActionPreference = "Stop"` + "\n") - b.WriteString(`Set-Location -LiteralPath ` + psQuote(workdir) + "\n") - if envFile != "" { - b.WriteString(`if (Test-Path -LiteralPath ` + psQuote(envFile) + `) { Get-Content -LiteralPath ` + psQuote(envFile) + ` | ForEach-Object { if ($_ -match '^([^=]+)=(.*)$') { [Environment]::SetEnvironmentVariable($matches[1], $matches[2], 'Process') } } }` + "\n") - } - for key, value := range env { - b.WriteString(`$env:` + key + ` = ` + psQuote(value) + "\n") - } + writeWindowsRemotePrefix(&b, workdir, env, envFile) if len(command) == 0 { b.WriteString("exit 0\n") } else { @@ -124,7 +117,23 @@ func windowsRemoteCommandWithEnvFile(workdir string, env map[string]string, envF } func windowsRemoteShellCommandWithEnvFile(workdir string, env map[string]string, envFile, script string) string { - return windowsRemoteCommandWithEnvFile(workdir, env, envFile, []string{"powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script}) + var b bytes.Buffer + writeWindowsRemotePrefix(&b, workdir, env, envFile) + b.WriteString(script) + b.WriteString("\nif (-not $?) { exit 1 }\n") + b.WriteString("if ($null -ne $global:LASTEXITCODE) { exit $global:LASTEXITCODE }\n") + return powershellCommand(b.String()) +} + +func writeWindowsRemotePrefix(b *bytes.Buffer, workdir string, env map[string]string, envFile string) { + b.WriteString(`$ErrorActionPreference = "Stop"` + "\n") + b.WriteString(`Set-Location -LiteralPath ` + psQuote(workdir) + "\n") + if envFile != "" { + b.WriteString(`if (Test-Path -LiteralPath ` + psQuote(envFile) + `) { Get-Content -LiteralPath ` + psQuote(envFile) + ` | ForEach-Object { if ($_ -match '^([^=]+)=(.*)$') { [Environment]::SetEnvironmentVariable($matches[1], $matches[2], 'Process') } } }` + "\n") + } + for key, value := range env { + b.WriteString(`$env:` + key + ` = ` + psQuote(value) + "\n") + } } func windowsRemoteMkdir(workdir string) string { diff --git a/internal/cli/version.go b/internal/cli/version.go index 0d1a720..b6e220e 100644 --- a/internal/cli/version.go +++ b/internal/cli/version.go @@ -1,3 +1,3 @@ package cli -var version = "0.4.0" +var version = "0.5.0" diff --git a/internal/cli/vnc.go b/internal/cli/vnc.go new file mode 100644 index 0000000..9be7c92 --- /dev/null +++ b/internal/cli/vnc.go @@ -0,0 +1,217 @@ +package cli + +import ( + "context" + "fmt" + "os/exec" + "runtime" + "strings" + "time" +) + +func (a App) vnc(ctx context.Context, args []string) error { + defaults := defaultConfig() + fs := newFlagSet("vnc", a.Stderr) + provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or ssh") + id := fs.String("id", "", "lease id or slug") + reclaim := fs.Bool("reclaim", false, "claim this lease for the current repo") + localPort := fs.String("local-port", "", "local VNC tunnel port") + openClient := fs.Bool("open", false, "open the VNC client locally") + hostManaged := fs.Bool("host-managed", false, "allow opening host-managed static VNC") + targetFlags := registerTargetFlags(fs, defaults) + if err := parseFlags(fs, args); err != nil { + return err + } + if *id == "" && fs.NArg() > 0 { + *id = fs.Arg(0) + } + cfg, err := loadConfig() + if err != nil { + return err + } + cfg.Provider = *provider + cfg.Desktop = true + if err := applyTargetFlagOverrides(&cfg, fs, targetFlags); err != nil { + return err + } + if isBlacksmithProvider(cfg.Provider) { + return exit(2, "desktop/VNC is not supported for provider=%s; Blacksmith owns machine connectivity", cfg.Provider) + } + if *id == "" && !isStaticProvider(cfg.Provider) { + return exit(2, "usage: crabbox vnc --id ") + } + if *openClient && isStaticProvider(cfg.Provider) && !*hostManaged { + return exit(2, "static %s VNC is an existing host, not a Crabbox-created box; rerun with --host-managed only if you want to open that host's OS login prompt", cfg.TargetOS) + } + server, target, leaseID, err := a.resolveLeaseTarget(ctx, cfg, *id) + if err != nil { + return err + } + if err := enforceManagedLeaseCapabilities(cfg, server, leaseID); err != nil { + return err + } + repo, err := findRepo() + if err != nil { + return err + } + if err := claimLeaseForRepoConfig(leaseID, serverSlug(server), cfg, repo.Root, cfg.IdleTimeout, *reclaim); err != nil { + return err + } + a.touchActiveLeaseBestEffort(ctx, cfg, server, leaseID) + endpoint, err := resolveVNCEndpoint(ctx, cfg, target) + if err != nil { + return err + } + if *localPort == "" { + *localPort = availableLocalVNCPort() + } + password := "" + if endpoint.Managed { + if target.TargetOS == targetLinux { + password, _ = runSSHOutput(ctx, target, "cat "+shellQuote(vncPasswordPath)) + } + } + if target.TargetOS == targetLinux && !isStaticProvider(cfg.Provider) && password == "" { + password, _ = runSSHOutput(ctx, target, "cat "+shellQuote(vncPasswordPath)) + } + tunnel := vncTunnelCommand(target, *localPort) + staticHostVNC := isStaticProvider(cfg.Provider) && !endpoint.Managed + if staticHostVNC { + fmt.Fprintf(a.Stdout, "target: static-host slug=%s provider=%s os=%s host=%s\n", blank(serverSlug(server), "-"), blank(server.Provider, cfg.Provider), blank(target.TargetOS, cfg.TargetOS), target.Host) + } else { + fmt.Fprintf(a.Stdout, "lease: %s slug=%s provider=%s target=%s\n", leaseID, blank(serverSlug(server), "-"), blank(server.Provider, cfg.Provider), blank(target.TargetOS, cfg.TargetOS)) + } + if staticHostVNC { + fmt.Fprintln(a.Stdout, "managed: false") + fmt.Fprintln(a.Stdout, "note: this is an existing host VNC service, not a Crabbox-created box") + } else { + fmt.Fprintln(a.Stdout, "managed: true") + } + if target.TargetOS == targetLinux { + fmt.Fprintf(a.Stdout, "display: %s\n", desktopDisplay) + } + if endpoint.Direct { + fmt.Fprintln(a.Stdout, "direct vnc:") + fmt.Fprintf(a.Stdout, " %s:%s\n", endpoint.Host, endpoint.Port) + fmt.Fprintf(a.Stdout, " vnc://%s:%s\n", endpoint.Host, endpoint.Port) + } else { + fmt.Fprintln(a.Stdout, "ssh tunnel:") + fmt.Fprintf(a.Stdout, " %s\n", tunnel) + } + fmt.Fprintln(a.Stdout, "vnc:") + if endpoint.Direct { + fmt.Fprintf(a.Stdout, " %s:%s\n", endpoint.Host, endpoint.Port) + } else { + fmt.Fprintf(a.Stdout, " localhost:%s\n", *localPort) + } + if strings.TrimSpace(password) != "" { + fmt.Fprintf(a.Stdout, "password: %s\n", strings.TrimSpace(password)) + } else if staticHostVNC { + fmt.Fprintln(a.Stdout, "credentials: host-managed") + if target.TargetOS == targetMacOS { + fmt.Fprintln(a.Stdout, "credential hint: use the macOS account or Screen Sharing password configured on that host") + } + if target.TargetOS == targetWindows { + fmt.Fprintln(a.Stdout, "credential hint: use the Windows/VNC password configured on that host") + } + } + if *openClient { + if staticHostVNC { + fmt.Fprintln(a.Stdout, "opening existing host VNC; expect that host's OS credential prompt") + } + url := fmt.Sprintf("vnc://%s:%s", endpoint.Host, endpoint.Port) + if !endpoint.Direct { + pid, err := startVNCTunnel(ctx, target, *localPort, endpoint.Host, endpoint.Port) + if err != nil { + return err + } + if pid > 0 { + fmt.Fprintf(a.Stdout, "tunnel pid: %d\n", pid) + } else { + fmt.Fprintln(a.Stdout, "tunnel: started in background") + } + url = fmt.Sprintf("vnc://localhost:%s", *localPort) + } + if err := openLocalURL(url); err != nil { + return err + } + fmt.Fprintf(a.Stdout, "opened: %s\n", url) + } + if endpoint.Direct { + fmt.Fprintln(a.Stdout, "Connect directly to the printed VNC endpoint.") + } else { + fmt.Fprintln(a.Stdout, "Keep the tunnel process running while connected.") + } + return nil +} + +func vncTunnelCommand(target SSHTarget, localPort string) string { + return strings.Join(shellWords(append([]string{"ssh"}, vncTunnelArgs(target, localPort, "127.0.0.1", managedVNCPort)...)), " ") +} + +func startVNCTunnel(ctx context.Context, target SSHTarget, localPort, remoteHost, remotePort string) (int, error) { + cmd := exec.Command("ssh", vncTunnelBackgroundArgs(target, localPort, remoteHost, remotePort)...) + out, err := cmd.CombinedOutput() + if err != nil { + if strings.TrimSpace(string(out)) != "" { + return 0, fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) + } + return 0, err + } + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if ctx.Err() != nil { + return 0, context.Cause(ctx) + } + if tcpReachable(ctx, "127.0.0.1", localPort, 200*time.Millisecond) { + return 0, nil + } + time.Sleep(100 * time.Millisecond) + } + return 0, exit(5, "timed out starting VNC SSH tunnel on localhost:%s", localPort) +} + +func vncTunnelArgs(target SSHTarget, localPort, remoteHost, remotePort string) []string { + return []string{ + "-i", target.Key, + "-o", "IdentitiesOnly=yes", + "-o", "BatchMode=yes", + "-o", "StrictHostKeyChecking=accept-new", + "-o", "UserKnownHostsFile=" + sshConfigFileValue(knownHostsFile(target)), + "-o", "ConnectTimeout=10", + "-o", "ConnectionAttempts=1", + "-o", "ExitOnForwardFailure=yes", + "-o", "ServerAliveInterval=15", + "-o", "ServerAliveCountMax=2", + "-p", target.Port, + "-N", + "-L", fmt.Sprintf("%s:%s:%s", localPort, remoteHost, remotePort), + target.User + "@" + target.Host, + } +} + +func vncTunnelBackgroundArgs(target SSHTarget, localPort, remoteHost, remotePort string) []string { + args := vncTunnelArgs(target, localPort, remoteHost, remotePort) + return append([]string{"-f"}, args...) +} + +func openLocalURL(url string) error { + name, args := openURLCommand(url) + if name == "" { + return exit(2, "opening VNC URLs is not supported on this local OS") + } + return exec.Command(name, args...).Start() +} + +func openURLCommand(url string) (string, []string) { + switch runtime.GOOS { + case "darwin": + return "open", []string{url} + case "windows": + return "rundll32", []string{"url.dll,FileProtocolHandler", url} + case "linux": + return "xdg-open", []string{url} + default: + return "", nil + } +} diff --git a/internal/cli/vnc_test.go b/internal/cli/vnc_test.go new file mode 100644 index 0000000..d48f3e9 --- /dev/null +++ b/internal/cli/vnc_test.go @@ -0,0 +1,41 @@ +package cli + +import ( + "strings" + "testing" +) + +func TestVNCTunnelCommandQuotesKeyPath(t *testing.T) { + got := vncTunnelCommand(SSHTarget{ + Key: "/tmp/Application Support/crabbox/id_ed25519", + Port: "2222", + User: "crabbox", + Host: "203.0.113.10", + }, "5907") + if !strings.Contains(got, "'-i' '/tmp/Application Support/crabbox/id_ed25519'") { + t.Fatalf("tunnel key path should be shell-quoted: %q", got) + } + if !strings.Contains(got, "'-L' '5907:127.0.0.1:5900'") { + t.Fatalf("tunnel should forward VNC loopback: %q", got) + } +} + +func TestVNCLoopbackCheckCommandSupportsWindows(t *testing.T) { + got := vncLoopbackCheckCommand(SSHTarget{TargetOS: targetWindows, WindowsMode: windowsModeNormal}) + if !strings.Contains(got, "powershell.exe") { + t.Fatalf("windows VNC check should use PowerShell: %q", got) + } + if !strings.Contains(got, "EncodedCommand") { + t.Fatalf("windows VNC check should be encoded for OpenSSH: %q", got) + } +} + +func TestOpenURLCommandIncludesURL(t *testing.T) { + name, args := openURLCommand("vnc://localhost:5901") + if name == "" { + t.Skip("current OS has no URL opener") + } + if len(args) == 0 || args[len(args)-1] != "vnc://localhost:5901" { + t.Fatalf("openURLCommand args=%#v should include URL", args) + } +} diff --git a/package.json b/package.json index 8f0c9be..5fa7b81 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/crabbox-plugin", - "version": "0.4.0", + "version": "0.5.0", "description": "OpenClaw plugin for running Crabbox remote testbox workflows", "license": "MIT", "type": "module", diff --git a/worker/package-lock.json b/worker/package-lock.json index 03ecb36..01fb30c 100644 --- a/worker/package-lock.json +++ b/worker/package-lock.json @@ -1,12 +1,12 @@ { "name": "@openclaw/crabbox-worker", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@openclaw/crabbox-worker", - "version": "0.4.0", + "version": "0.5.0", "dependencies": { "aws4fetch": "^1.0.20", "fast-xml-parser": "^5.7.2" diff --git a/worker/package.json b/worker/package.json index 7e794b0..011dbb8 100644 --- a/worker/package.json +++ b/worker/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/crabbox-worker", - "version": "0.4.0", + "version": "0.5.0", "private": true, "type": "module", "scripts": { diff --git a/worker/src/bootstrap.ts b/worker/src/bootstrap.ts index 9f35a54..5def996 100644 --- a/worker/src/bootstrap.ts +++ b/worker/src/bootstrap.ts @@ -4,6 +4,9 @@ export function cloudInit(config: LeaseConfig): string { const portLines = sshPorts(config) .map((port) => ` Port ${port}`) .join("\n"); + const readyChecks = optionalReadyChecks(config); + const writeFiles = optionalWriteFiles(config); + const bootstrap = optionalBootstrap(config); return `#cloud-config package_update: false package_upgrade: false @@ -31,6 +34,8 @@ ${portLines} jq --version >/dev/null test -f /var/lib/crabbox/bootstrapped test -w ${config.workRoot} +${readyChecks} +${writeFiles} runcmd: - | bash -euxo pipefail <<'BOOT' @@ -52,6 +57,7 @@ runcmd: } retry apt-get update retry apt-get install -y --no-install-recommends openssh-server ca-certificates curl git rsync jq +${bootstrap} mkdir -p ${config.workRoot} /var/cache/crabbox/pnpm /var/cache/crabbox/npm chown -R ${config.sshUser}:${config.sshUser} ${config.workRoot} /var/cache/crabbox install -d /var/lib/crabbox @@ -62,3 +68,151 @@ runcmd: BOOT `; } + +function optionalReadyChecks(config: LeaseConfig): string { + const lines: string[] = []; + if (config.desktop) { + lines.push( + " systemctl is-active --quiet crabbox-xvfb.service", + " systemctl is-active --quiet crabbox-desktop.service", + " systemctl is-active --quiet crabbox-desktop-session.service", + " systemctl is-active --quiet crabbox-x11vnc.service", + " ss -ltn | grep -q '127.0.0.1:5900'", + ); + } + if (config.browser) { + lines.push( + " test -s /var/lib/crabbox/browser.env", + " . /var/lib/crabbox/browser.env", + ' test -x "$BROWSER"', + ' "$BROWSER" --version >/dev/null', + ); + } + return lines.join("\n"); +} + +function optionalWriteFiles(config: LeaseConfig): string { + if (!config.desktop) { + return ""; + } + return ` - path: /etc/systemd/system/crabbox-xvfb.service + permissions: '0644' + content: | + [Unit] + Description=Crabbox Xvfb display + After=network.target + + [Service] + User=crabbox + ExecStart=/usr/bin/Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp -ac + Restart=always + + [Install] + WantedBy=multi-user.target + - path: /etc/systemd/system/crabbox-desktop.service + permissions: '0644' + content: | + [Unit] + Description=Crabbox XFCE desktop session + After=crabbox-xvfb.service + Requires=crabbox-xvfb.service + + [Service] + User=crabbox + Environment=DISPLAY=:99 + ExecStart=/usr/bin/startxfce4 + Restart=always + + [Install] + WantedBy=multi-user.target + - path: /usr/local/bin/crabbox-desktop-session + permissions: '0755' + content: | + #!/bin/sh + set -eu + export DISPLAY="\${DISPLAY:-:99}" + if command -v xsetroot >/dev/null 2>&1; then + xsetroot -solid '#20242b' || true + fi + if command -v xterm >/dev/null 2>&1 && ! pgrep -u "$(id -u)" -f 'xterm -title Crabbox Desktop' >/dev/null 2>&1; then + xterm -title 'Crabbox Desktop' -geometry 110x32+48+48 -bg '#111827' -fg '#e5e7eb' & + fi + tail -f /dev/null + - path: /etc/systemd/system/crabbox-desktop-session.service + permissions: '0644' + content: | + [Unit] + Description=Crabbox visible desktop helper + After=crabbox-desktop.service + Requires=crabbox-xvfb.service crabbox-desktop.service + + [Service] + User=crabbox + Environment=DISPLAY=:99 + ExecStart=/usr/local/bin/crabbox-desktop-session + Restart=always + + [Install] + WantedBy=multi-user.target + - path: /etc/systemd/system/crabbox-x11vnc.service + permissions: '0644' + content: | + [Unit] + Description=Crabbox loopback VNC server + After=crabbox-xvfb.service + Requires=crabbox-xvfb.service + + [Service] + User=crabbox + ExecStart=/usr/bin/x11vnc -display :99 -localhost -rfbport 5900 -forever -shared -rfbauth /var/lib/crabbox/vnc.pass + Restart=always + + [Install] + WantedBy=multi-user.target +`; +} + +function optionalBootstrap(config: LeaseConfig): string { + const parts: string[] = []; + if (config.desktop) { + parts.push(` retry apt-get install -y --no-install-recommends xvfb xfce4 xfce4-terminal x11vnc xauth dbus-x11 x11-xserver-utils xterm fonts-dejavu-core fonts-liberation iproute2 openssl + install -d -m 0750 -o crabbox -g crabbox /var/lib/crabbox + if [ ! -s /var/lib/crabbox/vnc.password ]; then + (umask 077 && openssl rand -base64 18 > /var/lib/crabbox/vnc.password) + fi + x11vnc -storepasswd "$(cat /var/lib/crabbox/vnc.password)" /var/lib/crabbox/vnc.pass >/dev/null + chown crabbox:crabbox /var/lib/crabbox/vnc.password /var/lib/crabbox/vnc.pass + chmod 0600 /var/lib/crabbox/vnc.password /var/lib/crabbox/vnc.pass + systemctl daemon-reload + systemctl enable --now crabbox-xvfb.service crabbox-desktop.service crabbox-desktop-session.service crabbox-x11vnc.service`); + } + if (config.browser) { + parts.push(` retry apt-get install -y --no-install-recommends gnupg + browser_path="" + if [ "$(dpkg --print-architecture)" = "amd64" ]; then + install -d -m 0755 /etc/apt/trusted.gpg.d + curl -fsSL https://dl.google.com/linux/linux_signing_key.pub > /etc/apt/trusted.gpg.d/google.asc + chmod 0644 /etc/apt/trusted.gpg.d/google.asc + echo "deb [arch=amd64] https://dl.google.com/linux/chrome/deb/ stable main" > /etc/apt/sources.list.d/google-chrome.list + if apt-get update && retry apt-get install -y --no-install-recommends google-chrome-stable; then + browser_path="$(command -v google-chrome || true)" + else + rm -f /etc/apt/sources.list.d/google-chrome.list + retry apt-get update || true + fi + fi + if [ -z "$browser_path" ]; then + if apt-cache show chromium >/dev/null 2>&1 && retry apt-get install -y --no-install-recommends chromium; then + browser_path="$(command -v chromium || true)" + elif apt-cache show chromium-browser >/dev/null 2>&1 && retry apt-get install -y --no-install-recommends chromium-browser; then + browser_path="$(command -v chromium-browser || true)" + fi + fi + if [ -n "$browser_path" ]; then + printf 'CHROME_BIN=%s\\nBROWSER=%s\\n' "$browser_path" "$browser_path" > /var/lib/crabbox/browser.env + chown crabbox:crabbox /var/lib/crabbox/browser.env + chmod 0644 /var/lib/crabbox/browser.env + fi`); + } + return parts.join("\n"); +} diff --git a/worker/src/config.ts b/worker/src/config.ts index 011df99..df946a3 100644 --- a/worker/src/config.ts +++ b/worker/src/config.ts @@ -4,6 +4,8 @@ export interface LeaseConfig { provider: Provider; target: TargetOS; windowsMode: WindowsMode; + desktop: boolean; + browser: boolean; profile: string; class: string; serverType: string; @@ -59,6 +61,8 @@ export function leaseConfig(input: LeaseRequest): LeaseConfig { provider, target, windowsMode, + desktop: input.desktop ?? false, + browser: input.browser ?? false, profile: input.profile ?? "default", class: machineClass, serverType, diff --git a/worker/src/fleet.ts b/worker/src/fleet.ts index 77a3583..1b4ef95 100644 --- a/worker/src/fleet.ts +++ b/worker/src/fleet.ts @@ -149,6 +149,8 @@ export class FleetDurableObject implements DurableObject { slug, provider: config.provider, target: config.target, + desktop: config.desktop, + browser: config.browser, cloudID: "", owner, org, diff --git a/worker/src/provider-labels.ts b/worker/src/provider-labels.ts index 66b477e..8f27ab2 100644 --- a/worker/src/provider-labels.ts +++ b/worker/src/provider-labels.ts @@ -35,6 +35,12 @@ export function leaseProviderLabels( if (config.target === "windows") { labels["windows_mode"] = config.windowsMode; } + if (config.desktop) { + labels["desktop"] = "true"; + } + if (config.browser) { + labels["browser"] = "true"; + } return sanitizeLabels({ ...labels, ...extra }); } diff --git a/worker/src/types.ts b/worker/src/types.ts index 2a55591..4f60511 100644 --- a/worker/src/types.ts +++ b/worker/src/types.ts @@ -42,6 +42,8 @@ export interface LeaseRequest { target?: TargetOS; targetOS?: TargetOS; windowsMode?: WindowsMode; + desktop?: boolean; + browser?: boolean; profile?: string; class?: string; serverType?: string; @@ -83,6 +85,8 @@ export interface LeaseRecord { provider: Provider; target: TargetOS; windowsMode?: WindowsMode; + desktop?: boolean; + browser?: boolean; cloudID: string; region?: string; owner: string; diff --git a/worker/test/bootstrap.test.ts b/worker/test/bootstrap.test.ts index 88104f9..8471418 100644 --- a/worker/test/bootstrap.test.ts +++ b/worker/test/bootstrap.test.ts @@ -5,6 +5,10 @@ import type { LeaseConfig } from "../src/config"; const config: LeaseConfig = { provider: "aws", + target: "linux", + windowsMode: "normal", + desktop: false, + browser: false, profile: "project-check", class: "standard", serverType: "c7a.8xlarge", @@ -59,4 +63,36 @@ describe("cloud-init bootstrap", () => { expect(got).not.toContain("docker.io"); expect(got).not.toContain("corepack"); }); + + it("adds desktop services only when requested", () => { + const got = cloudInit({ ...config, desktop: true }); + expect(got).toContain("xvfb xfce4 xfce4-terminal x11vnc xauth dbus-x11"); + expect(got).toContain("/etc/systemd/system/crabbox-xvfb.service"); + expect(got).toContain("/etc/systemd/system/crabbox-desktop.service"); + expect(got).toContain("/usr/local/bin/crabbox-desktop-session"); + expect(got).toContain("/etc/systemd/system/crabbox-desktop-session.service"); + expect(got).toContain("/etc/systemd/system/crabbox-x11vnc.service"); + expect(got).toContain("ExecStart=/usr/bin/startxfce4"); + expect(got).toContain("systemctl is-active --quiet crabbox-desktop.service"); + expect(got).toContain("systemctl is-active --quiet crabbox-desktop-session.service"); + expect(got).toContain("x11-xserver-utils xterm"); + expect(got).toContain("xsetroot -solid '#20242b'"); + expect(got).toContain("xterm -title 'Crabbox Desktop'"); + expect(got).toContain("(umask 077 && openssl rand -base64 18 > /var/lib/crabbox/vnc.password)"); + expect(got).toContain("-rfbauth /var/lib/crabbox/vnc.pass"); + expect(got).toContain("ss -ltn | grep -q '127.0.0.1:5900'"); + }); + + it("adds browser setup only when requested", () => { + const got = cloudInit({ ...config, browser: true }); + expect(got).toContain("https://dl.google.com/linux/linux_signing_key.pub"); + expect(got).toContain("chmod 0644 /etc/apt/trusted.gpg.d/google.asc"); + expect(got).toContain("https://dl.google.com/linux/chrome/deb/"); + expect(got).toContain("google-chrome-stable"); + expect(got).toContain("apt-cache show chromium"); + expect(got).toContain("apt-cache show chromium-browser"); + expect(got).toContain("/var/lib/crabbox/browser.env"); + expect(got).toContain('test -x "$BROWSER"'); + expect(got).toContain('"$BROWSER" --version >/dev/null'); + }); }); diff --git a/worker/test/config.test.ts b/worker/test/config.test.ts index 154a719..b5a5f33 100644 --- a/worker/test/config.test.ts +++ b/worker/test/config.test.ts @@ -55,9 +55,21 @@ describe("lease config", () => { expect(config.sshFallbackPorts).toEqual(["22"]); expect(config.capacityMarket).toBe("spot"); expect(config.capacityStrategy).toBe("most-available"); + expect(config.desktop).toBe(false); + expect(config.browser).toBe(false); expect(config.ttlSeconds).toBe(86_400); }); + it("preserves requested desktop and browser capabilities", () => { + const config = leaseConfig({ + sshPublicKey: "ssh-ed25519 test", + desktop: true, + browser: true, + }); + expect(config.desktop).toBe(true); + expect(config.browser).toBe(true); + }); + it("uses AWS defaults when requested", () => { const config = leaseConfig({ provider: "aws", sshPublicKey: "ssh-ed25519 test" }); expect(config.serverType).toBe("c7a.48xlarge"); diff --git a/worker/test/provider-labels.test.ts b/worker/test/provider-labels.test.ts index f88eb5f..f65411a 100644 --- a/worker/test/provider-labels.test.ts +++ b/worker/test/provider-labels.test.ts @@ -7,6 +7,10 @@ describe("provider labels", () => { it("caps expires_at at the shorter of ttl and idle timeout", () => { const config: LeaseConfig = { provider: "aws", + target: "linux", + windowsMode: "normal", + desktop: false, + browser: false, profile: "default", class: "beast", serverType: "c7a.48xlarge", @@ -50,4 +54,49 @@ describe("provider labels", () => { expect(value).toMatch(/^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$/); } }); + + it("labels requested desktop and browser capabilities", () => { + const config: LeaseConfig = { + provider: "aws", + target: "linux", + windowsMode: "normal", + desktop: true, + browser: true, + profile: "default", + class: "beast", + serverType: "c7a.48xlarge", + image: "ami", + location: "eu-west-1", + sshUser: "crabbox", + sshPort: "2222", + sshFallbackPorts: ["22"], + awsRegion: "eu-west-1", + awsRootGB: 400, + awsAMI: "", + awsSGID: "", + awsSubnetID: "", + awsProfile: "", + capacityMarket: "spot", + capacityStrategy: "most-available", + capacityFallback: "on-demand-after-120s", + capacityRegions: [], + capacityAvailabilityZones: [], + providerKey: "crabbox-cbx-123", + workRoot: "/work/crabbox", + ttlSeconds: 600, + idleTimeoutSeconds: 7200, + keep: false, + sshPublicKey: "ssh-ed25519 test", + }; + const labels = leaseProviderLabels( + config, + "cbx_123", + "blue-lobster", + "peter@example.com", + "aws", + new Date("2026-05-01T12:00:00Z"), + ); + expect(labels.desktop).toBe("true"); + expect(labels.browser).toBe("true"); + }); });