feat: add interactive desktop VNC support (#11)

This commit is contained in:
Peter Steinberger 2026-05-04 00:31:06 +01:00 committed by GitHub
parent d9213f8bef
commit 8caabdbaa9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 1850 additions and 38 deletions

View File

@ -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

View File

@ -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.

View File

@ -33,8 +33,8 @@ crabbox init [--force]
crabbox config show [--json]
crabbox config path
crabbox config set-broker --url <url> --token-stdin [--provider hetzner|aws]
crabbox warmup [--provider hetzner|aws|ssh|blacksmith-testbox] [--target linux|macos|windows] [--profile <name>] [--idle-timeout <duration>] [--timing-json]
crabbox run [--id <lease-id-or-slug>] [--provider hetzner|aws|ssh|blacksmith-testbox] [--target linux|macos|windows] [--windows-mode normal|wsl2] [--shell] [--checksum] [--debug] [--force-sync-large] [--timing-json] [--blacksmith-workflow <workflow>] -- <command...>
crabbox warmup [--provider hetzner|aws|ssh|blacksmith-testbox] [--target linux|macos|windows] [--desktop] [--browser] [--profile <name>] [--idle-timeout <duration>] [--timing-json]
crabbox run [--id <lease-id-or-slug>] [--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 <workflow>] -- <command...>
crabbox sync-plan [--limit <n>]
crabbox history [--lease <lease-id>] [--owner <email>] [--org <name>] [--limit <n>] [--json]
crabbox logs <run-id> [--json]
@ -54,6 +54,7 @@ crabbox admin leases [--state active|released|expired|failed] [--owner <email>]
crabbox admin release <lease-id-or-slug> [--delete]
crabbox admin delete <lease-id-or-slug> --force
crabbox ssh --id <lease-id-or-slug>
crabbox vnc --id <lease-id-or-slug> [--open]
crabbox inspect --id <lease-id-or-slug> [--json]
crabbox stop <lease-id-or-slug>
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 <paths> 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 <org> Blacksmith organization

View File

@ -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)

View File

@ -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 <duration>
--idle-timeout <duration>
--desktop
--browser
--keep
--no-sync
--sync-only

91
docs/commands/vnc.md Normal file
View File

@ -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:<port>` 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 <lease-id-or-slug>
--provider hetzner|aws|ssh
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>
--static-user <user>
--static-port <port>
--static-work-root <path>
--local-port <port>
--open
--host-managed
--reclaim
```

View File

@ -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 <duration>
--idle-timeout <duration>
--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

View File

@ -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)

View File

@ -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 <lease>` 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 <lease> --desktop -- <command...>` runs UI automation in
the desktop session;
- `crabbox run --id <lease> --browser -- <command...>` 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:

View File

@ -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:

480
docs/plan/vnc.md Normal file
View File

@ -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 -- <command...>
```
`--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] -- <command...>
crabbox vnc --id <lease-or-slug>
```
`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 <lease>` 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 <slug> --desktop --browser -- google-chrome --version
bin/crabbox run --id <slug> --desktop --browser --shell 'echo "$DISPLAY"; echo "$CHROME_BIN"'
bin/crabbox vnc --id <slug>
bin/crabbox stop <slug>
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 -- <cmd>` runs with `DISPLAY=:99` and browser env.
5. `crabbox vnc --id <lease>` 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.

View File

@ -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

View File

@ -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

View File

@ -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")
}

View File

@ -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)
}
}
}

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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"])
}

View File

@ -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 {

View File

@ -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")

View File

@ -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 {

View File

@ -1,3 +1,3 @@
package cli
var version = "0.4.0"
var version = "0.5.0"

217
internal/cli/vnc.go Normal file
View File

@ -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 <lease-id-or-slug>")
}
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
}
}

41
internal/cli/vnc_test.go Normal file
View File

@ -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)
}
}

View File

@ -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",

View File

@ -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"

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/crabbox-worker",
"version": "0.4.0",
"version": "0.5.0",
"private": true,
"type": "module",
"scripts": {

View File

@ -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");
}

View File

@ -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,

View File

@ -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,

View File

@ -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 });
}

View File

@ -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;

View File

@ -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');
});
});

View File

@ -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");

View File

@ -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");
});
});