Compare commits

..

35 Commits
v0.6.0 ... main

Author SHA1 Message Date
Peter Steinberger
0f2795d9ae
docs: add Azure changelog entry
Some checks failed
CI / Go (push) Has been cancelled
CI / Worker (push) Has been cancelled
CI / Docs (push) Has been cancelled
CI / Release Check (push) Has been cancelled
Pages / Deploy docs (push) Has been cancelled
2026-05-08 09:00:18 +01:00
Jonathan Moss
00725544c7
feat(azure): support linux and native windows leases
Add Azure as a managed provider for direct and brokered Crabbox leases.

- provision Azure Linux VMs with cloud-init, spot fallback, shared network adoption, and per-lease cleanup
- provision native Azure Windows VMs with VM Agent bootstrap and SSH/sync/run support
- add Azure broker support in the Cloudflare Worker, provider config, docs, and tests
- fix async Azure delete handling so successful 202 delete LROs do not refetch deleted resources
- keep Go core coverage above the CI threshold

Verified with CI plus live Azure Linux and native Windows leases.

Co-authored-by: Jonathan Moss <2729151+jwmoss@users.noreply.github.com>
2026-05-08 08:23:38 +01:00
Peter Steinberger
2e1194f6c0
fix: improve webvnc sharing and cursor ui 2026-05-08 07:30:30 +01:00
Peter Steinberger
188432c63a
feat: add collaborative webvnc observer mode 2026-05-08 06:25:10 +01:00
Peter Steinberger
b568298019
feat: improve desktop reliability artifacts 2026-05-08 04:52:51 +01:00
Peter Steinberger
0431fd3bb6
fix: expose share action on webvnc
Some checks are pending
CI / Go (push) Waiting to run
CI / Worker (push) Waiting to run
CI / Docs (push) Waiting to run
CI / Release Check (push) Waiting to run
Pages / Deploy docs (push) Waiting to run
2026-05-07 22:42:02 +01:00
Peter Steinberger
edd5fae230
fix: harden macos vnc password bootstrap 2026-05-07 22:38:18 +01:00
Peter Steinberger
fdef9df8af
fix: retry ssh fallback ports for desktop paths 2026-05-07 14:52:21 +01:00
Peter Steinberger
7884b1d71f
fix: fall back from coordinator pool list 2026-05-07 14:52:15 +01:00
Peter Steinberger
93a9e64998
docs: document desktop rescue UX 2026-05-07 14:13:29 +01:00
Peter Steinberger
5ed32f1bd0
feat: clarify WebVNC portal failure states 2026-05-07 14:13:26 +01:00
Peter Steinberger
4adbfc6d4a
feat: add desktop WebVNC rescue output 2026-05-07 14:13:23 +01:00
Peter Steinberger
770920e16d
docs: release 0.7.0 2026-05-07 13:46:12 +01:00
Peter Steinberger
0d3a65dfc1
feat: add lease sharing 2026-05-07 13:39:07 +01:00
Peter Steinberger
62d5c1b3d5
docs: document WebVNC clipboard controls 2026-05-07 13:18:04 +01:00
Peter Steinberger
aca01bf512
feat: harden desktop WebVNC reliability 2026-05-07 13:17:23 +01:00
Peter Steinberger
19cbc17602
fix: repair managed macos desktop readiness 2026-05-07 12:45:27 +01:00
Peter Steinberger
32a0f89627
chore: bump version to 0.7.0 2026-05-07 06:25:03 +01:00
Peter Steinberger
80c085c16b
docs: link egress from portal capabilities 2026-05-07 06:22:34 +01:00
Peter Steinberger
966d7df4bd
docs: refresh bridge command docs 2026-05-07 06:22:08 +01:00
Peter Steinberger
c638a55dbb
docs: refresh webvnc ticket docs 2026-05-07 06:21:47 +01:00
Peter Steinberger
335b1d2b28
docs: refresh desktop egress command docs 2026-05-07 06:21:23 +01:00
Peter Steinberger
88c42f96d7
docs: refresh egress cli index 2026-05-07 06:20:58 +01:00
Peter Steinberger
5abb6980cd
docs: add mediated egress flow chart 2026-05-07 06:20:26 +01:00
Peter Steinberger
d0b2c2379f
fix: allow public coordinator egress starts 2026-05-07 06:16:26 +01:00
Peter Steinberger
b40d36458a
feat: add mediated egress bridge 2026-05-07 06:10:22 +01:00
Vincent Koc
947b21ca46
fix: keep bridge tickets out of websocket urls 2026-05-06 20:29:06 -07:00
Peter Steinberger
120802c150
chore: start 0.6.2 development
Some checks are pending
CI / Go (push) Waiting to run
CI / Worker (push) Waiting to run
CI / Docs (push) Waiting to run
CI / Release Check (push) Waiting to run
Pages / Deploy docs (push) Waiting to run
2026-05-07 04:03:40 +01:00
Peter Steinberger
8c69be33a6
ci: require homebrew tap updates on release 2026-05-07 03:14:36 +01:00
Peter Steinberger
c3c111ba35
fix: sync islo workspaces before run 2026-05-07 02:30:15 +01:00
Peter Steinberger
6a45e46b1b
fix: suppress windows powershell progress output 2026-05-07 01:48:07 +01:00
Peter Steinberger
f4695953bc
fix: harden 0.6.1 runtime checks 2026-05-07 01:14:26 +01:00
Peter Steinberger
98af5a3e8f
fix: bootstrap exit-node leases over tailscale 2026-05-07 00:57:16 +01:00
Peter Steinberger
e328ead836
fix: validate tailscale exit-node egress 2026-05-07 00:48:53 +01:00
Peter Steinberger
f031e9d1aa
docs: expand crabbox user guide 2026-05-07 00:47:41 +01:00
148 changed files with 17285 additions and 696 deletions

View File

