Compare commits

..

5 Commits

Author SHA1 Message Date
Vincent Koc
b3e8c998c3
fix: satisfy worker control lint 2026-05-09 07:43:58 +08:00
Vincent Koc
218bfdeac8
docs: document adaptive control transport 2026-05-09 07:42:03 +08:00
Vincent Koc
b2eee1521b
feat: use control websocket for attach 2026-05-09 07:39:08 +08:00
Vincent Koc
a8a8a95243
feat: add coordinator control websocket 2026-05-09 07:32:24 +08:00
Vincent Koc
f399bf6d27
fix: reduce ssh run transport chatter 2026-05-07 20:03:08 -07:00
102 changed files with 1296 additions and 7878 deletions

View File

@ -79,8 +79,6 @@ crabbox desktop click --id <id-or-slug> --x 640 --y 420
crabbox desktop paste --id <id-or-slug> --text "peter@example.com"
crabbox desktop type --id <id-or-slug> --text "peter+qa@example.com"
crabbox desktop key --id <id-or-slug> ctrl+l
crabbox artifacts collect --id <id-or-slug> --all --output artifacts/<slug>
crabbox artifacts publish --dir artifacts/<slug> --pr <number>
crabbox sync-plan
crabbox history --lease <id-or-slug>
crabbox events <run_id> --json
@ -107,13 +105,6 @@ bridge disconnected`, `problem: browser not launched`, `problem: input stack
dead`, or similar will be followed by exact `rescue:` commands such as
`crabbox webvnc status/reset` or `crabbox desktop doctor`.
For UI QA proof, use `crabbox artifacts collect` instead of ad hoc screenshots
and shell recordings. It can bundle screenshots, MP4 recordings, trimmed GIFs,
desktop doctor output, WebVNC status, run logs, and metadata, then
`crabbox artifacts publish --pr <n>` can publish inline-ready Markdown through
the configured coordinator artifact backend. Use explicit `--storage s3`,
`--storage r2`, or `--storage local` only as a local fallback.
## Run Inspection Workflow
Use the CLI for durable run inspection; do not expect extra OpenClaw plugin

View File

@ -1,30 +1,19 @@
# Changelog
## Unreleased
### Added
- Added `provider: azure` for managed Azure Linux and native Windows SSH leases, including direct and brokered provisioning, shared Azure networking, SKU fallback, Azure docs, and cleanup support. Thanks @jwmoss.
## 0.7.0 - 2026-05-07
### Added
- Added an authenticated coordinator control WebSocket for low-latency run attach streams and lease heartbeats, with HTTP polling/heartbeat fallback for older brokers.
- Added mediated egress commands and browser wiring so Linux desktop leases can proxy selected app traffic through the operator machine via the coordinator bridge.
- Added WebVNC portal clipboard controls for sending local clipboard text into the remote session and copying remote clipboard text back to the local browser.
- Added rescue-first desktop/WebVNC failure output that names the failing layer and prints exact `rescue:` or native VNC fallback commands when bridges, viewers, browser launches, VNC targets, or input stacks hang.
- Added lease sharing for individual users or the owning org, including `crabbox share`, `crabbox unshare`, API access checks, and a portal share control on lease detail pages.
- Added collaborative WebVNC observer mode, with one active controller, read-only observers, and a portal takeover button that shows who is controlling the session.
- Added first-class `crabbox artifacts` commands for desktop screenshots, MP4 recordings, trimmed GIFs, logs, metadata, Mantis/OpenClaw QA templates, and PR-ready publishing through broker-owned artifact storage, AWS S3, or Cloudflare R2.
### Changed
- Changed WebVNC portal sharing to open as an in-session modal, added a standalone share-page back action, and simplified collaboration controls into a single stateful control button.
### Fixed
- Fixed `crabbox run` transport chatter by keeping SSH multiplexers alive longer, retrying fallback SSH ports for streaming commands, and batching stdout/stderr preview events into larger coordinator chunks.
- Fixed `egress start --coordinator` so live public-route egress starts work when the local default coordinator is Cloudflare Access-protected.
- Fixed macOS WebVNC cursor visibility by enabling noVNC's dot-cursor fallback when Screen Sharing sends a transparent or zero-sized cursor.
- Fixed Tailscale exit-node bootstrap paths to prefer tailnet metadata and fail clearly when remote exit-node egress is not active.
- Fixed `run --no-sync` timing summaries so they report `sync_skipped=true`.
- Fixed native Windows command output so first-use PowerShell progress records do not leak CLIXML into run logs.
@ -32,8 +21,6 @@
- Fixed Code and WebVNC bridge websocket auth so upgraded brokers receive short-lived bridge tickets in the `Authorization` header instead of logging them in URL query strings, while preserving query fallback for older brokers.
- Fixed managed AWS macOS desktop leases so readiness and WebVNC use a writable `ec2-user` work root, call `crabbox-ready` by absolute path, and read the generated Screen Sharing password via sudo.
- Fixed managed AWS macOS bootstrap so VNC password generation does not abort under `pipefail` before Screen Sharing readiness is installed.
- Fixed managed Linux bootstrap so SSH service activation cannot hang cloud-init before desktop/browser setup and readiness checks run.
- Fixed WebVNC daemon start-by-slug so coordinator-backed leases use the resolved target OS in the background bridge command.
- Fixed coordinator-backed `crabbox list` so a stale admin token no longer blocks normal logged-in users; the CLI now falls back to active user-visible leases instead of failing with `401 unauthorized`.
- Fixed desktop, screenshot, VNC, and WebVNC SSH helpers so they retry live fallback ports when a coordinator lease advertises an SSH port that is not ready yet.

View File

@ -12,7 +12,7 @@ Crabbox is an open-source remote testbox runner for maintainers and AI agents. L
crabbox run -- pnpm test
```
Behind that single command: a Go CLI on your laptop, a Cloudflare Worker broker that owns provider credentials and lease state, and a managed runner on Hetzner Cloud, AWS EC2, or Azure. Azure supports managed Linux and native Windows VMs. Crabbox can also wrap Blacksmith Testboxes when you choose `provider: blacksmith-testbox`, use Daytona or Islo sandboxes for direct-provider workflows, or use `provider: ssh` for existing macOS and Windows targets.
Behind that single command: a Go CLI on your laptop, a Cloudflare Worker broker that owns provider credentials and lease state, and a managed runner on Hetzner Cloud or AWS EC2. Crabbox can also wrap Blacksmith Testboxes when you choose `provider: blacksmith-testbox`, use Daytona or Islo sandboxes for direct-provider workflows, or use `provider: ssh` for existing macOS and Windows targets.
---
@ -53,7 +53,7 @@ Every lease has a stable `cbx_...` ID and a friendly crustacean slug (`blue-lobs
```text
your laptop Cloudflare Worker cloud provider
------------- ------------------ --------------
crabbox CLI -- HTTPS --> Fleet Durable Object --> Hetzner / AWS EC2 / Azure
crabbox CLI -- HTTPS --> Fleet Durable Object --> Hetzner / AWS EC2
| lease + cost state |
| |
+------------ SSH + rsync to leased runner <--------------+
@ -61,9 +61,9 @@ crabbox CLI -- HTTPS --> Fleet Durable Object --> Hetzner / AWS EC2 / Azur
- **CLI** — Go binary. Loads config, mints a per-lease SSH key, asks the broker for a lease, waits for SSH, seeds remote Git, rsyncs the dirty checkout (with fingerprint skip when nothing changed), runs the command, streams output, releases.
- **Broker** — Cloudflare Worker at `crabbox.openclaw.ai` plus a single Durable Object. Owns provider credentials, serializes lease state, enforces active-lease and monthly spend caps, and expires stale leases by alarm. Auth is GitHub login or a shared bearer token.
- **Runner**a throwaway SSH machine prepared with SSH on the primary port, default `2222`, plus configured fallback ports and Crabbox's sync/run prerequisites. Linux uses Ubuntu with cloud-init and `/work/crabbox`; native Windows uses OpenSSH, Git for Windows, and `C:\crabbox`. No broker credentials live on the box. Project runtimes (Go, Node, Docker, services, secrets) come from your repo's GitHub Actions hydration, devcontainer, Nix, mise/asdf, or setup scripts — not from Crabbox.
- **Runner**vanilla Ubuntu prepared by cloud-init with SSH on the primary port, default `2222`, plus configured fallback ports, Git, rsync, curl, jq, and `/work/crabbox`. No broker credentials live on the box. Project runtimes (Go, Node, Docker, services, secrets) come from your repo's GitHub Actions hydration, devcontainer, Nix, mise/asdf, or setup scripts — not from Crabbox.
A direct-provider mode (`--provider hetzner|aws|azure` with local credentials) exists for debugging the broker itself; the brokered path is the default.
A direct-provider mode (`--provider hetzner|aws` with local credentials) exists for debugging the broker itself; the brokered path is the default.
For the full mental model, see [How Crabbox Works](docs/how-it-works.md). For the doc-to-code map, see [Source Map](docs/source-map.md).
@ -73,15 +73,14 @@ For the full mental model, see [How Crabbox Works](docs/how-it-works.md). For th
- **Run observability.** Every coordinator-backed run gets an early `run_...` handle. Use `crabbox attach <run-id>` while it is active, `crabbox events <run-id> --after <seq> --limit <n>` for durable lifecycle/output events, and `crabbox logs <run-id>` for retained output after completion.
- **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, AWS EC2, and Azure are managed providers; AWS also owns Windows WSL2 and EC2 Mac targets. Linux defaults to Spot unless capacity config says otherwise. Providers fall back across compatible instance families when capacity or quota rejects a request.
- **Azure Linux and native Windows.** `provider: azure` provisions Linux and native Windows VMs in a configurable Azure subscription using `DefaultAzureCredential` in direct mode or service-principal secrets in the broker. Crabbox creates a shared resource group, vnet, subnet, and NSG on first use, then per-lease public IPs, NICs, and VMs. Linux uses cloud-init; Windows uses VM Agent Custom Script Extension to install OpenSSH/Git and configure the Crabbox user.
- **Brokered cloud.** Maintainers and agents share infra without sharing provider tokens. Hetzner and AWS EC2 are first-class managed providers; AWS also owns managed Windows and EC2 Mac targets. Linux defaults to Spot unless capacity config says otherwise. Providers fall back across compatible instance families when capacity or quota rejects a request.
- **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, config conventions, and portal visibility for active external runners.
- **Daytona and Islo sandboxes.** Set `provider: daytona` for Daytona SDK/toolbox execution from a snapshot with explicit SSH access when needed, or `provider: islo` for delegated Islo sandbox execution through the Islo Go SDK.
- **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.
- **Interactive desktop and browser leases.** `--browser` provisions Chrome or Chromium for headless automation, `--desktop` provisions visible UI with tunnel-only VNC takeover on managed Linux, AWS native Windows, and AWS EC2 Mac targets. `crabbox desktop doctor` checks session, VNC, input tooling, browser, ffmpeg, screen size, screenshot capture, and WebVNC portal state; `desktop click/paste/type/key` provide first-class input helpers so agents do not hand-roll brittle `xdotool` snippets. QA systems such as Mantis own scenario logic, screenshots, and PR evidence. Azure native Windows is SSH/sync/run only; use AWS for managed Windows desktop/WSL2 or `provider: ssh` for an existing Windows host.
- **Interactive desktop and browser leases.** `--browser` provisions Chrome or Chromium for headless automation, `--desktop` provisions visible UI with tunnel-only VNC takeover on managed Linux, AWS native Windows, and AWS EC2 Mac targets. `crabbox desktop doctor` checks session, VNC, input tooling, browser, ffmpeg, screen size, screenshot capture, and WebVNC portal state; `desktop click/paste/type/key` provide first-class input helpers so agents do not hand-roll brittle `xdotool` snippets. Hetzner Windows is not a managed target; use AWS for managed Windows or `provider: ssh` for an existing Windows host.
- **Authenticated web portal.** Browser login opens owner-scoped and explicitly shared lease/run views with searchable, paginated tables, muted external-runner rows, compact provider/OS/access icons, relative sortable times, recent run logs/events, WebVNC, code-server, and Linux lease/run telemetry charts. `crabbox share` can grant a lease to one user or the owning org, and the lease page exposes the same sharing controls for owners/managers. WebVNC is preferred for human demos because it preloads the VNC password; `webvnc status` reports local daemon, tunnel, target reachability, bridge/viewer state, recent events, URL/password, and native VNC fallback, while `webvnc reset` restarts only the selected lease's WebVNC/input stack. Admin sessions can also see non-owned runner leases behind `mine`/`system` filters.
- **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.
@ -113,16 +112,6 @@ AWS WSL2 standard m8i.large, m8i-flex.large, c8i.large, r8i.large
beast m8i.4xlarge, m8i-flex.4xlarge, c8i.4xlarge, r8i.4xlarge, m8i.2xlarge
AWS macOS all mac2.metal unless --type is set
Azure standard Standard_D32ads_v6, Standard_D32ds_v6, Standard_F32s_v2, then 16-vCPU fallbacks
fast Standard_D64ads_v6, Standard_D64ds_v6, Standard_F64s_v2, then 48/32-vCPU fallbacks
large Standard_D96ads_v6, Standard_D96ds_v6, then 64/48-vCPU fallbacks
beast Standard_D192ds_v6, Standard_D128ds_v6, then 96/64-vCPU fallbacks
Azure Win standard Standard_D2ads_v6, Standard_D2ds_v6, Standard_D2ads_v5, Standard_D2ds_v5, Standard_D2as_v6
fast Standard_D4ads_v6, Standard_D4ds_v6, Standard_D4ads_v5, Standard_D4ds_v5, Standard_D4as_v6
large Standard_D8ads_v6, Standard_D8ds_v6, Standard_D8ads_v5, Standard_D8ds_v5, Standard_D8as_v6
beast Standard_D16ads_v6, Standard_D16ds_v6, Standard_D16ads_v5, Standard_D16ds_v5, Standard_D8ads_v6
```
Override with `--type` or `CRABBOX_SERVER_TYPE` for a specific instance.

View File

@ -13,13 +13,13 @@ A `crabbox run` command leases a brokered cloud machine or reuses a static SSH h
```text
your laptop Cloudflare Worker cloud provider
------------- ------------------ --------------
crabbox CLI -- HTTPS --> Fleet Durable Object --> Hetzner / AWS EC2 / Azure
crabbox CLI -- HTTPS --> Fleet Durable Object --> Hetzner / AWS EC2
| lease + cost state |
| |
+------------ SSH + rsync to leased runner <--------------+
```
The CLI is a Go binary. The broker is a Cloudflare Worker plus a single Durable Object. Brokered Linux runners are vanilla Ubuntu boxes prepared by cloud-init with SSH, Git, rsync, curl, jq, and `/work/crabbox`; AWS can also broker managed Windows/WSL2 and EC2 Mac desktop targets, while Azure can broker native Windows SSH/sync/run targets. Static hosts are existing SSH machines selected with `provider: ssh`. Project runtimes come from Actions hydration or repo-owned setup. Runners hold no broker credentials - they are leaf nodes.
The CLI is a Go binary. The broker is a Cloudflare Worker plus a single Durable Object. Brokered Linux runners are vanilla Ubuntu boxes prepared by cloud-init with SSH, Git, rsync, curl, jq, and `/work/crabbox`; AWS can also broker managed Windows and EC2 Mac desktop targets. Static hosts are existing SSH machines selected with `provider: ssh`. Project runtimes come from Actions hydration or repo-owned setup. Runners hold no broker credentials - they are leaf nodes.
## A run, end to end

View File

@ -104,7 +104,6 @@ Owned backends:
- `hetzner-static`: pre-created warm machines.
- `hetzner-ephemeral`: created per lease or overflow.
- `aws`: one-time EC2 instances for burst capacity, managed Windows/WSL2, and EC2 Mac.
- `azure`: one-time Azure VMs for Linux and native Windows SSH/sync/run.
- `ssh-static`: manually managed machines reachable by SSH.
Brokered backends, later:
@ -112,7 +111,7 @@ Brokered backends, later:
- `github-actions`: register or dispatch real Actions-backed runner work when workflow parity is required.
- `external-runner`: adapter boundary for other hosted runner systems if needed.
The current broker implements `hetzner-ephemeral`, `aws`, and `azure`, and leaves interfaces ready for `hetzner-static`.
The current broker implements `hetzner-ephemeral` and `aws`, and leaves interfaces ready for `hetzner-static`.
## Machine Bootstrap

View File

@ -25,16 +25,16 @@ Primary output goes to stdout. Progress, diagnostics, and errors go to stderr. J
```text
crabbox doctor
crabbox login [--url <url>] [--provider hetzner|aws|azure] [--no-browser]
crabbox login --url <url> --token-stdin [--provider hetzner|aws|azure]
crabbox login [--url <url>] [--provider hetzner|aws] [--no-browser]
crabbox login --url <url> --token-stdin [--provider hetzner|aws]
crabbox logout
crabbox whoami [--json]
crabbox init [--force]
crabbox config show [--json]
crabbox config path
crabbox config set-broker --url <url> --token-stdin [--provider hetzner|aws|azure]
crabbox warmup [--provider hetzner|aws|azure|ssh|blacksmith-testbox|daytona|islo] [--target linux|macos|windows] [--desktop] [--browser] [--code] [--tailscale] [--network auto|tailscale|public] [--profile <name>] [--idle-timeout <duration>] [--timing-json]
crabbox run [--id <lease-id-or-slug>] [--provider hetzner|aws|azure|ssh|blacksmith-testbox|daytona|islo] [--target linux|macos|windows] [--windows-mode normal|wsl2] [--desktop] [--browser] [--code] [--tailscale] [--network auto|tailscale|public] [--shell] [--checksum] [--debug] [--force-sync-large] [--timing-json] [--blacksmith-workflow <workflow>] -- <command...>
crabbox config set-broker --url <url> --token-stdin [--provider hetzner|aws]
crabbox warmup [--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo] [--target linux|macos|windows] [--desktop] [--browser] [--code] [--tailscale] [--network auto|tailscale|public] [--profile <name>] [--idle-timeout <duration>] [--timing-json]
crabbox run [--id <lease-id-or-slug>] [--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo] [--target linux|macos|windows] [--windows-mode normal|wsl2] [--desktop] [--browser] [--code] [--tailscale] [--network auto|tailscale|public] [--shell] [--checksum] [--debug] [--force-sync-large] [--timing-json] [--blacksmith-workflow <workflow>] -- <command...>
crabbox desktop launch --id <lease-id-or-slug> [--browser] [--url <url>] [--egress <profile>] [--webvnc] [--open] [-- <command...>]
crabbox desktop doctor --id <lease-id-or-slug> [--network auto|tailscale|public]
crabbox desktop click --id <lease-id-or-slug> --x <n> --y <n> [--network auto|tailscale|public]
@ -49,11 +49,6 @@ crabbox egress client --id <lease-id-or-slug> [--listen <addr>] [--ticket <ticke
crabbox egress status --id <lease-id-or-slug>
crabbox egress stop --id <lease-id-or-slug>
crabbox media preview --input <video> --output <preview.gif> [--trimmed-video-output <change.mp4>]
crabbox artifacts collect --id <lease-id-or-slug> [--output <dir>] [--run <run-id>] [--all] [--screenshot] [--video] [--gif] [--doctor] [--webvnc-status] [--metadata] [--duration <duration>] [--fps <n>] [--gif-width <px>] [--network auto|tailscale|public] [--json]
crabbox artifacts video --id <lease-id-or-slug> [--output <path>] [--duration <duration>] [--fps <n>]
crabbox artifacts gif --input <video> --output <preview.gif> [--trimmed-video-output <change.mp4>]
crabbox artifacts template openclaw|mantis [--summary <text>|--summary-file <path>] [--before <path>] [--after <path>] [--output <path>]
crabbox artifacts publish --dir <dir> [--pr <n>] [--repo owner/name] [--storage auto|broker|s3|cloudflare|r2|local] [--bucket <name>] [--prefix <path>] [--base-url <url>] [--region <region>] [--profile <profile>] [--endpoint-url <url>] [--acl <acl>] [--presign] [--expires <duration>] [--dry-run] [--no-comment]
crabbox screenshot --id <lease-id-or-slug> [--output <path>]
crabbox sync-plan [--limit <n>]
crabbox history [--lease <lease-id>] [--owner <email>] [--org <name>] [--limit <n>] [--json]
@ -126,8 +121,6 @@ crabbox share --id blue-lobster --user friend@example.com
crabbox share --id blue-lobster --org
crabbox screenshot --id blue-lobster --output desktop.png
crabbox media preview --input desktop.mp4 --output desktop-preview.gif --trimmed-video-output desktop-change.mp4
crabbox artifacts collect --id blue-lobster --all --output artifacts/blue-lobster
crabbox artifacts publish --dir artifacts/blue-lobster --pr 123
crabbox run --id blue-lobster --shell 'pnpm install --frozen-lockfile && pnpm test'
crabbox stop blue-lobster
```
@ -605,16 +598,6 @@ CRABBOX_TAILSCALE_AUTH_KEY_ENV
CRABBOX_TAILSCALE_AUTH_KEY direct-provider only, via auth-key env
CRABBOX_TAILSCALE_EXIT_NODE
CRABBOX_TAILSCALE_EXIT_NODE_ALLOW_LAN_ACCESS
CRABBOX_ARTIFACTS_STORAGE default --storage for artifacts publish
CRABBOX_ARTIFACTS_BUCKET
CRABBOX_ARTIFACTS_PREFIX
CRABBOX_ARTIFACTS_BASE_URL
CRABBOX_ARTIFACTS_AWS_REGION
CRABBOX_ARTIFACTS_AWS_PROFILE
CRABBOX_ARTIFACTS_ENDPOINT_URL
CRABBOX_ARTIFACTS_S3_ACL
CRABBOX_ARTIFACTS_PRESIGN
CRABBOX_ARTIFACTS_EXPIRES
```
Provider/deploy variables live outside normal CLI operation:

View File

@ -13,7 +13,6 @@ Command docs live here, one file per top-level command. Keep `docs/cli.md` as th
- [run](run.md)
- [desktop](desktop.md)
- [media](media.md)
- [artifacts](artifacts.md)
- [sync-plan](sync-plan.md)
- [history](history.md)
- [logs](logs.md)

View File

@ -1,235 +0,0 @@
# artifacts
`crabbox artifacts` collects desktop QA evidence into a durable bundle, creates
trimmed review media, and publishes inline-ready assets for pull requests.
Use it when a desktop/WebVNC issue or UI fix needs more than a one-off
screenshot: MP4 recording, trimmed GIF, logs, doctor output, WebVNC status, and
metadata in one directory.
## Collect
```sh
crabbox artifacts collect --id blue-lobster --output artifacts/blue-lobster
crabbox artifacts collect --id blue-lobster --all --duration 20s --output artifacts/blue-lobster
crabbox artifacts collect --id blue-lobster --run run_123 --output artifacts/blue-lobster
```
By default `collect` writes:
- `metadata.json`
- `screenshot.png`
- `doctor.txt`
- `webvnc-status.json` when a coordinator login is configured
- `logs.txt` and `run.json` when `--run <run-id>` is provided
`--all` also records `screen.mp4`, creates `screen.trimmed.gif`, and writes
`screen.trimmed.mp4` using the same motion window. Video/GIF capture currently
requires a Linux desktop lease with `ffmpeg` and X11 capture support.
Useful flags:
```text
--id <lease-id-or-slug>
--output <dir>
--run <run-id>
--all
--screenshot
--video
--gif
--doctor
--webvnc-status
--metadata
--duration <duration> default 10s
--fps <n> default 15
--gif-width <px> default 640
--provider <name>
--network auto|public|tailscale
--json
```
When collection hits an unhealthy desktop, WebVNC, VNC, or input layer, it
prints the same inline `problem:`, `detail:`, and `rescue:` commands used by the
desktop and WebVNC commands. With `--json`, stdout remains valid JSON and those
same repair hints are returned in the `warnings` array instead of being printed
as text before the JSON document. If a capture step fails after the bundle has
started, the command still exits nonzero and includes an `error` object with a
stable code and message.
## Video
```sh
crabbox artifacts video --id blue-lobster --duration 15s --output screen.mp4
```
`video` records only an MP4 from a Linux desktop lease. It is useful when you
want to keep capture separate from bundle collection.
## GIF
```sh
crabbox artifacts gif \
--input screen.mp4 \
--output screen.trimmed.gif \
--trimmed-video-output screen.trimmed.mp4
```
`gif` is an alias for the same local motion-trimmed preview logic as
[`crabbox media preview`](media.md).
## Templates
```sh
crabbox artifacts template openclaw \
--before before.png \
--after after.gif \
--summary "Login modal no longer overlaps the toolbar." \
--output summary.md
crabbox artifacts template mantis --summary-file qa-notes.md
```
Templates write Markdown with `Summary`, `Before / After`, and `Evidence`
sections sized for Mantis/OpenClaw QA comments.
## Publish
```sh
crabbox artifacts publish \
--dir artifacts/blue-lobster \
--pr 123
crabbox artifacts publish \
--dir artifacts/blue-lobster \
--pr 123 \
--storage s3 \
--bucket qa-artifacts \
--prefix pr-123/blue-lobster \
--base-url https://qa-artifacts.example.com
crabbox artifacts publish \
--dir artifacts/blue-lobster \
--pr 123 \
--storage cloudflare \
--bucket qa-artifacts \
--prefix pr-123/blue-lobster \
--base-url https://artifacts.example.com
```
`publish` uploads bundle files, writes `published-artifacts.md`, and comments
on the PR with inline images/GIFs plus links to videos, logs, and metadata.
Use `--dry-run` to generate markdown and print intended actions without upload
or comment side effects.
Storage backends:
- `--storage auto` is the default. When a coordinator is configured, Crabbox
asks the broker for upload URLs and the broker-owned artifact backend handles
storage credentials. Without a coordinator, auto falls back to local markdown.
- `--storage broker` requires a configured coordinator and uploads through
broker-minted URLs.
- `--storage s3` uses the AWS CLI and uploads to `s3://<bucket>/<prefix>/...`.
- `--storage cloudflare` uses `wrangler r2 object put --remote`.
- `--storage r2` uses the AWS CLI against an S3-compatible R2 endpoint.
- `--storage local` writes markdown only. For `--pr`, local publishing needs a
`--base-url` that already serves the files, otherwise the PR would contain
unusable local paths.
S3 flags:
```text
--bucket <name>
--prefix <path>
--base-url <url>
--region <region>
--profile <profile>
--endpoint-url <url>
--acl <acl>
--presign
--expires <duration> default 168h
```
When `--base-url` is supplied, published links use that public URL. Otherwise
`--presign` generates temporary AWS/R2 S3 URLs after upload.
Cloudflare R2 flags:
```text
--bucket <name>
--prefix <path>
--base-url <url> required for --pr inline-ready links
```
For native Cloudflare publishing, `publish` runs `wrangler` with
`CRABBOX_ARTIFACTS_CLOUDFLARE_*` when present, then the generic
`CLOUDFLARE_*` environment. Prefer brokered publishing for shared teams so
Cloudflare and object-store secrets stay on the coordinator.
For S3-compatible R2 publishing, pass `--storage r2 --endpoint-url <r2-endpoint>
--profile <r2-profile>`. When present, Crabbox uses
`CRABBOX_ARTIFACTS_R2_ENDPOINT_URL` and `CRABBOX_ARTIFACTS_R2_AWS_PROFILE`
before falling back to generic AWS defaults.
Coordinator artifact backend configuration:
```text
CRABBOX_ARTIFACTS_BACKEND=s3|r2
CRABBOX_ARTIFACTS_BUCKET
CRABBOX_ARTIFACTS_PREFIX
CRABBOX_ARTIFACTS_BASE_URL
CRABBOX_ARTIFACTS_REGION
CRABBOX_ARTIFACTS_ENDPOINT_URL
CRABBOX_ARTIFACTS_ACCESS_KEY_ID
CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY
CRABBOX_ARTIFACTS_SESSION_TOKEN
CRABBOX_ARTIFACTS_UPLOAD_EXPIRES_SECONDS
CRABBOX_ARTIFACTS_URL_EXPIRES_SECONDS
```
For brokered publishing, the CLI never receives object-store credentials. It
sends artifact names, sizes, content types, and hashes to
`POST /v1/artifacts/uploads`; the coordinator returns one short-lived upload URL
per file plus the final URL to place in Markdown. Upload grants are signed with
the declared `content-length`, so the object store rejects oversized PUTs during
the grant window; the broker also caps each upload request at 5 GiB total before
signing grants. When `--prefix` is omitted for hosted publishing, the CLI derives
a unique prefix from the PR number, bundle directory, and current time so later
QA comments do not overwrite earlier evidence.
Coordinator artifact values split into two groups:
- Worker vars: `CRABBOX_ARTIFACTS_BACKEND`, `CRABBOX_ARTIFACTS_BUCKET`,
`CRABBOX_ARTIFACTS_PREFIX`, `CRABBOX_ARTIFACTS_BASE_URL`,
`CRABBOX_ARTIFACTS_REGION`, `CRABBOX_ARTIFACTS_ENDPOINT_URL`,
`CRABBOX_ARTIFACTS_UPLOAD_EXPIRES_SECONDS`, and
`CRABBOX_ARTIFACTS_URL_EXPIRES_SECONDS`. These describe where artifacts go and
how long URLs should live.
- Worker secrets: `CRABBOX_ARTIFACTS_ACCESS_KEY_ID`,
`CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY`, and optional
`CRABBOX_ARTIFACTS_SESSION_TOKEN`. These are S3-compatible object-store keys
used only by the coordinator to sign artifact upload/read URLs.
Our deployed coordinator currently uses R2-compatible storage with public final
URLs on `https://artifacts.openclaw.ai`, bucket
`openclaw-crabbox-artifacts`, and object prefix `crabbox-artifacts`. The actual
R2 access key id and secret access key are Worker secrets; they are not required
on developer machines for normal `crabbox artifacts publish`.
Environment defaults:
```text
CRABBOX_ARTIFACTS_STORAGE
CRABBOX_ARTIFACTS_BUCKET
CRABBOX_ARTIFACTS_PREFIX
CRABBOX_ARTIFACTS_BASE_URL
CRABBOX_ARTIFACTS_AWS_REGION
CRABBOX_ARTIFACTS_AWS_PROFILE
CRABBOX_ARTIFACTS_ENDPOINT_URL
CRABBOX_ARTIFACTS_S3_ACL
CRABBOX_ARTIFACTS_PRESIGN
CRABBOX_ARTIFACTS_EXPIRES
```
`publish --pr` uses `gh issue comment <pr> --body-file ...`, so the current
checkout must be authenticated with GitHub. Pass `--repo owner/name` when the
working directory is not inside the target repository.

View File

@ -10,8 +10,10 @@ crabbox attach run_abcdef123456 --poll 500ms
## Behavior
`attach` polls the coordinator for new run events on a fixed interval,
prints them as they arrive, and exits when the run finishes.
`attach` follows coordinator run events, prints them as they arrive, and exits
when the run finishes. Newer brokers stream events over the authenticated
coordinator control WebSocket; older brokers or dropped sockets fall back to
the HTTPS events API from the last printed sequence.
- stdout and stderr preview events are written back to stdout and stderr,
preserving the stream split;
@ -20,8 +22,8 @@ prints them as they arrive, and exits when the run finishes.
message;
- when the run has already finished, attach prints any remaining events
and exits;
- when the run is still active, attach polls until it sees a `finish`
event.
- when the run is still active, attach waits for streamed events or polls until
it sees the run finish.
`attach` is not detached command execution. It follows the events the
original CLI is emitting; if that CLI process dies, the run state remains
@ -40,7 +42,7 @@ after completion.
```text
--id <run-id> run id (also accepted as a positional argument)
--after <seq> resume after this event sequence number
--poll <duration> polling interval, default 1s
--poll <duration> fallback poll interval and WebSocket idle check, default 1s
```
## Use Cases

View File

@ -47,7 +47,7 @@ error and continue with the next candidate.
## Flags
```text
--provider hetzner|aws|azure provider to sweep (delegated providers do not need cleanup)
--provider hetzner|aws provider to sweep (delegated providers do not need cleanup)
--target linux|macos|windows for AWS, restrict by target
--windows-mode normal|wsl2 when target=windows
--static-host <host> ignored (provider=ssh has nothing to sweep)

View File

@ -64,7 +64,7 @@ and extension-host traffic stay below coordinator websocket frame limits.
```text
--id <lease-id-or-slug>
--provider hetzner|aws|azure
--provider hetzner|aws
--target linux
--network auto|tailscale|public
--local-port <port>

View File

@ -15,7 +15,7 @@ Subcommands:
```text
path
show [--json]
set-broker --url <url> [--token-stdin] [--admin-token-stdin] [--provider hetzner|aws|azure]
set-broker --url <url> [--token-stdin] [--admin-token-stdin] [--provider hetzner|aws]
```
`config show` reports broker auth as `auth` and `admin_auth`, plus

View File

@ -73,7 +73,7 @@ Flags:
```text
--id <lease-id-or-slug>
--provider hetzner|aws|azure|ssh|daytona
--provider hetzner|aws|ssh
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>

View File

@ -71,7 +71,7 @@ the exit code.
## Flags
```text
--provider hetzner|aws|azure|ssh provider to validate
--provider hetzner|aws|ssh provider to validate
--target linux|macos|windows target OS for ssh provider checks
--windows-mode normal|wsl2 when target=windows
--static-host <host> static SSH host

View File

@ -37,7 +37,7 @@ included.
```text
--id <lease-id-or-slug> lease to inspect; required for managed providers
--provider hetzner|aws|azure|ssh|daytona override the configured provider
--provider hetzner|aws|ssh override the configured provider
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host> static SSH host for provider=ssh

View File

@ -35,7 +35,7 @@ use the normalized Crabbox lease view.
Flags:
```text
--provider hetzner|aws|azure|ssh|blacksmith-testbox|daytona|islo
--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>

View File

@ -27,7 +27,7 @@ Flags:
```text
--url <url> broker URL
--provider hetzner|aws|azure default provider to store with the broker
--provider hetzner|aws default provider to store with the broker
--no-browser print the GitHub login URL instead of opening it
--token-stdin read broker token from stdin for operator automation
--json print JSON

View File

@ -89,7 +89,7 @@ Flags:
```text
--id <lease-id-or-slug>
--provider hetzner|aws|azure|ssh|blacksmith-testbox|daytona|islo
--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>

View File

@ -38,7 +38,7 @@ Flags:
```text
--id <lease-id-or-slug>
--provider hetzner|aws|azure|ssh|daytona
--provider hetzner|aws|ssh
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>

View File

@ -15,7 +15,7 @@ Flags:
```text
--id <lease-id-or-slug>
--provider hetzner|aws|azure|ssh|daytona
--provider hetzner|aws|ssh|daytona
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>

View File

@ -26,7 +26,7 @@ Flags:
```text
--id <lease-id-or-slug>
--provider hetzner|aws|azure|ssh|blacksmith-testbox|daytona|islo
--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>

View File

@ -15,7 +15,7 @@ The argument accepts the stable `cbx_...` ID or an active friendly slug. In `bla
Flags:
```text
--provider hetzner|aws|azure|ssh|blacksmith-testbox|daytona|islo
--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>

View File

@ -233,7 +233,7 @@ make sure the Dedicated Host is allocated in the selected AWS region.
```text
--id <lease-id-or-slug>
--provider hetzner|aws|azure|ssh|daytona
--provider hetzner|aws|ssh
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>

View File

@ -9,7 +9,6 @@ crabbox warmup --browser
crabbox warmup --tailscale
crabbox warmup --desktop --browser
crabbox warmup --provider aws --target windows --desktop
crabbox warmup --provider azure --target windows
crabbox warmup --provider aws --target macos --desktop --market on-demand --type mac2.metal
crabbox warmup --actions-runner
crabbox warmup --provider blacksmith-testbox --blacksmith-workflow .github/workflows/ci-check-testbox.yml --blacksmith-job test
@ -42,10 +41,8 @@ the updated PATH.
With `--provider hetzner`, managed provisioning supports Linux only. Hetzner can
run Windows through ISO/snapshot installation flows, but Crabbox does not manage
that path today. Use `--provider aws --target windows` for managed Windows
desktop or WSL2, `--provider azure --target windows` for native Windows
SSH/sync/run, or `--provider ssh --target windows` for an existing Hetzner
Windows host.
that path today. Use `--provider aws --target windows` for managed Windows, or
`--provider ssh --target windows` for an existing Hetzner Windows host.
With `--provider aws --target windows --windows-mode normal --desktop`, Crabbox
creates a real AWS Windows Server lease. EC2Launch user data installs OpenSSH
@ -60,11 +57,6 @@ imports an Ubuntu rootfs, and prepares the Linux-side `crabbox-ready` toolchain.
The AWS launch enables nested virtualization and uses C8i, M8i, or R8i instance
families for this mode. Commands and sync then use the POSIX WSL contract.
With `--provider azure --target windows`, Crabbox creates a native Windows
Server lease, uses the Azure VM Agent Custom Script Extension to install
OpenSSH Server and Git for Windows, and configures the `crabbox` user for
SSH/sync/run. Azure Windows does not provision VNC/browser/WSL2.
With `--provider aws --target macos --desktop`, Crabbox launches an EC2 Mac
instance on an already allocated Dedicated Host. Set `CRABBOX_AWS_MAC_HOST_ID`
or `aws.macHostId`, use `--market on-demand`, and expect EC2 Mac host lifecycle
@ -77,7 +69,7 @@ On success, `warmup` prints a concise total duration line. Add `--timing-json` t
Flags:
```text
--provider hetzner|aws|azure|ssh|blacksmith-testbox|daytona|islo
--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>

View File

@ -66,15 +66,6 @@ pid file under its local state directory and prints both paths. Use
that lease. Shutdown terminates both the daemon supervisor and the active child
bridge process.
The bridge keeps a warm pool of backend VNC sessions open (default 4 slots,
which is what the `slots=` field in `webvnc status` reports). That lets
multiple portal viewers join the same lease: one viewer is the controller,
later viewers start in observer mode, and any viewer can press **take over**
to become the controller — including the prior controller, who stays connected
as an observer and can reclaim control the same way. Observer mode is a
collaboration UX for trusted shared leases; it relies on the portal noVNC
client staying read-only and is not a hostile-client isolation boundary.
The older `crabbox webvnc --id <lease> --daemon`, `--background`, `--status`,
and `--stop` forms remain accepted as compatibility aliases, but new docs and
automation should use the explicit `daemon` subcommands.
@ -92,8 +83,7 @@ Typical status output is meant to be directly actionable:
webvnc daemon: pid=12345 log=...
vnc target: reachable 127.0.0.1:5900 managed=true
ssh tunnel: ssh ... -L 5901:127.0.0.1:5900 ...
portal bridge: connected=true viewers=2 observers=1 slots=2
portal controller: peter
portal bridge: connected=true viewer=false
event: 2026-05-07T12:00:00Z bridge_connected
webvnc: https://crabbox.openclaw.ai/portal/leases/cbx_.../vnc#password=...
fallback: crabbox vnc --provider aws --target linux --network tailscale --id cbx_... --open
@ -102,10 +92,10 @@ fallback: crabbox vnc --provider aws --target linux --network tailscale --id cbx
When a layer is unhealthy, the CLI prints `problem:`, optional `detail:`, and
one or more exact `rescue:` commands in the command output, not only in docs.
Common problems include `VNC bridge disconnected`, `WebVNC daemon not running`,
`waiting for an available WebVNC observer slot`, and `VNC target unreachable`.
If the browser portal path looks unhealthy but the target VNC service is
reachable, the output also prints the native `crabbox vnc ... --open` fallback
command with the same provider/target/network flags.
`WebVNC viewer already active`, and `VNC target unreachable`. If the browser
portal path looks unhealthy but the target VNC service is reachable, the output
also prints the native `crabbox vnc ... --open` fallback command with the same
provider/target/network flags.
Use `crabbox webvnc reset --id <lease> --open` when the portal is stuck on a
stale bridge/viewer/session. Reset closes only that lease's coordinator
@ -155,7 +145,7 @@ Flags:
```text
--id <lease-id-or-slug>
--provider hetzner|aws|azure
--provider hetzner|aws
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>
@ -177,7 +167,7 @@ daemon stop
Limitations:
- Coordinator-backed Hetzner, AWS, and Azure Linux desktop leases are supported.
- Coordinator-backed Hetzner and AWS desktop leases are supported.
- Static SSH hosts are intentionally not supported yet because the portal cannot
prove that host-managed VNC credentials and prompts are safe to expose.
- Blacksmith Testbox still owns its own machine connectivity.
@ -189,7 +179,7 @@ Limitations:
Run `crabbox login` for the coordinator you are using. WebVNC needs both the CLI
bridge and the browser portal to authenticate with the coordinator.
`webvnc currently supports coordinator-backed hetzner/aws/azure desktop leases`
`webvnc currently supports coordinator-backed hetzner/aws desktop leases`
WebVNC is not available for static SSH hosts or Blacksmith Testbox. Use
`crabbox vnc` for static hosts when you explicitly trust the host-managed VNC
@ -209,11 +199,9 @@ with that lease. Start or restart `crabbox webvnc daemon start --id <lease>
session state are likely. If the command is still running, wait for the portal
retry or reload the browser tab.
`waiting for an available WebVNC observer slot`
`WebVNC viewer already active`
The portal is reachable, but all bridge slots are already paired with viewers.
Restart the bridge with a current Crabbox CLI so it opens the default backend
pool. If the portal still cannot get a slot, run:
Close old WebVNC tabs first. If the portal still reports a stale viewer, run:
```sh
crabbox webvnc reset --id <lease-id-or-slug> --open

View File

@ -35,7 +35,6 @@ Read when:
- [Provider backends](../provider-backends.md): contract reference for backend interfaces and registration.
- [Authoring a provider](provider-authoring.md): step-by-step guide to writing a new provider.
- [AWS](aws.md): EC2 Linux, Windows, WSL2, EC2 Mac, capacity, AMIs, and security groups.
- [Azure](azure.md): Azure Linux/native Windows, shared infra, capacity, and cleanup.
- [Hetzner](hetzner.md): Linux-only managed Hetzner behavior, classes, and cleanup.
- [Blacksmith Testbox](blacksmith-testbox.md): delegated Testbox backend behavior.
- [Daytona](daytona.md): Daytona SDK/toolbox sandbox leases with optional short-lived SSH access.
@ -55,7 +54,6 @@ Read when:
- [Sync](sync.md): Git file-list manifests, rsync, fingerprints, excludes, guardrails, and sanity checks.
- [Actions hydration](actions-hydration.md): let GitHub Actions prepare a runner, then sync local work into that workspace.
- [Interactive desktop and VNC](interactive-desktop-vnc.md): VNC hub, support matrix, tunnel model, and QA boundaries.
- [Artifacts](artifacts.md): screenshots, video, trimmed GIFs, logs, metadata, templates, and PR publishing.
- [Linux VNC](vnc-linux.md), [Windows VNC](vnc-windows.md), [macOS VNC](vnc-macos.md): OS-specific desktop setup and troubleshooting.
- [Test results](test-results.md): JUnit summaries attached to recorded runs.
- [Cache controls](cache.md): inspect, purge, and warm remote package/build caches.
@ -75,7 +73,6 @@ Read when:
- [history](../commands/history.md)
- [logs](../commands/logs.md)
- [results](../commands/results.md)
- [artifacts](../commands/artifacts.md)
- [cache](../commands/cache.md)
- [status](../commands/status.md)
- [list](../commands/list.md)

View File

@ -8,9 +8,9 @@ Read when:
Actions hydration lets a repository reuse its existing GitHub Actions setup without putting repository-specific setup code in the Crabbox binary.
Runner registration is currently Linux-only. Brokered Hetzner/AWS/Azure Linux
targets work; static macOS/Windows and managed Windows/macOS targets are for
direct `crabbox run` loops until platform-specific runner installation is added.
Runner registration is currently Linux-only. Brokered Hetzner/AWS Linux targets
work; static macOS/Windows and managed AWS Windows/macOS targets are for direct
`crabbox run` loops until platform-specific runner installation is added.
The flow:

View File

@ -1,119 +0,0 @@
# Artifacts
Read when:
- collecting screenshots, videos, logs, or metadata from a desktop lease;
- turning a desktop recording into a trimmed GIF;
- publishing QA proof into a GitHub PR;
- deciding whether AWS S3 or Cloudflare R2 should host inline assets.
Crabbox artifacts are a local bundle plus optional hosted URLs. The command is
designed for QA handoff: capture the state of a lease, preserve enough metadata
to reproduce what happened, and publish a concise before/after/summary comment.
## Bundle Contract
`crabbox artifacts collect --id <lease>` writes a directory such as
`artifacts/blue-lobster` with:
- `metadata.json`: Crabbox version, lease id, slug, provider, network, target,
run id when provided, and capture time.
- `screenshot.png`: a desktop screenshot captured through the managed VNC
boundary.
- `doctor.txt`: the same desktop/session checks as `crabbox desktop doctor`.
- `webvnc-status.json`: bridge/viewer status when the lease is coordinator
backed.
- `logs.txt` and `run.json`: retained run output and run metadata when
`--run <run-id>` is set.
- `screen.mp4`, `screen.trimmed.gif`, and `screen.trimmed.mp4` when video/GIF
capture is requested.
Failures keep the rescue-first UX. If the input stack is dead, the VNC bridge
is disconnected, the browser did not launch, or screenshot/video capture fails,
the command prints a concrete `problem:` plus exact `rescue:` commands before
returning. In `--json` mode those hints are kept in `warnings`, stdout remains
parseable JSON, and post-start capture failures add an `error` object while
still returning a nonzero exit code.
## Media
Video capture is intentionally lease-local and Linux-first. The CLI records
the X11 desktop with remote `ffmpeg` and streams the MP4 back over SSH. GIF
generation then reuses the local motion-trimming logic from `crabbox media
preview`: leading/trailing static regions are removed and an optional trimmed
MP4 can be emitted beside the GIF.
Use `desktop launch --fullscreen` only when the artifact should show a
browser-only capture. The standard human QA profile remains windowed so panel
and window chrome stay visible.
## Publishing
GitHub comments cannot directly upload arbitrary local files through the issue
comment API. `crabbox artifacts publish --pr <n>` therefore uploads files to a
storage backend first, renders Markdown with inline image/GIF links, writes the
same body to `published-artifacts.md`, and posts that body with `gh`.
Supported storage:
- Brokered coordinator publishing through `crabbox artifacts publish` with no
storage flags. The coordinator owns object-store credentials and returns
short-lived upload URLs plus final public URLs.
- AWS S3 through the `aws` CLI.
- Cloudflare R2 through `wrangler r2 object put`.
- Local/hosted mode through `--storage local --base-url <url>` when another
process already serves the bundle.
For AWS S3, use either public/custom-domain URLs through `--base-url` or
temporary links through `--presign --expires <duration>`. For Cloudflare R2,
provide a public bucket/custom-domain `--base-url` when publishing to a PR;
without it, the upload can succeed but the PR would only have `r2://` object
identifiers, not inline-ready links.
## Broker Secret Model
Brokered publishing is intentionally asymmetric. Local users and agents only
need normal Crabbox coordinator auth. The coordinator holds the storage keys and
uses them to sign one upload request per artifact. Each upload grant includes a
signed `content-length`, so the configured size cap is enforced by the storage
backend, not only by the request metadata. The broker enforces both a 1 GiB
per-file cap and a 5 GiB per-request aggregate cap before minting upload URLs.
When users do not pass `--prefix`, hosted publishing adds a unique
PR/bundle/timestamp prefix so later artifact bundles cannot overwrite links from
earlier QA comments.
Coordinator artifact vars describe the backend:
- `CRABBOX_ARTIFACTS_BACKEND`: `s3` or `r2`.
- `CRABBOX_ARTIFACTS_BUCKET`: destination bucket.
- `CRABBOX_ARTIFACTS_PREFIX`: root object prefix for all brokered uploads.
- `CRABBOX_ARTIFACTS_BASE_URL`: public URL prefix for final Markdown links.
- `CRABBOX_ARTIFACTS_REGION` and `CRABBOX_ARTIFACTS_ENDPOINT_URL`: S3/R2 signing
endpoint details.
- `CRABBOX_ARTIFACTS_UPLOAD_EXPIRES_SECONDS`: lifetime for write grants.
- `CRABBOX_ARTIFACTS_URL_EXPIRES_SECONDS`: lifetime for signed read URLs when
no public base URL is configured.
Coordinator artifact secrets authorize signing:
- `CRABBOX_ARTIFACTS_ACCESS_KEY_ID`
- `CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY`
- `CRABBOX_ARTIFACTS_SESSION_TOKEN` when the backend uses temporary
credentials.
These keys are object-store credentials, not Crabbox provider credentials. They
should be scoped to the artifact bucket/prefix and should not grant Worker
deployment, Cloudflare account administration, lease creation, or cloud VM
provider access. The CLI receives only pre-signed URLs and final asset URLs.
## Templates
`crabbox artifacts template openclaw` and `crabbox artifacts template mantis`
produce Markdown with:
- `Summary`
- `Before / After`
- `Evidence`
The publish command uses the same layout, so local preview and PR comments stay
consistent.

View File

@ -1,133 +0,0 @@
# Azure
Read when:
- choosing Azure as the Crabbox provider;
- debugging Azure VM capacity, quotas, images, or SSH readiness;
- changing Azure provisioning code in the CLI.
Azure is a managed provider for Linux and native Windows SSH leases. It
creates VMs in a shared resource group, tags them with Crabbox lease
metadata, and bootstraps the normal SSH/sync contract through cloud-init
on Linux or Custom Script Extension on Windows. It works in direct mode with
local Azure auth and in brokered mode through Worker-owned service principal
secrets.
## Targets
| Target | Managed | Notes |
| --- | --- | --- |
| Linux | Yes | Cloud-init bootstrap, SSH, rsync, optional desktop/browser/code. |
| Windows | Yes | Native Windows SSH/sync/run only. No Azure desktop/browser/WSL2. |
| macOS | No | Azure does not offer managed macOS; use AWS EC2 Mac or static SSH. |
Examples:
```sh
crabbox warmup --provider azure --class beast
crabbox run --provider azure --class standard -- pnpm test
crabbox warmup --provider azure --target windows --class standard
crabbox warmup --provider azure --desktop --browser
crabbox vnc --id blue-lobster --open
```
## Classes
```text
standard Standard_D32ads_v6, Standard_D32ds_v6, Standard_F32s_v2, then D/F 16-vCPU fallbacks
fast Standard_D64ads_v6, Standard_D64ds_v6, Standard_F64s_v2, then D/F 48-vCPU and 32-vCPU fallbacks
large Standard_D96ads_v6, Standard_D96ds_v6, then D/F 64-vCPU and 48-vCPU fallbacks
beast Standard_D192ds_v6, Standard_D128ds_v6, then D/F 96-vCPU and 64-vCPU fallbacks
```
Native Windows uses the smaller AWS Windows class scale:
```text
standard Standard_D2ads_v6, Standard_D2ds_v6, Standard_D2ads_v5, Standard_D2ds_v5, then Standard_D2as_v6
fast Standard_D4ads_v6, Standard_D4ds_v6, Standard_D4ads_v5, Standard_D4ds_v5, then Standard_D4as_v6
large Standard_D8ads_v6, Standard_D8ds_v6, Standard_D8ads_v5, Standard_D8ds_v5, then Standard_D8as_v6
beast Standard_D16ads_v6, Standard_D16ds_v6, Standard_D16ads_v5, Standard_D16ds_v5, then Standard_D8ads_v6
```
Crabbox falls back through the candidate list when Azure rejects a SKU for
capacity or quota. Explicit `--type` is exact and fails clearly when the
SKU cannot be created. Spot leases fall back to on-demand when
`capacity.fallback` starts with `on-demand`.
Default Azure Linux class candidates mirror the vCPU scale of the AWS Linux
class table. Default Azure native Windows candidates mirror the AWS native
Windows class table. Crabbox asks Azure Resource SKUs whether the selected VM
supports ephemeral OS disks; ephemeral-capable sizes use local OS disks,
while exact `--type` requests for non-ephemeral sizes use managed
`StandardSSD_LRS` OS disks.
## Direct Auth And Env
Service principal env vars consumed by `DefaultAzureCredential`:
```text
AZURE_TENANT_ID
AZURE_CLIENT_ID
AZURE_CLIENT_SECRET
AZURE_SUBSCRIPTION_ID
```
Crabbox-specific overrides:
```text
CRABBOX_AZURE_SUBSCRIPTION_ID
CRABBOX_AZURE_TENANT_ID
CRABBOX_AZURE_CLIENT_ID
CRABBOX_AZURE_LOCATION
CRABBOX_AZURE_RESOURCE_GROUP
CRABBOX_AZURE_IMAGE
CRABBOX_AZURE_VNET
CRABBOX_AZURE_SUBNET
CRABBOX_AZURE_NSG
CRABBOX_AZURE_SSH_CIDRS
```
The service principal needs the
[Contributor](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#contributor)
role on the target resource group (or subscription, if you want Crabbox to
create the resource group on first use).
Brokered Azure uses `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`,
`AZURE_CLIENT_SECRET`, and `AZURE_SUBSCRIPTION_ID` on the Worker. Operators
own the shared infra settings through `CRABBOX_AZURE_*`. Lease requests may
override only `azureLocation` and `azureImage`.
## Shared Infra
The first acquire in an empty subscription creates:
- a resource group (default `crabbox-leases`);
- a virtual network and subnet (`10.42.0.0/16` / `10.42.0.0/24`);
- a network security group with SSH rules derived from `azure.sshCIDRs`,
the configured SSH port, and fallback ports.
These resources are created with `createOrUpdate` and reused across leases.
Per-lease provisioning creates only the public IP, NIC, VM, and OS disk.
Azure pricing is not hardcoded. Use `CRABBOX_COST_RATES_JSON` for exact
Azure cost guardrails.
## Desktop
Azure desktop leases use the standard Linux VNC path: Xvfb, a lightweight
desktop session, x11vnc bound to `127.0.0.1:5900`, and an SSH local tunnel
created by `crabbox vnc`. Azure native Windows currently supports SSH, sync,
and run only. Use AWS for managed Windows desktop or Windows WSL2.
## Cleanup
Direct cleanup is best-effort through Crabbox lease tags. `crabbox cleanup
--provider azure` enumerates VMs in the configured resource group, skips
kept or unexpired leases, and cascade-deletes expired ones. The shared
resource group, vnet, subnet, and NSG are preserved.
Related docs:
- [Providers](providers.md)
- [Linux VNC](vnc-linux.md)
- [cleanup command](../commands/cleanup.md)

View File

@ -14,7 +14,6 @@ Responsibilities:
- serialize fleet state in one Durable Object;
- create, heartbeat, release, expire, and look up leases;
- own provider credentials;
- own artifact storage credentials and mint scoped artifact upload URLs;
- create and delete provider resources;
- list the pool;
- enforce cost and active-lease guardrails;
@ -36,7 +35,6 @@ POST /v1/runs
GET /v1/runs/{run-id}
GET /v1/runs/{run-id}/logs
POST /v1/runs/{run-id}/finish
POST /v1/artifacts/uploads
GET /v1/runners
POST /v1/runners/sync
GET /v1/usage

View File

@ -69,10 +69,8 @@ Scenario systems such as Mantis own:
| --- | --- | --- | --- |
| Linux on Hetzner | Yes | Xvfb/XFCE/x11vnc over SSH tunnel | [Linux VNC](vnc-linux.md) |
| Linux on AWS | Yes | Xvfb/XFCE/x11vnc over SSH tunnel | [Linux VNC](vnc-linux.md) |
| Linux on Azure | Yes | Xvfb/XFCE/x11vnc over SSH tunnel | [Linux VNC](vnc-linux.md) |
| AWS Windows | Yes | TightVNC over SSH tunnel | [Windows VNC](vnc-windows.md) |
| AWS EC2 Mac | Yes | Screen Sharing/VNC over SSH tunnel | [macOS VNC](vnc-macos.md) |
| Azure Windows | No | SSH/sync/run only | [Azure](azure.md) |
| Static Linux | Host-managed | Existing loopback VNC service | [Linux VNC](vnc-linux.md) |
| Static macOS | Host-managed | Existing Screen Sharing/VNC | [macOS VNC](vnc-macos.md) |
| Static Windows | Host-managed | Existing VNC service | [Windows VNC](vnc-windows.md) |
@ -105,14 +103,6 @@ connects to the coordinator with a one-use bridge ticket, and the browser uses
bundled noVNC from the authenticated portal. The portal does not connect to the
runner by itself; the local bridge must keep running.
WebVNC supports collaborative viewing. The local bridge keeps a warm pool of
backend VNC sessions (default 4 slots), the first browser viewer controls the
lease, and additional viewers join as read-only observers. Any viewer — a new
observer or the prior controller — can press **take over** to become the
controller; whoever loses control stays connected as an observer and sees who
took over. Observer mode is intended for trusted shared leases; it is not a
hostile-client security boundary.
The portal toolbar supports explicit clipboard exchange. Paste reads the local
browser clipboard, forwards it to the remote VNC server, and sends the target
paste shortcut. Copy-remote is enabled after the remote server publishes
@ -126,14 +116,6 @@ Use `crabbox screenshot` when you need a PNG without taking over the session:
crabbox screenshot --id blue-lobster --output desktop.png
```
Use `crabbox artifacts` when QA needs a durable proof bundle instead of a
single screenshot:
```sh
crabbox artifacts collect --id blue-lobster --all --output artifacts/blue-lobster
crabbox artifacts publish --dir artifacts/blue-lobster --pr 123 --storage s3 --bucket qa-artifacts
```
Use `crabbox desktop launch` to start a browser or app inside the visible
session without keeping the SSH command attached:
@ -187,11 +169,10 @@ Managed VNC is tunnel-first:
- `--network tailscale` changes only the SSH endpoint used by that tunnel.
- WebVNC keeps the same local SSH tunnel and adds an authenticated browser
websocket through the coordinator.
- WebVNC browser websockets are paired with local bridge backend sessions
inside the coordinator Durable Object. One viewer is the controller; other
viewers are observers until they press **take over**. If a browser view
disconnects, only its paired backend session is reset and the local command
reconnects a fresh bridge slot for the next portal retry.
- The WebVNC browser websocket is paired with the local bridge process inside
the coordinator Durable Object; if the browser view disconnects, the local
command reconnects a fresh bridge for the portal retry. If the local process
exits, the browser view disconnects until you start it again.
- `crabbox webvnc status` reports the local daemon pid/log, SSH tunnel command,
target VNC reachability, coordinator bridge/viewer state, recent bridge
events, portal URL/password, and the exact native `crabbox vnc ... --open`
@ -242,6 +223,6 @@ often machine- and user-encrypted.
- [AWS](aws.md): AWS target matrix, capacity, AMIs, and EC2 Mac host requirements.
- [Hetzner](hetzner.md): Linux-only managed Hetzner behavior.
- [Blacksmith Testbox](blacksmith-testbox.md): delegated Testbox behavior and why VNC is not a Crabbox feature there yet.
- [vnc command](../commands/vnc.md), [webvnc command](../commands/webvnc.md), [screenshot command](../commands/screenshot.md), [desktop command](../commands/desktop.md), [artifacts command](../commands/artifacts.md), [egress command](../commands/egress.md).
- [vnc command](../commands/vnc.md), [webvnc command](../commands/webvnc.md), [screenshot command](../commands/screenshot.md), [desktop command](../commands/desktop.md), [egress command](../commands/egress.md).
- [Mediated egress](egress.md): per-app browser/app egress through the operator
machine for Discord, Slack, and similar source-IP-sensitive QA.

View File

@ -2,22 +2,20 @@
Read when:
- changing Hetzner, AWS, Azure, or Blacksmith Testbox provisioning;
- changing Hetzner, AWS, or Blacksmith Testbox provisioning;
- adding a backend;
- adjusting machine classes, fallback order, regions, or images.
Crabbox currently supports three brokered providers:
Crabbox currently supports two brokered providers:
```text
hetzner Hetzner Cloud servers
aws AWS EC2 instances
azure Azure Virtual Machines
```
Brokered Hetzner leases are Linux targets. Brokered AWS supports Linux, native
Windows Server, Windows WSL2, and EC2 Mac when a Dedicated Host is configured.
Brokered Azure supports Linux and native Windows SSH/sync/run. Static SSH still
exists for reusing existing macOS and Windows machines:
Windows Server, and EC2 Mac when a Dedicated Host is configured. Static SSH
still exists for reusing existing macOS and Windows machines:
```text
ssh Existing SSH host selected by static.host
@ -34,7 +32,6 @@ islo Islo sandboxes with delegated command execution
- [Provider reference](../providers/README.md): one page per built-in backend.
- [AWS](../providers/aws.md): EC2 Linux, Windows, WSL2, EC2 Mac, capacity, AMIs, and security groups.
- [Azure](../providers/azure.md): Azure Linux/native Windows, shared infra, capacity, and cleanup.
- [Hetzner](../providers/hetzner.md): Linux-only managed provider behavior, classes, and cleanup.
- [Static SSH](../providers/ssh.md): existing Linux, macOS, and Windows SSH hosts.
- [Blacksmith Testbox](../providers/blacksmith-testbox.md): delegated Testbox backend behavior.

View File

@ -7,7 +7,7 @@ Read when:
- changing SSH, VNC, or coordinator bootstrap behavior.
Tailscale is an optional Crabbox reachability layer. It is not a provider.
Providers still own machines: Hetzner, AWS, Azure, static SSH hosts, and Blacksmith
Providers still own machines: Hetzner, AWS, static SSH hosts, and Blacksmith
Testbox. Tailscale only changes which host Crabbox dials for SSH-backed work.
V1 support:

View File

@ -36,11 +36,17 @@ Cloud machines are vanilla Ubuntu runners that hold no broker secrets. They are
The CLI talks to the broker over HTTPS, then talks **directly** to the leased runner over SSH and rsync. The runner never calls the broker; that path stays one-way.
For long-lived coordinator interactions, newer CLIs also open one authenticated
WebSocket to the Fleet Durable Object at `/v1/control`. That socket carries
run-event attach streams and lease heartbeats so high-latency links do less
request polling. HTTPS endpoints remain canonical storage and compatibility
fallbacks, so older CLIs and older brokers still work.
## Ownership
| Layer | Owns |
|:------------|:----------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **CLI** | config + flags; per-lease SSH key; SSH readiness; Git seeding + rsync; sync fingerprints + sanity checks; remote command + streaming; heartbeats; release |
| **CLI** | config + flags; per-lease SSH key; SSH readiness; Git seeding + rsync; sync fingerprints + sanity checks; remote command + streaming; control WebSocket when available; HTTP fallback; release |
| **Broker** | request auth + identity; serialized lease state; provider credentials; machine create/delete; lease expiry; pool/status/inspect; usage; spend caps |
| **Provider** | raw compute: Hetzner Cloud servers or AWS EC2 instances |
| **Runner** | nothing durable for brokered boxes: Linux prepared by cloud-init with SSH, Git, rsync, curl, jq, `/work/crabbox`; AWS Windows/WSL2/macOS targets have provider-specific bootstrap; static targets are existing SSH hosts; project runtimes come from repo-owned setup |

View File

@ -352,26 +352,8 @@ CRABBOX_TAILSCALE_CLIENT_ID optional; required for brokered --tailscale
CRABBOX_TAILSCALE_CLIENT_SECRET optional; required for brokered --tailscale
CRABBOX_TAILSCALE_TAILNET optional
CRABBOX_TAILSCALE_TAGS optional
CRABBOX_ARTIFACTS_BACKEND optional; currently r2
CRABBOX_ARTIFACTS_BUCKET optional; currently openclaw-crabbox-artifacts
CRABBOX_ARTIFACTS_PREFIX optional; currently crabbox-artifacts
CRABBOX_ARTIFACTS_BASE_URL optional; currently https://artifacts.openclaw.ai
CRABBOX_ARTIFACTS_REGION optional; currently auto
CRABBOX_ARTIFACTS_ENDPOINT_URL optional; currently the R2 S3-compatible endpoint
CRABBOX_ARTIFACTS_ACCESS_KEY_ID optional; Worker secret when artifacts backend is enabled
CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY optional; Worker secret when artifacts backend is enabled
CRABBOX_ARTIFACTS_SESSION_TOKEN optional; Worker secret for temporary credentials
CRABBOX_ARTIFACTS_UPLOAD_EXPIRES_SECONDS optional
CRABBOX_ARTIFACTS_URL_EXPIRES_SECONDS optional
```
Artifact credentials on the coordinator are storage-only S3-compatible keys.
They exist so the Worker can sign one upload URL per artifact and return the
final asset URL. They are not Cloudflare deploy tokens, not Crabbox bearer/admin
tokens, and not VM provider credentials. Keep direct local S3/R2 credentials as
operator fallback only; normal artifact publishing should go through the
coordinator.
## Verified OpenClaw Run
Historical warm-run command from an OpenClaw checkout through the Cloudflare coordinator:

View File

@ -115,41 +115,8 @@ CRABBOX_TAILSCALE_CLIENT_SECRET required for brokered --tailscale
CRABBOX_TAILSCALE_TAILNET optional
CRABBOX_TAILSCALE_TAGS optional
CRABBOX_TAILSCALE_ENABLED optional; set 0 to disable brokered Tailscale
CRABBOX_ARTIFACTS_BACKEND optional; enables brokered artifact publishing
CRABBOX_ARTIFACTS_BUCKET required when artifact backend is enabled
CRABBOX_ARTIFACTS_PREFIX optional
CRABBOX_ARTIFACTS_BASE_URL optional; public final artifact URL prefix
CRABBOX_ARTIFACTS_REGION optional
CRABBOX_ARTIFACTS_ENDPOINT_URL optional; required for R2/custom S3 endpoints
CRABBOX_ARTIFACTS_ACCESS_KEY_ID required when artifact backend is enabled
CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY required when artifact backend is enabled
CRABBOX_ARTIFACTS_SESSION_TOKEN optional
CRABBOX_ARTIFACTS_UPLOAD_EXPIRES_SECONDS optional
CRABBOX_ARTIFACTS_URL_EXPIRES_SECONDS optional
```
Artifact backend vars are ordinary Worker vars except
`CRABBOX_ARTIFACTS_ACCESS_KEY_ID`, `CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY`, and
optional `CRABBOX_ARTIFACTS_SESSION_TOKEN`, which must be Worker secrets. These
object-store keys let the coordinator sign short-lived artifact upload/read
URLs; they should be scoped to the artifact bucket or prefix and should not have
Cloudflare account, Worker deployment, lease-provider, or VM permissions.
Our current coordinator artifact config is R2-compatible:
```text
CRABBOX_ARTIFACTS_BACKEND=r2
CRABBOX_ARTIFACTS_BUCKET=openclaw-crabbox-artifacts
CRABBOX_ARTIFACTS_PREFIX=crabbox-artifacts
CRABBOX_ARTIFACTS_BASE_URL=https://artifacts.openclaw.ai
CRABBOX_ARTIFACTS_REGION=auto
CRABBOX_ARTIFACTS_ENDPOINT_URL=<account>.r2.cloudflarestorage.com
```
The corresponding R2 access key id and secret access key are deployed as Worker
secrets, not local CLI defaults. Normal users should run
`crabbox artifacts publish` without direct S3/R2 credentials.
Cost-control secrets and settings:
```text

View File

@ -9,6 +9,19 @@ Read when:
Crabbox performance comes from avoiding repeated setup, keeping the sync small, choosing available capacity, and reusing project-defined hydration when it matters.
## High-Latency Links
Crabbox should not require a special slow-network mode. The CLI keeps SSH as
the universal command transport, but uses SSH ControlMaster with a longer
persist window so repeated probes, sync helpers, and commands avoid paying a
new handshake every time. Streaming commands retry coordinator-provided
fallback ports just like readiness and helper probes.
When the broker supports it, `crabbox attach` and lease heartbeats use one
authenticated coordinator WebSocket instead of repeated HTTP polls. If the
socket cannot connect or drops, the CLI resumes through the existing HTTPS API
from the last acknowledged run-event sequence.
## Warm Leases
Use `warmup` for repeated agent loops:

View File

@ -12,7 +12,6 @@ static SSH provider for existing machines.
| Provider | Backend kind | Targets | Best for |
| --- | --- | --- | --- |
| [AWS](aws.md) | SSH lease | Linux, Windows, macOS | broad managed capacity, Windows, EC2 Mac |
| [Azure](azure.md) | SSH lease | Linux, Windows | Azure-backed Linux and native Windows capacity |
| [Hetzner](hetzner.md) | SSH lease | Linux | fast Linux capacity at low cost |
| [Static SSH](ssh.md) | SSH lease | Linux, macOS, Windows | reusing an existing host |
| [Blacksmith Testbox](blacksmith-testbox.md) | delegated run | Linux | existing Blacksmith Testbox workflows |
@ -39,8 +38,7 @@ crabbox run --provider blacksmith-testbox --id tbx_123 -- pnpm test
## Brokered Versus Direct
AWS, Azure, and Hetzner can run through the Crabbox coordinator or directly
from the CLI.
AWS and Hetzner can run through the Crabbox coordinator or directly from the CLI.
Coordinator mode is the normal shared-team path: the Worker owns cloud
credentials, cost state, cleanup alarms, and lease accounting.
@ -58,7 +56,6 @@ Delegated providers do not use the Crabbox coordinator:
| Provider | `run` | `warmup` | `ssh` | VNC/code | Crabbox sync | Provider sync |
| --- | --- | --- | --- | --- | --- | --- |
| AWS | yes | yes | yes | yes | yes | no |
| Azure | yes | yes | yes | Linux VNC/code | yes | no |
| Hetzner | yes | yes | yes | Linux VNC/code | yes | no |
| Static SSH | yes | resolves host | yes | host-dependent | yes | no |
| Blacksmith Testbox | yes | yes | no | no | no | yes |

View File

@ -1,215 +0,0 @@
# Azure Provider
Read when:
- choosing `provider: azure`;
- debugging Azure VM capacity, quotas, images, or SSH readiness;
- changing `internal/providers/azure` or the direct Azure provisioning code.
Azure is a managed provider for Linux and native Windows SSH leases. Azure
provisions the VM, public IP, NIC, and OS disk, then Crabbox owns SSH
readiness, sync, command execution, results, and cleanup.
## When To Use
Use Azure when the team's cloud capacity lives in an Azure subscription, or
when Microsoft tooling, Entra ID, or Azure-specific networking constraints
make AWS or Hetzner inappropriate. Use Hetzner for cheaper Linux-only
capacity and AWS for Windows desktop, Windows WSL2, or macOS targets.
Azure supports direct mode and brokered Linux/native Windows leases. Direct
mode uses local Azure credentials. Brokered mode uses the operator-owned
Azure service principal configured on the Worker.
## Commands
```sh
crabbox warmup --provider azure --class beast
crabbox run --provider azure --class standard -- pnpm test
crabbox warmup --provider azure --target windows --class standard
crabbox warmup --provider azure --desktop --browser
crabbox ssh --provider azure --id blue-lobster
crabbox stop --provider azure blue-lobster
crabbox cleanup --provider azure
```
`--type` is exact (e.g. `--type Standard_D32ads_v6`). Use `--class` when SKU
fallback is desired.
## Config
```yaml
provider: azure
target: linux
class: beast
azure:
subscriptionId: 00000000-0000-0000-0000-000000000000
tenantId: 00000000-0000-0000-0000-000000000000
clientId: 00000000-0000-0000-0000-000000000000
location: eastus
resourceGroup: crabbox-leases
image: Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:latest
vnet: crabbox-vnet
subnet: crabbox-subnet
nsg: crabbox-nsg
sshCIDRs: []
```
`subscriptionId`, `tenantId`, and `clientId` may be set in config or sourced
from environment variables. The client secret is never read from config; it
must come from the environment.
Important direct-mode environment:
```text
AZURE_SUBSCRIPTION_ID
AZURE_TENANT_ID
AZURE_CLIENT_ID
AZURE_CLIENT_SECRET
CRABBOX_AZURE_SUBSCRIPTION_ID
CRABBOX_AZURE_TENANT_ID
CRABBOX_AZURE_CLIENT_ID
CRABBOX_AZURE_LOCATION
CRABBOX_AZURE_RESOURCE_GROUP
CRABBOX_AZURE_IMAGE
CRABBOX_AZURE_VNET
CRABBOX_AZURE_SUBNET
CRABBOX_AZURE_NSG
CRABBOX_AZURE_SSH_CIDRS
```
`AZURE_*` are the standard service principal env vars consumed by
`DefaultAzureCredential`. Crabbox does not read or print the client secret.
Brokered mode uses the same Azure service-principal secrets on the Worker:
`AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, and
`AZURE_SUBSCRIPTION_ID`. Operators own the resource group, vnet, subnet,
NSG, and SSH CIDR defaults through `CRABBOX_AZURE_*` env vars. A lease
request may override only `azureLocation` and `azureImage`.
## Auth
If `azure.tenantId` and `azure.clientId` (or `CRABBOX_AZURE_TENANT_ID` /
`CRABBOX_AZURE_CLIENT_ID`) are configured and `AZURE_CLIENT_SECRET` is set
in the environment, Crabbox builds a `ClientSecretCredential` from those
explicit values. Otherwise it falls back to
[`azidentity.NewDefaultAzureCredential`](https://pkg.go.dev/github.com/Azure/azure-sdk-for-go/sdk/azidentity#DefaultAzureCredential),
which scans environment, workload identity, managed identity, and CLI
credentials in order. The simplest setup is a service principal with the
[Contributor](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#contributor)
role scoped to the resource group, configured via:
```sh
export AZURE_TENANT_ID=...
export AZURE_CLIENT_ID=...
export AZURE_CLIENT_SECRET=...
export AZURE_SUBSCRIPTION_ID=...
```
See [Authenticate Go apps to Azure services with service principals](https://learn.microsoft.com/azure/developer/go/sdk/authentication/local-development-service-principal).
## Lifecycle
1. Resolve credentials per the rules above.
2. Ensure the shared resource group, virtual network, subnet, and network
security group exist. Crabbox first issues `Get` calls against each
resource. If a resource exists without the `managed_by=crabbox` tag,
Crabbox refuses to mutate it and returns an adopt-or-rename error. If a
resource exists with the tag, it is left alone (Crabbox does not
overwrite tags, address spaces, subnets, or rules on subsequent
acquires). If a resource is missing, it is created with Crabbox tags
and the configured layout. Inbound SSH rules are derived from
`azure.sshCIDRs`, the configured SSH port, and any fallback ports.
3. Mint a per-lease SSH key.
4. Pick the configured class SKU candidates and try each in order.
5. For each lease: create a public IP, NIC, and VM with cloud-init in
`osProfile.customData` and the SSH key in
`osProfile.linuxConfiguration.ssh.publicKeys` for Linux. Native Windows
uses a Windows Server small-disk Gen2 image, Windows `osProfile` fields
(`adminPassword`, `computerName`, and `windowsConfiguration`), and a
Custom Script Extension that runs the Crabbox bootstrap saved in
`C:\AzureData\CustomData.bin`.
6. Query Azure Resource SKUs for the VM size. If Azure reports ephemeral OS
disk support, use a local ephemeral OS disk. Otherwise use a managed
`StandardSSD_LRS` OS disk.
7. Tag the VM, NIC, and public IP with Crabbox lease metadata.
8. Wait for the public IP to allocate, then for SSH and `crabbox-ready`.
9. Let core sync and run over SSH.
10. On release/cleanup, cascade-delete VM → NIC → public IP → OS disk. The
shared infra remains.
## Classes
Default Linux SKUs:
```text
standard Standard_D32ads_v6, Standard_D32ds_v6, Standard_F32s_v2, Standard_D32ads_v5, Standard_D32ds_v5, then D/F 16-vCPU fallbacks
fast Standard_D64ads_v6, Standard_D64ds_v6, Standard_F64s_v2, Standard_D64ads_v5, Standard_D64ds_v5, then D/F 48-vCPU and 32-vCPU fallbacks
large Standard_D96ads_v6, Standard_D96ds_v6, Standard_D96ads_v5, Standard_D96ds_v5, then D/F 64-vCPU and 48-vCPU fallbacks
beast Standard_D192ds_v6, Standard_D128ds_v6, then D/F 96-vCPU and 64-vCPU fallbacks
```
Default native Windows SKUs:
```text
standard Standard_D2ads_v6, Standard_D2ds_v6, Standard_D2ads_v5, Standard_D2ds_v5, then Standard_D2as_v6
fast Standard_D4ads_v6, Standard_D4ds_v6, Standard_D4ads_v5, Standard_D4ds_v5, then Standard_D4as_v6
large Standard_D8ads_v6, Standard_D8ds_v6, Standard_D8ads_v5, Standard_D8ds_v5, then Standard_D8as_v6
beast Standard_D16ads_v6, Standard_D16ds_v6, Standard_D16ads_v5, Standard_D16ds_v5, then Standard_D8ads_v6
```
Class-based provisioning falls back across the candidate list when Azure
rejects a SKU for capacity or quota
(`SkuNotAvailable`, `QuotaExceeded`, `AllocationFailed`,
`OverconstrainedAllocationRequest`). Spot leases fall back to on-demand when
`capacity.fallback` starts with `on-demand`. Explicit `--type` is exact.
The default Linux candidates mirror the AWS Linux class table's vCPU scale.
The default Windows candidates mirror the AWS native Windows class table's
vCPU scale. Azure native Windows support covers SSH, sync, and run; Windows
WSL2 and macOS remain AWS or static-SSH targets.
## Capabilities
- SSH: yes.
- Crabbox sync: yes.
- Native Windows: yes for SSH, sync, and run.
- Desktop / browser / code: Linux only on Azure.
- Tailscale: Linux managed leases.
- Actions hydration: yes, Linux SSH leases.
- Coordinator: yes, brokered Linux/native Windows leases.
## Gotchas
- Azure VM names are constrained to 1-64 characters and cannot contain
underscores. The `leaseProviderName` helper substitutes underscores
for dashes; if you customize naming, keep that constraint in mind.
- Windows computer names are limited to 15 characters. Crabbox keeps the VM
resource name stable and derives a shorter Windows `computerName`.
- The first acquire in an empty subscription pays the cost of creating the
shared resource group, vnet, and NSG. Subsequent acquires only create
per-lease resources.
- If you already have a resource group / vnet / NSG with the configured
names, Crabbox will refuse to mutate them unless they carry
`managed_by=crabbox` as a tag. Either tag them to adopt, choose
different names in `azure.*` config, or let Crabbox create dedicated
resources.
- `crabbox stop --provider azure <name>` will only act on VMs that carry
`crabbox=true` (and either no `provider` tag or `provider=azure`). A
manually-named VM in the resource group will not be deleted by Crabbox.
- The default SSH NSG rule allows `0.0.0.0/0` when `azure.sshCIDRs` is
empty. Set explicit CIDRs for any production-adjacent setup.
- Azure costs are not hardcoded in Crabbox. Set `CRABBOX_COST_RATES_JSON`
when you need exact Azure cost guardrails.
- Azure native Windows uses Custom Script Extension because Windows custom
data is saved to disk but not executed by Azure provisioning. Do not add
rebooting bootstrap work to that extension path.
- Azure does not provide managed Windows WSL2 or macOS through this provider.
Use AWS or `provider: ssh` for those targets.
- Direct-mode cleanup is best effort. Use `crabbox cleanup --provider azure`
to sweep expired direct leases.
Related docs:
- [Feature: Azure](../features/azure.md)
- [Linux VNC](../features/vnc-linux.md)
- [Provider backends](../provider-backends.md)

View File

@ -71,16 +71,6 @@ Rules:
- `CRABBOX_TAILSCALE_CLIENT_ID` and `CRABBOX_TAILSCALE_CLIENT_SECRET` are
Worker secrets for minting one-off Tailscale auth keys when brokered
`--tailscale` leases are requested.
- `CRABBOX_ARTIFACTS_ACCESS_KEY_ID`, `CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY`,
and optional `CRABBOX_ARTIFACTS_SESSION_TOKEN` are Worker secrets for
brokered artifact publishing. They should be scoped to the artifact
bucket/prefix and used only to sign short-lived upload/read URLs.
- `CRABBOX_ARTIFACTS_BACKEND`, `CRABBOX_ARTIFACTS_BUCKET`,
`CRABBOX_ARTIFACTS_PREFIX`, `CRABBOX_ARTIFACTS_BASE_URL`,
`CRABBOX_ARTIFACTS_REGION`, `CRABBOX_ARTIFACTS_ENDPOINT_URL`,
`CRABBOX_ARTIFACTS_UPLOAD_EXPIRES_SECONDS`, and
`CRABBOX_ARTIFACTS_URL_EXPIRES_SECONDS` are Worker config values, not secret
material.
- `CRABBOX_GITHUB_ALLOWED_ORG(S)` and `CRABBOX_GITHUB_ALLOWED_TEAMS` are Worker config values for browser-login authorization.
- `CRABBOX_TAILSCALE_TAGS` is the coordinator allowlist/default for requested
Tailscale ACL tags. Do not allow arbitrary user-supplied tags.

View File

@ -35,7 +35,6 @@ This page maps user-facing behavior back to implementation files. Keep docs desc
- Direct Hetzner provider: `internal/providers/hetzner`, with API client helpers in `internal/cli/hcloud.go`
- Direct AWS provider: `internal/providers/aws`, with API client helpers in `internal/cli/aws.go`
- Direct Azure provider: `internal/providers/azure`, with API client helpers in `internal/cli/azure.go`
- Static SSH macOS/Windows provider: `internal/providers/ssh`, with target mapping helpers in `internal/cli/static.go`
- Blacksmith Testbox backend and argument/parsing helpers: `internal/providers/blacksmith`
- Daytona provider backend and SDK/toolbox wrapper: `internal/providers/daytona`
@ -44,21 +43,19 @@ This page maps user-facing behavior back to implementation files. Keep docs desc
`internal/cli/provider_backend.go`
- Built-in provider registration packages:
`internal/providers/hetzner`, `internal/providers/aws`,
`internal/providers/azure`,
`internal/providers/ssh`, `internal/providers/blacksmith`,
`internal/providers/daytona`, `internal/providers/islo`,
`internal/providers/all`
- Built-in provider backend implementations:
`internal/providers/aws`, `internal/providers/azure`,
`internal/providers/hetzner`,
`internal/providers/aws`, `internal/providers/hetzner`,
`internal/providers/ssh`, `internal/providers/blacksmith`,
`internal/providers/daytona`, `internal/providers/islo`,
plus shared helpers in `internal/providers/shared`
- Worker Hetzner provider: `worker/src/hetzner.ts`
- Worker AWS EC2 provider: `worker/src/aws.ts`
- Worker AWS AMI create/read/promote routes: `worker/src/fleet.ts`, `worker/src/aws.ts`
- Provider feature docs: `docs/features/aws.md`, `docs/features/azure.md`, `docs/features/hetzner.md`, `docs/features/blacksmith-testbox.md`, `docs/features/daytona.md`, `docs/features/islo.md`
- Provider reference docs: `docs/providers/README.md`, `docs/providers/aws.md`, `docs/providers/azure.md`, `docs/providers/hetzner.md`, `docs/providers/ssh.md`, `docs/providers/blacksmith-testbox.md`, `docs/providers/daytona.md`, `docs/providers/islo.md`
- Provider feature docs: `docs/features/aws.md`, `docs/features/hetzner.md`, `docs/features/blacksmith-testbox.md`, `docs/features/daytona.md`, `docs/features/islo.md`
- Provider reference docs: `docs/providers/README.md`, `docs/providers/aws.md`, `docs/providers/hetzner.md`, `docs/providers/ssh.md`, `docs/providers/blacksmith-testbox.md`, `docs/providers/daytona.md`, `docs/providers/islo.md`
- Provider/backend authoring guide: `docs/provider-backends.md`
- CLI cloud-init bootstrap: `internal/cli/bootstrap.go`
- Worker cloud-init bootstrap: `worker/src/bootstrap.ts`

17
go.mod
View File

@ -17,13 +17,6 @@ require (
)
require (
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6 v6.4.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v6 v6.2.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
@ -45,12 +38,9 @@ require (
github.com/daytonaio/daytona/libs/toolbox-api-client-go v0.172.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect
@ -61,10 +51,9 @@ require (
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect

29
go.sum
View File

@ -1,17 +1,3 @@
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6 v6.4.0 h1:z7Mqz6l0EFH549GvHEqfjKvi+cRScxLWbaoeLm9wxVQ=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6 v6.4.0/go.mod h1:v6gbfH+7DG7xH2kUNs+ZJ9tF6O3iNnR85wMtmr+F54o=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v6 v6.2.0 h1:HYGD75g0bQ3VO/Omedm54v4LrD3B1cGImuRF3AJ5wLo=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v6 v6.2.0/go.mod h1:ulHyBFJOI0ONiRL4vcJTmS7rx18jQQlEPmAgo80cRdM=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
github.com/alecthomas/kong v1.15.0 h1:BVJstKbpO73zKpmIu+m/aLRrNmWwxXPIGTNin9VmLVI=
@ -74,8 +60,6 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@ -95,10 +79,6 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@ -130,21 +110,12 @@ go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpu
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=

View File

@ -93,8 +93,6 @@ func (a App) directCommandHelp(ctx context.Context, args []string) (error, bool)
return a.egress(ctx, helpArgs), true
case "screenshot":
return a.screenshot(ctx, helpArgs), true
case "artifacts":
return nil, false
case "inspect":
return a.inspect(ctx, helpArgs), true
case "stop", "release":
@ -141,7 +139,6 @@ Commands:
run Sync the repo, run a remote command, stream output
desktop Launch apps into a visible desktop session
media Create preview artifacts from recorded desktop videos
artifacts Collect, transform, and publish QA artifacts
sync-plan Show local sync manifest size hotspots
history List recorded remote runs
logs Print recorded run logs
@ -177,8 +174,6 @@ Common Flows:
crabbox vnc --id blue-lobster --open
crabbox desktop launch --id blue-lobster --browser --url https://example.com --webvnc --open
crabbox media preview --input desktop.mp4 --output desktop-preview.gif --trimmed-video-output desktop-change.mp4
crabbox artifacts collect --id blue-lobster --all --output artifacts/blue-lobster
crabbox artifacts publish --pr 123 --dir artifacts/blue-lobster --storage s3 --bucket qa-artifacts
crabbox webvnc --id blue-lobster --open
crabbox code --id blue-lobster --open
crabbox egress start --id blue-lobster --profile discord --daemon
@ -206,11 +201,11 @@ Global:
--version Print version
Config:
crabbox login [--url <url>] [--provider aws|azure|hetzner] [--no-browser]
crabbox login --url <url> --token-stdin [--provider aws|azure|hetzner]
crabbox login [--url <url>] [--provider aws|hetzner] [--no-browser]
crabbox login --url <url> --token-stdin [--provider aws|hetzner]
crabbox config path
crabbox config show [--json]
crabbox config set-broker --url <url> --token-stdin [--provider aws|azure|hetzner]
crabbox config set-broker --url <url> --token-stdin [--provider aws|hetzner]
Environment:
CRABBOX_COORDINATOR Broker URL
@ -220,7 +215,7 @@ Environment:
CRABBOX_ACCESS_CLIENT_ID Cloudflare Access service token client ID
CRABBOX_ACCESS_CLIENT_SECRET Cloudflare Access service token client secret
CRABBOX_ACCESS_TOKEN Cloudflare Access JWT for protected routes
CRABBOX_PROVIDER hetzner, aws, azure, ssh, blacksmith-testbox, daytona, or islo
CRABBOX_PROVIDER hetzner, aws, ssh, blacksmith-testbox, daytona, or islo
CRABBOX_TARGET linux, macos, or windows
CRABBOX_WINDOWS_MODE normal or wsl2
CRABBOX_DESKTOP Provision or require desktop/VNC capability

View File

@ -1,607 +0,0 @@
package cli
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
type artifactFile struct {
Kind string `json:"kind"`
Name string `json:"name"`
Path string `json:"path"`
URL string `json:"url,omitempty"`
}
type artifactBundleMetadata struct {
CreatedAt string `json:"createdAt"`
Version string `json:"crabboxVersion"`
LeaseID string `json:"leaseId,omitempty"`
Slug string `json:"slug,omitempty"`
Provider string `json:"provider,omitempty"`
Network string `json:"network,omitempty"`
TargetOS string `json:"targetOS,omitempty"`
RunID string `json:"runId,omitempty"`
}
type artifactCollectResult struct {
Directory string `json:"directory"`
Files []artifactFile `json:"files"`
Metadata artifactBundleMetadata `json:"metadata"`
Warnings []artifactWarning `json:"warnings,omitempty"`
Error *artifactCollectError `json:"error,omitempty"`
}
type artifactCollectError struct {
Code string `json:"code"`
Message string `json:"message"`
}
type artifactWarning struct {
Problem string `json:"problem"`
Detail string `json:"detail,omitempty"`
Rescue []string `json:"rescue,omitempty"`
Fallback string `json:"fallback,omitempty"`
}
type artifactPublishOptions struct {
Directory string
Storage string
Bucket string
Prefix string
BaseURL string
PR int
Repo string
Template string
Summary string
SummaryFile string
Region string
Profile string
EndpointURL string
ACL string
Presign bool
Expires time.Duration
DryRun bool
NoComment bool
}
func (a App) artifactsCollect(ctx context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("artifacts collect", a.Stderr)
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or ssh")
id := fs.String("id", "", "lease id or slug")
output := fs.String("output", "", "artifact bundle directory")
runID := fs.String("run", "", "optional run id whose retained logs should be copied")
all := fs.Bool("all", false, "collect screenshot, video, GIF, doctor/status, logs, and metadata")
screenshot := fs.Bool("screenshot", true, "capture desktop screenshot")
video := fs.Bool("video", false, "record desktop video")
gif := fs.Bool("gif", false, "create trimmed GIF from recorded video")
doctor := fs.Bool("doctor", true, "write desktop doctor output")
webvncStatus := fs.Bool("webvnc-status", true, "write WebVNC portal status when coordinator is configured")
metadata := fs.Bool("metadata", true, "write metadata.json")
duration := fs.Duration("duration", 10*time.Second, "video capture duration")
fps := fs.Float64("fps", 15, "video frames per second")
gifWidth := fs.Int("gif-width", 640, "trimmed GIF width")
reclaim := fs.Bool("reclaim", false, "claim this lease for the current repo")
targetFlags := registerTargetFlags(fs, defaults)
networkFlags := registerNetworkModeFlag(fs, defaults)
jsonOut := fs.Bool("json", false, "print machine-readable result")
if err := parseFlags(fs, args); err != nil {
return err
}
setIDFromFirstArg(fs, id)
if *all {
*video = true
*gif = true
}
if *gif && !*video {
return exit(2, "artifacts collect --gif requires --video or --all")
}
if *duration <= 0 {
return exit(2, "artifacts collect --duration must be positive")
}
if *fps <= 0 {
return exit(2, "artifacts collect --fps must be positive")
}
if *gifWidth <= 0 {
return exit(2, "artifacts collect --gif-width must be positive")
}
cfg, err := loadLeaseTargetConfig(fs, *provider, targetFlags, networkFlags, leaseTargetConfigOptions{Desktop: true})
if err != nil {
return err
}
if isBlacksmithProvider(cfg.Provider) {
return exit(2, "artifacts collect is not supported for provider=%s; Blacksmith owns machine connectivity", cfg.Provider)
}
if err := requireLeaseID(*id, "crabbox artifacts collect --id <lease-id-or-slug> [--output <dir>]", cfg); err != nil {
return err
}
server, target, leaseID, err := a.resolveNetworkLeaseTarget(ctx, cfg, *id, false)
if err != nil {
return err
}
if isStaticProvider(cfg.Provider) && target.TargetOS != targetLinux {
return exit(2, "desktop artifacts are not collected from static %s hosts because those are existing host machines, not Crabbox-created desktops", target.TargetOS)
}
if err := enforceManagedLeaseCapabilities(cfg, server, leaseID); err != nil {
return err
}
if err := a.claimAndTouchLeaseTarget(ctx, cfg, server, leaseID, *reclaim); err != nil {
return err
}
dir := strings.TrimSpace(*output)
if dir == "" {
dir = defaultArtifactBundleDir(leaseID, serverSlug(server))
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return exit(2, "create artifact directory: %v", err)
}
result := artifactCollectResult{
Directory: dir,
Metadata: artifactBundleMetadata{
CreatedAt: time.Now().UTC().Format(time.RFC3339),
Version: version,
LeaseID: leaseID,
Slug: serverSlug(server),
Provider: cfg.Provider,
Network: string(cfg.Network),
TargetOS: target.TargetOS,
RunID: strings.TrimSpace(*runID),
},
}
addFile := func(kind, path string) {
result.Files = append(result.Files, artifactFile{Kind: kind, Name: filepath.Base(path), Path: path})
}
fail := func(err error, warning artifactWarning) error {
return a.finishArtifactCollectFailure(&result, *jsonOut, err, warning)
}
if *metadata {
path := filepath.Join(dir, "metadata.json")
if err := writeJSONFile(path, result.Metadata); err != nil {
return err
}
addFile("metadata", path)
}
if *screenshot {
if err := waitForLoopbackVNC(ctx, &target); err != nil {
return fail(err, artifactWarning{
Problem: rescueVNCTargetUnreachable,
Detail: err.Error(),
Rescue: []string{desktopDoctorCommand(rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID})},
})
}
path := filepath.Join(dir, "screenshot.png")
if err := captureDesktopScreenshot(ctx, target, path); err != nil {
return fail(err, artifactWarning{
Problem: classifyDesktopFailure(err.Error()),
Detail: err.Error(),
Rescue: []string{desktopDoctorCommand(rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID})},
})
}
addFile("screenshot", path)
}
if *doctor {
path := filepath.Join(dir, "doctor.txt")
out, err := runSSHOutput(ctx, target, desktopDoctorRemoteCommand(target))
if err != nil {
doctorErr := exit(5, "desktop doctor failed: %v", err)
return fail(doctorErr, artifactWarning{
Problem: classifyDesktopFailure(out),
Detail: trimFailureDetail(out),
Rescue: []string{desktopDoctorCommand(rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID})},
})
}
if err := os.WriteFile(path, []byte(out+"\n"), 0o644); err != nil {
return exit(2, "write doctor artifact: %v", err)
}
addFile("doctor", path)
}
if *webvncStatus {
if path, ok, err := a.writeArtifactWebVNCStatus(ctx, cfg, target, leaseID, dir, &result.Warnings); err != nil {
return err
} else if ok {
addFile("webvnc-status", path)
}
}
if strings.TrimSpace(*runID) != "" {
logPath, runPath, err := writeArtifactRunLogs(ctx, strings.TrimSpace(*runID), dir)
if err != nil {
return fail(err, artifactWarning{
Problem: rescueArtifactCaptureFailed,
Detail: err.Error(),
Rescue: []string{"crabbox logs " + strings.TrimSpace(*runID)},
})
}
addFile("logs", logPath)
addFile("run", runPath)
}
if *video {
if target.TargetOS != targetLinux {
err := exit(2, "artifacts collect --video currently requires target=linux with ffmpeg/x11grab")
return fail(err, artifactWarning{
Problem: rescueArtifactCaptureFailed,
Detail: err.Error(),
Rescue: []string{desktopDoctorCommand(rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID})},
})
}
path := filepath.Join(dir, "screen.mp4")
if err := captureDesktopVideo(ctx, target, path, *duration, *fps); err != nil {
return fail(err, artifactWarning{
Problem: classifyDesktopFailure(err.Error()),
Detail: err.Error(),
Rescue: []string{desktopDoctorCommand(rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID})},
})
}
addFile("video", path)
if *gif {
gifPath := filepath.Join(dir, "screen.trimmed.gif")
trimmedPath := filepath.Join(dir, "screen.trimmed.mp4")
preview, err := createMediaPreview(ctx, mediaPreviewOptions{
Input: path,
Output: gifPath,
TrimmedVideoOutput: trimmedPath,
Width: *gifWidth,
FPS: 4,
TrimStatic: true,
TrimPadding: 750 * time.Millisecond,
FreezeDuration: 500 * time.Millisecond,
FreezeNoise: "-50dB",
MinDuration: 1500 * time.Millisecond,
})
if err != nil {
return fail(err, artifactWarning{
Problem: rescueArtifactCaptureFailed,
Detail: err.Error(),
})
}
addFile("gif", preview.Output)
if preview.TrimmedVideoOutput != "" {
addFile("trimmed-video", preview.TrimmedVideoOutput)
}
}
}
sortArtifactFiles(result.Files)
if result.Files == nil {
result.Files = []artifactFile{}
}
if *jsonOut {
enc := json.NewEncoder(a.Stdout)
enc.SetIndent("", " ")
return enc.Encode(result)
}
for _, warning := range result.Warnings {
printArtifactWarning(a.Stdout, warning)
}
fmt.Fprintf(a.Stdout, "artifacts: %s\n", dir)
for _, file := range result.Files {
fmt.Fprintf(a.Stdout, "%s: %s\n", file.Kind, file.Path)
}
fmt.Fprintf(a.Stdout, "publish: crabbox artifacts publish --dir %s --pr <n>\n", strings.Join(readableShellWords([]string{dir}), " "))
return nil
}
func (a App) finishArtifactCollectFailure(result *artifactCollectResult, jsonOut bool, err error, warning artifactWarning) error {
if result == nil {
return err
}
sortArtifactFiles(result.Files)
if result.Files == nil {
result.Files = []artifactFile{}
}
if strings.TrimSpace(warning.Problem) != "" {
result.Warnings = append(result.Warnings, normalizeArtifactWarning(warning))
}
result.Error = &artifactCollectError{
Code: artifactErrorCode(result.Warnings),
Message: strings.TrimSpace(err.Error()),
}
if jsonOut {
enc := json.NewEncoder(a.Stdout)
enc.SetIndent("", " ")
if encodeErr := enc.Encode(result); encodeErr != nil {
return encodeErr
}
return err
}
for _, warning := range result.Warnings {
printArtifactWarning(a.Stdout, warning)
}
return err
}
func (a App) artifactsVideo(ctx context.Context, args []string) error {
target, cfg, leaseID, err := a.desktopCommandTarget(ctx, "artifacts video", args, false)
if err != nil {
return err
}
output, _ := stringFlagValue(args, "output")
if strings.TrimSpace(output) == "" {
output = "crabbox-" + normalizeLeaseSlug(leaseID) + "-screen.mp4"
}
duration := durationFlagValue(args, "duration", 10*time.Second)
fps := floatFlagValue(args, "fps", 15)
if duration <= 0 {
return exit(2, "artifacts video --duration must be positive")
}
if fps <= 0 {
return exit(2, "artifacts video --fps must be positive")
}
if target.TargetOS != targetLinux {
return exit(2, "artifacts video currently requires target=linux with ffmpeg/x11grab")
}
if err := captureDesktopVideo(ctx, target, output, duration, fps); err != nil {
printRescue(a.Stdout, classifyDesktopFailure(err.Error()), err.Error(), desktopDoctorCommand(rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID}))
return err
}
fmt.Fprintf(a.Stdout, "video: %s\n", output)
return nil
}
func (a App) artifactsGif(ctx context.Context, args []string) error {
return a.mediaPreview(ctx, args)
}
func (a App) artifactsTemplate(ctx context.Context, args []string) error {
_ = ctx
initialKind := ""
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
initialKind = args[0]
args = args[1:]
}
fs := newFlagSet("artifacts template", a.Stderr)
kind := fs.String("kind", initialKind, "template kind: openclaw or mantis")
before := fs.String("before", "", "before screenshot/GIF URL or path")
after := fs.String("after", "", "after screenshot/GIF URL or path")
summary := fs.String("summary", "", "summary text")
summaryFile := fs.String("summary-file", "", "summary markdown file")
output := fs.String("output", "", "output markdown path; stdout when omitted")
if err := parseFlags(fs, args); err != nil {
return err
}
text, err := summaryText(*summary, *summaryFile)
if err != nil {
return err
}
body := artifactTemplateMarkdown(*kind, text, *before, *after, nil)
if strings.TrimSpace(*output) == "" {
fmt.Fprint(a.Stdout, body)
return nil
}
if err := os.WriteFile(*output, []byte(body), 0o644); err != nil {
return exit(2, "write template: %v", err)
}
fmt.Fprintf(a.Stdout, "template: %s\n", *output)
return nil
}
func (a App) artifactsPublish(ctx context.Context, args []string) error {
opts, err := parseArtifactPublishOptions(args, a.Stderr)
if err != nil {
return err
}
var coord *CoordinatorClient
if opts.Storage == "auto" || opts.Storage == "broker" {
cfg, cfgErr := loadConfig()
if cfgErr != nil {
return cfgErr
}
var useCoordinator bool
coord, useCoordinator, err = newCoordinatorClient(cfg)
if err != nil {
return err
}
if opts.Storage == "auto" {
if useCoordinator && coord != nil && coord.Token != "" {
opts.Storage = "broker"
} else {
opts.Storage = "local"
}
}
}
ensureArtifactPublishPrefix(&opts)
files, err := listArtifactBundleFiles(opts.Directory)
if err != nil {
return err
}
if len(files) == 0 {
return exit(2, "artifact directory has no files: %s", opts.Directory)
}
summary, err := summaryText(opts.Summary, opts.SummaryFile)
if err != nil {
return err
}
var published []artifactFile
if opts.Storage == "broker" {
published, err = publishArtifactFilesBroker(ctx, coord, opts, files)
} else {
published, err = publishArtifactFiles(ctx, opts, files)
}
if err != nil {
return err
}
body := artifactTemplateMarkdown(opts.Template, summary, "", "", published)
bodyPath := filepath.Join(opts.Directory, "published-artifacts.md")
if err := os.WriteFile(bodyPath, []byte(body), 0o644); err != nil {
return exit(2, "write publish markdown: %v", err)
}
if opts.PR > 0 && !opts.NoComment {
if opts.Storage == "local" && opts.BaseURL == "" {
return exit(2, "artifacts publish --pr needs brokered publishing, --storage s3|r2|cloudflare, or --base-url for already-hosted local assets")
}
if opts.DryRun {
fmt.Fprintf(a.Stdout, "dry-run comment: gh issue comment %d --body-file %s\n", opts.PR, bodyPath)
} else if err := postGitHubPRComment(ctx, opts.PR, opts.Repo, bodyPath); err != nil {
return err
}
}
for _, file := range published {
if file.URL != "" {
fmt.Fprintf(a.Stdout, "%s: %s\n", file.Kind, file.URL)
} else {
fmt.Fprintf(a.Stdout, "%s: %s\n", file.Kind, file.Path)
}
}
fmt.Fprintf(a.Stdout, "markdown: %s\n", bodyPath)
return nil
}
func defaultArtifactBundleDir(leaseID, slug string) string {
name := strings.TrimSpace(slug)
if name == "" {
name = leaseID
}
if name == "" {
name = time.Now().UTC().Format("20060102-150405")
}
return filepath.Join("artifacts", normalizeLeaseSlug(name))
}
func writeJSONFile(path string, value any) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return exit(2, "encode %s: %v", path, err)
}
data = append(data, '\n')
if err := os.WriteFile(path, data, 0o644); err != nil {
return exit(2, "write %s: %v", path, err)
}
return nil
}
func (a App) writeArtifactWebVNCStatus(ctx context.Context, cfg Config, target SSHTarget, leaseID, dir string, warnings *[]artifactWarning) (string, bool, error) {
if isStaticProvider(cfg.Provider) || isBlacksmithProvider(cfg.Provider) {
return "", false, nil
}
coord, useCoordinator, err := newTargetCoordinatorClient(cfg)
if err != nil || !useCoordinator || coord == nil || coord.Token == "" {
return "", false, nil
}
status, err := coord.WebVNCStatus(ctx, leaseID)
path := filepath.Join(dir, "webvnc-status.json")
payload := map[string]any{"leaseId": leaseID, "target": target.TargetOS}
if err != nil {
payload["error"] = err.Error()
rescueCtx := rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID}
appendArtifactWarning(warnings, rescueVNCBridgeDisconnected, err.Error(), "", webVNCStatusRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
} else {
payload["status"] = status
rescueCtx := rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID}
if !status.BridgeConnected {
appendArtifactWarning(warnings, rescueVNCBridgeNotRunning, "portal has no active WebVNC bridge for this lease", "", webVNCDaemonStartRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
} else if webVNCObserverSlotsExhausted(status) {
appendArtifactWarning(warnings, rescueVNCObserverSlotsFull, "all WebVNC observer slots are in use or stale", "", webVNCDaemonStartRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
}
}
if err := writeJSONFile(path, payload); err != nil {
return "", false, err
}
return path, true, nil
}
func appendArtifactWarning(warnings *[]artifactWarning, problem, detail, fallback string, rescue ...string) {
if warnings == nil {
return
}
clean := normalizeArtifactWarning(artifactWarning{Problem: problem, Detail: detail, Fallback: fallback, Rescue: rescue})
if clean.Problem != "" {
*warnings = append(*warnings, clean)
}
}
func normalizeArtifactWarning(warning artifactWarning) artifactWarning {
clean := artifactWarning{
Problem: strings.TrimSpace(warning.Problem),
Detail: strings.TrimSpace(warning.Detail),
Fallback: strings.TrimSpace(warning.Fallback),
}
for _, command := range warning.Rescue {
if strings.TrimSpace(command) != "" {
clean.Rescue = append(clean.Rescue, strings.TrimSpace(command))
}
}
return clean
}
func artifactErrorCode(warnings []artifactWarning) string {
if len(warnings) == 0 || strings.TrimSpace(warnings[len(warnings)-1].Problem) == "" {
return "artifact_collect_failed"
}
return normalizeLeaseSlug(warnings[len(warnings)-1].Problem)
}
func printArtifactWarning(w io.Writer, warning artifactWarning) {
printRescueWithFallback(w, warning.Problem, warning.Detail, warning.Fallback, warning.Rescue...)
}
func writeArtifactRunLogs(ctx context.Context, runID, dir string) (string, string, error) {
coord, err := configuredCoordinator()
if err != nil {
return "", "", err
}
logText, err := coord.RunLogs(ctx, runID)
if err != nil {
return "", "", err
}
run, err := coord.Run(ctx, runID)
if err != nil {
return "", "", err
}
logPath := filepath.Join(dir, "logs.txt")
runPath := filepath.Join(dir, "run.json")
if err := os.WriteFile(logPath, []byte(logText), 0o644); err != nil {
return "", "", exit(2, "write logs artifact: %v", err)
}
if err := writeJSONFile(runPath, run); err != nil {
return "", "", err
}
return logPath, runPath, nil
}
func captureDesktopVideo(ctx context.Context, target SSHTarget, outputPath string, duration time.Duration, fps float64) error {
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil && filepath.Dir(outputPath) != "." {
return exit(2, "create video directory: %v", err)
}
file, err := os.Create(outputPath)
if err != nil {
return exit(2, "create video %s: %v", outputPath, err)
}
ok := false
defer func() {
_ = file.Close()
if !ok {
_ = os.Remove(outputPath)
}
}()
if err := runSSHToWriter(ctx, target, desktopVideoRemoteCommand(duration, fps), file); err != nil {
return exit(5, "capture video: %v", err)
}
ok = true
return nil
}
func desktopVideoRemoteCommand(duration time.Duration, fps float64) string {
seconds := strconv.FormatFloat(duration.Seconds(), 'f', 3, 64)
frameRate := strconv.FormatFloat(fps, 'f', 3, 64)
return fmt.Sprintf(`set -eu
export DISPLAY="${DISPLAY:-:99}"
if ! command -v ffmpeg >/dev/null 2>&1; then
echo "missing ffmpeg; warm a new --desktop lease or install ffmpeg" >&2
exit 127
fi
if command -v xdpyinfo >/dev/null 2>&1; then
size="$(xdpyinfo | awk '/dimensions:/{print $2; exit}')"
else
size=""
fi
if [ -z "$size" ]; then size="1920x1080"; fi
ffmpeg -hide_banner -loglevel error -y -f x11grab -video_size "$size" -framerate %s -i "$DISPLAY" -t %s -pix_fmt yuv420p -an -movflags frag_keyframe+empty_moov -f mp4 -
`, frameRate, seconds)
}

View File

@ -1,636 +0,0 @@
package cli
import (
"context"
"crypto/sha256"
"encoding/hex"
"flag"
"fmt"
"io"
"io/fs"
"mime"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
func parseArtifactPublishOptions(args []string, stderr io.Writer) (artifactPublishOptions, error) {
fs := newFlagSet("artifacts publish", stderr)
dir := fs.String("dir", os.Getenv("CRABBOX_ARTIFACTS_DIR"), "artifact bundle directory")
storage := fs.String("storage", firstNonBlank(os.Getenv("CRABBOX_ARTIFACTS_STORAGE"), "auto"), "storage backend: auto, broker, local, s3, cloudflare, or r2")
bucket := fs.String("bucket", os.Getenv("CRABBOX_ARTIFACTS_BUCKET"), "storage bucket")
prefix := fs.String("prefix", os.Getenv("CRABBOX_ARTIFACTS_PREFIX"), "object key prefix")
baseURL := fs.String("base-url", os.Getenv("CRABBOX_ARTIFACTS_BASE_URL"), "public base URL for inline-ready asset links")
pr := fs.Int("pr", 0, "GitHub pull request number to comment on")
repo := fs.String("repo", "", "GitHub repository slug for gh, e.g. openclaw/crabbox")
template := fs.String("template", "openclaw", "comment template: openclaw or mantis")
summary := fs.String("summary", "", "summary text")
summaryFile := fs.String("summary-file", "", "summary markdown file")
region := fs.String("region", firstNonBlank(os.Getenv("CRABBOX_ARTIFACTS_AWS_REGION"), os.Getenv("AWS_REGION"), os.Getenv("AWS_DEFAULT_REGION")), "AWS region for S3 URLs/CLI")
profile := fs.String("profile", firstNonBlank(os.Getenv("CRABBOX_ARTIFACTS_AWS_PROFILE"), os.Getenv("AWS_PROFILE")), "AWS profile for S3 CLI")
endpointURL := fs.String("endpoint-url", os.Getenv("CRABBOX_ARTIFACTS_ENDPOINT_URL"), "S3-compatible endpoint URL")
acl := fs.String("acl", os.Getenv("CRABBOX_ARTIFACTS_S3_ACL"), "optional S3 ACL, e.g. public-read")
presign := fs.Bool("presign", envBool("CRABBOX_ARTIFACTS_PRESIGN"), "use aws s3 presign URLs after upload")
expires := fs.Duration("expires", envDuration("CRABBOX_ARTIFACTS_EXPIRES", 7*24*time.Hour), "presigned URL lifetime")
dryRun := fs.Bool("dry-run", false, "print upload/comment commands without running them")
noComment := fs.Bool("no-comment", false, "skip GitHub PR comment")
if err := parseFlags(fs, args); err != nil {
return artifactPublishOptions{}, err
}
explicit := map[string]bool{}
fs.Visit(func(f *flag.Flag) {
explicit[f.Name] = true
})
opts := artifactPublishOptions{
Directory: strings.TrimSpace(*dir),
Storage: normalizeArtifactStorage(*storage),
Bucket: strings.TrimSpace(*bucket),
Prefix: strings.Trim(strings.TrimSpace(*prefix), "/"),
BaseURL: strings.TrimRight(strings.TrimSpace(*baseURL), "/"),
PR: *pr,
Repo: strings.TrimSpace(*repo),
Template: strings.TrimSpace(*template),
Summary: *summary,
SummaryFile: strings.TrimSpace(*summaryFile),
Region: strings.TrimSpace(*region),
Profile: strings.TrimSpace(*profile),
EndpointURL: strings.TrimSpace(*endpointURL),
ACL: strings.TrimSpace(*acl),
Presign: *presign,
Expires: *expires,
DryRun: *dryRun,
NoComment: *noComment,
}
if opts.Storage == "r2" {
if !explicit["profile"] {
opts.Profile = firstNonBlank(os.Getenv("CRABBOX_ARTIFACTS_R2_AWS_PROFILE"), opts.Profile)
}
if !explicit["endpoint-url"] {
opts.EndpointURL = firstNonBlank(os.Getenv("CRABBOX_ARTIFACTS_R2_ENDPOINT_URL"), opts.EndpointURL)
}
if !explicit["region"] {
opts.Region = firstNonBlank(os.Getenv("CRABBOX_ARTIFACTS_R2_AWS_REGION"), "auto")
}
}
if opts.Directory == "" {
return artifactPublishOptions{}, exit(2, "artifacts publish requires --dir")
}
if opts.PR < 0 {
return artifactPublishOptions{}, exit(2, "artifacts publish --pr must be positive")
}
if opts.Expires <= 0 {
return artifactPublishOptions{}, exit(2, "artifacts publish --expires must be positive")
}
switch opts.Storage {
case "auto", "broker", "local":
case "s3", "cloudflare", "r2":
if opts.Bucket == "" {
return artifactPublishOptions{}, exit(2, "artifacts publish --storage %s requires --bucket", opts.Storage)
}
default:
return artifactPublishOptions{}, exit(2, "artifacts publish --storage must be auto, broker, local, s3, cloudflare, or r2")
}
if opts.Storage == "r2" && opts.EndpointURL == "" {
return artifactPublishOptions{}, exit(2, "artifacts publish --storage r2 requires --endpoint-url or CRABBOX_ARTIFACTS_R2_ENDPOINT_URL")
}
if (opts.Storage == "cloudflare" || opts.Storage == "r2") && opts.PR > 0 && !opts.NoComment && opts.BaseURL == "" {
return artifactPublishOptions{}, exit(2, "artifacts publish --storage %s --pr requires --base-url for inline-ready R2 asset links", opts.Storage)
}
return opts, nil
}
func normalizeArtifactStorage(storage string) string {
switch strings.ToLower(strings.TrimSpace(storage)) {
case "", "auto":
return "auto"
case "broker", "coordinator":
return "broker"
case "local":
return "local"
case "s3", "aws", "aws-s3":
return "s3"
case "r2", "cloudflare-r2":
return "r2"
case "cloudflare", "cf":
return "cloudflare"
default:
return strings.ToLower(strings.TrimSpace(storage))
}
}
func listArtifactBundleFiles(dir string) ([]artifactFile, error) {
var files []artifactFile
err := filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if entry.IsDir() {
return nil
}
name := entry.Name()
if name == "published-artifacts.md" {
return nil
}
rel, err := filepath.Rel(dir, path)
if err != nil {
return err
}
files = append(files, artifactFile{Kind: artifactKindForPath(path), Name: filepath.ToSlash(rel), Path: path})
return nil
})
if err != nil {
return nil, exit(2, "read artifact directory: %v", err)
}
sortArtifactFiles(files)
return files, nil
}
func publishArtifactFiles(ctx context.Context, opts artifactPublishOptions, files []artifactFile) ([]artifactFile, error) {
published := make([]artifactFile, 0, len(files))
for _, file := range files {
out := file
key := artifactObjectKey(opts.Prefix, file.Name)
switch opts.Storage {
case "local":
if opts.BaseURL != "" {
out.URL = joinURLPath(opts.BaseURL, file.Name)
}
case "s3", "r2":
url, err := uploadArtifactS3(ctx, opts, file.Path, key)
if err != nil {
return nil, err
}
out.URL = url
case "cloudflare":
url, err := uploadArtifactCloudflare(ctx, opts, file.Path, key)
if err != nil {
return nil, err
}
out.URL = url
}
published = append(published, out)
}
return published, nil
}
func publishArtifactFilesBroker(ctx context.Context, coord *CoordinatorClient, opts artifactPublishOptions, files []artifactFile) ([]artifactFile, error) {
if coord == nil || coord.Token == "" {
return nil, exit(2, "artifacts publish --storage broker requires a configured coordinator; run `crabbox login` or pass --storage local|s3|r2")
}
ensureArtifactPublishPrefix(&opts)
input := CoordinatorArtifactUploadRequest{
Prefix: opts.Prefix,
Files: make([]CoordinatorArtifactUploadInput, 0, len(files)),
}
for _, file := range files {
info, err := os.Stat(file.Path)
if err != nil {
return nil, exit(2, "stat artifact %s: %v", file.Name, err)
}
hash, err := fileSHA256(file.Path)
if err != nil {
return nil, err
}
input.Files = append(input.Files, CoordinatorArtifactUploadInput{
Name: file.Name,
Size: info.Size(),
ContentType: artifactContentType(file.Path),
SHA256: hash,
})
}
grants, err := coord.CreateArtifactUploads(ctx, input)
if err != nil {
return nil, err
}
byName := map[string]CoordinatorArtifactUploadGrant{}
for _, grant := range grants.Files {
byName[grant.Name] = grant
}
published := make([]artifactFile, 0, len(files))
for _, file := range files {
grant, ok := byName[file.Name]
if !ok {
return nil, exit(2, "artifact broker did not return an upload grant for %s", file.Name)
}
if !opts.DryRun {
if err := uploadArtifactGrant(ctx, file.Path, grant); err != nil {
return nil, err
}
}
out := file
out.URL = grant.URL
published = append(published, out)
}
return published, nil
}
func ensureArtifactPublishPrefix(opts *artifactPublishOptions) {
if opts == nil || opts.Prefix != "" || opts.Storage == "local" {
return
}
opts.Prefix = defaultArtifactPublishPrefix(*opts, time.Now())
}
func defaultArtifactPublishPrefix(opts artifactPublishOptions, now time.Time) string {
scope := "publish"
if opts.PR > 0 {
scope = "pr-" + strconv.Itoa(opts.PR)
}
bundle := normalizeLeaseSlug(filepath.Base(filepath.Clean(opts.Directory)))
if bundle == "" || bundle == "." {
bundle = "bundle"
}
stamp := now.UTC().Format("20060102-150405") + "-" + fmt.Sprintf("%09d", now.UTC().Nanosecond())
return strings.Join([]string{scope, bundle, stamp}, "/")
}
func fileSHA256(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", exit(2, "open artifact %s: %v", path, err)
}
defer file.Close()
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return "", exit(2, "hash artifact %s: %v", path, err)
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
func uploadArtifactGrant(ctx context.Context, path string, grant CoordinatorArtifactUploadGrant) error {
if grant.Upload.URL == "" {
return exit(2, "artifact broker returned an empty upload URL for %s", grant.Name)
}
method := strings.ToUpper(strings.TrimSpace(grant.Upload.Method))
if method == "" {
method = http.MethodPut
}
file, err := os.Open(path)
if err != nil {
return exit(2, "open artifact %s: %v", grant.Name, err)
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return exit(2, "stat artifact %s: %v", grant.Name, err)
}
contentLength := info.Size()
if expected, ok, err := grantContentLength(grant.Upload.Headers); err != nil {
return exit(2, "artifact broker returned an invalid content-length for %s: %v", grant.Name, err)
} else if ok {
if expected != info.Size() {
return exit(2, "artifact %s size changed after broker grant: got %d bytes, expected %d", grant.Name, info.Size(), expected)
}
contentLength = expected
}
req, err := http.NewRequestWithContext(ctx, method, grant.Upload.URL, file)
if err != nil {
return exit(2, "create artifact upload request for %s: %v", grant.Name, err)
}
req.ContentLength = contentLength
for key, value := range grant.Upload.Headers {
if strings.EqualFold(strings.TrimSpace(key), "content-length") {
continue
}
if strings.TrimSpace(key) != "" && strings.TrimSpace(value) != "" {
req.Header.Set(key, value)
}
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return exit(2, "upload artifact %s: %v", grant.Name, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return exit(2, "upload artifact %s: http %d: %s", grant.Name, resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
}
func grantContentLength(headers map[string]string) (int64, bool, error) {
for key, value := range headers {
if !strings.EqualFold(strings.TrimSpace(key), "content-length") {
continue
}
n, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
if err != nil || n < 0 {
if err == nil {
err = fmt.Errorf("negative value")
}
return 0, true, err
}
return n, true, nil
}
return 0, false, nil
}
func uploadArtifactS3(ctx context.Context, opts artifactPublishOptions, path, key string) (string, error) {
dest := "s3://" + opts.Bucket + "/" + key
args := awsBaseArgs(opts)
args = append(args, "s3", "cp", path, dest, "--content-type", artifactContentType(path))
if opts.ACL != "" {
args = append(args, "--acl", opts.ACL)
}
if opts.DryRun {
return artifactS3URL(opts, key), nil
}
if _, err := exec.LookPath("aws"); err != nil {
return "", exit(2, "aws CLI is required for artifacts publish --storage s3: %v", err)
}
if out, err := commandOutput(ctx, "aws", args...); err != nil {
return "", exit(2, "aws s3 upload failed: %v: %s", err, tailForError(out))
}
if opts.Presign && opts.BaseURL == "" {
presignArgs := awsBaseArgs(opts)
presignArgs = append(presignArgs, "s3", "presign", dest, "--expires-in", fmt.Sprintf("%.0f", opts.Expires.Seconds()))
out, err := commandOutput(ctx, "aws", presignArgs...)
if err != nil {
return "", exit(2, "aws s3 presign failed: %v: %s", err, tailForError(out))
}
return strings.TrimSpace(out), nil
}
return artifactS3URL(opts, key), nil
}
func awsBaseArgs(opts artifactPublishOptions) []string {
var args []string
if opts.Profile != "" {
args = append(args, "--profile", opts.Profile)
}
if opts.Region != "" {
args = append(args, "--region", opts.Region)
}
if opts.EndpointURL != "" {
args = append(args, "--endpoint-url", opts.EndpointURL)
}
return args
}
func uploadArtifactCloudflare(ctx context.Context, opts artifactPublishOptions, path, key string) (string, error) {
if opts.DryRun {
return artifactCloudflareURL(opts, key), nil
}
if _, err := exec.LookPath("wrangler"); err != nil {
return "", exit(2, "wrangler CLI is required for artifacts publish --storage cloudflare: %v", err)
}
out, err := commandOutputWithEnv(ctx, artifactCloudflareEnv(), "wrangler", "r2", "object", "put", opts.Bucket+"/"+key, "--file", path, "--content-type", artifactContentType(path), "--remote")
if err != nil {
return "", exit(2, "wrangler r2 upload failed: %v: %s", err, tailForError(out))
}
return artifactCloudflareURL(opts, key), nil
}
func artifactCloudflareEnv() []string {
env := os.Environ()
if token := firstNonBlank(
os.Getenv("CRABBOX_ARTIFACTS_CLOUDFLARE_API_TOKEN"),
os.Getenv("CLOUDFLARE_API_TOKEN"),
); token != "" {
env = append(env, "CLOUDFLARE_API_TOKEN="+token)
}
if accountID := firstNonBlank(
os.Getenv("CRABBOX_ARTIFACTS_CLOUDFLARE_ACCOUNT_ID"),
os.Getenv("CLOUDFLARE_ACCOUNT_ID"),
); accountID != "" {
env = append(env, "CLOUDFLARE_ACCOUNT_ID="+accountID)
}
return env
}
func artifactS3URL(opts artifactPublishOptions, key string) string {
if opts.BaseURL != "" {
return joinURLPath(opts.BaseURL, key)
}
if opts.EndpointURL != "" {
return joinURLPath(opts.EndpointURL, opts.Bucket+"/"+key)
}
escapedKey := pathEscapeSegments(key)
if opts.Region != "" {
return "https://" + opts.Bucket + ".s3." + opts.Region + ".amazonaws.com/" + escapedKey
}
return "https://" + opts.Bucket + ".s3.amazonaws.com/" + escapedKey
}
func artifactCloudflareURL(opts artifactPublishOptions, key string) string {
if opts.BaseURL != "" {
return joinURLPath(opts.BaseURL, key)
}
return "r2://" + opts.Bucket + "/" + key
}
func artifactObjectKey(prefix, name string) string {
name = strings.TrimLeft(filepath.ToSlash(name), "/")
if strings.TrimSpace(prefix) == "" {
return name
}
return strings.Trim(strings.TrimSpace(prefix), "/") + "/" + name
}
func artifactContentType(path string) string {
if contentType := mime.TypeByExtension(strings.ToLower(filepath.Ext(path))); contentType != "" {
return contentType
}
switch strings.ToLower(filepath.Ext(path)) {
case ".md":
return "text/markdown; charset=utf-8"
case ".log":
return "text/plain; charset=utf-8"
default:
return "application/octet-stream"
}
}
func artifactKindForPath(path string) string {
name := strings.ToLower(filepath.Base(path))
ext := strings.ToLower(filepath.Ext(path))
switch {
case ext == ".gif":
return "gif"
case artifactExtIsVideo(ext):
return "video"
case (artifactExtIsImage(ext) || ext == "") && (strings.Contains(name, "screenshot") || strings.Contains(name, "before") || strings.Contains(name, "after")):
return "screenshot"
case strings.Contains(name, "doctor"):
return "doctor"
case strings.Contains(name, "webvnc"):
return "webvnc-status"
case strings.Contains(name, "metadata"):
return "metadata"
case strings.Contains(name, "log") || ext == ".txt":
return "logs"
default:
return strings.TrimPrefix(ext, ".")
}
}
func artifactExtIsVideo(ext string) bool {
switch strings.ToLower(ext) {
case ".mp4", ".mov", ".webm":
return true
default:
return false
}
}
func artifactExtIsImage(ext string) bool {
switch strings.ToLower(ext) {
case ".png", ".jpg", ".jpeg", ".gif":
return true
default:
return false
}
}
func artifactTemplateMarkdown(kind, summary, before, after string, files []artifactFile) string {
title := "OpenClaw QA Artifacts"
if strings.EqualFold(strings.TrimSpace(kind), "mantis") {
title = "Mantis QA Artifacts"
}
var b strings.Builder
fmt.Fprintf(&b, "## %s\n\n", title)
if strings.TrimSpace(summary) != "" {
fmt.Fprintf(&b, "### Summary\n%s\n\n", strings.TrimSpace(summary))
}
if before != "" || after != "" {
b.WriteString("### Before / After\n\n")
if before != "" {
fmt.Fprintf(&b, "**Before**\n\n%s\n\n", artifactMarkdownForAsset("before", before))
}
if after != "" {
fmt.Fprintf(&b, "**After**\n\n%s\n\n", artifactMarkdownForAsset("after", after))
}
}
if len(files) > 0 {
b.WriteString("### Evidence\n\n")
for _, file := range files {
location := firstNonBlank(file.URL, file.Path)
if location == "" {
continue
}
fmt.Fprintf(&b, "- %s: %s\n", file.Kind, artifactMarkdownForFile(file, location))
}
b.WriteString("\n")
}
return b.String()
}
func artifactMarkdownForFile(file artifactFile, location string) string {
if artifactFileIsInlineImage(file, location) {
return fmt.Sprintf("![%s](%s)", file.Name, location)
}
return fmt.Sprintf("[%s](%s)", file.Name, location)
}
func artifactMarkdownForAsset(label, location string) string {
if artifactLocationHasImageExtension(location) {
return fmt.Sprintf("![%s](%s)", label, location)
}
return fmt.Sprintf("[%s](%s)", label, location)
}
func artifactFileIsInlineImage(file artifactFile, location string) bool {
switch strings.ToLower(strings.TrimSpace(file.Kind)) {
case "gif", "screenshot", "image":
return true
}
return artifactLocationHasImageExtension(firstNonBlank(file.Name, location))
}
func artifactLocationHasImageExtension(location string) bool {
location = strings.TrimSpace(location)
if parsed, err := url.Parse(location); err == nil && parsed.Path != "" {
location = parsed.Path
} else {
location = strings.SplitN(location, "?", 2)[0]
location = strings.SplitN(location, "#", 2)[0]
}
switch strings.ToLower(filepath.Ext(location)) {
case ".png", ".jpg", ".jpeg", ".gif":
return true
default:
return false
}
}
func summaryText(summary, summaryFile string) (string, error) {
if strings.TrimSpace(summaryFile) == "" {
return strings.TrimSpace(summary), nil
}
data, err := os.ReadFile(summaryFile)
if err != nil {
return "", exit(2, "read summary file: %v", err)
}
if strings.TrimSpace(summary) != "" {
return strings.TrimSpace(summary) + "\n\n" + strings.TrimSpace(string(data)), nil
}
return strings.TrimSpace(string(data)), nil
}
func postGitHubPRComment(ctx context.Context, pr int, repo, bodyPath string) error {
args := []string{"issue", "comment", strconv.Itoa(pr), "--body-file", bodyPath}
if strings.TrimSpace(repo) != "" {
args = append(args, "--repo", strings.TrimSpace(repo))
}
if _, err := exec.LookPath("gh"); err != nil {
return exit(2, "gh CLI is required for artifacts publish --pr: %v", err)
}
if out, err := commandOutput(ctx, "gh", args...); err != nil {
return exit(2, "gh issue comment failed: %v: %s", err, tailForError(out))
}
return nil
}
func sortArtifactFiles(files []artifactFile) {
sort.Slice(files, func(i, j int) bool {
if files[i].Kind == files[j].Kind {
return files[i].Name < files[j].Name
}
return files[i].Kind < files[j].Kind
})
}
func joinURLPath(base, rel string) string {
base = strings.TrimRight(strings.TrimSpace(base), "/")
rel = strings.TrimLeft(filepath.ToSlash(rel), "/")
if base == "" {
return rel
}
return base + "/" + pathEscapeSegments(rel)
}
func pathEscapeSegments(path string) string {
parts := strings.Split(filepath.ToSlash(path), "/")
for i, part := range parts {
parts[i] = url.PathEscape(part)
}
return strings.Join(parts, "/")
}
func envBool(name string) bool {
value := strings.TrimSpace(os.Getenv(name))
switch strings.ToLower(value) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
func envDuration(name string, fallback time.Duration) time.Duration {
value := strings.TrimSpace(os.Getenv(name))
if value == "" {
return fallback
}
duration, err := time.ParseDuration(value)
if err != nil {
return fallback
}
return duration
}

View File

@ -1,518 +0,0 @@
package cli
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestParseArtifactPublishOptionsNormalizesStorage(t *testing.T) {
opts, err := parseArtifactPublishOptions([]string{
"--dir", "bundle",
"--storage", "r2",
"--bucket", "qa",
"--base-url", "https://artifacts.example.com/root/",
"--endpoint-url", "https://account.r2.cloudflarestorage.com",
"--prefix", "/runs/123/",
"--pr", "42",
}, io.Discard)
if err != nil {
t.Fatal(err)
}
if opts.Storage != "r2" {
t.Fatalf("storage=%q", opts.Storage)
}
if opts.BaseURL != "https://artifacts.example.com/root" {
t.Fatalf("baseURL=%q", opts.BaseURL)
}
if opts.Prefix != "runs/123" {
t.Fatalf("prefix=%q", opts.Prefix)
}
if opts.PR != 42 {
t.Fatalf("pr=%d", opts.PR)
}
}
func TestParseArtifactPublishOptionsRequiresExplicitDir(t *testing.T) {
t.Setenv("CRABBOX_ARTIFACTS_DIR", "")
_, err := parseArtifactPublishOptions([]string{
"--storage", "local",
}, io.Discard)
if err == nil {
t.Fatal("expected missing dir error")
}
if !strings.Contains(err.Error(), "requires --dir") {
t.Fatalf("err=%v", err)
}
}
func TestParseArtifactPublishOptionsAllowsDirFromEnv(t *testing.T) {
t.Setenv("CRABBOX_ARTIFACTS_DIR", "bundle")
opts, err := parseArtifactPublishOptions([]string{
"--storage", "local",
}, io.Discard)
if err != nil {
t.Fatal(err)
}
if opts.Directory != "bundle" {
t.Fatalf("dir=%q", opts.Directory)
}
}
func TestDefaultArtifactPublishPrefixIsUniqueAndScoped(t *testing.T) {
when := time.Date(2026, 5, 8, 3, 40, 41, 123456789, time.UTC)
got := defaultArtifactPublishPrefix(artifactPublishOptions{
Directory: "/tmp/artifacts/Blue Lobster",
PR: 42,
}, when)
if got != "pr-42/blue-lobster/20260508-034041-123456789" {
t.Fatalf("prefix=%q", got)
}
}
func TestEnsureArtifactPublishPrefixOnlyForHostedStorage(t *testing.T) {
hosted := artifactPublishOptions{Storage: "s3", Directory: "/tmp/artifacts/Blue Lobster", PR: 42}
ensureArtifactPublishPrefix(&hosted)
if !strings.HasPrefix(hosted.Prefix, "pr-42/blue-lobster/") {
t.Fatalf("hosted prefix=%q", hosted.Prefix)
}
local := artifactPublishOptions{Storage: "local", Directory: "/tmp/artifacts/Blue Lobster", PR: 42}
ensureArtifactPublishPrefix(&local)
if local.Prefix != "" {
t.Fatalf("local prefix=%q", local.Prefix)
}
}
func TestParseArtifactPublishOptionsR2UsesR2Defaults(t *testing.T) {
t.Setenv("AWS_PROFILE", "default")
t.Setenv("AWS_REGION", "us-east-1")
t.Setenv("CRABBOX_ARTIFACTS_R2_AWS_PROFILE", "qa-r2")
t.Setenv("CRABBOX_ARTIFACTS_R2_ENDPOINT_URL", "https://account.r2.cloudflarestorage.com")
opts, err := parseArtifactPublishOptions([]string{
"--dir", "bundle",
"--storage", "r2",
"--bucket", "qa",
}, io.Discard)
if err != nil {
t.Fatal(err)
}
if opts.Profile != "qa-r2" {
t.Fatalf("profile=%q", opts.Profile)
}
if opts.Region != "auto" {
t.Fatalf("region=%q", opts.Region)
}
if opts.EndpointURL != "https://account.r2.cloudflarestorage.com" {
t.Fatalf("endpointURL=%q", opts.EndpointURL)
}
}
func TestParseArtifactPublishOptionsRequiresCloudflareBaseURLForPR(t *testing.T) {
_, err := parseArtifactPublishOptions([]string{
"--dir", "bundle",
"--storage", "cloudflare",
"--bucket", "qa",
"--pr", "42",
}, io.Discard)
if err == nil {
t.Fatal("expected base-url validation error")
}
if !strings.Contains(err.Error(), "requires --base-url") {
t.Fatalf("err=%v", err)
}
}
func TestArtifactStorageURLs(t *testing.T) {
s3 := artifactS3URL(artifactPublishOptions{Bucket: "qa", Region: "eu-west-1"}, "runs/1/screen shot.png")
if s3 != "https://qa.s3.eu-west-1.amazonaws.com/runs/1/screen%20shot.png" {
t.Fatalf("s3 url=%s", s3)
}
custom := artifactS3URL(artifactPublishOptions{Bucket: "qa", EndpointURL: "https://s3.example.com/root"}, "runs/1/screen shot.png")
if custom != "https://s3.example.com/root/qa/runs/1/screen%20shot.png" {
t.Fatalf("custom s3 url=%s", custom)
}
r2 := artifactCloudflareURL(artifactPublishOptions{Bucket: "qa", BaseURL: "https://assets.example.com/base"}, "runs/1/after.gif")
if r2 != "https://assets.example.com/base/runs/1/after.gif" {
t.Fatalf("r2 url=%s", r2)
}
}
func TestArtifactCloudflareEnvUsesGenericCredentials(t *testing.T) {
t.Setenv("CLOUDFLARE_API_TOKEN", "generic-token")
t.Setenv("CLOUDFLARE_ACCOUNT_ID", "generic-account")
env := strings.Join(artifactCloudflareEnv(), "\n")
for _, want := range []string{
"CLOUDFLARE_API_TOKEN=generic-token",
"CLOUDFLARE_ACCOUNT_ID=generic-account",
} {
if !strings.Contains(env, want) {
t.Fatalf("env missing %q in %s", want, env)
}
}
}
func TestArtifactCloudflareEnvArtifactCredentialsWin(t *testing.T) {
t.Setenv("CLOUDFLARE_API_TOKEN", "generic-token")
t.Setenv("CLOUDFLARE_ACCOUNT_ID", "generic-account")
t.Setenv("CRABBOX_ARTIFACTS_CLOUDFLARE_API_TOKEN", "artifact-token")
t.Setenv("CRABBOX_ARTIFACTS_CLOUDFLARE_ACCOUNT_ID", "artifact-account")
env := strings.Join(artifactCloudflareEnv(), "\n")
for _, want := range []string{
"CLOUDFLARE_API_TOKEN=artifact-token",
"CLOUDFLARE_ACCOUNT_ID=artifact-account",
} {
if !strings.Contains(env, want) {
t.Fatalf("env missing %q in %s", want, env)
}
}
}
func TestArtifactTemplateMarkdownUsesInlineImages(t *testing.T) {
body := artifactTemplateMarkdown("mantis", "fixed login", "before.png", "https://cdn.example.com/after.gif", []artifactFile{
{Kind: "logs", Name: "logs.txt", URL: "https://cdn.example.com/logs.txt"},
{Kind: "screenshot", Name: "screenshot.png", URL: "https://s3.example.com/screenshot.png?X-Amz-Signature=abc"},
})
for _, want := range []string{
"## Mantis QA Artifacts",
"fixed login",
"![before](before.png)",
"![after](https://cdn.example.com/after.gif)",
"[logs.txt](https://cdn.example.com/logs.txt)",
"![screenshot.png](https://s3.example.com/screenshot.png?X-Amz-Signature=abc)",
} {
if !strings.Contains(body, want) {
t.Fatalf("markdown missing %q:\n%s", want, body)
}
}
}
func TestArtifactMarkdownForAssetIgnoresQueryString(t *testing.T) {
got := artifactMarkdownForAsset("after", "https://cdn.example.com/after.gif?token=secret")
if got != "![after](https://cdn.example.com/after.gif?token=secret)" {
t.Fatalf("markdown=%s", got)
}
}
func TestArtifactKindClassifiesBeforeAfterVideosAsVideo(t *testing.T) {
for _, name := range []string{"before.mp4", "after.mov", "nested/after.webm"} {
t.Run(name, func(t *testing.T) {
if got := artifactKindForPath(name); got != "video" {
t.Fatalf("kind=%q", got)
}
markdown := artifactMarkdownForFile(artifactFile{Kind: artifactKindForPath(name), Name: filepath.Base(name)}, "https://cdn.example.com/"+filepath.Base(name))
if strings.HasPrefix(markdown, "![") {
t.Fatalf("video markdown should be a link, got %s", markdown)
}
if !strings.HasPrefix(markdown, "[") {
t.Fatalf("video markdown should be a link, got %s", markdown)
}
})
}
}
func TestListArtifactBundleFilesSkipsPublishedMarkdown(t *testing.T) {
dir := t.TempDir()
mustWriteFile(t, filepath.Join(dir, "screenshot.png"), "png")
mustWriteFile(t, filepath.Join(dir, "published-artifacts.md"), "old")
mustWriteFile(t, filepath.Join(dir, "nested", "logs.txt"), "logs")
files, err := listArtifactBundleFiles(dir)
if err != nil {
t.Fatal(err)
}
var names []string
for _, file := range files {
names = append(names, file.Name)
}
got := strings.Join(names, ",")
if got != "nested/logs.txt,screenshot.png" {
t.Fatalf("files=%s", got)
}
}
func TestPublishArtifactFilesDryRunS3(t *testing.T) {
files := []artifactFile{{Kind: "gif", Name: "screen.gif", Path: "screen.gif"}}
opts := artifactPublishOptions{
Storage: "s3",
Bucket: "qa",
Region: "us-east-1",
Prefix: "runs/abc",
DryRun: true,
Expires: time.Hour,
}
published, err := publishArtifactFiles(context.Background(), opts, files)
if err != nil {
t.Fatal(err)
}
if len(published) != 1 || published[0].URL != "https://qa.s3.us-east-1.amazonaws.com/runs/abc/screen.gif" {
t.Fatalf("published=%#v", published)
}
}
func TestPublishArtifactFilesDryRunS3BaseURLWinsOverPresign(t *testing.T) {
files := []artifactFile{{Kind: "screenshot", Name: "screenshot.png", Path: "screenshot.png"}}
opts := artifactPublishOptions{
Storage: "r2",
Bucket: "qa",
Region: "auto",
Prefix: "runs/abc",
BaseURL: "https://artifacts.example.com",
Presign: true,
DryRun: true,
Expires: time.Hour,
}
published, err := publishArtifactFiles(context.Background(), opts, files)
if err != nil {
t.Fatal(err)
}
if len(published) != 1 || published[0].URL != "https://artifacts.example.com/runs/abc/screenshot.png" {
t.Fatalf("published=%#v", published)
}
}
func TestPublishArtifactFilesBrokerUploadsViaGrantedURL(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "screenshot.png")
mustWriteFile(t, path, "png-data")
wantHash := fmt.Sprintf("%x", sha256.Sum256([]byte("png-data")))
var uploaded string
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/artifacts/uploads":
var req CoordinatorArtifactUploadRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode upload request: %v", err)
}
if len(req.Files) != 1 || req.Files[0].Name != "screenshot.png" || req.Files[0].SHA256 != wantHash {
t.Fatalf("request=%#v", req)
}
w.Header().Set("content-type", "application/json")
_, _ = fmt.Fprintf(w, `{
"backend":"r2",
"bucket":"qa",
"prefix":"runs/abc",
"expiresAt":"2026-05-08T00:00:00Z",
"files":[{
"name":"screenshot.png",
"key":"runs/abc/screenshot.png",
"upload":{"method":"PUT","url":%q,"headers":{"content-type":"image/png","content-length":"8"},"expiresAt":"2026-05-08T00:00:00Z"},
"url":"https://artifacts.example.com/runs/abc/screenshot.png"
}]
}`, server.URL+"/upload/screenshot.png")
case "/upload/screenshot.png":
if r.Method != http.MethodPut {
t.Fatalf("method=%s", r.Method)
}
if r.ContentLength != int64(len("png-data")) {
t.Fatalf("content length=%d", r.ContentLength)
}
if len(r.TransferEncoding) > 0 {
t.Fatalf("transfer encoding=%v", r.TransferEncoding)
}
data, _ := io.ReadAll(r.Body)
uploaded = string(data)
w.WriteHeader(http.StatusOK)
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer server.Close()
published, err := publishArtifactFilesBroker(context.Background(), &CoordinatorClient{
BaseURL: server.URL,
Token: "token",
Client: server.Client(),
}, artifactPublishOptions{Storage: "broker", Prefix: "runs/abc"}, []artifactFile{
{Kind: "screenshot", Name: "screenshot.png", Path: path},
})
if err != nil {
t.Fatal(err)
}
if uploaded != "png-data" {
t.Fatalf("uploaded=%q", uploaded)
}
if len(published) != 1 || published[0].URL != "https://artifacts.example.com/runs/abc/screenshot.png" {
t.Fatalf("published=%#v", published)
}
}
func TestUploadArtifactGrantRejectsSizeMismatch(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "screenshot.png")
mustWriteFile(t, path, "png-data")
err := uploadArtifactGrant(context.Background(), path, CoordinatorArtifactUploadGrant{
Name: "screenshot.png",
Upload: struct {
Method string `json:"method"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
ExpiresAt string `json:"expiresAt"`
}{
Method: "PUT",
URL: "https://artifacts.example.com/upload",
Headers: map[string]string{"content-length": "1"},
},
})
if err == nil {
t.Fatal("expected size mismatch error")
}
if !strings.Contains(err.Error(), "size changed after broker grant") {
t.Fatalf("err=%v", err)
}
}
func TestArtifactsPublishValidatesSummaryBeforeMarkdownSideEffects(t *testing.T) {
dir := t.TempDir()
mustWriteFile(t, filepath.Join(dir, "screenshot.png"), "png")
var stdout, stderr bytes.Buffer
app := App{Stdout: &stdout, Stderr: &stderr}
err := app.artifactsPublish(context.Background(), []string{
"--dir", dir,
"--storage", "s3",
"--bucket", "qa",
"--dry-run",
"--summary-file", filepath.Join(dir, "missing.md"),
})
if err == nil {
t.Fatal("expected missing summary file error")
}
if !strings.Contains(err.Error(), "read summary file") {
t.Fatalf("err=%v", err)
}
if _, statErr := os.Stat(filepath.Join(dir, "published-artifacts.md")); !os.IsNotExist(statErr) {
t.Fatalf("published markdown should not be written before summary validation: %v", statErr)
}
if stdout.Len() != 0 {
t.Fatalf("stdout should be empty before publish side effects, got %q", stdout.String())
}
}
func TestPrintArtifactWarningUsesRescueShape(t *testing.T) {
var out bytes.Buffer
printArtifactWarning(&out, artifactWarning{
Problem: "WebVNC daemon not running",
Detail: "portal has no active bridge",
Rescue: []string{"crabbox webvnc reset --id blue --open"},
})
for _, want := range []string{
"problem: WebVNC daemon not running\n",
"detail: portal has no active bridge\n",
"rescue: crabbox webvnc reset --id blue --open\n",
} {
if !strings.Contains(out.String(), want) {
t.Fatalf("warning missing %q:\n%s", want, out.String())
}
}
}
func TestArtifactCollectFailureJSONIsParseable(t *testing.T) {
var stdout bytes.Buffer
app := App{Stdout: &stdout, Stderr: io.Discard}
result := artifactCollectResult{
Directory: "/tmp/bundle",
Metadata: artifactBundleMetadata{LeaseID: "cbx_123"},
Files: []artifactFile{{Kind: "metadata", Name: "metadata.json", Path: "/tmp/bundle/metadata.json"}},
}
err := app.finishArtifactCollectFailure(&result, true, exit(5, "capture screenshot: boom"), artifactWarning{
Problem: rescueScreenshotCaptureBroken,
Detail: "capture screenshot: boom",
Rescue: []string{"crabbox desktop doctor --id cbx_123"},
})
if err == nil {
t.Fatal("expected original collection error")
}
if strings.Contains(stdout.String(), "problem:") {
t.Fatalf("json stdout contains human rescue text: %q", stdout.String())
}
var decoded artifactCollectResult
if decodeErr := json.Unmarshal(stdout.Bytes(), &decoded); decodeErr != nil {
t.Fatalf("stdout is not valid JSON: %v\n%s", decodeErr, stdout.String())
}
if decoded.Error == nil || decoded.Error.Code != "screenshot-capture-broken" {
t.Fatalf("decoded error=%#v", decoded.Error)
}
if decoded.Files == nil || len(decoded.Files) != 1 {
t.Fatalf("files should remain a JSON array: %#v", decoded.Files)
}
if len(decoded.Warnings) != 1 || decoded.Warnings[0].Problem != rescueScreenshotCaptureBroken {
t.Fatalf("warnings=%#v", decoded.Warnings)
}
}
func TestArtifactsCollectValidationThroughKongStripsCommandPath(t *testing.T) {
var stdout, stderr bytes.Buffer
app := App{Stdout: &stdout, Stderr: &stderr}
err := app.Run(context.Background(), []string{"artifacts", "collect", "--gif", "--id", "dummy"})
if err == nil {
t.Fatal("expected validation error")
}
if !strings.Contains(err.Error(), "--gif requires --video or --all") {
t.Fatalf("err=%v stderr=%s stdout=%s", err, stderr.String(), stdout.String())
}
}
func TestWriteArtifactWebVNCStatusRecordsWarningsWithoutStdout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/leases/cbx_123/webvnc/status" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
_ = json.NewEncoder(w).Encode(CoordinatorWebVNCStatus{
LeaseID: "cbx_123",
Slug: "blue-lobster",
BridgeConnected: false,
ViewerConnected: true,
})
}))
defer server.Close()
dir := t.TempDir()
var stdout bytes.Buffer
app := App{Stdout: &stdout, Stderr: io.Discard}
var warnings []artifactWarning
path, ok, err := app.writeArtifactWebVNCStatus(context.Background(), Config{
Provider: "aws",
TargetOS: targetLinux,
Coordinator: server.URL,
CoordToken: "token",
}, SSHTarget{TargetOS: targetLinux}, "cbx_123", dir, &warnings)
if err != nil {
t.Fatal(err)
}
if !ok || path == "" {
t.Fatalf("path=%q ok=%t", path, ok)
}
if stdout.Len() != 0 {
t.Fatalf("status helper should not write rescue text directly to stdout, got %q", stdout.String())
}
if len(warnings) != 1 {
t.Fatalf("warnings=%#v", warnings)
}
if warnings[0].Problem != rescueVNCBridgeNotRunning {
t.Fatalf("warnings=%#v", warnings)
}
}
func mustWriteFile(t *testing.T, path, data string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
t.Fatal(err)
}
}

View File

@ -21,7 +21,7 @@ const defaultCoordinatorURL = "https://crabbox.openclaw.ai"
func (a App) login(ctx context.Context, args []string) error {
fs := newFlagSet("login", a.Stderr)
brokerURL := fs.String("url", "", "broker URL")
provider := fs.String("provider", "", "default provider: hetzner, aws, or azure")
provider := fs.String("provider", "", "default provider: hetzner or aws")
tokenStdin := fs.Bool("token-stdin", false, "read broker token from stdin")
noBrowser := fs.Bool("no-browser", false, "print GitHub login URL instead of opening a browser")
jsonOut := fs.Bool("json", false, "print JSON")

View File

@ -1,976 +0,0 @@
package cli
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v6"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources"
)
const (
azureAddressSpace = "10.42.0.0/16"
azureSubnetCIDR = "10.42.0.0/24"
azureProviderTag = "crabbox"
defaultAzureLinuxImage = "Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:latest"
defaultAzureWindowsImage = "MicrosoftWindowsServer:windowsserver2022:2022-datacenter-smalldisk-g2:latest"
azureDeleteRetryDelay = 15 * time.Second
azureDeleteRetryAttempts = 13
)
type AzureClient struct {
SubscriptionID string
Location string
ResourceGroup string
VNet string
Subnet string
NSG string
SSHCIDRs []string
Image azureImageRef
SSHPort string
FallbackPorts []string
cred azcore.TokenCredential
rg *armresources.ResourceGroupsClient
vnetc *armnetwork.VirtualNetworksClient
sgc *armnetwork.SecurityGroupsClient
pipc *armnetwork.PublicIPAddressesClient
nicc *armnetwork.InterfacesClient
vmc *armcompute.VirtualMachinesClient
vmextc *armcompute.VirtualMachineExtensionsClient
diskc *armcompute.DisksClient
skuc *armcompute.ResourceSKUsClient
ephemeralOSSupport map[string]bool
}
type azureImageRef struct{ Publisher, Offer, SKU, Version string }
func NewAzureClient(ctx context.Context, cfg Config) (*AzureClient, error) {
_ = ctx
if cfg.AzureSubscription == "" {
return nil, exit(3, "AZURE_SUBSCRIPTION_ID is required for direct azure provider")
}
if cfg.AzureLocation == "" {
return nil, exit(3, "azure location is required (set azure.location or CRABBOX_AZURE_LOCATION)")
}
cred, err := azureCredentialForConfig(cfg)
if err != nil {
return nil, exit(3, "azure credential: %v", err)
}
img, err := parseAzureImageRef(azureImageForConfig(cfg))
if err != nil {
return nil, err
}
rgFactory, err := armresources.NewClientFactory(cfg.AzureSubscription, cred, nil)
if err != nil {
return nil, fmt.Errorf("armresources factory: %w", err)
}
netFactory, err := armnetwork.NewClientFactory(cfg.AzureSubscription, cred, nil)
if err != nil {
return nil, fmt.Errorf("armnetwork factory: %w", err)
}
cmpFactory, err := armcompute.NewClientFactory(cfg.AzureSubscription, cred, nil)
if err != nil {
return nil, fmt.Errorf("armcompute factory: %w", err)
}
cidrs := cfg.AzureSSHCIDRs
if len(cidrs) == 0 {
cidrs = []string{"0.0.0.0/0"}
}
return &AzureClient{
SubscriptionID: cfg.AzureSubscription,
Location: cfg.AzureLocation,
ResourceGroup: cfg.AzureResourceGroup,
VNet: cfg.AzureVNet,
Subnet: cfg.AzureSubnet,
NSG: cfg.AzureNSG,
SSHCIDRs: cidrs,
Image: img,
SSHPort: cfg.SSHPort,
FallbackPorts: cfg.SSHFallbackPorts,
cred: cred,
rg: rgFactory.NewResourceGroupsClient(),
vnetc: netFactory.NewVirtualNetworksClient(),
sgc: netFactory.NewSecurityGroupsClient(),
pipc: netFactory.NewPublicIPAddressesClient(),
nicc: netFactory.NewInterfacesClient(),
vmc: cmpFactory.NewVirtualMachinesClient(),
vmextc: cmpFactory.NewVirtualMachineExtensionsClient(),
diskc: cmpFactory.NewDisksClient(),
skuc: cmpFactory.NewResourceSKUsClient(),
}, nil
}
func azureCredentialForConfig(cfg Config) (azcore.TokenCredential, error) {
if cfg.AzureTenant != "" && cfg.AzureClientID != "" {
if secret := os.Getenv("AZURE_CLIENT_SECRET"); secret != "" {
return azidentity.NewClientSecretCredential(cfg.AzureTenant, cfg.AzureClientID, secret, nil)
}
}
return azidentity.NewDefaultAzureCredential(nil)
}
func parseAzureImageRef(s string) (azureImageRef, error) {
parts := strings.Split(s, ":")
if len(parts) != 4 {
return azureImageRef{}, exit(2, "azure image must be Publisher:Offer:SKU:Version, got %q", s)
}
return azureImageRef{Publisher: parts[0], Offer: parts[1], SKU: parts[2], Version: parts[3]}, nil
}
func azureImageForConfig(cfg Config) string {
if cfg.TargetOS == targetWindows && (cfg.AzureImage == "" || cfg.AzureImage == defaultAzureLinuxImage) {
return defaultAzureWindowsImage
}
if cfg.AzureImage == "" {
return defaultAzureLinuxImage
}
return cfg.AzureImage
}
func azureVMSizeCandidatesForConfig(cfg Config) []string {
return azureVMSizeCandidatesForTargetModeClass(cfg.TargetOS, cfg.WindowsMode, cfg.Class)
}
func azureVMSizeCandidatesForTargetModeClass(target, windowsMode, class string) []string {
switch target {
case targetLinux:
return azureVMSizeCandidatesForClass(class)
case targetWindows:
if windowsMode == windowsModeNormal {
return azureWindowsVMSizeCandidatesForClass(class)
}
return []string{class}
default:
return []string{class}
}
}
func azureVMSizeCandidatesForClass(class string) []string {
switch class {
case "standard":
return []string{"Standard_D32ads_v6", "Standard_D32ds_v6", "Standard_F32s_v2", "Standard_D32ads_v5", "Standard_D32ds_v5", "Standard_D16ads_v6", "Standard_D16ds_v6", "Standard_F16s_v2"}
case "fast":
return []string{"Standard_D64ads_v6", "Standard_D64ds_v6", "Standard_F64s_v2", "Standard_D64ads_v5", "Standard_D64ds_v5", "Standard_D48ads_v6", "Standard_D48ds_v6", "Standard_F48s_v2", "Standard_D32ads_v6", "Standard_D32ds_v6", "Standard_F32s_v2"}
case "large":
return []string{"Standard_D96ads_v6", "Standard_D96ds_v6", "Standard_D96ads_v5", "Standard_D96ds_v5", "Standard_D64ads_v6", "Standard_D64ds_v6", "Standard_F64s_v2", "Standard_D48ads_v6", "Standard_D48ds_v6", "Standard_F48s_v2"}
case "beast":
return []string{"Standard_D192ds_v6", "Standard_D128ds_v6", "Standard_D96ads_v6", "Standard_D96ds_v6", "Standard_D96ads_v5", "Standard_D96ds_v5", "Standard_D64ads_v6", "Standard_D64ds_v6", "Standard_F64s_v2"}
default:
return []string{class}
}
}
func azureWindowsVMSizeCandidatesForClass(class string) []string {
switch class {
case "standard":
return []string{"Standard_D2ads_v6", "Standard_D2ds_v6", "Standard_D2ads_v5", "Standard_D2ds_v5", "Standard_D2as_v6"}
case "fast":
return []string{"Standard_D4ads_v6", "Standard_D4ds_v6", "Standard_D4ads_v5", "Standard_D4ds_v5", "Standard_D4as_v6"}
case "large":
return []string{"Standard_D8ads_v6", "Standard_D8ds_v6", "Standard_D8ads_v5", "Standard_D8ds_v5", "Standard_D8as_v6"}
case "beast":
return []string{"Standard_D16ads_v6", "Standard_D16ds_v6", "Standard_D16ads_v5", "Standard_D16ds_v5", "Standard_D8ads_v6"}
default:
return []string{class}
}
}
func azureSupportsEphemeralOS(vmSize string) bool {
normalized := strings.ToLower(vmSize)
if strings.HasPrefix(normalized, "standard_f") && strings.HasSuffix(normalized, "s_v2") {
return true
}
if (strings.HasPrefix(normalized, "standard_d") || strings.HasPrefix(normalized, "standard_e")) &&
(strings.Contains(normalized, "ds_v5") || strings.Contains(normalized, "ds_v6")) {
return true
}
return false
}
func (c *AzureClient) supportsEphemeralOS(ctx context.Context, vmSize string) bool {
if c.skuc == nil {
return azureSupportsEphemeralOS(vmSize)
}
if c.ephemeralOSSupport == nil {
if err := c.loadEphemeralOSSupport(ctx); err != nil {
return azureSupportsEphemeralOS(vmSize)
}
}
supported, ok := c.ephemeralOSSupport[vmSize]
if !ok {
return azureSupportsEphemeralOS(vmSize)
}
return supported
}
func (c *AzureClient) loadEphemeralOSSupport(ctx context.Context) error {
support := map[string]bool{}
filter := fmt.Sprintf("location eq '%s'", c.Location)
pager := c.skuc.NewListPager(&armcompute.ResourceSKUsClientListOptions{Filter: to.Ptr(filter)})
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
return err
}
for _, sku := range page.Value {
if sku == nil || sku.Name == nil || sku.ResourceType == nil || *sku.ResourceType != "virtualMachines" {
continue
}
support[*sku.Name] = azureSKUCapabilityTrue(sku.Capabilities, "EphemeralOSDiskSupported")
}
}
c.ephemeralOSSupport = support
return nil
}
func azureSKUCapabilityTrue(capabilities []*armcompute.ResourceSKUCapabilities, name string) bool {
for _, capability := range capabilities {
if capability == nil || capability.Name == nil || capability.Value == nil {
continue
}
if *capability.Name == name && strings.EqualFold(*capability.Value, "true") {
return true
}
}
return false
}
func (c *AzureClient) EnsureSharedInfra(ctx context.Context) error {
if err := c.ensureResourceGroup(ctx); err != nil {
return err
}
if err := c.ensureVNet(ctx); err != nil {
return err
}
return c.ensureNSG(ctx)
}
func azureSharedTags() map[string]*string {
return map[string]*string{
azureProviderTag: to.Ptr("true"),
"managed_by": to.Ptr("crabbox"),
}
}
func azureManagedByCrabbox(tags map[string]*string) bool {
if tags == nil {
return false
}
v := tags["managed_by"]
if v == nil {
return false
}
return *v == "crabbox"
}
func azureAdoptError(kind, name string) error {
return fmt.Errorf("azure %s %q exists but is not Crabbox-managed; either delete it, set tag managed_by=crabbox to adopt it, or use a different name", kind, name)
}
func preserveNonCrabboxRules(rules []*armnetwork.SecurityRule) []*armnetwork.SecurityRule {
out := make([]*armnetwork.SecurityRule, 0, len(rules))
for _, rule := range rules {
if rule == nil || rule.Name == nil {
continue
}
if strings.HasPrefix(*rule.Name, "crabbox-ssh-") {
continue
}
out = append(out, rule)
}
return out
}
func (c *AzureClient) ensureResourceGroup(ctx context.Context) error {
existing, err := c.rg.Get(ctx, c.ResourceGroup, nil)
if err == nil {
if !azureManagedByCrabbox(existing.Tags) {
return azureAdoptError("resource group", c.ResourceGroup)
}
return nil
}
if !isAzureNotFoundError(err) {
return fmt.Errorf("get resource group: %w", err)
}
if _, err := c.rg.CreateOrUpdate(ctx, c.ResourceGroup, armresources.ResourceGroup{
Location: to.Ptr(c.Location),
Tags: azureSharedTags(),
}, nil); err != nil {
return fmt.Errorf("create resource group: %w", err)
}
return nil
}
func (c *AzureClient) ensureVNet(ctx context.Context) error {
existing, err := c.vnetc.Get(ctx, c.ResourceGroup, c.VNet, nil)
if err == nil {
if !azureManagedByCrabbox(existing.Tags) {
return azureAdoptError("virtual network", c.VNet)
}
return nil
}
if !isAzureNotFoundError(err) {
return fmt.Errorf("get vnet: %w", err)
}
poller, err := c.vnetc.BeginCreateOrUpdate(ctx, c.ResourceGroup, c.VNet, armnetwork.VirtualNetwork{
Location: to.Ptr(c.Location),
Tags: azureSharedTags(),
Properties: &armnetwork.VirtualNetworkPropertiesFormat{
AddressSpace: &armnetwork.AddressSpace{
AddressPrefixes: []*string{to.Ptr(azureAddressSpace)},
},
Subnets: []*armnetwork.Subnet{{
Name: to.Ptr(c.Subnet),
Properties: &armnetwork.SubnetPropertiesFormat{
AddressPrefix: to.Ptr(azureSubnetCIDR),
},
}},
},
}, nil)
if err != nil {
return fmt.Errorf("begin vnet create: %w", err)
}
if _, err := poller.PollUntilDone(ctx, nil); err != nil {
return fmt.Errorf("vnet create: %w", err)
}
return nil
}
func (c *AzureClient) ensureNSG(ctx context.Context) error {
existing, err := c.sgc.Get(ctx, c.ResourceGroup, c.NSG, nil)
existingRules := []*armnetwork.SecurityRule{}
if err == nil {
if !azureManagedByCrabbox(existing.Tags) {
return azureAdoptError("network security group", c.NSG)
}
if existing.Properties != nil {
existingRules = existing.Properties.SecurityRules
}
} else if !isAzureNotFoundError(err) {
return fmt.Errorf("get nsg: %w", err)
}
rules := preserveNonCrabboxRules(existingRules)
usedPriorities := azureNSGUsedPriorities(rules)
for _, port := range sshPortCandidates(c.SSHPort, c.FallbackPorts) {
for j, cidr := range c.SSHCIDRs {
priority, err := nextAzureNSGPriority(usedPriorities)
if err != nil {
return err
}
rules = append(rules, &armnetwork.SecurityRule{
Name: to.Ptr(fmt.Sprintf("crabbox-ssh-%s-%d", port, j)),
Properties: &armnetwork.SecurityRulePropertiesFormat{
Protocol: to.Ptr(armnetwork.SecurityRuleProtocolTCP),
Access: to.Ptr(armnetwork.SecurityRuleAccessAllow),
Direction: to.Ptr(armnetwork.SecurityRuleDirectionInbound),
Priority: to.Ptr(priority),
SourceAddressPrefix: to.Ptr(cidr),
SourcePortRange: to.Ptr("*"),
DestinationAddressPrefix: to.Ptr("*"),
DestinationPortRange: to.Ptr(port),
},
})
}
}
poller, err := c.sgc.BeginCreateOrUpdate(ctx, c.ResourceGroup, c.NSG, armnetwork.SecurityGroup{
Location: to.Ptr(c.Location),
Tags: azureSharedTags(),
Properties: &armnetwork.SecurityGroupPropertiesFormat{
SecurityRules: rules,
},
}, nil)
if err != nil {
return fmt.Errorf("begin nsg create: %w", err)
}
if _, err := poller.PollUntilDone(ctx, nil); err != nil {
return fmt.Errorf("nsg create: %w", err)
}
return nil
}
func azureNSGUsedPriorities(rules []*armnetwork.SecurityRule) map[int32]bool {
used := map[int32]bool{}
for _, rule := range rules {
if rule == nil || rule.Properties == nil || rule.Properties.Priority == nil {
continue
}
used[*rule.Properties.Priority] = true
}
return used
}
func nextAzureNSGPriority(used map[int32]bool) (int32, error) {
for priority := int32(100); priority <= 4096; priority++ {
if !used[priority] {
used[priority] = true
return priority, nil
}
}
return 0, errors.New("azure nsg: no available security rule priorities")
}
func (c *AzureClient) CreateServerWithFallback(ctx context.Context, cfg Config, publicKey, leaseID, slug string, keep bool, logf func(string, ...any)) (Server, Config, error) {
if err := c.EnsureSharedInfra(ctx); err != nil {
return Server{}, cfg, err
}
var candidates []string
if cfg.ServerTypeExplicit && cfg.ServerType != "" {
candidates = []string{cfg.ServerType}
} else {
candidates = azureVMSizeCandidatesForConfig(cfg)
if cfg.ServerType != "" && cfg.ServerType != candidates[0] {
candidates = append([]string{cfg.ServerType}, candidates...)
}
}
var errs []error
for i, vmSize := range candidates {
next := cfg
next.ServerType = vmSize
if i > 0 && logf != nil {
logf("fallback provisioning type=%s after quota/capacity rejection\n", vmSize)
}
server, err := c.createServer(ctx, next, publicKey, leaseID, slug, keep)
if err == nil {
return server, next, nil
}
errs = append(errs, fmt.Errorf("%s: %w", vmSize, err))
if !isAzureRetryableProvisioningError(err) {
return Server{}, next, joinErrors(errs)
}
}
if strings.EqualFold(cfg.Capacity.Market, "spot") && strings.HasPrefix(cfg.Capacity.Fallback, "on-demand") {
for _, vmSize := range candidates {
next := cfg
next.ServerType = vmSize
next.Capacity.Market = "on-demand"
if logf != nil {
logf("fallback provisioning type=%s market=on-demand after spot rejection\n", vmSize)
}
server, err := c.createServer(ctx, next, publicKey, leaseID, slug, keep)
if err == nil {
return server, next, nil
}
errs = append(errs, fmt.Errorf("on-demand %s: %w", vmSize, err))
if !isAzureRetryableProvisioningError(err) {
return Server{}, next, joinErrors(errs)
}
}
}
return Server{}, cfg, joinErrors(errs)
}
func (c *AzureClient) createServer(ctx context.Context, cfg Config, publicKey, leaseID, slug string, keep bool) (server Server, err error) {
name := leaseProviderName(leaseID, slug)
defer func() {
if err == nil {
return
}
_ = c.deleteVMResources(context.Background(), name)
}()
return c.createServerSteps(ctx, cfg, publicKey, leaseID, slug, keep, name)
}
func (c *AzureClient) createServerSteps(ctx context.Context, cfg Config, publicKey, leaseID, slug string, keep bool, name string) (Server, error) {
pipName := name + "-pip"
nicName := name + "-nic"
diskName := name + "-osdisk"
if cfg.Tailscale.Enabled && cfg.Tailscale.Hostname == "" {
cfg.Tailscale.Hostname = renderTailscaleHostname(cfg.Tailscale.HostnameTemplate, leaseID, slug, cfg.Provider)
}
now := time.Now().UTC()
labels := directLeaseLabels(cfg, leaseID, slug, "azure", mapMarket(strings.EqualFold(cfg.Capacity.Market, "spot")), keep, now)
tags := azureLabelsToTags(labels)
pipPoller, err := c.pipc.BeginCreateOrUpdate(ctx, c.ResourceGroup, pipName, armnetwork.PublicIPAddress{
Location: to.Ptr(c.Location),
Tags: tags,
SKU: &armnetwork.PublicIPAddressSKU{
Name: to.Ptr(armnetwork.PublicIPAddressSKUNameStandard),
},
Properties: &armnetwork.PublicIPAddressPropertiesFormat{
PublicIPAllocationMethod: to.Ptr(armnetwork.IPAllocationMethodStatic),
},
}, nil)
if err != nil {
return Server{}, fmt.Errorf("begin public ip: %w", err)
}
pipResp, err := pipPoller.PollUntilDone(ctx, nil)
if err != nil {
return Server{}, fmt.Errorf("public ip: %w", err)
}
pipID := *pipResp.ID
subnetID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/virtualNetworks/%s/subnets/%s",
c.SubscriptionID, c.ResourceGroup, c.VNet, c.Subnet)
nsgID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/networkSecurityGroups/%s",
c.SubscriptionID, c.ResourceGroup, c.NSG)
nicPoller, err := c.nicc.BeginCreateOrUpdate(ctx, c.ResourceGroup, nicName, armnetwork.Interface{
Location: to.Ptr(c.Location),
Tags: tags,
Properties: &armnetwork.InterfacePropertiesFormat{
IPConfigurations: []*armnetwork.InterfaceIPConfiguration{{
Name: to.Ptr("ipconfig"),
Properties: &armnetwork.InterfaceIPConfigurationPropertiesFormat{
PrivateIPAllocationMethod: to.Ptr(armnetwork.IPAllocationMethodDynamic),
Subnet: &armnetwork.Subnet{ID: to.Ptr(subnetID)},
PublicIPAddress: &armnetwork.PublicIPAddress{ID: to.Ptr(pipID)},
},
}},
NetworkSecurityGroup: &armnetwork.SecurityGroup{ID: to.Ptr(nsgID)},
},
}, nil)
if err != nil {
return Server{}, fmt.Errorf("begin nic: %w", err)
}
nicResp, err := nicPoller.PollUntilDone(ctx, nil)
if err != nil {
return Server{}, fmt.Errorf("nic: %w", err)
}
nicID := *nicResp.ID
osProfile, err := c.azureOSProfile(cfg, publicKey, name, leaseID)
if err != nil {
return Server{}, err
}
osDisk := &armcompute.OSDisk{
Name: to.Ptr(diskName),
CreateOption: to.Ptr(armcompute.DiskCreateOptionTypesFromImage),
}
if c.supportsEphemeralOS(ctx, cfg.ServerType) {
osDisk.Caching = to.Ptr(armcompute.CachingTypesReadOnly)
osDisk.DiffDiskSettings = &armcompute.DiffDiskSettings{
Option: to.Ptr(armcompute.DiffDiskOptionsLocal),
}
} else {
osDisk.Caching = to.Ptr(armcompute.CachingTypesReadWrite)
osDisk.ManagedDisk = &armcompute.ManagedDiskParameters{
StorageAccountType: to.Ptr(armcompute.StorageAccountTypesStandardSSDLRS),
}
}
vmProperties := &armcompute.VirtualMachineProperties{
HardwareProfile: &armcompute.HardwareProfile{
VMSize: to.Ptr(armcompute.VirtualMachineSizeTypes(cfg.ServerType)),
},
StorageProfile: &armcompute.StorageProfile{
ImageReference: &armcompute.ImageReference{
Publisher: to.Ptr(c.Image.Publisher),
Offer: to.Ptr(c.Image.Offer),
SKU: to.Ptr(c.Image.SKU),
Version: to.Ptr(c.Image.Version),
},
OSDisk: osDisk,
},
OSProfile: osProfile,
NetworkProfile: &armcompute.NetworkProfile{
NetworkInterfaces: []*armcompute.NetworkInterfaceReference{{
ID: to.Ptr(nicID),
}},
},
}
if strings.EqualFold(cfg.Capacity.Market, "spot") {
vmProperties.Priority = to.Ptr(armcompute.VirtualMachinePriorityTypesSpot)
vmProperties.EvictionPolicy = to.Ptr(armcompute.VirtualMachineEvictionPolicyTypesDelete)
}
vmPoller, err := c.vmc.BeginCreateOrUpdate(ctx, c.ResourceGroup, name, armcompute.VirtualMachine{
Location: to.Ptr(c.Location),
Tags: tags,
Properties: vmProperties,
}, nil)
if err != nil {
return Server{}, fmt.Errorf("begin vm: %w", err)
}
vmResp, err := vmPoller.PollUntilDone(ctx, nil)
if err != nil {
return Server{}, fmt.Errorf("vm: %w", err)
}
if cfg.TargetOS == targetWindows {
if err := c.installWindowsBootstrapExtension(ctx, name, tags); err != nil {
return Server{}, err
}
}
return azureVMToServer(vmResp.VirtualMachine, ""), nil
}
func (c *AzureClient) azureOSProfile(cfg Config, publicKey, name, leaseID string) (*armcompute.OSProfile, error) {
if cfg.TargetOS != targetWindows {
sshPath := fmt.Sprintf("/home/%s/.ssh/authorized_keys", cfg.SSHUser)
return &armcompute.OSProfile{
ComputerName: to.Ptr(name),
AdminUsername: to.Ptr(cfg.SSHUser),
CustomData: to.Ptr(base64.StdEncoding.EncodeToString([]byte(cloudInit(cfg, publicKey)))),
LinuxConfiguration: &armcompute.LinuxConfiguration{
DisablePasswordAuthentication: to.Ptr(true),
SSH: &armcompute.SSHConfiguration{
PublicKeys: []*armcompute.SSHPublicKey{{
Path: to.Ptr(sshPath),
KeyData: to.Ptr(publicKey),
}},
},
},
}, nil
}
password, err := azureRandomAdminPassword()
if err != nil {
return nil, err
}
return &armcompute.OSProfile{
ComputerName: to.Ptr(azureComputerName(name, leaseID, cfg.TargetOS)),
AdminUsername: to.Ptr("crabadmin"),
AdminPassword: to.Ptr(password),
AllowExtensionOperations: to.Ptr(true),
CustomData: to.Ptr(base64.StdEncoding.EncodeToString([]byte(azureWindowsBootstrapPowerShell(cfg, publicKey)))),
WindowsConfiguration: &armcompute.WindowsConfiguration{
EnableAutomaticUpdates: to.Ptr(false),
ProvisionVMAgent: to.Ptr(true),
},
}, nil
}
func (c *AzureClient) installWindowsBootstrapExtension(ctx context.Context, vmName string, tags map[string]*string) error {
poller, err := c.vmextc.BeginCreateOrUpdate(ctx, c.ResourceGroup, vmName, "crabbox-bootstrap", armcompute.VirtualMachineExtension{
Location: to.Ptr(c.Location),
Tags: tags,
Properties: &armcompute.VirtualMachineExtensionProperties{
Publisher: to.Ptr("Microsoft.Compute"),
Type: to.Ptr("CustomScriptExtension"),
TypeHandlerVersion: to.Ptr("1.10"),
AutoUpgradeMinorVersion: to.Ptr(true),
Settings: map[string]any{"timestamp": time.Now().Unix()},
ProtectedSettings: map[string]any{
"commandToExecute": azureWindowsBootstrapCommand(),
},
},
}, nil)
if err != nil {
return fmt.Errorf("begin windows bootstrap extension: %w", err)
}
if _, err := poller.PollUntilDone(ctx, nil); err != nil {
return fmt.Errorf("windows bootstrap extension: %w", err)
}
return nil
}
func azureWindowsBootstrapCommand() string {
return `powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$p=Join-Path $env:SystemDrive 'AzureData\CustomData.bin'; $d=Join-Path $env:SystemDrive 'AzureData\crabbox-bootstrap.ps1'; Copy-Item -Force $p $d; & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $d"`
}
func azureRandomAdminPassword() (string, error) {
var b [18]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("generate azure admin password: %w", err)
}
return "Cb1!" + base64.StdEncoding.EncodeToString(b[:])[:18], nil
}
func azureComputerName(vmName, leaseID, target string) string {
if target != targetWindows {
return vmName
}
source := leaseID
if source == "" {
source = vmName
}
var b strings.Builder
for _, r := range strings.ToLower(source) {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
}
}
suffix := b.String()
if suffix == "" {
suffix = "windows"
}
if len(suffix) > 12 {
suffix = suffix[:12]
}
return "cbx" + suffix
}
func (c *AzureClient) WaitForServerIP(ctx context.Context, name string) (Server, error) {
pipName := name + "-pip"
deadline := time.Now().Add(2 * time.Minute)
for {
pip, err := c.pipc.Get(ctx, c.ResourceGroup, pipName, nil)
if err != nil {
return Server{}, err
}
if pip.Properties != nil && pip.Properties.IPAddress != nil && *pip.Properties.IPAddress != "" {
vm, err := c.vmc.Get(ctx, c.ResourceGroup, name, nil)
if err != nil {
return Server{}, err
}
return azureVMToServer(vm.VirtualMachine, *pip.Properties.IPAddress), nil
}
if time.Now().After(deadline) {
return Server{}, fmt.Errorf("timeout waiting for public ip on %s", name)
}
select {
case <-ctx.Done():
return Server{}, ctx.Err()
case <-time.After(5 * time.Second):
}
}
}
func (c *AzureClient) GetServer(ctx context.Context, name string) (Server, error) {
vm, err := c.vmc.Get(ctx, c.ResourceGroup, name, nil)
if err != nil {
return Server{}, err
}
pipName := name + "-pip"
ip := ""
if pip, err := c.pipc.Get(ctx, c.ResourceGroup, pipName, nil); err == nil && pip.Properties != nil && pip.Properties.IPAddress != nil {
ip = *pip.Properties.IPAddress
}
return azureVMToServer(vm.VirtualMachine, ip), nil
}
func (c *AzureClient) ListCrabboxServers(ctx context.Context) ([]Server, error) {
pager := c.vmc.NewListPager(c.ResourceGroup, nil)
var servers []Server
for pager.More() {
page, err := pager.NextPage(ctx)
if err != nil {
if isAzureNotFoundError(err) {
return servers, nil
}
return nil, err
}
for _, vm := range page.Value {
if vm == nil {
continue
}
if vm.Tags == nil || vm.Tags[azureProviderTag] == nil || *vm.Tags[azureProviderTag] != "true" {
continue
}
ip := ""
if vm.Name != nil {
pipName := *vm.Name + "-pip"
if pip, err := c.pipc.Get(ctx, c.ResourceGroup, pipName, nil); err == nil && pip.Properties != nil && pip.Properties.IPAddress != nil {
ip = *pip.Properties.IPAddress
}
}
servers = append(servers, azureVMToServer(*vm, ip))
}
}
return servers, nil
}
func (c *AzureClient) DeleteServer(ctx context.Context, name string) error {
return c.deleteVMResources(ctx, name)
}
func (c *AzureClient) deleteVMResources(ctx context.Context, name string) error {
for attempt := 0; ; attempt++ {
errs, retry := c.deleteVMResourcesOnce(ctx, name)
if len(errs) == 0 {
return nil
}
if !retry || attempt >= azureDeleteRetryAttempts-1 {
return joinErrors(errs)
}
select {
case <-ctx.Done():
errs = append(errs, ctx.Err())
return joinErrors(errs)
case <-time.After(azureDeleteRetryDelay):
}
}
}
func (c *AzureClient) deleteVMResourcesOnce(ctx context.Context, name string) ([]error, bool) {
var errs []error
retry := false
if poller, err := c.vmc.BeginDelete(ctx, c.ResourceGroup, name, nil); err == nil {
if _, err := poller.PollUntilDone(ctx, nil); err != nil && !isAzureNotFoundError(err) {
errs = append(errs, fmt.Errorf("delete vm %s: %w", name, err))
retry = retry || isAzureRetryableDeleteError(err)
}
} else if !isAzureNotFoundError(err) {
errs = append(errs, fmt.Errorf("begin delete vm: %w", err))
retry = retry || isAzureRetryableDeleteError(err)
}
if poller, err := c.nicc.BeginDelete(ctx, c.ResourceGroup, name+"-nic", nil); err == nil {
if _, err := poller.PollUntilDone(ctx, nil); err != nil && !isAzureNotFoundError(err) {
errs = append(errs, fmt.Errorf("delete nic %s-nic: %w", name, err))
retry = retry || isAzureRetryableDeleteError(err)
}
} else if !isAzureNotFoundError(err) {
errs = append(errs, fmt.Errorf("begin delete nic: %w", err))
retry = retry || isAzureRetryableDeleteError(err)
}
if err := c.deletePublicIP(ctx, name+"-pip"); err != nil {
errs = append(errs, err)
retry = retry || isAzureRetryableDeleteError(err)
}
if poller, err := c.diskc.BeginDelete(ctx, c.ResourceGroup, name+"-osdisk", nil); err == nil {
if _, err := poller.PollUntilDone(ctx, nil); err != nil && !isAzureNotFoundError(err) {
errs = append(errs, fmt.Errorf("delete disk %s-osdisk: %w", name, err))
retry = retry || isAzureRetryableDeleteError(err)
}
} else if !isAzureNotFoundError(err) {
errs = append(errs, fmt.Errorf("begin delete disk: %w", err))
retry = retry || isAzureRetryableDeleteError(err)
}
return errs, retry
}
func (c *AzureClient) deletePublicIP(ctx context.Context, pipName string) error {
poller, err := c.pipc.BeginDelete(ctx, c.ResourceGroup, pipName, nil)
if err != nil {
if isAzureNotFoundError(err) {
return nil
}
return fmt.Errorf("begin delete pip: %w", err)
}
if _, err := poller.PollUntilDone(ctx, nil); err != nil && !isAzureNotFoundError(err) {
return fmt.Errorf("delete pip %s: %w", pipName, err)
}
return nil
}
func (c *AzureClient) SetTags(ctx context.Context, name string, labels map[string]string) error {
poller, err := c.vmc.BeginUpdate(ctx, c.ResourceGroup, name, armcompute.VirtualMachineUpdate{
Tags: azureLabelsToTags(labels),
}, nil)
if err != nil {
return err
}
if _, err := poller.PollUntilDone(ctx, nil); err != nil {
return err
}
return nil
}
func azureVMToServer(vm armcompute.VirtualMachine, ip string) Server {
s := Server{
Provider: "azure",
Labels: map[string]string{},
}
if vm.Name != nil {
s.CloudID = *vm.Name
s.Name = *vm.Name
}
if vm.Properties != nil && vm.Properties.ProvisioningState != nil {
s.Status = *vm.Properties.ProvisioningState
}
if vm.Properties != nil && vm.Properties.HardwareProfile != nil && vm.Properties.HardwareProfile.VMSize != nil {
s.ServerType.Name = string(*vm.Properties.HardwareProfile.VMSize)
}
s.PublicNet.IPv4.IP = ip
for k, v := range vm.Tags {
if v != nil {
s.Labels[azureTagToLabelKey(k)] = *v
}
}
normalizeAzureWindowsModeLabel(s.Labels)
return s
}
func azureLabelsToTags(labels map[string]string) map[string]*string {
return stringMapToPtrMap(azureTagsFromLabels(labels))
}
func azureTagsFromLabels(labels map[string]string) map[string]string {
out := make(map[string]string, len(labels))
for k, v := range labels {
out[azureLabelToTagKey(k)] = v
}
return out
}
func azureLabelToTagKey(key string) string {
if strings.HasPrefix(strings.ToLower(key), "windows") {
return "crabbox_" + key
}
return key
}
func azureTagToLabelKey(key string) string {
if strings.HasPrefix(key, "crabbox_windows") {
return strings.TrimPrefix(key, "crabbox_")
}
return key
}
func normalizeAzureWindowsModeLabel(labels map[string]string) {
if labels == nil {
return
}
if labels["windows_mode"] == "" && labels["crabbox_windows_mode"] != "" {
labels["windows_mode"] = labels["crabbox_windows_mode"]
}
}
func stringMapToPtrMap(m map[string]string) map[string]*string {
out := make(map[string]*string, len(m))
for k, v := range m {
out[k] = to.Ptr(v)
}
return out
}
func isAzureRetryableProvisioningError(err error) bool {
if err == nil {
return false
}
s := err.Error()
return strings.Contains(s, "SkuNotAvailable") ||
strings.Contains(s, "QuotaExceeded") ||
strings.Contains(s, "OperationNotAllowed") ||
strings.Contains(s, "AllocationFailed") ||
strings.Contains(s, "ZonalAllocationFailed") ||
strings.Contains(s, "OverconstrainedAllocationRequest")
}
func isAzureNotFoundError(err error) bool {
if err == nil {
return false
}
var respErr *azcore.ResponseError
if errors.As(err, &respErr) && respErr.StatusCode == 404 {
return true
}
s := err.Error()
return strings.Contains(s, "ResourceNotFound") || strings.Contains(s, "NotFound")
}
func isAzureRetryableDeleteError(err error) bool {
if err == nil {
return false
}
s := err.Error()
return strings.Contains(s, "NicReservedForAnotherVm") ||
strings.Contains(s, "PublicIPAddressCannotBeDeleted") ||
strings.Contains(s, "InUse") ||
strings.Contains(s, "AnotherOperationInProgress") ||
(strings.Contains(s, "OperationNotAllowed") && strings.Contains(s, "retry after"))
}
func deleteAzureServer(ctx context.Context, cfg Config, server Server) error {
client, err := NewAzureClient(ctx, cfg)
if err != nil {
return err
}
name := server.CloudID
if name == "" {
name = server.Name
}
if name == "" {
return errors.New("azure delete: server has no name")
}
return client.DeleteServer(ctx, name)
}

View File

@ -1,389 +0,0 @@
package cli
import (
"reflect"
"strings"
"testing"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v6"
)
func TestParseAzureImageRef(t *testing.T) {
t.Parallel()
cases := []struct {
name string
input string
want azureImageRef
wantErr bool
}{
{
name: "ubuntu jammy gen2",
input: "Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:latest",
want: azureImageRef{Publisher: "Canonical", Offer: "0001-com-ubuntu-server-jammy", SKU: "22_04-lts-gen2", Version: "latest"},
},
{
name: "missing version",
input: "Canonical:offer:sku",
wantErr: true,
},
{
name: "empty",
input: "",
wantErr: true,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := parseAzureImageRef(tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error for %q, got nil", tc.input)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tc.want {
t.Fatalf("got %+v, want %+v", got, tc.want)
}
})
}
}
func TestAzureImageForConfig(t *testing.T) {
t.Parallel()
linux := baseConfig()
linux.TargetOS = targetLinux
if got := azureImageForConfig(linux); got != defaultAzureLinuxImage {
t.Fatalf("linux image=%q want %q", got, defaultAzureLinuxImage)
}
windows := baseConfig()
windows.TargetOS = targetWindows
if got := azureImageForConfig(windows); got != defaultAzureWindowsImage {
t.Fatalf("windows image=%q want %q", got, defaultAzureWindowsImage)
}
windows.AzureImage = "Contoso:offer:sku:latest"
if got := azureImageForConfig(windows); got != windows.AzureImage {
t.Fatalf("windows explicit image=%q want %q", got, windows.AzureImage)
}
}
func TestAzureVMSizeCandidatesForClass(t *testing.T) {
t.Parallel()
cases := []struct {
class string
want []string
}{
{class: "standard", want: []string{"Standard_D32ads_v6", "Standard_D32ds_v6", "Standard_F32s_v2", "Standard_D32ads_v5", "Standard_D32ds_v5", "Standard_D16ads_v6", "Standard_D16ds_v6", "Standard_F16s_v2"}},
{class: "fast", want: []string{"Standard_D64ads_v6", "Standard_D64ds_v6", "Standard_F64s_v2", "Standard_D64ads_v5", "Standard_D64ds_v5", "Standard_D48ads_v6", "Standard_D48ds_v6", "Standard_F48s_v2", "Standard_D32ads_v6", "Standard_D32ds_v6", "Standard_F32s_v2"}},
{class: "large", want: []string{"Standard_D96ads_v6", "Standard_D96ds_v6", "Standard_D96ads_v5", "Standard_D96ds_v5", "Standard_D64ads_v6", "Standard_D64ds_v6", "Standard_F64s_v2", "Standard_D48ads_v6", "Standard_D48ds_v6", "Standard_F48s_v2"}},
{class: "beast", want: []string{"Standard_D192ds_v6", "Standard_D128ds_v6", "Standard_D96ads_v6", "Standard_D96ds_v6", "Standard_D96ads_v5", "Standard_D96ds_v5", "Standard_D64ads_v6", "Standard_D64ds_v6", "Standard_F64s_v2"}},
{class: "Standard_F2s", want: []string{"Standard_F2s"}},
}
for _, tc := range cases {
got := azureVMSizeCandidatesForClass(tc.class)
if !reflect.DeepEqual(got, tc.want) {
t.Fatalf("class=%q: got %v, want %v", tc.class, got, tc.want)
}
}
}
func TestAzureVMSizeCandidatesForTargetModeClass(t *testing.T) {
t.Parallel()
linux := azureVMSizeCandidatesForTargetModeClass(targetLinux, windowsModeNormal, "standard")
if !reflect.DeepEqual(linux, azureVMSizeCandidatesForClass("standard")) {
t.Fatalf("linux target got %v want azure linux table", linux)
}
windows := azureVMSizeCandidatesForTargetModeClass(targetWindows, windowsModeNormal, "standard")
if want := azureWindowsVMSizeCandidatesForClass("standard"); !reflect.DeepEqual(windows, want) {
t.Fatalf("windows target got %v want %v", windows, want)
}
wsl2 := azureVMSizeCandidatesForTargetModeClass(targetWindows, windowsModeWSL2, "standard")
if !reflect.DeepEqual(wsl2, []string{"standard"}) {
t.Fatalf("wsl2 target got %v want explicit fallback", wsl2)
}
}
func TestAzureWindowsVMSizeCandidatesForClass(t *testing.T) {
t.Parallel()
got := azureWindowsVMSizeCandidatesForClass("beast")
want := []string{"Standard_D16ads_v6", "Standard_D16ds_v6", "Standard_D16ads_v5", "Standard_D16ds_v5", "Standard_D8ads_v6"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestServerTypeForProviderClassAzure(t *testing.T) {
t.Parallel()
got := serverTypeForProviderClass("azure", "beast")
if got != "Standard_D192ds_v6" {
t.Fatalf("got %q, want Standard_D192ds_v6", got)
}
}
func TestAzureSupportsEphemeralOS(t *testing.T) {
t.Parallel()
cases := map[string]bool{
"Standard_D2as_v5": false,
"Standard_D8s_v5": false,
"Standard_D2ads_v5": true,
"Standard_D2ads_v6": true,
"Standard_F2s_v2": true,
"Standard_E4ds_v5": true,
"Standard_D2as_v6": false,
"Standard_D2s_v6": false,
"Standard_B2s": false,
"Standard_A2_v2": false,
"": false,
}
for size, want := range cases {
if got := azureSupportsEphemeralOS(size); got != want {
t.Fatalf("size=%q got %v want %v", size, got, want)
}
}
}
func TestAzureComputerNameWindowsLimit(t *testing.T) {
t.Parallel()
got := azureComputerName("crabbox-coral-lobster-c9adbbb9", "cbx_8556d7bc1580", targetWindows)
if len(got) > 15 {
t.Fatalf("computer name %q length=%d", got, len(got))
}
if got != "cbxcbx8556d7bc1" {
t.Fatalf("got %q", got)
}
if linux := azureComputerName("crabbox-coral-lobster-c9adbbb9", "cbx_8556d7bc1580", targetLinux); linux != "crabbox-coral-lobster-c9adbbb9" {
t.Fatalf("linux computer name changed to %q", linux)
}
}
func TestAzureWindowsBootstrapPowerShell(t *testing.T) {
t.Parallel()
cfg := baseConfig()
cfg.Provider = "azure"
cfg.TargetOS = targetWindows
cfg.WorkRoot = defaultWindowsWorkRoot
got := azureWindowsBootstrapPowerShell(cfg, "ssh-rsa test")
for _, want := range []string{
"OpenSSH-Win64.zip",
"Git-2.52.0-64-bit.exe",
"administrators_authorized_keys",
"Match Group administrators",
"$sshPorts = @('2222', '22')",
"PasswordAuthentication no",
"Restart-Service sshd -Force",
"Set-Content -NoNewline -Encoding ASCII -Path $setupCompletePath",
} {
if !strings.Contains(got, want) {
t.Fatalf("bootstrap missing %q", want)
}
}
if strings.Contains(got, "Restart-Computer") {
t.Fatalf("azure extension bootstrap must not restart inside Custom Script Extension")
}
}
func TestAzureTagsMapReservedWindowsPrefix(t *testing.T) {
t.Parallel()
labels := map[string]string{
"crabbox": "true",
"windows_mode": "normal",
}
tags := azureTagsFromLabels(labels)
if tags["windows_mode"] != "" {
t.Fatalf("reserved windows tag key was not remapped: %#v", tags)
}
if tags["crabbox_windows_mode"] != "normal" {
t.Fatalf("missing remapped windows mode tag: %#v", tags)
}
server := azureVMToServer(armcompute.VirtualMachine{
Tags: stringMapToPtrMap(tags),
}, "")
if server.Labels["windows_mode"] != "normal" {
t.Fatalf("windows_mode label not restored: %#v", server.Labels)
}
}
func TestAzureSKUCapabilityTrue(t *testing.T) {
t.Parallel()
caps := []*armcompute.ResourceSKUCapabilities{
{Name: to.Ptr("EphemeralOSDiskSupported"), Value: to.Ptr("True")},
}
if !azureSKUCapabilityTrue(caps, "EphemeralOSDiskSupported") {
t.Fatal("capability should be true")
}
caps[0].Value = to.Ptr("False")
if azureSKUCapabilityTrue(caps, "EphemeralOSDiskSupported") {
t.Fatal("capability should be false")
}
}
func TestStringMapToPtrMap(t *testing.T) {
t.Parallel()
in := map[string]string{"a": "1", "b": "2"}
out := stringMapToPtrMap(in)
if len(out) != 2 {
t.Fatalf("len=%d, want 2", len(out))
}
if *out["a"] != "1" || *out["b"] != "2" {
t.Fatalf("values = %v, %v", *out["a"], *out["b"])
}
}
func TestIsAzureRetryableProvisioningError(t *testing.T) {
t.Parallel()
cases := map[string]bool{
"": false,
"some other error": false,
"compute.VMs: SkuNotAvailable in this region": true,
"QuotaExceeded for cores": true,
"AllocationFailed: out of capacity": true,
"OverconstrainedAllocationRequest: zone exhausted": true,
}
for msg, want := range cases {
var err error
if msg != "" {
err = errSentinel(msg)
}
if got := isAzureRetryableProvisioningError(err); got != want {
t.Fatalf("msg=%q got %v want %v", msg, got, want)
}
}
}
func TestIsAzureNotFoundError(t *testing.T) {
t.Parallel()
cases := map[string]bool{
"": false,
"transient": false,
"ResponseError: ResourceNotFound: vm missing": true,
"NotFound: pip already deleted": true,
}
for msg, want := range cases {
var err error
if msg != "" {
err = errSentinel(msg)
}
if got := isAzureNotFoundError(err); got != want {
t.Fatalf("msg=%q got %v want %v", msg, got, want)
}
}
}
func TestIsAzureRetryableDeleteError(t *testing.T) {
t.Parallel()
cases := map[string]bool{
"": false,
"validation failed": false,
"NicReservedForAnotherVm retry after 180 seconds": true,
"PublicIPAddressCannotBeDeleted because in use": true,
"AnotherOperationInProgress": true,
"OperationNotAllowed retry after 180 seconds": true,
}
for msg, want := range cases {
var err error
if msg != "" {
err = errSentinel(msg)
}
if got := isAzureRetryableDeleteError(err); got != want {
t.Fatalf("msg=%q got %v want %v", msg, got, want)
}
}
}
func TestPreserveNonCrabboxRules(t *testing.T) {
t.Parallel()
in := []*armnetwork.SecurityRule{
{Name: to.Ptr("crabbox-ssh-2222-0")},
{Name: to.Ptr("operator-https")},
nil,
{},
}
got := preserveNonCrabboxRules(in)
if len(got) != 1 || got[0] == nil || got[0].Name == nil || *got[0].Name != "operator-https" {
t.Fatalf("got %+v, want a single operator-https rule", got)
}
}
func TestNextAzureNSGPrioritySkipsPreservedRules(t *testing.T) {
t.Parallel()
used := azureNSGUsedPriorities([]*armnetwork.SecurityRule{{
Name: to.Ptr("operator-ssh"),
Properties: &armnetwork.SecurityRulePropertiesFormat{
Priority: to.Ptr[int32](100),
},
}})
got, err := nextAzureNSGPriority(used)
if err != nil {
t.Fatal(err)
}
if got != 101 {
t.Fatalf("got %d want 101", got)
}
}
type errSentinel string
func (e errSentinel) Error() string { return string(e) }
func TestAzureManagedByCrabbox(t *testing.T) {
t.Parallel()
val := "crabbox"
other := "platform-team"
cases := []struct {
name string
tags map[string]*string
want bool
}{
{name: "nil tags", tags: nil, want: false},
{name: "missing key", tags: map[string]*string{"crabbox": &val}, want: false},
{name: "wrong value", tags: map[string]*string{"managed_by": &other}, want: false},
{name: "match", tags: map[string]*string{"managed_by": &val}, want: true},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := azureManagedByCrabbox(tc.tags); got != tc.want {
t.Fatalf("got %v want %v", got, tc.want)
}
})
}
}
func TestAzureCredentialForConfigPrefersClientSecret(t *testing.T) {
t.Setenv("AZURE_CLIENT_SECRET", "shh")
cfg := Config{
AzureTenant: "00000000-0000-0000-0000-000000000001",
AzureClientID: "00000000-0000-0000-0000-000000000002",
}
cred, err := azureCredentialForConfig(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, ok := cred.(*azidentity.ClientSecretCredential); !ok {
t.Fatalf("got %T, want *azidentity.ClientSecretCredential", cred)
}
}
func TestAzureCredentialForConfigFallsBackToDefault(t *testing.T) {
// Make sure env vars don't accidentally yield ClientSecretCredential.
t.Setenv("AZURE_CLIENT_SECRET", "")
cfg := Config{AzureTenant: "tenant", AzureClientID: "client"}
cred, err := azureCredentialForConfig(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, ok := cred.(*azidentity.ClientSecretCredential); ok {
t.Fatalf("got ClientSecretCredential, want DefaultAzureCredential")
}
if _, ok := cred.(*azidentity.DefaultAzureCredential); !ok {
t.Fatalf("got %T, want *azidentity.DefaultAzureCredential", cred)
}
}

View File

@ -84,8 +84,8 @@ runcmd:
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
systemctl enable ssh || true
timeout 30s systemctl restart ssh || timeout 30s systemctl restart ssh.socket || true
systemctl enable --now ssh
systemctl restart ssh
%[7]s
touch /var/lib/crabbox/bootstrapped
crabbox-ready
@ -102,7 +102,12 @@ tasks:
`
}
func windowsBootstrapHeaderPowerShell(cfg Config, publicKey, workRoot string) string {
func windowsBootstrapPowerShell(cfg Config, publicKey string) string {
workRoot := cfg.WorkRoot
if workRoot == "" {
workRoot = `C:\crabbox`
}
wslMode := cfg.WindowsMode == windowsModeWSL2
return `
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
@ -125,108 +130,6 @@ function New-CrabboxPassword {
$user = ` + psQuote(cfg.SSHUser) + `
$publicKey = ` + psQuote(publicKey) + `
$workRoot = ` + psQuote(workRoot) + `
$sshPorts = ` + windowsSSHPortsPowerShell(cfg) + `
$base = "C:\ProgramData\crabbox"
$setupCompletePath = Join-Path $base "setup-complete"
$openSSHZip = "$env:TEMP\OpenSSH-Win64.zip"
$gitInstaller = "$env:TEMP\Git-2.52.0-64-bit.exe"
New-Item -ItemType Directory -Force -Path $base, $workRoot | Out-Null
`
}
func windowsBootstrapCorePowerShell() string {
return `
if (-not (Test-Path -LiteralPath $passwordPath)) {
New-CrabboxPassword | Set-Content -NoNewline -Encoding ASCII -Path $passwordPath
}
$userPassword = (Get-Content -Raw -Path $passwordPath).Trim()
if ($userPassword.Length -lt 12 -or $userPassword -notmatch '[A-Z]' -or $userPassword -notmatch '[a-z]' -or $userPassword -notmatch '[0-9]' -or $userPassword -notmatch '[^A-Za-z0-9]') {
$userPassword = New-CrabboxPassword
Set-Content -NoNewline -Encoding ASCII -Path $passwordPath -Value $userPassword
}
$secure = ConvertTo-SecureString $userPassword -AsPlainText -Force
if (-not (Get-LocalUser -Name $user -ErrorAction SilentlyContinue)) {
New-LocalUser -Name $user -Password $secure -PasswordNeverExpires -AccountNeverExpires | Out-Null
} else {
Set-LocalUser -Name $user -Password $secure -PasswordNeverExpires $true
}
Add-LocalGroupMember -Group "Administrators" -Member $user -ErrorAction SilentlyContinue
Set-Content -NoNewline -Encoding ASCII -Path $usernamePath -Value $user
if ($passwordMirrorPath) {
Set-Content -NoNewline -Encoding ASCII -Path $passwordMirrorPath -Value $userPassword
}
$userSID = (Get-LocalUser -Name $user).SID.Value
icacls.exe $workRoot /grant "*${userSID}:(OI)(CI)F" | Out-Null
$userSSHDir = Join-Path (Join-Path "C:\Users" $user) ".ssh"
$userAuthorizedKeys = Join-Path $userSSHDir "authorized_keys"
New-Item -ItemType Directory -Force -Path $userSSHDir | Out-Null
Set-Content -Encoding ASCII -Path $userAuthorizedKeys -Value $publicKey
icacls.exe $userSSHDir /inheritance:r /grant "*${userSID}:F" /grant "*S-1-5-32-544:F" /grant "*S-1-5-18:F" | Out-Null
icacls.exe $userAuthorizedKeys /inheritance:r /grant "*${userSID}:F" /grant "*S-1-5-32-544:F" /grant "*S-1-5-18:F" | Out-Null
if (-not (Get-Service -Name sshd -ErrorAction SilentlyContinue)) {
Retry { Invoke-WebRequest -Uri ` + psQuote(openSSHWin64ZipURL) + ` -OutFile $openSSHZip -UseBasicParsing }
Remove-Item -Recurse -Force "C:\Program Files\OpenSSH" -ErrorAction SilentlyContinue
Expand-Archive -LiteralPath $openSSHZip -DestinationPath "C:\Program Files" -Force
if (Test-Path -LiteralPath "C:\Program Files\OpenSSH-Win64") {
Rename-Item -LiteralPath "C:\Program Files\OpenSSH-Win64" -NewName "OpenSSH" -Force
}
& "C:\Program Files\OpenSSH\install-sshd.ps1"
}
New-Item -ItemType Directory -Force -Path "$env:ProgramData\ssh" | Out-Null
Set-Content -Encoding ASCII -Path "$env:ProgramData\ssh\administrators_authorized_keys" -Value $publicKey
icacls.exe "$env:ProgramData\ssh\administrators_authorized_keys" /inheritance:r /grant "*S-1-5-32-544:F" /grant "*S-1-5-18:F" | Out-Null
$sshdConfigPath = "$env:ProgramData\ssh\sshd_config"
$sshdConfig = ""
if (Test-Path -LiteralPath $sshdConfigPath) {
$sshdConfig = Get-Content -Raw -LiteralPath $sshdConfigPath
}
$globalLines = @()
$matchLines = @()
$inMatch = $false
foreach ($line in ($sshdConfig -split "\r?\n")) {
if ($line -match '^\s*Match\s+') { $inMatch = $true }
if (-not $inMatch -and $line -match '^\s*Port\s+\d+\s*$') { continue }
if ($enforceKeyAuth -and -not $inMatch -and $line -match '^\s*(PasswordAuthentication|PubkeyAuthentication)\s+') { continue }
if ($inMatch) { $matchLines += $line } else { $globalLines += $line }
}
foreach ($port in $sshPorts) { $globalLines += "Port $port" }
if ($enforceKeyAuth) {
$globalLines += "PubkeyAuthentication yes"
$globalLines += "PasswordAuthentication no"
}
if (($matchLines -join [Environment]::NewLine) -notmatch '(?im)^\s*Match\s+Group\s+administrators\b') {
$matchLines += "Match Group administrators"
$matchLines += " AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys"
}
Set-Content -Encoding ASCII -LiteralPath $sshdConfigPath -Value (($globalLines + $matchLines) -join [Environment]::NewLine)
foreach ($port in $sshPorts) {
$ruleName = "crabbox-sshd-$port"
if (-not (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue)) {
New-NetFirewallRule -Name $ruleName -DisplayName "Crabbox OpenSSH $port" -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort $port | Out-Null
}
}
Set-Service -Name sshd -StartupType Automatic
Start-Service sshd
if (-not (Test-Path -LiteralPath "C:\Program Files\Git\cmd\git.exe")) {
Retry { Invoke-WebRequest -Uri ` + psQuote(gitForWindowsSetupURL) + ` -OutFile $gitInstaller -UseBasicParsing }
Start-Process -FilePath $gitInstaller -ArgumentList "/VERYSILENT","/NORESTART","/NOCANCEL","/SP-" -Wait
}
$machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine")
foreach ($path in @("C:\Program Files\OpenSSH", "C:\Program Files\Git\cmd", "C:\Program Files\Git\usr\bin")) {
if ($machinePath -notlike "*$path*") { $machinePath = "$machinePath;$path" }
if ($env:Path -notlike "*$path*") { $env:Path = "$env:Path;$path" }
}
[Environment]::SetEnvironmentVariable("Path", $machinePath, "Machine")
`
}
func windowsBootstrapPowerShell(cfg Config, publicKey string) string {
workRoot := cfg.WorkRoot
if workRoot == "" {
workRoot = `C:\crabbox`
}
wslMode := cfg.WindowsMode == windowsModeWSL2
return windowsBootstrapHeaderPowerShell(cfg, publicKey, workRoot) + `
$wslMode = $` + fmt.Sprint(wslMode) + `
$wslDistro = "Crabbox"
$wslRoot = "C:\ProgramData\crabbox\wsl\Crabbox"
@ -236,16 +139,17 @@ $wslRootfsMinBytes = 100 * 1024 * 1024
$wslSetup = "C:\ProgramData\crabbox\wsl\linux-setup.sh"
$wslFeaturesMarker = "C:\ProgramData\crabbox\wsl-features-rebooted"
$wslKernelMarker = "C:\ProgramData\crabbox\wsl-kernel-rebooted"
$sshPorts = ` + windowsSSHPortsPowerShell(cfg) + `
$vncPasswordPath = "C:\ProgramData\crabbox\vnc.password"
$windowsUsernamePath = "C:\ProgramData\crabbox\windows.username"
$windowsPasswordPath = "C:\ProgramData\crabbox\windows.password"
$passwordPath = $vncPasswordPath
$usernamePath = $windowsUsernamePath
$passwordMirrorPath = $windowsPasswordPath
$enforceKeyAuth = $false
$userVNCStartupPath = "C:\ProgramData\crabbox\start-user-vnc.ps1"
$userVNCStartupCommandPath = Join-Path (Join-Path (Join-Path "C:\Users" $user) "AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup") "crabbox-user-vnc.cmd"
$setupCompletePath = "C:\ProgramData\crabbox\setup-complete"
$openSSHZip = "$env:TEMP\OpenSSH-Win64.zip"
$gitInstaller = "$env:TEMP\Git-2.52.0-64-bit.exe"
$tightVNCInstaller = "$env:TEMP\tightvnc-2.8.85-gpl-setup-64bit.msi"
New-Item -ItemType Directory -Force -Path "C:\ProgramData\crabbox", $workRoot | Out-Null
function Restart-CrabboxBootstrap($MarkerPath) {
Set-Content -NoNewline -Encoding ASCII -Path $MarkerPath -Value (Get-Date).ToString("o")
Restart-Computer -Force
@ -340,7 +244,74 @@ crabbox-ready
wsl.exe -d $wslDistro --user root --exec bash /mnt/c/ProgramData/crabbox/wsl/linux-setup.sh
if ($LASTEXITCODE -ne 0) { throw "WSL setup failed with exit $LASTEXITCODE" }
}
` + windowsBootstrapCorePowerShell() + `
if (-not (Test-Path -LiteralPath $vncPasswordPath)) {
New-CrabboxPassword | Set-Content -NoNewline -Encoding ASCII -Path $vncPasswordPath
}
$userPassword = Get-Content -Raw -Path $vncPasswordPath
if ($userPassword.Length -lt 12 -or $userPassword -notmatch '[A-Z]' -or $userPassword -notmatch '[a-z]' -or $userPassword -notmatch '[0-9]' -or $userPassword -notmatch '[^A-Za-z0-9]') {
$userPassword = New-CrabboxPassword
Set-Content -NoNewline -Encoding ASCII -Path $vncPasswordPath -Value $userPassword
}
$secure = ConvertTo-SecureString $userPassword -AsPlainText -Force
if (-not (Get-LocalUser -Name $user -ErrorAction SilentlyContinue)) {
New-LocalUser -Name $user -Password $secure -PasswordNeverExpires -AccountNeverExpires | Out-Null
} else {
Set-LocalUser -Name $user -Password $secure -PasswordNeverExpires $true
}
Add-LocalGroupMember -Group "Administrators" -Member $user -ErrorAction SilentlyContinue
Set-Content -NoNewline -Encoding ASCII -Path $windowsUsernamePath -Value $user
Set-Content -NoNewline -Encoding ASCII -Path $windowsPasswordPath -Value $userPassword
$userSID = (Get-LocalUser -Name $user).SID.Value
$userSSHDir = Join-Path (Join-Path "C:\Users" $user) ".ssh"
$userAuthorizedKeys = Join-Path $userSSHDir "authorized_keys"
New-Item -ItemType Directory -Force -Path $userSSHDir | Out-Null
Set-Content -Encoding ASCII -Path $userAuthorizedKeys -Value $publicKey
icacls.exe $userAuthorizedKeys /inheritance:r /grant "*${userSID}:F" /grant "*S-1-5-32-544:F" /grant "*S-1-5-18:F" | Out-Null
if (-not (Get-Service -Name sshd -ErrorAction SilentlyContinue)) {
Retry { Invoke-WebRequest -Uri ` + psQuote(openSSHWin64ZipURL) + ` -OutFile $openSSHZip -UseBasicParsing }
Remove-Item -Recurse -Force "C:\Program Files\OpenSSH" -ErrorAction SilentlyContinue
Expand-Archive -LiteralPath $openSSHZip -DestinationPath "C:\Program Files" -Force
if (Test-Path -LiteralPath "C:\Program Files\OpenSSH-Win64") {
Rename-Item -LiteralPath "C:\Program Files\OpenSSH-Win64" -NewName "OpenSSH" -Force
}
& "C:\Program Files\OpenSSH\install-sshd.ps1"
}
New-Item -ItemType Directory -Force -Path "$env:ProgramData\ssh" | Out-Null
Set-Content -Encoding ASCII -Path "$env:ProgramData\ssh\administrators_authorized_keys" -Value $publicKey
icacls.exe "$env:ProgramData\ssh\administrators_authorized_keys" /inheritance:r /grant "*S-1-5-32-544:F" /grant "*S-1-5-18:F" | Out-Null
$sshdConfigPath = "$env:ProgramData\ssh\sshd_config"
$sshdConfig = ""
if (Test-Path -LiteralPath $sshdConfigPath) {
$sshdConfig = Get-Content -Raw -LiteralPath $sshdConfigPath
}
$globalLines = @()
$matchLines = @()
$inMatch = $false
foreach ($line in ($sshdConfig -split "\r?\n")) {
if ($line -match '^\s*Match\s+') { $inMatch = $true }
if (-not $inMatch -and $line -match '^\s*Port\s+\d+\s*$') { continue }
if ($inMatch) { $matchLines += $line } else { $globalLines += $line }
}
foreach ($port in $sshPorts) { $globalLines += "Port $port" }
Set-Content -Encoding ASCII -LiteralPath $sshdConfigPath -Value (($globalLines + $matchLines) -join [Environment]::NewLine)
foreach ($port in $sshPorts) {
$ruleName = "crabbox-sshd-$port"
if (-not (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue)) {
New-NetFirewallRule -Name $ruleName -DisplayName "Crabbox OpenSSH $port" -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort $port | Out-Null
}
}
Set-Service -Name sshd -StartupType Automatic
Start-Service sshd
if (-not (Test-Path -LiteralPath "C:\Program Files\Git\cmd\git.exe")) {
Retry { Invoke-WebRequest -Uri ` + psQuote(gitForWindowsSetupURL) + ` -OutFile $gitInstaller -UseBasicParsing }
Start-Process -FilePath $gitInstaller -ArgumentList "/VERYSILENT","/NORESTART","/NOCANCEL","/SP-" -Wait
}
$machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine")
foreach ($path in @("C:\Program Files\OpenSSH", "C:\Program Files\Git\cmd", "C:\Program Files\Git\usr\bin")) {
if ($machinePath -notlike "*$path*") { $machinePath = "$machinePath;$path" }
if ($env:Path -notlike "*$path*") { $env:Path = "$env:Path;$path" }
}
[Environment]::SetEnvironmentVariable("Path", $machinePath, "Machine")
Initialize-CrabboxWSL2
if (-not (Test-Path -LiteralPath "C:\Program Files\TightVNC\tvnserver.exe")) {
Retry { Invoke-WebRequest -Uri ` + psQuote(tightVNCMSIURL) + ` -OutFile $tightVNCInstaller -UseBasicParsing }
@ -409,24 +380,6 @@ if (-not (Test-Path -LiteralPath $setupCompletePath)) {
`
}
func azureWindowsBootstrapPowerShell(cfg Config, publicKey string) string {
workRoot := cfg.WorkRoot
if workRoot == "" {
workRoot = defaultWindowsWorkRoot
}
return windowsBootstrapHeaderPowerShell(cfg, publicKey, workRoot) + `
$passwordPath = Join-Path $base "windows.password"
$usernamePath = Join-Path $base "windows.username"
$passwordMirrorPath = $null
$enforceKeyAuth = $true
` + windowsBootstrapCorePowerShell() + `
Restart-Service sshd -Force
git --version | Out-Null
tar --version | Out-Null
Set-Content -NoNewline -Encoding ASCII -Path $setupCompletePath -Value (Get-Date).ToString("o")
`
}
func windowsSSHPortsPowerShell(cfg Config) string {
ports := sshPortCandidates(cfg.SSHPort, cfg.SSHFallbackPorts)
quoted := make([]string, 0, len(ports))

View File

@ -17,8 +17,6 @@ func TestCloudInitUsesRetryingBootstrap(t *testing.T) {
"test -f /var/lib/crabbox/bootstrapped",
"test -w /work/crabbox",
" Port 2222\n Port 22",
"systemctl enable ssh || true",
"timeout 30s systemctl restart ssh || timeout 30s systemctl restart ssh.socket || true",
"touch /var/lib/crabbox/bootstrapped",
} {
if !strings.Contains(got, want) {
@ -28,9 +26,6 @@ func TestCloudInitUsesRetryingBootstrap(t *testing.T) {
if strings.Contains(got, "\npackages:\n") {
t.Fatal("cloudInit() must not use cloud-init's one-shot packages module")
}
if strings.Contains(got, "systemctl enable --now ssh") {
t.Fatal("cloudInit() must not use blocking systemctl enable --now ssh")
}
for _, notWant := range []string{"go version", "golang-go", "go.dev/dl/go", "/usr/local/go", "node --version", "pnpm --version", "docker --version", "build-essential", "docker.io", "corepack"} {
if strings.Contains(got, notWant) {
t.Fatalf("cloudInit() should not install project language runtime %q", notWant)
@ -42,7 +37,7 @@ func TestCloudInitStartsSSHBeforeOptionalDesktopBootstrap(t *testing.T) {
cfg := baseConfig()
cfg.Desktop = true
got := cloudInit(cfg, "ssh-ed25519 test")
sshIndex := strings.Index(got, "timeout 30s systemctl restart ssh")
sshIndex := strings.Index(got, "systemctl restart ssh")
desktopIndex := strings.Index(got, "retry apt-get install -y --no-install-recommends xvfb")
bootstrappedIndex := strings.Index(got, "touch /var/lib/crabbox/bootstrapped")
if sshIndex < 0 || desktopIndex < 0 || bootstrappedIndex < 0 {
@ -187,7 +182,6 @@ func TestAWSUserDataWindowsProfile(t *testing.T) {
"OpenSSH-Win64.zip",
"install-sshd.ps1",
"administrators_authorized_keys",
"Match Group administrators",
"$sshPorts = @('2222', '22')",
"sshd_config",
"Port $port",

View File

@ -47,9 +47,6 @@ func validateRequestedCapabilities(cfg Config) error {
if cfg.Code && !featureSetHas(spec.Features, FeatureCode) {
return exit(2, "web code is not supported for provider=%s", provider.Name())
}
if cfg.Provider == "azure" && cfg.TargetOS == targetWindows && (cfg.Desktop || cfg.Browser || cfg.Code || cfg.Tailscale.Enabled) {
return exit(2, "provider=azure target=windows currently supports SSH, sync, and run; desktop/browser/code/tailscale require Linux or AWS Windows where supported")
}
if cfg.Code && cfg.TargetOS != targetLinux {
return exit(2, "web code currently supports managed Linux leases only")
}

View File

@ -21,7 +21,6 @@ type crabboxKongCLI struct {
Run runKongCmd `cmd:"" passthrough:"" help:"Sync the repo, run a remote command, stream output."`
Desktop desktopKongCmd `cmd:"" help:"Launch apps into a visible desktop session."`
Media mediaKongCmd `cmd:"" help:"Create preview artifacts from recorded desktop videos."`
Artifacts artifactsKongCmd `cmd:"" help:"Collect, transform, and publish QA artifacts."`
SyncPlan syncPlanKongCmd `cmd:"" name:"sync-plan" passthrough:"" help:"Show local sync manifest size hotspots."`
History historyKongCmd `cmd:"" passthrough:"" help:"List recorded remote runs."`
Logs logsKongCmd `cmd:"" passthrough:"" help:"Print recorded run logs."`
@ -112,7 +111,7 @@ func normalizeKongHelpArgs(args []string) []string {
func isKongCommandGroup(command string) bool {
switch command {
case "actions", "admin", "artifacts", "cache", "config", "desktop", "image", "machine", "media", "pool":
case "actions", "admin", "cache", "config", "desktop", "image", "machine", "media", "pool":
return true
default:
return false
@ -238,29 +237,6 @@ type mediaPreviewKongCmd struct {
Args []string `arg:"" optional:""`
}
type artifactsKongCmd struct {
Collect artifactsCollectKongCmd `cmd:"" passthrough:"" help:"Collect screenshots, video, logs, status, and metadata into a bundle."`
Video artifactsVideoKongCmd `cmd:"" passthrough:"" help:"Record an MP4 from a desktop lease."`
Gif artifactsGifKongCmd `cmd:"" passthrough:"" help:"Create a trimmed GIF preview from a video."`
Template artifactsTemplateKongCmd `cmd:"" passthrough:"" help:"Write Mantis/OpenClaw QA summary markdown."`
Publish artifactsPublishKongCmd `cmd:"" passthrough:"" help:"Upload a bundle and optionally comment inline-ready assets on a PR."`
}
type artifactsCollectKongCmd struct {
Args []string `arg:"" optional:""`
}
type artifactsVideoKongCmd struct {
Args []string `arg:"" optional:""`
}
type artifactsGifKongCmd struct {
Args []string `arg:"" optional:""`
}
type artifactsTemplateKongCmd struct {
Args []string `arg:"" optional:""`
}
type artifactsPublishKongCmd struct {
Args []string `arg:"" optional:""`
}
type cacheKongCmd struct {
List cacheListKongCmd `cmd:"" passthrough:"" help:"Show remote cache usage."`
Stats cacheStatsKongCmd `cmd:"" passthrough:"" help:"Show remote cache usage."`
@ -404,22 +380,6 @@ func (c *mediaPreviewKongCmd) Run(ctx context.Context, app App) error {
return app.mediaPreview(ctx, c.Args)
}
func (c *artifactsCollectKongCmd) Run(ctx context.Context, app App) error {
return app.artifactsCollect(ctx, stripKongCommandPath(c.Args, "artifacts", "collect"))
}
func (c *artifactsVideoKongCmd) Run(ctx context.Context, app App) error {
return app.artifactsVideo(ctx, stripKongCommandPath(c.Args, "artifacts", "video"))
}
func (c *artifactsGifKongCmd) Run(ctx context.Context, app App) error {
return app.artifactsGif(ctx, stripKongCommandPath(c.Args, "artifacts", "gif"))
}
func (c *artifactsTemplateKongCmd) Run(ctx context.Context, app App) error {
return app.artifactsTemplate(ctx, stripKongCommandPath(c.Args, "artifacts", "template"))
}
func (c *artifactsPublishKongCmd) Run(ctx context.Context, app App) error {
return app.artifactsPublish(ctx, stripKongCommandPath(c.Args, "artifacts", "publish"))
}
func (c *cacheListKongCmd) Run(ctx context.Context, app App) error {
return app.cacheStats(ctx, c.Args)
}
@ -484,14 +444,3 @@ func (c *versionKongCmd) Run(app App) error {
fmt.Fprintln(app.Stdout, version)
return nil
}
func stripKongCommandPath(args []string, path ...string) []string {
out := append([]string{}, args...)
for _, part := range path {
if len(out) == 0 || out[0] != part {
return out
}
out = out[1:]
}
return out
}

View File

@ -53,7 +53,7 @@ const (
func (a App) webCode(ctx context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("code", a.Stderr)
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or azure")
provider := fs.String("provider", defaults.Provider, "provider: hetzner or aws")
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 code-server tunnel port")

View File

@ -38,16 +38,6 @@ type Config struct {
AWSRootGB int32
AWSSSHCIDRs []string
AWSMacHostID string
AzureSubscription string
AzureTenant string
AzureClientID string
AzureLocation string
AzureResourceGroup string
AzureImage string
AzureVNet string
AzureSubnet string
AzureNSG string
AzureSSHCIDRs []string
SSHUser string
SSHKey string
SSHPort string
@ -205,31 +195,25 @@ func baseConfig() Config {
class := "beast"
provider := "hetzner"
return Config{
Profile: "default",
Provider: provider,
TargetOS: "linux",
WindowsMode: "normal",
Network: NetworkAuto,
Class: class,
ServerType: "",
Location: "fsn1",
Image: "ubuntu-24.04",
AWSRegion: "eu-west-1",
AWSRootGB: 400,
AzureLocation: "eastus",
AzureResourceGroup: "crabbox-leases",
AzureImage: defaultAzureLinuxImage,
AzureVNet: "crabbox-vnet",
AzureSubnet: "crabbox-subnet",
AzureNSG: "crabbox-nsg",
SSHUser: "crabbox",
SSHKey: sshKey,
SSHPort: "2222",
SSHFallbackPorts: []string{"22"},
ProviderKey: "crabbox-steipete",
WorkRoot: defaultPOSIXWorkRoot,
TTL: 90 * time.Minute,
IdleTimeout: 30 * time.Minute,
Profile: "default",
Provider: provider,
TargetOS: "linux",
WindowsMode: "normal",
Network: NetworkAuto,
Class: class,
ServerType: "",
Location: "fsn1",
Image: "ubuntu-24.04",
AWSRegion: "eu-west-1",
AWSRootGB: 400,
SSHUser: "crabbox",
SSHKey: sshKey,
SSHPort: "2222",
SSHFallbackPorts: []string{"22"},
ProviderKey: "crabbox-steipete",
WorkRoot: defaultPOSIXWorkRoot,
TTL: 90 * time.Minute,
IdleTimeout: 30 * time.Minute,
Sync: SyncConfig{
Delete: true,
Checksum: false,
@ -299,7 +283,6 @@ type fileConfig struct {
Broker *fileBrokerConfig `yaml:"broker,omitempty"`
Hetzner *fileHetznerConfig `yaml:"hetzner,omitempty"`
AWS *fileAWSConfig `yaml:"aws,omitempty"`
Azure *fileAzureConfig `yaml:"azure,omitempty"`
SSH *fileSSHConfig `yaml:"ssh,omitempty"`
Sync *fileSyncConfig `yaml:"sync,omitempty"`
Env *fileEnvConfig `yaml:"env,omitempty"`
@ -353,19 +336,6 @@ type fileAWSConfig struct {
MacHostID string `yaml:"macHostId,omitempty"`
}
type fileAzureConfig struct {
SubscriptionID string `yaml:"subscriptionId,omitempty"`
TenantID string `yaml:"tenantId,omitempty"`
ClientID string `yaml:"clientId,omitempty"`
Location string `yaml:"location,omitempty"`
ResourceGroup string `yaml:"resourceGroup,omitempty"`
Image string `yaml:"image,omitempty"`
VNet string `yaml:"vnet,omitempty"`
Subnet string `yaml:"subnet,omitempty"`
NSG string `yaml:"nsg,omitempty"`
SSHCIDRs []string `yaml:"sshCIDRs,omitempty"`
}
type fileSSHConfig struct {
User string `yaml:"user,omitempty"`
Key string `yaml:"key,omitempty"`
@ -678,38 +648,6 @@ func applyFileConfig(cfg *Config, file fileConfig) {
cfg.AWSMacHostID = file.AWS.MacHostID
}
}
if file.Azure != nil {
if file.Azure.SubscriptionID != "" {
cfg.AzureSubscription = file.Azure.SubscriptionID
}
if file.Azure.TenantID != "" {
cfg.AzureTenant = file.Azure.TenantID
}
if file.Azure.ClientID != "" {
cfg.AzureClientID = file.Azure.ClientID
}
if file.Azure.Location != "" {
cfg.AzureLocation = file.Azure.Location
}
if file.Azure.ResourceGroup != "" {
cfg.AzureResourceGroup = file.Azure.ResourceGroup
}
if file.Azure.Image != "" {
cfg.AzureImage = file.Azure.Image
}
if file.Azure.VNet != "" {
cfg.AzureVNet = file.Azure.VNet
}
if file.Azure.Subnet != "" {
cfg.AzureSubnet = file.Azure.Subnet
}
if file.Azure.NSG != "" {
cfg.AzureNSG = file.Azure.NSG
}
if len(file.Azure.SSHCIDRs) > 0 {
cfg.AzureSSHCIDRs = file.Azure.SSHCIDRs
}
}
if file.SSH != nil {
if file.SSH.User != "" {
cfg.SSHUser = file.SSH.User
@ -1005,18 +943,6 @@ func applyEnv(cfg *Config) {
if cidrs := os.Getenv("CRABBOX_AWS_SSH_CIDRS"); cidrs != "" {
cfg.AWSSSHCIDRs = splitCommaList(cidrs)
}
cfg.AzureSubscription = getenv("CRABBOX_AZURE_SUBSCRIPTION_ID", getenv("AZURE_SUBSCRIPTION_ID", cfg.AzureSubscription))
cfg.AzureTenant = getenv("CRABBOX_AZURE_TENANT_ID", getenv("AZURE_TENANT_ID", cfg.AzureTenant))
cfg.AzureClientID = getenv("CRABBOX_AZURE_CLIENT_ID", getenv("AZURE_CLIENT_ID", cfg.AzureClientID))
cfg.AzureLocation = getenv("CRABBOX_AZURE_LOCATION", cfg.AzureLocation)
cfg.AzureResourceGroup = getenv("CRABBOX_AZURE_RESOURCE_GROUP", cfg.AzureResourceGroup)
cfg.AzureImage = getenv("CRABBOX_AZURE_IMAGE", cfg.AzureImage)
cfg.AzureVNet = getenv("CRABBOX_AZURE_VNET", cfg.AzureVNet)
cfg.AzureSubnet = getenv("CRABBOX_AZURE_SUBNET", cfg.AzureSubnet)
cfg.AzureNSG = getenv("CRABBOX_AZURE_NSG", cfg.AzureNSG)
if cidrs := os.Getenv("CRABBOX_AZURE_SSH_CIDRS"); cidrs != "" {
cfg.AzureSSHCIDRs = splitCommaList(cidrs)
}
cfg.SSHUser = getenv("CRABBOX_SSH_USER", cfg.SSHUser)
cfg.SSHKey = getenv("CRABBOX_SSH_KEY", cfg.SSHKey)
cfg.SSHPort = getenv("CRABBOX_SSH_PORT", cfg.SSHPort)
@ -1183,9 +1109,6 @@ func serverTypeForConfig(cfg Config) string {
if cfg.Provider == "aws" {
return awsInstanceTypeCandidatesForConfig(cfg)[0]
}
if cfg.Provider == "azure" {
return azureVMSizeCandidatesForConfig(cfg)[0]
}
return serverTypeForClass(cfg.Class)
}
@ -1199,9 +1122,6 @@ func serverTypeForProviderClass(provider, class string) string {
if provider == "aws" {
return awsInstanceTypeCandidatesForClass(class)[0]
}
if provider == "azure" {
return azureVMSizeCandidatesForClass(class)[0]
}
return serverTypeForClass(class)
}

View File

@ -134,7 +134,7 @@ func (a App) configShow(args []string) error {
func (a App) configSetBroker(args []string) error {
fs := newFlagSet("config set-broker", a.Stderr)
url := fs.String("url", "", "broker URL")
provider := fs.String("provider", "", "default provider: hetzner, aws, or azure")
provider := fs.String("provider", "", "default provider: hetzner or aws")
tokenStdin := fs.Bool("token-stdin", false, "read broker token from stdin")
adminTokenStdin := fs.Bool("admin-token-stdin", false, "read broker admin token from stdin")
if err := parseFlags(fs, args); err != nil {

223
internal/cli/control_ws.go Normal file
View File

@ -0,0 +1,223 @@
package cli
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"nhooyr.io/websocket"
)
const coordinatorControlDialTimeout = 1500 * time.Millisecond
type coordinatorControlConn struct {
conn *websocket.Conn
}
type coordinatorControlMessage struct {
Type string `json:"type"`
Protocol int `json:"protocol,omitempty"`
ClientID string `json:"clientID,omitempty"`
RunID string `json:"runID,omitempty"`
Events []CoordinatorRunEvent `json:"events,omitempty"`
NextSeq int `json:"nextSeq,omitempty"`
LeaseID string `json:"leaseID,omitempty"`
OK bool `json:"ok,omitempty"`
ExpiresAt string `json:"expiresAt,omitempty"`
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
IdleTimeoutSeconds int `json:"idleTimeoutSeconds,omitempty"`
Telemetry *LeaseTelemetry `json:"telemetry,omitempty"`
}
func dialCoordinatorControl(ctx context.Context, coord *CoordinatorClient) (*coordinatorControlConn, error) {
endpoint, err := coordinatorControlURL(coord.BaseURL)
if err != nil {
return nil, err
}
headers := http.Header{}
coord.addRequestHeaders(headers)
opts := &websocket.DialOptions{
HTTPHeader: headers,
}
if coord.Client != nil {
opts.HTTPClient = coord.Client
}
conn, resp, err := websocket.Dial(ctx, endpoint, opts)
if err != nil {
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
}
return nil, err
}
return &coordinatorControlConn{conn: conn}, nil
}
func coordinatorControlURL(baseURL string) (string, error) {
base, err := url.Parse(baseURL)
if err != nil {
return "", err
}
switch base.Scheme {
case "http":
base.Scheme = "ws"
case "https":
base.Scheme = "wss"
case "ws", "wss":
default:
return "", fmt.Errorf("unsupported coordinator scheme %q", base.Scheme)
}
base.Path = strings.TrimRight(base.Path, "/") + "/v1/control"
base.RawQuery = ""
base.Fragment = ""
return base.String(), nil
}
func (c *coordinatorControlConn) close() {
if c == nil || c.conn == nil {
return
}
_ = c.conn.Close(websocket.StatusNormalClosure, "")
}
func (c *coordinatorControlConn) write(ctx context.Context, payload any) error {
data, err := json.Marshal(payload)
if err != nil {
return err
}
return c.conn.Write(ctx, websocket.MessageText, data)
}
func (c *coordinatorControlConn) read(ctx context.Context) (coordinatorControlMessage, error) {
typ, data, err := c.conn.Read(ctx)
if err != nil {
return coordinatorControlMessage{}, err
}
if typ != websocket.MessageText {
return coordinatorControlMessage{}, fmt.Errorf("control websocket sent non-text frame")
}
var msg coordinatorControlMessage
if err := json.Unmarshal(data, &msg); err != nil {
return coordinatorControlMessage{}, err
}
return msg, nil
}
func followRunControlWebSocket(ctx context.Context, coord *CoordinatorClient, runID string, after int, poll time.Duration, stdout, stderr io.Writer) (int, bool, bool, error) {
dialCtx, cancel := context.WithTimeout(ctx, coordinatorControlDialTimeout)
control, err := dialCoordinatorControl(dialCtx, coord)
cancel()
if err != nil {
if ctx.Err() != nil {
return after, false, false, ctx.Err()
}
return after, false, false, nil
}
defer control.close()
nextAfter := after
writeCtx, writeCancel := context.WithTimeout(ctx, 5*time.Second)
err = control.write(writeCtx, map[string]any{
"type": "subscribe_run",
"runID": runID,
"after": nextAfter,
"limit": 100,
})
writeCancel()
if err != nil {
return nextAfter, false, true, nil
}
for {
readCtx, readCancel := context.WithTimeout(ctx, poll)
msg, err := control.read(readCtx)
readCancel()
if err != nil {
if ctx.Err() != nil {
return nextAfter, false, true, ctx.Err()
}
if errors.Is(err, context.DeadlineExceeded) {
done, err := coordinatorRunDone(ctx, coord, runID)
if err != nil {
return nextAfter, false, true, err
}
if done {
return nextAfter, true, true, nil
}
continue
}
done, doneErr := coordinatorRunDone(ctx, coord, runID)
if doneErr != nil {
return nextAfter, false, true, doneErr
}
if done {
return nextAfter, true, true, nil
}
return nextAfter, false, true, nil
}
switch msg.Type {
case "hello", "pong", "heartbeat":
continue
case "error":
return nextAfter, false, true, nil
case "run_events":
for _, event := range msg.Events {
if event.Seq <= nextAfter {
continue
}
nextAfter = event.Seq
printAttachEvent(stdout, stderr, event)
}
ackCtx, ackCancel := context.WithTimeout(ctx, 2*time.Second)
_ = control.write(ackCtx, map[string]any{
"type": "ack",
"runID": runID,
"seq": nextAfter,
})
ackCancel()
}
}
}
func coordinatorRunDone(ctx context.Context, coord *CoordinatorClient, runID string) (bool, error) {
run, err := coord.Run(ctx, runID)
if err != nil {
return false, err
}
return run.State != "running", nil
}
func (c *coordinatorControlConn) heartbeat(ctx context.Context, leaseID string, idleTimeout *time.Duration, telemetry *LeaseTelemetry) error {
payload := coordinatorControlMessage{
Type: "heartbeat",
LeaseID: leaseID,
Telemetry: telemetry,
}
if idleTimeout != nil && *idleTimeout > 0 {
payload.IdleTimeoutSeconds = int(idleTimeout.Seconds())
}
if err := c.write(ctx, payload); err != nil {
return err
}
for {
msg, err := c.read(ctx)
if err != nil {
return err
}
if msg.Type != "heartbeat" {
continue
}
if !msg.OK {
if msg.Error != "" {
return fmt.Errorf("control heartbeat failed: %s", msg.Error)
}
return fmt.Errorf("control heartbeat failed")
}
return nil
}
}

View File

@ -157,17 +157,13 @@ type CoordinatorWebVNCEvent struct {
}
type CoordinatorWebVNCStatus struct {
LeaseID string `json:"leaseID"`
Slug string `json:"slug,omitempty"`
BridgeConnected bool `json:"bridgeConnected"`
ViewerConnected bool `json:"viewerConnected"`
ViewerCount int `json:"viewerCount,omitempty"`
ObserverCount int `json:"observerCount,omitempty"`
AvailableViewerSlots int `json:"availableViewerSlots,omitempty"`
ControllerLabel string `json:"controllerLabel,omitempty"`
Command string `json:"command"`
Message string `json:"message,omitempty"`
Events []CoordinatorWebVNCEvent `json:"events,omitempty"`
LeaseID string `json:"leaseID"`
Slug string `json:"slug,omitempty"`
BridgeConnected bool `json:"bridgeConnected"`
ViewerConnected bool `json:"viewerConnected"`
Command string `json:"command"`
Message string `json:"message,omitempty"`
Events []CoordinatorWebVNCEvent `json:"events,omitempty"`
}
type CoordinatorWebVNCReset struct {
@ -302,38 +298,6 @@ type CoordinatorRunEventInput struct {
ExitCode *int `json:"exitCode,omitempty"`
}
type CoordinatorArtifactUploadRequest struct {
Prefix string `json:"prefix,omitempty"`
Files []CoordinatorArtifactUploadInput `json:"files"`
}
type CoordinatorArtifactUploadInput struct {
Name string `json:"name"`
Size int64 `json:"size"`
ContentType string `json:"contentType,omitempty"`
SHA256 string `json:"sha256,omitempty"`
}
type CoordinatorArtifactUploadResponse struct {
Backend string `json:"backend"`
Bucket string `json:"bucket"`
Prefix string `json:"prefix"`
ExpiresAt string `json:"expiresAt"`
Files []CoordinatorArtifactUploadGrant `json:"files"`
}
type CoordinatorArtifactUploadGrant struct {
Name string `json:"name"`
Key string `json:"key"`
Upload struct {
Method string `json:"method"`
URL string `json:"url"`
Headers map[string]string `json:"headers"`
ExpiresAt string `json:"expiresAt"`
} `json:"upload"`
URL string `json:"url"`
}
type TestResultSummary struct {
Format string `json:"format"`
Files []string `json:"files"`
@ -492,8 +456,6 @@ func (c *CoordinatorClient) CreateLease(ctx context.Context, cfg Config, publicK
"awsRootGB": cfg.AWSRootGB,
"awsSSHCIDRs": cfg.AWSSSHCIDRs,
"awsMacHostID": cfg.AWSMacHostID,
"azureLocation": cfg.AzureLocation,
"azureImage": cfg.AzureImage,
"sshUser": cfg.SSHUser,
"sshPort": cfg.SSHPort,
"sshFallbackPorts": cfg.SSHFallbackPorts,
@ -838,12 +800,6 @@ func (c *CoordinatorClient) AppendRunEvent(ctx context.Context, runID string, in
return res.Event, err
}
func (c *CoordinatorClient) CreateArtifactUploads(ctx context.Context, input CoordinatorArtifactUploadRequest) (CoordinatorArtifactUploadResponse, error) {
var res CoordinatorArtifactUploadResponse
err := c.do(ctx, http.MethodPost, "/v1/artifacts/uploads", input, &res)
return res, err
}
func (c *CoordinatorClient) SyncExternalRunners(ctx context.Context, provider string, runners []CoordinatorExternalRunner) (CoordinatorExternalRunnerSyncResponse, error) {
var res CoordinatorExternalRunnerSyncResponse
err := c.do(ctx, http.MethodPost, "/v1/runners/sync", map[string]any{
@ -941,16 +897,7 @@ func (c *CoordinatorClient) doHTTP(ctx context.Context, method, path string, dat
if hasBody {
req.Header.Set("Content-Type", "application/json")
}
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
c.addAccessHeaders(req.Header)
if owner := localCoordinatorOwner(); owner != "" {
req.Header.Set("X-Crabbox-Owner", owner)
}
if org := os.Getenv("CRABBOX_ORG"); org != "" {
req.Header.Set("X-Crabbox-Org", org)
}
c.addRequestHeaders(req.Header)
resp, err := c.Client.Do(req)
if err != nil {
return err
@ -959,6 +906,19 @@ func (c *CoordinatorClient) doHTTP(ctx context.Context, method, path string, dat
return decodeCoordinatorResponse(method, path, resp.StatusCode, resp.Body, out)
}
func (c *CoordinatorClient) addRequestHeaders(headers http.Header) {
if c.Token != "" {
headers.Set("Authorization", "Bearer "+c.Token)
}
c.addAccessHeaders(headers)
if owner := localCoordinatorOwner(); owner != "" {
headers.Set("X-Crabbox-Owner", owner)
}
if org := os.Getenv("CRABBOX_ORG"); org != "" {
headers.Set("X-Crabbox-Org", org)
}
}
func (c *CoordinatorClient) doCurl(ctx context.Context, method, path string, data []byte, hasBody bool, out any) error {
config, cleanup, err := c.curlConfig(method, path, data, hasBody)
if err != nil {

View File

@ -13,6 +13,8 @@ import (
"strings"
"testing"
"time"
"nhooyr.io/websocket"
)
func TestCoordinatorMachineIDAcceptsStringOrNumber(t *testing.T) {
@ -295,6 +297,10 @@ func TestCoordinatorAppendRunTelemetry(t *testing.T) {
func TestCoordinatorHeartbeatTouchesImmediately(t *testing.T) {
touches := make(chan struct{}, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/control" {
http.NotFound(w, r)
return
}
if r.URL.Path != "/v1/leases/cbx_123/heartbeat" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
@ -318,6 +324,10 @@ func TestCoordinatorHeartbeatTouchesImmediately(t *testing.T) {
func TestCoordinatorHeartbeatIncludesTelemetry(t *testing.T) {
bodies := make(chan string, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v1/control" {
http.NotFound(w, r)
return
}
if r.URL.Path != "/v1/leases/cbx_123/heartbeat" {
t.Fatalf("unexpected path %s", r.URL.Path)
}
@ -346,6 +356,59 @@ func TestCoordinatorHeartbeatIncludesTelemetry(t *testing.T) {
}
}
func TestCoordinatorHeartbeatUsesControlWebSocket(t *testing.T) {
bodies := make(chan string, 1)
httpHeartbeats := make(chan struct{}, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/v1/control":
conn, err := websocket.Accept(w, r, nil)
if err != nil {
t.Errorf("accept control websocket: %v", err)
return
}
defer conn.Close(websocket.StatusNormalClosure, "")
_, data, err := conn.Read(r.Context())
if err != nil {
t.Errorf("read control heartbeat: %v", err)
return
}
bodies <- string(data)
_ = conn.Write(r.Context(), websocket.MessageText, []byte(`{"type":"heartbeat","leaseID":"cbx_123","ok":true,"expiresAt":"2026-05-01T00:30:00Z"}`))
<-r.Context().Done()
case r.Method == http.MethodPost && r.URL.Path == "/v1/leases/cbx_123/heartbeat":
httpHeartbeats <- struct{}{}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"lease":{"id":"cbx_123","provider":"aws","state":"active","expiresAt":"2026-05-01T00:30:00Z"}}`))
default:
t.Fatalf("unexpected path %s", r.URL.Path)
}
}))
defer server.Close()
load := 0.77
collector := func(context.Context) (*LeaseTelemetry, error) {
return &LeaseTelemetry{Load1: &load}, nil
}
client := CoordinatorClient{BaseURL: server.URL, Client: server.Client()}
stop := startCoordinatorHeartbeat(context.Background(), &client, "cbx_123", 30*time.Minute, nil, collector, io.Discard)
defer stop()
select {
case body := <-bodies:
if !strings.Contains(body, `"type":"heartbeat"`) || !strings.Contains(body, `"load1":0.77`) {
t.Fatalf("control heartbeat body=%s", body)
}
case <-time.After(2 * time.Second):
t.Fatal("heartbeat did not use control websocket")
}
select {
case <-httpHeartbeats:
t.Fatal("heartbeat fell back to HTTP despite websocket success")
default:
}
}
func TestCoordinatorLeaseWatchCancelsWhenLeaseReleased(t *testing.T) {
oldInterval := coordinatorLeaseWatchInterval
coordinatorLeaseWatchInterval = 10 * time.Millisecond
@ -379,8 +442,6 @@ func TestCoordinatorLeaseWatchCancelsWhenLeaseReleased(t *testing.T) {
func TestCoordinatorCreateLeaseSendsAWSSSHCIDRs(t *testing.T) {
var body struct {
AWSSSHCIDRs []string `json:"awsSSHCIDRs"`
AzureLocation string `json:"azureLocation"`
AzureImage string `json:"azureImage"`
SSHFallbackPorts []string `json:"sshFallbackPorts"`
ServerTypeExplicit bool `json:"serverTypeExplicit"`
Capacity map[string]any
@ -403,8 +464,6 @@ func TestCoordinatorCreateLeaseSendsAWSSSHCIDRs(t *testing.T) {
ServerType: "t3.small",
ServerTypeExplicit: true,
AWSSSHCIDRs: []string{"198.51.100.7/32"},
AzureLocation: "eastus",
AzureImage: "Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:latest",
SSHFallbackPorts: []string{"22", "2022"},
Capacity: CapacityConfig{
Market: "spot",
@ -419,12 +478,6 @@ func TestCoordinatorCreateLeaseSendsAWSSSHCIDRs(t *testing.T) {
if len(body.AWSSSHCIDRs) != 1 || body.AWSSSHCIDRs[0] != "198.51.100.7/32" {
t.Fatalf("awsSSHCIDRs=%v", body.AWSSSHCIDRs)
}
if body.AzureLocation != "eastus" {
t.Fatalf("azureLocation=%q", body.AzureLocation)
}
if body.AzureImage != "Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:latest" {
t.Fatalf("azureImage=%q", body.AzureImage)
}
if len(body.SSHFallbackPorts) != 2 || body.SSHFallbackPorts[0] != "22" || body.SSHFallbackPorts[1] != "2022" {
t.Fatalf("sshFallbackPorts=%v", body.SSHFallbackPorts)
}

View File

@ -11,7 +11,7 @@ import (
func (a App) desktopLaunch(ctx context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("desktop launch", a.Stderr)
provider := fs.String("provider", defaults.Provider, providerHelpSSH())
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or ssh")
id := fs.String("id", "", "lease id or slug")
browser := fs.Bool("browser", false, "launch the target browser")
url := fs.String("url", "", "URL to pass to the launched browser")
@ -54,7 +54,7 @@ func (a App) desktopLaunch(ctx context.Context, args []string) error {
return err
}
if *webvnc && (isBlacksmithProvider(cfg.Provider) || isStaticProvider(cfg.Provider)) {
return exit(2, "desktop launch --webvnc currently supports coordinator-backed hetzner/aws/azure desktop leases")
return exit(2, "desktop launch --webvnc currently supports coordinator-backed hetzner/aws desktop leases")
}
if *id == "" && !isStaticProvider(cfg.Provider) {
return exit(2, "usage: crabbox desktop launch --id <lease-id-or-slug> [--browser] [--url <url>] -- <command...>")

View File

@ -7,7 +7,6 @@ import (
"os"
"strconv"
"strings"
"time"
)
func (a App) desktopDoctor(ctx context.Context, args []string) error {
@ -32,11 +31,12 @@ func (a App) desktopDoctor(ctx context.Context, args []string) error {
fmt.Fprintf(a.Stdout, "portal failed webvnc %v\n", err)
printRescue(a.Stdout, rescueVNCBridgeDisconnected, err.Error(), webVNCStatusRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
} else {
fmt.Fprintf(a.Stdout, "portal ok webvnc bridge=%t viewers=%d observers=%d slots=%d\n", status.BridgeConnected, status.ViewerCount, status.ObserverCount, status.AvailableViewerSlots)
fmt.Fprintf(a.Stdout, "portal ok webvnc bridge=%t viewer=%t\n", status.BridgeConnected, status.ViewerConnected)
if status.ViewerConnected {
printRescue(a.Stdout, rescueVNCStaleViewer, "close stale WebVNC tabs or reset this lease's WebVNC session", webVNCResetRescueCommand(rescueCtx))
}
if !status.BridgeConnected {
printRescue(a.Stdout, rescueVNCBridgeNotRunning, "portal has no active WebVNC bridge for this lease", webVNCDaemonStartRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
} else if webVNCObserverSlotsExhausted(status) {
printRescue(a.Stdout, rescueVNCObserverSlotsFull, "all WebVNC observer slots are in use or stale", webVNCDaemonStartRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
}
}
}
@ -147,11 +147,6 @@ func (a App) desktopCommandTarget(ctx context.Context, name string, args []strin
if strings.HasSuffix(name, "key") {
fs.String("keys", "", "xdotool key sequence")
}
if name == "artifacts video" {
fs.String("output", "", "local MP4 output path")
fs.Duration("duration", 10*time.Second, "video capture duration")
fs.Float64("fps", 15, "video frames per second")
}
if err := parseFlags(fs, args); err != nil {
return SSHTarget{}, Config{}, "", err
}
@ -221,15 +216,12 @@ func desktopTextArgOrStdin(stderr io.Writer, args []string, name string) (string
}
func stringFlagValue(args []string, name string) (string, bool) {
prefixes := []string{"--" + name + "=", "-" + name + "="}
names := map[string]bool{"--" + name: true, "-" + name: true}
prefix := "--" + name + "="
for i, arg := range args {
for _, prefix := range prefixes {
if strings.HasPrefix(arg, prefix) {
return strings.TrimPrefix(arg, prefix), true
}
if strings.HasPrefix(arg, prefix) {
return strings.TrimPrefix(arg, prefix), true
}
if names[arg] && i+1 < len(args) {
if arg == "--"+name && i+1 < len(args) {
return args[i+1], true
}
}
@ -245,30 +237,6 @@ func intFlagValue(args []string, name string) (int, bool) {
return n, err == nil
}
func floatFlagValue(args []string, name string, fallback float64) float64 {
value, ok := stringFlagValue(args, name)
if !ok {
return fallback
}
n, err := strconv.ParseFloat(value, 64)
if err != nil {
return fallback
}
return n
}
func durationFlagValue(args []string, name string, fallback time.Duration) time.Duration {
value, ok := stringFlagValue(args, name)
if !ok {
return fallback
}
duration, err := time.ParseDuration(value)
if err != nil {
return fallback
}
return duration
}
func desktopShouldPasteForType(text string) bool {
if text == "" {
return false

View File

@ -106,30 +106,6 @@ func TestDesktopKeySequenceArgSkipsLeaseID(t *testing.T) {
}
}
func TestStringFlagValueAcceptsGoFlagForms(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{name: "double dash space", args: []string{"--output", "screen.mp4"}, want: "screen.mp4"},
{name: "double dash equals", args: []string{"--output=screen.mp4"}, want: "screen.mp4"},
{name: "single dash space", args: []string{"-output", "screen.mp4"}, want: "screen.mp4"},
{name: "single dash equals", args: []string{"-output=screen.mp4"}, want: "screen.mp4"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := stringFlagValue(tt.args, "output")
if !ok {
t.Fatal("missing flag")
}
if got != tt.want {
t.Fatalf("value=%q, want %q", got, tt.want)
}
})
}
}
func TestDesktopLaunchWebVNCArgsCarriesTargetDetails(t *testing.T) {
got := desktopLaunchWebVNCArgs(
Config{Provider: "aws", TargetOS: targetWindows, WindowsMode: windowsModeWSL2, Network: NetworkTailscale},

View File

@ -9,7 +9,7 @@ import (
func (a App) doctor(ctx context.Context, args []string) error {
fs := newFlagSet("doctor", a.Stderr)
provider := fs.String("provider", defaultConfig().Provider, "provider: hetzner, aws, azure, or ssh")
provider := fs.String("provider", defaultConfig().Provider, "provider: hetzner, aws, or ssh")
id := fs.String("id", "", "remote lease id to inspect")
targetFlags := registerTargetFlags(fs, defaultConfig())
if err := parseFlags(fs, args); err != nil {
@ -138,20 +138,6 @@ func (a App) doctor(ctx context.Context, args []string) error {
} else {
fmt.Fprintf(a.Stdout, "ok aws crabbox_servers=%d region=%s default_type=%s\n", len(servers), cfg.AWSRegion, cfg.ServerType)
}
case "azure":
client, err := NewAzureClient(ctx, cfg)
if err != nil {
fmt.Fprintf(a.Stdout, "failed azure %v\n", err)
ok = false
break
}
servers, err := client.ListCrabboxServers(ctx)
if err != nil {
fmt.Fprintf(a.Stdout, "failed azure %v\n", err)
ok = false
} else {
fmt.Fprintf(a.Stdout, "ok azure crabbox_servers=%d location=%s default_type=%s\n", len(servers), cfg.AzureLocation, cfg.ServerType)
}
default:
client, err := newHetznerClient()
if err != nil {

View File

@ -38,13 +38,7 @@ func TestFlagWasSet(t *testing.T) {
if !flagWasSet(fs, "id") {
t.Fatal("id should be marked set")
}
if !FlagWasSet(fs, "id") {
t.Fatal("exported id check should be marked set")
}
if flagWasSet(fs, "json") {
t.Fatal("json should not be marked set")
}
if FlagWasSet(fs, "json") {
t.Fatal("exported json check should not be marked set")
}
}

View File

@ -152,6 +152,13 @@ func (a App) attach(ctx context.Context, args []string) error {
return err
}
nextAfter := *after
if wsAfter, done, used, err := followRunControlWebSocket(ctx, coord, *runID, nextAfter, *poll, a.Stdout, a.Stderr); err != nil {
return err
} else if done {
return nil
} else if used {
nextAfter = wsAfter
}
for {
events, err := coord.RunEvents(ctx, *runID, nextAfter, 100)
if err != nil {

View File

@ -7,6 +7,8 @@ import (
"net/http"
"net/http/httptest"
"testing"
"nhooyr.io/websocket"
)
func TestEventsCommandPassesPagination(t *testing.T) {
@ -39,6 +41,8 @@ func TestAttachCommandReplaysOutputAndStopsWhenRunFinished(t *testing.T) {
eventCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/v1/control":
http.NotFound(w, r)
case r.Method == http.MethodGet && r.URL.Path == "/v1/runs/run_123/events":
eventCalls++
if eventCalls == 1 {
@ -80,3 +84,62 @@ func TestAttachCommandReplaysOutputAndStopsWhenRunFinished(t *testing.T) {
t.Fatalf("eventCalls=%d, want 2", eventCalls)
}
}
func TestAttachCommandStreamsOverControlWebSocket(t *testing.T) {
controlCalls := 0
eventCalls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/v1/control":
controlCalls++
conn, err := websocket.Accept(w, r, nil)
if err != nil {
t.Errorf("accept control websocket: %v", err)
return
}
defer conn.Close(websocket.StatusNormalClosure, "")
for {
_, data, err := conn.Read(r.Context())
if err != nil {
return
}
var msg map[string]any
if err := json.Unmarshal(data, &msg); err != nil {
t.Errorf("control message JSON: %v", err)
return
}
if msg["type"] == "subscribe_run" {
if msg["runID"] != "run_123" {
t.Errorf("runID=%v", msg["runID"])
}
_ = conn.Write(r.Context(), websocket.MessageText, []byte(`{"type":"run_events","runID":"run_123","events":[{"runID":"run_123","seq":1,"type":"stdout","stream":"stdout","data":"hello ws\n","createdAt":"2026-05-02T00:00:00Z"}],"nextSeq":1}`))
}
}
case r.Method == http.MethodGet && r.URL.Path == "/v1/runs/run_123":
_, _ = w.Write([]byte(`{"run":{"id":"run_123","leaseID":"cbx_123","owner":"peter@example.com","org":"openclaw","provider":"aws","class":"standard","serverType":"t3.small","command":["true"],"state":"succeeded","phase":"finished","logBytes":0,"logTruncated":false,"startedAt":"2026-05-02T00:00:00Z"}}`))
case r.Method == http.MethodGet && r.URL.Path == "/v1/runs/run_123/events":
eventCalls++
_, _ = w.Write([]byte(`{"events":[]}`))
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.String())
}
}))
defer server.Close()
t.Setenv("CRABBOX_COORDINATOR", server.URL)
t.Setenv("CRABBOX_COORDINATOR_TOKEN", "")
var stdout, stderr bytes.Buffer
app := App{Stdout: &stdout, Stderr: &stderr}
if err := app.attach(context.Background(), []string{"run_123", "--poll", "1ms"}); err != nil {
t.Fatal(err)
}
if stdout.String() != "hello ws\n" {
t.Fatalf("stdout=%q", stdout.String())
}
if controlCalls != 1 {
t.Fatalf("controlCalls=%d, want 1", controlCalls)
}
if eventCalls != 0 {
t.Fatalf("HTTP eventCalls=%d, want websocket attach to avoid polling events", eventCalls)
}
}

View File

@ -10,7 +10,7 @@ import (
func (a App) inspect(ctx context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("inspect", a.Stderr)
provider := fs.String("provider", defaults.Provider, providerHelpSSH())
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or ssh")
id := fs.String("id", "", "lease id or slug")
jsonOut := fs.Bool("json", false, "print JSON")
targetFlags := registerTargetFlags(fs, defaults)

View File

@ -57,7 +57,7 @@ func EnsureTestboxKey(leaseID string) (string, string, error) {
}
func ensureTestboxKeyForConfig(cfg Config, leaseID string) (string, string, error) {
if (cfg.Provider == "aws" || cfg.Provider == "azure") && cfg.TargetOS == targetWindows {
if cfg.Provider == "aws" && cfg.TargetOS == targetWindows {
return ensureTestboxKeyWithType(leaseID, "rsa")
}
return ensureTestboxKey(leaseID)

View File

@ -269,16 +269,6 @@ func commandOutput(ctx context.Context, name string, args ...string) (string, er
return out.String(), err
}
func commandOutputWithEnv(ctx context.Context, env []string, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = env
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
err := cmd.Run()
return out.String(), err
}
func tailForError(text string) string {
text = strings.TrimSpace(text)
const limit = 4096

View File

@ -305,7 +305,7 @@ func coordinatorMachineOrphanField(labels map[string]string, activeLeaseIDs map[
func (a App) cleanup(ctx context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("machine cleanup", a.Stderr)
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or azure")
provider := fs.String("provider", defaults.Provider, "provider: hetzner or aws")
dryRun := fs.Bool("dry-run", false, "only print")
providerFlags := registerProviderFlags(fs, defaults)
targetFlags := registerTargetFlags(fs, defaults)

View File

@ -311,11 +311,11 @@ func normalizeProviderName(name string) string {
}
func providerHelpAll() string {
return "provider: hetzner, aws, azure, ssh, blacksmith-testbox, daytona, or islo"
return "provider: hetzner, aws, ssh, blacksmith-testbox, daytona, or islo"
}
func providerHelpSSH() string {
return "provider: hetzner, aws, azure, ssh, or daytona"
return "provider: hetzner, aws, ssh, or daytona"
}
func isBlacksmithProvider(provider string) bool {

View File

@ -8,37 +8,12 @@ import (
func init() {
RegisterProvider(testHetznerProvider{})
RegisterProvider(testAWSProvider{})
RegisterProvider(testAzureProvider{})
RegisterProvider(testStaticSSHProvider{})
RegisterProvider(testBlacksmithProvider{})
RegisterProvider(testDaytonaProvider{})
RegisterProvider(testIsloProvider{})
}
type testAzureProvider struct{}
func (testAzureProvider) Name() string { return "azure" }
func (testAzureProvider) Aliases() []string { return nil }
func (testAzureProvider) Spec() ProviderSpec {
return ProviderSpec{
Name: "azure",
Kind: ProviderKindSSHLease,
Targets: []TargetSpec{
{OS: targetLinux},
{OS: targetWindows, WindowsMode: windowsModeNormal},
},
Features: FeatureSet{FeatureSSH, FeatureCrabboxSync, FeatureCleanup, FeatureDesktop, FeatureBrowser, FeatureCode, FeatureTailscale},
Coordinator: CoordinatorSupported,
}
}
func (testAzureProvider) RegisterFlags(*flag.FlagSet, Config) any { return noProviderFlags{} }
func (testAzureProvider) ApplyFlags(*Config, *flag.FlagSet, any) error {
return nil
}
func (p testAzureProvider) Configure(cfg Config, rt Runtime) (Backend, error) {
return testSSHBackend{spec: p.Spec()}, nil
}
type testHetznerProvider struct{}
func (testHetznerProvider) Name() string { return "hetzner" }

View File

@ -31,21 +31,6 @@ func touchDirectLeaseBestEffort(ctx context.Context, cfg Config, server Server,
}
return server
}
if cfg.Provider == "azure" || server.Provider == "azure" {
client, err := NewAzureClient(ctx, cfg)
if err != nil {
fmt.Fprintf(stderr, "warning: direct touch state=%s: %v\n", state, err)
return server
}
name := server.CloudID
if name == "" {
name = server.Name
}
if err := client.SetTags(ctx, name, server.Labels); err != nil {
fmt.Fprintf(stderr, "warning: direct touch state=%s: %v\n", state, err)
}
return server
}
client, err := newHetznerClient()
if err != nil {
fmt.Fprintf(stderr, "warning: direct touch state=%s: %v\n", state, err)

View File

@ -13,12 +13,10 @@ const (
rescueInputStackDead = "input stack dead"
rescueVNCBridgeDisconnected = "VNC bridge disconnected"
rescueVNCBridgeNotRunning = "WebVNC daemon not running"
rescueVNCObserverSlotsFull = "WebVNC observer slots exhausted"
rescueVNCStaleViewer = "WebVNC viewer already active"
rescueVNCTargetUnreachable = "VNC target unreachable"
rescueWindowManagerMissing = "window manager missing"
rescueScreenshotCaptureBroken = "screenshot capture broken"
rescueArtifactCaptureFailed = "artifact capture failed"
)
type rescueContext struct {

View File

@ -852,16 +852,42 @@ func startCoordinatorHeartbeat(ctx context.Context, coord *CoordinatorClient, le
done := make(chan struct{})
go func() {
defer close(done)
var control *coordinatorControlConn
defer func() {
if control != nil {
control.close()
}
}()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
telemetry := collectLeaseTelemetryBestEffort(rootCtx, telemetryCollector)
callCtx, heartbeatCancel := context.WithTimeout(rootCtx, 20*time.Second)
var err error
var idleTimeoutOverride *time.Duration
if updateIdleTimeout != nil {
_, err = coord.UpdateLeaseIdleTimeoutWithTelemetry(callCtx, leaseID, *updateIdleTimeout, telemetry)
idleTimeoutOverride = updateIdleTimeout
}
if control == nil {
dialCtx, dialCancel := context.WithTimeout(callCtx, coordinatorControlDialTimeout)
control, _ = dialCoordinatorControl(dialCtx, coord)
dialCancel()
}
if control != nil {
err = control.heartbeat(callCtx, leaseID, idleTimeoutOverride, telemetry)
if err != nil {
control.close()
control = nil
}
}
if control == nil {
if updateIdleTimeout != nil {
_, err = coord.UpdateLeaseIdleTimeoutWithTelemetry(callCtx, leaseID, *updateIdleTimeout, telemetry)
} else {
_, err = coord.TouchLeaseWithTelemetry(callCtx, leaseID, telemetry)
}
} else {
_, err = coord.TouchLeaseWithTelemetry(callCtx, leaseID, telemetry)
err = nil
}
heartbeatCancel()
if err != nil && rootCtx.Err() == nil {
@ -1107,9 +1133,6 @@ func deleteServer(ctx context.Context, cfg Config, server Server) error {
}
return nil
}
if cfg.Provider == "azure" || server.Provider == "azure" {
return deleteAzureServer(ctx, cfg, server)
}
client, err := newHetznerClient()
if err != nil {
return err

View File

@ -9,7 +9,7 @@ import (
)
const (
runEventOutputChunkBytes = 4096
runEventOutputChunkBytes = 16 * 1024
runEventOutputMaxBytes = 64 * 1024
runEventOutputQueueSize = 32
runEventOutputPostWait = 2 * time.Second

View File

@ -14,7 +14,7 @@ import (
func (a App) screenshot(ctx context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("screenshot", a.Stderr)
provider := fs.String("provider", defaults.Provider, providerHelpSSH())
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or ssh")
id := fs.String("id", "", "lease id or slug")
output := fs.String("output", "", "local PNG output path")
reclaim := fs.Bool("reclaim", false, "claim this lease for the current repo")

View File

@ -329,20 +329,23 @@ func runSSHInput(ctx context.Context, target SSHTarget, remote string, input io.
func runSSHStream(ctx context.Context, target SSHTarget, remote string, stdout, stderr io.Writer) int {
remote = wrapRemoteForTarget(target, remote)
cmd := exec.CommandContext(ctx, "ssh", sshArgs(target, remote)...)
cmd.Stdout = stdout
cmd.Stderr = stderr
err := cmd.Run()
if err == nil {
return 0
}
var exitErr *exec.ExitError
if ok := asExitError(err, &exitErr); ok {
if status, ok := exitErr.Sys().(syscall.WaitStatus); ok {
return status.ExitStatus()
lastCode := 7
for _, port := range sshPortCandidates(target.Port, target.FallbackPorts) {
probe := target
probe.Port = port
cmd := exec.CommandContext(ctx, "ssh", sshArgs(probe, remote)...)
cmd.Stdout = stdout
cmd.Stderr = stderr
err := cmd.Run()
if err == nil {
return 0
}
lastCode = exitCode(err)
if !shouldRetrySSHPort(err) {
return lastCode
}
}
return 7
return lastCode
}
func sshArgs(target SSHTarget, remote string) []string {
@ -380,7 +383,7 @@ func sshBaseArgsWithOptions(target SSHTarget, connectTimeout, connectionAttempts
} else {
args = append(args,
"-o", "ControlMaster=auto",
"-o", "ControlPersist=60s",
"-o", "ControlPersist=10m",
"-o", "ControlPath="+sshControlPath(target),
)
}

View File

@ -223,7 +223,7 @@ func TestSSHArgsIncludeReliabilityOptions(t *testing.T) {
"ServerAliveInterval=15",
"ServerAliveCountMax=2",
"ControlMaster=auto",
"ControlPersist=60s",
"ControlPersist=10m",
"ControlPath=",
"crabbox-ssh-",
"-%C",
@ -279,6 +279,54 @@ func TestShouldRetrySSHPortOnlyForTransportExit(t *testing.T) {
}
}
func TestRunSSHStreamRetriesFallbackPorts(t *testing.T) {
dir := t.TempDir()
sshPath := filepath.Join(dir, "ssh")
portsPath := filepath.Join(dir, "ports")
script := `#!/bin/sh
port=""
while [ "$#" -gt 0 ]; do
if [ "$1" = "-p" ]; then
shift
port="$1"
fi
shift
done
printf '%s\n' "$port" >> "$CRABBOX_FAKE_SSH_PORTS"
if [ "$port" = "2222" ]; then
exit 255
fi
printf 'ok\n'
exit 0
`
if err := os.WriteFile(sshPath, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv("CRABBOX_FAKE_SSH_PORTS", portsPath)
var stdout, stderr bytes.Buffer
code := runSSHStream(context.Background(), SSHTarget{
User: "crabbox",
Host: "203.0.113.10",
Port: "2222",
FallbackPorts: []string{"22"},
}, "true", &stdout, &stderr)
if code != 0 {
t.Fatalf("runSSHStream exit=%d stderr=%q", code, stderr.String())
}
if stdout.String() != "ok\n" {
t.Fatalf("stdout=%q want ok", stdout.String())
}
ports, err := os.ReadFile(portsPath)
if err != nil {
t.Fatal(err)
}
if string(ports) != "2222\n22\n" {
t.Fatalf("ports=%q want fallback sequence", string(ports))
}
}
func TestSSHCommandLineRedactsSecretAuthUser(t *testing.T) {
target := SSHTarget{
User: "tok_live_secret",

View File

@ -115,7 +115,7 @@ func validateProviderTarget(cfg Config) error {
return err
}
if !providerSpecSupportsTarget(provider.Spec(), cfg.TargetOS, cfg.WindowsMode) {
return exit(2, "%s", unsupportedManagedTargetMessageForConfig(provider.Name(), cfg))
return exit(2, "%s", unsupportedManagedTargetMessage(provider.Name(), cfg.TargetOS))
}
if cfg.Provider == "aws" && cfg.TargetOS == targetMacOS {
if cfg.AWSMacHostID == "" && cfg.Coordinator == "" {
@ -143,20 +143,6 @@ func providerSpecSupportsTarget(spec ProviderSpec, targetOS, windowsMode string)
}
func unsupportedManagedTargetMessage(provider, target string) string {
return unsupportedManagedTargetMessageForConfig(provider, Config{TargetOS: target, WindowsMode: windowsModeNormal})
}
func unsupportedManagedTargetMessageForConfig(provider string, cfg Config) string {
target := cfg.TargetOS
if provider == "azure" && target == targetWindows && cfg.WindowsMode == windowsModeWSL2 {
return "provider=azure supports native Windows only; use provider=aws for managed Windows WSL2 or provider=ssh for existing Windows WSL2 hosts"
}
if provider == "azure" {
if target == targetMacOS {
return "provider=azure managed provisioning supports target=linux and native Windows only; use provider=aws with an EC2 Mac Dedicated Host or provider=ssh for existing macOS hosts"
}
return "provider=azure managed provisioning supports target=linux and native Windows only"
}
switch target {
case targetWindows:
return sprintf("provider=%s managed provisioning supports target=linux only; use provider=aws for managed Windows or provider=ssh for existing Windows hosts", provider)

View File

@ -51,43 +51,6 @@ func TestValidateProviderTargetAllowsAWSNativeWindows(t *testing.T) {
}
}
func TestValidateProviderTargetAllowsAzureNativeWindowsOnly(t *testing.T) {
cfg := baseConfig()
cfg.Provider = "azure"
cfg.TargetOS = targetWindows
cfg.WindowsMode = windowsModeNormal
if err := validateProviderTarget(cfg); err != nil {
t.Fatalf("native err=%v", err)
}
cfg.WindowsMode = windowsModeWSL2
err := validateProviderTarget(cfg)
if err == nil || !strings.Contains(err.Error(), "native Windows only") {
t.Fatalf("wsl2 err=%v", err)
}
}
func TestValidateRequestedCapabilitiesRejectsAzureWindowsDesktop(t *testing.T) {
for name, mutate := range map[string]func(*Config){
"desktop": func(cfg *Config) { cfg.Desktop = true },
"browser": func(cfg *Config) { cfg.Browser = true },
"code": func(cfg *Config) { cfg.Code = true },
"tailscale": func(cfg *Config) { cfg.Tailscale.Enabled = true },
} {
t.Run(name, func(t *testing.T) {
cfg := baseConfig()
cfg.Provider = "azure"
cfg.TargetOS = targetWindows
cfg.WindowsMode = windowsModeNormal
mutate(&cfg)
err := validateRequestedCapabilities(cfg)
if err == nil || !strings.Contains(err.Error(), "SSH, sync, and run") {
t.Fatalf("err=%v", err)
}
})
}
}
func TestValidateProviderTargetAllowsStaticNonLinux(t *testing.T) {
for _, target := range []string{targetMacOS, targetWindows} {
cfg := baseConfig()

View File

@ -12,7 +12,7 @@ import (
func (a App) vnc(ctx context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("vnc", a.Stderr)
provider := fs.String("provider", defaults.Provider, providerHelpSSH())
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")

View File

@ -39,7 +39,7 @@ func (a App) webvnc(ctx context.Context, args []string) error {
fmt.Fprintln(fs.Output(), "")
fmt.Fprintln(fs.Output(), "Bridge flags:")
fmt.Fprintln(fs.Output(), " --id <lease-id-or-slug>")
fmt.Fprintln(fs.Output(), " --provider hetzner|aws|azure")
fmt.Fprintln(fs.Output(), " --provider hetzner|aws")
fmt.Fprintln(fs.Output(), " --target linux|macos|windows")
fmt.Fprintln(fs.Output(), " --windows-mode normal|wsl2")
fmt.Fprintln(fs.Output(), " --static-host <host>")
@ -51,7 +51,7 @@ func (a App) webvnc(ctx context.Context, args []string) error {
fmt.Fprintln(fs.Output(), " --open")
fmt.Fprintln(fs.Output(), " --reclaim")
}
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or azure")
provider := fs.String("provider", defaults.Provider, "provider: hetzner or aws")
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")
@ -83,7 +83,7 @@ func (a App) webvnc(ctx context.Context, args []string) error {
return err
}
if isBlacksmithProvider(cfg.Provider) || isStaticProvider(cfg.Provider) {
return exit(2, "webvnc currently supports coordinator-backed hetzner/aws/azure desktop leases")
return exit(2, "webvnc currently supports coordinator-backed hetzner/aws desktop leases")
}
coord, useCoordinator, err := newTargetCoordinatorClient(cfg)
if err != nil {
@ -137,16 +137,26 @@ func (a App) webvnc(ctx context.Context, args []string) error {
portal := webVNCPortalURL(coord.BaseURL, leaseID, username, password)
rescueCtx := rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID}
opened := false
return serveWebVNCBridgePool(ctx, webVNCBridgePoolConfig{
Coord: coord,
LeaseID: leaseID,
Host: connHost,
Port: connPort,
PoolSize: defaultWebVNCBridgePoolSize,
RescueCtx: rescueCtx,
NativeVNC: nativeVNCOpenCommand(cfg, target, leaseID),
Log: a.Stdout,
OnReady: func() error {
connectedOnce := false
attempt := 0
for {
bridge, err := connectWebVNCBridge(ctx, coord, leaseID, connHost, connPort)
if err != nil {
if !connectedOnce {
printRescueWithFallback(a.Stdout, rescueVNCBridgeDisconnected, err.Error(), nativeVNCOpenCommand(cfg, target, leaseID), webVNCStatusRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
return err
}
attempt++
delay := webVNCReconnectDelay(attempt)
printRescueWithFallback(a.Stdout, rescueVNCBridgeDisconnected, err.Error(), nativeVNCOpenCommand(cfg, target, leaseID), webVNCStatusRescueCommand(rescueCtx))
fmt.Fprintf(a.Stdout, "bridge: reconnecting in %s\n", delay)
if err := waitWebVNCReconnect(ctx, delay); err != nil {
return err
}
continue
}
connectedOnce = true
if attempt == 0 {
fmt.Fprintln(a.Stdout, "bridge: connected; keep this process running while using WebVNC")
fmt.Fprintf(a.Stdout, "webvnc: %s\n", portal)
if strings.TrimSpace(password) != "" {
@ -155,131 +165,36 @@ func (a App) webvnc(ctx context.Context, args []string) error {
fmt.Fprintf(a.Stdout, "username: %s\n", strings.TrimSpace(username))
}
}
if *openPortal && !opened {
if err := openLocalURL(portal); err != nil {
return err
}
opened = true
fmt.Fprintf(a.Stdout, "opened: %s\n", portal)
}
return nil
},
})
}
const defaultWebVNCBridgePoolSize = 4
type webVNCBridgePoolConfig struct {
Coord *CoordinatorClient
LeaseID string
Host string
Port string
PoolSize int
RescueCtx rescueContext
NativeVNC string
Log io.Writer
OnReady func() error
}
type webVNCBridgePoolEvent struct {
Kind string
Slot int
Attempt int
Err error
}
func serveWebVNCBridgePool(ctx context.Context, cfg webVNCBridgePoolConfig) error {
if cfg.PoolSize < 1 {
cfg.PoolSize = 1
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
events := make(chan webVNCBridgePoolEvent, cfg.PoolSize)
for slot := 0; slot < cfg.PoolSize; slot++ {
go serveWebVNCBridgeSlot(ctx, cfg, slot, events)
}
ready := false
initialFailures := make(map[int]bool)
var firstErr error
for {
select {
case <-ctx.Done():
return context.Cause(ctx)
case event := <-events:
switch event.Kind {
case "ready":
if !ready {
ready = true
if cfg.OnReady != nil {
if err := cfg.OnReady(); err != nil {
return err
}
}
}
case "initial-error":
if !ready {
initialFailures[event.Slot] = true
if firstErr == nil {
firstErr = event.Err
}
if len(initialFailures) >= cfg.PoolSize {
printRescueWithFallback(cfg.Log, rescueVNCBridgeDisconnected, firstErr.Error(), cfg.NativeVNC, webVNCStatusRescueCommand(cfg.RescueCtx), webVNCResetRescueCommand(cfg.RescueCtx))
return firstErr
}
}
case "retry":
if ready && event.Err != nil {
printRescueWithFallback(cfg.Log, classifyWebVNCBridgeProblem(event.Err), event.Err.Error(), cfg.NativeVNC, webVNCStatusRescueCommand(cfg.RescueCtx), webVNCResetRescueCommand(cfg.RescueCtx))
fmt.Fprintf(cfg.Log, "bridge[%d]: reconnecting in %s\n", event.Slot+1, webVNCReconnectDelay(event.Attempt))
}
case "fatal":
if event.Err != nil {
return event.Err
}
}
} else {
fmt.Fprintf(a.Stdout, "bridge: reconnected after viewer reset (attempt %d)\n", attempt+1)
}
}
}
func serveWebVNCBridgeSlot(ctx context.Context, cfg webVNCBridgePoolConfig, slot int, events chan<- webVNCBridgePoolEvent) {
connectedOnce := false
attempt := 0
for {
bridge, err := connectWebVNCBridge(ctx, cfg.Coord, cfg.LeaseID, cfg.Host, cfg.Port)
if err != nil {
attempt, kind := nextWebVNCBridgeFailure(connectedOnce, attempt)
events <- webVNCBridgePoolEvent{Kind: kind, Slot: slot, Attempt: attempt, Err: err}
if err := waitWebVNCReconnect(ctx, webVNCReconnectDelay(attempt)); err != nil {
events <- webVNCBridgePoolEvent{Kind: "fatal", Slot: slot, Err: err}
return
if *openPortal && !opened {
if err := openLocalURL(portal); err != nil {
bridge.Close()
return err
}
continue
opened = true
fmt.Fprintf(a.Stdout, "opened: %s\n", portal)
}
connectedOnce = true
attempt = 0
events <- webVNCBridgePoolEvent{Kind: "ready", Slot: slot}
err = bridge.Serve(ctx)
if !retryableWebVNCBridgeError(err) {
events <- webVNCBridgePoolEvent{Kind: "fatal", Slot: slot, Err: err}
return
return err
}
attempt++
events <- webVNCBridgePoolEvent{Kind: "retry", Slot: slot, Attempt: attempt, Err: err}
if err := waitWebVNCReconnect(ctx, webVNCReconnectDelay(attempt)); err != nil {
events <- webVNCBridgePoolEvent{Kind: "fatal", Slot: slot, Err: err}
return
delay := webVNCReconnectDelay(attempt)
if err != nil {
printRescueWithFallback(a.Stdout, classifyWebVNCBridgeProblem(err), err.Error(), nativeVNCOpenCommand(cfg, target, leaseID), webVNCStatusRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
fmt.Fprintf(a.Stdout, "bridge: reconnecting in %s\n", delay)
} else {
printRescueWithFallback(a.Stdout, rescueVNCBridgeDisconnected, "viewer closed", nativeVNCOpenCommand(cfg, target, leaseID), webVNCStatusRescueCommand(rescueCtx))
fmt.Fprintf(a.Stdout, "bridge: reconnecting in %s\n", delay)
}
if err := waitWebVNCReconnect(ctx, delay); err != nil {
return err
}
}
}
func nextWebVNCBridgeFailure(connectedOnce bool, attempt int) (int, string) {
attempt++
if !connectedOnce && attempt == 1 {
return attempt, "initial-error"
}
return attempt, "retry"
}
func (a App) webVNCDaemonCommand(ctx context.Context, args []string) error {
if len(args) == 0 {
return exit(2, "usage: crabbox webvnc daemon start|status|stop --id <lease-id-or-slug>")
@ -300,10 +215,10 @@ func (a App) webVNCDaemonCommand(ctx context.Context, args []string) error {
}
}
func (a App) webVNCDaemonStart(ctx context.Context, args []string) error {
func (a App) webVNCDaemonStart(_ context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("webvnc daemon start", a.Stderr)
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or azure")
provider := fs.String("provider", defaults.Provider, "provider: hetzner or aws")
id := fs.String("id", "", "lease id or slug")
localPort := fs.String("local-port", "", "local VNC tunnel port")
openPortal := fs.Bool("open", false, "open the web portal VNC page")
@ -322,34 +237,7 @@ func (a App) webVNCDaemonStart(ctx context.Context, args []string) error {
return err
}
target := SSHTarget{TargetOS: cfg.TargetOS, WindowsMode: cfg.WindowsMode}
bridgeID := *id
if !isBlacksmithProvider(cfg.Provider) && !isStaticProvider(cfg.Provider) {
coord, useCoordinator, err := newTargetCoordinatorClient(cfg)
if err != nil {
return err
}
if useCoordinator && coord != nil && coord.Token != "" {
server, resolvedTarget, leaseID, err := a.resolveNetworkLeaseTarget(ctx, cfg, *id, false)
if err != nil {
return err
}
if err := enforceManagedLeaseCapabilities(cfg, server, leaseID); err != nil {
return err
}
if server.Provider != "" {
cfg.Provider = server.Provider
}
if resolvedTarget.TargetOS != "" {
cfg.TargetOS = resolvedTarget.TargetOS
}
if resolvedTarget.WindowsMode != "" {
cfg.WindowsMode = resolvedTarget.WindowsMode
}
target = resolvedTarget
bridgeID = leaseID
}
}
daemonArgs := webVNCBridgeArgs(cfg, target, bridgeID, *openPortal)
daemonArgs := webVNCBridgeArgs(cfg, target, *id, *openPortal)
if strings.TrimSpace(*localPort) != "" {
daemonArgs = append(daemonArgs, "--local-port", strings.TrimSpace(*localPort))
}
@ -388,7 +276,7 @@ func (a App) webVNCDaemonStopCommand(args []string) error {
func (a App) webVNCStatusCommand(ctx context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("webvnc status", a.Stderr)
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or azure")
provider := fs.String("provider", defaults.Provider, "provider: hetzner or aws")
id := fs.String("id", "", "lease id or slug")
localPort := fs.String("local-port", "", "local VNC tunnel port")
targetFlags := registerTargetFlags(fs, defaults)
@ -405,7 +293,7 @@ func (a App) webVNCStatusCommand(ctx context.Context, args []string) error {
return err
}
if isBlacksmithProvider(cfg.Provider) || isStaticProvider(cfg.Provider) {
return exit(2, "webvnc status currently supports coordinator-backed hetzner/aws/azure desktop leases")
return exit(2, "webvnc status currently supports coordinator-backed hetzner/aws desktop leases")
}
coord, useCoordinator, err := newTargetCoordinatorClient(cfg)
if err != nil {
@ -465,10 +353,7 @@ func (a App) webVNCStatusCommand(ctx context.Context, args []string) error {
fmt.Fprintf(a.Stdout, "portal bridge: unknown (%v)\n", statusErr)
printRescue(a.Stdout, rescueVNCBridgeDisconnected, statusErr.Error(), webVNCStatusRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
} else {
fmt.Fprintf(a.Stdout, "portal bridge: connected=%t viewers=%d observers=%d slots=%d\n", status.BridgeConnected, status.ViewerCount, status.ObserverCount, status.AvailableViewerSlots)
if strings.TrimSpace(status.ControllerLabel) != "" {
fmt.Fprintf(a.Stdout, "portal controller: %s\n", strings.TrimSpace(status.ControllerLabel))
}
fmt.Fprintf(a.Stdout, "portal bridge: connected=%t viewer=%t\n", status.BridgeConnected, status.ViewerConnected)
if strings.TrimSpace(status.Message) != "" {
fmt.Fprintf(a.Stdout, "portal message: %s\n", status.Message)
}
@ -488,10 +373,10 @@ func (a App) webVNCStatusCommand(ctx context.Context, args []string) error {
}
}
fmt.Fprintf(a.Stdout, "fallback: %s\n", nativeVNCOpenCommand(cfg, target, leaseID))
if statusErr == nil && !status.BridgeConnected {
if statusErr == nil && status.ViewerConnected {
printRescue(a.Stdout, rescueVNCStaleViewer, "close stale WebVNC tabs or reset this lease's WebVNC session", webVNCResetRescueCommand(rescueCtx))
} else if statusErr == nil && !status.BridgeConnected {
printRescue(a.Stdout, rescueVNCBridgeNotRunning, "portal has no active WebVNC bridge for this lease", webVNCDaemonStartRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
} else if statusErr == nil && webVNCObserverSlotsExhausted(status) {
printRescue(a.Stdout, rescueVNCObserverSlotsFull, "all WebVNC observer slots are in use or stale", webVNCDaemonStartRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
}
return nil
}
@ -499,7 +384,7 @@ func (a App) webVNCStatusCommand(ctx context.Context, args []string) error {
func (a App) webVNCResetCommand(ctx context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("webvnc reset", a.Stderr)
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or azure")
provider := fs.String("provider", defaults.Provider, "provider: hetzner or aws")
id := fs.String("id", "", "lease id or slug")
openPortal := fs.Bool("open", false, "open the web portal VNC page")
targetFlags := registerTargetFlags(fs, defaults)
@ -516,7 +401,7 @@ func (a App) webVNCResetCommand(ctx context.Context, args []string) error {
return err
}
if isBlacksmithProvider(cfg.Provider) || isStaticProvider(cfg.Provider) {
return exit(2, "webvnc reset currently supports coordinator-backed hetzner/aws/azure desktop leases")
return exit(2, "webvnc reset currently supports coordinator-backed hetzner/aws desktop leases")
}
coord, useCoordinator, err := newTargetCoordinatorClient(cfg)
if err != nil {
@ -1015,16 +900,6 @@ func classifyWebVNCBridgeProblem(err error) string {
return rescueVNCBridgeDisconnected
}
func webVNCObserverSlotsExhausted(status CoordinatorWebVNCStatus) bool {
if !status.BridgeConnected || status.AvailableViewerSlots != 0 {
return false
}
if status.ViewerCount > 0 {
return true
}
return strings.Contains(status.Message, "available WebVNC observer slot")
}
func webVNCReconnectDelay(attempt int) time.Duration {
if attempt < 1 {
attempt = 1

View File

@ -173,53 +173,6 @@ func TestClassifyWebVNCBridgeProblem(t *testing.T) {
}
}
func TestNextWebVNCBridgeFailureBacksOffInitialFailures(t *testing.T) {
attempt, kind := nextWebVNCBridgeFailure(false, 0)
if attempt != 1 || kind != "initial-error" {
t.Fatalf("first failure attempt=%d kind=%q", attempt, kind)
}
attempt, kind = nextWebVNCBridgeFailure(false, attempt)
if attempt != 2 || kind != "retry" {
t.Fatalf("second initial failure attempt=%d kind=%q", attempt, kind)
}
if got := webVNCReconnectDelay(attempt); got != time.Second {
t.Fatalf("second initial failure delay=%s, want 1s", got)
}
attempt, kind = nextWebVNCBridgeFailure(true, 0)
if attempt != 1 || kind != "retry" {
t.Fatalf("post-connect failure attempt=%d kind=%q", attempt, kind)
}
}
func TestWebVNCObserverSlotsExhausted(t *testing.T) {
if !webVNCObserverSlotsExhausted(CoordinatorWebVNCStatus{
BridgeConnected: true,
ViewerCount: 4,
AvailableViewerSlots: 0,
}) {
t.Fatal("expected full viewer pool to be exhausted")
}
if !webVNCObserverSlotsExhausted(CoordinatorWebVNCStatus{
BridgeConnected: true,
AvailableViewerSlots: 0,
Message: "waiting for an available WebVNC observer slot",
}) {
t.Fatal("expected exhausted status message to be exhausted")
}
if webVNCObserverSlotsExhausted(CoordinatorWebVNCStatus{
BridgeConnected: true,
}) {
t.Fatal("old bridge-only status must not be treated as exhausted")
}
if webVNCObserverSlotsExhausted(CoordinatorWebVNCStatus{
BridgeConnected: true,
ViewerCount: 1,
AvailableViewerSlots: 2,
}) {
t.Fatal("available slots must not be exhausted")
}
}
func TestRetryBridgeTicketInQuery(t *testing.T) {
resp := &http.Response{
StatusCode: http.StatusUnauthorized,

View File

@ -2,7 +2,6 @@ package all
import (
_ "github.com/openclaw/crabbox/internal/providers/aws"
_ "github.com/openclaw/crabbox/internal/providers/azure"
_ "github.com/openclaw/crabbox/internal/providers/blacksmith"
_ "github.com/openclaw/crabbox/internal/providers/daytona"
_ "github.com/openclaw/crabbox/internal/providers/hetzner"

View File

@ -1,204 +0,0 @@
package azure
import (
"context"
"fmt"
"io"
"os"
"strings"
"time"
core "github.com/openclaw/crabbox/internal/cli"
"github.com/openclaw/crabbox/internal/providers/shared"
)
type Config = core.Config
type Runtime = core.Runtime
type ProviderSpec = core.ProviderSpec
type Backend = core.Backend
type AcquireRequest = core.AcquireRequest
type ResolveRequest = core.ResolveRequest
type ListRequest = core.ListRequest
type LeaseView = core.LeaseView
type ReleaseLeaseRequest = core.ReleaseLeaseRequest
type TouchRequest = core.TouchRequest
type CleanupRequest = core.CleanupRequest
type LeaseTarget = core.LeaseTarget
type Server = core.Server
type SSHTarget = core.SSHTarget
type azureLeaseBackend struct{ shared.DirectSSHBackend }
func NewAzureLeaseBackend(spec ProviderSpec, cfg Config, rt Runtime) Backend {
cfg.Provider = "azure"
return &azureLeaseBackend{DirectSSHBackend: shared.DirectSSHBackend{SpecValue: spec, Cfg: cfg, RT: rt}}
}
func (b *azureLeaseBackend) Acquire(ctx context.Context, req AcquireRequest) (LeaseTarget, error) {
return acquireAttemptsRetry(b.RT, req.Keep, func() (LeaseTarget, error) {
return b.acquireOnce(ctx, req.Keep)
})
}
func (b *azureLeaseBackend) acquireOnce(ctx context.Context, keep bool) (LeaseTarget, error) {
if b.Cfg.Tailscale.Enabled && b.Cfg.Tailscale.AuthKey == "" {
return LeaseTarget{}, exit(2, "direct --tailscale requires %s to contain a Tailscale auth key; brokered mode uses coordinator OAuth secrets", b.Cfg.Tailscale.AuthKeyEnv)
}
client, err := newAzureClient(ctx, b.Cfg)
if err != nil {
return LeaseTarget{}, err
}
leaseID := newLeaseID()
servers, err := client.ListCrabboxServers(ctx)
if err != nil {
return LeaseTarget{}, err
}
slug := allocateDirectLeaseSlug(leaseID, servers)
cfg := b.Cfg
keyPath, publicKey, err := ensureTestboxKeyForConfig(cfg, leaseID)
if err != nil {
return LeaseTarget{}, err
}
cfg.SSHKey = keyPath
cfg.ProviderKey = providerKeyForLease(leaseID)
fmt.Fprintf(b.RT.Stderr, "provisioning provider=azure lease=%s slug=%s class=%s preferred_type=%s location=%s rg=%s keep=%v\n",
leaseID, slug, cfg.Class, cfg.ServerType, cfg.AzureLocation, cfg.AzureResourceGroup, keep)
server, cfg, err := client.CreateServerWithFallback(ctx, cfg, publicKey, leaseID, slug, keep, func(format string, args ...any) {
fmt.Fprintf(b.RT.Stderr, format, args...)
})
if err != nil {
return LeaseTarget{}, err
}
fmt.Fprintf(b.RT.Stderr, "provisioned lease=%s server=%s type=%s\n", leaseID, server.DisplayID(), cfg.ServerType)
server, err = client.WaitForServerIP(ctx, server.CloudID)
if err != nil {
return LeaseTarget{}, err
}
target := sshTargetFromConfig(cfg, server.PublicNet.IPv4.IP)
if err := waitForSSHReady(ctx, &target, b.RT.Stderr, "bootstrap", bootstrapWaitTimeout(cfg)); err != nil {
_ = client.DeleteServer(context.Background(), server.CloudID)
return LeaseTarget{}, err
}
server.Labels["state"] = "ready"
if err := client.SetTags(ctx, server.CloudID, server.Labels); err != nil {
fmt.Fprintf(b.RT.Stderr, "warning: set tags: %v\n", err)
}
return LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, nil
}
func (b *azureLeaseBackend) Resolve(ctx context.Context, req ResolveRequest) (LeaseTarget, error) {
client, err := newAzureClient(ctx, b.Cfg)
if err != nil {
return LeaseTarget{}, err
}
if strings.HasPrefix(req.ID, "crabbox-") {
server, err := client.GetServer(ctx, req.ID)
if err != nil {
return LeaseTarget{}, err
}
if !isCrabboxAzureLease(server) {
return LeaseTarget{}, exit(4, "lease/server not found: %s (vm exists but is not Crabbox-managed)", req.ID)
}
leaseID := blank(server.Labels["lease"], req.ID)
target := sshTargetFromConfig(b.Cfg, server.PublicNet.IPv4.IP)
useStoredTestboxKey(&target, leaseID)
return LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, nil
}
servers, err := client.ListCrabboxServers(ctx)
if err != nil {
return LeaseTarget{}, err
}
if server, leaseID, err := findServerByAlias(servers, req.ID); err != nil {
return LeaseTarget{}, err
} else if leaseID != "" {
target := sshTargetFromConfig(b.Cfg, server.PublicNet.IPv4.IP)
useStoredTestboxKey(&target, leaseID)
return LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, nil
}
return LeaseTarget{}, exit(4, "lease/server not found: %s", req.ID)
}
func isCrabboxAzureLease(server Server) bool {
if server.Labels == nil {
return false
}
if server.Labels["crabbox"] != "true" {
return false
}
if provider := server.Labels["provider"]; provider != "" && provider != "azure" {
return false
}
return true
}
func (b *azureLeaseBackend) List(ctx context.Context, req ListRequest) ([]LeaseView, error) {
_ = req
client, err := newAzureClient(ctx, b.Cfg)
if err != nil {
return nil, err
}
return client.ListCrabboxServers(ctx)
}
func (b *azureLeaseBackend) ReleaseLease(ctx context.Context, req ReleaseLeaseRequest) error {
if err := deleteServer(ctx, b.Cfg, req.Lease.Server); err != nil {
return err
}
removeLeaseClaim(req.Lease.LeaseID)
return nil
}
func (b *azureLeaseBackend) Touch(ctx context.Context, req TouchRequest) (Server, error) {
return b.DirectSSHBackend.Touch(ctx, req.Lease.Server, req.State), nil
}
func (b *azureLeaseBackend) Cleanup(ctx context.Context, req CleanupRequest) error {
servers, err := b.List(ctx, ListRequest{Options: req.Options})
if err != nil {
return err
}
return b.CleanupServers(ctx, req, servers)
}
func acquireAttemptsRetry(rt Runtime, keep bool, acquire func() (LeaseTarget, error)) (LeaseTarget, error) {
return shared.AcquireAttemptsRetry(rt, keep, acquire)
}
func exit(code int, format string, args ...any) core.ExitError {
return core.Exit(code, format, args...)
}
func newAzureClient(ctx context.Context, cfg Config) (*core.AzureClient, error) {
return core.NewAzureClient(ctx, cfg)
}
func newLeaseID() string { return core.NewLeaseID() }
func allocateDirectLeaseSlug(id string, servers []Server) string {
return core.AllocateDirectLeaseSlug(id, servers)
}
func ensureTestboxKeyForConfig(cfg Config, leaseID string) (string, string, error) {
return core.EnsureTestboxKeyForConfig(cfg, leaseID)
}
func providerKeyForLease(leaseID string) string { return core.ProviderKeyForLease(leaseID) }
func sshTargetFromConfig(cfg Config, host string) SSHTarget {
return core.SSHTargetFromConfig(cfg, host)
}
func waitForSSHReady(ctx context.Context, target *SSHTarget, stderr io.Writer, phase string, timeout time.Duration) error {
return core.WaitForSSHReady(ctx, target, stderr, phase, timeout)
}
func bootstrapWaitTimeout(cfg Config) time.Duration { return core.BootstrapWaitTimeout(cfg) }
func deleteServer(ctx context.Context, cfg Config, server Server) error {
return core.DeleteServer(ctx, cfg, server)
}
func blank(value, fallback string) string { return core.Blank(value, fallback) }
func useStoredTestboxKey(target *SSHTarget, leaseID string) {
if keyPath, err := core.TestboxKeyPath(leaseID); err == nil {
if _, statErr := os.Stat(keyPath); statErr == nil {
target.Key = keyPath
}
}
}
func findServerByAlias(servers []Server, id string) (Server, string, error) {
return core.FindServerByAlias(servers, id)
}
func removeLeaseClaim(leaseID string) { core.RemoveLeaseClaim(leaseID) }

View File

@ -1,35 +0,0 @@
package azure
import (
"flag"
core "github.com/openclaw/crabbox/internal/cli"
)
func init() {
core.RegisterProvider(Provider{})
}
type Provider struct{}
func (Provider) Name() string { return "azure" }
func (Provider) Aliases() []string { return nil }
func (Provider) Spec() core.ProviderSpec {
return core.ProviderSpec{
Name: "azure",
Kind: core.ProviderKindSSHLease,
Targets: []core.TargetSpec{
{OS: core.TargetLinux},
{OS: core.TargetWindows, WindowsMode: "normal"},
},
Features: core.FeatureSet{core.FeatureSSH, core.FeatureCrabboxSync, core.FeatureCleanup, core.FeatureDesktop, core.FeatureBrowser, core.FeatureCode, core.FeatureTailscale},
Coordinator: core.CoordinatorSupported,
}
}
func (Provider) RegisterFlags(*flag.FlagSet, core.Config) any { return core.NoProviderFlags() }
func (Provider) ApplyFlags(*core.Config, *flag.FlagSet, any) error {
return nil
}
func (p Provider) Configure(cfg core.Config, rt core.Runtime) (core.Backend, error) {
return NewAzureLeaseBackend(p.Spec(), cfg, rt), nil
}

View File

@ -1,84 +0,0 @@
package azure
import (
"testing"
core "github.com/openclaw/crabbox/internal/cli"
)
func TestIsCrabboxAzureLeaseRequiresProviderTag(t *testing.T) {
t.Parallel()
cases := []struct {
name string
labels map[string]string
want bool
}{
{name: "nil labels", labels: nil, want: false},
{name: "no crabbox tag", labels: map[string]string{"managed_by": "crabbox"}, want: false},
{name: "different provider", labels: map[string]string{"crabbox": "true", "provider": "aws"}, want: false},
{name: "tagged azure", labels: map[string]string{"crabbox": "true", "provider": "azure"}, want: true},
{name: "tagged no provider", labels: map[string]string{"crabbox": "true"}, want: true},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s := core.Server{Labels: tc.labels}
if got := isCrabboxAzureLease(s); got != tc.want {
t.Fatalf("labels=%+v got %v want %v", tc.labels, got, tc.want)
}
})
}
}
func TestProviderRegistered(t *testing.T) {
provider, err := core.ProviderFor("azure")
if err != nil {
t.Fatalf("expected azure provider to be registered: %v", err)
}
if got := provider.Name(); got != "azure" {
t.Fatalf("provider name = %q, want %q", got, "azure")
}
}
func TestProviderSpec(t *testing.T) {
spec := Provider{}.Spec()
if spec.Name != "azure" {
t.Fatalf("spec.Name = %q, want azure", spec.Name)
}
if spec.Kind != core.ProviderKindSSHLease {
t.Fatalf("spec.Kind = %q, want %q", spec.Kind, core.ProviderKindSSHLease)
}
if spec.Coordinator != core.CoordinatorSupported {
t.Fatalf("spec.Coordinator = %q, want %q", spec.Coordinator, core.CoordinatorSupported)
}
wantTargets := []core.TargetSpec{
{OS: core.TargetLinux},
{OS: core.TargetWindows, WindowsMode: "normal"},
}
if len(spec.Targets) != len(wantTargets) {
t.Fatalf("spec.Targets = %+v, want %+v", spec.Targets, wantTargets)
}
for i, want := range wantTargets {
if spec.Targets[i] != want {
t.Fatalf("spec.Targets[%d] = %+v, want %+v", i, spec.Targets[i], want)
}
}
wantFeatures := []core.Feature{
core.FeatureSSH,
core.FeatureCrabboxSync,
core.FeatureCleanup,
core.FeatureDesktop,
core.FeatureBrowser,
core.FeatureCode,
core.FeatureTailscale,
}
if len(spec.Features) != len(wantFeatures) {
t.Fatalf("spec.Features = %+v, want %+v", spec.Features, wantFeatures)
}
for i, f := range wantFeatures {
if spec.Features[i] != f {
t.Fatalf("spec.Features[%d] = %q, want %q", i, spec.Features[i], f)
}
}
}

View File

@ -1,284 +0,0 @@
import { AwsClient } from "aws4fetch";
import type { Env } from "./types";
export interface ArtifactUploadRequest {
files?: ArtifactUploadFile[];
prefix?: string;
}
export interface ArtifactUploadFile {
name?: string;
size?: number;
contentType?: string;
sha256?: string;
}
export interface ArtifactUploadGrant {
name: string;
key: string;
upload: {
method: "PUT";
url: string;
headers: Record<string, string>;
expiresAt: string;
};
url: string;
}
export interface ArtifactUploadResponse {
backend: string;
bucket: string;
prefix: string;
expiresAt: string;
files: ArtifactUploadGrant[];
}
interface ArtifactConfig {
backend: "s3" | "r2";
bucket: string;
prefix: string;
baseURL: string;
endpointURL: string;
region: string;
accessKeyID: string;
secretAccessKey: string;
sessionToken: string;
uploadExpiresSeconds: number;
urlExpiresSeconds: number;
}
const defaultUploadExpiresSeconds = 15 * 60;
const defaultURLExpiresSeconds = 7 * 24 * 60 * 60;
const maxArtifactFiles = 100;
const maxArtifactFileBytes = 1024 * 1024 * 1024;
const maxArtifactBatchBytes = 5 * 1024 * 1024 * 1024;
export async function artifactUploadResponse(
env: Env,
request: ArtifactUploadRequest,
owner: string,
): Promise<ArtifactUploadResponse> {
const config = artifactConfig(env);
const files = normalizeArtifactFiles(request.files ?? []);
if (files.length === 0) {
throw new Error("artifacts upload request requires at least one file");
}
const prefix = artifactPrefix(config.prefix, owner, request.prefix);
const now = new Date();
const uploadExpiresAt = new Date(
now.getTime() + config.uploadExpiresSeconds * 1000,
).toISOString();
const grants = await Promise.all(
files.map(async (file) => {
const key = artifactObjectKey(prefix, file.name);
const headers = artifactUploadHeaders(file);
return {
name: file.name,
key,
upload: {
method: "PUT" as const,
url: await presignArtifactURL(config, "PUT", key, config.uploadExpiresSeconds, headers),
headers,
expiresAt: uploadExpiresAt,
},
url: await artifactReadURL(config, key),
};
}),
);
return {
backend: config.backend,
bucket: config.bucket,
prefix,
expiresAt: uploadExpiresAt,
files: grants,
};
}
function artifactConfig(env: Env): ArtifactConfig {
const backend = normalizedBackend(env.CRABBOX_ARTIFACTS_BACKEND);
const bucket = trimmed(env.CRABBOX_ARTIFACTS_BUCKET);
const accessKeyID = trimmed(env.CRABBOX_ARTIFACTS_ACCESS_KEY_ID);
const secretAccessKey = trimmed(env.CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY);
if (!backend || !bucket || !accessKeyID || !secretAccessKey) {
throw new Error(
"artifact broker is not configured; set CRABBOX_ARTIFACTS_BACKEND, BUCKET, ACCESS_KEY_ID, and SECRET_ACCESS_KEY",
);
}
const endpointURL = stripTrailingSlash(env.CRABBOX_ARTIFACTS_ENDPOINT_URL);
if (backend === "r2" && !endpointURL) {
throw new Error("artifact broker r2 backend requires CRABBOX_ARTIFACTS_ENDPOINT_URL");
}
const region = trimmed(env.CRABBOX_ARTIFACTS_REGION) || (backend === "r2" ? "auto" : "us-east-1");
return {
backend,
bucket,
prefix: trimmed(env.CRABBOX_ARTIFACTS_PREFIX) || "crabbox-artifacts",
baseURL: stripTrailingSlash(env.CRABBOX_ARTIFACTS_BASE_URL),
endpointURL,
region,
accessKeyID,
secretAccessKey,
sessionToken: trimmed(env.CRABBOX_ARTIFACTS_SESSION_TOKEN),
uploadExpiresSeconds: positiveInt(
env.CRABBOX_ARTIFACTS_UPLOAD_EXPIRES_SECONDS,
defaultUploadExpiresSeconds,
),
urlExpiresSeconds: positiveInt(
env.CRABBOX_ARTIFACTS_URL_EXPIRES_SECONDS,
defaultURLExpiresSeconds,
),
};
}
function normalizedBackend(value: string | undefined): "s3" | "r2" | "" {
switch (trimmed(value).toLowerCase()) {
case "s3":
case "aws":
case "aws-s3":
return "s3";
case "r2":
case "cloudflare-r2":
return "r2";
default:
return "";
}
}
function normalizeArtifactFiles(files: ArtifactUploadFile[]): Required<ArtifactUploadFile>[] {
if (files.length > maxArtifactFiles) {
throw new Error(`artifacts upload request supports at most ${maxArtifactFiles} files`);
}
let totalSize = 0;
const normalized = files.map((file) => {
const name = normalizeArtifactName(file.name);
const size = Number(file.size ?? 0);
if (!Number.isFinite(size) || size < 0 || size > maxArtifactFileBytes) {
throw new Error(`invalid artifact size for ${name}`);
}
totalSize += size;
return {
name,
size,
contentType: normalizeContentType(file.contentType),
sha256: normalizeHash(file.sha256),
};
});
if (totalSize > maxArtifactBatchBytes) {
throw new Error(`artifacts upload request supports at most ${maxArtifactBatchBytes} bytes`);
}
return normalized;
}
function normalizeArtifactName(value: string | undefined): string {
const name = trimmed(value).replace(/\\/g, "/").replace(/^\/+/, "");
const parts = name.split("/").filter(Boolean);
if (parts.length === 0 || parts.some((part) => part === "." || part === "..")) {
throw new Error(`invalid artifact name: ${value ?? ""}`);
}
return parts.join("/");
}
function normalizeContentType(value: string | undefined): string {
return trimmed(value).slice(0, 200);
}
function normalizeHash(value: string | undefined): string {
const hash = trimmed(value).toLowerCase();
return /^[a-f0-9]{64}$/.test(hash) ? hash : "";
}
function artifactPrefix(
configPrefix: string,
owner: string,
requestPrefix: string | undefined,
): string {
const parts = [
normalizePrefixPart(configPrefix),
normalizePrefixPart(owner),
normalizePrefixPart(requestPrefix),
].filter(Boolean);
return parts.join("/");
}
function normalizePrefixPart(value: string | undefined): string {
return trimmed(value)
.replace(/\\/g, "/")
.split("/")
.filter((part) => part && part !== "." && part !== "..")
.join("/");
}
function artifactObjectKey(prefix: string, name: string): string {
return [prefix, name].filter(Boolean).join("/");
}
function artifactUploadHeaders(file: Required<ArtifactUploadFile>): Record<string, string> {
return {
...(file.contentType ? { "content-type": file.contentType } : {}),
"content-length": String(file.size),
};
}
async function artifactReadURL(config: ArtifactConfig, key: string): Promise<string> {
if (config.baseURL) {
return joinURLPath(config.baseURL, pathEscapeSegments(key));
}
return presignArtifactURL(config, "GET", key, config.urlExpiresSeconds);
}
async function presignArtifactURL(
config: ArtifactConfig,
method: "GET" | "PUT",
key: string,
expiresSeconds: number,
headers: Record<string, string> = {},
): Promise<string> {
const client = new AwsClient({
accessKeyId: config.accessKeyID,
secretAccessKey: config.secretAccessKey,
service: "s3",
region: config.region,
...(config.sessionToken ? { sessionToken: config.sessionToken } : {}),
});
const url = new URL(artifactS3ObjectURL(config, key));
url.searchParams.set("X-Amz-Expires", String(expiresSeconds));
const signed = await client.sign(url.toString(), {
method,
headers,
aws: { signQuery: true, allHeaders: true },
});
return signed.url;
}
function artifactS3ObjectURL(config: ArtifactConfig, key: string): string {
const encodedKey = pathEscapeSegments(key);
if (config.endpointURL) {
return joinURLPath(config.endpointURL, `${config.bucket}/${encodedKey}`);
}
if (config.region === "us-east-1") {
return `https://${config.bucket}.s3.amazonaws.com/${encodedKey}`;
}
return `https://${config.bucket}.s3.${config.region}.amazonaws.com/${encodedKey}`;
}
function joinURLPath(base: string, suffix: string): string {
return `${stripTrailingSlash(base)}/${suffix.replace(/^\/+/, "")}`;
}
function pathEscapeSegments(value: string): string {
return value.split("/").map(encodeURIComponent).join("/");
}
function stripTrailingSlash(value: string | undefined): string {
return trimmed(value).replace(/\/+$/, "");
}
function positiveInt(value: string | undefined, fallback: number): number {
const n = Number.parseInt(trimmed(value), 10);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
function trimmed(value: string | undefined): string {
return (value ?? "").trim();
}

View File

@ -1,825 +0,0 @@
import { azureWindowsBootstrapPowerShell, cloudInit } from "./bootstrap";
import { azureLocationFor, azureVMSizeCandidatesForTargetClass, type LeaseConfig } from "./config";
import { leaseProviderLabels } from "./provider-labels";
import { leaseProviderName } from "./slug";
import type { Env, ProviderMachine } from "./types";
const ADDRESS_SPACE = "10.42.0.0/16";
const SUBNET_CIDR = "10.42.0.0/24";
const API_VERSIONS = {
resources: "2021-04-01",
network: "2024-05-01",
compute: "2024-07-01",
disks: "2024-03-02",
};
const DELETE_RETRY_ATTEMPTS = 13;
const DELETE_RETRY_DELAY_MS = 15_000;
const DEFAULT_AZURE_LINUX_IMAGE = "Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:latest";
const DEFAULT_AZURE_WINDOWS_IMAGE =
"MicrosoftWindowsServer:windowsserver2022:2022-datacenter-smalldisk-g2:latest";
interface TokenCache {
token: string;
expiresAt: number;
}
interface AzureVM {
id?: string;
name?: string;
location?: string;
tags?: Record<string, string>;
properties?: {
provisioningState?: string;
hardwareProfile?: { vmSize?: string };
};
}
interface AzurePublicIP {
id?: string;
name?: string;
properties?: { ipAddress?: string };
}
interface AzureSecurityRule {
name?: string;
properties?: Record<string, unknown>;
}
interface AzureSKU {
name?: string;
resourceType?: string;
capabilities?: { name?: string; value?: string }[];
}
export class AzureClient {
private readonly tenant: string;
private readonly clientID: string;
private readonly secret: string;
readonly subscription: string;
readonly resourceGroup: string;
readonly vnet: string;
readonly subnet: string;
readonly nsg: string;
readonly image: string;
readonly sshCIDRs: string[];
readonly defaultLocation: string;
private cache?: TokenCache;
private ephemeralOSSupport?: Map<string, boolean>;
fetcher: typeof fetch = (input, init) => fetch(input, init);
constructor(env: Env) {
if (!env.AZURE_TENANT_ID) throw new Error("AZURE_TENANT_ID secret is required");
if (!env.AZURE_CLIENT_ID) throw new Error("AZURE_CLIENT_ID secret is required");
if (!env.AZURE_CLIENT_SECRET) throw new Error("AZURE_CLIENT_SECRET secret is required");
if (!env.AZURE_SUBSCRIPTION_ID) throw new Error("AZURE_SUBSCRIPTION_ID secret is required");
this.tenant = env.AZURE_TENANT_ID;
this.clientID = env.AZURE_CLIENT_ID;
this.secret = env.AZURE_CLIENT_SECRET;
this.subscription = env.AZURE_SUBSCRIPTION_ID;
this.resourceGroup = env.CRABBOX_AZURE_RESOURCE_GROUP?.trim() || "crabbox-leases";
this.vnet = env.CRABBOX_AZURE_VNET?.trim() || "crabbox-vnet";
this.subnet = env.CRABBOX_AZURE_SUBNET?.trim() || "crabbox-subnet";
this.nsg = env.CRABBOX_AZURE_NSG?.trim() || "crabbox-nsg";
this.image = env.CRABBOX_AZURE_IMAGE?.trim() || DEFAULT_AZURE_LINUX_IMAGE;
this.sshCIDRs = (env.CRABBOX_AZURE_SSH_CIDRS ?? "")
.split(",")
.map((value) => value.trim())
.filter(Boolean);
if (this.sshCIDRs.length === 0) this.sshCIDRs.push("0.0.0.0/0");
this.defaultLocation = env.CRABBOX_AZURE_LOCATION?.trim() || "eastus";
}
async listCrabboxServers(): Promise<ProviderMachine[]> {
const response = await this.arm<{ value: AzureVM[] }>(
"GET",
`/resourceGroups/${this.resourceGroup}/providers/Microsoft.Compute/virtualMachines`,
API_VERSIONS.compute,
).catch((error) => {
if (isNotFound(error)) return { value: [] as AzureVM[] };
throw error;
});
const tagged = (response.value ?? []).filter((vm) => vm.tags?.["crabbox"] === "true");
const ips = await Promise.all(
tagged.map((vm) =>
vm.name ? this.publicIP(`${vm.name}-pip`).catch(() => "") : Promise.resolve(""),
),
);
return tagged.map((vm, index) => toMachine(vm, ips[index] ?? ""));
}
async createServerWithFallback(
config: LeaseConfig,
leaseID: string,
slug: string,
owner: string,
): Promise<{ server: ProviderMachine; serverType: string; market: string }> {
const location = azureLocationFor(
{ CRABBOX_AZURE_LOCATION: this.defaultLocation },
config.azureLocation,
);
await this.ensureSharedInfra(location, config);
const candidates =
config.serverTypeExplicit && config.serverType
? [config.serverType]
: prependUnique(
config.serverType,
azureVMSizeCandidatesForTargetClass(config.target, config.class, config.windowsMode),
);
const failures: string[] = [];
for (const vmSize of candidates) {
try {
// oxlint-disable-next-line eslint/no-await-in-loop -- SKU fallback must stay sequential.
const server = await this.createVM(
{ ...config, serverType: vmSize },
location,
leaseID,
slug,
owner,
);
return { server, serverType: vmSize, market: config.capacityMarket };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
failures.push(`${vmSize}: ${message}`);
if (!isRetryableProvisioningError(message)) break;
}
}
if (config.capacityMarket === "spot" && config.capacityFallback.startsWith("on-demand")) {
for (const vmSize of candidates) {
try {
// oxlint-disable-next-line eslint/no-await-in-loop -- market fallback must preserve ordered capacity preference.
const server = await this.createVM(
{ ...config, capacityMarket: "on-demand", serverType: vmSize },
location,
leaseID,
slug,
owner,
);
return { server, serverType: vmSize, market: "on-demand" };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
failures.push(`on-demand ${vmSize}: ${message}`);
if (!isRetryableProvisioningError(message)) break;
}
}
}
throw new Error(failures.join("; "));
}
async deleteServer(name: string): Promise<void> {
for (let attempt = 0; ; attempt += 1) {
// oxlint-disable-next-line eslint/no-await-in-loop -- delete retries must wait for Azure dependency locks.
const result = await this.deleteServerOnce(name);
if (result.errors.length === 0) return;
if (!result.retry || attempt >= DELETE_RETRY_ATTEMPTS - 1) {
throw new Error(result.errors.join("; "));
}
// oxlint-disable-next-line eslint/no-await-in-loop -- the next delete attempt depends on this delay.
await sleep(DELETE_RETRY_DELAY_MS);
}
}
private async deleteServerOnce(name: string): Promise<{ errors: string[]; retry: boolean }> {
const result = { errors: [] as string[], retry: false };
await this.deleteResource("vm", vmPath(this.resourceGroup, name), API_VERSIONS.compute, result);
await this.deleteResource(
"nic",
networkPath(this.resourceGroup, "networkInterfaces", `${name}-nic`),
API_VERSIONS.network,
result,
);
await this.deleteResource(
"pip",
networkPath(this.resourceGroup, "publicIPAddresses", `${name}-pip`),
API_VERSIONS.network,
result,
);
await this.deleteResource(
"disk",
`/resourceGroups/${this.resourceGroup}/providers/Microsoft.Compute/disks/${name}-osdisk`,
API_VERSIONS.disks,
result,
);
return result;
}
private async deleteResource(
kind: string,
path: string,
apiVersion: string,
result: { errors: string[]; retry: boolean },
): Promise<void> {
try {
await this.arm("DELETE", path, apiVersion);
} catch (error) {
if (isNotFound(error)) return;
result.errors.push(`delete ${kind}: ${errorMessage(error)}`);
result.retry ||= isRetryableDeleteError(error);
}
}
async ensureSharedInfra(location: string, config: LeaseConfig): Promise<void> {
const tags = { crabbox: "true", managed_by: "crabbox" };
const rg = await this.arm<{ tags?: Record<string, string> }>(
"GET",
`/resourceGroups/${this.resourceGroup}`,
API_VERSIONS.resources,
).catch((error) => {
if (isNotFound(error)) return undefined;
throw error;
});
if (rg) {
if (rg.tags?.["managed_by"] !== "crabbox") {
throw new Error(`azure resource group ${this.resourceGroup} is not Crabbox-managed`);
}
} else {
await this.arm("PUT", `/resourceGroups/${this.resourceGroup}`, API_VERSIONS.resources, {
location,
tags,
});
}
const vnet = await this.arm<{ tags?: Record<string, string> }>(
"GET",
networkPath(this.resourceGroup, "virtualNetworks", this.vnet),
API_VERSIONS.network,
).catch((error) => {
if (isNotFound(error)) return undefined;
throw error;
});
if (vnet) {
if (vnet.tags?.["managed_by"] !== "crabbox") {
throw new Error(`azure vnet ${this.vnet} is not Crabbox-managed`);
}
} else {
await this.arm(
"PUT",
networkPath(this.resourceGroup, "virtualNetworks", this.vnet),
API_VERSIONS.network,
{
location,
tags,
properties: {
addressSpace: { addressPrefixes: [ADDRESS_SPACE] },
subnets: [{ name: this.subnet, properties: { addressPrefix: SUBNET_CIDR } }],
},
},
);
}
const nsg = await this.arm<{
tags?: Record<string, string>;
properties?: { securityRules?: AzureSecurityRule[] };
}>(
"GET",
networkPath(this.resourceGroup, "networkSecurityGroups", this.nsg),
API_VERSIONS.network,
).catch((error) => {
if (isNotFound(error)) return undefined;
throw error;
});
if (nsg && nsg.tags?.["managed_by"] !== "crabbox") {
throw new Error(`azure nsg ${this.nsg} is not Crabbox-managed`);
}
const preserved = preserveNonCrabboxRules(nsg?.properties?.securityRules ?? []);
const usedPriorities = usedNSGPriorities(preserved);
const rules = [...preserved, ...this.buildSSHRules(config, usedPriorities)];
await this.arm(
"PUT",
networkPath(this.resourceGroup, "networkSecurityGroups", this.nsg),
API_VERSIONS.network,
{
location,
tags,
properties: { securityRules: rules },
},
);
}
private buildSSHRules(config: LeaseConfig, usedPriorities: Set<number>) {
const ports = [config.sshPort, ...config.sshFallbackPorts].filter(Boolean);
const rules = [];
for (const port of ports) {
for (let index = 0; index < this.sshCIDRs.length; index += 1) {
const priority = nextNSGPriority(usedPriorities);
rules.push({
name: `crabbox-ssh-${port}-${index}`,
properties: {
priority,
direction: "Inbound",
access: "Allow",
protocol: "Tcp",
sourceAddressPrefix: this.sshCIDRs[index],
sourcePortRange: "*",
destinationAddressPrefix: "*",
destinationPortRange: port,
},
});
}
}
return rules;
}
private async createVM(
config: LeaseConfig,
location: string,
leaseID: string,
slug: string,
owner: string,
): Promise<ProviderMachine> {
const name = leaseProviderName(leaseID, slug);
try {
return await this.createVMUnchecked(config, location, leaseID, slug, owner, name);
} catch (error) {
await this.deleteServer(name).catch(() => undefined);
throw error;
}
}
private async createVMUnchecked(
config: LeaseConfig,
location: string,
leaseID: string,
slug: string,
owner: string,
name: string,
): Promise<ProviderMachine> {
const tags = azureTagsFromLabels(
leaseProviderLabels(config, leaseID, slug, owner, "azure", new Date(), {
market: config.capacityMarket,
}),
);
await this.arm(
"PUT",
networkPath(this.resourceGroup, "publicIPAddresses", `${name}-pip`),
API_VERSIONS.network,
{
location,
tags,
sku: { name: "Standard" },
properties: { publicIPAllocationMethod: "Static" },
},
);
const subnetID = `/subscriptions/${this.subscription}/resourceGroups/${this.resourceGroup}/providers/Microsoft.Network/virtualNetworks/${this.vnet}/subnets/${this.subnet}`;
const nsgID = `/subscriptions/${this.subscription}/resourceGroups/${this.resourceGroup}/providers/Microsoft.Network/networkSecurityGroups/${this.nsg}`;
const pipID = `/subscriptions/${this.subscription}/resourceGroups/${this.resourceGroup}/providers/Microsoft.Network/publicIPAddresses/${name}-pip`;
const nicID = `/subscriptions/${this.subscription}/resourceGroups/${this.resourceGroup}/providers/Microsoft.Network/networkInterfaces/${name}-nic`;
await this.arm(
"PUT",
networkPath(this.resourceGroup, "networkInterfaces", `${name}-nic`),
API_VERSIONS.network,
{
location,
tags,
properties: {
ipConfigurations: [
{
name: "ipconfig",
properties: {
privateIPAllocationMethod: "Dynamic",
subnet: { id: subnetID },
publicIPAddress: { id: pipID },
},
},
],
networkSecurityGroup: { id: nsgID },
},
},
);
const image = parseImageRef(this.imageForConfig(config));
const customData = btoa(
config.target === "windows" ? azureWindowsBootstrapPowerShell(config) : cloudInit(config),
);
const osDisk: Record<string, unknown> = {
name: `${name}-osdisk`,
createOption: "FromImage",
};
if (await this.supportsEphemeralOS(config.serverType, location)) {
osDisk["caching"] = "ReadOnly";
osDisk["diffDiskSettings"] = { option: "Local" };
} else {
osDisk["caching"] = "ReadWrite";
osDisk["managedDisk"] = { storageAccountType: "StandardSSD_LRS" };
}
const vmProperties: Record<string, unknown> = {
hardwareProfile: { vmSize: config.serverType },
storageProfile: {
imageReference: image,
osDisk,
},
osProfile: this.osProfile(config, name, leaseID, customData),
networkProfile: { networkInterfaces: [{ id: nicID }] },
};
if (config.capacityMarket === "spot") {
vmProperties["priority"] = "Spot";
vmProperties["evictionPolicy"] = "Delete";
}
await this.arm("PUT", vmPath(this.resourceGroup, name), API_VERSIONS.compute, {
location,
tags,
properties: vmProperties,
});
if (config.target === "windows") {
await this.installWindowsBootstrapExtension(location, name, tags);
}
const ip = await this.publicIP(`${name}-pip`);
const vm = await this.arm<AzureVM>(
"GET",
vmPath(this.resourceGroup, name),
API_VERSIONS.compute,
);
return toMachine(vm, ip);
}
private imageForConfig(config: LeaseConfig): string {
const image = config.azureImage || this.image;
if (config.target === "windows" && image === DEFAULT_AZURE_LINUX_IMAGE) {
return DEFAULT_AZURE_WINDOWS_IMAGE;
}
return image;
}
private osProfile(
config: LeaseConfig,
name: string,
leaseID: string,
customData: string,
): Record<string, unknown> {
if (config.target !== "windows") {
return {
computerName: name,
adminUsername: config.sshUser,
customData,
linuxConfiguration: {
disablePasswordAuthentication: true,
ssh: {
publicKeys: [
{
path: `/home/${config.sshUser}/.ssh/authorized_keys`,
keyData: config.sshPublicKey,
},
],
},
},
};
}
return {
computerName: azureComputerName(name, leaseID, config.target),
adminUsername: "crabadmin",
adminPassword: azureRandomAdminPassword(),
allowExtensionOperations: true,
customData,
windowsConfiguration: {
provisionVMAgent: true,
enableAutomaticUpdates: false,
},
};
}
private async installWindowsBootstrapExtension(
location: string,
vmName: string,
tags: Record<string, string>,
): Promise<void> {
await this.arm(
"PUT",
`${vmPath(this.resourceGroup, vmName)}/extensions/crabbox-bootstrap`,
API_VERSIONS.compute,
{
location,
tags,
properties: {
publisher: "Microsoft.Compute",
type: "CustomScriptExtension",
typeHandlerVersion: "1.10",
autoUpgradeMinorVersion: true,
settings: { timestamp: Math.trunc(Date.now() / 1000) },
protectedSettings: {
commandToExecute: azureWindowsBootstrapCommand(),
},
},
},
);
}
private async publicIP(name: string): Promise<string> {
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
// oxlint-disable-next-line eslint/no-await-in-loop -- public IP polling must wait between Azure reads.
const pip = await this.arm<AzurePublicIP>(
"GET",
networkPath(this.resourceGroup, "publicIPAddresses", name),
API_VERSIONS.network,
);
if (pip.properties?.ipAddress) return pip.properties.ipAddress;
// oxlint-disable-next-line eslint/no-await-in-loop -- this delay is the polling interval.
await sleep(2_000);
}
throw new Error(`timed out waiting for public ip: ${name}`);
}
private async arm<T>(
method: string,
path: string,
apiVersion: string,
body?: unknown,
): Promise<T> {
const token = await this.token();
const url = `https://management.azure.com/subscriptions/${this.subscription}${path}?api-version=${apiVersion}`;
const init: RequestInit = {
method,
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json",
},
};
if (body !== undefined) init.body = JSON.stringify(body);
const response = await this.fetcher(url, init);
if (!response.ok && response.status !== 201 && response.status !== 202) {
throw new Error(
`azure ${method} ${path}: http ${response.status}: ${await safeBody(response)}`,
);
}
const initialText = await response.text();
if (response.status === 201 || response.status === 202) {
await this.awaitLRO(response, token);
if (method === "DELETE") return undefined as T;
// 201 typically returns the resource in the initial body; 202 returns nothing,
// so re-GET the resource to read its post-provision state.
if (initialText) return JSON.parse(initialText) as T;
const refetch = await this.fetcher(url, {
headers: { authorization: `Bearer ${token}` },
});
if (!refetch.ok) {
throw new Error(
`azure ${method} ${path}: refetch http ${refetch.status}: ${await safeBody(refetch)}`,
);
}
const refetchText = await refetch.text();
return refetchText ? (JSON.parse(refetchText) as T) : (undefined as T);
}
if (response.status === 204) return undefined as T;
return initialText ? (JSON.parse(initialText) as T) : (undefined as T);
}
private async supportsEphemeralOS(vmSize: string, location: string): Promise<boolean> {
if (!this.ephemeralOSSupport) {
try {
this.ephemeralOSSupport = await this.loadEphemeralOSSupport(location);
} catch {
return azureSupportsEphemeralOS(vmSize);
}
}
return this.ephemeralOSSupport.get(vmSize) ?? azureSupportsEphemeralOS(vmSize);
}
private async loadEphemeralOSSupport(location: string): Promise<Map<string, boolean>> {
const token = await this.token();
const url = new URL(
`https://management.azure.com/subscriptions/${this.subscription}/providers/Microsoft.Compute/skus`,
);
url.searchParams.set("api-version", API_VERSIONS.compute);
url.searchParams.set("$filter", `location eq '${location}'`);
const response = await this.fetcher(url.toString(), {
headers: { authorization: `Bearer ${token}` },
});
if (!response.ok) {
throw new Error(
`azure GET resource skus: http ${response.status}: ${await safeBody(response)}`,
);
}
const json = (await response.json()) as { value?: AzureSKU[] };
const support = new Map<string, boolean>();
for (const sku of json.value ?? []) {
if (!sku.name || sku.resourceType !== "virtualMachines") continue;
support.set(sku.name, azureSKUCapabilityTrue(sku.capabilities, "EphemeralOSDiskSupported"));
}
return support;
}
private async awaitLRO(response: Response, token: string): Promise<void> {
const asyncURL =
response.headers.get("azure-asyncoperation") ?? response.headers.get("location");
if (!asyncURL) return;
const retryAfter = Number.parseInt(response.headers.get("retry-after") ?? "", 10);
const interval = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 3_000;
const deadline = Date.now() + 20 * 60_000;
while (Date.now() < deadline) {
// oxlint-disable-next-line eslint/no-await-in-loop -- LRO must wait between status reads.
await sleep(interval);
// oxlint-disable-next-line eslint/no-await-in-loop -- LRO polling is sequential.
const poll = await this.fetcher(asyncURL, {
headers: { authorization: `Bearer ${token}` },
});
if (!poll.ok) {
// oxlint-disable-next-line eslint/no-await-in-loop -- only reached on error to format diagnostic.
const detail = await safeBody(poll);
throw new Error(`azure LRO poll: http ${poll.status}: ${detail}`);
}
// oxlint-disable-next-line eslint/no-await-in-loop -- reading the LRO status payload is part of polling.
const text = await poll.text();
const status = text ? (JSON.parse(text) as { status?: string }).status?.toLowerCase() : "";
if (status === "succeeded") return;
if (status === "failed" || status === "canceled") {
throw new Error(`azure LRO ${status}: ${text}`);
}
}
throw new Error("azure long-running operation timed out");
}
private async token(): Promise<string> {
if (this.cache && this.cache.expiresAt > Date.now() + 30_000) return this.cache.token;
const body = new URLSearchParams({
grant_type: "client_credentials",
client_id: this.clientID,
client_secret: this.secret,
scope: "https://management.azure.com/.default",
});
const response = await this.fetcher(
`https://login.microsoftonline.com/${this.tenant}/oauth2/v2.0/token`,
{
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: body.toString(),
},
);
if (!response.ok) {
throw new Error(`azure token: http ${response.status}: ${await safeBody(response)}`);
}
const json = (await response.json()) as { access_token?: string; expires_in?: number };
if (!json.access_token) throw new Error("azure token response missing access_token");
this.cache = {
token: json.access_token,
expiresAt: Date.now() + (json.expires_in ?? 3600) * 1000,
};
return this.cache.token;
}
}
function azureWindowsBootstrapCommand(): string {
return `powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$p=Join-Path $env:SystemDrive 'AzureData\\CustomData.bin'; $d=Join-Path $env:SystemDrive 'AzureData\\crabbox-bootstrap.ps1'; Copy-Item -Force $p $d; & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $d"`;
}
function azureRandomAdminPassword(): string {
const bytes = new Uint8Array(18);
crypto.getRandomValues(bytes);
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return `Cb1!${btoa(binary).slice(0, 18)}`;
}
function azureComputerName(vmName: string, leaseID: string, target: string): string {
if (target !== "windows") return vmName;
const suffix = (leaseID || vmName)
.toLowerCase()
.replace(/[^a-z0-9]/g, "")
.slice(0, 12);
return `cbx${suffix || "windows"}`;
}
function vmPath(rg: string, name: string): string {
return `/resourceGroups/${rg}/providers/Microsoft.Compute/virtualMachines/${name}`;
}
function networkPath(rg: string, kind: string, name: string): string {
return `/resourceGroups/${rg}/providers/Microsoft.Network/${kind}/${name}`;
}
function parseImageRef(value: string): {
publisher: string;
offer: string;
sku: string;
version: string;
} {
const parts = value.split(":");
if (parts.length !== 4) {
throw new Error(`azure image must be Publisher:Offer:SKU:Version, got ${value}`);
}
return { publisher: parts[0]!, offer: parts[1]!, sku: parts[2]!, version: parts[3]! };
}
function toMachine(vm: AzureVM, ip: string): ProviderMachine {
return {
provider: "azure",
id: 0,
cloudID: vm.name ?? "",
name: vm.name ?? "",
status: vm.properties?.provisioningState ?? "",
serverType: vm.properties?.hardwareProfile?.vmSize ?? "",
host: ip,
labels: azureLabelsFromTags(vm.tags ?? {}),
};
}
export function azureTagsFromLabels(labels: Record<string, string>): Record<string, string> {
return Object.fromEntries(
Object.entries(labels).map(([key, value]) => [azureLabelToTagKey(key), value]),
);
}
export function azureLabelsFromTags(tags: Record<string, string>): Record<string, string> {
const labels = Object.fromEntries(
Object.entries(tags).map(([key, value]) => [azureTagToLabelKey(key), value]),
);
if (!labels["windows_mode"] && labels["crabbox_windows_mode"]) {
labels["windows_mode"] = labels["crabbox_windows_mode"];
}
return labels;
}
function azureLabelToTagKey(key: string): string {
return key.toLowerCase().startsWith("windows") ? `crabbox_${key}` : key;
}
function azureTagToLabelKey(key: string): string {
return key.startsWith("crabbox_windows") ? key.replace(/^crabbox_/, "") : key;
}
function isNotFound(error: unknown): boolean {
const message = errorMessage(error);
return message.includes("http 404") || message.includes("ResourceNotFound");
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export function isRetryableDeleteError(error: unknown): boolean {
const message = errorMessage(error);
return (
message.includes("NicReservedForAnotherVm") ||
message.includes("PublicIPAddressCannotBeDeleted") ||
message.includes("InUse") ||
message.includes("AnotherOperationInProgress") ||
(message.includes("OperationNotAllowed") && message.includes("retry after"))
);
}
export function preserveNonCrabboxRules(rules: AzureSecurityRule[]): AzureSecurityRule[] {
return rules.filter((rule) => !rule.name?.startsWith("crabbox-ssh-"));
}
function usedNSGPriorities(rules: AzureSecurityRule[]): Set<number> {
const used = new Set<number>();
for (const rule of rules) {
const priority = rule.properties?.["priority"];
if (typeof priority === "number") used.add(priority);
}
return used;
}
function nextNSGPriority(used: Set<number>): number {
for (let priority = 100; priority <= 4096; priority += 1) {
if (!used.has(priority)) {
used.add(priority);
return priority;
}
}
throw new Error("azure nsg: no available security rule priorities");
}
export function azureSupportsEphemeralOS(vmSize: string): boolean {
const normalized = vmSize.toLowerCase();
if (normalized.startsWith("standard_f") && normalized.endsWith("s_v2")) {
return true;
}
if (
(normalized.startsWith("standard_d") || normalized.startsWith("standard_e")) &&
(normalized.includes("ds_v5") || normalized.includes("ds_v6"))
) {
return true;
}
return false;
}
function azureSKUCapabilityTrue(
capabilities: { name?: string; value?: string }[] | undefined,
name: string,
): boolean {
return (
capabilities?.some(
(capability) => capability.name === name && capability.value?.toLowerCase() === "true",
) ?? false
);
}
export function isRetryableProvisioningError(message: string): boolean {
return (
message.includes("SkuNotAvailable") ||
message.includes("QuotaExceeded") ||
message.includes("AllocationFailed") ||
message.includes("ZonalAllocationFailed") ||
message.includes("OverconstrainedAllocationRequest") ||
message.includes("OperationNotAllowed")
);
}
function prependUnique(first: string, rest: string[]): string[] {
return [first, ...rest.filter((value) => value !== first)];
}
async function safeBody(response: Response): Promise<string> {
const text = await response.text();
return text.length > 500 ? `${text.slice(0, 500)}...` : text;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@ -77,8 +77,8 @@ runcmd:
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
systemctl enable ssh || true
timeout 30s systemctl restart ssh || timeout 30s systemctl restart ssh.socket || true
systemctl enable --now ssh
systemctl restart ssh
${bootstrap}
touch /var/lib/crabbox/bootstrapped
crabbox-ready
@ -94,10 +94,9 @@ tasks:
`;
}
function windowsBootstrapHeaderPowerShell(config: LeaseConfig): string {
export function windowsBootstrapPowerShell(config: LeaseConfig): string {
return `
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
function Retry($ScriptBlock) {
for ($i = 1; $i -le 8; $i++) {
@ -118,23 +117,23 @@ $user = ${psQuote(config.sshUser)}
$publicKey = ${psQuote(config.sshPublicKey)}
$workRoot = ${psQuote(config.workRoot)}
$sshPorts = ${windowsSSHPortsPowerShell(config)}
$base = "C:\\ProgramData\\crabbox"
$setupCompletePath = Join-Path $base "setup-complete"
$vncPasswordPath = "C:\\ProgramData\\crabbox\\vnc.password"
$windowsUsernamePath = "C:\\ProgramData\\crabbox\\windows.username"
$windowsPasswordPath = "C:\\ProgramData\\crabbox\\windows.password"
$userVNCStartupPath = "C:\\ProgramData\\crabbox\\start-user-vnc.ps1"
$userVNCStartupCommandPath = Join-Path (Join-Path (Join-Path "C:\\Users" $user) "AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs\\Startup") "crabbox-user-vnc.cmd"
$setupCompletePath = "C:\\ProgramData\\crabbox\\setup-complete"
$openSSHZip = "$env:TEMP\\OpenSSH-Win64.zip"
$gitInstaller = "$env:TEMP\\Git-2.52.0-64-bit.exe"
New-Item -ItemType Directory -Force -Path $base, $workRoot | Out-Null
`;
$tightVNCInstaller = "$env:TEMP\\tightvnc-2.8.85-gpl-setup-64bit.msi"
New-Item -ItemType Directory -Force -Path "C:\\ProgramData\\crabbox", $workRoot | Out-Null
if (-not (Test-Path -LiteralPath $vncPasswordPath)) {
New-CrabboxPassword | Set-Content -NoNewline -Encoding ASCII -Path $vncPasswordPath
}
function windowsBootstrapCorePowerShell(): string {
return `
if (-not (Test-Path -LiteralPath $passwordPath)) {
New-CrabboxPassword | Set-Content -NoNewline -Encoding ASCII -Path $passwordPath
}
$userPassword = (Get-Content -Raw -Path $passwordPath).Trim()
$userPassword = Get-Content -Raw -Path $vncPasswordPath
if ($userPassword.Length -lt 12 -or $userPassword -notmatch '[A-Z]' -or $userPassword -notmatch '[a-z]' -or $userPassword -notmatch '[0-9]' -or $userPassword -notmatch '[^A-Za-z0-9]') {
$userPassword = New-CrabboxPassword
Set-Content -NoNewline -Encoding ASCII -Path $passwordPath -Value $userPassword
Set-Content -NoNewline -Encoding ASCII -Path $vncPasswordPath -Value $userPassword
}
$secure = ConvertTo-SecureString $userPassword -AsPlainText -Force
if (-not (Get-LocalUser -Name $user -ErrorAction SilentlyContinue)) {
@ -143,17 +142,13 @@ if (-not (Get-LocalUser -Name $user -ErrorAction SilentlyContinue)) {
Set-LocalUser -Name $user -Password $secure -PasswordNeverExpires $true
}
Add-LocalGroupMember -Group "Administrators" -Member $user -ErrorAction SilentlyContinue
Set-Content -NoNewline -Encoding ASCII -Path $usernamePath -Value $user
if ($passwordMirrorPath) {
Set-Content -NoNewline -Encoding ASCII -Path $passwordMirrorPath -Value $userPassword
}
Set-Content -NoNewline -Encoding ASCII -Path $windowsUsernamePath -Value $user
Set-Content -NoNewline -Encoding ASCII -Path $windowsPasswordPath -Value $userPassword
$userSID = (Get-LocalUser -Name $user).SID.Value
icacls.exe $workRoot /grant "*\${userSID}:(OI)(CI)F" | Out-Null
$userSSHDir = Join-Path (Join-Path "C:\\Users" $user) ".ssh"
$userAuthorizedKeys = Join-Path $userSSHDir "authorized_keys"
New-Item -ItemType Directory -Force -Path $userSSHDir | Out-Null
Set-Content -Encoding ASCII -Path $userAuthorizedKeys -Value $publicKey
icacls.exe $userSSHDir /inheritance:r /grant "*\${userSID}:F" /grant "*S-1-5-32-544:F" /grant "*S-1-5-18:F" | Out-Null
icacls.exe $userAuthorizedKeys /inheritance:r /grant "*\${userSID}:F" /grant "*S-1-5-32-544:F" /grant "*S-1-5-18:F" | Out-Null
if (-not (Get-Service -Name sshd -ErrorAction SilentlyContinue)) {
Retry { Invoke-WebRequest -Uri ${psQuote(openSSHWin64ZipURL)} -OutFile $openSSHZip -UseBasicParsing }
@ -178,18 +173,9 @@ $inMatch = $false
foreach ($line in ($sshdConfig -split "\\r?\\n")) {
if ($line -match '^\\s*Match\\s+') { $inMatch = $true }
if (-not $inMatch -and $line -match '^\\s*Port\\s+\\d+\\s*$') { continue }
if ($enforceKeyAuth -and -not $inMatch -and $line -match '^\\s*(PasswordAuthentication|PubkeyAuthentication)\\s+') { continue }
if ($inMatch) { $matchLines += $line } else { $globalLines += $line }
}
foreach ($port in $sshPorts) { $globalLines += "Port $port" }
if ($enforceKeyAuth) {
$globalLines += "PubkeyAuthentication yes"
$globalLines += "PasswordAuthentication no"
}
if (($matchLines -join [Environment]::NewLine) -notmatch '(?im)^\\s*Match\\s+Group\\s+administrators\\b') {
$matchLines += "Match Group administrators"
$matchLines += " AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys"
}
Set-Content -Encoding ASCII -LiteralPath $sshdConfigPath -Value (($globalLines + $matchLines) -join [Environment]::NewLine)
foreach ($port in $sshPorts) {
$ruleName = "crabbox-sshd-$port"
@ -209,26 +195,6 @@ foreach ($path in @("C:\\Program Files\\OpenSSH", "C:\\Program Files\\Git\\cmd",
if ($env:Path -notlike "*$path*") { $env:Path = "$env:Path;$path" }
}
[Environment]::SetEnvironmentVariable("Path", $machinePath, "Machine")
`;
}
export function windowsBootstrapPowerShell(config: LeaseConfig): string {
return (
windowsBootstrapHeaderPowerShell(config) +
`
$vncPasswordPath = "C:\\ProgramData\\crabbox\\vnc.password"
$windowsUsernamePath = "C:\\ProgramData\\crabbox\\windows.username"
$windowsPasswordPath = "C:\\ProgramData\\crabbox\\windows.password"
$passwordPath = $vncPasswordPath
$usernamePath = $windowsUsernamePath
$passwordMirrorPath = $windowsPasswordPath
$enforceKeyAuth = $false
$userVNCStartupPath = "C:\\ProgramData\\crabbox\\start-user-vnc.ps1"
$userVNCStartupCommandPath = Join-Path (Join-Path (Join-Path "C:\\Users" $user) "AppData\\Roaming\\Microsoft\\Windows\\Start Menu\\Programs\\Startup") "crabbox-user-vnc.cmd"
$tightVNCInstaller = "$env:TEMP\\tightvnc-2.8.85-gpl-setup-64bit.msi"
` +
windowsBootstrapCorePowerShell() +
`
if (-not (Test-Path -LiteralPath "C:\\Program Files\\TightVNC\\tvnserver.exe")) {
Retry { Invoke-WebRequest -Uri ${psQuote(tightVNCMSIURL)} -OutFile $tightVNCInstaller -UseBasicParsing }
$vncPassword = Get-Content -Raw -Path $vncPasswordPath
@ -292,27 +258,7 @@ if (-not (Test-Path -LiteralPath $setupCompletePath)) {
Set-Content -NoNewline -Encoding ASCII -Path $setupCompletePath -Value (Get-Date).ToString("o")
Restart-Computer -Force
}
`
);
}
export function azureWindowsBootstrapPowerShell(config: LeaseConfig): string {
return (
windowsBootstrapHeaderPowerShell(config) +
`
$passwordPath = Join-Path $base "windows.password"
$usernamePath = Join-Path $base "windows.username"
$passwordMirrorPath = $null
$enforceKeyAuth = $true
` +
windowsBootstrapCorePowerShell() +
`
Restart-Service sshd -Force
git --version | Out-Null
tar --version | Out-Null
Set-Content -NoNewline -Encoding ASCII -Path $setupCompletePath -Value (Get-Date).ToString("o")
`
);
`;
}
function windowsSSHPortsPowerShell(config: LeaseConfig): string {

View File

@ -27,8 +27,6 @@ export interface LeaseConfig {
awsRootGB: number;
awsSSHCIDRs: string[];
awsMacHostID: string;
azureLocation: string;
azureImage: string;
capacityMarket: "spot" | "on-demand";
capacityStrategy:
| "most-available"
@ -52,7 +50,7 @@ export interface LeaseConfig {
export function leaseConfig(input: LeaseRequest): LeaseConfig {
const provider = input.provider ?? "hetzner";
if (provider !== "hetzner" && provider !== "aws" && provider !== "azure") {
if (provider !== "hetzner" && provider !== "aws") {
throw new Error(`unsupported provider: ${String(provider)}`);
}
const target = normalizeTarget(input.target ?? input.targetOS ?? "linux");
@ -60,23 +58,13 @@ export function leaseConfig(input: LeaseRequest): LeaseConfig {
if (
target !== "linux" &&
!(provider === "aws" && target === "windows") &&
!(provider === "aws" && target === "macos") &&
!(provider === "azure" && target === "windows" && windowsMode === "normal")
!(provider === "aws" && target === "macos")
) {
if (provider === "hetzner" || provider === "azure") {
throw new Error(unsupportedManagedTargetMessage(provider, target, windowsMode));
if (provider === "hetzner") {
throw new Error(unsupportedManagedTargetMessage(provider, target));
}
throw new Error(`unsupported target for brokered ${provider}: ${target}`);
}
if (
provider === "azure" &&
target === "windows" &&
(input.desktop || input.browser || input.code || input.tailscale)
) {
throw new Error(
"brokered azure target=windows currently supports SSH, sync, and run; desktop/browser/code/tailscale require Linux or AWS Windows where supported",
);
}
if (target === "macos") {
if (provider !== "aws") {
throw new Error(`unsupported target for brokered ${provider}: ${target}`);
@ -127,8 +115,6 @@ export function leaseConfig(input: LeaseRequest): LeaseConfig {
awsRootGB: input.awsRootGB ?? 400,
awsSSHCIDRs: validCIDRs(input.awsSSHCIDRs ?? []),
awsMacHostID: input.awsMacHostID ?? "",
azureLocation: input.azureLocation ?? "",
azureImage: input.azureImage ?? "",
capacityMarket: input.capacity?.market ?? "spot",
capacityStrategy: input.capacity?.strategy ?? "most-available",
capacityFallback: input.capacity?.fallback ?? "on-demand-after-120s",
@ -167,20 +153,7 @@ function defaultSSHUser(provider: Provider, target: TargetOS, windowsMode: Windo
return "crabbox";
}
function unsupportedManagedTargetMessage(
provider: Provider,
target: TargetOS,
windowsMode: WindowsMode,
): string {
if (provider === "azure" && target === "windows" && windowsMode === "wsl2") {
return "brokered azure supports native Windows only; use brokered aws for managed Windows WSL2 or provider=ssh for existing Windows WSL2 hosts";
}
if (provider === "azure") {
if (target === "macos") {
return "brokered azure managed provisioning supports target=linux and native Windows only; use brokered aws with an EC2 Mac Dedicated Host or provider=ssh for existing macOS hosts";
}
return "brokered azure managed provisioning supports target=linux and native Windows only";
}
function unsupportedManagedTargetMessage(provider: Provider, target: TargetOS): string {
if (target === "windows") {
return `brokered ${provider} managed provisioning supports target=linux only; use brokered aws for managed Windows or provider=ssh for existing Windows hosts`;
}
@ -190,13 +163,6 @@ function unsupportedManagedTargetMessage(
return `brokered ${provider} managed provisioning supports target=linux only`;
}
export function azureLocationFor(
env: { CRABBOX_AZURE_LOCATION?: string },
override: string,
): string {
return override.trim() || env.CRABBOX_AZURE_LOCATION?.trim() || "eastus";
}
export function normalizeTailscaleTags(values: string[]): string[] {
return uniqueStrings(
values
@ -273,9 +239,6 @@ export function serverTypeForProviderClass(provider: Provider, machineClass: str
if (provider === "aws") {
return awsInstanceTypeCandidatesForClass(machineClass)[0] ?? machineClass;
}
if (provider === "azure") {
return azureVMSizeCandidatesForClass(machineClass)[0] ?? machineClass;
}
return serverTypeForClass(machineClass);
}
@ -290,124 +253,9 @@ export function serverTypeForConfig(
awsInstanceTypeCandidatesForTargetClass(target, machineClass, windowsMode)[0] ?? machineClass
);
}
if (provider === "azure") {
return (
azureVMSizeCandidatesForTargetClass(target, machineClass, windowsMode)[0] ?? machineClass
);
}
return serverTypeForClass(machineClass);
}
export function azureVMSizeCandidatesForTargetClass(
target: TargetOS,
machineClass: string,
windowsMode: WindowsMode = "normal",
): string[] {
if (target === "linux") {
return azureVMSizeCandidatesForClass(machineClass);
}
if (target === "windows" && windowsMode === "normal") {
return azureWindowsVMSizeCandidatesForClass(machineClass);
}
return [machineClass];
}
export function azureVMSizeCandidatesForClass(machineClass: string): string[] {
switch (machineClass) {
case "standard":
return [
"Standard_D32ads_v6",
"Standard_D32ds_v6",
"Standard_F32s_v2",
"Standard_D32ads_v5",
"Standard_D32ds_v5",
"Standard_D16ads_v6",
"Standard_D16ds_v6",
"Standard_F16s_v2",
];
case "fast":
return [
"Standard_D64ads_v6",
"Standard_D64ds_v6",
"Standard_F64s_v2",
"Standard_D64ads_v5",
"Standard_D64ds_v5",
"Standard_D48ads_v6",
"Standard_D48ds_v6",
"Standard_F48s_v2",
"Standard_D32ads_v6",
"Standard_D32ds_v6",
"Standard_F32s_v2",
];
case "large":
return [
"Standard_D96ads_v6",
"Standard_D96ds_v6",
"Standard_D96ads_v5",
"Standard_D96ds_v5",
"Standard_D64ads_v6",
"Standard_D64ds_v6",
"Standard_F64s_v2",
"Standard_D48ads_v6",
"Standard_D48ds_v6",
"Standard_F48s_v2",
];
case "beast":
return [
"Standard_D192ds_v6",
"Standard_D128ds_v6",
"Standard_D96ads_v6",
"Standard_D96ds_v6",
"Standard_D96ads_v5",
"Standard_D96ds_v5",
"Standard_D64ads_v6",
"Standard_D64ds_v6",
"Standard_F64s_v2",
];
default:
return [machineClass];
}
}
export function azureWindowsVMSizeCandidatesForClass(machineClass: string): string[] {
switch (machineClass) {
case "standard":
return [
"Standard_D2ads_v6",
"Standard_D2ds_v6",
"Standard_D2ads_v5",
"Standard_D2ds_v5",
"Standard_D2as_v6",
];
case "fast":
return [
"Standard_D4ads_v6",
"Standard_D4ds_v6",
"Standard_D4ads_v5",
"Standard_D4ds_v5",
"Standard_D4as_v6",
];
case "large":
return [
"Standard_D8ads_v6",
"Standard_D8ds_v6",
"Standard_D8ads_v5",
"Standard_D8ds_v5",
"Standard_D8as_v6",
];
case "beast":
return [
"Standard_D16ads_v6",
"Standard_D16ds_v6",
"Standard_D16ads_v5",
"Standard_D16ds_v5",
"Standard_D8ads_v6",
];
default:
return [machineClass];
}
}
export function awsInstanceTypeCandidatesForTargetClass(
target: TargetOS,
machineClass: string,

File diff suppressed because it is too large Load Diff

View File

@ -231,12 +231,8 @@ export function portalLeaseDetail(
);
}
export function portalShareLease(
lease: LeaseRecord,
options: { embedded?: boolean } = {},
): Response {
export function portalShareLease(lease: LeaseRecord): Response {
const slug = lease.slug || lease.id;
const sharePath = `/portal/leases/${encodeURIComponent(lease.id)}/share${options.embedded ? "?embed=1" : ""}`;
const users = Object.entries(lease.share?.users ?? {}).toSorted(([a], [b]) => a.localeCompare(b));
const userRows = users.length
? users
@ -245,7 +241,7 @@ export function portalShareLease(
<td>${escapeHTML(user)}</td>
<td><span class="pill">${escapeHTML(role)}</span></td>
<td>
<form method="post" action="${sharePath}">
<form method="post" action="/portal/leases/${encodeURIComponent(lease.id)}/share">
<input type="hidden" name="action" value="remove-user">
<input type="hidden" name="user" value="${escapeHTML(user)}">
<button class="button secondary" type="submit">remove</button>
@ -257,44 +253,40 @@ export function portalShareLease(
: `<tr><td colspan="3" class="empty">no shared users</td></tr>`;
return html(
`Share ${slug}`,
`<main class="portal-shell run-shell share-shell${options.embedded ? " share-shell-embedded" : ""}">
${
options.embedded
? ""
: portalHeader({
meta: `share ${escapeHTML(slug)} <span class="mono">${escapeHTML(lease.id)}</span>`,
actions: `
<a class="button secondary" href="/portal/leases/${encodeURIComponent(lease.id)}">back to lease</a>
<a class="button secondary" href="/portal">leases</a>
<a class="button secondary" href="/portal/logout">log out</a>
`,
})
}
`<main class="portal-shell run-shell">
${portalHeader({
meta: `share ${escapeHTML(slug)} <span class="mono">${escapeHTML(lease.id)}</span>`,
actions: `
<a class="button secondary" href="/portal/leases/${encodeURIComponent(lease.id)}">lease</a>
<a class="button secondary" href="/portal">leases</a>
<a class="button secondary" href="/portal/logout">log out</a>
`,
})}
<section class="panel">
<div class="section-head">
<h2>org access</h2>
<span class="pill">${escapeHTML(lease.share?.org ?? "off")}</span>
</div>
<form class="share-form" method="post" action="${sharePath}">
<form class="share-form" method="post" action="/portal/leases/${encodeURIComponent(lease.id)}/share">
<input type="hidden" name="action" value="set-org">
<select name="role" aria-label="org role">
<option value=""${lease.share?.org ? "" : " selected"}>off</option>
<option value="use"${lease.share?.org === "use" ? " selected" : ""}>use</option>
<option value="manage"${lease.share?.org === "manage" ? " selected" : ""}>manage</option>
</select>
<button class="button action" type="submit">save</button>
<button class="button" type="submit">save</button>
</form>
</section>
<section class="panel">
<div class="section-head"><h2>users</h2></div>
<form class="share-form" method="post" action="${sharePath}">
<form class="share-form" method="post" action="/portal/leases/${encodeURIComponent(lease.id)}/share">
<input type="hidden" name="action" value="add-user">
<input name="user" type="email" placeholder="friend@example.com" required>
<select name="role" aria-label="user role">
<option value="use">use</option>
<option value="manage">manage</option>
</select>
<button class="button action" type="submit">add</button>
<button class="button" type="submit">add</button>
</form>
<div class="table-scroll">
<table>
@ -303,14 +295,11 @@ export function portalShareLease(
</table>
</div>
</section>
<form method="post" action="${sharePath}">
<form method="post" action="/portal/leases/${encodeURIComponent(lease.id)}/share">
<input type="hidden" name="action" value="clear">
<button class="button danger" type="submit">clear sharing</button>
</form>
</main>`,
200,
"",
options.embedded ? { frameAncestors: "'self'" } : {},
);
}
@ -523,9 +512,6 @@ export function portalVNC(lease: LeaseRecord): Response {
const title = `WebVNC ${slug}`;
const wsPath = `/portal/leases/${encodeURIComponent(lease.id)}/vnc/viewer`;
const statusPath = `/portal/leases/${encodeURIComponent(lease.id)}/vnc/status`;
const controlPath = `/portal/leases/${encodeURIComponent(lease.id)}/vnc/control`;
const sharePath = `/portal/leases/${encodeURIComponent(lease.id)}/share`;
const embeddedSharePath = `${sharePath}?embed=1`;
const bridgeCmd = webVNCBridgeCommand(lease);
const fullscreenIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 9V4h5"/><path d="M20 9V4h-5"/><path d="M4 15v5h5"/><path d="M20 15v5h-5"/></svg>`;
const reconnectIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 4v5h-5"/></svg>`;
@ -538,12 +524,11 @@ export function portalVNC(lease: LeaseRecord): Response {
meta: `<span>WebVNC ${escapeHTML(slug)}</span><span class="vnc-dot"></span>${providerBadge(lease.provider)}<span class="vnc-dot"></span>${targetBadge(target, lease.windowsMode)}<span class="vnc-dot"></span><span class="vnc-id">${escapeHTML(lease.id)}</span>`,
actions: `
<span id="status" class="status-pill">waiting for bridge</span>
<button id="vnc-takeover" class="button secondary vnc-control" type="button" hidden>take control</button>
<button id="vnc-copy-remote" class="icon-btn" type="button" title="copy remote clipboard" aria-label="copy remote clipboard" disabled>${copyIcon}</button>
<button id="vnc-paste" class="icon-btn" type="button" title="paste clipboard" aria-label="paste clipboard">${pasteIcon}</button>
<button id="vnc-reconnect" class="icon-btn" type="button" title="reconnect" aria-label="reconnect">${reconnectIcon}</button>
<button id="vnc-fullscreen" class="icon-btn" type="button" title="fullscreen" aria-label="toggle fullscreen">${fullscreenIcon}</button>
<button id="vnc-share" class="button secondary" type="button">share</button>
<a class="button secondary" href="/portal/leases/${encodeURIComponent(lease.id)}/share">share</a>
<a class="button secondary" href="/portal">leases</a>
<a class="button secondary" href="/portal/logout">log out</a>
`,
@ -554,13 +539,6 @@ export function portalVNC(lease: LeaseRecord): Response {
<code id="vnc-bridge-cmd" class="vnc-bridge-cmd">${escapeHTML(bridgeCmd)}</code>
<button id="vnc-copy" class="icon-btn" type="button" title="copy command" aria-label="copy bridge command">${copyIcon}</button>
</footer>
<dialog id="vnc-share-dialog" class="vnc-share-dialog" aria-label="Share lease">
<div class="vnc-share-head">
<div><strong>share ${escapeHTML(slug)}</strong><small>${escapeHTML(lease.id)}</small></div>
<button id="vnc-share-close" class="icon-btn" type="button" title="close share" aria-label="close share">×</button>
</div>
<iframe id="vnc-share-frame" class="vnc-share-frame" title="Share ${escapeHTML(slug)}" loading="lazy"></iframe>
</dialog>
</main>
<script type="module" nonce="${nonce}">
import RFBModule from ${JSON.stringify(novncModuleURL)};
@ -570,11 +548,6 @@ export function portalVNC(lease: LeaseRecord): Response {
const wsURL = new URL(${JSON.stringify(wsPath)}, window.location.href);
wsURL.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const statusURL = new URL(${JSON.stringify(statusPath)}, window.location.href);
const controlURL = new URL(${JSON.stringify(controlPath)}, window.location.href);
const shareURL = new URL(${JSON.stringify(embeddedSharePath)}, window.location.href);
const viewerID = "viewer_" + (crypto.randomUUID?.() || String(Date.now()) + Math.random()).replace(/[^A-Za-z0-9_.:-]/g, "");
wsURL.searchParams.set("viewer", viewerID);
statusURL.searchParams.set("viewer", viewerID);
const fragment = new URLSearchParams(window.location.hash.slice(1));
const target = ${JSON.stringify(target)};
const username = fragment.get("username") || "";
@ -593,9 +566,6 @@ export function portalVNC(lease: LeaseRecord): Response {
let connected = false;
let stopped = false;
let remoteClipboardText = "";
let statusTimer;
let controllerLabel = "";
let isController = false;
function retryDelay() {
return Math.min(5000, 500 * 2 ** retryAttempt);
}
@ -630,38 +600,6 @@ export function portalVNC(lease: LeaseRecord): Response {
return undefined;
}
}
function applyCollaborationState(state) {
if (!state) return;
const role = state.viewerRole || "none";
const takeoverBtn = document.getElementById("vnc-takeover");
const previousControllerLabel = controllerLabel;
controllerLabel = state.controllerLabel || "";
const controlling = role === "controller";
const connectedViewer = role === "controller" || role === "observer";
isController = controlling;
if (rfb) {
rfb.viewOnly = !controlling;
}
if (takeoverBtn) {
takeoverBtn.hidden = !connectedViewer;
takeoverBtn.disabled = controlling || !connectedViewer;
takeoverBtn.dataset.role = controlling ? "controller" : "observer";
takeoverBtn.textContent = controlling ? "you control" : "take control";
takeoverBtn.title = controlling
? "You are controlling this session"
: controllerLabel
? "Currently observing; " + controllerLabel + " controls"
: "Currently observing";
}
if (!controlling && connectedViewer && previousControllerLabel && controllerLabel && previousControllerLabel !== controllerLabel) {
setStatus(controllerLabel + " took control", "warn");
}
}
async function refreshCollaborationState() {
const state = await bridgeState();
applyCollaborationState(state);
return state;
}
function scheduleRetry(label) {
if (stopped) return;
const delay = retryDelay();
@ -680,23 +618,19 @@ export function portalVNC(lease: LeaseRecord): Response {
scheduleRetry(state.message || "WebVNC daemon not running; run the bridge command below");
return;
}
if (state && state.availableViewerSlots === 0) {
scheduleRetry(state.message || "waiting for an available WebVNC observer slot");
if (state?.viewerConnected) {
scheduleRetry("WebVNC viewer already active; close stale WebVNC tabs or run reset");
return;
}
setStatus(retryAttempt ? "bridge connected; opening viewer" : "connecting");
rfb = new RFB(screen, wsURL.toString(), options);
rfb.showDotCursor = true;
rfb.scaleViewport = true;
rfb.resizeSession = false;
rfb.viewOnly = true;
rfb.viewOnly = false;
rfb.addEventListener("connect", () => {
connected = true;
retryAttempt = 0;
setStatus("connected", "ok");
void refreshCollaborationState();
window.clearInterval(statusTimer);
statusTimer = window.setInterval(refreshCollaborationState, 1500);
});
rfb.addEventListener("clipboard", (event) => {
remoteClipboardText = event.detail?.text || "";
@ -733,25 +667,8 @@ export function portalVNC(lease: LeaseRecord): Response {
window.addEventListener("beforeunload", () => {
stopped = true;
window.clearTimeout(retryTimer);
window.clearInterval(statusTimer);
rfb?.disconnect();
});
const takeoverBtn = document.getElementById("vnc-takeover");
takeoverBtn?.addEventListener("click", async () => {
try {
const response = await fetch(controlURL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ viewerID }),
});
const state = response.ok ? await response.json() : undefined;
if (!response.ok) throw new Error(state?.message || "takeover failed");
applyCollaborationState(state);
setStatus("you took control", "ok");
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error), "bad");
}
});
const reconnectBtn = document.getElementById("vnc-reconnect");
reconnectBtn?.addEventListener("click", () => {
window.clearTimeout(retryTimer);
@ -768,24 +685,6 @@ export function portalVNC(lease: LeaseRecord): Response {
document.documentElement.requestFullscreen?.().catch(() => {});
}
});
const shareBtn = document.getElementById("vnc-share");
const shareDialog = document.getElementById("vnc-share-dialog");
const shareFrame = document.getElementById("vnc-share-frame");
const shareCloseBtn = document.getElementById("vnc-share-close");
shareBtn?.addEventListener("click", () => {
if (shareFrame && !shareFrame.src) {
shareFrame.src = shareURL.toString();
}
if (shareDialog?.showModal) {
shareDialog.showModal();
} else {
window.location.href = ${JSON.stringify(sharePath)};
}
});
shareCloseBtn?.addEventListener("click", () => shareDialog?.close());
shareDialog?.addEventListener("click", (event) => {
if (event.target === shareDialog) shareDialog.close();
});
async function readClipboardText() {
if (navigator.clipboard?.readText) {
try {
@ -813,10 +712,6 @@ export function portalVNC(lease: LeaseRecord): Response {
setStatus("connect before paste", "warn");
return;
}
if (!isController) {
setStatus(controllerLabel ? controllerLabel + " is controlling" : "observer mode", "warn");
return;
}
const text = await readClipboardText();
if (!text) return;
try {
@ -1708,13 +1603,7 @@ function resultsSummary(run: RunRecord): string {
</dl>`;
}
function html(
title: string,
body: string,
status = 200,
nonce = "",
options: { frameAncestors?: string } = {},
): Response {
function html(title: string, body: string, status = 200, nonce = ""): Response {
const pageNonce = nonce || scriptNonce();
const scriptSource = `'self' 'nonce-${pageNonce}'`;
return new Response(
@ -1763,8 +1652,6 @@ function html(
.button { display:inline-flex; align-items:center; justify-content:center; min-height:28px; padding:0 10px; border-radius:7px; background:var(--accent); color:#001018; text-decoration:none; font-size:12px; font-weight:700; white-space:nowrap; }
.button.secondary { background:transparent; color:var(--fg); border:1px solid var(--line); font-weight:500; }
.button.secondary:hover { background:#1b1f24; border-color:#3a4046; }
.button.action { min-width:56px; border:1px solid color-mix(in srgb, var(--accent) 42%, var(--line)); background:color-mix(in srgb, var(--accent) 10%, transparent); color:#bae6fd; }
.button.action:hover { background:color-mix(in srgb, var(--accent) 16%, transparent); border-color:color-mix(in srgb, var(--accent) 58%, var(--line)); }
.button:disabled { opacity:0.45; cursor:not-allowed; }
.button.danger { border:1px solid color-mix(in srgb, var(--bad) 42%, var(--line)); background:color-mix(in srgb, var(--bad) 18%, transparent); color:#fecaca; cursor:pointer; }
.lease-link { display:block; min-width:0; text-decoration:none; overflow:hidden; text-overflow:ellipsis; }
@ -1856,12 +1743,8 @@ function html(
.table-search { width:100%; height:28px; padding:0 9px; border:1px solid var(--line); border-radius:7px; background:#0c0e10; color:var(--fg); font:inherit; font-size:12px; }
.table-search::placeholder { color:#6b7280; }
.table-search:focus { outline:2px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset:1px; border-color:color-mix(in srgb, var(--accent) 55%, var(--line)); }
.share-shell { height:auto; min-height:100dvh; align-content:start; grid-auto-rows:max-content; }
.share-shell-embedded { width:100%; min-height:0; padding:0; gap:8px; }
.share-shell .panel { align-self:start; }
.share-form { display:flex; align-items:center; gap:8px; padding:8px 10px; border-bottom:1px solid var(--line-soft); background:color-mix(in srgb, var(--panel-2) 44%, transparent); }
.share-form input,.share-form select { height:32px; min-width:0; padding:0 10px; border:1px solid var(--line); border-radius:7px; background:#0b0d0f; color:var(--fg); font:inherit; font-size:12px; }
.share-form input:focus,.share-form select:focus { outline:2px solid color-mix(in srgb, var(--accent) 34%, transparent); outline-offset:1px; border-color:color-mix(in srgb, var(--accent) 55%, var(--line)); }
.share-form { display:flex; align-items:center; gap:8px; padding:10px; border-bottom:1px solid var(--line-soft); }
.share-form input,.share-form select { height:30px; min-width:0; padding:0 9px; border:1px solid var(--line); border-radius:7px; background:#0c0e10; color:var(--fg); font:inherit; font-size:12px; }
.share-form input { flex:1; }
.table-filters { display:flex; align-items:center; gap:3px; min-width:0; overflow-x:auto; padding:2px; border:1px solid var(--line); border-radius:7px; background:#0c0e10; scrollbar-width:none; }
.table-filters::-webkit-scrollbar { display:none; }
@ -1910,9 +1793,6 @@ function html(
.status-pill[data-tone="ok"] { color:var(--ok); border-color:color-mix(in srgb, var(--ok) 35%, var(--line)); }
.status-pill[data-tone="warn"] { color:var(--warn); border-color:color-mix(in srgb, var(--warn) 35%, var(--line)); }
.status-pill[data-tone="bad"] { color:var(--bad); border-color:color-mix(in srgb, var(--bad) 45%, var(--line)); }
.vnc-control { min-width:112px; transition:background 0.15s,border-color 0.15s,color 0.15s; }
.vnc-control[data-role="controller"]:disabled { opacity:1; cursor:default; color:var(--fg); border-color:var(--line); background:var(--panel-2); }
.vnc-control[data-role="observer"] { color:#bae6fd; border-color:color-mix(in srgb, var(--accent) 38%, var(--line)); background:color-mix(in srgb, var(--accent) 8%, transparent); }
.icon-btn { display:inline-flex; align-items:center; justify-content:center; width:32px; height:32px; padding:0; border-radius:8px; background:transparent; color:var(--fg); border:1px solid var(--line); cursor:pointer; transition:background 0.15s, border-color 0.15s, color 0.15s; }
.icon-btn:hover { background:#1b1f24; border-color:#3a4046; }
.icon-btn:active { background:#22272d; }
@ -1929,12 +1809,6 @@ function html(
.vnc-bridge { display:flex; align-items:center; gap:10px; padding:6px 10px; border:1px solid var(--line); border-radius:8px; background:var(--panel); }
.vnc-bridge-label { font-size:10px; text-transform:uppercase; letter-spacing:0.08em; color:var(--muted); flex-shrink:0; padding-left:4px; }
.vnc-bridge-cmd { display:block; flex:1; min-width:0; padding:6px 10px; border:none; border-radius:5px; background:transparent; color:#d1fae5; font-family:var(--mono); font-size:13px; overflow-x:auto; white-space:nowrap; }
.vnc-share-dialog { width:min(760px, calc(100vw - 36px)); max-height:min(640px, calc(100dvh - 48px)); padding:0; border:1px solid var(--line); border-radius:10px; background:var(--panel); color:var(--fg); box-shadow:0 24px 90px rgba(0,0,0,0.58); overflow:hidden; }
.vnc-share-dialog::backdrop { background:rgba(0,0,0,0.58); backdrop-filter:blur(2px); }
.vnc-share-head { display:flex; align-items:center; justify-content:space-between; gap:12px; min-height:42px; padding:8px 10px 8px 14px; border-bottom:1px solid var(--line); background:var(--panel-2); }
.vnc-share-head strong { display:block; font-size:13px; }
.vnc-share-head small { display:block; margin-top:1px; color:var(--muted); font-family:var(--mono); font-size:11px; }
.vnc-share-frame { display:block; width:100%; height:min(540px, calc(100dvh - 104px)); border:0; background:var(--bg); }
.commands { padding:12px; display:grid; gap:8px; }
.command-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:end; }
.command-row > div { min-width:0; overflow:hidden; }
@ -1973,8 +1847,6 @@ function html(
.vnc-meta p .vnc-id { display:none; }
.portal-actions { gap:6px; }
.portal-actions .button { min-height:30px; padding:0 10px; }
.vnc-share-dialog { width:calc(100vw - 20px); }
.vnc-share-frame { height:calc(100dvh - 104px); }
.vnc-bridge-label { display:none; }
}
</style>
@ -1988,8 +1860,7 @@ function html(
"default-src 'none'",
"base-uri 'none'",
"connect-src 'self' ws: wss:",
"frame-src 'self'",
`frame-ancestors ${options.frameAncestors ?? "'none'"}`,
"frame-ancestors 'none'",
"img-src 'self' data: blob:",
`script-src ${scriptSource}`,
"style-src 'unsafe-inline'",

View File

@ -16,17 +16,6 @@ export interface Env {
CRABBOX_CAPACITY_AVAILABILITY_ZONES?: string;
CRABBOX_CAPACITY_HINTS?: string;
CRABBOX_CAPACITY_LARGE_CLASSES?: string;
AZURE_TENANT_ID?: string;
AZURE_CLIENT_ID?: string;
AZURE_CLIENT_SECRET?: string;
AZURE_SUBSCRIPTION_ID?: string;
CRABBOX_AZURE_LOCATION?: string;
CRABBOX_AZURE_RESOURCE_GROUP?: string;
CRABBOX_AZURE_IMAGE?: string;
CRABBOX_AZURE_VNET?: string;
CRABBOX_AZURE_SUBNET?: string;
CRABBOX_AZURE_NSG?: string;
CRABBOX_AZURE_SSH_CIDRS?: string;
CRABBOX_SHARED_TOKEN?: string;
CRABBOX_ADMIN_TOKEN?: string;
CRABBOX_SESSION_SECRET?: string;
@ -53,17 +42,6 @@ export interface Env {
CRABBOX_TAILSCALE_CLIENT_SECRET?: string;
CRABBOX_TAILSCALE_TAILNET?: string;
CRABBOX_TAILSCALE_TAGS?: string;
CRABBOX_ARTIFACTS_BACKEND?: string;
CRABBOX_ARTIFACTS_BUCKET?: string;
CRABBOX_ARTIFACTS_PREFIX?: string;
CRABBOX_ARTIFACTS_BASE_URL?: string;
CRABBOX_ARTIFACTS_REGION?: string;
CRABBOX_ARTIFACTS_ENDPOINT_URL?: string;
CRABBOX_ARTIFACTS_ACCESS_KEY_ID?: string;
CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY?: string;
CRABBOX_ARTIFACTS_SESSION_TOKEN?: string;
CRABBOX_ARTIFACTS_UPLOAD_EXPIRES_SECONDS?: string;
CRABBOX_ARTIFACTS_URL_EXPIRES_SECONDS?: string;
}
export interface LeaseRequest {
@ -96,8 +74,6 @@ export interface LeaseRequest {
awsRootGB?: number;
awsSSHCIDRs?: string[];
awsMacHostID?: string;
azureLocation?: string;
azureImage?: string;
capacity?: {
market?: "spot" | "on-demand";
strategy?: "most-available" | "price-capacity-optimized" | "capacity-optimized" | "sequential";
@ -117,7 +93,7 @@ export interface LeaseRequest {
sshPublicKey?: string;
}
export type Provider = "hetzner" | "aws" | "azure";
export type Provider = "hetzner" | "aws";
export type TargetOS = "linux" | "macos" | "windows";
export type WindowsMode = "normal" | "wsl2";

View File

@ -1,375 +0,0 @@
import { describe, expect, it } from "vitest";
import {
AzureClient,
azureLabelsFromTags,
azureSupportsEphemeralOS,
azureTagsFromLabels,
isRetryableDeleteError,
isRetryableProvisioningError,
preserveNonCrabboxRules,
} from "../src/azure";
import type { LeaseConfig } from "../src/config";
import type { Env } from "../src/types";
const baseEnv: Env = {
FLEET: {} as DurableObjectNamespace,
HETZNER_TOKEN: "",
AZURE_TENANT_ID: "tenant",
AZURE_CLIENT_ID: "client",
AZURE_CLIENT_SECRET: "secret",
AZURE_SUBSCRIPTION_ID: "sub",
};
describe("azure provider", () => {
it("classifies Azure capacity and quota errors as retryable", () => {
expect(isRetryableProvisioningError("SkuNotAvailable: D8s_v5 not available")).toBe(true);
expect(isRetryableProvisioningError("QuotaExceeded for cores")).toBe(true);
expect(isRetryableProvisioningError("AllocationFailed")).toBe(true);
expect(isRetryableProvisioningError("OverconstrainedAllocationRequest")).toBe(true);
expect(isRetryableProvisioningError("ResourceNotFound")).toBe(false);
expect(isRetryableProvisioningError("")).toBe(false);
});
it("classifies transient Azure delete dependency errors as retryable", () => {
expect(isRetryableDeleteError("NicReservedForAnotherVm retry after 180 seconds")).toBe(true);
expect(isRetryableDeleteError("PublicIPAddressCannotBeDeleted because it is in use")).toBe(
true,
);
expect(isRetryableDeleteError("AnotherOperationInProgress")).toBe(true);
expect(isRetryableDeleteError("plain validation error")).toBe(false);
});
it("maps Azure-reserved Windows tag prefixes without changing internal labels", () => {
const tags = azureTagsFromLabels({ crabbox: "true", windows_mode: "normal" });
expect(tags.windows_mode).toBeUndefined();
expect(tags.crabbox_windows_mode).toBe("normal");
expect(azureLabelsFromTags(tags).windows_mode).toBe("normal");
});
it("continues deleting per-lease resources after a delete failure", async () => {
const client = new AzureClient(baseEnv);
const deletes: string[] = [];
const fakeFetch = ((input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url = typeof input === "string" ? input : input.toString();
if (url.includes("login.microsoftonline.com")) {
return Promise.resolve(
new Response(JSON.stringify({ access_token: "tkn", expires_in: 3600 }), { status: 200 }),
);
}
if (init?.method === "DELETE") {
deletes.push(url);
if (url.includes("/virtualMachines/crabbox-blue-lobster?")) {
return Promise.resolve(new Response("busy", { status: 409 }));
}
if (url.includes("/networkInterfaces/crabbox-blue-lobster-nic?")) {
return Promise.resolve(new Response("missing", { status: 404 }));
}
return Promise.resolve(new Response(null, { status: 204 }));
}
return Promise.resolve(new Response("{}", { status: 200 }));
}) as typeof fetch;
client.fetcher = fakeFetch;
await expect(client.deleteServer("crabbox-blue-lobster")).rejects.toThrow(/delete vm/);
expect(deletes.some((url) => url.includes("/virtualMachines/crabbox-blue-lobster?"))).toBe(
true,
);
expect(
deletes.some((url) => url.includes("/networkInterfaces/crabbox-blue-lobster-nic?")),
).toBe(true);
expect(
deletes.some((url) => url.includes("/publicIPAddresses/crabbox-blue-lobster-pip?")),
).toBe(true);
expect(deletes.some((url) => url.includes("/disks/crabbox-blue-lobster-osdisk?"))).toBe(true);
});
it("treats successful async Azure deletes as complete without refetching deleted resources", async () => {
const client = new AzureClient(baseEnv);
const deletes: string[] = [];
const fakeFetch = ((input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url = typeof input === "string" ? input : input.toString();
if (url.includes("login.microsoftonline.com")) {
return Promise.resolve(
new Response(JSON.stringify({ access_token: "tkn", expires_in: 3600 }), { status: 200 }),
);
}
if (init?.method === "DELETE") {
deletes.push(url);
return Promise.resolve(new Response(null, { status: 202 }));
}
if (
url.includes("/virtualMachines/") ||
url.includes("/networkInterfaces/") ||
url.includes("/publicIPAddresses/") ||
url.includes("/disks/")
) {
return Promise.resolve(new Response("deleted", { status: 404 }));
}
return Promise.resolve(new Response("{}", { status: 200 }));
}) as typeof fetch;
client.fetcher = fakeFetch;
await expect(client.deleteServer("crabbox-blue-lobster")).resolves.toBeUndefined();
expect(deletes).toHaveLength(4);
});
it("requires the four Azure SP secrets", () => {
expect(() => new AzureClient({ ...baseEnv, AZURE_TENANT_ID: undefined })).toThrow(
/AZURE_TENANT_ID/,
);
expect(() => new AzureClient({ ...baseEnv, AZURE_CLIENT_ID: undefined })).toThrow(
/AZURE_CLIENT_ID/,
);
expect(() => new AzureClient({ ...baseEnv, AZURE_CLIENT_SECRET: undefined })).toThrow(
/AZURE_CLIENT_SECRET/,
);
expect(() => new AzureClient({ ...baseEnv, AZURE_SUBSCRIPTION_ID: undefined })).toThrow(
/AZURE_SUBSCRIPTION_ID/,
);
});
it("applies CRABBOX_AZURE_* defaults", () => {
const client = new AzureClient(baseEnv);
expect(client.resourceGroup).toBe("crabbox-leases");
expect(client.vnet).toBe("crabbox-vnet");
expect(client.subnet).toBe("crabbox-subnet");
expect(client.nsg).toBe("crabbox-nsg");
expect(client.image).toContain("Canonical");
expect(client.sshCIDRs).toEqual(["0.0.0.0/0"]);
expect(client.defaultLocation).toBe("eastus");
});
it("creates Windows VMs with Windows OS profile and bootstrap extension", async () => {
const client = new AzureClient(baseEnv);
const bodies: unknown[] = [];
const fakeFetch = ((input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url = typeof input === "string" ? input : input.toString();
if (url.includes("login.microsoftonline.com")) {
return Promise.resolve(
new Response(JSON.stringify({ access_token: "tkn", expires_in: 3600 }), { status: 200 }),
);
}
if (init?.body) bodies.push(JSON.parse(String(init.body)));
if (url.includes("/resourceGroups/crabbox-leases?")) {
return Promise.resolve(
new Response(JSON.stringify({ tags: { managed_by: "crabbox" } }), { status: 200 }),
);
}
if (url.includes("/virtualNetworks/crabbox-vnet?")) {
return Promise.resolve(
new Response(JSON.stringify({ tags: { managed_by: "crabbox" } }), { status: 200 }),
);
}
if (url.includes("/networkSecurityGroups/crabbox-nsg?") && init?.method === "GET") {
return Promise.resolve(
new Response(
JSON.stringify({ tags: { managed_by: "crabbox" }, properties: { securityRules: [] } }),
{ status: 200 },
),
);
}
if (url.includes("/providers/Microsoft.Compute/skus?")) {
return Promise.resolve(
new Response(
JSON.stringify({
value: [
{
name: "Standard_D2ads_v6",
resourceType: "virtualMachines",
capabilities: [{ name: "EphemeralOSDiskSupported", value: "True" }],
},
],
}),
{ status: 200 },
),
);
}
if (url.includes("/publicIPAddresses/") && init?.method === "GET") {
return Promise.resolve(
new Response(JSON.stringify({ properties: { ipAddress: "192.0.2.10" } }), {
status: 200,
}),
);
}
if (url.includes("/virtualMachines/") && init?.method === "GET") {
return Promise.resolve(
new Response(
JSON.stringify({
name: "crabbox-blue-lobster",
tags: { crabbox: "true" },
properties: {
provisioningState: "Succeeded",
hardwareProfile: { vmSize: "Standard_D2ads_v6" },
},
}),
{ status: 200 },
),
);
}
return Promise.resolve(new Response("{}", { status: 200 }));
}) as typeof fetch;
client.fetcher = fakeFetch;
const config: LeaseConfig = {
provider: "azure",
target: "windows",
windowsMode: "normal",
desktop: false,
browser: false,
code: false,
tailscale: false,
tailscaleTags: ["tag:crabbox"],
tailscaleHostname: "",
tailscaleAuthKey: "",
tailscaleExitNode: "",
tailscaleExitNodeAllowLanAccess: false,
profile: "default",
class: "standard",
serverType: "Standard_D2ads_v6",
serverTypeExplicit: true,
location: "fsn1",
image: "ubuntu-24.04",
awsRegion: "eu-west-1",
awsAMI: "",
awsSGID: "",
awsSubnetID: "",
awsProfile: "",
awsRootGB: 400,
awsSSHCIDRs: [],
awsMacHostID: "",
azureLocation: "eastus",
azureImage: "",
capacityMarket: "spot",
capacityStrategy: "most-available",
capacityFallback: "on-demand-after-120s",
capacityRegions: [],
capacityAvailabilityZones: [],
capacityHints: true,
sshUser: "crabbox",
sshPort: "2222",
sshFallbackPorts: ["22"],
providerKey: "crabbox-cbx",
workRoot: "C:\\crabbox",
ttlSeconds: 5400,
idleTimeoutSeconds: 1800,
keep: false,
sshPublicKey: "ssh-rsa test",
};
await client.createServerWithFallback(config, "cbx_123456789abc", "blue-lobster", "owner");
const vmBody = bodies.find(
(body): body is { properties: { osProfile: Record<string, unknown> } } =>
typeof body === "object" &&
body !== null &&
"properties" in body &&
JSON.stringify(body).includes("windowsConfiguration"),
);
expect(vmBody?.properties.osProfile).toMatchObject({
computerName: "cbxcbx123456789",
adminUsername: "crabadmin",
allowExtensionOperations: true,
windowsConfiguration: { provisionVMAgent: true, enableAutomaticUpdates: false },
});
expect(String(vmBody?.properties.osProfile.customData ?? "")).toBeTruthy();
expect(JSON.stringify(vmBody)).toContain("MicrosoftWindowsServer");
const extensionBody = bodies.find((body) =>
JSON.stringify(body).includes("CustomScriptExtension"),
);
expect(JSON.stringify(extensionBody)).toContain("AzureData\\\\CustomData.bin");
});
it("honors CRABBOX_AZURE_* overrides", () => {
const client = new AzureClient({
...baseEnv,
CRABBOX_AZURE_RESOURCE_GROUP: "custom-rg",
CRABBOX_AZURE_LOCATION: "westus2",
CRABBOX_AZURE_SSH_CIDRS: "10.0.0.0/8, 192.168.0.0/16",
});
expect(client.resourceGroup).toBe("custom-rg");
expect(client.defaultLocation).toBe("westus2");
expect(client.sshCIDRs).toEqual(["10.0.0.0/8", "192.168.0.0/16"]);
});
it("caches the client_credentials token across calls", async () => {
const client = new AzureClient(baseEnv);
let tokenMints = 0;
const fakeFetch = ((input: RequestInfo | URL, _init?: RequestInit): Promise<Response> => {
const url = typeof input === "string" ? input : input.toString();
if (url.includes("login.microsoftonline.com")) {
tokenMints += 1;
return Promise.resolve(
new Response(JSON.stringify({ access_token: "tkn", expires_in: 3600 }), {
status: 200,
}),
);
}
return Promise.resolve(new Response(JSON.stringify({ value: [] }), { status: 200 }));
}) as typeof fetch;
client.fetcher = fakeFetch;
await client.listCrabboxServers();
await client.listCrabboxServers();
expect(tokenMints).toBe(1);
});
it("drops crabbox-ssh-* rules and preserves operator rules", () => {
const kept = preserveNonCrabboxRules([
{ name: "crabbox-ssh-2222-0", properties: { destinationPortRange: "2222" } },
{ name: "operator-https", properties: { destinationPortRange: "443" } },
]);
expect(kept).toEqual([{ name: "operator-https", properties: { destinationPortRange: "443" } }]);
});
it("uses a conservative ephemeral OS disk fallback", () => {
expect(azureSupportsEphemeralOS("Standard_D2as_v5")).toBe(false);
expect(azureSupportsEphemeralOS("Standard_D2s_v5")).toBe(false);
expect(azureSupportsEphemeralOS("Standard_D2ads_v5")).toBe(true);
expect(azureSupportsEphemeralOS("Standard_D2ads_v6")).toBe(true);
expect(azureSupportsEphemeralOS("Standard_F2s_v2")).toBe(true);
expect(azureSupportsEphemeralOS("Standard_D48ads_v6")).toBe(true);
expect(azureSupportsEphemeralOS("Standard_F48s_v2")).toBe(true);
});
it("filters listCrabboxServers by crabbox=true tag", async () => {
const client = new AzureClient(baseEnv);
const fakeFetch = ((input: RequestInfo | URL, _init?: RequestInit): Promise<Response> => {
const url = typeof input === "string" ? input : input.toString();
if (url.includes("login.microsoftonline.com")) {
return Promise.resolve(
new Response(JSON.stringify({ access_token: "tkn", expires_in: 3600 }), { status: 200 }),
);
}
if (url.includes("/virtualMachines?")) {
return Promise.resolve(
new Response(
JSON.stringify({
value: [
{
name: "kept",
tags: { crabbox: "true" },
properties: { provisioningState: "Succeeded" },
},
{
name: "stranger",
tags: { other: "thing" },
properties: { provisioningState: "Succeeded" },
},
],
}),
{ status: 200 },
),
);
}
if (url.includes("/publicIPAddresses/kept-pip?")) {
return Promise.resolve(
new Response(JSON.stringify({ properties: { ipAddress: "1.2.3.4" } }), { status: 200 }),
);
}
return Promise.resolve(new Response("{}", { status: 200 }));
}) as typeof fetch;
client.fetcher = fakeFetch;
const machines = await client.listCrabboxServers();
expect(machines).toHaveLength(1);
expect(machines[0]?.name).toBe("kept");
expect(machines[0]?.host).toBe("1.2.3.4");
});
});

View File

@ -1,11 +1,6 @@
import { describe, expect, it } from "vitest";
import {
awsUserData,
azureWindowsBootstrapPowerShell,
cloudInit,
windowsBootstrapPowerShell,
} from "../src/bootstrap";
import { awsUserData, cloudInit, windowsBootstrapPowerShell } from "../src/bootstrap";
import type { LeaseConfig } from "../src/config";
const config: LeaseConfig = {
@ -62,13 +57,8 @@ describe("cloud-init bootstrap", () => {
expect(got).toContain("test -f /var/lib/crabbox/bootstrapped");
expect(got).toContain("test -w /work/crabbox");
expect(got).toContain(" Port 2222\n Port 22");
expect(got).toContain("systemctl enable ssh || true");
expect(got).toContain(
"timeout 30s systemctl restart ssh || timeout 30s systemctl restart ssh.socket || true",
);
expect(got).toContain("touch /var/lib/crabbox/bootstrapped");
expect(got).not.toContain("\npackages:\n");
expect(got).not.toContain("systemctl enable --now ssh");
expect(got).not.toContain("go version");
expect(got).not.toContain("golang-go");
expect(got).not.toContain("go.dev/dl/go");
@ -201,7 +191,6 @@ describe("cloud-init bootstrap", () => {
expect(got).toContain("OpenSSH-Win64.zip");
expect(got).toContain("install-sshd.ps1");
expect(got).toContain("administrators_authorized_keys");
expect(got).toContain("Match Group administrators");
expect(got).toContain("$sshPorts = @('2222', '22')");
expect(got).toContain("sshd_config");
expect(got).toContain("Port $port");
@ -222,27 +211,6 @@ describe("cloud-init bootstrap", () => {
expect(got).toContain("Restart-Computer -Force");
});
it("builds Azure Windows extension bootstrap without restart", () => {
const input = {
...config,
provider: "azure",
target: "windows",
workRoot: "C:\\crabbox",
sshPublicKey: "ssh-rsa test",
} as const;
const got = azureWindowsBootstrapPowerShell(input);
expect(got).toContain("OpenSSH-Win64.zip");
expect(got).toContain("Git-2.52.0-64-bit.exe");
expect(got).toContain("administrators_authorized_keys");
expect(got).toContain("Match Group administrators");
expect(got).toContain("$sshPorts = @('2222', '22')");
expect(got).toContain("PasswordAuthentication no");
expect(got).toContain("Restart-Service sshd -Force");
expect(got).toContain("Set-Content -NoNewline -Encoding ASCII -Path $setupCompletePath");
expect(got).not.toContain("Restart-Computer");
expect(got).not.toContain("tightvnc");
});
it("builds macOS user data for managed screen sharing", () => {
const got = awsUserData({
...config,

View File

@ -5,9 +5,6 @@ import { describe, expect, it } from "vitest";
import {
awsInstanceTypeCandidatesForClass,
awsInstanceTypeCandidatesForTargetClass,
azureWindowsVMSizeCandidatesForClass,
azureVMSizeCandidatesForClass,
azureVMSizeCandidatesForTargetClass,
leaseConfig,
serverTypeCandidatesForClass,
serverTypeForClass,
@ -47,29 +44,6 @@ describe("machine class config", () => {
]);
});
it("maps known classes to preferred Azure candidates", () => {
expect(serverTypeForProviderClass("azure", "standard")).toBe("Standard_D32ads_v6");
expect(azureVMSizeCandidatesForClass("standard")).toEqual([
"Standard_D32ads_v6",
"Standard_D32ds_v6",
"Standard_F32s_v2",
"Standard_D32ads_v5",
"Standard_D32ds_v5",
"Standard_D16ads_v6",
"Standard_D16ds_v6",
"Standard_F16s_v2",
]);
expect(azureVMSizeCandidatesForTargetClass("linux", "standard")).toEqual(
azureVMSizeCandidatesForClass("standard"),
);
expect(azureVMSizeCandidatesForTargetClass("windows", "standard")).toEqual(
azureWindowsVMSizeCandidatesForClass("standard"),
);
expect(azureVMSizeCandidatesForTargetClass("windows", "standard", "wsl2")).toEqual([
"standard",
]);
});
it("maps AWS Windows and macOS classes to compatible families", () => {
expect(awsInstanceTypeCandidatesForTargetClass("windows", "standard")).toEqual([
"m7i.large",
@ -81,18 +55,11 @@ describe("machine class config", () => {
it("matches the Go CLI machine class tables", () => {
const go = readFileSync(new URL("../../internal/cli/config.go", import.meta.url), "utf8");
const goAzure = readFileSync(new URL("../../internal/cli/azure.go", import.meta.url), "utf8");
const classes = ["standard", "fast", "large", "beast"];
const hetzner = parseGoStringArrayCases(goFunctionBody(go, "serverTypeCandidatesForClass"));
const awsLinux = parseGoStringArrayCases(
goFunctionBody(go, "awsInstanceTypeCandidatesForClass"),
);
const azureLinux = parseGoStringArrayCases(
goFunctionBody(goAzure, "azureVMSizeCandidatesForClass"),
);
const azureWindows = parseGoStringArrayCases(
goFunctionBody(goAzure, "azureWindowsVMSizeCandidatesForClass"),
);
const awsTarget = goFunctionBody(go, "awsInstanceTypeCandidatesForTargetModeClass");
const awsWSL2 = parseGoStringArrayCases(
goSwitchAfter(awsTarget, "if windowsMode == windowsModeWSL2"),
@ -102,9 +69,6 @@ describe("machine class config", () => {
for (const name of classes) {
expect(serverTypeCandidatesForClass(name)).toEqual(hetzner[name]);
expect(awsInstanceTypeCandidatesForClass(name)).toEqual(awsLinux[name]);
expect(azureVMSizeCandidatesForClass(name)).toEqual(azureLinux[name]);
expect(azureWindowsVMSizeCandidatesForClass(name)).toEqual(azureWindows[name]);
expect(azureVMSizeCandidatesForTargetClass("windows", name)).toEqual(azureWindows[name]);
expect(awsInstanceTypeCandidatesForTargetClass("windows", name)).toEqual(awsWindows[name]);
expect(awsInstanceTypeCandidatesForTargetClass("windows", name, "wsl2")).toEqual(
awsWSL2[name],
@ -239,48 +203,6 @@ describe("lease config", () => {
expect(config.awsRegion).toBe("eu-west-1");
});
it("uses Azure defaults when requested", () => {
const config = leaseConfig({
provider: "azure",
azureLocation: "eastus",
azureImage: "Canonical:offer:sku:latest",
sshPublicKey: "ssh-ed25519 test",
});
expect(config.serverType).toBe("Standard_D192ds_v6");
expect(config.azureLocation).toBe("eastus");
expect(config.azureImage).toBe("Canonical:offer:sku:latest");
});
it("allows Azure native Windows leases", () => {
const config = leaseConfig({
provider: "azure",
target: "windows",
sshPublicKey: "ssh-rsa test",
});
expect(config.serverType).toBe("Standard_D16ads_v6");
expect(config.workRoot).toBe("C:\\crabbox");
expect(config.windowsMode).toBe("normal");
expect(config.sshUser).toBe("crabbox");
expect(() =>
leaseConfig({
provider: "azure",
target: "windows",
windowsMode: "wsl2",
sshPublicKey: "ssh-rsa test",
}),
).toThrow("native Windows only");
for (const capability of ["desktop", "browser", "code", "tailscale"] as const) {
expect(() =>
leaseConfig({
provider: "azure",
target: "windows",
[capability]: true,
sshPublicKey: "ssh-rsa test",
}),
).toThrow("SSH, sync, and run");
}
});
it("records linux target defaults and rejects unsupported brokered non-linux targets", () => {
const config = leaseConfig({ sshPublicKey: "ssh-ed25519 test" });
expect(config.target).toBe("linux");

View File

@ -61,6 +61,40 @@ class MemoryStorage {
}
}
class FakeWebSocket {
readyState = WebSocket.OPEN;
private attachment: unknown;
private readonly sent: string[] = [];
constructor(attachment?: unknown) {
this.attachment = attachment;
}
send(data: string): void {
this.sent.push(data);
}
close(): void {
this.readyState = WebSocket.CLOSED;
}
accept(): void {}
addEventListener(): void {}
serializeAttachment(attachment: unknown): void {
this.attachment = attachment;
}
deserializeAttachment(): unknown {
return this.attachment;
}
sentJSON(): unknown[] {
return this.sent.map((value) => JSON.parse(value) as unknown);
}
}
describe("fleet lease identity and idle", () => {
it("creates leases through the public route with slug and idle metadata", async () => {
const storage = new MemoryStorage();
@ -192,24 +226,7 @@ describe("fleet lease identity and idle", () => {
request("GET", "/portal/leases/blue-lobster/share", { headers: friendHeaders }),
);
expect(friendSharePage.status).toBe(200);
const friendShareBody = await friendSharePage.text();
expect(friendShareBody).toContain("share blue-lobster");
expect(friendShareBody).toContain("share-shell");
expect(friendShareBody).toContain("back to lease");
expect(friendShareBody).toContain('class="button action" type="submit">save</button>');
expect(friendShareBody).toContain('class="button action" type="submit">add</button>');
const embeddedSharePage = await fleet.fetch(
request("GET", "/portal/leases/blue-lobster/share?embed=1", { headers: friendHeaders }),
);
expect(embeddedSharePage.status).toBe(200);
expect(embeddedSharePage.headers.get("content-security-policy")).toContain(
"frame-ancestors 'self'",
);
const embeddedShareBody = await embeddedSharePage.text();
expect(embeddedShareBody).toContain("share-shell-embedded");
expect(embeddedShareBody).toContain("/portal/leases/cbx_000000000001/share?embed=1");
expect(embeddedShareBody).not.toContain("back to lease");
expect(await friendSharePage.text()).toContain("share blue-lobster");
const stranger = await fleet.fetch(
request("GET", "/v1/leases/blue-lobster", { headers: strangerHeaders }),
@ -1674,17 +1691,12 @@ describe("fleet lease identity and idle", () => {
expect(pageBody).toContain("function scheduleRetry");
expect(pageBody).toContain("/portal/leases/cbx_000000000001/vnc/status");
expect(pageBody).toContain("/portal/leases/cbx_000000000001/share");
expect(pageBody).toContain("/portal/leases/cbx_000000000001/share?embed=1");
expect(pageBody).toContain("vnc-share-dialog");
expect(pageBody).toContain("vnc-share-frame");
expect(pageBody).toContain('document.getElementById("vnc-share")');
expect(pageBody).toContain("vnc-copy-remote");
expect(pageBody).toContain("vnc-paste");
expect(pageBody).toContain("vnc-copy");
expect(pageBody).toContain('addEventListener("clipboard"');
expect(pageBody).toContain("remote clipboard ready");
expect(pageBody).toContain("clipboardPasteFrom");
expect(pageBody).toContain("rfb.showDotCursor = true");
expect(pageBody).toContain('target === "macos"');
expect(pageBody).toContain("MetaLeft");
expect(pageBody).toContain("ControlLeft");
@ -1692,15 +1704,9 @@ describe("fleet lease identity and idle", () => {
expect(pageBody).toContain('data-provider="hetzner"');
expect(pageBody).toContain('data-target="linux"');
expect(pageBody).toContain("WebVNC daemon not running; run the bridge command below");
expect(pageBody).toContain("waiting for an available WebVNC observer slot");
expect(pageBody).toContain("/portal/leases/cbx_000000000001/vnc/control");
expect(pageBody).toContain("vnc-takeover");
expect(pageBody).toContain("vnc-control");
expect(pageBody).toContain("take control");
expect(pageBody).toContain("you control");
expect(pageBody).not.toContain("vnc-role");
expect(pageBody).not.toContain("status-pill vnc-role");
expect(pageBody).toContain("rfb.viewOnly = !controlling");
expect(pageBody).toContain(
"WebVNC viewer already active; close stale WebVNC tabs or run reset",
);
expect(pageBody).toContain('fragment.get("username")');
expect(pageBody).toContain('types.includes("username")');
expect(pageBody).not.toContain("cdn.jsdelivr.net");
@ -1811,30 +1817,20 @@ describe("fleet lease identity and idle", () => {
it("resets the WebVNC bridge when the viewer goes away", () => {
const buffers = new Map<string, WebVNCBuffer>();
buffers.set("cbx_000000000001", { chunks: ["RFB 003.008\n"], bytes: 12 });
buffers.set("cbx_000000000001:agent_a", { chunks: ["RFB 003.008\n"], bytes: 12 });
const closed: Array<{ code: number; reason: string }> = [];
const agents = new Map<string, Map<string, WebSocket>>();
agents.set(
"cbx_000000000001",
new Map([
[
"agent_a",
{
readyState: WebSocket.OPEN,
close(code: number, reason: string) {
closed.push({ code, reason });
},
} as WebSocket,
],
]),
);
const agents = new Map<string, WebSocket>();
agents.set("cbx_000000000001", {
readyState: WebSocket.OPEN,
close(code: number, reason: string) {
closed.push({ code, reason });
},
} as WebSocket);
resetWebVNCBridge(agents, buffers, "cbx_000000000001", 1011, "WebVNC viewer disconnected");
expect(closed).toEqual([{ code: 1011, reason: "WebVNC viewer disconnected" }]);
expect(agents.has("cbx_000000000001")).toBe(false);
expect(buffers.has("cbx_000000000001")).toBe(false);
expect(buffers.has("cbx_000000000001:agent_a")).toBe(false);
});
it("keeps pool inventory admin-only", async () => {
@ -1905,132 +1901,6 @@ describe("fleet lease identity and idle", () => {
expect.objectContaining({ id: "ami-000000000001", state: "available" }),
);
});
it("mints broker-owned artifact upload URLs without exposing secrets", async () => {
const fleet = testFleet(
new MemoryStorage(),
{},
{
CRABBOX_ARTIFACTS_BACKEND: "r2",
CRABBOX_ARTIFACTS_BUCKET: "qa-artifacts",
CRABBOX_ARTIFACTS_PREFIX: "qa",
CRABBOX_ARTIFACTS_BASE_URL: "https://artifacts.example.com",
CRABBOX_ARTIFACTS_REGION: "auto",
CRABBOX_ARTIFACTS_ENDPOINT_URL: "https://account.r2.cloudflarestorage.com",
CRABBOX_ARTIFACTS_ACCESS_KEY_ID: "access-key",
CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY: "super-secret",
},
);
const response = await fleet.fetch(
request("POST", "/v1/artifacts/uploads", {
headers: { "x-crabbox-owner": "peter@example.com" },
body: {
prefix: "pr-42",
files: [
{
name: "screenshots/after.png",
size: 123,
contentType: "image/png",
sha256: await sha256HexForTest("after"),
},
],
},
}),
);
expect(response.status).toBe(201);
const body = (await response.json()) as {
backend: string;
bucket: string;
prefix: string;
files: Array<{
name: string;
key: string;
url: string;
upload: { url: string; headers: Record<string, string> };
}>;
};
expect(body.backend).toBe("r2");
expect(body.bucket).toBe("qa-artifacts");
expect(body.prefix).toBe("qa/peter@example.com/pr-42");
expect(body.files[0].key).toBe("qa/peter@example.com/pr-42/screenshots/after.png");
expect(body.files[0].url).toBe(
"https://artifacts.example.com/qa/peter%40example.com/pr-42/screenshots/after.png",
);
expect(body.files[0].upload.headers["content-length"]).toBe("123");
expect(body.files[0].upload.headers["content-type"]).toBe("image/png");
expect(body.files[0].upload.url).toContain("X-Amz-Signature=");
expect(new URL(body.files[0].upload.url).searchParams.get("X-Amz-SignedHeaders")).toContain(
"content-length",
);
expect(JSON.stringify(body)).not.toContain("super-secret");
});
it("reports artifact broker setup errors without provider-specific local credentials", async () => {
const fleet = testFleet();
const response = await fleet.fetch(
request("POST", "/v1/artifacts/uploads", {
body: { files: [{ name: "screenshot.png", size: 1 }] },
}),
);
const body = (await response.json()) as { error: string; message: string };
expect(response.status).toBe(400);
expect(body.error).toBe("artifact_upload_unavailable");
expect(body.message).toContain("artifact broker is not configured");
});
it("requires an R2 endpoint before minting artifact upload URLs", async () => {
const fleet = testFleet(
new MemoryStorage(),
{},
{
CRABBOX_ARTIFACTS_BACKEND: "r2",
CRABBOX_ARTIFACTS_BUCKET: "qa-artifacts",
CRABBOX_ARTIFACTS_ACCESS_KEY_ID: "access-key",
CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY: "super-secret",
},
);
const response = await fleet.fetch(
request("POST", "/v1/artifacts/uploads", {
body: { files: [{ name: "screenshot.png", size: 1 }] },
}),
);
const body = (await response.json()) as { error: string; message: string };
expect(response.status).toBe(400);
expect(body.error).toBe("artifact_upload_unavailable");
expect(body.message).toContain("CRABBOX_ARTIFACTS_ENDPOINT_URL");
});
it("caps aggregate artifact upload bytes before minting grants", async () => {
const fleet = testFleet(
new MemoryStorage(),
{},
{
CRABBOX_ARTIFACTS_BACKEND: "r2",
CRABBOX_ARTIFACTS_BUCKET: "qa-artifacts",
CRABBOX_ARTIFACTS_ENDPOINT_URL: "https://account.r2.cloudflarestorage.com",
CRABBOX_ARTIFACTS_ACCESS_KEY_ID: "access-key",
CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY: "super-secret",
},
);
const response = await fleet.fetch(
request("POST", "/v1/artifacts/uploads", {
body: {
files: Array.from({ length: 6 }, (_, index) => ({
name: `video-${index}.mp4`,
size: 1024 * 1024 * 1024,
})),
},
}),
);
const body = (await response.json()) as { error: string; message: string };
expect(response.status).toBe(400);
expect(body.error).toBe("artifact_upload_unavailable");
expect(body.message).toContain("5368709120 bytes");
});
});
describe("fleet run history", () => {
@ -2136,6 +2006,89 @@ describe("fleet run history", () => {
]);
});
it("streams run events and lease heartbeats over a control websocket", async () => {
const storage = new MemoryStorage();
const fleet = testFleet(storage);
const headers = {
"x-crabbox-owner": "peter@example.com",
"x-crabbox-org": "openclaw",
};
storage.seed(
"lease:cbx_000000000001",
testLease({
id: "cbx_000000000001",
slug: "blue-lobster",
owner: "peter@example.com",
org: "openclaw",
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
}),
);
storage.seed(
"run:run_000000000001",
testRun({
id: "run_000000000001",
leaseID: "cbx_000000000001",
owner: "peter@example.com",
org: "openclaw",
eventCount: 1,
}),
);
storage.seed("runevent:run_000000000001:000000000001", {
runID: "run_000000000001",
seq: 1,
type: "run.started",
phase: "starting",
createdAt: "2026-05-01T00:00:00.000Z",
});
const socket = new FakeWebSocket({
kind: "control",
clientID: "ctrl_1",
owner: "peter@example.com",
org: "openclaw",
subscriptions: {},
});
(
fleet as unknown as {
controlSockets: Map<string, WebSocket>;
}
).controlSockets.set("ctrl_1", socket as unknown as WebSocket);
await fleet.webSocketMessage(
socket as unknown as WebSocket,
JSON.stringify({ type: "subscribe_run", runID: "run_000000000001", after: 0 }),
);
expect(socket.sentJSON()[0]).toMatchObject({
type: "run_events",
runID: "run_000000000001",
nextSeq: 1,
events: [{ seq: 1, type: "run.started" }],
});
await fleet.fetch(
request("POST", "/v1/runs/run_000000000001/events", {
headers,
body: { type: "stdout", stream: "stdout", data: "ok\n" },
}),
);
expect(socket.sentJSON()[1]).toMatchObject({
type: "run_events",
runID: "run_000000000001",
nextSeq: 2,
events: [{ seq: 2, type: "stdout", data: "ok\n" }],
});
await fleet.webSocketMessage(
socket as unknown as WebSocket,
JSON.stringify({ type: "heartbeat", leaseID: "blue-lobster", idleTimeoutSeconds: 900 }),
);
expect(socket.sentJSON()[2]).toMatchObject({
type: "heartbeat",
leaseID: "cbx_000000000001",
ok: true,
});
expect(storage.value<LeaseRecord>("lease:cbx_000000000001")?.idleTimeoutSeconds).toBe(900);
});
it("records finished runs and serves logs", async () => {
const fleet = testFleet();
const ownerHeaders = {

Some files were not shown because too many files have changed in this diff Show More