feat: add interactive desktop VNC support (#11)
This commit is contained in:
parent
d9213f8bef
commit
8caabdbaa9
16
CHANGELOG.md
16
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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
91
docs/commands/vnc.md
Normal 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
|
||||
```
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
|
||||
@ -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
480
docs/plan/vnc.md
Normal 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.
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
251
internal/cli/capabilities.go
Normal file
251
internal/cli/capabilities.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
28
internal/cli/coordinator_capabilities_test.go
Normal file
28
internal/cli/coordinator_capabilities_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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"])
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
package cli
|
||||
|
||||
var version = "0.4.0"
|
||||
var version = "0.5.0"
|
||||
|
||||
217
internal/cli/vnc.go
Normal file
217
internal/cli/vnc.go
Normal 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
41
internal/cli/vnc_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
4
worker/package-lock.json
generated
4
worker/package-lock.json
generated
@ -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"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/crabbox-worker",
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user