@ -68,6 +68,19 @@ crabbox stop <cbx_id-or-slug>
```sh
crabbox status --id <id-or-slug> --wait
crabbox inspect --id <id-or-slug> --json
crabbox webvnc --id <id-or-slug> --open
crabbox webvnc daemon start --id <id-or-slug> --open
crabbox webvnc daemon status --id <id-or-slug>
crabbox webvnc daemon stop --id <id-or-slug>
crabbox webvnc status --id <id-or-slug>
crabbox webvnc reset --id <id-or-slug> --open
crabbox desktop doctor --id <id-or-slug>
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
@ -80,6 +93,27 @@ crabbox usage --scope org
CRABBOX_LIVE=1 CRABBOX_LIVE_REPO=/path/to/openclaw scripts/live-smoke.sh
```
For human desktop demos, prefer WebVNC over native VNC because
`crabbox webvnc --open` preloads the lease password in the browser fragment.
Use native `crabbox vnc --id <id-or-slug> --open` as the fallback printed by
`crabbox webvnc status` or `crabbox webvnc reset`. For input automation, use
`crabbox desktop click/paste/type/key` instead of hand-written `xdotool`;
`desktop type` switches to clipboard paste for symbol-heavy text such as emails
and passwords. `desktop key` accepts both `--id <lease> <keys>` and positional
`<lease> <keys>` forms for shortcuts.
When desktop/WebVNC hangs, trust the inline rescue output first: `problem: VNC
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

@ -39,29 +39,38 @@ jobs:
RELEASE_TAG: ${{ inputs.tag }}
run: git checkout "$RELEASE_TAG"
- name: Check Homebrew tap token
id: homebrew
- name: Resolve release tag
id: release
env:
DISPATCH_TAG: ${{ inputs.tag }}
REF_NAME: ${{ github.ref_name }}
run: |
tag="${DISPATCH_TAG:-$REF_NAME}"
if [ -z "$tag" ]; then
echo "::error::could not resolve release tag"
exit 1
fi
echo "tag=$tag" >>"$GITHUB_OUTPUT"
echo "version=${tag#v}" >>"$GITHUB_OUTPUT"
- name: Verify Homebrew tap token
env:
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
run: |
if [ -z "$HOMEBREW_TAP_GITHUB_TOKEN" ]; then
echo "skip=true" >>"$GITHUB_OUTPUT"
echo "::warning::HOMEBREW_TAP_GITHUB_TOKEN is missing; skipping Homebrew tap publish"
exit 0
echo "::error::HOMEBREW_TAP_GITHUB_TOKEN is missing; cannot publish Homebrew formula"
exit 1
fi
code="$(curl -sS -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $HOMEBREW_TAP_GITHUB_TOKEN" \
-H "Accept: application/vnd.github+json" \
https://api.github.com/repos/openclaw/homebrew-tap || true)"
if [ "$code" != "200" ]; then
echo "skip=true" >>"$GITHUB_OUTPUT"
echo "::warning::HOMEBREW_TAP_GITHUB_TOKEN cannot access openclaw/homebrew-tap (HTTP $code); skipping Homebrew tap publish"
exit 0
echo "::error::HOMEBREW_TAP_GITHUB_TOKEN cannot access openclaw/homebrew-tap (HTTP $code)"
exit 1
fi
echo "skip=false" >>"$GITHUB_OUTPUT"
- name: GoReleaser
if: ${{ steps.homebrew.outputs.skip != 'true' }}
uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser
@ -71,12 +80,17 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
- name: GoReleaser without Homebrew
if: ${{ steps.homebrew.outputs.skip == 'true' }}
uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser
version: "~> v2"
args: release --clean --config /tmp/.goreleaser.yaml --skip=homebrew
- name: Verify Homebrew formula
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
RELEASE_VERSION: ${{ steps.release.outputs.version }}
run: |
formula="$(gh api repos/openclaw/homebrew-tap/contents/Formula/crabbox.rb --jq '.content' | base64 --decode)"
if ! grep -q "version \"$RELEASE_VERSION\"" <<<"$formula"; then
echo "::error::openclaw/homebrew-tap Formula/crabbox.rb was not updated to $RELEASE_VERSION"
exit 1
fi
if ! grep -q "releases/download/v$RELEASE_VERSION/" <<<"$formula"; then
echo "::error::openclaw/homebrew-tap Formula/crabbox.rb does not point at v$RELEASE_VERSION assets"
exit 1
fi

View File

@ -1,5 +1,42 @@
# 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 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 `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.
- Fixed Islo provider sync so `crabbox run --provider islo` uploads the local workspace, uses the correct `/workspace/<workdir>`, and falls back to chunked exec upload while the archive API returns server errors.
- 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.
## 0.6.0 - 2026-05-07
### Added

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 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.
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.
---
@ -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
crabbox CLI -- HTTPS --> Fleet Durable Object --> Hetzner / AWS EC2 / Azure
| lease + cost state |
| |
+------------ SSH + rsync to leased runner <--------------+
@ -61,9 +61,9 @@ crabbox CLI -- HTTPS --> Fleet Durable Object --> Hetzner / AWS EC2
- **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**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.
- **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.
A direct-provider mode (`--provider hetzner|aws` with local credentials) exists for debugging the broker itself; the brokered path is the default.
A direct-provider mode (`--provider hetzner|aws|azure` 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,16 @@ 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 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.
- **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.
- **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, and QA systems such as Mantis own scenario logic, screenshots, and PR evidence. 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 lease and 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. Admin sessions can also see non-owned runner leases behind `mine`/`system` filters.
- **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.
- **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.
- **Operator surface.** `doctor`, `init`, `status`, `inspect`, `list`, `usage`, `history`, `logs`, `results`, `cache`, `admin`, `cleanup`, plus `--json` output where it matters.
@ -112,6 +113,16 @@ 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
crabbox CLI -- HTTPS --> Fleet Durable Object --> Hetzner / AWS EC2 / Azure
| 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 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.
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.
## A run, end to end
@ -76,8 +76,9 @@ Run history and inspection are intentionally handled by the Crabbox CLI and repo
Pick whichever matches your intent:
- **Get the mental model:** [How Crabbox Works](how-it-works.md), [Architecture](architecture.md), [Orchestrator](orchestrator.md).
- **Use the CLI:** [CLI](cli.md), [Commands](commands/README.md), [Features](features/README.md), [Actions hydration](features/actions-hydration.md), [Browser portal](features/portal.md), [Telemetry](features/telemetry.md).
- **Start here:** [Getting started](getting-started.md), [How Crabbox Works](how-it-works.md), [Concepts and glossary](concepts.md).
- **Get the mental model:** [Architecture](architecture.md), [Orchestrator](orchestrator.md).
- **Use the CLI:** [CLI](cli.md), [Commands](commands/README.md), [Features](features/README.md), [Configuration](features/configuration.md), [Actions hydration](features/actions-hydration.md), [Browser portal](features/portal.md), [Telemetry](features/telemetry.md).
- **Pick or add a target:** [Provider reference](providers/README.md), [Providers feature overview](features/providers.md), [Provider authoring](features/provider-authoring.md), [Provider backends](provider-backends.md), [AWS](providers/aws.md), [Hetzner](providers/hetzner.md), [Static SSH](providers/ssh.md), [Blacksmith Testbox](providers/blacksmith-testbox.md), [Daytona](providers/daytona.md), [Islo](providers/islo.md), [Interactive desktop and VNC](features/interactive-desktop-vnc.md).
- **Operate it:** [Operations](operations.md), [Observability](observability.md), [Troubleshooting](troubleshooting.md), [Performance](performance.md).
- **Set it up or audit it:** [Infrastructure](infrastructure.md), [Security](security.md), [Source Map](source-map.md), [MVP Plan](mvp-plan.md).

View File

@ -104,6 +104,7 @@ 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:
@ -111,7 +112,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` and `aws`, and leaves interfaces ready for `hetzner-static`.
The current broker implements `hetzner-ephemeral`, `aws`, and `azure`, and leaves interfaces ready for `hetzner-static`.
## Machine Bootstrap

View File

@ -25,19 +25,35 @@ Primary output goes to stdout. Progress, diagnostics, and errors go to stderr. J
```text
crabbox doctor
crabbox login [--url <url>] [--provider hetzner|aws] [--no-browser]
crabbox login --url <url> --token-stdin [--provider hetzner|aws]
crabbox login [--url <url>] [--provider hetzner|aws|azure] [--no-browser]
crabbox login --url <url> --token-stdin [--provider hetzner|aws|azure]
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]
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>] [--webvnc] [--open] [-- <command...>]
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 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]
crabbox desktop paste --id <lease-id-or-slug> --text <text> [--network auto|tailscale|public]
crabbox desktop paste --id <lease-id-or-slug> [--network auto|tailscale|public] < input.txt
crabbox desktop type --id <lease-id-or-slug> --text <text> [--network auto|tailscale|public]
crabbox desktop key --id <lease-id-or-slug> <keys> [--network auto|tailscale|public]
crabbox code --id <lease-id-or-slug> [--open]
crabbox egress start --id <lease-id-or-slug> [--profile <name>|--allow <hosts>] [--listen <addr>] [--coordinator <url>] [--daemon]
crabbox egress host --id <lease-id-or-slug> [--profile <name>|--allow <hosts>]
crabbox egress client --id <lease-id-or-slug> [--listen <addr>] [--ticket <ticket>] [--session <id>]
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]
@ -53,6 +69,8 @@ crabbox actions register --id <lease-id-or-slug> [--repo owner/name]
crabbox actions dispatch [--workflow <file|name|id>] [-f key=value]
crabbox status --id <lease-id-or-slug> [--network auto|tailscale|public] [--wait]
crabbox list [--json]
crabbox share --id <lease-id-or-slug> [--user <email>] [--org] [--role use|manage] [--list] [--json]
crabbox unshare --id <lease-id-or-slug> [--user <email>] [--org] [--all] [--json]
crabbox usage [--scope user|org|all] [--user <email>] [--org <name>] [--month YYYY-MM] [--json]
crabbox admin leases [--state active|released|expired|failed] [--owner <email>] [--org <name>] [--json]
crabbox admin release <lease-id-or-slug> [--delete]
@ -60,6 +78,11 @@ crabbox admin delete <lease-id-or-slug> --force
crabbox ssh --id <lease-id-or-slug> [--network auto|tailscale|public]
crabbox vnc --id <lease-id-or-slug> [--network auto|tailscale|public] [--open]
crabbox webvnc --id <lease-id-or-slug> [--network auto|tailscale|public] [--open]
crabbox webvnc daemon start --id <lease-id-or-slug> [--network auto|tailscale|public] [--open]
crabbox webvnc daemon status --id <lease-id-or-slug>
crabbox webvnc daemon stop --id <lease-id-or-slug>
crabbox webvnc status --id <lease-id-or-slug> [--network auto|tailscale|public]
crabbox webvnc reset --id <lease-id-or-slug> [--network auto|tailscale|public] [--open]
crabbox inspect --id <lease-id-or-slug> [--network auto|tailscale|public] [--json]
crabbox stop <lease-id-or-slug>
crabbox cleanup [--dry-run]
@ -88,10 +111,23 @@ crabbox warmup --desktop --browser
crabbox run --id blue-lobster -- pnpm test:changed
crabbox vnc --id blue-lobster --open
crabbox webvnc --id blue-lobster --open
crabbox webvnc status --id blue-lobster
crabbox webvnc daemon start --id blue-lobster --open
crabbox code --id blue-lobster --open
crabbox desktop launch --id blue-lobster --browser --url https://example.com --webvnc --open
crabbox desktop doctor --id blue-lobster
crabbox desktop paste --id blue-lobster --text "peter@example.com"
crabbox desktop key --id blue-lobster ctrl+l
crabbox egress start --id blue-lobster --profile discord --daemon
crabbox desktop launch --id blue-lobster --browser --url https://discord.com/login --egress discord --webvnc --open
crabbox egress status --id blue-lobster
crabbox egress stop --id blue-lobster
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
```
@ -299,8 +335,9 @@ short-lived Daytona SSH tokens and redacts those tokens from output. Daytona
auth can come from `DAYTONA_API_KEY` / `DAYTONA_JWT_TOKEN` env or an
authenticated Daytona CLI profile created by `daytona login --api-key`. With
`provider: islo`, Crabbox delegates sandbox setup and command execution to the
Islo Go SDK; sync is delegated and `--sync-only`, `--checksum`, and
`--force-sync-large` are unsupported.
Islo Go SDK, uploads the Crabbox sync manifest as a gzipped archive into the
Islo workdir, and rejects only the SSH/rsync-specific `--sync-only` and
`--checksum` modes.
## Exit Codes
@ -568,6 +605,16 @@ 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,6 +13,7 @@ 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)
@ -22,6 +23,8 @@ Command docs live here, one file per top-level command. Keep `docs/cli.md` as th
- [cache](cache.md)
- [status](status.md)
- [list](list.md)
- [share](share.md)
- [unshare](unshare.md)
- [image](image.md)
- [usage](usage.md)
- [admin](admin.md)
@ -30,6 +33,7 @@ Command docs live here, one file per top-level command. Keep `docs/cli.md` as th
- [vnc](vnc.md)
- [webvnc](webvnc.md)
- [code](code.md)
- [egress](egress.md)
- [screenshot](screenshot.md)
- [inspect](inspect.md)
- [stop](stop.md)

235
docs/commands/artifacts.md Normal file
View File

@ -0,0 +1,235 @@
# 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

@ -3,27 +3,63 @@
`crabbox attach` follows recorded events for an active coordinator run.
```sh
crabbox attach run_...
crabbox attach --id run_... --after 42
crabbox attach run_abcdef123456
crabbox attach --id run_abcdef123456 --after 42
crabbox attach run_abcdef123456 --poll 500ms
```
Stdout and stderr preview events are written back to stdout and stderr.
Lifecycle events are printed to stderr with their sequence number, phase,
timestamp, and message. When the run has already finished, `attach` prints any
remaining events and exits.
## Behavior
Flags:
`attach` polls the coordinator for new run events on a fixed interval,
prints them as they arrive, and exits when the run finishes.
- stdout and stderr preview events are written back to stdout and stderr,
preserving the stream split;
- lifecycle events (lease, bootstrap, sync, command-start, finish, release)
are printed to stderr with their sequence number, phase, timestamp, and
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.
`attach` is not detached command execution. It follows the events the
original CLI is emitting; if that CLI process dies, the run state remains
inspectable through [history](history.md), [events](events.md), and
[logs](logs.md), but `attach` cannot resurrect it.
## Bounded Output
Output events are a bounded preview. The coordinator caps stdout/stderr
capture at 64 KiB per run and records an `output.truncated` marker when the
cap is reached. Use [logs](logs.md) for the larger retained command output
after completion.
## Flags
```text
--id <run-id> run id
--after <seq> resume after this event sequence
--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
```
`attach` follows events emitted by the original CLI. It is not detached command
execution. If the original CLI process dies, the last recorded phase remains
inspectable through [history](history.md), [events](events.md), and
[logs](logs.md).
## Use Cases
Output events are a bounded preview. Use [logs](logs.md) for the retained
command output after completion.
- watch a long warmup or run from a second terminal without disturbing the
original CLI;
- monitor an agent-launched run while doing something else locally;
- replay events from a known sequence (`--after`) when reconnecting after
a network blip.
## Direct Mode
Direct-provider mode does not record runs centrally, so `attach` has no
event stream to follow. Use shell output from the original CLI instead.
Related docs:
- [logs](logs.md)
- [events](events.md)
- [history](history.md)
- [run](run.md)
- [History and logs](../features/history-logs.md)

View File

@ -9,23 +9,107 @@ crabbox cache warm --id blue-lobster -- pnpm install --frozen-lockfile
crabbox cache purge --id blue-lobster --kind pnpm --force
```
`--id` accepts the stable `cbx_...` ID or an active friendly slug. Cache commands that SSH to the box touch the lease and validate the local repo claim; add `--reclaim` to move an existing claim.
Cache kinds:
## Subcommands
```text
pnpm
npm
docker
git
all
cache stats show usage for each cache kind on the lease
cache warm run a command in the synced workdir to populate caches
cache purge delete one or all cache kinds (requires --force)
```
`cache warm` runs a command in the synced repo workdir for that lease. On boxes prepared by `crabbox actions hydrate`, it uses the hydrated `$GITHUB_WORKSPACE` and sources the workflow env handoff like `crabbox run`.
`--id` accepts the canonical `cbx_...` lease ID or an active friendly
slug. Cache commands SSH to the box, touch the lease, and validate the
local repo claim. Add `--reclaim` to move an existing claim from another
repo.
Repo `cache.pnpm`, `cache.npm`, `cache.docker`, and `cache.git` toggles control which kinds `stats` reports and which kinds `purge --kind all` removes.
## Cache Kinds
```text
pnpm /var/cache/crabbox/pnpm
npm /var/cache/crabbox/npm
docker Docker layer/image cache (host-managed)
git /var/cache/crabbox/git (shared origin objects)
all every kind enabled in repo config
```
Repo `cache.pnpm`, `cache.npm`, `cache.docker`, and `cache.git` toggles
control which kinds `stats` reports and which kinds `purge --kind all`
removes. Disabled kinds are omitted from stats, are not purged by
`--kind all`, and asking to purge a disabled specific kind fails early.
## stats
```sh
crabbox cache stats --id blue-lobster
```
Prints sizes for each enabled cache kind:
```text
pnpm 8.4GiB
npm 1.2GiB
docker 18.7GiB
git 430MiB
```
`--json` returns the same data as a structured object.
## warm
```sh
crabbox cache warm --id blue-lobster -- pnpm install --frozen-lockfile
crabbox cache warm --id blue-lobster -- docker compose pull
```
Runs a command in the synced repo workdir for that lease. On boxes
prepared by `crabbox actions hydrate`, it uses the hydrated
`$GITHUB_WORKSPACE` and sources the workflow env handoff, just like
`crabbox run` does.
Use warm for one-off cache priming when you do not want to record a full
run history entry.
## purge
```sh
crabbox cache purge --id blue-lobster --kind pnpm --force
crabbox cache purge --id blue-lobster --kind all --force
```
Removes the named cache kind from the lease. `--force` is required to
prevent accidental purges. If `cache.maxGB` is set, purge is rarely
needed - the runner trims the oldest entries automatically when caches
exceed the cap.
## Flags
```text
--id <lease-id-or-slug> target lease (required)
--kind pnpm|npm|docker|git|all for purge
--force required for purge
--reclaim move local claim from another repo
--json stats as JSON
```
## When To Use Cache
Caches are speed hints, not source of truth. The synced worktree remains
authoritative.
- Use `cache stats` to confirm a long-lived warm box is gaining benefit
from cached packages.
- Use `cache warm` to prime a fresh lease before handing it to agents that
run many short commands.
- Use `cache purge` when a corrupt cache is poisoning a build (rare;
usually the underlying tool's own cache reset works first).
Disposable leases lose cache state when the VM is deleted; kept leases
can reuse cache state across repeated agent runs. For shared baked
images, see [Prebaked runner images](../features/prebaked-images.md).
Related docs:
- [Performance](../performance.md)
- [Cache controls](../features/cache.md)
- [Performance](../performance.md)
- [run](run.md)
- [actions](actions.md)

View File

@ -1,29 +1,77 @@
# cleanup
`crabbox cleanup` sweeps direct-provider leftovers.
`crabbox cleanup` sweeps direct-provider leftovers based on Crabbox labels.
```sh
crabbox cleanup --dry-run
crabbox cleanup
```
Cleanup refuses to run when a coordinator is configured. Brokered cleanup belongs to the Durable Object alarm.
`crabbox machine cleanup` is preserved as a compatibility alias.
Direct cleanup skips kept machines, deletes expired ready/leased/active machines, and gives running/provisioning machines an extra stale safety window. It relies on provider labels such as `lease`, `slug`, `expires_at`, and `state`.
## Behavior
Static SSH targets are existing hosts, so `provider=ssh` has nothing to sweep.
Cleanup refuses to run when a coordinator is configured. Brokered cleanup
belongs to the Durable Object alarm; sweeping provider resources behind the
coordinator can race live brokered leases.
Flags:
In direct-provider mode, cleanup is intentionally conservative:
- skip machines tagged `keep=true`;
- skip machines in `running` or `provisioning` state until the extra stale
safety window passes (expiry plus 12 hours);
- delete machines that are clearly expired in `ready`, `leased`, or
`active` states;
- delete machines that have been inactive past expiry.
Selection is label-driven. Cleanup uses `lease`, `slug`, `expires_at`,
`last_touched_at`, `state`, and `keep` labels written when the machine was
created. Resources without Crabbox labels are never touched.
Static SSH targets are existing operator-owned hosts, so `provider=ssh`
has nothing to sweep. Cleanup exits early for that provider.
## Output
`--dry-run` lists every decision without taking action:
```text
--provider hetzner|aws
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>
--static-user <user>
--static-port <port>
--static-work-root <path>
--dry-run
hetzner cx53 hz-12345 lease=cbx_abcdef123456 slug=blue-lobster keep=true skip=keep
hetzner cx53 hz-67890 lease=cbx_abcdef234567 slug=amber-crab expires_at=2026-05-01T17:30:00Z delete
```
`crabbox machine cleanup` remains as a compatibility alias.
Without `--dry-run`, the same lines print but each `delete` is followed by
`deleted` after the provider call returns. Failures print the provider
error and continue with the next candidate.
## Flags
```text
--provider hetzner|aws|azure 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)
--static-user <user> ignored
--static-port <port> ignored
--static-work-root <path> ignored
--dry-run log decisions without making provider calls
```
## When To Run
- after a CLI process crashed mid-warmup and left a server behind;
- when migrating from direct mode to brokered mode (sweep first, then
switch);
- as a safety net after rotating provider credentials;
- never as part of a brokered workflow - the coordinator owns that path.
For brokered fleets, audit `crabbox admin leases --state active` and use
`crabbox admin release` instead.
Related docs:
- [stop](stop.md)
- [admin](admin.md)
- [Lifecycle cleanup](../features/lifecycle-cleanup.md)
- [Orchestrator](../orchestrator.md)
- [Operations](../operations.md)

View File

@ -46,7 +46,10 @@ browser
Keep the local `crabbox code` process running while using the editor. The
coordinator authenticates the browser through portal auth and authenticates the
local bridge with a one-use, short-lived ticket.
local bridge with a one-use, short-lived ticket. The CLI sends the ticket as
an `Authorization: Bearer ...` header so it stays out of websocket URLs and
proxy/access logs; the coordinator accepts a `?ticket=` query string as a
fallback for older CLIs.
If the browser opens before the local bridge connects, the Code portal renders a
waiting state with the exact `crabbox code --id <lease> --open` command, copy
@ -61,7 +64,7 @@ and extension-host traffic stay below coordinator websocket frame limits.
```text
--id <lease-id-or-slug>
--provider hetzner|aws
--provider hetzner|aws|azure
--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]
set-broker --url <url> [--token-stdin] [--admin-token-stdin] [--provider hetzner|aws|azure]
```
`config show` reports broker auth as `auth` and `admin_auth`, plus

View File

@ -7,7 +7,15 @@ over VNC manually.
crabbox warmup --desktop --browser
crabbox desktop launch --id blue-lobster --browser --url https://example.com
crabbox desktop launch --id blue-lobster --browser --url https://example.com --webvnc --open
crabbox desktop launch --id blue-lobster --browser --url https://discord.com/login --egress discord --webvnc --open
crabbox desktop launch --id blue-lobster -- xterm
crabbox desktop doctor --id blue-lobster
crabbox desktop click --id blue-lobster --x 640 --y 420
crabbox desktop paste --id blue-lobster --text "peter@example.com"
printf 'peter@example.com' | crabbox desktop paste --id blue-lobster
crabbox desktop type --id blue-lobster --text "hello"
crabbox desktop key --id blue-lobster ctrl+l
crabbox desktop key blue-lobster ctrl+l
```
The command resolves and touches the lease, verifies `desktop=true`, waits for
@ -19,6 +27,13 @@ into the authenticated WebVNC portal. Add `--open` to open that portal locally.
Browser launches default to a windowed human desktop with the remote panel and
title bar visible; use `--fullscreen` only for capture/video workflows.
`--egress <profile>` passes the active lease-local egress proxy to the launched
browser as `--proxy-server=http://127.0.0.1:3128`, so the browser exits to the
internet through the operator machine running `crabbox egress start`. Start
the egress bridge first; the flag currently requires `--browser`. Override the
proxy address with `--egress-proxy host:port` if you started egress on a
non-default port. See [egress](egress.md) for the full bridge model.
On Windows, SSH sessions cannot directly own the visible console desktop, so
Crabbox writes a one-shot PowerShell launcher under `C:\ProgramData\crabbox` and
runs it as an interactive scheduled task for the logged-in `crabbox` user. The
@ -26,11 +41,39 @@ launcher minimizes existing windows, starts the app, and tries to foreground
the new process. On Linux and macOS, the command is detached with `setsid` or
`nohup`.
`crabbox desktop doctor` checks the selected lease without syncing the repo.
For Linux desktop leases it reports VM/session health separately from portal
health: `DISPLAY`, Xvfb/window manager/panel, VNC listener, `xdotool`,
clipboard tool, browser binary, `ffmpeg`, screen size, screenshot capture, and
WebVNC bridge/viewer state. Failures include a one-line repair suggestion so
you can tell session bugs from WebVNC/browser-portal bugs.
Desktop launch and input failures now surface the failing layer directly in the
CLI output. For example, a missing visible browser reports `problem: browser not
launched`, a dead input path reports `problem: input stack dead`, and a broken
portal path reports `problem: VNC bridge disconnected` or `problem: WebVNC
daemon not running`. The same output includes exact `rescue:` commands such as
`crabbox desktop doctor --id <lease>` or `crabbox webvnc reset --id <lease>
--open`.
Input helpers also operate on the selected lease over SSH without repo sync.
Use them instead of hand-written `xdotool` snippets. `desktop type` uses raw
`xdotool type` only for simple alphanumeric text; text with emails, passwords,
symbols such as `@` or `+`, URLs, whitespace, or long payloads goes through the
remote clipboard and paste path because keyboard layouts can otherwise corrupt
special characters.
`desktop paste` accepts `--text` or stdin. `desktop key` accepts either
`--id <lease> <keys>` or the positional lease form `<lease> <keys>`; the key
sequence is parsed after lease flags so common forms such as
`crabbox desktop key blue-lobster ctrl+l` and
`crabbox desktop key -id blue-lobster ctrl+l` send `ctrl+l`, not the lease id.
Flags:
```text
--id <lease-id-or-slug>
--provider hetzner|aws|ssh
--provider hetzner|aws|azure|ssh|daytona
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>
@ -42,5 +85,28 @@ Flags:
--webvnc
--open
--fullscreen
--egress <profile>
--egress-proxy <host:port>
--reclaim
```
Input helper flags:
```text
desktop doctor --id <lease-id-or-slug>
desktop click --id <lease-id-or-slug> --x <n> --y <n>
desktop paste --id <lease-id-or-slug> --text <text>
desktop paste --id <lease-id-or-slug> < input.txt
desktop type --id <lease-id-or-slug> --text <text>
desktop key --id <lease-id-or-slug> <keys>
desktop key <lease-id-or-slug> <keys>
desktop key --id <lease-id-or-slug> --keys <keys>
```
Related docs:
- [egress](egress.md)
- [vnc](vnc.md)
- [webvnc](webvnc.md)
- [Lease capabilities](../features/capabilities.md)
- [Mediated egress](../features/egress.md)

View File

@ -1,29 +1,101 @@
# doctor
`crabbox doctor` checks local prerequisites and broker/provider access.
`crabbox doctor` runs the local preflight before you commit to a long
workflow. It is fast (under a second on a healthy machine), local-only, and
never calls a billable provider API.
```sh
crabbox doctor
crabbox doctor --provider aws
crabbox doctor --provider hetzner --target linux
crabbox doctor --provider ssh --target windows --windows-mode normal --static-host win-dev.local
```
It checks local tools, user config permissions, per-lease key generation support,
coordinator health when configured, and direct-provider API access otherwise. If
`CRABBOX_SSH_KEY` is explicitly set, it also validates that private key and
matching `.pub` file.
For `provider=ssh`, doctor checks that the static SSH host is reachable and has
the tools required by the selected target mode.
Flags:
## What It Checks
```text
--provider hetzner|aws|ssh
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>
--static-user <user>
--static-port <port>
--static-work-root <path>
config config files load and parse, required keys are present
auth broker token is set, signed token is valid, identity resolves
network coordinator URL reachable, DNS works, SSH transport probes work
ssh SSH key path readable, key permissions sane, ssh-keygen on PATH
tools rsync, git, ssh, ssh-keygen present and executable
```
For `--provider ssh`, doctor also probes the static host: SSH reachability
on the configured port, target-required tools (`bash`, `git`, `rsync`,
`tar` for POSIX targets; OpenSSH, PowerShell, and `tar` for native
Windows), and `static.workRoot` writability.
When `CRABBOX_SSH_KEY` is explicitly set, doctor validates the private key
and the matching `.pub` file. When unset, it skips that check because
per-lease keys do not need a global key.
For the full list of checks, including how each one decides between
`fail`, `skip`, and `ok`, see
[Doctor checks](../features/doctor.md).
## Output
```text
config:
ok user config: ~/.config/crabbox/config.yaml
ok repo config: ./.crabbox.yaml
ok provider: aws
ok target: linux
auth:
ok broker: https://crabbox.openclaw.ai
ok owner: alex@example.com
network:
ok coordinator dns
ok coordinator https
ssh:
ok ssh-keygen present
skip ssh.key unset (per-lease keys will be used)
tools:
ok git
ok rsync
ok ssh
ok ssh-keygen
```
Failures swap the leading `ok` for `fail` and add a remediation hint:
```text
auth:
fail broker token is missing - run `crabbox login`
```
Exit code is `0` on full success, `2` on any failure. Skips never change
the exit code.
## Flags
```text
--provider hetzner|aws|azure|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
--static-user <user> static SSH user override
--static-port <port> static SSH port override
--static-work-root <path> static target work root
```
## When To Run
- before the first `crabbox run` on a new machine;
- after rotating the broker token;
- after editing `~/.crabbox.yaml` or repo config;
- in agent boot sequences as a sanity check;
- when triaging "Crabbox is broken" reports - doctor often catches the
problem before the user has to describe it.
Doctor is safe to run from `pre-commit`, scheduled jobs, and CI smoke
because it never provisions, never costs money, and never modifies state.
Related docs:
- [Doctor checks](../features/doctor.md)
- [Configuration](../features/configuration.md)
- [Auth and admin](../features/auth-admin.md)
- [Network and reachability](../features/network.md)
- [Troubleshooting](../troubleshooting.md)

135
docs/commands/egress.md Normal file
View File

@ -0,0 +1,135 @@
# egress
`crabbox egress` bridges lease-local browser or app traffic through the machine
running the egress host agent.
```sh
crabbox egress start --id blue-lobster --profile discord
crabbox egress start --id blue-lobster --profile discord --daemon
crabbox desktop launch --id blue-lobster --browser --url https://discord.com/login --egress discord
crabbox egress status --id blue-lobster
crabbox egress stop --id blue-lobster
```
## How It Works
`egress start` installs a short-lived egress client helper on the lease, starts
a loopback HTTP proxy such as `127.0.0.1:3128`, then runs a local host bridge on
the operator machine. Both sides connect outbound to the coordinator with
one-use tickets. The coordinator pairs the two WebSockets and forwards
multiplexed proxy messages; it does not open internet connections itself.
The browser/app data path is:
```text
Chrome in lease
-> lease 127.0.0.1:3128
-> coordinator Durable Object
-> local crabbox egress host process
-> internet from the operator machine
```
`desktop launch --egress <profile>` passes the lease-local proxy to Chrome as:
```text
--proxy-server=http://127.0.0.1:3128
```
The portal lease detail page shows the active egress session, host/client
connection state, and copyable `egress status` / `egress stop` commands. It
does not expose tickets or raw proxy URLs.
## Subcommands
```text
start Start a remote lease proxy and local host bridge
host Run only the local egress host bridge
client Run only the lease-side proxy bridge
status Show coordinator bridge status
stop Stop the local host daemon and remote lease client
```
Use `host` and `client` directly when debugging tickets, custom tunnels, or a
manually installed helper.
## Profiles And Allowlist
The host side refuses to become an open proxy. Use a built-in profile or an
explicit allowlist:
```sh
crabbox egress start --id blue-lobster --profile discord
crabbox egress start --id blue-lobster --allow example.com,*.example.com
```
Built-in profiles:
- `discord`: `discord.com`, `*.discord.com`, `discordcdn.com`,
`*.discordcdn.com`, `hcaptcha.com`, `*.hcaptcha.com`
- `slack`: `slack.com`, `*.slack.com`, `slack-edge.com`, `*.slack-edge.com`
Wildcard entries match the named domain and subdomains.
## Flags
Common:
```text
--id <lease-id-or-slug>
--provider hetzner|aws
--profile <name>
--allow <comma-separated-host-patterns>
```
`start`:
```text
--listen 127.0.0.1:3128
--daemon
--coordinator <public-coordinator-url>
--target linux
--network auto|tailscale|public
```
`host` and `client` debugging:
```text
--coordinator <url>
--ticket <ticket>
--session <session-id>
```
## Limitations
- The shipped path is per-app/per-process egress, not full VM routing.
- `egress start` supports coordinator-backed Linux SSH leases.
- `egress start` refuses non-Linux targets until target-specific remote helper
install/start commands exist.
- `egress start` does not install Cloudflare Access service-token credentials
on the remote lease. If Access credentials are configured locally, use a
public coordinator route, or run `egress client` manually only when it is safe
to provide the required access headers.
- The first implementation uses JSON/base64 bridge frames. That is good enough
for browser QA but can be optimized with binary frames later.
## Troubleshooting
`egress host requires --profile or --allow`
The host bridge will not start as an open proxy. Pick a profile or pass an
explicit allowlist.
`remote egress client did not listen`
Inspect the remote helper log:
```sh
crabbox ssh --id blue-lobster
cat /tmp/crabbox-egress-client.log
```
`desktop launch --egress currently requires --browser`
The automatic proxy flag is wired for browser launches. For custom apps, pass
the app's proxy flag yourself or use the lease-local proxy address printed by
`egress start`.

View File

@ -3,34 +3,79 @@
`crabbox events` prints the coordinator event log for a recorded run.
```sh
crabbox events run_...
crabbox events --id run_... --after 42 --limit 100
crabbox events run_... --json
crabbox events run_abcdef123456
crabbox events --id run_abcdef123456 --after 42 --limit 100
crabbox events run_abcdef123456 --json
```
Coordinator-backed `crabbox run` creates a durable `run_...` handle before it
leases or syncs. The CLI appends lifecycle events as the run advances through
leasing, bootstrap, sync, command execution, output streaming, finish, and
release.
## What Events Are Recorded
Human output includes sequence number, event type, phase, stream, timestamp, and
short message or output text. JSON output returns the raw event records.
Output events are a bounded preview: stdout/stderr capture stops after 64 KiB
per run and records an `output.truncated` marker. Use `crabbox logs` for the
larger retained command output.
Coordinator-backed `crabbox run` creates a durable `run_...` handle before
it leases or syncs. The CLI appends ordered events as the run advances:
Flags:
- `lease.acquire.start`, `lease.acquire.success`, `lease.acquire.fail`;
- `bootstrap.wait`, `bootstrap.ready`;
- `sync.start`, `sync.skip`, `sync.success`, `sync.fail`;
- `command.start`, `command.finish`;
- `output.stdout`, `output.stderr`, `output.truncated`;
- `release.start`, `release.success`, `release.fail`.
Each event carries a sequence number, event type, phase, optional stream
(stdout/stderr), timestamp, and short message or output text.
## Output
Human output prints sequence number, event type, phase, stream, timestamp,
and message:
```text
--id <run-id> run id
--after <seq> only show events after this sequence
--limit <n> default 500, maximum 500
--json print JSON
1 lease.acquire.start plan 2026-05-07T07:42:18Z
2 lease.acquire.success plan 2026-05-07T07:42:21Z leased=cbx_abcdef123456 slug=blue-lobster
3 bootstrap.wait provision 2026-05-07T07:42:21Z
4 bootstrap.ready provision 2026-05-07T07:43:05Z
5 sync.start sync 2026-05-07T07:43:05Z
6 sync.success sync 2026-05-07T07:43:08Z files=184 bytes=12.4MiB
7 command.start run 2026-05-07T07:43:08Z pnpm test
8 output.stdout run 2026-05-07T07:43:09Z > vitest run
9 output.stdout run 2026-05-07T07:43:11Z ✓ src/foo.test.ts (8)
...
42 command.finish run 2026-05-07T07:45:32Z exit=0
43 release.success release 2026-05-07T07:45:34Z
```
Related:
`--json` returns the raw event records.
## Bounded Output Capture
Output events are a bounded preview. The coordinator caps stdout/stderr
capture at 64 KiB per run and records an `output.truncated` marker when
the cap is reached. The retained log keeps up to 8 MiB. For the larger
retained command output, use [logs](logs.md).
## Flags
```text
--id <run-id> run id (also accepted as a positional argument)
--after <seq> only show events after this sequence number
--limit <n> maximum number of events, default 500, maximum 500
--json print JSON
```
`--after` is what `attach` uses internally - resume from a known sequence
without replaying the whole event log.
## Use Cases
- post-mortem on a failed run when you need the exact sequence of phases;
- correlating a failed step with the timestamps of surrounding sync or
bootstrap events;
- scripting a status check that filters by event type;
- archiving event records for runs that exceeded the retained log cap.
Related docs:
- [history](history.md)
- [attach](attach.md)
- [logs](logs.md)
- [attach](attach.md)
- [results](results.md)
- [History and logs](../features/history-logs.md)

View File

@ -1,27 +1,106 @@
# init
`crabbox init` onboards a repository for agent-first remote verification.
It writes the minimum config needed for `crabbox run` and sets up the
optional Actions hydration bridge and agent skill.
```sh
crabbox init
crabbox init --force
crabbox init --workflow .github/workflows/crabbox-test.yml
```
It writes:
## Files It Writes
- `.crabbox.yaml`
- `.github/workflows/crabbox.yml`
- `.agents/skills/crabbox/SKILL.md`
```text
.crabbox.yaml repo defaults (provider, profile, class, sync, env)
.github/workflows/crabbox.yml Actions hydration stub (optional)
.agents/skills/crabbox/SKILL.md agent-facing skill instructions
```
The generated workflow is intentionally conservative. It is a starting point for repo-specific hydration, not a full replacement for CI. Edit it to install dependencies, start service containers, and warm caches before agents begin repeated `crabbox run` calls.
By default `init` will not overwrite existing files. `--force` overrides
that and replaces them with freshly generated content.
The workflow contract is the same one used by `crabbox actions hydrate`: it accepts the Crabbox lease ID and dynamic runner label, runs on that self-hosted runner, writes a ready marker under `$HOME/.crabbox/actions`, and keeps the job alive for the remote command loop.
## `.crabbox.yaml`
Flags:
A starting template that includes:
- a default `profile` and `class`;
- `sync.exclude` covering common heavy directories;
- `env.allow` with conservative defaults (`CI`, `NODE_OPTIONS`,
`PROJECT_*`);
- `actions.workflow` pointing at the generated workflow stub;
- `cache` toggles for pnpm, npm, docker, and git.
Open the file after `init` and adjust it to match the repo:
- pick the right `class` for the workload;
- add repo-specific `sync.exclude` patterns;
- expand `env.allow` for project-specific tunables;
- pin `sync.baseRef` to the project's default branch.
See [Configuration](../features/configuration.md) for the full schema.
## `.github/workflows/crabbox.yml`
The generated workflow is intentionally conservative. It is a starting
point for repo-specific hydration, not a full replacement for CI. Edit it
to install dependencies, start service containers, and warm caches before
agents begin repeated `crabbox run` calls.
The workflow contract is the one used by `crabbox actions hydrate`:
- accepts the Crabbox lease ID and dynamic runner label;
- runs on that self-hosted runner registered by Crabbox;
- writes a ready marker under `$HOME/.crabbox/actions`;
- keeps the job alive so the local CLI can run repeated commands in the
hydrated workspace.
If the repo has no Actions hydration plans, you can delete the workflow.
`crabbox run` works fine without it - hydration is optional.
## `.agents/skills/crabbox/SKILL.md`
Repo-local agent instructions. The generated skill explains:
- when to use Crabbox vs running locally;
- how to acquire and reuse leases;
- which commands the agent should prefer (`warmup`, `run --id`, `stop`);
- what env vars the project allows;
- where to find repo-specific test commands.
Edit this file to match how you want agents to operate in the repo. The
skill is read by OpenClaw and similar agent runtimes that auto-discover
`.agents/skills/`.
## Flags
```text
--force overwrite generated files
--config <path> repo config path
--workflow <path> workflow path
--skill <path> agent skill path
--config <path> repo config path (default ./.crabbox.yaml)
--workflow <path> Actions workflow path (default .github/workflows/crabbox.yml)
--skill <path> agent skill path (default .agents/skills/crabbox/SKILL.md)
```
## Idempotency
`init` is safe to re-run. Without `--force`, it leaves existing files
alone and exits with a summary of what would be created. With `--force`,
it replaces files atomically.
## After Init
```sh
crabbox doctor # validate the config
crabbox sync-plan # preview what would sync
crabbox warmup # acquire a lease
crabbox run -- pnpm test # run a command
```
Related docs:
- [Configuration](../features/configuration.md)
- [Repository onboarding](../features/repository-onboarding.md)
- [Actions hydration](../features/actions-hydration.md)
- [Sync](../features/sync.md)
- [Getting started](../getting-started.md)

View File

@ -1,6 +1,8 @@
# inspect
`crabbox inspect` prints detailed lease and provider metadata.
`crabbox inspect` prints detailed lease and provider metadata. Use it for
debugging coordinator state, provider labels, expiry, SSH target details,
and Tailscale metadata.
```sh
crabbox inspect --id blue-lobster
@ -9,23 +11,60 @@ crabbox inspect --id blue-lobster --json
crabbox inspect --provider ssh --target windows --windows-mode wsl2 --static-host win-dev.local
```
Use this for debugging coordinator state, provider labels, expiry, and SSH target details.
## Output
Flags:
Human output prints lease state, provider, server type, public IP, work
root, owner, org, idle timeout, TTL, expiry, last touched, the resolved
SSH command for the selected network mode, and any Tailscale metadata the
lease carries.
```text
--id <lease-id-or-slug>
--provider hetzner|aws|ssh
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>
--static-user <user>
--static-port <port>
--static-work-root <path>
--network auto|tailscale|public
--json
lease=cbx_abcdef123456 slug=blue-lobster
state=active provider=aws server=i-0abcdef0123456789 type=c7a.48xlarge
host=203.0.113.10 user=crabbox port=2222 work_root=/work/crabbox
owner=alex@example.com org=openclaw
idle_timeout=30m0s ttl=90m0s
created_at=2026-05-07T07:42:18Z last_touched=2026-05-07T07:55:12Z expires_at=2026-05-07T08:25:12Z
ssh: ssh -i ~/.config/crabbox/testboxes/cbx_abcdef123456/id_ed25519 -p 2222 crabbox@203.0.113.10
tailscale: state=ok ipv4=100.64.0.5 fqdn=blue-lobster.tail-scale.ts.net tags=tag:crabbox
```
JSON output includes non-secret Tailscale metadata when present. Human output
prints both the provider host and the resolved SSH command for the selected
network.
JSON output returns the structured record, including non-secret Tailscale
metadata. Secrets (broker tokens, provider keys, VNC passwords) are never
included.
## Flags
```text
--id <lease-id-or-slug> lease to inspect; required for managed providers
--provider hetzner|aws|azure|ssh|daytona override the configured provider
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host> static SSH host for provider=ssh
--static-user <user> static SSH user override
--static-port <port> static SSH port override
--static-work-root <path> static target work root
--network auto|tailscale|public select which address inspect prints
--json print JSON
```
## Inspect vs Status vs List
- `inspect` is the long-form record for one lease, including provider
metadata, label state, and the resolved SSH command;
- `status` is the shorter "is this lease healthy right now" check, with
optional `--wait` and bounded telemetry;
- `list` is the table view across many leases, scoped by owner/org or
fleet-wide for admins.
Use `inspect` when something is unexpected and you want all the detail in
one place. Use `status` when an automation needs a quick liveness check.
Use `list` when you are looking for a specific lease across the pool.
Related docs:
- [status](status.md)
- [list](list.md)
- [ssh](ssh.md)
- [Identifiers](../features/identifiers.md)
- [Network and reachability](../features/network.md)

View File

@ -35,7 +35,7 @@ use the normalized Crabbox lease view.
Flags:
```text
--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo
--provider hetzner|aws|azure|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 default provider to store with the broker
--provider hetzner|aws|azure 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

@ -7,9 +7,31 @@ crabbox logout
crabbox logout --json
```
The broker URL and provider are left in place so a later `crabbox login` or `crabbox login --token-stdin` can reuse them.
The broker URL and provider stay in place so a later `crabbox login` or
`crabbox login --token-stdin` can reuse them. Per-lease SSH keys, repo
claims, and history records are unaffected.
After logout:
- `crabbox whoami` exits with auth code 3 (`auth failure`);
- `crabbox run` and `crabbox warmup` against the coordinator fail with the
same code;
- direct-provider mode keeps working when local provider credentials
(AWS SDK, `HCLOUD_TOKEN`) are present, because direct mode does not need
the broker token.
Use logout when:
- a token has leaked or you want to rotate it;
- you are switching the operator identity on a shared workstation;
- you are testing the unauthenticated path.
To clear everything (URL, provider, token, profile defaults), edit the user
config file directly. `crabbox config path` prints the location.
Related docs:
- [login](login.md)
- [whoami](whoami.md)
- [Auth and admin](../features/auth-admin.md)
- [Configuration](../features/configuration.md)

View File

@ -1,20 +1,70 @@
# logs
`crabbox logs` prints the retained remote output for a recorded run.
`crabbox logs` prints the retained command output for a recorded run.
```sh
crabbox logs run_...
crabbox logs --id run_...
crabbox logs run_... --json
crabbox logs run_abcdef123456
crabbox logs --id run_abcdef123456
crabbox logs run_abcdef123456 --json
```
The plain form writes the log text to stdout. `--json` returns run metadata plus the log.
## What Gets Stored
Logs are bounded remote stdout/stderr captures. The CLI keeps up to 8 MiB per run and the coordinator stores larger captures in chunks, so failures from noisy parallel runs remain visible without turning run history into unlimited archival storage.
When `crabbox run` runs against a coordinator, it streams remote stdout and
stderr to the local terminal *and* records a bounded copy on the
coordinator. The CLI keeps up to 8 MiB of capture per run; the coordinator
stores larger captures in chunks so a noisy parallel run does not exceed
Durable Object storage limits.
Output beyond the cap is truncated with an `output.truncated` marker on the
last event so the consumer knows the tail is missing.
## Output
The plain form writes the log text to stdout. `--json` returns run metadata
plus the log:
```json
{
"runId": "run_abcdef123456",
"leaseId": "cbx_abcdef123456",
"exitCode": 0,
"truncated": false,
"log": "..."
}
```
`--json` is stable enough for scripts that filter by exit code and want the
log text in one payload.
## Flags
```text
--id <run-id> run id (also accepted as a positional argument)
--json print JSON with metadata and log text
```
## When To Use Logs vs Events vs Attach
- `logs` returns the retained command output. Use when you want the full
bounded transcript after the run finished.
- `events` returns ordered run events (lease, sync, command, output chunks,
finish). Use when you need to know *what happened* and *when*.
- `attach` follows live events. Use when the run is still active and you
want to watch it without re-attaching the original CLI.
Logs and events are independent surfaces - logs stay focused on command
output, events stay focused on lifecycle.
## Direct Mode
Direct-provider mode does not record runs centrally, so `crabbox logs` has
nothing to fetch. Use shell output or the local terminal log instead.
Related docs:
- [history](history.md)
- [events](events.md)
- [attach](attach.md)
- [results](results.md)
- [History and logs](../features/history-logs.md)

View File

@ -1,17 +1,22 @@
# results
`crabbox results` prints structured test summaries attached to a recorded run.
`crabbox results` prints structured test summaries attached to a recorded
run.
```sh
crabbox run --id cbx_... --junit junit.xml -- go test ./...
crabbox results run_...
crabbox results run_... --json
crabbox run --id cbx_abcdef123456 --junit junit.xml -- go test ./...
crabbox results run_abcdef123456
crabbox results run_abcdef123456 --json
```
Results are attached only when `crabbox run` is told where to find remote JUnit XML. Use either:
## When Results Are Attached
Results are attached only when `crabbox run` is told where to find remote
JUnit XML. Use either:
```sh
crabbox run --junit junit.xml -- <command...>
crabbox run --junit junit.xml,reports/junit.xml -- <command...>
```
or repo config:
@ -23,10 +28,76 @@ results:
- reports/junit.xml
```
Human output shows totals and failed test cases. JSON output returns the stored summary. Stored summaries keep aggregate counts but cap bulky failure details.
After the command exits, the CLI reads each remote file from the workdir,
parses JUnit, and sends only the summary to the coordinator. Raw XML is not
stored. Multiple JUnit files are merged into a single summary so a multi-
report test setup still produces one result record.
## Output
Human output shows totals and the names of failed test cases:
```text
run_abcdef123456 lease=cbx_abcdef123456 command="pnpm test"
totals: tests=412 failures=2 errors=0 skipped=4 time=42.318s
failures:
src/auth.test.ts > login → returns user
src/sync.test.ts > rsync → handles deletes
```
`--json` returns the stored structured summary:
```json
{
"runId": "run_abcdef123456",
"totals": { "tests": 412, "failures": 2, "errors": 0, "skipped": 4, "timeSeconds": 42.318 },
"failures": [
{ "suite": "src/auth.test.ts", "name": "login → returns user" },
{ "suite": "src/sync.test.ts", "name": "rsync → handles deletes" }
],
"files": [
{ "path": "junit.xml", "size": 12345 }
]
}
```
## Limits
The coordinator caps stored summaries:
- aggregate counters (tests, failures, errors, skipped) are kept verbatim;
- failed-case entries are capped to a bounded list;
- long strings (test names, suite names, message bodies) are truncated;
- file lists keep paths and sizes, never raw bytes.
This keeps the result record small enough for the lease detail page and
the run detail page to render without paging through gigabytes of XML.
## Flags
```text
--id <run-id> run id (also accepted as a positional argument)
--json print JSON
```
## When To Use Results vs Logs
- `results` is the structured summary - "did the suite pass, and which
cases failed?";
- `logs` is the retained command output - "what did the command print?".
Use `results` for dashboards and quick triage. Use `logs` when you need to
read the actual stack trace.
## Future Formats
Today only JUnit XML is supported. Vitest JSON, Go `test2json`, and flaky-
test correlation across runs are tracked in
[Test results](../features/test-results.md).
Related docs:
- [run](run.md)
- [history](history.md)
- [logs](logs.md)
- [Test results](../features/test-results.md)

View File

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

43
docs/commands/share.md Normal file
View File

@ -0,0 +1,43 @@
# share
`crabbox share` grants access to an existing coordinator lease.
```sh
crabbox share --id blue-lobster --user friend@example.com
crabbox share --id blue-lobster --user friend@example.com --role manage
crabbox share --id blue-lobster --org
crabbox share --id blue-lobster --org --role manage
crabbox share --id blue-lobster --list
crabbox share blue-lobster --list --json
```
Roles:
```text
use see the lease and use visible portal bridges such as WebVNC/code
manage use access plus changing sharing and stopping the lease
```
`--org` shares with authenticated users whose org matches the lease org.
`--user` is repeatable and stores normalized lowercase email addresses.
SSH-based commands still require a local private key accepted by the runner.
Sharing grants coordinator and portal access; it does not copy SSH private keys
between people.
Flags:
```text
--id <lease-id-or-slug>
--user <email>
--org
--role use|manage
--list
--json
```
Related docs:
- [unshare](unshare.md)
- [Auth and admin](../features/auth-admin.md)
- [Browser portal](../features/portal.md)

View File

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

View File

@ -1,25 +1,80 @@
# sync-plan
`crabbox sync-plan` prints the local sync manifest without leasing a box.
Use it to preview what `crabbox run` would send before paying for a cold
sync, or after editing `.crabboxignore` to confirm artifacts dropped out
of the manifest.
```sh
crabbox sync-plan
crabbox sync-plan --limit 10
crabbox sync-plan --limit 25 --json
```
It uses the same Git file-list manifest, `.crabboxignore`, and config excludes
as `crabbox run`, then prints:
## What It Reads
`sync-plan` uses the same Git file-list manifest, `.crabboxignore`, and
`sync.exclude` rules as `crabbox run`:
- tracked files from `git ls-files --cached`;
- nonignored untracked files from
`git ls-files --others --exclude-standard`;
- root `.crabboxignore` patterns;
- repo-local `sync.exclude` patterns;
- Crabbox's default cache/build excludes.
It does not require a lease, does not call the broker, and does not call
any provider API.
## Output
Default output prints:
- candidate file count and total bytes;
- tracked deletes that would be applied remotely;
- largest files;
- largest first or second-level directories.
- the largest files;
- the largest first or second-level directories.
Use it before a cold sync when the preflight estimate looks too large, or after
editing `.crabboxignore` to confirm that local artifacts dropped out of the
manifest.
```text
files: 1843
bytes: 312.5MiB
tracked deletes: 0
largest files:
84.5MiB assets/demo.mp4
12.4MiB fixtures/sample-data.json
...
largest directories:
140.2MiB assets
80.1MiB fixtures
...
```
## Flags
```text
--limit <n> show this many files and directories in each top list (default 5)
--json print structured JSON output
```
`--limit 0` shows the full lists (use sparingly; large repos produce big
output).
## Use Cases
- preview a first sync before warming a beast-class lease;
- find sneaky directories that grew (`.cache/`, `dist/`, generated assets);
- audit `.crabboxignore` after adding new excludes;
- compare repo footprint over time as part of repo health checks.
The numbers `sync-plan` prints are upper bounds; rsync's actual transfer
size depends on what is already on the remote runner. Repeat sync after a
warmup is much smaller because the manifest matches the remote fingerprint
and rsync ships only changed bytes.
Related docs:
- [run](run.md)
- [Sync](../features/sync.md)
- [Configuration](../features/configuration.md)

30
docs/commands/unshare.md Normal file
View File

@ -0,0 +1,30 @@
# unshare
`crabbox unshare` removes sharing from an existing coordinator lease.
```sh
crabbox unshare --id blue-lobster --user friend@example.com
crabbox unshare --id blue-lobster --org
crabbox unshare --id blue-lobster --all
crabbox unshare blue-lobster --all --json
```
Use `--user` to remove individual users, `--org` to remove org-wide access, or
`--all` to clear every sharing rule. Only the lease owner, a `manage` share, or
an admin session can change sharing.
Flags:
```text
--id <lease-id-or-slug>
--user <email>
--org
--all
--json
```
Related docs:
- [share](share.md)
- [Auth and admin](../features/auth-admin.md)
- [Browser portal](../features/portal.md)

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|ssh
--provider hetzner|aws|azure|ssh|daytona
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>

View File

@ -9,6 +9,7 @@ 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
@ -41,8 +42,10 @@ 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, or
`--provider ssh --target windows` for an existing Hetzner Windows host.
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.
With `--provider aws --target windows --windows-mode normal --desktop`, Crabbox
creates a real AWS Windows Server lease. EC2Launch user data installs OpenSSH
@ -57,6 +60,11 @@ 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
@ -69,7 +77,7 @@ On success, `warmup` prints a concise total duration line. Add `--timing-json` t
Flags:
```text
--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo
--provider hetzner|aws|azure|ssh|blacksmith-testbox|daytona|islo
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>

View File

@ -11,7 +11,12 @@ crabbox warmup --desktop
crabbox webvnc --id blue-lobster
crabbox webvnc --id blue-lobster --network tailscale
crabbox webvnc --id blue-lobster --open
crabbox webvnc --id blue-lobster --daemon --open
crabbox webvnc daemon start --id blue-lobster --open
crabbox webvnc daemon status --id blue-lobster
crabbox webvnc daemon stop --id blue-lobster
crabbox webvnc status --id blue-lobster
crabbox webvnc status --id blue-lobster --network tailscale
crabbox webvnc reset --id blue-lobster --open
```
## How It Works
@ -45,16 +50,68 @@ This keeps the security boundary the same as `crabbox vnc`:
- VNC stays bound to runner loopback.
- The cloud provider does not open public VNC ingress.
- The coordinator authenticates the browser through portal auth and the bridge
through a one-use short-lived ticket.
through a one-use short-lived ticket. The CLI sends the ticket as an
`Authorization: Bearer ...` header so it stays out of websocket URLs and
proxy/access logs; the coordinator falls back to a `?ticket=` query string
for older CLIs.
- The noVNC client is served from the coordinator origin, not a third-party CDN.
- The local `crabbox webvnc` process must keep running while the browser uses
the desktop.
Use `--daemon` (or `--background`) to keep the bridge running without a tmux or
foreground shell. Crabbox writes the bridge log and pid file under its local
state directory and prints both paths. Use `--status` to print those paths
again, and `--stop` to kill the background bridge for that lease. Shutdown
terminates both the daemon supervisor and the active child bridge process.
Use `crabbox webvnc daemon start --id <lease> --open` to keep the bridge
running without a tmux or foreground shell. Crabbox writes the bridge log and
pid file under its local state directory and prints both paths. Use
`crabbox webvnc daemon status --id <lease>` for the local pid/log check, and
`crabbox webvnc daemon stop --id <lease>` to kill the background bridge for
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.
Use `crabbox webvnc status --id <lease>` for the full health view: local daemon
pid/log, SSH tunnel command, target VNC reachability, coordinator bridge/viewer
state, recent bridge events, portal URL/password, and the exact native VNC
fallback command. If status or reset is run with `--network public` or
`--network tailscale`, the printed native VNC fallback carries the same network
selection.
Typical status output is meant to be directly actionable:
```text
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
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
```
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.
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
WebVNC sockets, stops only that lease's local daemon pid after verifying it is
a Crabbox WebVNC process, restarts the target desktop helper/VNC services, then
starts a fresh background bridge and prints the new portal URL.
`--network tailscale` changes only the SSH endpoint used for the local tunnel.
The runner VNC service stays bound to loopback.
@ -69,8 +126,9 @@ flow redirects first, the page may still prompt for the VNC password; use the
password printed by the command. If an old browser tab is retrying with a stale
fragment, close it before opening the new bridge URL.
The portal page may show `waiting for bridge` until the local command has
connected. If you opened the portal first, start:
The portal page may show `WebVNC daemon not running` or `waiting for VNC
bridge` until the local command has connected. If you opened the portal first,
start:
```sh
crabbox webvnc --id <lease-id-or-slug>
@ -78,13 +136,26 @@ crabbox webvnc --id <lease-id-or-slug>
in a terminal and leave it running.
For human demos, prefer WebVNC over native VNC because `crabbox webvnc --open`
preloads the per-lease password in the local browser URL fragment. Use native
VNC only as the fallback printed by `crabbox webvnc status` or
`crabbox webvnc reset`.
The WebVNC toolbar includes clipboard controls. The paste control reads the
local browser clipboard, sends it through noVNC, and then sends the target paste
shortcut: Command-V for macOS targets, Ctrl-V for Linux and Windows targets.
When the remote VNC server publishes clipboard text, the copy-remote control is
enabled; click it to write that remote text into the local browser clipboard.
Browsers require a user gesture for clipboard writes, so remote-to-local copy is
explicit instead of fully automatic.
## Flags
Flags:
```text
--id <lease-id-or-slug>
--provider hetzner|aws
--provider hetzner|aws|azure
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>
@ -94,10 +165,11 @@ Flags:
--network auto|tailscale|public
--local-port <port>
--open
--daemon
--background
--status
--stop
status
reset
daemon start
daemon status
daemon stop
--reclaim
```
@ -105,7 +177,7 @@ Flags:
Limitations:
- Coordinator-backed Hetzner and AWS desktop leases are supported.
- Coordinator-backed Hetzner, AWS, and Azure Linux 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.
@ -117,7 +189,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 desktop leases`
`webvnc currently supports coordinator-backed hetzner/aws/azure 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
@ -129,12 +201,26 @@ The lease is reachable over SSH, but the desktop service is not ready or was not
provisioned. Create the lease with `--desktop`, or wait for bootstrap to finish
and retry.
The portal keeps saying `waiting for bridge`
The portal keeps saying `WebVNC daemon not running` or `waiting for VNC bridge`
The browser can reach the coordinator, but no local bridge is currently paired
with that lease. Start or restart `crabbox webvnc --id <lease>` locally and keep
the process running. If the command is still running, wait for the portal retry
or reload the browser tab.
with that lease. Start or restart `crabbox webvnc daemon start --id <lease>
--open`, or run `crabbox webvnc reset --id <lease> --open` when stale tabs or
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`
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:
```sh
crabbox webvnc reset --id <lease-id-or-slug> --open
```
If WebVNC remains unreliable, use the exact native fallback command printed by
`crabbox webvnc status --id <lease-id-or-slug>`.
VNC authentication fails

View File

@ -1,21 +1,77 @@
# whoami
`crabbox whoami` verifies broker auth and prints the identity the coordinator sees.
`crabbox whoami` verifies broker auth and prints the identity the
coordinator sees.
```sh
crabbox whoami
crabbox whoami --json
```
Human output:
## Human Output
```text
user=steipete@gmail.com org=openclaw auth=github broker=https://crabbox.openclaw.ai
user=alex@example.com org=openclaw auth=github broker=https://crabbox.openclaw.ai
```
Identity normally comes from the signed GitHub login token. Shared bearer-token automation reports owner/org from `X-Crabbox-Owner` and `X-Crabbox-Org`; the CLI fills those from `CRABBOX_OWNER`, Git email env, `git config user.email`, and `CRABBOX_ORG`. Raw Cloudflare Access identity headers are ignored; only a verified Access JWT email can become the bearer-token owner. JSON output also reports the forwarded auth mode, such as `github` or `bearer`.
The fields:
- `user` - the resolved owner email.
- `org` - the organization namespace, when set.
- `auth` - the authentication mode the coordinator accepted (`github` for
signed login tokens, `bearer` for shared automation tokens).
- `broker` - the configured coordinator URL.
## JSON Output
```json
{
"owner": "alex@example.com",
"org": "openclaw",
"auth": "github",
"broker": "https://crabbox.openclaw.ai",
"tokenSource": "user-config",
"accessJwtVerified": false
}
```
JSON output also reports the forwarded auth mode, where the token came
from (`user-config`, `env`, `stdin`), and whether a verified Cloudflare
Access JWT was present.
## Identity Sources
Identity normally comes from the signed GitHub login token. The browser
flow embeds the verified GitHub email and allowed-org membership in a
short-lived signed token; the coordinator extracts owner/org from that
token, not from headers.
Shared bearer-token automation reports owner/org from `X-Crabbox-Owner` and
`X-Crabbox-Org`. The CLI fills those headers from:
- `CRABBOX_OWNER` env (highest precedence);
- `GIT_AUTHOR_EMAIL` or `GIT_COMMITTER_EMAIL` env;
- `git config user.email`;
- `CRABBOX_ORG` env for the org header.
Raw Cloudflare Access identity headers are ignored. Only a verified Access
JWT email (with the JWT validated against the Cloudflare team's public
keys) can become the bearer-token owner.
## Exit Codes
```text
0 identity resolved successfully
2 broker URL or token missing
3 auth failure (token rejected, GitHub org membership missing, etc.)
```
Use `whoami` in CI scripts before any long workflow to fail fast on auth
issues.
Related docs:
- [login](login.md)
- [logout](logout.md)
- [Auth and admin](../features/auth-admin.md)
- [Broker auth and routing](../features/broker-auth-routing.md)

256
docs/concepts.md Normal file
View File

@ -0,0 +1,256 @@
# Concepts
Read when:
- you encounter a Crabbox term you do not recognize;
- you are writing docs and want to stay consistent with existing usage;
- you need a single page that lays out the vocabulary.
This page is a glossary. It defines the nouns and the verbs Crabbox uses
across the CLI, broker, providers, and docs. When two synonyms exist, the
preferred form is in **bold**.
## Compute Vocabulary
**Lease** - a time-bounded reservation of a remote runner that Crabbox
created or resolved. Has a canonical ID (`cbx_...`), a friendly slug, an
idle timeout, a TTL, and a state (`active`, `released`, `expired`,
`failed`). Leases are the unit of cost accounting and cleanup.
**Runner** - the remote machine itself. Provisioned by the provider,
prepared by cloud-init, used for one or more leases. Crabbox does not
distinguish between a Hetzner cloud server, an AWS EC2 instance, and a
static SSH host beyond what the provider backend tells it - all are
runners.
**Box** / **Testbox** - informal synonym for runner. Used in the README and
some early docs. Prefer "runner" in new docs unless the surrounding context
is talking about leases as a product (in which case "box" reads better).
**Pool** - the set of currently active runners visible to a user, org, or
the whole fleet. `crabbox list` and `/v1/pool` both expose it.
**Slug** - the friendly name for a lease. Looks like `blue-lobster`.
Generated from a stable hash of the lease ID; collisions append a 4-hex
suffix. See [Identifiers](features/identifiers.md).
**Lease ID** - the canonical machine-friendly identifier
(`cbx_abcdef123456`). Used in labels, logs, and APIs. Always 16 chars.
**Run** - a single `crabbox run` invocation against a coordinator. Has a
`run_...` ID, an owning lease, a command, an exit code, and a record in
coordinator history.
## Roles
**CLI** - the local Go binary `crabbox`. Owns config, sync, command
execution, output streaming, and per-lease SSH keys. See
[Architecture](architecture.md).
**Broker** / **Coordinator** - the Cloudflare Worker plus Fleet Durable
Object. Owns provider credentials, lease state, expiry, cleanup alarms,
usage, and cost. Both terms are used interchangeably; "coordinator" is
preferred in feature docs that emphasize state, "broker" when emphasizing
the trust boundary between CLI and provider.
**Provider** - a Crabbox component that knows how to acquire, resolve,
list, and release runners on a backing service. Built-in providers: AWS,
Hetzner, Static SSH, Blacksmith Testbox, Daytona, Islo. See
[Provider reference](providers/README.md).
**Backend** - the Go interface a provider implements:
`SSHLeaseBackend` for providers that hand Crabbox a real SSH target,
`DelegatedRunBackend` for providers that own command execution
themselves. See [Provider backends](provider-backends.md).
**Operator** - a person with broker-side access (admin token, Cloudflare
config). Operators run `crabbox admin` commands and image bake/promote
flows.
**Agent** - an LLM-backed process invoking Crabbox through the CLI or the
OpenClaw plugin. Agents are first-class users of Crabbox; the docs
intentionally write for both humans and agents.
## Modes
**Brokered mode** / **coordinator mode** - the normal path, where the CLI
talks to the Cloudflare Worker for lease creation, lease state, and
cleanup. Provider secrets stay broker-side. Used for shared team
infrastructure.
**Direct mode** / **direct-provider mode** - the local-debug fallback, where
the CLI talks straight to the provider API (AWS SDK, Hetzner API, Daytona
SDK, Islo SDK). No coordinator, no central history, no spend caps. Use
when you are debugging the broker itself.
**Static mode** - lease behavior for `provider: ssh`. The host is operator-
owned; Crabbox does not provision or delete it. Bypasses both broker and
direct provisioning paths.
**Delegated mode** - the path used by Blacksmith, Islo, and the Daytona
`run` flow. The provider owns command execution and streams output back to
Crabbox. Crabbox-owned sync (`--sync-only`, `--checksum`) is rejected;
sync timing reports `sync=delegated`.
## Commands
**warmup** - acquire a lease and keep it ready. No command runs yet.
**run** - acquire or reuse a lease, sync, run a command, stream output,
release.
**stop** - release a specific lease and delete its provider resources.
**cleanup** - sweep direct-provider leftovers based on labels. Refuses
when a coordinator is configured.
**reuse** - using `--id` (or a slug) to pick an existing lease instead of
creating a new one. Both `warmup` (idempotent) and `run` accept `--id`.
**reclaim** - move a local claim from one repo to another so a lease
created in repo A can be reused from repo B. Required because Crabbox
binds leases to repos by default.
**hydrate** - prepare a runner with project dependencies, usually by
dispatching a real GitHub Actions job that registers an ephemeral
self-hosted runner. The CLI then runs the local command in the hydrated
workspace. See [Actions hydration](features/actions-hydration.md).
## State
**Idle timeout** - the duration a lease may go without heartbeats before
the broker auto-releases it. Default 30m. Reset by every heartbeat or
explicit touch.
**TTL** - the absolute maximum wall-clock lifetime of a lease. Default
90m. Cannot be extended by heartbeats. `expiresAt = min(createdAt + ttl,
lastTouchedAt + idleTimeout)`.
**Heartbeat** - a `POST /v1/leases/{id}/heartbeat` call sent by the CLI
during long-running commands. Updates `lastTouchedAt`, can ship telemetry
samples, and can update idle timeout when explicitly requested.
**Touch** - lower-level synonym for "update lease state and idle". The
provider's `Touch` method is what handles direct-provider state updates;
heartbeat is the brokered equivalent.
**Reserved cost** - the worst-case TTL cost the broker reserves for a
lease at creation time (`hourlyRate × ttl`). Charged against the monthly
spend cap until the lease ends; freed on release. Distinct from elapsed
runtime cost, which is reported by `crabbox usage`.
**Estimated cost** - elapsed-runtime cost for a lease, computed from the
hourly rate and the time spent in `active`. What `crabbox usage` reports
as a billing approximation.
## Sync
**Manifest** - the NUL-delimited list of paths Crabbox will sync, built
from `git ls-files --cached` and `git ls-files --others --exclude-standard`.
**Fingerprint** - a hash of the commit, dirty file metadata, and manifest.
When the local fingerprint matches the remote one, Crabbox skips rsync.
**Git seeding** - the optional first-sync step where Crabbox fetches the
configured origin/base ref into the runner's Git directory before rsync,
so changed-file diffs are available remotely.
**Base ref** - the Git ref that Crabbox seeds and hydrates. Default
`main`. Configurable per repo in `sync.baseRef`.
**Sanity check** - a guardrail run after rsync that detects mass tracked
deletions, missing manifest entries, and other suspicious sync outcomes.
## Capabilities
**Desktop** - lease capability that adds Xvfb + XFCE + x11vnc. Required
for `crabbox vnc`, `crabbox webvnc`, and most `--browser` UI runs.
**Browser** - lease capability that installs Chrome/Chromium and exports
`BROWSER`/`CHROME_BIN`. Useful for Playwright/Vitest/etc. without a full
QA harness.
**Code** - lease capability that installs code-server bound to loopback.
Used by `crabbox code` and the portal `/code/` bridge.
**Tailscale** - optional reachability layer for managed Linux leases.
Joins the lease to the configured tailnet so clients on the tailnet can
reach the runner without the public IP. Distinct from the network mode
(`--network tailscale`) that selects which plane the CLI uses.
## Backplane
**Durable Object** - the Cloudflare Worker primitive that holds Crabbox
fleet state. Crabbox uses one fleet Durable Object so all scheduling
decisions are serialized.
**Alarm** - the Durable Object scheduling primitive that fires on a future
timestamp. Crabbox uses alarms for idle-timeout sweeps and TTL cleanup.
**Portal** - the server-rendered web UI hosted by the same Worker. Pages
under `/portal/...`. See [Browser portal](features/portal.md).
**Bridge** - a portal endpoint that proxies traffic to a loopback service
on the lease (VNC, code-server). Bridges authenticate against the portal
session, then talk to the lease over the internal SSH plane.
## Identity
**Owner** - the email address that owns a lease. Resolved from the signed
GitHub login token, `CRABBOX_OWNER`, Git env, or `git config user.email`.
**Org** - the GitHub-style organization namespace for a lease. Resolved
from the signed token or `CRABBOX_ORG`. Used for usage scoping and
multi-tenant cost caps.
**Allowed org** - the GitHub org membership the broker requires before
issuing a signed login token. Configured per Cloudflare Worker.
**Admin token** - the separately scoped token required for `/v1/pool`,
admin lease routes, and fleet-wide listing. Held more closely than the
shared automation token.
**Cloudflare Access** - optional protection layer in front of the Worker.
When configured, the Worker only trusts the `CF-Access-Jwt-Assertion`
header (verified upstream); raw identity headers from the client are
ignored.
## Storage
**State directory** - where the CLI keeps local state (claims, per-lease
keys, known_hosts). Defaults to `$XDG_STATE_HOME/crabbox`, falling back to
the platform-specific user config directory.
**Claim** - a JSON file under the state directory binding a lease to a
repo. Required for `crabbox run --id` to resolve slugs and to refuse
cross-repo reuse without `--reclaim`.
**Workdir** / **work root** - the directory on the runner where Crabbox
syncs the repo. Default `/work/crabbox` on Linux; provider-specific on
Windows and macOS.
## Documentation
**Source map** - the doc page that points each user-facing behavior at the
implementation file behind it. Updated when behavior changes. See
[Source map](source-map.md).
**Feature page** - a doc under `docs/features/<name>.md` describing what
Crabbox does in one capability area. Owns the conceptual story; commands
and providers cross-link from here.
**Command page** - a doc under `docs/commands/<name>.md` describing the
flags, behavior, and exit codes of one CLI command. One per top-level
command, kept in sync with `--help` by `scripts/check-command-docs.mjs`.
**Provider page** - a doc under `docs/providers/<name>.md` describing one
provider's targets, config keys, env vars, sync behavior, and expected
failures.
Related docs:
- [How Crabbox Works](how-it-works.md)
- [Architecture](architecture.md)
- [CLI](cli.md)
- [Configuration](features/configuration.md)
- [Provider backends](provider-backends.md)

View File

@ -8,39 +8,65 @@ Read when:
- you are deciding where a behavior belongs;
- you need the feature-level contract before changing code.
Core features:
## Foundations
- [Configuration](configuration.md): precedence, YAML schema, profiles, classes, env vars.
- [Identifiers](identifiers.md): lease IDs, slugs, run IDs, claims, and how lookup resolves.
- [Doctor checks](doctor.md): what `crabbox doctor` validates and how to extend it.
- [Network and reachability](network.md): `--network auto|tailscale|public`, port fallback, public/tailnet planes.
- [Lease capabilities](capabilities.md): `--desktop`, `--browser`, and `--code` selection rules.
- [Environment forwarding](env-forwarding.md): name-based env allowlist for the remote command.
## Brokered fleet
- [Coordinator](coordinator.md): brokered leases through Cloudflare Workers and Durable Objects.
- [Broker auth and routing](broker-auth-routing.md): GitHub login, shared bearer tokens, optional Cloudflare Access, and Worker routes.
- [Browser portal](portal.md): authenticated lease/run UI, detail pages, bridge routes, and runner visibility.
- [Broker auth and routing](broker-auth-routing.md): GitHub login, shared bearer tokens, optional Cloudflare Access, and Worker routes.
- [Auth and admin](auth-admin.md): login/logout/whoami and trusted operator controls.
- [Telemetry](telemetry.md): lightweight Linux load, memory, disk, uptime, and run resource samples.
- [History and logs](history-logs.md): coordinator run records, events, and retained remote output.
- [Cost and usage](cost-usage.md): guardrails, provider-backed pricing, and reporting.
- [Lifecycle cleanup](lifecycle-cleanup.md): release, expiry, keep mode, and direct cleanup.
## Providers
- [Providers](providers.md): provider overview, target matrix, classes, and fallback.
- [Provider backends](../provider-backends.md): implementation guide for adding a new provider/backend/plugin.
- [Provider authoring](provider-authoring.md): step-by-step guide for adding a provider package.
- [Capacity and fallback](capacity-fallback.md): class chains, market spot/on-demand, region/AZ routing.
- [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.
- [Islo](islo.md): delegated Islo sandbox runs using the Islo Go SDK.
## Runners and reachability
- [Tailscale](tailscale.md): optional tailnet reachability for managed Linux leases and static hosts.
- [Mediated egress](egress.md): browser/app egress through an operator machine using the Cloudflare Worker mediator.
- [Runner bootstrap](runner-bootstrap.md): cloud-init, installed tools, SSH port, and readiness.
- [Prebaked runner images](prebaked-images.md): provider-owned image storage and the image/cache/state boundary.
- [Image bake runbook](image-bake-runbook.md): exact AWS bake, candidate smoke, promotion, rollback, and cleanup flow.
- [SSH keys](ssh-keys.md): per-lease keys, provider key cleanup, and local storage.
## Sync, run, and recording
- [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.
- [SSH keys](ssh-keys.md): per-lease keys, provider key cleanup, and local storage.
- [Cost and usage](cost-usage.md): guardrails, provider-backed pricing, and reporting.
- [History and logs](history-logs.md): coordinator run records, events, and retained remote output.
- [Telemetry](telemetry.md): lightweight Linux load, memory, disk, uptime, and run resource samples.
- [Test results](test-results.md): JUnit summaries attached to recorded runs.
- [Cache controls](cache.md): inspect, purge, and warm remote package/build caches.
- [Auth and admin](auth-admin.md): login/logout/whoami and trusted operator controls.
- [Lifecycle cleanup](lifecycle-cleanup.md): release, expiry, keep mode, and direct cleanup.
## Integrations
- [OpenClaw plugin](openclaw-plugin.md): agent tools that wrap the CLI.
- [Repository onboarding](repository-onboarding.md): `crabbox init`, repo config, workflow stub, and agent skill.
- [Source map](../source-map.md): implementation files behind documented behavior.
Command docs:
## Command docs
- [doctor](../commands/doctor.md)
- [init](../commands/init.md)
@ -49,6 +75,7 @@ Command docs:
- [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 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.
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.
The flow:

119
docs/features/artifacts.md Normal file
View File

@ -0,0 +1,119 @@
# 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

@ -26,6 +26,9 @@ crabbox login --no-browser
crabbox login --url <url> --token-stdin
crabbox whoami
crabbox logout
crabbox share --id blue-lobster --user friend@example.com
crabbox share --id blue-lobster --org
crabbox unshare --id blue-lobster --user friend@example.com
```
Trusted operator controls:
@ -41,15 +44,23 @@ Admin commands require the separate admin token. GitHub browser-login tokens can
Normal user tokens are owner/org scoped:
```text
GET /v1/leases own leases only
GET /v1/leases/{id-or-slug} exact ID and slug lookup must match owner/org
POST /v1/leases/{id}/heartbeat own leases only
POST /v1/leases/{id}/release own leases only
GET /v1/leases own and shared leases only
GET /v1/leases/{id-or-slug} exact ID and slug lookup must be visible
POST /v1/leases/{id}/heartbeat own or shared leases
PUT/DELETE /v1/leases/{id}/share owner, manage share, or admin only
POST /v1/leases/{id}/release owner, manage share, or admin only
GET /v1/runs and logs own runs only
GET /v1/usage own usage only
GET /v1/pool admin token only
```
Lease sharing grants coordinator and portal access without distributing the
shared bearer token or admin token. A `use` share can see the lease and open
visible portal bridges such as WebVNC/code. A `manage` share can also change
sharing and stop the lease. `--org` shares with authenticated users whose org
matches the lease org. SSH-based CLI use still requires a local private key
accepted by the runner; sharing does not copy SSH private keys between users.
Do not distribute the shared token or admin token to untrusted users. Keep the admin token narrower and more closely held than the shared automation token.
Related docs:

133
docs/features/azure.md Normal file
View File

@ -0,0 +1,133 @@
# 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

@ -0,0 +1,199 @@
# Lease Capabilities
Read when:
- adding `--desktop`, `--browser`, or `--code` to a workflow;
- changing how Crabbox detects whether a lease can host a visible desktop;
- adding a new lease capability flag.
Lease capabilities are opt-in features that change what a managed runner can
do beyond running headless commands. They are a separate concept from the
provider feature set declared in `ProviderSpec.Features`: feature set says
"this provider can support a desktop"; lease capability says "this lease was
created with a desktop and exposes one right now".
## The Three Capabilities
```text
--desktop visible desktop with a loopback VNC server
--browser Chrome/Chromium installed and exported via $BROWSER and $CHROME_BIN
--code code-server bound to a loopback port for portal/code bridging
```
All three default to off. They have to be requested at lease creation time
(`crabbox warmup --desktop`) and reused afterwards. A lease created without a
capability cannot grow it later.
## Selection And Validation
Capability flags follow a two-step validation:
1. **Provider feature check.** When the user sets a capability flag,
`validateRequestedCapabilities` looks up the selected provider's
`Spec.Features` and rejects the request if the matching feature
(`FeatureDesktop`, `FeatureBrowser`, `FeatureCode`) is missing. Hetzner
Linux supports all three; Blacksmith Testbox supports none.
2. **Lease label check.** When reusing a lease (`--id`),
`enforceManagedLeaseCapabilities` checks the matching label
(`desktop=true`, `browser=true`, `code=true`) on the existing lease. If
the label is missing, Crabbox refuses with a hint to warm a new lease.
For static SSH targets, label enforcement is skipped because Crabbox does not
own the host. The capability is detected probe-by-probe instead - `--desktop`
on a static target probes the loopback VNC port; `--browser` on a static
target probes for Chrome and exports `BROWSER`/`CHROME_BIN` from what it
finds.
`--code` is currently restricted to managed Linux leases. The validator
rejects it for Windows, macOS, and static SSH.
## Desktop
When a managed Linux lease is created with `--desktop`, bootstrap installs:
- Xvfb (virtual framebuffer);
- a slim XFCE session;
- x11vnc bound to `127.0.0.1:5900`;
- a randomized VNC password at `/var/lib/crabbox/vnc.password`;
- screenshot tooling (`scrot`) and ffmpeg.
`crabbox vnc --id ...` opens an SSH tunnel to that loopback port. The user's
local VNC viewer talks through the tunnel and uses the password the CLI
fetches from `/var/lib/crabbox/vnc.password`. There is no public VNC port; the
loopback bind is the security boundary.
Static targets must already expose loopback VNC at `127.0.0.1:5900`. macOS
hosts can enable Screen Sharing; Windows hosts need a VNC server bound to
loopback (TightVNC works).
For per-OS detail and known limits, see:
- [Linux VNC](vnc-linux.md);
- [Windows VNC](vnc-windows.md);
- [macOS VNC](vnc-macos.md);
- [Interactive desktop and VNC](interactive-desktop-vnc.md).
When the run injects environment, Crabbox also sets:
```text
DISPLAY=:99
CRABBOX_DESKTOP=1
```
Tools that respect `DISPLAY` will draw onto the desktop the lease created.
## Browser
`--browser` adds a usable browser to the lease without dragging in a full QA
test environment.
On managed Linux:
- Google Chrome stable when available;
- Chromium fallback;
- native addon build helpers (`build-essential`, `libgbm-dev`,
`libnss3-dev`, etc.) so dependency installs that compile against Chromium
succeed.
On static targets, Crabbox probes for an existing browser and reports an
error if none is found. `requestedCapabilityEnv` shells out to the host:
- macOS: `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`;
- Windows: `chrome.exe` or `msedge.exe` from PATH or the standard install
directories;
- Linux: `$BROWSER`, `$CHROME_BIN`, then `google-chrome`, `chromium`, or
`chromium-browser` from PATH.
The detected path is exported into the run as:
```text
BROWSER=/path/to/browser
CHROME_BIN=/path/to/browser
CRABBOX_BROWSER=1
```
Test runners that read `BROWSER` or `CHROME_BIN` (Vitest, Playwright, etc.)
work without extra plumbing. If a browser is requested but no binary is
found, the run aborts before the command starts.
For browser QA where the remote service is sensitive to source IP (Discord
login, Slack workspace bootstrap, regional CDN behavior), pair `--browser`
with [mediated egress](egress.md). `crabbox egress start` opens a lease-local
proxy that exits to the internet through the operator machine, and `crabbox
desktop launch --egress <profile>` passes that proxy to Chrome.
## Code
`--code` provisions code-server on managed Linux leases:
- installs the binary at `/usr/local/bin/code-server`;
- binds to a loopback port (default `8080`);
- generates an auth token stored in coordinator state.
The portal and `crabbox code --id ...` open a code-server tab through the
authenticated portal bridge at `/portal/leases/{id-or-slug}/code/`. The bridge
proxies HTTP and WebSocket traffic to the loopback port; the code-server
auth token is injected by the bridge so the user does not see it. There is no
public code-server port.
Code is managed-Linux-only because the bridge depends on the lease shape and
the cloud-init that prepares the binary. Windows, macOS, and static SSH are
intentionally not supported today.
## Capability Labels
Managed lease records carry capability labels so list, status, and detail
pages can render the capability matrix without re-probing the host:
```text
desktop=true|false
browser=true|false
code=true|false
```
`enforceManagedLeaseCapabilities` reads these labels to gate `--desktop`,
`--browser`, and `--code` on `--id` reuse paths. The labels are written when
the lease is created and never flipped on a live lease.
## Composing Capabilities
Capabilities are independent - any combination is allowed where the
provider supports them:
```sh
crabbox warmup --desktop # desktop only
crabbox warmup --desktop --browser # browser running on the desktop
crabbox warmup --desktop --browser --code # full interactive box
crabbox warmup --browser # headless browser, no VNC
crabbox warmup --code # editor-only Linux lease
```
Capability bootstrap adds installation time. A bare lease is the fastest to
warm; a lease with all three takes the longest. Use the lightest combination
that satisfies the workflow.
## Static Targets
For static SSH hosts, capability validation degrades to probe-based detection:
- `--desktop`: probe `127.0.0.1:5900` over SSH; fail with a clear error if
the port is not bound;
- `--browser`: probe for a browser binary using the OS-specific search list;
fail if none found;
- `--code` is rejected (managed Linux only).
This is intentional. Crabbox is not responsible for installing software on
operator-owned static hosts; if the box does not expose the capability, the
run should not silently fall back.
Related docs:
- [warmup command](../commands/warmup.md)
- [run command](../commands/run.md)
- [vnc command](../commands/vnc.md)
- [webvnc command](../commands/webvnc.md)
- [code command](../commands/code.md)
- [egress command](../commands/egress.md)
- [Interactive desktop and VNC](interactive-desktop-vnc.md)
- [Mediated egress](egress.md)
- [Browser portal](portal.md)

View File

@ -0,0 +1,215 @@
# Capacity And Fallback
Read when:
- adding or changing machine classes;
- debugging "why did Crabbox pick this instance type?";
- working on AWS spot/on-demand fallback or Hetzner location fallback;
- configuring multi-region or multi-AZ capacity for AWS.
Crabbox cares about capacity in three ways:
1. **Class fallback** - the ordered list of provider types that satisfy a
class request.
2. **Market fallback** - AWS-specific Spot to On-Demand failover within a
class.
3. **Region/AZ routing** - where the broker tries to provision when capacity
is tight in a single zone.
Hetzner only deals with class fallback. AWS deals with all three. Static
SSH, Blacksmith, Daytona, and Islo do not have capacity fallback because
the operator or external service controls the underlying resources.
## Classes
Class names are provider-agnostic intent labels:
```text
standard typical CI lane
fast ~2x more cores than standard for parallel-friendly suites
large memory-heavy or many-process workloads
beast maximum capacity within the provider's burstable family
```
Each provider maps the four class names to an ordered list of concrete
instance types. The list is the fallback chain: try the first; if rejected,
try the second; and so on.
The full Hetzner and AWS class tables live in
[Providers](providers.md#hetzner-summary). The table also lists the AWS
Windows, Windows WSL2, and macOS class maps.
## When Class Fallback Triggers
Hetzner falls back when:
- the requested server type is unavailable in the configured location;
- the project quota rejects the request;
- the API returns a transient capacity error.
AWS falls back when:
- the instance type is rejected by capacity in the chosen Availability Zone;
- the account policy denies the type (e.g. quota = 0 vCPUs);
- the spot request is rejected by capacity.
Quota rejections are detected from the API error code rather than scraped
from the message string, so the fallback is deterministic. The next
candidate in the chain is tried until either one succeeds or the chain is
exhausted.
When the chain is exhausted, Crabbox returns exit code 4 (`no capacity`) and
the error includes `provisioningAttempts` that record which types were
tried, why each failed, and where (region/AZ for AWS). The same metadata is
attached to the failed lease record on the coordinator so operators can
inspect what went wrong without rerunning the workflow.
## Explicit Type Override
`--type c7a.16xlarge` and the matching `type:` config key skip the class
fallback chain and request that specific instance type. The contract is
"give me this exact type, not a fallback". If the provider rejects it,
Crabbox fails loudly with exit code 4 and does not silently choose a
different type.
Use `--type` when:
- you want deterministic capacity for benchmarks;
- you are pinning a specific generation for a known-bug workaround;
- you are debugging the capacity layer itself.
For everything else, prefer a class - the fallback chain handles transient
rejections without operator intervention.
## AWS Market Fallback
AWS supports two markets: `spot` and `on-demand`.
```yaml
capacity:
market: spot
fallback: on-demand-after-120s
```
`capacity.market: spot` requests Spot capacity first. `capacity.fallback:
on-demand-after-120s` falls back to On-Demand for the same instance type
when Spot fails to come up within 120 seconds. Set `fallback` to `none` (or
omit it) to never fall back to On-Demand.
Per-command overrides:
```sh
crabbox warmup --market spot
crabbox run --market on-demand -- pnpm test
```
The `--market` flag overrides `capacity.market` for one lease without
rewriting repo config. Use it when an account is temporarily out of Spot
quota or when Spot interruption rates spike.
## AWS Capacity Hints
The brokered AWS path uses Service Quotas and EC2 placement scoring to
preflight large requests:
```yaml
capacity:
hints: true
largeClasses:
- large
- beast
```
When `hints: true` and the class is in `largeClasses`:
- the broker calls Service Quotas to check applied Spot or On-Demand vCPU
limits;
- candidates that exceed quota are recorded as quota attempts and skipped;
- remaining candidates are scored with `GetSpotPlacementScores` (Spot mode)
to pick the most-available region/AZ.
The result is a single provisioning attempt that picks the best location
and skips known-rejected types instead of letting the chain stumble through
them sequentially.
Hints apply only on the brokered (Worker) path. Direct AWS mode still falls
back through the class chain but does not run quota or placement preflight.
## Region And Availability Zone Routing
```yaml
capacity:
regions:
- eu-west-1
- us-east-1
availabilityZones:
- eu-west-1a
- eu-west-1b
```
`regions` is the ordered list of AWS regions the broker considers when
multiple regions are configured. Single-region setups use `aws.region` and
leave `capacity.regions` empty; multi-region setups list every region the
broker may launch into.
`availabilityZones` narrows the per-region zone selection. The broker uses
Spot placement scoring across the listed AZs and picks the highest-scoring
zone that has capacity.
Regions are tried in order; AZs within a region are scored. If every AZ in
a region rejects the request, Crabbox advances to the next region.
## Fallback Strategies
```yaml
capacity:
strategy: most-available
```
| Value | Behavior |
|:------|:---------|
| `most-available` (default) | use placement scoring or class chain order |
| `cheapest` | prefer types with the lowest live hourly price (when known) |
| `provider-default` | follow the provider's own placement defaults |
`cheapest` is currently honored on the brokered AWS path that has live
pricing. Hetzner does not differentiate strategies because its server-type
prices are consistent across locations.
## Direct Mode Differences
Direct provider mode (no coordinator) supports class fallback but has no
quota preflight, no placement score, no `provisioningAttempts` metadata, and
no central history. Direct AWS still respects `--market` and the `fallback`
config key, so spot-to-on-demand failover works locally - just without the
diagnostic richness the broker provides.
If a direct AWS run exits with code 4, run the same command through the
broker once to get structured `provisioningAttempts` evidence; then go back
to direct mode for the rest of the iteration loop.
## Failure Surface
Capacity failures map to:
```text
exit 4 no capacity every candidate in the chain was rejected
exit 5 provisioning failed a candidate was accepted but never reached SSH
exit 8 lease expired long warmup exceeded the configured TTL before SSH
```
The accompanying error message names the chain, the markets that were
tried, and (for brokered runs) `provisioningAttempts` you can inspect with:
```sh
crabbox history --lease cbx_...
```
Related docs:
- [Providers](providers.md)
- [AWS](../providers/aws.md)
- [Hetzner](../providers/hetzner.md)
- [Cost and usage](cost-usage.md)
- [Orchestrator](../orchestrator.md)
- [Operations](../operations.md)

View File

@ -0,0 +1,399 @@
# Configuration
Read when:
- adding a new config key, env override, or flag;
- debugging "why is Crabbox using value X here?";
- onboarding a repo and choosing what belongs in repo config vs user config;
- reviewing the YAML schema that `crabbox config show` and `crabbox init`
emit.
Crabbox configuration is layered. The CLI loads values from five sources and
merges them in a deterministic order. Each source is optional - the binary
boots with sane defaults for everything.
## Precedence
```text
flags > env > repo-local crabbox.yaml/.crabbox.yaml > user config > defaults
```
Reading order is the lowest precedence first: defaults are applied, then
overridden by user config, then repo config, then env vars, then flags. Every
override only replaces fields that are explicitly set; unset fields fall
through.
`crabbox config show` prints the merged configuration as the CLI sees it after
all five layers run. `--json` is stable enough to diff in scripts.
`crabbox config path` prints the user config file path so other tools can
edit it without parsing prose.
## File Locations
```text
macOS user: ~/Library/Application Support/crabbox/config.yaml
Linux user: ~/.config/crabbox/config.yaml
XDG override: $XDG_CONFIG_HOME/crabbox/config.yaml
repo: ./crabbox.yaml or ./.crabbox.yaml at repo root
explicit: $CRABBOX_CONFIG (any path)
```
If `CRABBOX_CONFIG` is set, it overrides the repo-local search and replaces
the effective repo config. User config is never replaced by the env override.
State that does not belong in either YAML file:
- live lease records (those are coordinator-owned);
- per-lease SSH private keys (those live under the user config dir but not in
`config.yaml`);
- provider secrets (those live in the broker environment, your shell env, or
a credential manager).
## YAML Schema
The full schema below merges what `crabbox init` emits and what advanced
operators set in user config. Most repos only need a small subset.
### Top-level
```yaml
broker:
url: https://crabbox.openclaw.ai
provider: aws
token: <signed-github-token-or-shared-token>
access:
clientId: <cloudflare-access-service-token-id>
clientSecret: <cloudflare-access-service-token-secret>
provider: aws # default provider when --provider is not set
target: linux # default target OS
windows:
mode: normal # normal or wsl2 when target=windows
profile: project-check
class: beast # standard | fast | large | beast
type: c7a.48xlarge # explicit provider type, overrides class fallback
network: auto # auto | tailscale | public
lease:
idleTimeout: 30m
ttl: 90m
```
### Capacity
```yaml
capacity:
market: spot # spot | on-demand
strategy: most-available
fallback: on-demand-after-120s
hints: true
regions:
- eu-west-1
- us-east-1
availabilityZones:
- eu-west-1a
- eu-west-1b
largeClasses:
- large
- beast
```
### AWS
```yaml
aws:
region: eu-west-1
ami: ami-0123456789abcdef0
securityGroupId: sg-0abcdef0123456789
subnetId: subnet-0abcdef0123456789
instanceProfile: crabbox-runner
rootGB: 400
sshCidrs:
- 203.0.113.0/24
macHostId: h-0123456789abcdef0
```
### Hetzner
Hetzner credentials and image come from broker-side config. Repos do not need
a `hetzner:` block unless they pin a class or location.
### Static SSH
```yaml
provider: ssh
target: macos
static:
host: mac-studio.local
user: steipete
port: "22"
workRoot: /Users/steipete/crabbox
```
### Blacksmith Testbox
```yaml
provider: blacksmith-testbox
blacksmith:
org: openclaw
workflow: .github/workflows/ci-check-testbox.yml
job: test
ref: main
idleTimeout: 90m
debug: false
```
### Daytona
```yaml
provider: daytona
daytona:
snapshot: openclaw-crabbox
apiKey: <daytona-api-key> # prefer DAYTONA_API_KEY env
```
### Sync
```yaml
sync:
delete: true
checksum: false
gitSeed: true
fingerprint: true
baseRef: main
timeout: 15m
warnFiles: 50000
warnBytes: 5368709120
failFiles: 150000
failBytes: 21474836480
allowLarge: false
exclude:
- node_modules
- .turbo
- dist
```
A `.crabboxignore` file at the repo root appends to `sync.exclude`. See
[Sync](sync.md) for the matcher rules.
### Env Forwarding
```yaml
env:
allow:
- CI
- NODE_OPTIONS
- PROJECT_*
```
`env.allow` is name-based and supports trailing wildcards. Crabbox forwards
matching local env vars to the remote command. Secrets do not belong in
`env.allow`; pass them through provider-side mechanisms.
### Actions
```yaml
actions:
workflow: .github/workflows/crabbox.yml
job: test
ref: main
fields:
- crabbox_docker_cache=true
runnerLabels:
- crabbox
ephemeral: true
runnerVersion: latest
```
### Cache
```yaml
cache:
pnpm: true
npm: true
docker: true
git: true
maxGB: 80
purgeOnRelease: false
```
### Results
```yaml
results:
junit:
- junit.xml
- reports/junit.xml
```
### SSH
```yaml
ssh:
key: ~/.ssh/id_ed25519
user: crabbox
port: "2222"
fallbackPorts:
- "22"
```
### Tailscale
```yaml
tailscale:
enabled: false
tags:
- tag:crabbox
hostnameTemplate: crabbox-{slug}
authKeyEnv: CRABBOX_TAILSCALE_AUTH_KEY
exitNode: ""
exitNodeAllowLanAccess: false
```
### Mediated Egress
Mediated egress is a browser/app QA feature where a lease exits to the internet
through an operator machine over the Cloudflare Worker mediator. The first
implementation is opt-in and profile-based.
```yaml
egress:
enabled: false
listen: 127.0.0.1:3128
browserProxy: true
profiles:
discord:
allow:
- discord.com
- "*.discord.com"
- discordcdn.com
- "*.discordcdn.com"
slack:
allow:
- slack.com
- "*.slack.com"
- slack-edge.com
- "*.slack-edge.com"
```
See [Mediated egress](egress.md) for the design, security model, and command
surface. The current CLI ships built-in `discord` and `slack` profiles; the
YAML shape is the intended config surface for making those profiles
user-configurable.
## Profiles
Profiles are named bundles of config that get applied as a layer on top of
user/repo config. They live under a `profiles:` map and are selected by
`--profile` or `profile:` in repo config.
```yaml
profiles:
project-check:
class: beast
sync:
baseRef: main
env:
allow:
- PROJECT_*
smoke:
class: standard
lease:
ttl: 30m
```
Use profiles when one repo has multiple test lanes with different machine
classes, sync rules, or env allowlists. A repo without profiles never needs
the block.
## Machine Classes
A machine class is a provider-agnostic name for "standard", "fast", "large",
or "beast" capacity. Each provider maps the class to a list of concrete
instance/server types and falls back through the list when the first
candidate cannot be provisioned.
| Class | Intent |
|:------|:-------|
| `standard` | typical CI lane |
| `fast` | ~2x more cores than standard for parallel-friendly suites |
| `large` | memory-heavy or many-process workloads |
| `beast` | maximum capacity within the provider's burstable family |
Class-to-type mappings live in [Providers](providers.md). When you set
`type:`, that exact provider type wins and the class is ignored. The
`--type` and `type:` paths intentionally do not fall back; they fail loud
if the provider rejects the type.
## Environment Variables
Every YAML key has a `CRABBOX_*` env override. The full list is in
[CLI](../cli.md#environment-variables). Common ones:
```text
CRABBOX_COORDINATOR
CRABBOX_COORDINATOR_TOKEN
CRABBOX_PROVIDER
CRABBOX_TARGET
CRABBOX_PROFILE
CRABBOX_DEFAULT_CLASS
CRABBOX_IDLE_TIMEOUT
CRABBOX_TTL
CRABBOX_NETWORK
CRABBOX_OWNER
CRABBOX_ORG
```
Provider credentials live outside the Crabbox env namespace because they are
provider-native:
```text
HCLOUD_TOKEN / HETZNER_TOKEN
AWS_PROFILE / AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN
DAYTONA_API_KEY / DAYTONA_JWT_TOKEN
BLACKSMITH_* (read by the Blacksmith CLI)
ISLO_API_KEY (read by the Islo SDK)
```
## What Belongs Where
| Setting | User config | Repo config | Profile | Notes |
|:--------|:------------|:------------|:--------|:------|
| `broker.url` and `broker.token` | yes | no | no | Per-machine identity. |
| `provider`, `class`, `type` | optional default | yes | yes | Per-repo defaults; profiles for lanes. |
| `sync.exclude`, `sync.fingerprint`, `sync.baseRef` | no | yes | yes | Lives with the repo. |
| `env.allow` | no | yes | yes | Repo decides what is safe to forward. |
| Per-user SSH key path | yes | no | no | Personal preference. |
| `aws.region`, `aws.ami` | optional | yes | yes | Repos can pin region. |
| Tailscale tags and template | yes | yes | yes | Both layers can set this. |
| Profiles | yes | yes | n/a | Either layer can define profiles. |
The rule of thumb: anything other repos should inherit when they clone goes in
repo config; anything tied to one operator's machine goes in user config.
## Validation
The CLI validates config eagerly:
- `parseNetworkMode` rejects `--network` values outside `auto|tailscale|public`;
- `validateNetworkConfig` requires `tailscale.tags` when `tailscale.enabled`
is true and rejects Tailscale on Blacksmith and static providers;
- `validateRequestedCapabilities` rejects `--desktop`, `--browser`, or
`--code` for providers whose `Spec.Features` does not list the matching
feature flag;
- `crabbox doctor` runs a richer set of checks against config, network
reachability, and SSH keys.
When validation fails, `crabbox` exits with code 2 and a message that names
the offending field.
Related docs:
- [CLI](../cli.md)
- [config command](../commands/config.md)
- [doctor command](../commands/doctor.md)
- [Sync](sync.md)
- [Providers](providers.md)
- [Capacity and fallback](capacity-fallback.md)
- [Network and reachability](network.md)

View File

@ -14,6 +14,7 @@ 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;
@ -35,6 +36,7 @@ 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

172
docs/features/doctor.md Normal file
View File

@ -0,0 +1,172 @@
# Doctor Checks
Read when:
- adding a new precheck before users run long workflows;
- debugging an unexpected `doctor` failure;
- deciding whether a check belongs in `doctor` or somewhere else.
`crabbox doctor` is the local preflight. It validates the things that have
silently broken commands in the past so users get an answer before they
spend ten minutes on a failed lease.
The command is fast (under a second on a healthy machine), local-only,
non-destructive, and never talks to provider APIs that might cost money.
## Categories
Doctor groups checks under five categories:
```text
config config files load and parse, required keys are present
auth broker token is set, signed token is valid, identity resolves
network coordinator URL reachable, DNS works, SSH transport probes work
ssh SSH key path readable, key type acceptable, ssh-keygen on PATH
tools rsync, git, ssh, ssh-keygen present and executable
```
Each category emits one or more pass/fail/skip lines. Failures are listed
first; passes and skips follow in deterministic order so the output is
diffable across runs.
## What `config` Checks
- The user config file parses without error.
- The repo config (when present) parses without error.
- Provider name resolves through `ProviderFor`.
- Target OS is one of `linux`, `macos`, `windows`.
- Network mode is one of `auto`, `tailscale`, `public`.
- Tailscale config validates when `tailscale.enabled: true` (tags non-empty,
hostname template non-empty, exit-node-allow-lan-access requires an
exit node, target is `linux`, provider is not Blacksmith or static).
- Class is one of `standard`, `fast`, `large`, `beast` when set; explicit
`type:` values are accepted as-is.
## What `auth` Checks
- A broker URL is configured if the user expects coordinator mode.
- A broker token is present when the URL is configured.
- The signed token (when GitHub login was used) decodes and is not expired.
- Owner can be resolved from `CRABBOX_OWNER`, Git env, or
`git config user.email`.
- `whoami` succeeds against the configured coordinator with the stored
token.
When auth is missing, doctor prints `crabbox login` as the next step.
## What `network` Checks
- The coordinator URL resolves via DNS.
- The coordinator is reachable over HTTPS within a small timeout.
- When `--network tailscale` is configured, `tailscale status` reports a
joined client.
- SSH transport probes succeed for the primary port and fall back to the
configured fallback ports.
DNS is checked before HTTPS so a broken DNS responder does not look like a
broker outage.
## What `ssh` Checks
- The configured SSH key path (`ssh.key` or `CRABBOX_SSH_KEY`) is readable
when set.
- The key file has a sensible permissions mode (warn on group/world
readable).
- `ssh-keygen` is on PATH so per-lease key generation works.
- The user's `~/.ssh/known_hosts` is writable (if it exists).
When `ssh.key` is unset, doctor skips the path validation - per-lease keys
do not need a global key.
## What `tools` Checks
- `git` is on PATH.
- `rsync` is on PATH.
- `ssh` is on PATH.
- `ssh-keygen` is on PATH.
The check is path-based, not version-based. Crabbox tolerates any reasonably
modern version of these tools.
## What Doctor Does Not Do
Doctor stays local on purpose. It does not:
- start a real lease or provision a server;
- talk to AWS, Hetzner, Daytona, Islo, or any provider API;
- run `git ls-files` against the repo (that belongs in `crabbox sync-plan`);
- estimate costs;
- modify config or rotate keys.
Anything that costs money or has side effects belongs in a different
command. Doctor is for "before I run anything, is my machine sane?" and
should be safe to run from `pre-commit` hooks, agent boot, or CI smoke.
## Output Shape
```text
config:
ok user config: ~/.config/crabbox/config.yaml
ok repo config: ./.crabbox.yaml
ok provider: aws
ok target: linux
ok network: auto
auth:
ok broker: https://crabbox.openclaw.ai
ok owner: alex@example.com
ok org: openclaw
network:
ok coordinator dns
ok coordinator https
ssh:
ok ssh-keygen present
skip ssh.key unset (per-lease keys will be used)
tools:
ok git
ok rsync
ok ssh
ok ssh-keygen
```
Failures swap the leading `ok` for `fail` and add a remediation hint:
```text
auth:
fail broker token is missing - run `crabbox login`
```
Skips swap `ok` for `skip` and explain why the check did not run:
```text
network:
skip coordinator unconfigured (direct provider mode)
```
Exit code is `0` on full success, `2` on any failure. Skips do not change
the exit code.
## Adding A Check
Doctor checks live in `internal/cli/doctor.go`. Each check returns a
`doctorResult{ Status, Category, Subject, Detail, Remediation }`. The CLI
sorts results by category, then by subject, so output stays stable.
Rules for new checks:
- they must run in under ~100ms;
- they must not call out to a paid API or write any state;
- they must produce a `Remediation` string when they fail;
- they should `skip` (not `fail`) when the configuration genuinely does
not apply (e.g. SSH key check when `ssh.key` is unset).
Tests in `doctor_test.go` exercise the result struct and ordering. Add a
test for the new check that asserts the failure message and remediation
text so future refactors do not silently regress the user-facing output.
Related docs:
- [doctor command](../commands/doctor.md)
- [Configuration](configuration.md)
- [Network and reachability](network.md)
- [SSH keys](ssh-keys.md)
- [Source map](../source-map.md)

489
docs/features/egress.md Normal file
View File

@ -0,0 +1,489 @@
# Mediated Egress
Read when:
- browser or app QA needs a lease to use the same public internet path as an
operator workstation;
- adding the `crabbox egress` command family;
- comparing mediated browser/app egress with Tailscale exit nodes, Cloudflare
Tunnel, or full-VM routing;
- wiring Mantis-style visual QA for Discord, Slack, or other web apps that
are sensitive to source IP, browser login, or regional routing.
Status: implemented as a CLI-first bridge. The shipped slice supports
`egress start`, `host`, `client`, `status`, and browser launches with
`desktop launch --egress`.
## Goal
Some QA scenarios need the runner to look like it is browsing from the same
network as the human or agent driving the test. Discord and Slack are good
examples: login, bot verification, abuse heuristics, and regional behavior can
change when the browser comes from a fresh cloud IP.
The first Crabbox egress goal is:
```text
Chrome or an app inside a Crabbox lease
uses a local proxy inside the lease
and exits to the internet from the operator machine running Crabbox.
```
This is intentionally per-app/per-process egress. It should make browser QA and
Mantis scenarios reproducible without changing every route on the VM. Full
machine routing can be added later through a Linux exit node or a dedicated
gateway when a scenario truly needs all traffic to move.
## Non-Goals
Mediated egress is not:
- a public open proxy;
- a replacement for provider firewalls or SSH access controls;
- a transparent VM-wide VPN in the first implementation;
- a way for the Cloudflare Worker itself to become the internet egress point;
- a place to store browser login state, app credentials, or provider secrets.
The Cloudflare Worker is the mediator. The operator machine is the egress point.
## Existing Pieces
Crabbox already has two bridge models that are close to the desired shape:
- WebVNC: `crabbox webvnc` keeps an SSH tunnel to the lease VNC service and
connects a local bridge process to the coordinator with a one-use ticket. The
browser portal then talks to that bridge through the Worker Durable Object.
- Code portal: `crabbox code` starts a code-server process on the lease and
proxies HTTP/WebSocket traffic through a ticketed coordinator bridge.
Those bridges establish the important boundaries:
- the Worker owns authenticated routing, tickets, status, and cleanup;
- bridge agents connect outbound to the Worker;
- each bridge is tied to one lease and short-lived ticket material;
- the portal is not allowed to reach private runner services by itself.
Mediated egress should reuse that model instead of introducing an unrelated
proxy service.
## Architecture
Mediated egress has two long-running agents and one Worker Durable Object
session.
```text
Cloudflare Worker / Fleet Durable Object
+----------------------------------------+
| ticket auth, socket pairing, status, |
| allowlist metadata, cleanup, counters |
+-------------------+--------------------+
|
paired WebSocket streams over HTTPS
|
+------------------------------+------------------------------+
| |
+-------v-----------------+ +-------------v------+
| lease egress client | | host egress agent |
| runs inside the lease | | runs on operator |
| listens on 127.0.0.1 | | machine / gateway |
+-----------+-------------+ +-------------+------+
| |
| HTTP CONNECT / proxy | TCP
| |
+-----v------+ +------v-----+
| Chrome / | | internet |
| Slack app | | from host |
+------------+ +------------+
```
The lease side exposes a loopback proxy such as `127.0.0.1:3128`. Chrome or a
desktop app is launched with:
```sh
--proxy-server=http://127.0.0.1:3128
```
The host side opens the real outbound TCP connections. Remote services see the
operator machine's internet path, not the cloud provider's default egress IP.
## Setup And Traffic Flow
```text
Operator CLI
|
| crabbox egress start --id blue-lobster --profile discord --daemon
v
Resolve lease through coordinator
|
+-- if local coordinator is Access-protected:
| use --coordinator https://crabbox.openclaw.ai
| so the lease can connect without private Access credentials
|
v
Create shared egress session
|
+--> create client ticket
| |
| v
| SSH to lease
| |
| v
| install/run crabbox egress client
| |
| v
| listen on 127.0.0.1:3128 inside lease
|
+--> create host ticket
|
v
run local crabbox egress host
|
v
connect outbound to coordinator
Runtime browser request
|
| Chrome --proxy-server=http://127.0.0.1:3128
v
Lease-local proxy
|
| HTTP CONNECT host:443
v
Cloudflare Worker / Fleet Durable Object
|
| pair lease client + host agent by leaseID/sessionID
v
Host egress agent on operator machine
|
| enforce allowlist, open TCP connection
v
Internet service sees operator public IP
```
Teardown runs in the opposite direction: `crabbox egress stop` stops the local
host daemon and asks the lease to kill the remote client; releasing a lease also
clears coordinator-side egress sockets and session status.
## Command Shape
The CLI is explicit enough for debugging but ergonomic for the common
desktop-browser case.
Low-level commands:
```sh
crabbox egress host --id blue-lobster --profile discord
crabbox egress client --id blue-lobster --listen 127.0.0.1:3128
crabbox egress status --id blue-lobster
crabbox egress stop --id blue-lobster
```
Operator-friendly orchestration:
```sh
crabbox egress start --id blue-lobster --profile discord --daemon
crabbox desktop launch --id blue-lobster \
--browser \
--url https://discord.com/login \
--egress discord \
--webvnc \
--open
```
`egress start`:
1. resolve the lease;
2. create a host ticket and start the host bridge locally;
3. create a client ticket and start the lease-side proxy over SSH;
4. write the active proxy endpoint into lease-local state;
5. print status and cleanup commands.
Today the orchestrated `egress start` path is Linux-only because it installs a
Linux helper and starts it with POSIX shell commands. Non-Linux targets should
use manual target-specific setup until Crabbox grows native helper install
commands for those operating systems. If your coordinator needs Cloudflare
Access credentials, use a public coordinator route for `egress start`, or run
the low-level pieces manually with an explicit secret-handling plan.
`desktop launch --egress <profile>` passes the configured lease-local proxy to
the browser command. Start `egress start` first so something is listening on the
lease proxy port.
## Worker API
The coordinator exposes ticketed routes next to the WebVNC and code bridge
routes:
```text
POST /v1/leases/{leaseID}/egress/ticket
GET /v1/leases/{leaseID}/egress/host?ticket=...
GET /v1/leases/{leaseID}/egress/client?ticket=...
GET /v1/leases/{leaseID}/egress/status
```
The ticket request should include:
```json
{
"role": "host",
"profile": "discord",
"allow": ["discord.com", "*.discord.com"],
"sessionID": "egress_..."
}
```
The Worker tracks enough state to answer status and clean up stale bridges:
```text
leaseID
sessionID
owner/org
profile
allowlist
hostConnected
clientConnected
activeConnections
bytesIn
bytesOut
lastHostnames
createdAt
lastSeenAt
expiresAt
```
Like WebVNC/code, agent WebSocket upgrades should be accepted only after a
one-use ticket is consumed by the Fleet Durable Object. Cloudflare Access
service-token headers may get the request through the edge, but Crabbox ticket
auth still owns the bridge authorization.
## Stream Protocol
WebVNC can forward one raw byte stream. Egress needs many concurrent TCP
connections because a browser opens several sockets at once.
The bridge protocol needs multiplexed streams:
```text
hello { role, sessionID, protocolVersion }
open { connId, host, port }
open_ok { connId }
data { connId, bytes }
close { connId }
error { connId, message }
stats { activeConnections, bytesIn, bytesOut }
```
The lease egress client parses HTTP proxy requests from Chrome. For `CONNECT
host:port`, it asks the host agent to open a TCP connection. For plain HTTP
absolute-form requests, it can either proxy them directly or translate them to
a stream to port 80.
The first implementation may use JSON control frames and base64 data chunks for
simplicity. The protocol should reserve a version field so a later binary frame
format can avoid base64 overhead without changing the CLI surface.
## Security Model
Mediated egress must default closed.
Required guardrails:
- no listener bound to a public interface;
- one-use, short-lived tickets bound to lease, owner/org, role, and session;
- explicit domain allowlist or named profile;
- idle timeout and lease TTL cleanup;
- bounded active connections per session;
- bounded per-frame size;
- hostname logging only, not URLs or payload;
- no proxy passwords, tickets, or credentials in logs;
- host agent refuses destinations outside the allowlist;
- session closes when either side disconnects for longer than a short grace
period.
The host agent is powerful because it opens internet connections from the
operator network. It should show a clear startup summary before connecting:
```text
lease: blue-lobster
profile: discord
allowed: discord.com, *.discord.com, discordcdn.com, *.discordcdn.com
listening: none public; outbound websocket only
```
## Profiles
Profiles keep common browser QA scenarios repeatable without turning egress
into a blanket tunnel.
Intended config shape:
```yaml
egress:
enabled: false
listen: 127.0.0.1:3128
browserProxy: true
profiles:
discord:
allow:
- discord.com
- "*.discord.com"
- discordcdn.com
- "*.discordcdn.com"
- hcaptcha.com
- "*.hcaptcha.com"
slack:
allow:
- slack.com
- "*.slack.com"
- slack-edge.com
- "*.slack-edge.com"
```
Profiles should be merged like other config: flags over env over repo config
over user config over defaults. Repo config can define scenario profiles; user
config can define local preferences such as the default listen address.
## Browser And Desktop Integration
`--browser` leases already install a browser wrapper exposed through `BROWSER`
and `CHROME_BIN`. Egress should integrate at that seam.
Planned behavior:
- `crabbox egress start` launches the lease-local proxy at
`127.0.0.1:3128` by default;
- `crabbox desktop launch --egress <profile>` passes
`--proxy-server=http://127.0.0.1:<port>` when launching Chrome/Chromium;
- a later `crabbox run --egress <profile>` may opt command processes into
`HTTP_PROXY`, `HTTPS_PROXY`, and `ALL_PROXY`, but should never do this by
default for every run.
This keeps browser QA easy while avoiding surprising build or package-manager
traffic through a workstation.
## Portal Integration
The portal lease detail page shows egress status when a session exists:
- profile and allowlist;
- host/client connected state;
- copyable start/status/stop commands.
The portal should not expose raw proxy URLs or ticket values. It should treat
egress like WebVNC/code: a bridge that exists only while local agents are
running.
Connection counts, byte counters, and recent hostnames are still CLI/API-only
follow-ups once the bridge reports structured runtime stats.
## Comparison With Alternatives
### Tailscale Exit Node
A Tailscale exit node can route the whole VM through another machine. That is
useful when every process must share the same egress path. It is also more
fragile: OS forwarding, NAT, ACLs, and route approval all have to line up.
Use Tailscale exit nodes later for full-machine scenarios. Use mediated egress
first for browser/app QA.
### Cloudflare Tunnel TCP
A named Cloudflare Tunnel plus Access can expose private TCP services without a
public listener. It is useful as an operational building block, but it still
needs host and lease processes plus lifecycle management. Keeping the first
implementation inside the existing Worker/Durable Object bridge gives Crabbox
one auth, status, and cleanup model.
### Cloudflare Worker As Egress
Workers should not be the source of browser internet traffic for this feature.
The goal is not "use Cloudflare's IP"; it is "use the operator machine's
internet". The Worker mediates the two sides.
## Implementation Plan
### Phase 1: CLI-Only Mediated Proxy
Done:
- egress ticket and status routes in the Fleet Durable Object;
- host/client WebSocket bridge attachments;
- multiplexed stream protocol with connection IDs;
- `crabbox egress host`, `client`, `start`, `status`, and `stop`;
- domain allowlist enforcement on the host side;
- tests for ticket use, allowlist rejection, request parsing, and status
reporting.
### Phase 2: Browser Wiring
- Add `desktop launch --egress <profile>`. Done.
- Add optional browser wrapper support for `CRABBOX_BROWSER_PROXY_SERVER`.
- Add lease-local egress state beyond the active proxy port.
- Add a live smoke that launches a browser through the proxy and proves the
observed public IP matches the host agent path.
### Phase 3: Portal And Daemon UX
Done:
- portal egress status on the lease detail page;
- daemon supervisor behavior matching WebVNC;
- duplicate-daemon replacement and cleanup;
- clearer cleanup on lease stop/expiry.
Remaining:
- Add docs and examples for Discord and Slack QA.
### Phase 4: Full-Machine Options
- Keep mediated per-app egress as the default.
- Add a separate full-route mode only when the target is a suitable Linux
gateway or a confirmed Tailscale exit node.
- Document full-route mode as higher-risk and provider/OS dependent.
## Verification
Useful proof for the first implementation:
```sh
crabbox warmup --provider hetzner --desktop --browser
crabbox egress start --id blue-lobster --profile discord --daemon
crabbox desktop launch --id blue-lobster \
--browser \
--url https://discord.com/login \
--egress discord \
--webvnc \
--open
```
Expected evidence:
- `egress status` reports host and client connected;
- a browser IP check shows the host-side egress IP;
- Discord loads inside the WebVNC desktop;
- the host agent logs only allowed hostnames and byte counters;
- stopping the lease tears down the bridge and local proxy.
## Source Map
Planned implementation files:
- CLI command router: `internal/cli/cli_kong.go`
- egress command implementation: `internal/cli/egress.go`
- coordinator client ticket/status calls: `internal/cli/coordinator.go`
- desktop/browser launch integration: `internal/cli/desktop.go`
- browser wrapper bootstrap: `internal/cli/bootstrap.go`, `worker/src/bootstrap.ts`
- Worker top-level WebSocket routing: `worker/src/index.ts`
- Fleet Durable Object bridge state and routes: `worker/src/fleet.ts`
- Worker request/record types: `worker/src/types.ts`
- portal lease detail status: `worker/src/portal.ts`
Related docs:
- [Interactive desktop and VNC](interactive-desktop-vnc.md)
- [Broker auth and routing](broker-auth-routing.md)
- [Browser portal](portal.md)
- [Tailscale](tailscale.md)
- [Configuration](configuration.md)

View File

@ -0,0 +1,155 @@
# Environment Forwarding
Read when:
- adding a new env var that the remote command needs to see;
- debugging "why is `$CI` empty inside `crabbox run`?";
- writing a repo config that lets agents set tunable values without flags;
- reviewing a PR that loosens or tightens the env allowlist.
By default, `crabbox run` does not forward arbitrary local environment
variables to the remote command. Forwarding is opt-in and name-based: the
repo declares which variable names are allowed, and Crabbox forwards only
those that are present locally.
## Why Allowlist
Agents and CI environments run with rich and sometimes sensitive
environments: tokens, private credentials, terminal paths, vendor-specific
debug flags. Forwarding everything would:
- leak secrets to remote runners;
- introduce non-determinism between local and CI runs;
- make it impossible to reason about what affects a remote command.
Allowlist forwarding makes the contract explicit. The repo decides what
"counts" as input to the remote command, and the user can audit the
allowlist in `crabbox.yaml`.
## Configuration
```yaml
env:
allow:
- CI
- NODE_OPTIONS
- PROJECT_*
```
Rules:
- entries are env var names, not values;
- a trailing `*` is a prefix wildcard (`PROJECT_*` matches `PROJECT_FOO`,
`PROJECT_BAR`);
- inline wildcards (`PROJECT_*_DEBUG`) are not supported;
- match is exact and case-sensitive;
- empty entries are ignored.
The user-side override is `CRABBOX_ENV_ALLOW`, a comma-separated list:
```sh
CRABBOX_ENV_ALLOW='CI,NODE_OPTIONS,PROJECT_*' crabbox run -- pnpm test
```
`CRABBOX_ENV_ALLOW` replaces the repo allowlist for that command rather than
appending to it. Use it for one-off tests; persistent allowances belong in
`env.allow`.
## What Gets Forwarded
For each env var in the allowlist, Crabbox checks whether the variable is
set locally. If it is, the variable is forwarded to the remote command with
the same name and value. If it is not set locally, nothing is forwarded -
Crabbox does not invent values.
The remote command sees the variables as part of its environment when run
through SSH:
```sh
ssh runner 'CI=true NODE_OPTIONS=--max_old_space_size=4096 cd workdir && pnpm test'
```
Quoting and escaping happen automatically. Values that contain shell
metacharacters are passed through safely.
## Capability-Injected Env
A small set of env vars is injected by Crabbox itself when the matching
capability is requested. These bypass the allowlist because Crabbox owns
them:
```text
DISPLAY=:99 when --desktop
CRABBOX_DESKTOP=1 when --desktop
BROWSER=<path> when --browser, after probe
CHROME_BIN=<path> when --browser, after probe
CRABBOX_BROWSER=1 when --browser
```
User-allowed env vars override capability-injected ones if they overlap.
Repos that need a different `BROWSER` value can include `BROWSER` in
`env.allow` and set it locally.
## Secrets
Do not put secrets in `env.allow` even if forwarding seems convenient.
Secrets belong in:
- the broker environment (Cloudflare Worker secrets) for provider
credentials;
- the operator's credential store (`op`, AWS Vault, etc.) for short-lived
tokens;
- per-runner image bake when the secret should be on every lease;
- post-bootstrap secret injection in repo-owned setup scripts (devcontainer,
mise, repo-controlled `bin/setup`).
Crabbox forwards values it sees locally. If a secret leaks into the
allowlist, every run of every contributor will leak it.
## Examples
```yaml
env:
allow:
- CI # mark a remote command as CI-driven
- NODE_OPTIONS # adjust Node memory in test suites
- PYTEST_ADDOPTS # tune pytest flags from the local env
- PROJECT_* # repo's own debug knobs
- VITEST_* # let agents override vitest config
- DEBUG # `debug` package selector
```
Common things you usually do not allow:
```text
HOME, USER, PATH, SHELL runner already has its own
SSH_* leaks SSH agent state
GITHUB_TOKEN use Actions hydration or runner setup
AWS_* use IAM roles or instance profile
*_API_KEY, *_TOKEN use a secret manager
```
## Inspecting Forwarding
`crabbox run --debug` prints the set of env vars that were forwarded for
that invocation. Use it to verify that the allowlist matches expectations
before debugging "why does the remote command not see this variable?".
```sh
$ crabbox run --debug -- env | grep '^PROJECT'
[crabbox] forwarding env: CI NODE_OPTIONS PROJECT_FOO PROJECT_BAR
PROJECT_FOO=value
PROJECT_BAR=other-value
```
Variables that match the allowlist but are unset locally are not in the
forwarded list, so the debug line is the source of truth for "what did the
remote command actually see".
Related docs:
- [Sync](sync.md)
- [Configuration](configuration.md)
- [run command](../commands/run.md)
- [Capabilities](capabilities.md)
- [Security](../security.md)

View File

@ -0,0 +1,199 @@
# Identifiers
Read when:
- changing how Crabbox names leases, slugs, runs, or claims;
- debugging "why does `crabbox run --id` not find this lease?";
- adding a new lookup form (alias, provider id, anything that resolves to a
lease).
Crabbox names every long-lived thing twice: once with a stable canonical ID
that machines compare, and once with a friendly slug that humans type. This
page lists the identifiers, where they come from, and how lookup resolves
across them.
## Lease ID
Canonical lease IDs look like:
```text
cbx_abcdef123456
```
The pattern is fixed: the literal `cbx_` prefix followed by 12 hex characters.
`isCanonicalLeaseID` enforces it as a regex; anything else is treated as a
slug or alias.
The CLI mints a provisional lease ID before calling the broker. The broker
may return a different final ID (when the Worker dedupes a retried request,
for example); the CLI then moves the local SSH key directory and claim file
from the provisional ID to the final ID with `MoveStoredTestboxKey` and
re-keys references accordingly.
Provider resources reference the lease ID through Crabbox labels:
```text
crabbox-lease=cbx_abcdef123456
```
That label is what `crabbox cleanup` and `crabbox list` use to map a provider
machine back to a Crabbox lease.
## Slug
Slugs are friendly, human-typeable lease names. They look like:
```text
blue-lobster
amber-crab
silver-shrimp
```
Slugs are generated from a stable hash of the lease ID, so the same lease
always gets the same slug. The vocabulary is small (14 adjectives, 8 nouns)
because Crabbox is intentionally a small fleet. When a slug collides with an
existing active lease, `slugWithCollisionSuffix` appends a 4-hex suffix
keyed by the seed:
```text
blue-lobster-1234
```
The collision path is rare in normal use - a single user's active leases
rarely exceed the 14 × 8 = 112 unique base slugs.
Slugs are normalized everywhere they are accepted. `normalizeLeaseSlug` keeps
only `[a-z0-9-]`, collapses runs of separators, and trims leading/trailing
dashes. `Blue_Lobster` and `BLUE-LOBSTER` resolve to `blue-lobster`.
## Provider Name
Each managed lease also gets a per-provider resource name that includes the
slug and a hash of the lease ID, so the provider console shows useful names:
```text
crabbox-blue-lobster-7f8a2c1d
```
That name is what shows up as the EC2 `Name` tag, the Hetzner server name,
and the Daytona sandbox name. It is derived from `leaseProviderName(leaseID,
slug)`; the function falls back to `crabbox-cbx-...` if the slug is empty.
## Run ID
Each `crabbox run` against a coordinator also gets a durable run handle:
```text
run_abcdef123456
```
A run is created before the lease is acquired so events can be appended for
leasing failures, sync failures, and command output even when the run never
reaches command-start. Run IDs are stable across a single invocation;
retrying the same command produces a new run.
`crabbox history`, `crabbox events`, `crabbox attach`, `crabbox logs`, and
`crabbox results` all accept run IDs. Slugs do not resolve to runs - only to
leases.
## Local Claims
Reusable leases get a JSON claim file stored under the user state directory:
```text
$XDG_STATE_HOME/crabbox/claims/cbx_abcdef123456.json
```
When `XDG_STATE_HOME` is not set, claims live next to user config in
`~/Library/Application Support/crabbox/state/claims` on macOS or
`~/.config/crabbox/state/claims` on Linux.
The claim payload looks like:
```json
{
"leaseID": "cbx_abcdef123456",
"slug": "blue-lobster",
"provider": "aws",
"repoRoot": "/Users/steipete/Projects/openclaw",
"claimedAt": "2026-05-07T07:42:18Z",
"lastUsedAt": "2026-05-07T07:55:12Z",
"idleTimeoutSeconds": 1800
}
```
Claims do three things:
- bind a lease to one repo so wrappers and agents do not silently reuse a
lease against a different checkout;
- give `crabbox run --id blue-lobster` a slug-to-canonical-ID translation
without round-tripping the broker;
- power "is this lease still mine?" checks before destructive operations
(`stop`, `cleanup`, `actions register`).
A conflicting claim (same lease, different repo) refuses commands by default;
`--reclaim` overrides the check and rewrites the claim atomically.
Static SSH leases tag their claims with `provider: ssh` so the resolver knows
the lease bypasses the coordinator. Coordinator-backed claims leave
`provider` blank because the coordinator owns provider tracking.
## SSH Key Storage
Per-lease SSH key directories are keyed by lease ID:
```text
~/.config/crabbox/testboxes/cbx_abcdef123456/id_ed25519
~/.config/crabbox/testboxes/cbx_abcdef123456/id_ed25519.pub
~/.config/crabbox/testboxes/cbx_abcdef123456/known_hosts
```
The provisional → final lease ID move uses `os.Rename` on the directory so
the key, public key, and known_hosts file all migrate atomically. The
provider key name (`crabbox-cbx-abcdef123456`) is what the cloud account
sees.
## Resolving An Identifier
`crabbox <command> --id <value>` accepts:
- a canonical `cbx_...` lease ID;
- a normalized slug (`blue-lobster`, `Blue Lobster`, `BLUE_LOBSTER` all resolve
to the same lease);
- in coordinator mode, also the slug as known to the broker, regardless of
case.
Resolution order:
1. Read the local claim store for the literal identifier or any slug match
in `claims/`.
2. If a matching claim exists, use its `leaseID` as the canonical handle.
3. If no claim is found and a coordinator is configured, ask the coordinator
to resolve the identifier (slug or canonical ID).
4. For static SSH and direct-provider modes, fall back to the provider's
`Resolve` implementation (`SSHLeaseBackend.Resolve`).
The first source that returns a hit wins. This is why `--id blue-lobster`
works from any directory once the warmup ran in some other repo - the local
claim translates slug to lease ID before the broker is involved.
## Identifier Lifetime
```text
provisional lease ID newLeaseID() call → broker returns final ID
final lease ID broker accepts → stored in claim, key dir, labels
slug computed on first lease creation, stable forever
provider name derived from lease ID + slug
run ID minted per crabbox run when a coordinator is configured
```
Slugs are not recycled. When a lease ends, the slug stays free for any future
lease that happens to hash to it; the small vocabulary makes that
collision-by-hash possible but rare in practice.
Related docs:
- [Coordinator](coordinator.md)
- [SSH keys](ssh-keys.md)
- [Lifecycle cleanup](lifecycle-cleanup.md)
- [Source map](../source-map.md)

View File

@ -4,6 +4,9 @@ Read when:
- choosing a desktop target for browser/UI QA;
- opening a lease with VNC or WebVNC;
- diagnosing stale WebVNC viewers, bridge disconnects, or broken desktop
sessions;
- driving desktop input from agents without hand-written `xdotool`;
- deciding which layer owns desktop setup, browser state, screenshots, or
credentials.
@ -17,8 +20,10 @@ boundary.
```sh
crabbox warmup --desktop --browser
crabbox vnc --id blue-lobster --open
crabbox webvnc --id blue-lobster --open
crabbox webvnc status --id blue-lobster
crabbox desktop doctor --id blue-lobster
crabbox vnc --id blue-lobster --open
crabbox screenshot --id blue-lobster --output desktop.png
```
@ -64,8 +69,10 @@ 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) |
@ -73,7 +80,18 @@ Scenario systems such as Mantis own:
## Commands
Use `crabbox vnc` for a native VNC client:
Use `crabbox webvnc` for the authenticated coordinator portal. This is the
preferred path for human demos because `--open` preloads the VNC password in
the local browser fragment:
```sh
crabbox webvnc --id blue-lobster --open
crabbox webvnc status --id blue-lobster
crabbox webvnc reset --id blue-lobster --open
```
Use `crabbox vnc` for a native VNC client when WebVNC status/reset says the
portal/browser path is unhealthy or when you need a native client feature:
```sh
crabbox vnc --id blue-lobster
@ -81,24 +99,41 @@ crabbox vnc --id blue-lobster --network tailscale
crabbox vnc --id blue-lobster --open
```
Use `crabbox webvnc` for the authenticated coordinator portal:
```sh
crabbox webvnc --id blue-lobster --open
```
WebVNC uses the same runner-side VNC service as `crabbox vnc`. The difference
is the viewer path: a local `crabbox webvnc` process keeps an SSH tunnel open,
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
clipboard text and then writes that text to the local browser clipboard on
click; browsers generally block fully automatic clipboard writes without a user
gesture.
Use `crabbox screenshot` when you need a PNG without taking over the session:
```sh
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:
@ -111,6 +146,36 @@ panel, title bar, and surrounding session remain visible. Use
`desktop launch --fullscreen` only when you intentionally want browser-only
video or capture output.
Use `crabbox desktop doctor --id <lease>` before blaming WebVNC. It checks the
lease's desktop session, VNC service, input tooling, browser binary, ffmpeg,
screen geometry, and screenshot capture, then separately reports WebVNC
bridge/viewer status with one-line repair suggestions.
Failure output is designed for rescue-first debugging. When a desktop command
cannot prove the expected state, Crabbox prints the failed layer as
`problem: browser not launched`, `problem: input stack dead`, `problem: VNC
bridge disconnected`, `problem: WebVNC daemon not running`, or similar, followed
by an exact `rescue:` command. WebVNC status/reset also prints the exact native
`crabbox vnc ... --open` fallback when the native viewer is the better next
step.
Use first-class input helpers instead of hand-rolled `xdotool`:
```sh
crabbox desktop click --id blue-lobster --x 640 --y 420
crabbox desktop paste --id blue-lobster --text "peter@example.com"
printf 'peter@example.com' | crabbox desktop paste --id blue-lobster
crabbox desktop type --id blue-lobster --text "hello"
crabbox desktop key --id blue-lobster ctrl+l
crabbox desktop key blue-lobster ctrl+l
```
Prefer `desktop paste` or symbol-aware `desktop type` for emails, passwords,
URLs, and text containing characters such as `@` or `+`; raw key-symbol typing
can vary with the target X keyboard layout. `desktop key` is for shortcuts and
special keys, and supports both `--id <lease> <keys>` and positional
`<lease> <keys>` forms.
## Network Model
Managed VNC is tunnel-first:
@ -122,10 +187,22 @@ 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.
- 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.
- 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.
- `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`
fallback. The fallback preserves explicit `--network public` or
`--network tailscale` selections.
- `crabbox webvnc reset` closes only the selected lease's WebVNC sockets,
stops only that lease's verified local WebVNC daemon, restarts the target
desktop/VNC services, then prints the fresh portal URL.
- WebVNC and desktop commands print rescue commands inline when the bridge,
viewer, browser launch, VNC target, or input stack fails, so operators do not
need to dig through troubleshooting docs during a demo.
Crabbox does not bind managed VNC directly to a public IP or Tailscale 100.x
address. Static hosts can expose direct `host:5900` only when the operator has
@ -165,4 +242,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).
- [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).
- [Mediated egress](egress.md): per-app browser/app egress through the operator
machine for Discord, Slack, and similar source-IP-sensitive QA.

View File

@ -7,10 +7,11 @@ Read when:
- reviewing delegated provider behavior.
`provider: islo` delegates sandbox setup and command execution to Islo. Crabbox
uses the Islo Go SDK for auth, sandbox lifecycle, list, status, and stop. The
SDK's current exec stream helper coalesces output, so Crabbox keeps a small SSE
reader for `POST /sandboxes/{name}/exec/stream` while still using the SDK auth
provider.
uses the Islo Go SDK for auth, sandbox lifecycle, list, status, and stop. It
builds the normal Crabbox sync manifest and uploads it as a gzipped archive into
the sandbox workdir before executing the command. The SDK's current exec stream
helper coalesces output, so Crabbox keeps a small SSE reader for
`POST /sandboxes/{name}/exec/stream` while still using the SDK auth provider.
## Auth
@ -49,11 +50,12 @@ crabbox stop --provider islo <slug>
- `warmup` creates a `crabbox-...` Islo sandbox and stores a local lease ID of
the form `isb_<crabbox-sandbox-name>` plus a Crabbox slug.
- `run` creates or reuses a sandbox, streams stdout/stderr from Islo's SSE exec
- `run` creates or reuses a sandbox, syncs the local Git-managed working set
into `/workspace/<islo.workdir>`, streams stdout/stderr from Islo's SSE exec
endpoint, and returns the remote exit code.
- Sync is delegated to Islo. `--sync-only`, `--checksum`, and
`--force-sync-large` are rejected because Crabbox cannot honor those local
rsync options.
- `--sync-only` and `--checksum` are rejected because Islo does not expose a
Crabbox SSH/rsync target. Large-sync guardrails still apply, and
`--force-sync-large` is honored for intentional large archive syncs.
- `list`, `status`, and `stop` use the Islo SDK and return core-rendered
Crabbox views for Crabbox-created sandboxes only.

195
docs/features/network.md Normal file
View File

@ -0,0 +1,195 @@
# Network And Reachability
Read when:
- choosing between `--network auto`, `tailscale`, or `public`;
- debugging "Crabbox can SSH but my browser can't reach the desktop";
- changing how Crabbox falls back between the public IP and the tailnet IP;
- adjusting SSH port fallbacks for restrictive operator networks.
A Crabbox lease can be reachable through more than one network plane.
Brokered Linux leases can join a Tailscale tailnet, brokered AWS Windows and
EC2 Mac leases stay public, and static SSH targets can be on either depending
on how the operator configured them. The CLI picks one plane per command and
prints which it picked.
## Modes
```text
--network auto prefer tailnet when reachable, otherwise fall back to public
--network tailscale require tailnet reachability; fail otherwise
--network public ignore tailnet metadata and use the public address
```
`auto` is the default. It optimizes for "do not surprise me": prefer tailnet
when both client and runner are on the tailnet, fall back transparently to
the public path when the client is off-tailnet.
`tailscale` is the strict mode. Use it when you specifically want to verify
tailnet reachability or when the public IP is firewalled to a CI runner that
your local box cannot reach.
`public` is the escape hatch. Use it when the tailnet metadata is stale, when
you are debugging public-network issues, or when the client cannot reach the
tailnet for unrelated reasons.
The mode applies to `crabbox ssh`, `crabbox run`, `crabbox vnc`, and
`crabbox webvnc`. `crabbox status --network auto` also resolves through this
path so the printed address matches what later commands will use.
## How `auto` Picks A Plane
For a lease with tailnet metadata, `auto` mode:
1. reads `tailscale_fqdn`, `tailscale_ipv4`, and `tailscale_hostname` from the
server labels;
2. probes the first non-empty option over SSH with a 5-second TCP transport
probe;
3. uses that target if the probe succeeds;
4. falls back to the public IP and prints `network=public` with the reason
`tailscale_unreachable`.
For a lease with no tailnet metadata, `auto` is just public mode.
Static SSH targets behave the same way when the static host name is a
MagicDNS or `100.x` address. If the operator points `static.host` at a
MagicDNS name, `--network tailscale` works without any other configuration -
the address is already on the tailnet.
## Public Reachability
Brokered AWS Linux, AWS Windows, AWS Mac, Hetzner Linux, Daytona, and Islo
leases all expose at least one public address. Crabbox stores the public
address on the server record and uses it whenever the network mode resolves
to `public`.
Public addresses are gated by the provider's security group / firewall. AWS
managed leases use the `crabbox-runners` security group with SSH ingress
limited to the configured CIDRs or the request source IP. Hetzner managed
leases use the cloud firewall attached to the project; the broker keeps it
limited to the operator's IPs.
If your client IP changes during a long warmup, the existing security group
rule may not include the new IP. Re-running `crabbox status` adds the
current IP back and updates the rule.
## Tailnet Reachability
When a managed Linux lease is created with `--tailscale`, cloud-init:
- installs the Tailscale package;
- joins the tailnet with the configured tags (default `tag:crabbox`);
- writes non-secret metadata to `/var/lib/crabbox/tailscale-*`;
- extends `crabbox-ready` with a bounded check that a `100.x` address has
been assigned;
- discards the auth key after `tailscale up` so it never persists.
The metadata Crabbox stores on the lease record:
```text
tailscale=true
tailscale_hostname=blue-lobster
tailscale_fqdn=blue-lobster.tail-scale.ts.net
tailscale_ipv4=100.64.0.5
tailscale_state=ok
tailscale_tags=tag:crabbox
tailscale_exit_node=...
tailscale_exit_node_allow_lan_access=true|false
```
Brokered leases get a one-shot auth key minted by the Worker via Tailscale
OAuth (`worker/src/tailscale.ts`). Direct-provider leases use a key from
`CRABBOX_TAILSCALE_AUTH_KEY`. The auth key is never stored on the runner.
When the metadata says the lease is on the tailnet but the client cannot
reach it, the most common reasons are:
- the client is not joined to the tailnet (`tailscale status` on the client);
- ACLs block the tag pair from reaching `100.x`;
- the runner's `tailscaled` process died (rare; readiness probes catch it
before the lease is handed back).
`crabbox status --id <lease> --network tailscale` is the fastest way to test
tailnet reachability after lease creation.
## SSH Port And Fallback
Crabbox runs SSH on a non-standard port by default to keep noise out of the
provider firewall logs:
```yaml
ssh:
port: "2222"
fallbackPorts:
- "22"
```
`ssh.port` is the primary port the bootstrap binds to. `ssh.fallbackPorts` is
an ordered list of additional ports the CLI will try when the primary port
is unreachable - typically because the operator's egress is restricted, the
sshd has not bound the new port yet, or cloud-init is still mid-flight.
Fallback rules:
- the CLI tries primary first, then each fallback in order;
- the first port that opens a TCP connection wins for that command;
- success is sticky for the run; the next command repeats the probe;
- the CLI prints `ssh-port-fallback=22` when fallback was used.
Set `ssh.fallbackPorts: []` or `CRABBOX_SSH_FALLBACK_PORTS=none` to disable
fallback entirely. Some networks prefer this so a misconfigured `2222` rule
fails loud instead of quietly using `22`.
## Loopback-Bound Capabilities
Lease capabilities (desktop, code) are bound to loopback on purpose so they
do not need provider firewall changes:
```text
VNC 127.0.0.1:5900 reached via SSH tunnel
code-server 127.0.0.1:8080 reached via portal bridge
```
The network mode does not change loopback bindings. `--network` only changes
which interface the SSH tunnel or portal bridge uses to talk to the lease.
Loopback is loopback; it is reachable from the runner regardless.
## Static Hosts
Static SSH targets honor the same modes:
- `--network public` uses `static.host` as configured;
- `--network tailscale` requires `static.host` to be a MagicDNS name or
`100.x` address, then probes for SSH reachability;
- `--network auto` defers to the resolved address: if `static.host` is on
the tailnet, that is what `auto` uses; otherwise it is public.
Tailscale-managed bootstrap (`--tailscale`) is rejected for static providers.
Static hosts are operator-owned; Crabbox does not install Tailscale on them.
Set `static.host` to a tailnet address and select `--network tailscale`
explicitly.
## Failure Surface
When a network mode cannot be satisfied, the CLI exits with code 5 and a
message that names the mode and the lease:
```text
network=tailscale requested but lease cbx_... has no tailnet address
network=tailscale requested for static host mac-studio but SSH is not reachable
network=tailscale requested but blue-lobster.tail-scale.ts.net is not reachable over SSH
```
`auto` mode never fails on a tailnet probe; it falls back to public and
records the reason. The `network=public reason=tailscale_unreachable` log
line is the diagnostic signal that the tailnet plane is unhealthy even
though the command kept working.
Related docs:
- [Tailscale](tailscale.md)
- [Runner bootstrap](runner-bootstrap.md)
- [SSH keys](ssh-keys.md)
- [vnc command](../commands/vnc.md)
- [ssh command](../commands/ssh.md)
- [doctor command](../commands/doctor.md)

View File

@ -0,0 +1,165 @@
# OpenClaw Plugin
Read when:
- enabling Crabbox as a plugin inside OpenClaw;
- changing the plugin tools, schema, or wrapper behavior;
- understanding why some Crabbox surfaces are CLI-only and not plugin tools.
The Crabbox repository root is also a native OpenClaw plugin package. When
OpenClaw loads the plugin, it exposes a small set of agent tools that shell
out to the user's installed `crabbox` binary. The plugin does not embed the
CLI or duplicate any of its logic - it is a thin contract for safe, allowlisted
invocations.
## Plugin Manifest
`openclaw.plugin.json` declares the plugin id, the tools it owns, and the
config schema:
```json
{
"id": "crabbox",
"name": "Crabbox",
"description": "Run Crabbox remote testbox checks from OpenClaw.",
"activation": { "onStartup": true },
"contracts": {
"tools": [
"crabbox_run",
"crabbox_warmup",
"crabbox_status",
"crabbox_list",
"crabbox_stop"
]
},
"configSchema": { ... }
}
```
The runtime entrypoint is `index.js`. Tests in `index.test.js` lock the tool
schemas, argv shapes, output trimming, and config validation so a future
refactor cannot silently change the agent-facing contract.
## Tools
```text
crabbox_run run a command on a leased remote box
crabbox_warmup acquire a warm box for repeated commands
crabbox_status query a lease's state
crabbox_list list visible leases for the current owner/org
crabbox_stop stop a lease and release its provider resources
```
Each tool accepts an argv array of `string` plus an optional `env` object of
string values. The plugin enforces these as JSON schema before invoking the
binary, so an agent cannot pass arbitrary shell commands or non-string env
values.
`crabbox_run`, `crabbox_warmup`, and `crabbox_stop` can be disabled per
install by setting `allowRun`, `allowWarmup`, or `allowStop` to `false` in
plugin config. `crabbox_status` and `crabbox_list` are read-only and always
allowed.
## Config
The plugin accepts only four config keys, all optional:
```json
{
"binary": "crabbox",
"maxOutputBytes": 60000,
"timeoutSeconds": 1800,
"allowRun": true,
"allowWarmup": true,
"allowStop": true
}
```
| Key | Default | Effect |
|:----|:--------|:-------|
| `binary` | `crabbox` | Path to the Crabbox binary. Set when the binary is not on PATH. |
| `maxOutputBytes` | 60000 | Max captured stdout/stderr returned to the model per call. |
| `timeoutSeconds` | 1800 | Default wrapper timeout for a Crabbox CLI invocation. |
| `allowRun` | true | Gate `crabbox_run`. |
| `allowWarmup` | true | Gate `crabbox_warmup`. |
| `allowStop` | true | Gate `crabbox_stop`. |
Crabbox config (broker URL, provider, token, profile, class) lives in the
user/repo config files. The plugin does not duplicate those keys; it inherits
them from whatever `crabbox config show` would return for the agent's
working directory.
## Output Handling
The plugin captures stdout and stderr separately, trims each to
`maxOutputBytes`, and reports the exit code, the trimmed bytes, and a
truncation flag back to the model. Truncated output gets a tail marker so
agents know they did not get the full transcript:
```text
... [output truncated; 12345 of 87654 bytes shown]
```
Long-running tools still respect `timeoutSeconds`. When the wrapper times
out, the plugin sends SIGTERM, waits a short grace period, then escalates to
SIGKILL. The exit code in the response reflects the wrapper outcome, not the
inner remote command.
## What Belongs In The CLI Instead
History, log inspection, attach, results, usage, and admin operations are
intentionally not plugin tools. They are best run from a shell-capable agent:
```sh
crabbox history --lease cbx_...
crabbox events run_... --after 0 --limit 50
crabbox attach run_...
crabbox logs run_...
crabbox results run_...
crabbox usage --scope user
crabbox admin leases --state active
crabbox cleanup --dry-run
```
Reasons for keeping these out of the plugin:
- they often produce more output than `maxOutputBytes` can usefully capture;
- agents tend to want raw logs they can grep, not trimmed model output;
- admin tools are easier to gate at the shell level (env, allowlists) than
through plugin config;
- `crabbox attach` is interactive by design.
## Provider Allowlist
The plugin schema constrains the `provider` argument to the providers
Crabbox actually supports:
```text
aws | hetzner | ssh | blacksmith-testbox | blacksmith | daytona | islo
```
Adding a provider to the CLI requires updating this list in `index.js` and
the test fixture in `index.test.js`. The schema is the agent-facing contract;
without the update, the new provider would be rejected by JSON validation
before reaching the binary.
## When To Update
Edit the plugin when you:
- add or remove a provider;
- add a new agent-safe tool (read-only, owner-scoped, bounded output);
- change argv conventions across all `crabbox` commands (rare);
- update default timeouts or output budgets.
Run `node --test index.test.js` after every change. The tests exercise the
schema, argv handling, and output trimming end-to-end.
Related docs:
- [docs/README.md](../README.md) - top-level overview includes the plugin.
- [Source map](../source-map.md) - `package.json`, `openclaw.plugin.json`,
`index.js`, `index.test.js`.
- [run command](../commands/run.md) - what `crabbox_run` ultimately invokes.
- [warmup command](../commands/warmup.md) - what `crabbox_warmup` invokes.
- [stop command](../commands/stop.md) - what `crabbox_stop` invokes.

View File

@ -16,6 +16,8 @@ client-side JavaScript only for filtering, sorting, and clipboard copy.
```text
GET /portal
GET /portal/leases/{id-or-slug}
GET /portal/leases/{id-or-slug}/share
POST /portal/leases/{id-or-slug}/share
POST /portal/leases/{id-or-slug}/release
GET /portal/leases/{id-or-slug}/vnc
GET /portal/leases/{id-or-slug}/code/
@ -43,7 +45,8 @@ Default view rules:
- Defaults to active leases when any are active.
- Falls back to all visible leases when the active list is empty.
- Normal browser sessions see only their own owner/org leases.
- Normal browser sessions see their own leases plus leases shared directly
with them or with their org.
- Admin sessions also see non-owned runner leases. `mine` and `system`
filters distinguish personal leases from external runners (Blacksmith
Testboxes synced from CLI list output) so external rows do not leak to
@ -62,22 +65,35 @@ visibility-only detail page.
The lease detail page shows:
- compact provider/target badges and the lease state pill;
- bridge status (whether the WebVNC and code-server bridges are up);
- bridge status for the WebVNC, code-server, and mediated egress bridges,
including host/client connection state for an active egress session;
- the latest Linux telemetry sample as gauges, with sparklines when multiple
samples are present;
- stale-telemetry, high-load, high-memory, and high-disk status pills when
thresholds are exceeded;
- an access panel with copy-to-clipboard commands for `crabbox ssh`,
`crabbox run`, `crabbox webvnc`, and `crabbox code`;
`crabbox run`, `crabbox webvnc`, `crabbox code`, and (when an egress
session is active) `crabbox egress status` / `crabbox egress stop`;
- a viewport-fitted "recent runs" grid with state filters;
- a stop action when the lease is releasable.
Owners and users with `manage` access see a share control in the top-right
lease header. The share page can add individual users, set org-wide access, or
clear sharing. `use` shares can open visible lease pages and portal bridges;
`manage` shares can also change sharing and stop the lease.
`/portal/leases/{id-or-slug}/vnc` and `/portal/leases/{id-or-slug}/code/`
are bridges, not portal pages. They proxy WebSocket and HTTP traffic to the
matching capability on the lease so a user does not need an SSH tunnel to
open the desktop or editor. See
[Interactive desktop and VNC](interactive-desktop-vnc.md) and
[code command](../commands/code.md).
open the desktop or editor. The mediated egress bridge has its own
ticketed websocket route under `/v1/leases/{id-or-slug}/egress/...` rather
than a portal path, because egress is operator-driven and never opens an
HTML view. See [Interactive desktop and VNC](interactive-desktop-vnc.md),
[code command](../commands/code.md), and [Mediated egress](egress.md).
All bridge tickets travel as `Authorization: Bearer ...` headers on the
agent websocket upgrade, with a `?ticket=` query string fallback for older
CLIs. The portal never echoes ticket values back to the browser.
## Run Detail `/portal/runs/{run-id}`

View File

@ -2,20 +2,22 @@
Read when:
- changing Hetzner, AWS, or Blacksmith Testbox provisioning;
- changing Hetzner, AWS, Azure, or Blacksmith Testbox provisioning;
- adding a backend;
- adjusting machine classes, fallback order, regions, or images.
Crabbox currently supports two brokered providers:
Crabbox currently supports three 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, and EC2 Mac when a Dedicated Host is configured. Static SSH
still exists for reusing existing macOS and Windows machines:
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:
```text
ssh Existing SSH host selected by static.host
@ -32,6 +34,7 @@ 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, static SSH hosts, and Blacksmith
Providers still own machines: Hetzner, AWS, Azure, static SSH hosts, and Blacksmith
Testbox. Tailscale only changes which host Crabbox dials for SSH-backed work.
V1 support:
@ -85,7 +85,10 @@ Brokered mode does not require a local Tailscale key.
`tailscale.exitNode` asks the lease to route outbound internet through a
tailnet exit node after it joins Tailscale. Use a MagicDNS name or 100.x address
for an approved exit node. `tailscale.exitNodeAllowLanAccess` maps to
Tailscale's LAN-access flag and requires `tailscale.exitNode`.
Tailscale's LAN-access flag and requires `tailscale.exitNode`. In `network:
auto`, exit-node leases bootstrap over the tailnet host once it appears because
the public/provider SSH path can become asymmetric after the lease selects the
exit node.
## Brokered Mode
@ -130,6 +133,13 @@ The exit node must already advertise exit-node capability and be approved in
Tailscale admin. ACLs/grants must allow the lease's tags, such as
`tag:crabbox`, to access `autogroup:internet` through exit nodes.
After the lease is reachable, Crabbox verifies that the selected exit node can
reach the public internet. If that check fails, the run stops before sync or the
remote command and reports the exit-node egress failure. This usually means the
exit node is not approved for internet routing, the tailnet policy does not
grant `autogroup:internet` to the lease tag, or the exit-node machine itself is
not forwarding traffic.
## VNC And SSH
Crabbox continues to use OpenSSH and per-lease SSH keys. Tailscale SSH is not

View File

@ -15,6 +15,8 @@ bind x11vnc to loopback, and let the CLI create an SSH tunnel.
```sh
crabbox warmup --desktop --browser
crabbox run --id blue-lobster --desktop --browser -- google-chrome --version
crabbox desktop doctor --id blue-lobster
crabbox webvnc --id blue-lobster --open
crabbox vnc --id blue-lobster --open
crabbox screenshot --id blue-lobster --output linux.png
```
@ -25,6 +27,7 @@ Managed Linux desktop leases include:
- a lightweight desktop/window-manager session;
- x11vnc bound to `127.0.0.1:5900`;
- screenshot and video capture tools (`scrot` and `ffmpeg`);
- input helpers (`xdotool`) and clipboard paste tools (`xclip`/`xsel`);
- a generated per-lease VNC password at `/var/lib/crabbox/vnc.password`;
- optional Chrome stable or Chromium fallback, first-run suppression, and native
addon build helpers when `--browser` is requested;
@ -80,10 +83,29 @@ use:
crabbox desktop launch --id blue-lobster --browser --url https://example.com
```
Run `crabbox desktop doctor --id blue-lobster` to separate session problems
from WebVNC/browser-portal problems. Missing `xfwm4`, `xfce4-panel`, x11vnc,
clipboard tools, browser, ffmpeg, screen size, or screenshot capture each get a
specific repair line.
Input symbols are wrong
Use Crabbox's desktop helpers instead of raw `xdotool type`:
```sh
crabbox desktop paste --id blue-lobster --text "peter+qa@example.com"
crabbox desktop type --id blue-lobster --text "peter+qa@example.com"
```
`desktop type` uses clipboard paste for symbol-heavy text, so `@`, `+`,
password-like values, and URLs do not depend on the target X keyboard layout.
Related docs:
- [Interactive desktop and VNC](interactive-desktop-vnc.md)
- [Hetzner](hetzner.md)
- [AWS](aws.md)
- [vnc command](../commands/vnc.md)
- [webvnc command](../commands/webvnc.md)
- [desktop command](../commands/desktop.md)
- [screenshot command](../commands/screenshot.md)

View File

@ -29,7 +29,9 @@ EC2 Mac requirements:
Bootstrap enables Screen Sharing for `ec2-user`, sets a generated per-lease
password, stores it at `/var/db/crabbox/vnc.password`, and keeps access behind
the SSH tunnel. `crabbox vnc` prints:
the SSH tunnel. Managed EC2 Mac leases use `/Users/ec2-user/crabbox` as the
default work root because the macOS system volume is read-only. `crabbox vnc`
prints:
```text
macos username: ec2-user

232
docs/getting-started.md Normal file
View File

@ -0,0 +1,232 @@
# Getting Started
Read when:
- you are new to Crabbox and want a working `run` in 10 minutes;
- you are evaluating Crabbox for a repo and want to see the shape;
- you want a reference for what a typical onboarding looks like.
This is a cookbook, not a reference. It walks through one repo end to end,
from install to `crabbox run -- pnpm test`. For deeper coverage, follow the
links in each step.
## Step 1. Install
```sh
brew install openclaw/tap/crabbox
```
Verify the install:
```sh
crabbox --version
crabbox doctor
```
`crabbox doctor` should print `ok` for `tools` (git, rsync, ssh,
ssh-keygen). It is fine if `auth` and `network` are still missing - we set
those next.
If you do not have Homebrew, GitHub Releases ship signed tarballs for macOS,
Linux, and Windows. Download the matching archive from
<https://github.com/openclaw/crabbox/releases>.
## Step 2. Log In
```sh
crabbox login
```
`login` opens a browser to the GitHub OAuth flow. The broker exchanges the
OAuth code, verifies your GitHub org membership, and writes a signed token
to your user config. From then on, every `crabbox` command authenticates
automatically.
```sh
crabbox whoami
```
Confirms the resolved owner, org, broker URL, and selected provider.
If you are running Crabbox in a CI environment that cannot open a browser,
use shared-token auth:
```sh
printf '%s' "$TOKEN" | crabbox login \
--url https://crabbox.openclaw.ai \
--provider aws \
--token-stdin
```
See [Auth and admin](features/auth-admin.md) for the full identity model.
## Step 3. Onboard A Repo
Inside the repo:
```sh
crabbox init
```
`init` writes three files:
```text
.crabbox.yaml repo defaults (profile, class, sync, env)
.github/workflows/crabbox.yml Actions hydration stub (optional)
.agents/skills/crabbox/SKILL.md agent-facing skill instructions
```
Open `.crabbox.yaml` and fill in:
- `profile`: a name for this lane (e.g. `project-check`);
- `class`: `standard`, `fast`, `large`, or `beast`;
- `sync.exclude`: directories that should not be sent to the runner;
- `env.allow`: env vars the remote command should see.
Then run:
```sh
crabbox sync-plan
```
`sync-plan` previews what would be sent: file count, total bytes, the
biggest files. If it shows surprises (a `dist/` folder, a `.cache/` you
forgot, a 2 GiB asset), tighten `sync.exclude` and re-run. The first sync
to a fresh runner is bound by this size.
## Step 4. Warm A Box
```sh
crabbox warmup
```
Warmup acquires a lease through the broker, provisions the runner,
bootstraps SSH and tooling, and prints a slug + lease ID:
```text
leased cbx_abcdef123456 slug=blue-lobster provider=aws server=i-0123 type=c7a.48xlarge ip=203.0.113.10 idle_timeout=30m0s expires=2026-05-07T17:30:00Z
```
The lease is now waiting for commands. Idle timeout (default 30m) and TTL
(default 90m) bound how long it lives before the broker reclaims it.
## Step 5. Run A Command
```sh
crabbox run --id blue-lobster -- pnpm test
```
What happens:
1. The CLI verifies SSH readiness on the lease.
2. It seeds remote Git from your origin/base ref, then rsyncs the dirty
working tree.
3. It runs the command over SSH, streaming stdout/stderr.
4. It heartbeats the broker so the lease does not idle out mid-test.
5. It records a `run_...` history entry with sync time, command time, exit
code, and (for Linux) bounded telemetry samples.
You can omit `--id` for a one-shot run:
```sh
crabbox run -- pnpm test
```
That acquires a fresh lease, runs the command, and releases the lease when
the command exits. Use this for ad-hoc tests; use `warmup` + `--id` for
iterative work.
## Step 6. Inspect History
```sh
crabbox history
crabbox events run_abcdef123456
crabbox logs run_abcdef123456
crabbox results run_abcdef123456
```
`history` lists recent runs for the lease or owner. `events` prints ordered
events (lease, sync, command, output chunks, finish). `logs` returns the
retained command output. `results` parses any JUnit reports the run
attached.
`/portal/runs/run_abcdef123456` renders the same data as a browser page if
you prefer a UI.
## Step 7. Stop The Lease
When you are done:
```sh
crabbox stop blue-lobster
```
Stop releases the lease, deletes the provider machine, removes the local
claim, and frees reserved cost. If you forget, the broker idle alarm
releases the lease automatically.
```sh
crabbox cleanup --dry-run
```
`cleanup` is a sweep for direct-provider leftovers. It refuses to run when
a coordinator is configured because brokered cleanup is the alarm's job.
## Common Variations
Use a kept lease across days:
```sh
crabbox warmup --idle-timeout 4h --ttl 8h
crabbox run --id blue-lobster -- pnpm test
crabbox run --id blue-lobster -- pnpm bench
crabbox stop blue-lobster
```
Open a desktop session:
```sh
crabbox warmup --desktop
crabbox vnc --id blue-lobster --open
```
Open a code-server tab:
```sh
crabbox warmup --code
crabbox code --id blue-lobster --open
```
Use a Mac Studio you already own:
```yaml
# .crabbox.yaml
provider: ssh
target: macos
static:
host: mac-studio.local
user: steipete
port: "22"
workRoot: /Users/steipete/crabbox
```
```sh
crabbox run -- xcodebuild test
```
Use AWS instead of the configured default:
```sh
crabbox run --provider aws --class beast -- pnpm test
```
## Where To Go Next
- [How Crabbox Works](how-it-works.md) - the mental model.
- [CLI](cli.md) - the full command surface and exit codes.
- [Commands](commands/README.md) - one page per command.
- [Features](features/README.md) - one page per feature.
- [Configuration](features/configuration.md) - YAML schema and precedence.
- [Providers](features/providers.md) - which provider to pick.
- [Provider authoring](features/provider-authoring.md) - add a new provider.
- [Troubleshooting](troubleshooting.md) - what to do when a step fails.

View File

@ -352,8 +352,26 @@ 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,8 +115,41 @@ 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
@ -225,7 +258,10 @@ Before tagging a release:
- Live smoke at least one coordinator-backed `crabbox run`, then verify
`crabbox attach`, `crabbox events`, `crabbox logs`, and lease cleanup.
- Push, pull, and wait for CI green on the release commit.
- Tag and push `vX.Y.Z`, then wait for the release workflow.
- Tag and push `vX.Y.Z`, then wait for the release workflow. The workflow
publishes GitHub release assets and directly pushes the generated
`Formula/crabbox.rb` update to `openclaw/homebrew-tap` with
`HOMEBREW_TAP_GITHUB_TOKEN`; missing tap access is a release failure.
- Verify the GitHub release assets and Homebrew formula update.
- `brew update`, install or upgrade `openclaw/tap/crabbox`, run
`crabbox --version`, and run a short live smoke from the installed binary.

View File

@ -12,6 +12,7 @@ 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 |
@ -38,7 +39,8 @@ crabbox run --provider blacksmith-testbox --id tbx_123 -- pnpm test
## Brokered Versus Direct
AWS and Hetzner can run through the Crabbox coordinator or directly from the CLI.
AWS, Azure, 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.
@ -56,6 +58,7 @@ 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 |

215
docs/providers/azure.md Normal file
View File

@ -0,0 +1,215 @@
# 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

@ -8,8 +8,9 @@ Read when:
Islo is a delegated run provider. Crabbox uses the Islo SDK for sandbox
lifecycle and a streaming exec endpoint for command output. Islo owns sandbox
state and workspace setup; Crabbox owns local config, repo claims, slugs,
timing summaries, and normalized list/status rendering.
state and command transport; Crabbox owns local config, repo claims, sync
manifests and guardrails, slugs, timing summaries, and normalized list/status
rendering.
## When To Use
@ -69,23 +70,27 @@ Provider flags:
1. Create or resolve a Crabbox-owned Islo sandbox.
2. Store a local lease ID with the `isb_` prefix and a friendly slug.
3. Execute commands through Islo's streaming exec endpoint.
4. Require an exit event before treating a stream as successful.
5. Delete the sandbox on release unless kept.
3. Build the Crabbox sync manifest and upload a gzipped archive into
`/workspace/<islo.workdir>`.
4. Execute commands through Islo's streaming exec endpoint in that workdir.
5. Require an exit event before treating a stream as successful.
6. Delete the sandbox on release unless kept.
## Capabilities
- SSH: no.
- Crabbox sync: no.
- Provider sync: yes, Islo-owned.
- Crabbox sync: yes, archive sync through the Islo API or chunked exec fallback.
- Provider sync: no separate Islo CLI sync.
- Desktop/browser/code: no Crabbox VNC/code surface.
- Actions hydration: no.
- Coordinator: no.
## Gotchas
- `--sync-only`, `--checksum`, and `--force-sync-large` are rejected because
Crabbox cannot apply local rsync semantics.
- `--sync-only` and `--checksum` are rejected because Islo does not expose a
Crabbox SSH/rsync target.
- Large-sync guardrails still apply. Use `--force-sync-large` when a large Islo
archive sync is intentional.
- `--shell` passes the raw shell string to the remote shell path.
- IDs can be Crabbox slugs, `isb_...` lease IDs, or Crabbox-created sandbox
names. Non-Crabbox Islo sandboxes are rejected.

View File

@ -71,6 +71,16 @@ 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,6 +35,7 @@ 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`
@ -43,19 +44,21 @@ 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/hetzner`,
`internal/providers/aws`, `internal/providers/azure`,
`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/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 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/backend authoring guide: `docs/provider-backends.md`
- CLI cloud-init bootstrap: `internal/cli/bootstrap.go`
- Worker cloud-init bootstrap: `worker/src/bootstrap.ts`
@ -65,6 +68,7 @@ This page maps user-facing behavior back to implementation files. Keep docs desc
- VNC tunnel command: `internal/cli/vnc.go`
- WebVNC portal bridge: `internal/cli/webvnc.go`, `worker/src/portal.ts`, `worker/src/fleet.ts`
- Web code portal bridge: `internal/cli/code.go`, `worker/src/portal.ts`, `worker/src/fleet.ts`
- Mediated egress bridge: `internal/cli/egress.go`, `internal/cli/coordinator.go`, `internal/cli/desktop.go`, `worker/src/index.ts`, `worker/src/fleet.ts`, `docs/features/egress.md`
- Desktop screenshot command: `internal/cli/screenshot.go`
- Interactive desktop/VNC contract: `docs/features/interactive-desktop-vnc.md`, `docs/features/vnc-linux.md`, `docs/features/vnc-windows.md`, `docs/features/vnc-macos.md`
@ -104,6 +108,23 @@ repository-owned setup, usually through Actions hydration or repo scripts.
- Plugin metadata and config schema: `package.json`, `openclaw.plugin.json`
- Tool registration and CLI wrapper behavior: `index.js`
- Plugin tests: `index.test.js`
- Plugin feature doc: `docs/features/openclaw-plugin.md`
## Cross-cutting Feature Docs
- Configuration precedence and YAML schema: `docs/features/configuration.md` (config code in `internal/cli/config.go`, `internal/cli/config_cmd.go`)
- Identifiers (lease IDs, slugs, claims, run IDs): `docs/features/identifiers.md` (code in `internal/cli/lease.go`, `internal/cli/slug.go`, `internal/cli/claim.go`)
- Doctor checks: `docs/features/doctor.md` (code in `internal/cli/doctor.go`)
- Network and reachability: `docs/features/network.md` (code in `internal/cli/network.go`)
- Lease capabilities: `docs/features/capabilities.md` (code in `internal/cli/capabilities.go`)
- Environment forwarding: `docs/features/env-forwarding.md` (forwarding logic in `internal/cli/run.go`)
- Mediated egress: `docs/features/egress.md` (CLI/Worker bridge for browser/app egress through an operator machine)
- Capacity and fallback: `docs/features/capacity-fallback.md` (code in `internal/cli/aws.go`, `worker/src/aws.ts`, class maps in `internal/cli/config.go`)
- Telemetry: `docs/features/telemetry.md` (code in `internal/cli/telemetry.go`)
- Browser portal: `docs/features/portal.md` (code in `worker/src/portal.ts`)
- Provider authoring guide: `docs/features/provider-authoring.md` (cross-references `internal/cli/provider_backend.go` and `internal/providers/*`)
- Concepts/glossary: `docs/concepts.md`
- Getting started walkthrough: `docs/getting-started.md`
## Build, CI, Docs, And Release

View File

@ -178,7 +178,8 @@ Symptoms:
`tailscale_disabled`, or `invalid_tailscale_tags`;
- `--network tailscale` says the lease has no tailnet address;
- `--network tailscale` says the tailnet host is unreachable over SSH;
- `--network auto` falls back to `public`.
- `--network auto` falls back to `public`;
- `tailscale exit node ... joined but remote internet egress failed`.
Checks:
@ -200,6 +201,12 @@ Fixes:
- ensure requested tags are in the Worker `CRABBOX_TAILSCALE_TAGS` allowlist;
- ensure the local client is joined to the same tailnet and ACLs allow SSH to
the tagged node;
- for exit nodes, ensure the exit node is approved and that tailnet grants or
ACLs allow the lease tag, for example `tag:crabbox`, to reach
`autogroup:internet`;
- if the exit node is a personal Mac, verify Tailscale still advertises it as
an exit node and that the Mac can actually forward internet traffic for
clients;
- use `--network public` to prove the provider SSH path independently;
- use `--network auto` when fallback to public is acceptable;
- use `--network tailscale` when a missing or unreachable tailnet path should

17
go.mod
View File

@ -17,6 +17,13 @@ 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
@ -38,9 +45,12 @@ 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
@ -51,9 +61,10 @@ 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/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.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
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,3 +1,17 @@
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=
@ -60,6 +74,8 @@ 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=
@ -79,6 +95,10 @@ 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=
@ -110,12 +130,21 @@ 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

@ -89,8 +89,12 @@ func (a App) directCommandHelp(ctx context.Context, args []string) (error, bool)
return a.webvnc(ctx, helpArgs), true
case "code":
return a.webCode(ctx, helpArgs), true
case "egress":
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":
@ -137,6 +141,7 @@ 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
@ -146,6 +151,8 @@ Commands:
cache Inspect, purge, or warm remote caches
status Show lease state; add --wait to block until ready
list List Crabbox machines
share Share a lease with users or the owning org
unshare Remove lease sharing
image Create or promote brokered AWS runner images
usage Show cost and usage estimates by user, org, or fleet
admin Lease admin controls for trusted operators
@ -154,6 +161,7 @@ Commands:
vnc Print or open VNC connection details for a desktop lease
webvnc Bridge a desktop lease into the authenticated web portal
code Bridge a code lease into the authenticated web portal
egress Bridge lease browser/app traffic through this machine
screenshot Capture a PNG from a desktop lease
inspect Print lease/provider details; add --json for scripts
stop Release a lease or delete a direct-provider machine
@ -169,8 +177,13 @@ 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
crabbox share --id blue-lobster --user friend@example.com
crabbox share --id blue-lobster --org
crabbox screenshot --id blue-lobster --output desktop.png
crabbox inspect --id blue-lobster --json
crabbox history --lease cbx_abcdef123456
@ -193,11 +206,11 @@ Global:
--version Print version
Config:
crabbox login [--url <url>] [--provider aws|hetzner] [--no-browser]
crabbox login --url <url> --token-stdin [--provider aws|hetzner]
crabbox login [--url <url>] [--provider aws|azure|hetzner] [--no-browser]
crabbox login --url <url> --token-stdin [--provider aws|azure|hetzner]
crabbox config path
crabbox config show [--json]
crabbox config set-broker --url <url> --token-stdin [--provider aws|hetzner]
crabbox config set-broker --url <url> --token-stdin [--provider aws|azure|hetzner]
Environment:
CRABBOX_COORDINATOR Broker URL
@ -207,7 +220,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, ssh, blacksmith-testbox, daytona, or islo
CRABBOX_PROVIDER hetzner, aws, azure, 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

607
internal/cli/artifacts.go Normal file
View File

@ -0,0 +1,607 @@
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

@ -0,0 +1,636 @@
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

@ -0,0 +1,518 @@
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 or aws")
provider := fs.String("provider", "", "default provider: hetzner, aws, or azure")
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")

976
internal/cli/azure.go Normal file
View File

@ -0,0 +1,976 @@
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)
}

389
internal/cli/azure_test.go Normal file
View File

@ -0,0 +1,389 @@
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 --now ssh
systemctl restart ssh
systemctl enable ssh || true
timeout 30s systemctl restart ssh || timeout 30s systemctl restart ssh.socket || true
%[7]s
touch /var/lib/crabbox/bootstrapped
crabbox-ready
@ -102,12 +102,7 @@ tasks:
`
}
func windowsBootstrapPowerShell(cfg Config, publicKey string) string {
workRoot := cfg.WorkRoot
if workRoot == "" {
workRoot = `C:\crabbox`
}
wslMode := cfg.WindowsMode == windowsModeWSL2
func windowsBootstrapHeaderPowerShell(cfg Config, publicKey, workRoot string) string {
return `
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
@ -130,6 +125,108 @@ 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"
@ -139,17 +236,16 @@ $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
@ -244,74 +340,7 @@ 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" }
}
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")
` + windowsBootstrapCorePowerShell() + `
Initialize-CrabboxWSL2
if (-not (Test-Path -LiteralPath "C:\Program Files\TightVNC\tvnserver.exe")) {
Retry { Invoke-WebRequest -Uri ` + psQuote(tightVNCMSIURL) + ` -OutFile $tightVNCInstaller -UseBasicParsing }
@ -380,6 +409,24 @@ 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))
@ -392,14 +439,20 @@ func windowsSSHPortsPowerShell(cfg Config) string {
func macOSUserData(cfg Config, _ string) string {
workRoot := cfg.WorkRoot
if workRoot == "" {
workRoot = "/work/crabbox"
workRoot = defaultMacOSWorkRoot
}
return `#!/bin/bash
set -euxo pipefail
install -d -m 0755 ` + shellQuote(workRoot) + ` /var/db/crabbox
chown -R ` + shellQuote(cfg.SSHUser) + `:staff ` + shellQuote(workRoot) + `
if [ ! -s /var/db/crabbox/vnc.password ]; then
set +o pipefail
pw="$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 16)"
set -o pipefail
if [ "${#pw}" -ne 16 ]; then
echo "failed to generate vnc password" >&2
exit 1
fi
printf '%s\n' "$pw" >/var/db/crabbox/vnc.password
dscl . -passwd /Users/` + shellQuote(cfg.SSHUser) + ` "$pw"
fi
@ -534,7 +587,7 @@ func cloudInitOptionalBootstrap(cfg Config) string {
parts = append(parts, cloudInitTailscaleBootstrap(cfg))
}
if cfg.Desktop {
parts = append(parts, ` retry apt-get install -y --no-install-recommends xvfb xfce4-session xfwm4 xfce4-panel xfdesktop4 xfce4-terminal xfconf xfce4-settings x11vnc xauth dbus-x11 x11-xserver-utils xterm scrot ffmpeg xdotool wmctrl fonts-dejavu-core fonts-liberation iproute2 openssl
parts = append(parts, ` retry apt-get install -y --no-install-recommends xvfb xfce4-session xfwm4 xfce4-panel xfdesktop4 xfce4-terminal xfconf xfce4-settings x11vnc xauth dbus-x11 x11-xserver-utils xterm scrot ffmpeg xdotool wmctrl xclip xsel fonts-dejavu-core fonts-liberation iproute2 openssl
install -d -m 0750 -o crabbox -g crabbox /var/lib/crabbox
if [ ! -s /var/lib/crabbox/vnc.password ]; then
(umask 077 && openssl rand -base64 18 > /var/lib/crabbox/vnc.password)
@ -572,7 +625,7 @@ func cloudInitOptionalBootstrap(cfg Config) string {
install -d -m 0755 /etc/opt/chrome/policies/managed /etc/chromium/policies/managed
printf '%s\n' '{"DefaultBrowserSettingEnabled":false,"MetricsReportingEnabled":false,"PromotionalTabsEnabled":false}' > /etc/opt/chrome/policies/managed/crabbox.json
cp /etc/opt/chrome/policies/managed/crabbox.json /etc/chromium/policies/managed/crabbox.json
printf '%s\n' '#!/bin/sh' "exec \"$browser_path\" --no-first-run --no-default-browser-check --disable-default-apps \"\$@\"" > "$browser_wrapper"
printf '%s\n' '#!/bin/sh' "exec \"$browser_path\" --no-first-run --no-default-browser-check --disable-default-apps --window-size=1500,900 --window-position=80,80 \"\$@\"" > "$browser_wrapper"
chmod 0755 "$browser_wrapper"
printf 'CHROME_BIN=%s\nBROWSER=%s\n' "$browser_wrapper" "$browser_wrapper" > /var/lib/crabbox/browser.env
chown crabbox:crabbox /var/lib/crabbox/browser.env

View File

@ -17,6 +17,8 @@ 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) {
@ -26,6 +28,9 @@ 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)
@ -37,7 +42,7 @@ func TestCloudInitStartsSSHBeforeOptionalDesktopBootstrap(t *testing.T) {
cfg := baseConfig()
cfg.Desktop = true
got := cloudInit(cfg, "ssh-ed25519 test")
sshIndex := strings.Index(got, "systemctl restart ssh")
sshIndex := strings.Index(got, "timeout 30s 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 {
@ -58,7 +63,7 @@ func TestCloudInitDesktopProfile(t *testing.T) {
for _, want := range []string{
"xvfb xfce4-session xfwm4 xfce4-panel xfdesktop4 xfce4-terminal",
"xfconf xfce4-settings x11vnc xauth dbus-x11",
"x11-xserver-utils xterm scrot ffmpeg xdotool wmctrl",
"x11-xserver-utils xterm scrot ffmpeg xdotool wmctrl xclip xsel",
"/etc/systemd/system/crabbox-xvfb.service",
"/etc/systemd/system/crabbox-desktop.service",
"/usr/local/bin/crabbox-desktop-session",
@ -95,12 +100,12 @@ func TestCloudInitBrowserProfile(t *testing.T) {
"apt-cache show chromium-browser",
"/etc/opt/chrome/policies/managed/crabbox.json",
"/usr/local/bin/crabbox-browser",
"--no-first-run --no-default-browser-check --disable-default-apps",
"--no-first-run --no-default-browser-check --disable-default-apps --window-size=1500,900 --window-position=80,80",
"/var/lib/crabbox/browser.env",
"test -x \"$BROWSER\"",
"\"$BROWSER\" --version >/dev/null",
"printf '%s\\n' '{\"DefaultBrowserSettingEnabled\":false,\"MetricsReportingEnabled\":false,\"PromotionalTabsEnabled\":false}' > /etc/opt/chrome/policies/managed/crabbox.json",
"printf '%s\\n' '#!/bin/sh' \"exec \\\"$browser_path\\\" --no-first-run --no-default-browser-check --disable-default-apps \\\"\\$@\\\"\" > \"$browser_wrapper\"",
"printf '%s\\n' '#!/bin/sh' \"exec \\\"$browser_path\\\" --no-first-run --no-default-browser-check --disable-default-apps --window-size=1500,900 --window-position=80,80 \\\"\\$@\\\"\" > \"$browser_wrapper\"",
} {
if !strings.Contains(got, want) {
t.Fatalf("cloudInit(browser) missing %q", want)
@ -182,6 +187,7 @@ func TestAWSUserDataWindowsProfile(t *testing.T) {
"OpenSSH-Win64.zip",
"install-sshd.ps1",
"administrators_authorized_keys",
"Match Group administrators",
"$sshPorts = @('2222', '22')",
"sshd_config",
"Port $port",
@ -254,10 +260,15 @@ func TestAWSUserDataMacOSProfile(t *testing.T) {
cfg.Provider = "aws"
cfg.TargetOS = targetMacOS
cfg.SSHUser = "ec2-user"
cfg.WorkRoot = defaultMacOSWorkRoot
got := awsUserData(cfg, "ssh-ed25519 test")
for _, want := range []string{
"#!/bin/bash",
defaultMacOSWorkRoot,
"/var/db/crabbox/vnc.password",
"set +o pipefail",
"set -o pipefail",
"failed to generate vnc password",
"com.apple.screensharing",
"/usr/local/bin/crabbox-ready",
"nc -z 127.0.0.1 5900",

View File

@ -47,6 +47,9 @@ 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")
}
@ -270,7 +273,7 @@ func vncPasswordCommand(target SSHTarget) string {
return powershellCommand("Get-Content -Raw -LiteralPath " + psQuote(windowsVNCPasswordPath))
}
if target.TargetOS == targetMacOS {
return "cat " + shellQuote(macOSVNCPasswordPath)
return "sudo cat " + shellQuote(macOSVNCPasswordPath)
}
return "cat " + shellQuote(vncPasswordPath)
}

View File

@ -21,6 +21,7 @@ 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."`
@ -30,6 +31,8 @@ type crabboxKongCLI struct {
Cache cacheKongCmd `cmd:"" help:"Inspect, purge, or warm remote caches."`
Status statusKongCmd `cmd:"" passthrough:"" help:"Show lease state; add --wait to block until ready."`
List listKongCmd `cmd:"" passthrough:"" help:"List Crabbox machines."`
Share shareKongCmd `cmd:"" passthrough:"" help:"Share a lease with users or the owning org."`
Unshare unshareKongCmd `cmd:"" passthrough:"" help:"Remove lease sharing."`
Image imageKongCmd `cmd:"" help:"Create or promote brokered AWS runner images."`
Usage usageKongCmd `cmd:"" passthrough:"" help:"Show cost and usage estimates by user, org, or fleet."`
Admin adminKongCmd `cmd:"" help:"Lease admin controls for trusted operators."`
@ -38,6 +41,7 @@ type crabboxKongCLI struct {
Vnc vncKongCmd `cmd:"" name:"vnc" passthrough:"" help:"Print or open VNC connection details for a desktop lease."`
Webvnc webvncKongCmd `cmd:"" name:"webvnc" passthrough:"" help:"Bridge a desktop lease into the authenticated web portal."`
Code codeKongCmd `cmd:"" passthrough:"" help:"Bridge a code lease into the authenticated web portal."`
Egress egressKongCmd `cmd:"" passthrough:"" help:"Bridge lease browser/app traffic through this machine."`
Screenshot screenshotKongCmd `cmd:"" passthrough:"" help:"Capture a PNG from a desktop lease."`
Inspect inspectKongCmd `cmd:"" passthrough:"" help:"Print lease/provider details; add --json for scripts."`
Stop stopKongCmd `cmd:"" passthrough:"" help:"Release a lease or delete a direct-provider machine."`
@ -108,7 +112,7 @@ func normalizeKongHelpArgs(args []string) []string {
func isKongCommandGroup(command string) bool {
switch command {
case "actions", "admin", "cache", "config", "desktop", "image", "machine", "media", "pool":
case "actions", "admin", "artifacts", "cache", "config", "desktop", "image", "machine", "media", "pool":
return true
default:
return false
@ -160,6 +164,12 @@ type statusKongCmd struct {
type listKongCmd struct {
Args []string `arg:"" optional:""`
}
type shareKongCmd struct {
Args []string `arg:"" optional:""`
}
type unshareKongCmd struct {
Args []string `arg:"" optional:""`
}
type usageKongCmd struct {
Args []string `arg:"" optional:""`
}
@ -175,6 +185,9 @@ type webvncKongCmd struct {
type codeKongCmd struct {
Args []string `arg:"" optional:""`
}
type egressKongCmd struct {
Args []string `arg:"" optional:""`
}
type screenshotKongCmd struct {
Args []string `arg:"" optional:""`
}
@ -193,10 +206,30 @@ type cleanupKongCmd struct {
type desktopKongCmd struct {
Launch desktopLaunchKongCmd `cmd:"" passthrough:"" help:"Start an app inside a desktop lease."`
Doctor desktopDoctorKongCmd `cmd:"" passthrough:"" help:"Check desktop session readiness for a lease."`
Click desktopClickKongCmd `cmd:"" passthrough:"" help:"Click inside a desktop lease."`
Paste desktopPasteKongCmd `cmd:"" passthrough:"" help:"Paste text into a desktop lease."`
Type desktopTypeKongCmd `cmd:"" passthrough:"" help:"Type text into a desktop lease."`
Key desktopKeyKongCmd `cmd:"" passthrough:"" help:"Send keys to a desktop lease."`
}
type desktopLaunchKongCmd struct {
Args []string `arg:"" optional:""`
}
type desktopDoctorKongCmd struct {
Args []string `arg:"" optional:""`
}
type desktopClickKongCmd struct {
Args []string `arg:"" optional:""`
}
type desktopPasteKongCmd struct {
Args []string `arg:"" optional:""`
}
type desktopTypeKongCmd struct {
Args []string `arg:"" optional:""`
}
type desktopKeyKongCmd struct {
Args []string `arg:"" optional:""`
}
type mediaKongCmd struct {
Preview mediaPreviewKongCmd `cmd:"" passthrough:"" help:"Create a trimmed animated GIF preview from a video."`
@ -205,6 +238,29 @@ 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."`
@ -309,11 +365,14 @@ func (c *attachKongCmd) Run(ctx context.Context, app App) error { return app.a
func (c *resultsKongCmd) Run(ctx context.Context, app App) error { return app.results(ctx, c.Args) }
func (c *statusKongCmd) Run(ctx context.Context, app App) error { return app.status(ctx, c.Args) }
func (c *listKongCmd) Run(ctx context.Context, app App) error { return app.list(ctx, c.Args) }
func (c *shareKongCmd) Run(ctx context.Context, app App) error { return app.share(ctx, c.Args) }
func (c *unshareKongCmd) Run(ctx context.Context, app App) error { return app.unshare(ctx, c.Args) }
func (c *usageKongCmd) Run(ctx context.Context, app App) error { return app.usage(ctx, c.Args) }
func (c *sshKongCmd) Run(ctx context.Context, app App) error { return app.ssh(ctx, c.Args) }
func (c *vncKongCmd) Run(ctx context.Context, app App) error { return app.vnc(ctx, c.Args) }
func (c *webvncKongCmd) Run(ctx context.Context, app App) error { return app.webvnc(ctx, c.Args) }
func (c *codeKongCmd) Run(ctx context.Context, app App) error { return app.webCode(ctx, c.Args) }
func (c *egressKongCmd) Run(ctx context.Context, app App) error { return app.egress(ctx, c.Args) }
func (c *screenshotKongCmd) Run(ctx context.Context, app App) error {
return app.screenshot(ctx, c.Args)
}
@ -325,11 +384,42 @@ func (c *cleanupKongCmd) Run(ctx context.Context, app App) error { return app.cl
func (c *desktopLaunchKongCmd) Run(ctx context.Context, app App) error {
return app.desktopLaunch(ctx, c.Args)
}
func (c *desktopDoctorKongCmd) Run(ctx context.Context, app App) error {
return app.desktopDoctor(ctx, c.Args)
}
func (c *desktopClickKongCmd) Run(ctx context.Context, app App) error {
return app.desktopClick(ctx, c.Args)
}
func (c *desktopPasteKongCmd) Run(ctx context.Context, app App) error {
return app.desktopPaste(ctx, c.Args)
}
func (c *desktopTypeKongCmd) Run(ctx context.Context, app App) error {
return app.desktopType(ctx, c.Args)
}
func (c *desktopKeyKongCmd) Run(ctx context.Context, app App) error {
return app.desktopKey(ctx, c.Args)
}
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)
}
@ -394,3 +484,14 @@ 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 or aws")
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or azure")
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")
@ -250,9 +250,14 @@ func connectCodeBridge(ctx context.Context, coord *CoordinatorClient, leaseID, h
if err != nil {
return nil, err
}
ws, _, err := websocket.Dial(ctx, webCodeAgentURL(coord.BaseURL, leaseID, ticket.Ticket), &websocket.DialOptions{
HTTPHeader: coord.webVNCAccessHeaders(),
ws, resp, err := websocket.Dial(ctx, webCodeAgentURL(coord.BaseURL, leaseID), &websocket.DialOptions{
HTTPHeader: bridgeTicketHeaders(coord, ticket.Ticket),
})
if retryBridgeTicketInQuery(resp, err) {
ws, _, err = websocket.Dial(ctx, webCodeAgentURLWithTicket(coord.BaseURL, leaseID, ticket.Ticket), &websocket.DialOptions{
HTTPHeader: coord.webVNCAccessHeaders(),
})
}
if err != nil {
return nil, err
}
@ -690,7 +695,7 @@ func availableLocalCodePort() string {
return "8081"
}
func webCodeAgentURL(base, leaseID, ticket string) string {
func webCodeAgentURL(base, leaseID string) string {
u, err := url.Parse(base)
if err != nil {
return base
@ -701,6 +706,16 @@ func webCodeAgentURL(base, leaseID, ticket string) string {
u.Scheme = "ws"
}
u.Path = strings.TrimRight(u.Path, "/") + "/v1/leases/" + url.PathEscape(leaseID) + "/code/agent"
u.RawQuery = ""
u.Fragment = ""
return u.String()
}
func webCodeAgentURLWithTicket(base, leaseID, ticket string) string {
u, err := url.Parse(webCodeAgentURL(base, leaseID))
if err != nil {
return base
}
values := url.Values{}
values.Set("ticket", ticket)
u.RawQuery = values.Encode()

View File

@ -1,20 +1,27 @@
package cli
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"nhooyr.io/websocket"
)
func TestWebCodeURLs(t *testing.T) {
if got := webCodeAgentURL("https://crabbox.openclaw.ai", "cbx_abcdef123456", "code_abc"); got != "wss://crabbox.openclaw.ai/v1/leases/cbx_abcdef123456/code/agent?ticket=code_abc" {
if got := webCodeAgentURL("https://crabbox.openclaw.ai", "cbx_abcdef123456"); got != "wss://crabbox.openclaw.ai/v1/leases/cbx_abcdef123456/code/agent" {
t.Fatalf("agent URL=%q", got)
}
if got := webCodeAgentURLWithTicket("https://crabbox.openclaw.ai", "cbx_abcdef123456", "code_abc"); got != "wss://crabbox.openclaw.ai/v1/leases/cbx_abcdef123456/code/agent?ticket=code_abc" {
t.Fatalf("agent fallback URL=%q", got)
}
if got := webCodePortalURL("https://crabbox.openclaw.ai/", "cbx_abcdef123456"); got != "https://crabbox.openclaw.ai/portal/leases/cbx_abcdef123456/code/" {
t.Fatalf("portal URL=%q", got)
}
@ -23,6 +30,58 @@ func TestWebCodeURLs(t *testing.T) {
}
}
func TestConnectCodeBridgeSendsTicketInAuthorizationHeader(t *testing.T) {
agentConnected := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/leases/cbx_abcdef123456/code/ticket":
if r.Method != http.MethodPost {
t.Errorf("ticket method=%s", r.Method)
}
if got := r.Header.Get("Authorization"); got != "Bearer test-token" {
t.Errorf("authorization=%q", got)
}
_ = json.NewEncoder(w).Encode(coordinatorCodeTicket{
Ticket: "code_abcdef1234567890abcdef1234567890",
LeaseID: "cbx_abcdef123456",
})
case "/v1/leases/cbx_abcdef123456/code/agent":
if got := r.URL.Query().Get("ticket"); got != "" {
t.Errorf("query ticket=%q", got)
}
if got := r.Header.Get("Authorization"); got != "Bearer code_abcdef1234567890abcdef1234567890" {
t.Errorf("bridge authorization=%q", got)
}
conn, err := websocket.Accept(w, r, nil)
if err != nil {
t.Errorf("websocket accept: %v", err)
return
}
close(agentConnected)
_, _, _ = conn.Read(context.Background())
_ = conn.Close(websocket.StatusNormalClosure, "test done")
default:
http.NotFound(w, r)
}
}))
defer server.Close()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
coord := &CoordinatorClient{BaseURL: server.URL, Token: "test-token", Client: server.Client()}
bridge, err := connectCodeBridge(ctx, coord, "cbx_abcdef123456", "127.0.0.1", "8080")
if err != nil {
t.Fatal(err)
}
defer bridge.Close(websocket.StatusNormalClosure, "test done")
select {
case <-agentConnected:
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
func TestMappedRemoteCodeFolderTracksCurrentSubdirectory(t *testing.T) {
root := t.TempDir()
subdir := filepath.Join(root, "worker", "src")

View File

@ -38,6 +38,16 @@ 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
@ -195,25 +205,31 @@ 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,
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,
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,
Sync: SyncConfig{
Delete: true,
Checksum: false,
@ -283,6 +299,7 @@ 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"`
@ -336,6 +353,19 @@ 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"`
@ -648,6 +678,38 @@ 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
@ -943,6 +1005,18 @@ 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)
@ -1109,6 +1183,9 @@ 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)
}
@ -1122,6 +1199,9 @@ 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 or aws")
provider := fs.String("provider", "", "default provider: hetzner, aws, or azure")
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 {

View File

@ -37,6 +37,7 @@ type CoordinatorLease struct {
Region string `json:"region,omitempty"`
Owner string `json:"owner"`
Org string `json:"org"`
Share *CoordinatorShare `json:"share,omitempty"`
Profile string `json:"profile"`
Class string `json:"class"`
ServerType string `json:"serverType"`
@ -64,6 +65,20 @@ type CoordinatorLease struct {
TelemetryHistory []*LeaseTelemetry `json:"telemetryHistory,omitempty"`
}
type CoordinatorShareRole string
const (
CoordinatorShareUse CoordinatorShareRole = "use"
CoordinatorShareManage CoordinatorShareRole = "manage"
)
type CoordinatorShare struct {
Users map[string]CoordinatorShareRole `json:"users,omitempty"`
Org CoordinatorShareRole `json:"org,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
UpdatedBy string `json:"updatedBy,omitempty"`
}
type ProvisioningAttempt struct {
Region string `json:"region,omitempty"`
ServerType string `json:"serverType"`
@ -135,6 +150,55 @@ type CoordinatorWebVNCTicket struct {
ExpiresAt string `json:"expiresAt"`
}
type CoordinatorWebVNCEvent struct {
At string `json:"at"`
Event string `json:"event"`
Reason string `json:"reason,omitempty"`
}
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"`
}
type CoordinatorWebVNCReset struct {
LeaseID string `json:"leaseID"`
Slug string `json:"slug,omitempty"`
BridgeWasConnected bool `json:"bridgeWasConnected"`
ViewerWasConnected bool `json:"viewerWasConnected"`
Command string `json:"command"`
Events []CoordinatorWebVNCEvent `json:"events,omitempty"`
}
type CoordinatorEgressTicket struct {
Ticket string `json:"ticket"`
LeaseID string `json:"leaseID"`
Role string `json:"role"`
SessionID string `json:"sessionID"`
ExpiresAt string `json:"expiresAt"`
}
type CoordinatorEgressStatus struct {
LeaseID string `json:"leaseID"`
Slug string `json:"slug,omitempty"`
SessionID string `json:"sessionID,omitempty"`
Profile string `json:"profile,omitempty"`
Allow []string `json:"allow,omitempty"`
HostConnected bool `json:"hostConnected"`
ClientConnected bool `json:"clientConnected"`
CreatedAt string `json:"createdAt,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
}
type CoordinatorRunsResponse struct {
Runs []CoordinatorRun `json:"runs"`
}
@ -238,6 +302,38 @@ 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"`
@ -396,6 +492,8 @@ 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,
@ -429,6 +527,37 @@ func (c *CoordinatorClient) GetLease(ctx context.Context, id string) (Coordinato
return res.Lease, err
}
func (c *CoordinatorClient) LeaseShare(ctx context.Context, id string) (CoordinatorShare, error) {
var res struct {
Share CoordinatorShare `json:"share"`
}
err := c.do(ctx, http.MethodGet, "/v1/leases/"+url.PathEscape(id)+"/share", nil, &res)
return res.Share, err
}
func (c *CoordinatorClient) UpdateLeaseShare(ctx context.Context, id string, share CoordinatorShare) (CoordinatorShare, error) {
var res struct {
Share CoordinatorShare `json:"share"`
}
err := c.do(ctx, http.MethodPut, "/v1/leases/"+url.PathEscape(id)+"/share", share, &res)
return res.Share, err
}
func (c *CoordinatorClient) DeleteLeaseShare(ctx context.Context, id, user string, org bool) (CoordinatorShare, error) {
var res struct {
Share CoordinatorShare `json:"share"`
}
body := map[string]any{}
if strings.TrimSpace(user) != "" {
body["user"] = strings.TrimSpace(user)
}
if org {
body["org"] = true
}
err := c.do(ctx, http.MethodDelete, "/v1/leases/"+url.PathEscape(id)+"/share", body, &res)
return res.Share, err
}
func (c *CoordinatorClient) ReleaseLease(ctx context.Context, id string, deleteServer bool) (CoordinatorLease, error) {
var res struct {
Lease CoordinatorLease `json:"lease"`
@ -484,6 +613,25 @@ func (c *CoordinatorClient) Pool(ctx context.Context, cfg Config) ([]Coordinator
return res.Machines, err
}
func (c *CoordinatorClient) Leases(ctx context.Context, state string, limit int) ([]CoordinatorLease, error) {
var res struct {
Leases []CoordinatorLease `json:"leases"`
}
values := url.Values{}
if state != "" {
values.Set("state", state)
}
if limit > 0 {
values.Set("limit", strconv.Itoa(limit))
}
path := "/v1/leases"
if encoded := values.Encode(); encoded != "" {
path += "?" + encoded
}
err := c.do(ctx, http.MethodGet, path, nil, &res)
return res.Leases, err
}
func (c *CoordinatorClient) Usage(ctx context.Context, scope, owner, org, month string) (CoordinatorUsageResponse, error) {
var res CoordinatorUsageResponse
values := url.Values{}
@ -538,6 +686,42 @@ func (c *CoordinatorClient) CreateWebVNCTicket(ctx context.Context, leaseID stri
return res, err
}
func (c *CoordinatorClient) WebVNCStatus(ctx context.Context, leaseID string) (CoordinatorWebVNCStatus, error) {
var res CoordinatorWebVNCStatus
err := c.do(ctx, http.MethodGet, "/v1/leases/"+url.PathEscape(leaseID)+"/webvnc/status", nil, &res)
return res, err
}
func (c *CoordinatorClient) ResetWebVNC(ctx context.Context, leaseID string) (CoordinatorWebVNCReset, error) {
var res CoordinatorWebVNCReset
err := c.do(ctx, http.MethodPost, "/v1/leases/"+url.PathEscape(leaseID)+"/webvnc/reset", map[string]any{}, &res)
return res, err
}
func (c *CoordinatorClient) CreateEgressTicket(ctx context.Context, leaseID, role, sessionID, profile string, allow []string) (CoordinatorEgressTicket, error) {
var res CoordinatorEgressTicket
body := map[string]any{
"role": role,
}
if strings.TrimSpace(sessionID) != "" {
body["sessionID"] = strings.TrimSpace(sessionID)
}
if strings.TrimSpace(profile) != "" {
body["profile"] = strings.TrimSpace(profile)
}
if len(allow) > 0 {
body["allow"] = allow
}
err := c.do(ctx, http.MethodPost, "/v1/leases/"+url.PathEscape(leaseID)+"/egress/ticket", body, &res)
return res, err
}
func (c *CoordinatorClient) EgressStatus(ctx context.Context, leaseID string) (CoordinatorEgressStatus, error) {
var res CoordinatorEgressStatus
err := c.do(ctx, http.MethodGet, "/v1/leases/"+url.PathEscape(leaseID)+"/egress/status", nil, &res)
return res, err
}
func (c *CoordinatorClient) AdminLeases(ctx context.Context, state, owner, org string, limit int) ([]CoordinatorLease, error) {
var res struct {
Leases []CoordinatorLease `json:"leases"`
@ -654,6 +838,12 @@ 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{

View File

@ -379,6 +379,8 @@ 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
@ -401,6 +403,8 @@ 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",
@ -415,6 +419,12 @@ 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

@ -4,27 +4,34 @@ import (
"bytes"
"context"
"fmt"
"path/filepath"
"strings"
)
func (a App) desktopLaunch(ctx context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("desktop launch", a.Stderr)
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or ssh")
provider := fs.String("provider", defaults.Provider, providerHelpSSH())
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")
webvnc := fs.Bool("webvnc", false, "bridge the launched desktop into the authenticated WebVNC portal")
openPortal := fs.Bool("open", false, "open the WebVNC portal when --webvnc is set")
fullscreen := fs.Bool("fullscreen", false, "leave launched browser fullscreen for capture/video workflows")
egress := fs.String("egress", "", "egress profile; passes the active lease-local proxy to the browser")
egressProxy := fs.String("egress-proxy", defaultEgressListen, "lease-local egress proxy for --egress")
reclaim := fs.Bool("reclaim", false, "claim this lease for the current repo")
targetFlags := registerTargetFlags(fs, defaults)
networkFlags := registerNetworkModeFlag(fs, defaults)
if err := parseFlags(fs, args); err != nil {
return err
}
if *openPortal && !*webvnc {
return exit(2, "desktop launch --open requires --webvnc")
}
if strings.TrimSpace(*egress) != "" && !*browser {
return exit(2, "desktop launch --egress currently requires --browser")
}
positionalID := false
if *id == "" && fs.NArg() > 0 {
*id = fs.Arg(0)
@ -40,16 +47,19 @@ func (a App) desktopLaunch(ctx context.Context, args []string) error {
if err := applyTargetFlagOverrides(&cfg, fs, targetFlags); err != nil {
return err
}
if err := applyNetworkModeFlagOverride(&cfg, fs, networkFlags); err != nil {
return err
}
if err := validateRequestedCapabilities(cfg); err != nil {
return err
}
if *webvnc && (isBlacksmithProvider(cfg.Provider) || isStaticProvider(cfg.Provider)) {
return exit(2, "desktop launch --webvnc currently supports coordinator-backed hetzner/aws desktop leases")
return exit(2, "desktop launch --webvnc currently supports coordinator-backed hetzner/aws/azure desktop leases")
}
if *id == "" && !isStaticProvider(cfg.Provider) {
return exit(2, "usage: crabbox desktop launch --id <lease-id-or-slug> [--browser] [--url <url>] -- <command...>")
}
server, target, leaseID, err := a.resolveLeaseTarget(ctx, cfg, *id)
server, target, leaseID, err := a.resolveNetworkLeaseTarget(ctx, cfg, *id, false)
if err != nil {
return err
}
@ -75,26 +85,49 @@ func (a App) desktopLaunch(ctx context.Context, args []string) error {
if positionalID && len(command) > 0 && command[0] == *id {
command = command[1:]
}
expectBrowserLaunch := false
if *browser {
if len(command) == 0 {
if env["BROWSER"] == "" {
printRescue(a.Stdout, rescueBrowserNotLaunched, "browser=true requested but target did not report BROWSER", desktopDoctorCommand(rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID}))
return exit(2, "browser=true requested but target did not report BROWSER")
}
command = []string{env["BROWSER"]}
expectBrowserLaunch = true
if strings.TrimSpace(*egress) != "" {
command = append(command, "--proxy-server=http://"+strings.TrimSpace(*egressProxy))
}
if strings.TrimSpace(*url) != "" {
command = append(command, strings.TrimSpace(*url))
}
} else if strings.TrimSpace(*url) != "" {
expectBrowserLaunch = desktopCommandLooksLikeBrowser(command, env["BROWSER"])
if strings.TrimSpace(*egress) != "" {
command = append(command, "--proxy-server=http://"+strings.TrimSpace(*egressProxy))
}
command = append(command, strings.TrimSpace(*url))
} else if strings.TrimSpace(*egress) != "" {
expectBrowserLaunch = desktopCommandLooksLikeBrowser(command, env["BROWSER"])
command = append(command, "--proxy-server=http://"+strings.TrimSpace(*egressProxy))
} else {
expectBrowserLaunch = desktopCommandLooksLikeBrowser(command, env["BROWSER"])
}
}
if len(command) == 0 {
return exit(2, "usage: crabbox desktop launch --id <lease-id-or-slug> -- <command...>")
}
workdir := remoteJoin(cfg, leaseID, repo.Name)
if err := runSSHQuiet(ctx, target, desktopLaunchRemoteCommand(target, workdir, env, command, *browser && !*fullscreen)); err != nil {
rescueCtx := rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID}
if out, err := runSSHCombinedOutput(ctx, target, desktopLaunchRemoteCommand(target, workdir, env, command, *browser && !*fullscreen)); err != nil {
printRescue(a.Stdout, classifyDesktopFailure(out), trimFailureDetail(out), desktopDoctorCommand(rescueCtx), desktopLaunchRetryCommand(rescueCtx, command))
return exit(5, "launch desktop command: %v", err)
}
if expectBrowserLaunch && target.TargetOS == targetLinux {
if out, err := runSSHCombinedOutput(ctx, target, desktopBrowserLaunchCheckCommand()); err != nil {
printRescue(a.Stdout, rescueBrowserNotLaunched, trimFailureDetail(out), desktopDoctorCommand(rescueCtx), desktopLaunchRetryCommand(rescueCtx, command))
return exit(5, "browser not launched for %s: %v", leaseID, err)
}
}
fmt.Fprintf(a.Stdout, "launched: %s\n", strings.Join(command, " "))
if *webvnc {
return a.webvnc(ctx, desktopLaunchWebVNCArgs(cfg, target, leaseID, *openPortal))
@ -105,6 +138,9 @@ func (a App) desktopLaunch(ctx context.Context, args []string) error {
func desktopLaunchWebVNCArgs(cfg Config, target SSHTarget, leaseID string, openPortal bool) []string {
targetOS := firstNonBlank(target.TargetOS, cfg.TargetOS)
args := []string{"--provider", cfg.Provider, "--target", targetOS, "--id", leaseID}
if cfg.Network != "" && cfg.Network != NetworkAuto {
args = append(args, "--network", string(cfg.Network))
}
windowsMode := firstNonBlank(target.WindowsMode, cfg.WindowsMode)
if targetOS == targetWindows && windowsMode != "" {
args = append(args, "--windows-mode", windowsMode)
@ -181,6 +217,45 @@ func posixWindowBrowserCommand() string {
`
}
func desktopBrowserLaunchCheckCommand() string {
return `set +e
export DISPLAY="${DISPLAY:-:99}"
sleep 5
if command -v xdotool >/dev/null 2>&1; then
window="$(xdotool search --onlyvisible --class google-chrome 2>/dev/null | tail -1 || true)"
[ -n "$window" ] || window="$(xdotool search --onlyvisible --class chromium 2>/dev/null | tail -1 || true)"
if [ -n "$window" ]; then
exit 0
fi
echo "browser window not visible on DISPLAY=$DISPLAY" >&2
fi
if command -v pgrep >/dev/null 2>&1 && {
pgrep -x google-chrome >/dev/null 2>&1 ||
pgrep -x chrome >/dev/null 2>&1 ||
pgrep -x chromium >/dev/null 2>&1 ||
pgrep -x chromium-browser >/dev/null 2>&1
}; then
exit 0
fi
echo "browser process not found" >&2
exit 1`
}
func desktopCommandLooksLikeBrowser(command []string, browserEnv string) bool {
if len(command) == 0 {
return false
}
first := strings.TrimSpace(command[0])
if first == "" {
return false
}
if strings.TrimSpace(browserEnv) != "" && first == strings.TrimSpace(browserEnv) {
return true
}
lower := strings.ToLower(filepath.Base(first))
return strings.Contains(lower, "chrome") || strings.Contains(lower, "chromium")
}
func writeShellArgv(b *bytes.Buffer, command []string) {
for i, arg := range command {
if i > 0 {

View File

@ -0,0 +1,356 @@
package cli
import (
"context"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
)
func (a App) desktopDoctor(ctx context.Context, args []string) error {
target, cfg, leaseID, err := a.desktopCommandTarget(ctx, "desktop doctor", args, false)
if err != nil {
return err
}
fmt.Fprintf(a.Stdout, "lease: %s provider=%s target=%s\n", leaseID, cfg.Provider, target.TargetOS)
out, err := runSSHOutput(ctx, target, desktopDoctorRemoteCommand(target))
if err != nil {
return exit(5, "desktop doctor failed: %v", err)
}
fmt.Fprintln(a.Stdout, out)
if isBlacksmithProvider(cfg.Provider) || isStaticProvider(cfg.Provider) {
return nil
}
coord, useCoordinator, err := newTargetCoordinatorClient(cfg)
if err == nil && useCoordinator && coord != nil && coord.Token != "" {
rescueCtx := rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID}
status, err := coord.WebVNCStatus(ctx, leaseID)
if err != nil {
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)
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))
}
}
}
return nil
}
func (a App) desktopClick(ctx context.Context, args []string) error {
target, cfg, leaseID, err := a.desktopCommandTarget(ctx, "desktop click", args, true)
if err != nil {
return err
}
x, xOK := intFlagValue(args, "x")
y, yOK := intFlagValue(args, "y")
if !xOK || !yOK || x < 0 || y < 0 {
return exit(2, "usage: crabbox desktop click --id <lease-id-or-slug> --x <n> --y <n>")
}
if out, err := runSSHCombinedOutput(ctx, target, desktopClickRemoteCommand(x, y)); err != nil {
a.printDesktopInputRescue(classifyDesktopFailure(out), out, cfg, target, leaseID)
return exit(5, "desktop click failed for %s: %v", leaseID, err)
}
fmt.Fprintf(a.Stdout, "clicked: lease=%s x=%d y=%d\n", leaseID, x, y)
return nil
}
func (a App) desktopPaste(ctx context.Context, args []string) error {
target, cfg, leaseID, err := a.desktopCommandTarget(ctx, "desktop paste", args, true)
if err != nil {
return err
}
text, err := desktopTextArgOrStdin(a.Stderr, args, "desktop paste")
if err != nil {
return err
}
var stdout, stderr strings.Builder
if err := runSSHInput(ctx, target, desktopPasteRemoteCommand(), strings.NewReader(text), &stdout, &stderr); err != nil {
a.printDesktopInputRescue(classifyDesktopFailure(stderr.String()+"\n"+stdout.String()), stderr.String()+"\n"+stdout.String(), cfg, target, leaseID)
return exit(5, "desktop paste failed for %s: %v", leaseID, err)
}
fmt.Fprintf(a.Stdout, "pasted: lease=%s bytes=%d\n", leaseID, len(text))
return nil
}
func (a App) desktopType(ctx context.Context, args []string) error {
target, cfg, leaseID, err := a.desktopCommandTarget(ctx, "desktop type", args, true)
if err != nil {
return err
}
text, err := desktopTextArgOrStdin(a.Stderr, args, "desktop type")
if err != nil {
return err
}
if desktopShouldPasteForType(text) {
var stdout, stderr strings.Builder
if err := runSSHInput(ctx, target, desktopPasteRemoteCommand(), strings.NewReader(text), &stdout, &stderr); err != nil {
a.printDesktopInputRescue(classifyDesktopFailure(stderr.String()+"\n"+stdout.String()), stderr.String()+"\n"+stdout.String(), cfg, target, leaseID)
return exit(5, "desktop type paste fallback failed for %s: %v", leaseID, err)
}
fmt.Fprintf(a.Stdout, "typed: lease=%s method=paste bytes=%d\n", leaseID, len(text))
return nil
}
if out, err := runSSHCombinedOutput(ctx, target, desktopTypeRemoteCommand(text)); err != nil {
a.printDesktopInputRescue(classifyDesktopFailure(out), out, cfg, target, leaseID)
return exit(5, "desktop type failed for %s: %v", leaseID, err)
}
fmt.Fprintf(a.Stdout, "typed: lease=%s method=xdotool bytes=%d\n", leaseID, len(text))
return nil
}
func (a App) desktopKey(ctx context.Context, args []string) error {
target, cfg, leaseID, err := a.desktopCommandTarget(ctx, "desktop key", args, true)
if err != nil {
return err
}
keys, err := desktopKeySequenceArg(args)
if err != nil {
return err
}
if strings.TrimSpace(keys) == "" {
return exit(2, "usage: crabbox desktop key --id <lease-id-or-slug> <keys>")
}
if out, err := runSSHCombinedOutput(ctx, target, desktopKeyRemoteCommand(keys)); err != nil {
a.printDesktopInputRescue(classifyDesktopFailure(out), out, cfg, target, leaseID)
return exit(5, "desktop key failed for %s: %v", leaseID, err)
}
fmt.Fprintf(a.Stdout, "key: lease=%s keys=%s\n", leaseID, strings.TrimSpace(keys))
return nil
}
func (a App) printDesktopInputRescue(problem, output string, cfg Config, target SSHTarget, leaseID string) {
ctx := rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID}
printRescue(a.Stdout, problem, trimFailureDetail(output), desktopDoctorCommand(ctx))
}
func (a App) desktopCommandTarget(ctx context.Context, name string, args []string, requireLinux bool) (SSHTarget, Config, string, error) {
defaults := defaultConfig()
fs := newFlagSet(name, a.Stderr)
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or ssh")
id := fs.String("id", "", "lease id or slug")
targetFlags := registerTargetFlags(fs, defaults)
networkFlags := registerNetworkModeFlag(fs, defaults)
if strings.HasSuffix(name, "click") {
fs.Int("x", -1, "x coordinate")
fs.Int("y", -1, "y coordinate")
}
if strings.HasSuffix(name, "paste") || strings.HasSuffix(name, "type") {
fs.String("text", "", "text to enter")
}
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
}
setIDFromFirstArg(fs, id)
cfg, err := loadLeaseTargetConfig(fs, *provider, targetFlags, networkFlags, leaseTargetConfigOptions{Desktop: true})
if err != nil {
return SSHTarget{}, Config{}, "", err
}
if isBlacksmithProvider(cfg.Provider) {
return SSHTarget{}, Config{}, "", exit(2, "desktop helpers are not supported for provider=%s; Blacksmith owns machine connectivity", cfg.Provider)
}
if err := requireLeaseID(*id, "crabbox "+name+" --id <lease-id-or-slug>", cfg); err != nil {
return SSHTarget{}, Config{}, "", err
}
server, target, leaseID, err := a.resolveNetworkLeaseTarget(ctx, cfg, *id, false)
if err != nil {
return SSHTarget{}, Config{}, "", err
}
if err := enforceManagedLeaseCapabilities(cfg, server, leaseID); err != nil {
return SSHTarget{}, Config{}, "", err
}
if requireLinux && target.TargetOS != targetLinux {
return SSHTarget{}, Config{}, "", exit(2, "desktop input helpers currently require target=linux with xdotool")
}
a.touchLeaseTargetBestEffort(ctx, cfg, LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, "")
return target, cfg, leaseID, nil
}
func desktopKeySequenceArg(args []string) (string, error) {
defaults := defaultConfig()
fs := newFlagSet("desktop key", io.Discard)
fs.String("provider", defaults.Provider, "provider: hetzner, aws, or ssh")
id := fs.String("id", "", "lease id or slug")
registerTargetFlags(fs, defaults)
registerNetworkModeFlag(fs, defaults)
keys := fs.String("keys", "", "xdotool key sequence")
if err := parseFlags(fs, args); err != nil {
return "", err
}
if strings.TrimSpace(*keys) != "" {
return *keys, nil
}
remaining := fs.Args()
if *id == "" && len(remaining) > 0 {
remaining = remaining[1:]
}
if len(remaining) == 0 {
return "", nil
}
return remaining[0], nil
}
func desktopTextArgOrStdin(stderr io.Writer, args []string, name string) (string, error) {
_ = stderr
if text, ok := stringFlagValue(args, "text"); ok {
return text, nil
}
info, err := os.Stdin.Stat()
if err == nil && info.Mode()&os.ModeCharDevice != 0 {
return "", exit(2, "usage: crabbox %s --id <lease-id-or-slug> --text <text>", name)
}
data, err := io.ReadAll(os.Stdin)
if err != nil {
return "", exit(2, "read stdin: %v", err)
}
return string(data), nil
}
func stringFlagValue(args []string, name string) (string, bool) {
prefixes := []string{"--" + name + "=", "-" + name + "="}
names := map[string]bool{"--" + name: true, "-" + name: true}
for i, arg := range args {
for _, prefix := range prefixes {
if strings.HasPrefix(arg, prefix) {
return strings.TrimPrefix(arg, prefix), true
}
}
if names[arg] && i+1 < len(args) {
return args[i+1], true
}
}
return "", false
}
func intFlagValue(args []string, name string) (int, bool) {
value, ok := stringFlagValue(args, name)
if !ok {
return 0, false
}
n, err := strconv.Atoi(value)
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
}
if strings.ContainsAny(text, "\n\r\t @+:/\\'\"`$&|;<>[]{}()!*?=") {
return true
}
if len(text) > 64 {
return true
}
return false
}
func desktopClickRemoteCommand(x, y int) string {
return fmt.Sprintf(`set -eu
export DISPLAY="${DISPLAY:-:99}"
command -v xdotool >/dev/null 2>&1 || { echo "missing xdotool; warm a new --desktop lease or install xdotool" >&2; exit 127; }
xdotool mousemove %d %d click 1`, x, y)
}
func desktopKeyRemoteCommand(keys string) string {
return `set -eu
export DISPLAY="${DISPLAY:-:99}"
command -v xdotool >/dev/null 2>&1 || { echo "missing xdotool; warm a new --desktop lease or install xdotool" >&2; exit 127; }
xdotool key --clearmodifiers ` + shellQuote(strings.TrimSpace(keys))
}
func desktopTypeRemoteCommand(text string) string {
return `set -eu
export DISPLAY="${DISPLAY:-:99}"
command -v xdotool >/dev/null 2>&1 || { echo "missing xdotool; warm a new --desktop lease or install xdotool" >&2; exit 127; }
xdotool type --clearmodifiers --delay 1 -- ` + shellQuote(text)
}
func desktopPasteRemoteCommand() string {
return `set -eu
export DISPLAY="${DISPLAY:-:99}"
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
cat > "$tmp"
if command -v xclip >/dev/null 2>&1; then
timeout 5s xclip -selection clipboard -loops 1 "$tmp" &
clip_pid=$!
elif command -v xsel >/dev/null 2>&1; then
timeout 5s xsel --clipboard --input < "$tmp" &
clip_pid=$!
elif command -v wl-copy >/dev/null 2>&1; then
wl-copy --paste-once < "$tmp" &
clip_pid=$!
else
echo "missing clipboard tool; warm a new --desktop lease or install xclip/xsel" >&2
exit 127
fi
command -v xdotool >/dev/null 2>&1 || { echo "missing xdotool; warm a new --desktop lease or install xdotool" >&2; exit 127; }
sleep 0.2
xdotool key --clearmodifiers ctrl+v
wait "$clip_pid" || true`
}
func desktopDoctorRemoteCommand(target SSHTarget) string {
if target.TargetOS != targetLinux {
return `echo "session warn target unsupported repair=desktop doctor has full checks for linux/xvfb leases"`
}
return `set +e
export DISPLAY="${DISPLAY:-:99}"
check() {
layer="$1"; item="$2"; shift 2
if "$@" >/dev/null 2>&1; then
echo "$layer ok $item"
else
echo "$layer failed $item repair=$CRABBOX_REPAIR"
fi
}
CRABBOX_REPAIR="ensure DISPLAY=:99 is exported"; [ -n "$DISPLAY" ] && echo "session ok DISPLAY=$DISPLAY" || echo "session failed DISPLAY repair=export DISPLAY=:99"
CRABBOX_REPAIR="restart crabbox-xvfb.service"; check session xvfb pgrep -f "Xvfb :99"
CRABBOX_REPAIR="restart crabbox-desktop.service"; check session xfwm4 pgrep -x xfwm4
CRABBOX_REPAIR="restart crabbox-desktop.service"; check session panel pgrep -x xfce4-panel
CRABBOX_REPAIR="restart crabbox-x11vnc.service"; check vm vnc ss -ltn sport = :5900
CRABBOX_REPAIR="warm a new --desktop lease or install xdotool"; check input xdotool command -v xdotool
CRABBOX_REPAIR="warm a new --desktop lease or install xclip"; if command -v xclip >/dev/null 2>&1 || command -v xsel >/dev/null 2>&1 || command -v wl-copy >/dev/null 2>&1; then echo "input ok clipboard"; else echo "input failed clipboard repair=$CRABBOX_REPAIR"; fi
CRABBOX_REPAIR="warm with --browser or install Chrome/Chromium"; if [ -f /var/lib/crabbox/browser.env ]; then . /var/lib/crabbox/browser.env; fi; if [ -n "${BROWSER:-}" ] && [ -x "$BROWSER" ]; then echo "session ok browser=$BROWSER"; elif command -v google-chrome >/dev/null 2>&1 || command -v chromium >/dev/null 2>&1 || command -v chromium-browser >/dev/null 2>&1; then echo "session ok browser"; else echo "session failed browser repair=$CRABBOX_REPAIR"; fi
CRABBOX_REPAIR="warm a new --desktop lease or install ffmpeg"; check capture ffmpeg command -v ffmpeg
CRABBOX_REPAIR="restart crabbox-xvfb.service"; if command -v xrandr >/dev/null 2>&1; then size="$(xrandr 2>/dev/null | awk '/ connected/{getline; print $1; exit}')"; [ -n "$size" ] && echo "session ok screen=$size" || echo "session failed screen repair=$CRABBOX_REPAIR"; else echo "session failed screen repair=install x11-xserver-utils"; fi
CRABBOX_REPAIR="restart desktop services or install scrot"; if command -v scrot >/dev/null 2>&1; then tmp="$(mktemp --suffix=.png)" && scrot -z -o "$tmp" >/dev/null 2>&1 && test -s "$tmp"; ok=$?; rm -f "$tmp"; [ "$ok" -eq 0 ] && echo "capture ok screenshot" || echo "capture failed screenshot repair=$CRABBOX_REPAIR"; else echo "capture failed screenshot repair=$CRABBOX_REPAIR"; fi`
}

View File

@ -30,9 +30,109 @@ func TestDesktopLaunchRemoteCommandUsesDetachedPOSIXSession(t *testing.T) {
}
}
func TestDesktopTypeUsesPasteForSymbolHeavyText(t *testing.T) {
for _, text := range []string{"peter@example.com", "token+secret", "line one\nline two", "https://example.com"} {
if !desktopShouldPasteForType(text) {
t.Fatalf("expected paste fallback for %q", text)
}
}
if desktopShouldPasteForType("helloWorld123") {
t.Fatal("plain alphanumeric text should use xdotool type")
}
}
func TestDesktopPasteRemoteCommandPrefersClipboardTools(t *testing.T) {
got := desktopPasteRemoteCommand()
for _, want := range []string{
"timeout 5s xclip -selection clipboard -loops 1",
"timeout 5s xsel --clipboard --input",
"wl-copy --paste-once",
"xdotool key --clearmodifiers ctrl+v",
"wait \"$clip_pid\" || true",
} {
if !strings.Contains(got, want) {
t.Fatalf("paste command missing %q:\n%s", want, got)
}
}
}
func TestDesktopKeySequenceArgSkipsLeaseID(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{
name: "positional id",
args: []string{"blue-lobster", "ctrl+l"},
want: "ctrl+l",
},
{
name: "single dash id",
args: []string{"-id", "blue-lobster", "ctrl+l"},
want: "ctrl+l",
},
{
name: "double dash id",
args: []string{"--id", "blue-lobster", "ctrl+l"},
want: "ctrl+l",
},
{
name: "equals id",
args: []string{"--id=blue-lobster", "ctrl+l"},
want: "ctrl+l",
},
{
name: "explicit keys",
args: []string{"--id", "blue-lobster", "--keys", "ctrl+l"},
want: "ctrl+l",
},
{
name: "single dash explicit keys",
args: []string{"-id", "blue-lobster", "-keys", "ctrl+l"},
want: "ctrl+l",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := desktopKeySequenceArg(tt.args)
if err != nil {
t.Fatal(err)
}
if got != tt.want {
t.Fatalf("keys=%q, want %q", got, tt.want)
}
})
}
}
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},
Config{Provider: "aws", TargetOS: targetWindows, WindowsMode: windowsModeWSL2, Network: NetworkTailscale},
SSHTarget{TargetOS: targetWindows, WindowsMode: windowsModeWSL2},
"cbx_1",
true,
@ -41,6 +141,7 @@ func TestDesktopLaunchWebVNCArgsCarriesTargetDetails(t *testing.T) {
for _, want := range []string{
"--provider aws",
"--target windows",
"--network tailscale",
"--windows-mode wsl2",
"--id cbx_1",
"--open",
@ -51,6 +152,48 @@ func TestDesktopLaunchWebVNCArgsCarriesTargetDetails(t *testing.T) {
}
}
func TestDesktopLaunchRemoteCommandCanPassEgressProxyToBrowser(t *testing.T) {
got := desktopLaunchRemoteCommand(
SSHTarget{TargetOS: targetLinux},
"/work/crabbox/cbx_1/repo",
map[string]string{"DISPLAY": ":99", "BROWSER": "/usr/bin/chromium"},
[]string{"/usr/bin/chromium", "--proxy-server=http://127.0.0.1:3128", "https://discord.com/login"},
true,
)
if !strings.Contains(got, "'/usr/bin/chromium' '--proxy-server=http://127.0.0.1:3128' 'https://discord.com/login'") {
t.Fatalf("desktop launch command missing egress proxy arg:\n%s", got)
}
}
func TestDesktopCommandLooksLikeBrowser(t *testing.T) {
if !desktopCommandLooksLikeBrowser([]string{"/usr/bin/google-chrome"}, "") {
t.Fatal("google-chrome should be treated as browser")
}
if !desktopCommandLooksLikeBrowser([]string{"/opt/crabbox-browser"}, "/opt/crabbox-browser") {
t.Fatal("BROWSER env wrapper should be treated as browser")
}
if desktopCommandLooksLikeBrowser([]string{"xterm"}, "/opt/crabbox-browser") {
t.Fatal("xterm should not be treated as browser")
}
}
func TestDesktopBrowserLaunchCheckAvoidsSelfMatchingShell(t *testing.T) {
got := desktopBrowserLaunchCheckCommand()
if strings.Contains(got, "pgrep -f") {
t.Fatalf("launch check must not match its own shell text:\n%s", got)
}
for _, want := range []string{
"pgrep -x google-chrome",
"pgrep -x chrome",
"pgrep -x chromium",
"pgrep -x chromium-browser",
} {
if !strings.Contains(got, want) {
t.Fatalf("launch check missing process-name probe %q:\n%s", want, got)
}
}
}
func TestWindowsDesktopLaunchRemoteCommandUsesInteractiveTask(t *testing.T) {
got := desktopLaunchRemoteCommand(
SSHTarget{TargetOS: targetWindows, WindowsMode: windowsModeNormal},

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, or ssh")
provider := fs.String("provider", defaultConfig().Provider, "provider: hetzner, aws, azure, or ssh")
id := fs.String("id", "", "remote lease id to inspect")
targetFlags := registerTargetFlags(fs, defaultConfig())
if err := parseFlags(fs, args); err != nil {
@ -138,6 +138,20 @@ 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 {

970
internal/cli/egress.go Normal file
View File

@ -0,0 +1,970 @@
package cli
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
"nhooyr.io/websocket"
)
const (
defaultEgressListen = "127.0.0.1:3128"
egressRemoteBinary = "/tmp/crabbox-egress-client"
egressRemoteLog = "/tmp/crabbox-egress-client.log"
egressMaxMessageBytes = 2 * 1024 * 1024
egressCopyChunkBytes = 32 * 1024
egressOpenTimeout = 20 * time.Second
egressDialTimeout = 15 * time.Second
egressRemoteReadyWait = 5 * time.Second
egressDaemonRestartWait = 1 * time.Second
)
type egressProxyMessage struct {
Type string `json:"type"`
ID string `json:"id,omitempty"`
Host string `json:"host,omitempty"`
Port string `json:"port,omitempty"`
Body string `json:"body,omitempty"`
Error string `json:"error,omitempty"`
}
type egressOpenResult struct {
err error
}
func (a App) egress(ctx context.Context, args []string) error {
if len(args) == 0 || isHelpArg(args[0]) {
a.printEgressHelp()
if len(args) == 0 {
return exit(2, "missing egress subcommand")
}
return nil
}
switch args[0] {
case "host":
return a.egressHost(ctx, args[1:])
case "client":
return a.egressClient(ctx, args[1:])
case "start":
return a.egressStart(ctx, args[1:])
case "status":
return a.egressStatus(ctx, args[1:])
case "stop":
return a.egressStop(ctx, args[1:])
default:
a.printEgressHelp()
return exit(2, "unknown egress subcommand %q", args[0])
}
}
func (a App) printEgressHelp() {
fmt.Fprintln(a.Stdout, `Usage:
crabbox egress start --id <lease-id-or-slug> --profile discord [--daemon]
crabbox egress host --id <lease-id-or-slug> --profile discord
crabbox egress client --id <lease-id-or-slug> --listen 127.0.0.1:3128
crabbox egress status --id <lease-id-or-slug>
crabbox egress stop --id <lease-id-or-slug>
Mediated egress lets a lease-local browser/app proxy exit through the machine
running the egress host agent. The coordinator only mediates paired WebSocket
bridges; the host agent opens the real outbound TCP connections.`)
}
func (a App) egressHost(ctx context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("egress host", a.Stderr)
provider := fs.String("provider", defaults.Provider, "provider: hetzner or aws")
id := fs.String("id", "", "lease id or slug")
coordinatorURL := fs.String("coordinator", "", "coordinator URL override")
ticket := fs.String("ticket", "", "pre-created egress host ticket")
sessionID := fs.String("session", "", "egress session id")
profile := fs.String("profile", "", "egress profile name")
allowCSV := fs.String("allow", "", "comma-separated allowed host patterns")
if err := parseFlags(fs, args); err != nil {
return err
}
setIDFromFirstArg(fs, id)
allow := egressAllowlist(*profile, splitCSV(*allowCSV))
if *id == "" {
return exit(2, "usage: crabbox egress host --id <lease-id-or-slug> --profile <name>|--allow <hosts>")
}
if len(allow) == 0 {
return exit(2, "egress host requires --profile or --allow; refusing to start an open proxy")
}
coord, leaseID, err := a.egressCoordinatorAndLease(ctx, *provider, *coordinatorURL, *id)
if err != nil {
return err
}
bridge, err := connectEgressBridge(ctx, coord, leaseID, "host", *ticket, *sessionID, *profile, allow)
if err != nil {
return err
}
fmt.Fprintf(a.Stdout, "egress host: connected lease=%s session=%s profile=%s allow=%s\n", leaseID, bridge.sessionID, blank(*profile, "-"), strings.Join(allow, ","))
return bridge.serveHost(ctx, allow)
}
func (a App) egressClient(ctx context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("egress client", a.Stderr)
provider := fs.String("provider", defaults.Provider, "provider: hetzner or aws")
id := fs.String("id", "", "lease id or slug")
coordinatorURL := fs.String("coordinator", "", "coordinator URL override")
ticket := fs.String("ticket", "", "pre-created egress client ticket")
sessionID := fs.String("session", "", "egress session id")
listen := fs.String("listen", defaultEgressListen, "lease-local proxy listen address")
if err := parseFlags(fs, args); err != nil {
return err
}
setIDFromFirstArg(fs, id)
if *id == "" {
return exit(2, "usage: crabbox egress client --id <lease-id-or-slug> [--listen 127.0.0.1:3128]")
}
if err := validateEgressListen(*listen); err != nil {
return err
}
coord, leaseID, err := a.egressCoordinatorAndLease(ctx, *provider, *coordinatorURL, *id)
if err != nil {
return err
}
bridge, err := connectEgressBridge(ctx, coord, leaseID, "client", *ticket, *sessionID, "", nil)
if err != nil {
return err
}
fmt.Fprintf(a.Stdout, "egress client: connected lease=%s session=%s listen=%s\n", leaseID, bridge.sessionID, *listen)
return bridge.serveClient(ctx, *listen)
}
func (a App) egressStart(ctx context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("egress start", a.Stderr)
provider := fs.String("provider", defaults.Provider, "provider: hetzner or aws")
id := fs.String("id", "", "lease id or slug")
profile := fs.String("profile", "", "egress profile name")
allowCSV := fs.String("allow", "", "comma-separated allowed host patterns")
listen := fs.String("listen", defaultEgressListen, "lease-local proxy listen address")
coordinatorURL := fs.String("coordinator", "", "coordinator URL override")
daemon := fs.Bool("daemon", false, "start the local host bridge in the background")
targetFlags := registerTargetFlags(fs, defaults)
networkFlags := registerNetworkModeFlag(fs, defaults)
if err := parseFlags(fs, args); err != nil {
return err
}
setIDFromFirstArg(fs, id)
if *id == "" {
return exit(2, "usage: crabbox egress start --id <lease-id-or-slug> --profile <name>|--allow <hosts>")
}
allow := egressAllowlist(*profile, splitCSV(*allowCSV))
if len(allow) == 0 {
return exit(2, "egress start requires --profile or --allow; refusing to start an open proxy")
}
if err := validateEgressListen(*listen); err != nil {
return err
}
cfg, err := loadLeaseTargetConfig(fs, *provider, targetFlags, networkFlags, leaseTargetConfigOptions{})
if err != nil {
return err
}
cfg, err = egressStartCoordinatorConfig(cfg, *coordinatorURL)
if err != nil {
return err
}
coord, useCoordinator, err := newTargetCoordinatorClient(cfg)
if err != nil {
return err
}
if !useCoordinator || coord == nil || coord.Token == "" {
return exit(2, "egress start requires a configured coordinator login; run crabbox login first")
}
server, target, leaseID, err := a.resolveNetworkLeaseTarget(ctx, cfg, *id, false)
if err != nil {
return err
}
if err := enforceManagedLeaseCapabilities(cfg, server, leaseID); err != nil {
return err
}
sessionID := newLocalEgressSessionID()
if err := installRemoteEgressClient(ctx, target); err != nil {
return err
}
clientTicket, err := coord.CreateEgressTicket(ctx, leaseID, "client", sessionID, *profile, allow)
if err != nil {
return err
}
remote := remoteEgressClientCommand(coord.BaseURL, leaseID, clientTicket.Ticket, sessionID, *listen)
if err := runSSHQuiet(ctx, target, remote); err != nil {
return exit(5, "start remote egress client: %v", err)
}
if err := waitRemoteEgressClient(ctx, target, *listen); err != nil {
return err
}
fmt.Fprintf(a.Stdout, "egress client: lease=%s listen=%s log=%s\n", leaseID, *listen, egressRemoteLog)
hostArgs := []string{
"host",
"--provider", cfg.Provider,
"--id", leaseID,
"--coordinator", coord.BaseURL,
"--session", sessionID,
}
if strings.TrimSpace(*profile) != "" {
hostArgs = append(hostArgs, "--profile", strings.TrimSpace(*profile))
}
if len(allow) > 0 {
hostArgs = append(hostArgs, "--allow", strings.Join(allow, ","))
}
if *daemon {
return a.startEgressHostDaemon(leaseID, hostArgs)
}
hostTicket, err := coord.CreateEgressTicket(ctx, leaseID, "host", sessionID, *profile, allow)
if err != nil {
return err
}
hostArgs = append(hostArgs, "--ticket", hostTicket.Ticket)
return a.egressHost(ctx, hostArgs)
}
func (a App) egressStatus(ctx context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("egress status", a.Stderr)
provider := fs.String("provider", defaults.Provider, "provider: hetzner or aws")
id := fs.String("id", "", "lease id or slug")
coordinatorURL := fs.String("coordinator", "", "coordinator URL override")
if err := parseFlags(fs, args); err != nil {
return err
}
setIDFromFirstArg(fs, id)
if *id == "" {
return exit(2, "usage: crabbox egress status --id <lease-id-or-slug>")
}
coord, leaseID, err := a.egressCoordinatorAndLease(ctx, *provider, *coordinatorURL, *id)
if err != nil {
return err
}
status, err := coord.EgressStatus(ctx, leaseID)
if err != nil {
return err
}
fmt.Fprintf(a.Stdout, "egress: lease=%s session=%s profile=%s host=%t client=%t allow=%s\n", status.LeaseID, blank(status.SessionID, "-"), blank(status.Profile, "-"), status.HostConnected, status.ClientConnected, strings.Join(status.Allow, ","))
return nil
}
func (a App) egressStop(ctx context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("egress stop", a.Stderr)
provider := fs.String("provider", defaults.Provider, "provider: hetzner or aws")
id := fs.String("id", "", "lease id or slug")
targetFlags := registerTargetFlags(fs, defaults)
networkFlags := registerNetworkModeFlag(fs, defaults)
if err := parseFlags(fs, args); err != nil {
return err
}
setIDFromFirstArg(fs, id)
if *id == "" {
return exit(2, "usage: crabbox egress stop --id <lease-id-or-slug>")
}
stoppedLocal, err := a.stopEgressHostDaemon(*id)
if err != nil {
return err
}
cfg, cfgErr := loadLeaseTargetConfig(fs, *provider, targetFlags, networkFlags, leaseTargetConfigOptions{})
if cfgErr == nil {
if _, target, leaseID, resolveErr := a.resolveNetworkLeaseTarget(ctx, cfg, *id, false); resolveErr == nil {
_ = runSSHQuiet(ctx, target, "pkill -f '[c]rabbox-egress-client egress client' >/dev/null 2>&1 || true")
if leaseID != *id && !stoppedLocal {
stoppedLocal, _ = a.stopEgressHostDaemon(leaseID)
}
fmt.Fprintf(a.Stdout, "egress remote client: stopped lease=%s\n", leaseID)
}
}
if !stoppedLocal {
fmt.Fprintf(a.Stdout, "egress host daemon: no local daemon for %s\n", *id)
}
return nil
}
func (a App) egressCoordinatorAndLease(ctx context.Context, provider, coordinatorURL, id string) (*CoordinatorClient, string, error) {
cfg, err := loadConfig()
if err != nil {
return nil, "", err
}
cfg.Provider = provider
if strings.TrimSpace(coordinatorURL) != "" {
cfg.Coordinator = strings.TrimRight(strings.TrimSpace(coordinatorURL), "/")
cfg.CoordToken = firstNonBlank(cfg.CoordToken, "ticket-only")
}
coord, useCoordinator, err := newTargetCoordinatorClient(cfg)
if err != nil {
return nil, "", err
}
if !useCoordinator || coord == nil || coord.BaseURL == "" {
return nil, "", exit(2, "egress requires a configured coordinator")
}
if strings.TrimSpace(coordinatorURL) != "" && coord.Token == "ticket-only" {
return coord, id, nil
}
if coord.Token == "" {
return nil, "", exit(2, "egress requires a configured coordinator login; run crabbox login first")
}
lease, err := coord.GetLease(ctx, id)
if err != nil {
return nil, "", err
}
return coord, lease.ID, nil
}
type egressBridge struct {
ws *websocket.Conn
sessionID string
writeMu sync.Mutex
mu sync.Mutex
conns map[string]net.Conn
pending map[string]chan egressOpenResult
}
func connectEgressBridge(ctx context.Context, coord *CoordinatorClient, leaseID, role, ticket, sessionID, profile string, allow []string) (*egressBridge, error) {
if strings.TrimSpace(ticket) == "" {
resolvedSessionID, err := reusableEgressSessionID(ctx, coord, leaseID, sessionID)
if err != nil {
return nil, err
}
sessionID = resolvedSessionID
created, err := coord.CreateEgressTicket(ctx, leaseID, role, sessionID, profile, allow)
if err != nil {
return nil, err
}
ticket = created.Ticket
sessionID = created.SessionID
} else if strings.TrimSpace(sessionID) == "" {
sessionID = "egress_manual"
}
ws, _, err := websocket.Dial(ctx, egressAgentURL(coord.BaseURL, leaseID, role, ticket), &websocket.DialOptions{
HTTPHeader: coord.webVNCAccessHeaders(),
})
if err != nil {
return nil, err
}
ws.SetReadLimit(egressMaxMessageBytes)
return &egressBridge{
ws: ws,
sessionID: sessionID,
conns: map[string]net.Conn{},
pending: map[string]chan egressOpenResult{},
}, nil
}
func reusableEgressSessionID(ctx context.Context, coord *CoordinatorClient, leaseID, sessionID string) (string, error) {
if strings.TrimSpace(sessionID) != "" {
return strings.TrimSpace(sessionID), nil
}
status, err := coord.EgressStatus(ctx, leaseID)
if err != nil {
return "", err
}
return strings.TrimSpace(status.SessionID), nil
}
func (b *egressBridge) serveHost(ctx context.Context, allow []string) error {
defer b.close()
for {
var msg egressProxyMessage
if err := b.readMessage(ctx, &msg); err != nil {
return err
}
switch msg.Type {
case "open":
go b.hostOpen(ctx, msg, allow)
case "data":
b.writeConn(msg)
case "close":
b.closeConn(msg.ID)
}
}
}
func (b *egressBridge) hostOpen(ctx context.Context, msg egressProxyMessage, allow []string) {
if !egressHostAllowed(msg.Host, allow) {
_ = b.writeJSON(ctx, egressProxyMessage{Type: "error", ID: msg.ID, Error: "host not allowed"})
return
}
conn, err := (&net.Dialer{Timeout: egressDialTimeout}).DialContext(ctx, "tcp", net.JoinHostPort(msg.Host, msg.Port))
if err != nil {
_ = b.writeJSON(ctx, egressProxyMessage{Type: "error", ID: msg.ID, Error: err.Error()})
return
}
b.mu.Lock()
b.conns[msg.ID] = conn
b.mu.Unlock()
if err := b.writeJSON(ctx, egressProxyMessage{Type: "open_ok", ID: msg.ID}); err != nil {
_ = conn.Close()
return
}
go b.copyConnToBridge(ctx, msg.ID, conn)
}
func (b *egressBridge) serveClient(ctx context.Context, listen string) error {
defer b.close()
if err := validateEgressListen(listen); err != nil {
return err
}
ln, err := net.Listen("tcp", listen)
if err != nil {
return err
}
defer ln.Close()
errc := make(chan error, 2)
go func() { errc <- b.clientReadLoop(ctx) }()
go func() {
for {
conn, err := ln.Accept()
if err != nil {
errc <- err
return
}
go b.handleProxyConn(ctx, conn)
}
}()
select {
case <-ctx.Done():
return context.Cause(ctx)
case err := <-errc:
return err
}
}
func (b *egressBridge) clientReadLoop(ctx context.Context) error {
for {
var msg egressProxyMessage
if err := b.readMessage(ctx, &msg); err != nil {
return err
}
switch msg.Type {
case "open_ok":
b.finishOpen(msg.ID, nil)
case "error":
b.finishOpen(msg.ID, errors.New(msg.Error))
b.closeConn(msg.ID)
case "data":
b.writeConn(msg)
case "close":
b.closeConn(msg.ID)
}
}
}
func (b *egressBridge) handleProxyConn(ctx context.Context, conn net.Conn) {
defer conn.Close()
reader := bufio.NewReader(conn)
req, err := http.ReadRequest(reader)
if err != nil {
return
}
host, port, err := egressRequestHostPort(req)
if err != nil {
_, _ = io.WriteString(conn, "HTTP/1.1 400 Bad Request\r\n\r\n")
return
}
id := newLocalEgressConnID()
if err := b.openRemote(ctx, id, host, port); err != nil {
_, _ = io.WriteString(conn, "HTTP/1.1 502 Bad Gateway\r\n\r\n")
return
}
b.mu.Lock()
b.conns[id] = conn
b.mu.Unlock()
defer b.closeConn(id)
if req.Method == http.MethodConnect {
_, _ = io.WriteString(conn, "HTTP/1.1 200 Connection Established\r\nProxy-Agent: crabbox\r\n\r\n")
} else {
var buf bytes.Buffer
req.RequestURI = ""
req.URL.Scheme = ""
req.URL.Host = ""
if err := req.Write(&buf); err != nil {
return
}
if err := b.writeJSON(ctx, egressProxyMessage{Type: "data", ID: id, Body: base64.StdEncoding.EncodeToString(buf.Bytes())}); err != nil {
return
}
}
if reader.Buffered() > 0 {
buffered, _ := reader.Peek(reader.Buffered())
if len(buffered) > 0 {
_ = b.writeJSON(ctx, egressProxyMessage{Type: "data", ID: id, Body: base64.StdEncoding.EncodeToString(buffered)})
}
}
b.copyConnToBridge(ctx, id, conn)
}
func (b *egressBridge) openRemote(ctx context.Context, id, host, port string) error {
ch := make(chan egressOpenResult, 1)
b.mu.Lock()
b.pending[id] = ch
b.mu.Unlock()
if err := b.writeJSON(ctx, egressProxyMessage{Type: "open", ID: id, Host: host, Port: port}); err != nil {
return err
}
timer := time.NewTimer(egressOpenTimeout)
defer timer.Stop()
select {
case result := <-ch:
return result.err
case <-timer.C:
return errors.New("egress open timed out")
case <-ctx.Done():
return context.Cause(ctx)
}
}
func (b *egressBridge) finishOpen(id string, err error) {
b.mu.Lock()
ch := b.pending[id]
delete(b.pending, id)
b.mu.Unlock()
if ch != nil {
ch <- egressOpenResult{err: err}
}
}
func (b *egressBridge) copyConnToBridge(ctx context.Context, id string, conn net.Conn) {
buf := make([]byte, egressCopyChunkBytes)
for {
n, err := conn.Read(buf)
if n > 0 {
if writeErr := b.writeJSON(ctx, egressProxyMessage{
Type: "data",
ID: id,
Body: base64.StdEncoding.EncodeToString(buf[:n]),
}); writeErr != nil {
return
}
}
if err != nil {
_ = b.writeJSON(ctx, egressProxyMessage{Type: "close", ID: id})
b.closeConn(id)
return
}
}
}
func (b *egressBridge) writeConn(msg egressProxyMessage) {
data, err := base64.StdEncoding.DecodeString(msg.Body)
if err != nil {
return
}
b.mu.Lock()
conn := b.conns[msg.ID]
b.mu.Unlock()
if conn != nil {
_, _ = conn.Write(data)
}
}
func (b *egressBridge) closeConn(id string) {
b.mu.Lock()
conn := b.conns[id]
delete(b.conns, id)
delete(b.pending, id)
b.mu.Unlock()
if conn != nil {
_ = conn.Close()
}
}
func (b *egressBridge) readMessage(ctx context.Context, msg *egressProxyMessage) error {
_, data, err := b.ws.Read(ctx)
if err != nil {
return err
}
return json.Unmarshal(data, msg)
}
func (b *egressBridge) writeJSON(ctx context.Context, msg egressProxyMessage) error {
data, err := json.Marshal(msg)
if err != nil {
return err
}
b.writeMu.Lock()
defer b.writeMu.Unlock()
return b.ws.Write(ctx, websocket.MessageText, data)
}
func (b *egressBridge) close() {
if b == nil {
return
}
_ = b.ws.Close(websocket.StatusNormalClosure, "egress stopped")
b.mu.Lock()
defer b.mu.Unlock()
for id, conn := range b.conns {
_ = conn.Close()
delete(b.conns, id)
}
for id, ch := range b.pending {
ch <- egressOpenResult{err: errors.New("egress bridge stopped")}
delete(b.pending, id)
}
}
func egressRequestHostPort(req *http.Request) (string, string, error) {
hostport := req.Host
if req.URL != nil && req.URL.Host != "" {
hostport = req.URL.Host
}
if hostport == "" {
return "", "", errors.New("missing host")
}
host, port, err := net.SplitHostPort(hostport)
if err == nil {
return strings.ToLower(strings.Trim(host, "[]")), port, nil
}
host = strings.ToLower(strings.Trim(hostport, "[]"))
if req.Method == http.MethodConnect {
return "", "", fmt.Errorf("CONNECT target must include port: %s", hostport)
}
if req.URL != nil && req.URL.Scheme == "https" {
return host, "443", nil
}
return host, "80", nil
}
func egressAllowlist(profile string, explicit []string) []string {
out := sanitizeEgressAllowlist(explicit)
switch strings.ToLower(strings.TrimSpace(profile)) {
case "discord":
out = append(out, "discord.com", "*.discord.com", "discordcdn.com", "*.discordcdn.com", "hcaptcha.com", "*.hcaptcha.com")
case "slack":
out = append(out, "slack.com", "*.slack.com", "slack-edge.com", "*.slack-edge.com")
}
return sanitizeEgressAllowlist(out)
}
func sanitizeEgressAllowlist(values []string) []string {
seen := map[string]bool{}
var out []string
for _, value := range values {
normalized := strings.ToLower(strings.TrimSpace(value))
if normalized == "" || normalized == "*" || seen[normalized] {
continue
}
seen[normalized] = true
out = append(out, normalized)
}
return out
}
func egressHostAllowed(host string, allow []string) bool {
host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
if host == "" {
return false
}
for _, pattern := range allow {
pattern = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(pattern)), ".")
switch {
case strings.HasPrefix(pattern, "*."):
suffix := strings.TrimPrefix(pattern, "*.")
if host == suffix || strings.HasSuffix(host, "."+suffix) {
return true
}
case host == pattern:
return true
}
}
return false
}
func validateEgressListen(listen string) error {
host, port, err := net.SplitHostPort(strings.TrimSpace(listen))
if err != nil || strings.TrimSpace(port) == "" {
return exit(2, "invalid egress listen address %q; use 127.0.0.1:<port>", listen)
}
host = strings.Trim(strings.ToLower(strings.TrimSpace(host)), "[]")
if host == "localhost" {
return nil
}
ip := net.ParseIP(host)
if ip == nil || !ip.IsLoopback() {
return exit(2, "egress listen address must be loopback-only; use 127.0.0.1:<port>")
}
return nil
}
func splitCSV(value string) []string {
if strings.TrimSpace(value) == "" {
return nil
}
parts := strings.Split(value, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
if normalized := strings.TrimSpace(part); normalized != "" {
out = append(out, normalized)
}
}
return out
}
func egressCoordinatorNeedsAccess(access AccessConfig) bool {
return strings.TrimSpace(access.ClientID) != "" ||
strings.TrimSpace(access.ClientSecret) != "" ||
strings.TrimSpace(access.Token) != ""
}
func egressStartCoordinatorConfig(cfg Config, coordinatorURL string) (Config, error) {
if override := strings.TrimSpace(coordinatorURL); override != "" {
cfg.Coordinator = strings.TrimRight(override, "/")
cfg.Access = AccessConfig{}
return cfg, nil
}
if egressCoordinatorNeedsAccess(cfg.Access) {
return cfg, exit(2, "egress start cannot install a remote client when coordinator Access credentials are configured; use --coordinator with a public coordinator route or run egress client manually with safe credentials")
}
return cfg, nil
}
func egressAgentURL(base, leaseID, role, ticket string) string {
u, err := url.Parse(base)
if err != nil {
return base
}
if u.Scheme == "https" {
u.Scheme = "wss"
} else {
u.Scheme = "ws"
}
u.Path = strings.TrimRight(u.Path, "/") + "/v1/leases/" + url.PathEscape(leaseID) + "/egress/" + role
values := url.Values{}
values.Set("ticket", ticket)
u.RawQuery = values.Encode()
u.Fragment = ""
return u.String()
}
func newLocalEgressSessionID() string {
return "egress_" + strconv.FormatInt(time.Now().UnixNano(), 36)
}
func newLocalEgressConnID() string {
return "conn_" + strconv.FormatInt(time.Now().UnixNano(), 36)
}
func installRemoteEgressClient(ctx context.Context, target SSHTarget) error {
exe, cleanup, err := egressClientBinaryForTarget(ctx, target)
if err != nil {
return err
}
defer cleanup()
args := append(scpBaseArgs(target), exe, target.User+"@"+target.Host+":"+egressRemoteBinary)
cmd := exec.CommandContext(ctx, "scp", args...)
if out, err := cmd.CombinedOutput(); err != nil {
return exit(5, "copy egress client: %v: %s", err, strings.TrimSpace(string(out)))
}
return runSSHQuiet(ctx, target, "chmod 700 "+shellQuote(egressRemoteBinary))
}
func egressClientBinaryForTarget(ctx context.Context, target SSHTarget) (string, func(), error) {
exe, err := os.Executable()
if err != nil {
return "", func() {}, exit(2, "resolve crabbox executable: %v", err)
}
if target.TargetOS != "" && target.TargetOS != targetLinux {
return "", func() {}, exit(2, "egress start only supports Linux lease targets; target=%s is not supported", target.TargetOS)
}
if runtime.GOOS == "linux" {
return exe, func() {}, nil
}
repo, err := findRepo()
if err != nil {
return "", func() {}, exit(2, "cross-build egress client: %v", err)
}
out := filepath.Join(os.TempDir(), "crabbox-egress-client-linux-amd64-"+strconv.FormatInt(time.Now().UnixNano(), 36))
cmd := exec.CommandContext(ctx, "go", "build", "-trimpath", "-o", out, "./cmd/crabbox")
cmd.Dir = repo.Root
cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH=amd64", "CGO_ENABLED=0")
if data, err := cmd.CombinedOutput(); err != nil {
return "", func() {}, exit(5, "cross-build linux egress client: %v: %s", err, strings.TrimSpace(string(data)))
}
return out, func() { _ = os.Remove(out) }, nil
}
func scpBaseArgs(target SSHTarget) []string {
args := []string{
"-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UserKnownHostsFile=" + sshConfigFileValue(knownHostsFile(target)),
"-o", "ConnectTimeout=10",
"-o", "ConnectionAttempts=3",
"-P", target.Port,
}
if target.Key != "" {
args = append([]string{"-i", target.Key, "-o", "IdentitiesOnly=yes"}, args...)
}
return args
}
func remoteEgressClientCommand(coordinatorURL, leaseID, ticket, sessionID, listen string) string {
args := []string{
egressRemoteBinary,
"egress",
"client",
"--coordinator", coordinatorURL,
"--id", leaseID,
"--ticket", ticket,
"--session", sessionID,
"--listen", listen,
}
var b strings.Builder
b.WriteString("pkill -f '[c]rabbox-egress-client egress client' >/dev/null 2>&1 || true\n")
b.WriteString("nohup ")
for i, arg := range args {
if i > 0 {
b.WriteByte(' ')
}
b.WriteString(shellQuote(arg))
}
b.WriteString(" >" + shellQuote(egressRemoteLog) + " 2>&1 < /dev/null &\n")
return b.String()
}
func waitRemoteEgressClient(ctx context.Context, target SSHTarget, listen string) error {
host, port, err := net.SplitHostPort(listen)
if err != nil {
return exit(2, "invalid egress listen address %q", listen)
}
deadline := time.Now().Add(egressRemoteReadyWait)
for time.Now().Before(deadline) {
if ctx.Err() != nil {
return context.Cause(ctx)
}
if runSSHQuiet(ctx, target, egressRemoteProbeCommand(host, port)) == nil {
return nil
}
time.Sleep(250 * time.Millisecond)
}
return exit(5, "remote egress client did not listen on %s; inspect %s", listen, egressRemoteLog)
}
func egressRemoteProbeCommand(host, port string) string {
return "if command -v nc >/dev/null 2>&1; then nc -z " + shellQuote(host) + " " + shellQuote(port) + " >/dev/null 2>&1; else timeout 1 bash -lc " + shellQuote("</dev/tcp/"+host+"/"+port) + " >/dev/null 2>&1; fi"
}
func (a App) startEgressHostDaemon(leaseID string, args []string) error {
exe, err := os.Executable()
if err != nil {
return exit(2, "resolve crabbox executable: %v", err)
}
if stopped, err := a.stopEgressHostDaemon(leaseID); err != nil {
return err
} else if stopped {
fmt.Fprintln(a.Stdout, "egress host daemon: replacing previous daemon")
}
logPath, pidPath, err := egressDaemonPaths(leaseID)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(logPath), 0o700); err != nil {
return exit(2, "create egress daemon directory: %v", err)
}
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return exit(2, "open egress daemon log: %v", err)
}
defer logFile.Close()
childArgs := append([]string{"egress"}, args...)
cmd := exec.Command("sh", "-c", egressDaemonSupervisorScript(exe, childArgs))
cmd.Stdin = nil
cmd.Stdout = logFile
cmd.Stderr = logFile
configureDaemonCommand(cmd)
if err := cmd.Start(); err != nil {
return exit(5, "start egress daemon: %v", err)
}
pid := cmd.Process.Pid
if err := os.WriteFile(pidPath, []byte(fmt.Sprintf("%d\n", pid)), 0o600); err != nil {
_ = cmd.Process.Kill()
return exit(2, "write egress daemon pid: %v", err)
}
if err := cmd.Process.Release(); err != nil {
return exit(5, "release egress daemon process: %v", err)
}
fmt.Fprintf(a.Stdout, "egress host daemon: pid=%d log=%s\n", pid, logPath)
return nil
}
func (a App) stopEgressHostDaemon(leaseID string) (bool, error) {
_, pidPath, err := egressDaemonPaths(leaseID)
if err != nil {
return false, err
}
pid, err := readWebVNCDaemonPID(pidPath)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
command, alive := webVNCDaemonProcessCommand(pid)
if !alive {
_ = os.Remove(pidPath)
fmt.Fprintf(a.Stdout, "egress host daemon: removed stale pid=%d\n", pid)
return true, nil
}
if !isEgressDaemonCommand(command) {
return false, exit(5, "refusing to stop pid %d; command does not look like crabbox egress: %s", pid, strings.TrimSpace(command))
}
process, err := os.FindProcess(pid)
if err != nil {
return false, exit(5, "find egress daemon pid %d: %v", pid, err)
}
if err := stopDaemonProcess(process, pid); err != nil {
return false, exit(5, "stop egress daemon pid %d: %v", pid, err)
}
_ = os.Remove(pidPath)
fmt.Fprintf(a.Stdout, "egress host daemon: stopped pid=%d\n", pid)
return true, nil
}
func isEgressDaemonCommand(command string) bool {
command = strings.ToLower(command)
return strings.Contains(command, "crabbox") && strings.Contains(command, "egress")
}
func egressDaemonSupervisorScript(exe string, args []string) string {
argv := make([]string, 0, len(args)+1)
argv = append(argv, shellQuote(exe))
for _, arg := range args {
argv = append(argv, shellQuote(arg))
}
return "set -u\n" +
"echo 'egress daemon supervisor: starting'\n" +
"while :; do\n" +
" " + strings.Join(argv, " ") + "\n" +
" code=$?\n" +
" echo \"egress daemon supervisor: child exited code=$code; restarting in 1s\"\n" +
" sleep " + strconv.Itoa(int(egressDaemonRestartWait/time.Second)) + "\n" +
"done\n"
}
func egressDaemonPaths(leaseID string) (string, string, error) {
dir, err := crabboxStateDir()
if err != nil {
return "", "", err
}
bridgeDir := filepath.Join(dir, "egress")
name := safeWebVNCDaemonName(leaseID)
return filepath.Join(bridgeDir, name+".log"), filepath.Join(bridgeDir, name+".pid"), nil
}

183
internal/cli/egress_test.go Normal file
View File

@ -0,0 +1,183 @@
package cli
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func TestEgressHostAllowedMatchesExactAndWildcards(t *testing.T) {
allow := []string{"discord.com", "*.discordcdn.com"}
for _, host := range []string{"discord.com", "cdn.discordcdn.com", "media.cdn.discordcdn.com"} {
if !egressHostAllowed(host, allow) {
t.Fatalf("expected %s to be allowed", host)
}
}
for _, host := range []string{"example.com", "discord.com.evil.test"} {
if egressHostAllowed(host, allow) {
t.Fatalf("expected %s to be rejected", host)
}
}
}
func TestEgressAllowlistRejectsBareWildcard(t *testing.T) {
allow := egressAllowlist("", []string{"*"})
if len(allow) != 0 {
t.Fatalf("bare wildcard allowlist=%v, want empty", allow)
}
if egressHostAllowed("example.com", []string{"*"}) {
t.Fatal("bare wildcard should not allow every host")
}
}
func TestValidateEgressListenRequiresLoopback(t *testing.T) {
for _, listen := range []string{"127.0.0.1:3128", "localhost:3128", "[::1]:3128"} {
if err := validateEgressListen(listen); err != nil {
t.Fatalf("expected %s to be valid: %v", listen, err)
}
}
for _, listen := range []string{"0.0.0.0:3128", ":3128", "192.168.1.10:3128", "[::]:3128"} {
if err := validateEgressListen(listen); err == nil {
t.Fatalf("expected %s to be rejected", listen)
}
}
}
func TestEgressCoordinatorNeedsAccess(t *testing.T) {
if egressCoordinatorNeedsAccess(AccessConfig{}) {
t.Fatal("empty access config should not block egress start")
}
for _, access := range []AccessConfig{
{ClientID: "client"},
{ClientSecret: "secret"},
{Token: "jwt"},
} {
if !egressCoordinatorNeedsAccess(access) {
t.Fatalf("access config should block egress start: %#v", access)
}
}
}
func TestEgressStartCoordinatorOverrideUsesPublicRoute(t *testing.T) {
cfg := Config{
Coordinator: "https://crabbox-access.openclaw.ai",
Access: AccessConfig{ClientID: "client", ClientSecret: "secret", Token: "jwt"},
}
got, err := egressStartCoordinatorConfig(cfg, "https://crabbox.openclaw.ai/")
if err != nil {
t.Fatal(err)
}
if got.Coordinator != "https://crabbox.openclaw.ai" {
t.Fatalf("coordinator=%q", got.Coordinator)
}
if egressCoordinatorNeedsAccess(got.Access) {
t.Fatalf("override should clear access headers for remote-safe start: %#v", got.Access)
}
if _, err := egressStartCoordinatorConfig(cfg, ""); err == nil {
t.Fatal("expected access-protected coordinator without override to be rejected")
}
}
func TestEgressClientBinaryRejectsNonLinuxTargets(t *testing.T) {
_, cleanup, err := egressClientBinaryForTarget(context.Background(), SSHTarget{TargetOS: targetWindows})
defer cleanup()
if err == nil {
t.Fatal("expected non-Linux egress target to be rejected")
}
if !strings.Contains(err.Error(), "only supports Linux lease targets") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestManualEgressTicketCreationReusesActiveSession(t *testing.T) {
var ticketBody map[string]any
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/v1/leases/cbx_abcdef123456/egress/status":
_ = json.NewEncoder(w).Encode(map[string]any{
"leaseID": "cbx_abcdef123456",
"sessionID": "egress_shared123",
})
case r.Method == http.MethodPost && r.URL.Path == "/v1/leases/cbx_abcdef123456/egress/ticket":
if err := json.NewDecoder(r.Body).Decode(&ticketBody); err != nil {
t.Fatalf("decode ticket body: %v", err)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"ticket": "egress_ticket",
"leaseID": "cbx_abcdef123456",
"role": "client",
"sessionID": ticketBody["sessionID"],
"expiresAt": "2026-05-07T00:00:00Z",
})
default:
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
coord := &CoordinatorClient{BaseURL: server.URL, Client: server.Client()}
sessionID, err := reusableEgressSessionID(context.Background(), coord, "cbx_abcdef123456", "")
if err != nil {
t.Fatal(err)
}
if sessionID != "egress_shared123" {
t.Fatalf("sessionID=%q", sessionID)
}
if _, err := coord.CreateEgressTicket(context.Background(), "cbx_abcdef123456", "client", sessionID, "", nil); err != nil {
t.Fatal(err)
}
if ticketBody["sessionID"] != "egress_shared123" {
t.Fatalf("ticket sessionID=%v", ticketBody["sessionID"])
}
}
func TestEgressRequestHostPort(t *testing.T) {
connect := &http.Request{Method: http.MethodConnect, Host: "discord.com:443"}
host, port, err := egressRequestHostPort(connect)
if err != nil {
t.Fatal(err)
}
if host != "discord.com" || port != "443" {
t.Fatalf("CONNECT host/port=%s/%s", host, port)
}
absolute := &http.Request{
Method: http.MethodGet,
Host: "proxy.local",
URL: &url.URL{Scheme: "http", Host: "example.com", Path: "/"},
}
host, port, err = egressRequestHostPort(absolute)
if err != nil {
t.Fatal(err)
}
if host != "example.com" || port != "80" {
t.Fatalf("absolute URL host/port=%s/%s", host, port)
}
}
func TestEgressAgentURL(t *testing.T) {
got := egressAgentURL("https://crabbox.openclaw.ai", "cbx_abcdef123456", "host", "egress_abc")
want := "wss://crabbox.openclaw.ai/v1/leases/cbx_abcdef123456/egress/host?ticket=egress_abc"
if got != want {
t.Fatalf("egressAgentURL=%q want %q", got, want)
}
}
func TestRemoteEgressClientCommandRedactsThroughShellQuoting(t *testing.T) {
got := remoteEgressClientCommand("https://crabbox.openclaw.ai", "cbx_abcdef123456", "egress_ticket", "egress_session", "127.0.0.1:3128")
for _, want := range []string{
"pkill -f '[c]rabbox-egress-client egress client'",
"'/tmp/crabbox-egress-client' 'egress' 'client'",
"'--coordinator' 'https://crabbox.openclaw.ai'",
"'--ticket' 'egress_ticket'",
">'/tmp/crabbox-egress-client.log' 2>&1",
} {
if !strings.Contains(got, want) {
t.Fatalf("remote command missing %q:\n%s", want, got)
}
}
}

View File

@ -38,7 +38,13 @@ 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

@ -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, "provider: hetzner, aws, or ssh")
provider := fs.String("provider", defaults.Provider, providerHelpSSH())
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.TargetOS == targetWindows {
if (cfg.Provider == "aws" || cfg.Provider == "azure") && cfg.TargetOS == targetWindows {
return ensureTestboxKeyWithType(leaseID, "rsa")
}
return ensureTestboxKey(leaseID)

View File

@ -129,6 +129,9 @@ func (a App) resolveNetworkLeaseTarget(ctx context.Context, cfg Config, id strin
return Server{}, SSHTarget{}, "", err
}
target = resolved.Target
if target.Host != "" {
_ = probeSSHTransport(ctx, &target, 4*time.Second)
}
if printFallback && resolved.FallbackReason != "" {
fmt.Fprintf(a.Stderr, "network fallback %s\n", resolved.FallbackReason)
}

View File

@ -269,6 +269,16 @@ 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

@ -251,6 +251,31 @@ func resolveNetworkTarget(ctx context.Context, cfg Config, server Server, target
}
}
func bootstrapNetworkTarget(cfg Config, server Server, target SSHTarget) SSHTarget {
if !preferTailscaleBootstrap(cfg, server) {
return target
}
host := tailscaleTargetHost(serverTailscaleMetadata(server))
if host == "" {
return target
}
next := target
next.Host = host
next.NetworkKind = NetworkTailscale
return next
}
func preferTailscaleBootstrap(cfg Config, server Server) bool {
if cfg.Network == NetworkPublic {
return false
}
if cfg.Network == NetworkTailscale {
return true
}
meta := serverTailscaleMetadata(server)
return meta.Enabled && meta.ExitNode != ""
}
func tailscaleTargetHost(meta TailscaleMetadata) string {
return firstNonEmpty(meta.FQDN, meta.IPv4, meta.Hostname)
}
@ -370,3 +395,51 @@ if [ -f /var/lib/crabbox/tailscale-exit-node-allow-lan-access ]; then cat /var/l
}
return meta, nil
}
func validateTailscaleExitNodeEgress(ctx context.Context, server Server, target SSHTarget) error {
meta := serverTailscaleMetadata(server)
if strings.TrimSpace(meta.ExitNode) == "" {
return nil
}
command := tailscaleExitNodeEgressCheckScript()
if out, err := runSSHCombinedOutput(ctx, target, command); err != nil {
detail := strings.TrimSpace(out)
if detail == "" {
detail = err.Error()
}
return exit(5, "tailscale exit node %s joined but remote internet egress failed; verify the exit node is approved and forwarding internet traffic: %s", meta.ExitNode, detail)
}
return nil
}
func tailscaleExitNodeEgressCheckScript() string {
return `set -eu
if ! command -v tailscale >/dev/null 2>&1; then
printf '%s\n' "tailscale is not installed for exit-node egress check" >&2
exit 87
fi
prefs="$(tailscale debug prefs 2>/dev/null)" || {
printf '%s\n' "tailscale prefs unavailable for exit-node egress check" >&2
exit 88
}
case "$prefs" in
*'"ExitNodeID": ""'*|*'"ExitNodeID":""'*)
printf '%s\n' "exit node is not selected in tailscale prefs" >&2
exit 86
;;
*'"ExitNodeID":'*)
;;
*)
printf '%s\n' "tailscale prefs did not include ExitNodeID" >&2
exit 89
;;
esac
if command -v curl >/dev/null 2>&1; then
timeout 12 sh -c 'curl -4fsS --connect-timeout 5 https://ifconfig.me/ip || curl -4fsS --connect-timeout 5 https://icanhazip.com' >/tmp/crabbox-exit-node-ip
else
printf '%s\n' "curl is not installed for exit-node egress check" >&2
exit 87
fi
test -s /tmp/crabbox-exit-node-ip
`
}

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