Merge pull request #30 from openclaw/work/reapply-main-work-20260504233337

feat: stage desktop and WebVNC updates
This commit is contained in:
Vincent Koc 2026-05-05 13:00:37 -07:00 committed by GitHub
commit 31c95eb7bf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 2728 additions and 151 deletions

View File

@ -55,4 +55,4 @@ brews:
install: |
bin.install "crabbox"
test: |
system "#{bin}/crabbox", "--version"
system bin/"crabbox", "--version"

View File

@ -7,6 +7,8 @@
- Added `crabbox desktop launch --webvnc --open` to launch a desktop browser/app and immediately bridge the same lease into the WebVNC portal.
- Added `crabbox webvnc --daemon`/`--background` plus `--status`/`--stop` for background WebVNC bridges without tmux.
- Added `crabbox media preview` for creating motion-trimmed GIF previews and optional trimmed MP4 clips from desktop recordings.
- Added `crabbox code` and per-lease `/code/` portal URLs for authenticated code-server access on `--code` Linux leases.
- Added `.crabboxignore` for repo-local sync-only exclude patterns shared by `run` and `sync-plan`.
### Fixed
@ -23,6 +25,12 @@
- Fixed managed Linux browser setup so Chrome/Chromium launches skip first-run and default-browser prompts.
- Fixed managed Linux browser cloud-init setup so Chrome/Chromium policy and wrapper generation cannot break YAML parsing.
- Fixed WebVNC portal passwords with escaped special characters and kept the bridge alive across viewer resets and transient coordinator EOFs.
- Fixed managed AWS Windows WSL2 bootstrap by using the current Ubuntu WSL rootfs URL, downloading large rootfs files through `curl.exe`, and retrying empty or partial rootfs downloads instead of reusing a poisoned tarball. Thanks @vincentkoc.
- Fixed AWS Windows WSL2 mode overrides so they refresh the default instance type to a nested-virtualization-capable family. Thanks @steipete.
- Fixed AWS Windows WSL2 runs so mode overrides also refresh the default work root to `/work/crabbox` while keeping WSL2 sync on the fast rsync path.
- Fixed remote git seeding so an unfetchable local commit cannot leave an empty `.git` worktree that makes sync sanity report every tracked file as deleted.
- Skipped remote git seeding for local commits that are not present in any remote-tracking ref, avoiding slow doomed clone/fetch attempts before rsync.
- Fixed Windows archive sync from macOS so Apple extended attributes do not spam remote tar warnings.
## 0.5.0 - 2026-05-04
@ -56,8 +64,6 @@
- Fixed failed Blacksmith Testbox warmups so printed, newly listed, or delayed `tbx_...` boxes are stopped instead of being left queued after an upstream workflow error.
- Fixed `crabbox run --junit` so all-passing JUnit files record results instead of leaving the coordinator run stuck when the failure list is empty.
- Fixed native Windows `--shell` runs so multi-statement PowerShell scripts keep their quotes instead of being re-parsed by a nested PowerShell process.
- Fixed managed AWS Windows WSL2 bootstrap by using the current Ubuntu WSL rootfs URL, downloading large rootfs files through `curl.exe`, and retrying empty or partial rootfs downloads instead of reusing a poisoned tarball. Thanks @vincentkoc.
- Fixed AWS Windows WSL2 mode overrides so they refresh the default instance type to a nested-virtualization-capable family. Thanks @steipete.
- Removed the static macOS managed-login path so static host VNC cannot be mistaken for a Crabbox-created external instance.
- Excluded macOS AppleDouble `._*` sidecar files from default sync manifests so native Windows archives do not transfer invalid TypeScript/package sidecars.
- Quoted `crabbox vnc` tunnel key paths so macOS `Application Support` lease keys can be pasted directly into a shell.

View File

@ -33,9 +33,10 @@ crabbox init [--force]
crabbox config show [--json]
crabbox config path
crabbox config set-broker --url <url> --token-stdin [--provider hetzner|aws]
crabbox warmup [--provider hetzner|aws|ssh|blacksmith-testbox] [--target linux|macos|windows] [--desktop] [--browser] [--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] [--target linux|macos|windows] [--windows-mode normal|wsl2] [--desktop] [--browser] [--tailscale] [--network auto|tailscale|public] [--shell] [--checksum] [--debug] [--force-sync-large] [--timing-json] [--blacksmith-workflow <workflow>] -- <command...>
crabbox warmup [--provider hetzner|aws|ssh|blacksmith-testbox] [--target linux|macos|windows] [--desktop] [--browser] [--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] [--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 code --id <lease-id-or-slug> [--open]
crabbox media preview --input <video> --output <preview.gif> [--trimmed-video-output <change.mp4>]
crabbox screenshot --id <lease-id-or-slug> [--output <path>]
crabbox sync-plan [--limit <n>]
@ -87,6 +88,7 @@ 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 code --id blue-lobster --open
crabbox desktop launch --id blue-lobster --browser --url https://example.com --webvnc --open
crabbox screenshot --id blue-lobster --output desktop.png
crabbox media preview --input desktop.mp4 --output desktop-preview.gif --trimmed-video-output desktop-change.mp4
@ -260,6 +262,7 @@ Flags:
--idle-timeout <duration> idle expiry, default 30m
--desktop provision or require visible desktop capability
--browser provision or require browser capability
--code provision or require web code capability
--tailscale join new managed Linux leases to the configured tailnet
--tailscale-tags <csv> Tailscale tags for new managed leases
--tailscale-hostname-template <template>

View File

@ -29,6 +29,7 @@ Command docs live here, one file per top-level command. Keep `docs/cli.md` as th
- [ssh](ssh.md)
- [vnc](vnc.md)
- [webvnc](webvnc.md)
- [code](code.md)
- [screenshot](screenshot.md)
- [inspect](inspect.md)
- [stop](stop.md)

97
docs/commands/code.md Normal file
View File

@ -0,0 +1,97 @@
# code
`crabbox code` bridges a code-server workspace for a Linux lease into the
authenticated coordinator portal.
```sh
crabbox warmup --code
crabbox code --id blue-lobster
crabbox code --id blue-lobster --open
```
## How It Works
Create or reuse a lease with `code=true`:
```sh
crabbox warmup --code
```
The Linux bootstrap installs `code-server` only for leases that request the
capability. `crabbox code` then resolves the lease, starts `code-server` on
runner loopback, opens an SSH tunnel, mints a short-lived bridge ticket, and
registers a local bridge with the coordinator.
The editor opens the synced workspace by default. If you run `crabbox code`
from a subdirectory inside the local checkout, Crabbox maps that relative path
onto the remote workspace and opens the matching folder. Actions-hydrated
leases use the hydration workspace instead of the default `/work/crabbox/...`
path.
The browser URL is lease-scoped:
```text
/portal/leases/<lease-id>/code/
```
The data path is:
```text
browser
<-> coordinator /portal/leases/<lease>/code/
<-> local crabbox code process
<-> SSH tunnel
<-> runner 127.0.0.1:8080
```
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.
Managed code-server starts with `Default Dark Modern` as the default theme. The
bridge also chunks large HTTP responses and websocket frames so VS Code assets
and extension-host traffic stay below coordinator websocket frame limits.
## Flags
```text
--id <lease-id-or-slug>
--provider hetzner|aws
--target linux
--network auto|tailscale|public
--local-port <port>
--open
--reclaim
```
## Limitations
- Coordinator-backed Linux leases are supported.
- Static SSH hosts, Windows, macOS, and Blacksmith Testbox are intentionally not
supported by this portal bridge yet.
- `code-server` auth is disabled on the runner side because the trusted access
boundary is the authenticated coordinator portal plus the local bridge.
## Troubleshooting
`lease ... was not created with code=true`
Warm a new lease with the code capability:
```sh
crabbox warmup --code
```
The portal shows a bridge command
The browser can reach the coordinator, but no local bridge is registered. Start
`crabbox code --id <lease>` locally and keep it running.
Check bridge health with:
```sh
curl https://crabbox.openclaw.ai/portal/leases/<lease>/code/health
```
When authenticated, the health response includes whether the code bridge agent
is currently connected.

View File

@ -46,7 +46,7 @@ metadata exists and SSH is reachable, `tailscale` fails if the tailnet path is
not available, and `public` forces the provider host. See
[Tailscale](../features/tailscale.md).
Sync uses `git ls-files --cached --others --exclude-standard` to build a file manifest, then feeds that manifest to rsync over SSH. That means tracked files plus nonignored untracked files sync, while `.git`, ignored local build output, dependency folders, and common caches stay out of the transfer. Crabbox records a local/remote sync fingerprint and skips rsync when the tracked commit plus manifest and dirty metadata have not changed. Use `--checksum` when you need a paranoid checksum scan, and `--debug` to print sync timing, progress, and itemized rsync output.
Sync uses `git ls-files --cached --others --exclude-standard` to build a file manifest, then feeds that manifest to rsync over SSH. That means tracked files plus nonignored untracked files sync, while `.git`, ignored local build output, dependency folders, `.crabboxignore` patterns, `sync.exclude` patterns, and common caches stay out of the transfer. Crabbox records a local/remote sync fingerprint and skips rsync when the tracked commit plus manifest and dirty metadata have not changed. Use `--checksum` when you need a paranoid checksum scan, and `--debug` to print sync timing, progress, and itemized rsync output.
For `provider=ssh`, `target=macos` and `target=windows windows.mode=wsl2`
use the same POSIX rsync flow. Native Windows mode uses PowerShell over OpenSSH
@ -63,7 +63,7 @@ At the end of every command, `run` prints a one-line summary with sync duration,
Use `--timing-json` to emit a final JSON timing record with provider, lease ID, sync phases, command duration, total duration, exit code, and Actions run URL when available. In `blacksmith-testbox` mode, sync is reported as delegated in the same schema.
Before the first rsync into a Git checkout, Crabbox tries to seed the remote worktree from the local `origin` remote so the first sync is a dirty-tree overlay instead of a full source upload. Project-specific excludes, env forwarding, and base ref belong in `crabbox.yaml` or `.crabbox.yaml`.
Before the first rsync into a Git checkout, Crabbox tries to seed the remote worktree from the local `origin` remote so the first sync is a dirty-tree overlay instead of a full source upload. Project-specific excludes can live in `.crabboxignore` or `sync.exclude` in `crabbox.yaml` / `.crabbox.yaml`; env forwarding and base ref belong in config.
After sync, Crabbox runs a remote sanity check. If the remote checkout reports at least 200 tracked deletions, Crabbox fails before running tests unless local `CRABBOX_ALLOW_MASS_DELETIONS=1` is set.
@ -92,6 +92,7 @@ Flags:
--idle-timeout <duration>
--desktop
--browser
--code
--tailscale
--tailscale-tags <comma-separated tags>
--tailscale-hostname-template <template>
@ -116,6 +117,10 @@ Flags:
`--idle-timeout` controls inactivity expiry, default `30m`. `--ttl` remains the maximum wall-clock lifetime, default `90m`.
Crabbox records a local repo claim for each reused lease. If a lease is already claimed by another repo, use `--reclaim` to move the claim intentionally.
`--code` provisions or requires a Linux lease with code-server installed. Use
`crabbox code --id <lease>` to expose the editor through the authenticated
portal.
For AWS one-shot leases, `--market` overrides `capacity.market` for this run.
Explicit `--type` keeps exact-type semantics; Crabbox reports why that type
failed rather than falling back to a different size.

View File

@ -7,14 +7,17 @@ crabbox sync-plan
crabbox sync-plan --limit 10
```
It uses the same Git file-list manifest and excludes as `crabbox run`, then prints:
It uses the same Git file-list manifest, `.crabboxignore`, and config excludes
as `crabbox run`, then prints:
- candidate file count and total bytes;
- tracked deletes that would be applied remotely;
- largest files;
- largest first or second-level directories.
Use it before a cold sync when the preflight estimate looks too large.
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.
Related docs:

View File

@ -73,6 +73,7 @@ Flags:
--idle-timeout <duration>
--desktop
--browser
--code
--tailscale
--tailscale-tags <comma-separated tags>
--tailscale-hostname-template <template>
@ -100,6 +101,10 @@ Chromium package fallback.
automation and operator takeover. It does not imply a browser. Use
`--desktop --browser` when a headed browser should run in the visible display.
`--code` provisions `code-server` for Linux leases and enables
`crabbox code --id <lease>` to bridge the workspace through the authenticated
portal at `/portal/leases/<lease>/code/`.
`--tailscale` joins newly created managed Linux leases to the configured
tailnet. `--network` controls the SSH endpoint printed after readiness:
`auto` prefers the tailnet when reachable, `tailscale` requires it, and

View File

@ -3,6 +3,9 @@
`crabbox webvnc` bridges a desktop lease into the authenticated coordinator
portal.
Use it when you want the same VNC desktop that `crabbox vnc` opens, but inside
a browser tab instead of a native VNC client.
```sh
crabbox warmup --desktop
crabbox webvnc --id blue-lobster
@ -11,6 +14,8 @@ crabbox webvnc --id blue-lobster --open
crabbox webvnc --id blue-lobster --daemon --open
```
## How It Works
The command resolves the lease like `crabbox vnc`, verifies that the lease has
`desktop=true`, starts the normal SSH tunnel to the runner's loopback VNC
service, mints a short-lived bridge ticket over the authenticated coordinator
@ -18,6 +23,23 @@ API, and opens a websocket bridge to the coordinator with that ticket. The
browser connects to `/portal/leases/<lease>/vnc` after GitHub portal auth, and
the Durable Object pairs that browser websocket with the local bridge process.
The data path is:
```text
browser noVNC
<-> coordinator portal websocket
<-> local crabbox webvnc process
<-> SSH tunnel
<-> runner 127.0.0.1:5900
```
That means the local `crabbox webvnc` process is not just a launcher. It is the
live bridge between the browser and the SSH-tunneled VNC socket. Keep it
running while the browser tab is open. If the browser tab reloads or drops, the
command re-registers a fresh bridge so the portal retry can reconnect.
## Security Boundary
This keeps the security boundary the same as `crabbox vnc`:
- VNC stays bound to runner loopback.
@ -36,6 +58,8 @@ again, and `--stop` to kill the background bridge for that lease.
`--network tailscale` changes only the SSH endpoint used for the local tunnel.
The runner VNC service stays bound to loopback.
## Portal And Passwords
`--open` opens the portal page after the bridge starts. If the VNC password is
available, the command also places it in the URL fragment for the local browser
tab. URL fragments are not sent to the coordinator, and Crabbox preserves
@ -44,6 +68,17 @@ 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:
```sh
crabbox webvnc --id <lease-id-or-slug>
```
in a terminal and leave it running.
## Flags
Flags:
```text
@ -65,6 +100,8 @@ Flags:
--reclaim
```
## Limitations
Limitations:
- Coordinator-backed Hetzner and AWS desktop leases are supported.
@ -72,6 +109,38 @@ Limitations:
prove that host-managed VNC credentials and prompts are safe to expose.
- Blacksmith Testbox still owns its own machine connectivity.
## Troubleshooting
`webvnc requires a configured coordinator login`
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 is not available for static SSH hosts or Blacksmith Testbox. Use
`crabbox vnc` for static hosts when you explicitly trust the host-managed VNC
service.
`target does not expose VNC on 127.0.0.1:5900`
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 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.
VNC authentication fails
Use the password printed by `crabbox webvnc`. With `--open`, the command tries
to pass the password in the browser URL fragment, but a portal login redirect
can lose that fragment before noVNC sees it.
Related docs:
- [Interactive desktop and VNC](../features/interactive-desktop-vnc.md)

View File

@ -87,6 +87,12 @@ Use `crabbox webvnc` for the authenticated coordinator portal:
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.
Use `crabbox screenshot` when you need a PNG without taking over the session:
```sh
@ -116,6 +122,10 @@ Managed VNC is tunnel-first:
- `--network tailscale` changes only the SSH endpoint used by that tunnel.
- WebVNC keeps the same local SSH tunnel and adds an authenticated browser
websocket through the coordinator.
- The WebVNC browser websocket is paired with the local bridge process inside
the coordinator Durable Object; if the browser view disconnects, the local
command reconnects a fresh bridge for the portal retry. If the local process
exits, the browser view disconnects until you start it again.
Crabbox 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

View File

@ -11,7 +11,8 @@ It syncs the Git-managed working set, not the whole directory tree:
- tracked files from `git ls-files --cached`;
- nonignored untracked files from `git ls-files --others --exclude-standard`;
- repo-local `sync.exclude` patterns and Crabbox's default cache/build excludes.
- root `.crabboxignore` patterns, repo-local `sync.exclude` patterns, and
Crabbox's default cache/build excludes.
Ignored build output, dependency folders, `.git`, and common local caches stay out of the transfer. This keeps first syncs close to the code that CI would see while still letting agents test uncommitted edits.
@ -67,6 +68,12 @@ Use `crabbox sync-plan` to inspect the local manifest before leasing a box. It p
Repo-local config should hold project-specific excludes and env allowlists. Secrets must not be passed as command-line arguments or broad env globs.
Use `.crabboxignore` when you only need repo-local sync exclusions. The file is
read from the repository root. Blank lines and lines starting with `#` are
ignored; remaining lines are appended to `sync.exclude` and use the same matcher
as config excludes. Crabbox intentionally supports only `.crabboxignore`; there
is no short alias.
Related docs:
- [CLI](../cli.md)

View File

@ -44,17 +44,19 @@ This page maps user-facing behavior back to implementation files. Keep docs desc
- CLI cloud-init bootstrap: `internal/cli/bootstrap.go`
- Worker cloud-init bootstrap: `worker/src/bootstrap.ts`
- Tailscale feature contract: `docs/features/tailscale.md`
- Desktop/browser capability flags, env injection, and VNC checks: `internal/cli/capabilities.go`, `internal/cli/run.go`
- Desktop/browser/code capability flags, env injection, and VNC checks: `internal/cli/capabilities.go`, `internal/cli/run.go`
- Desktop app launch into visible sessions: `internal/cli/desktop.go`
- 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`
- 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`
Bootstrap is intentionally tiny unless optional lease capabilities are requested:
OpenSSH, CA certificates, curl, Git, rsync, jq, `/work/crabbox`, cache
directories, and `crabbox-ready`. `--desktop` adds Xvfb/Openbox/x11vnc and
loopback VNC. `--browser` adds Chrome stable or a Chromium fallback. Project
loopback VNC. `--browser` adds Chrome stable or a Chromium fallback. `--code`
adds code-server for authenticated portal editor access. Project
runtimes such as Go, Node, pnpm, Docker, databases, and services are
repository-owned setup, usually through Actions hydration or repo scripts.

View File

@ -87,6 +87,8 @@ func (a App) directCommandHelp(ctx context.Context, args []string) (error, bool)
return a.vnc(ctx, helpArgs), true
case "webvnc":
return a.webvnc(ctx, helpArgs), true
case "code":
return a.webCode(ctx, helpArgs), true
case "screenshot":
return a.screenshot(ctx, helpArgs), true
case "inspect":
@ -122,8 +124,8 @@ Start Here:
Lease a reusable box and print a cbx_... id plus friendly slug.
crabbox run --id blue-lobster -- pnpm test:changed
Sync this checkout to the box and run a command.
crabbox warmup --desktop --browser
Lease a UI-capable box with a browser.
crabbox warmup --desktop --browser --code
Lease a UI-capable box with a browser and web code editor.
Commands:
init Onboard the current repo for Crabbox
@ -151,6 +153,7 @@ Commands:
ssh Print the SSH command for a lease
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
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
@ -167,6 +170,7 @@ Common Flows:
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 webvnc --id blue-lobster --open
crabbox code --id blue-lobster --open
crabbox screenshot --id blue-lobster --output desktop.png
crabbox inspect --id blue-lobster --json
crabbox history --lease cbx_abcdef123456
@ -208,6 +212,7 @@ Environment:
CRABBOX_WINDOWS_MODE normal or wsl2
CRABBOX_DESKTOP Provision or require desktop/VNC capability
CRABBOX_BROWSER Provision or require browser capability
CRABBOX_CODE Provision or require web code capability
CRABBOX_STATIC_HOST Static SSH host for provider=ssh
CRABBOX_OWNER Usage owner override
CRABBOX_ORG Usage org override

View File

@ -438,6 +438,10 @@ func cloudInitOptionalReadyChecks(cfg Config) string {
b.WriteString(" test -x \"$BROWSER\"\n")
b.WriteString(" \"$BROWSER\" --version >/dev/null\n")
}
if cfg.Code {
b.WriteString(" test -x /usr/local/bin/code-server\n")
b.WriteString(" /usr/local/bin/code-server --version >/dev/null\n")
}
return strings.TrimRight(b.String(), "\n")
}
@ -572,6 +576,11 @@ func cloudInitOptionalBootstrap(cfg Config) string {
chown crabbox:crabbox /var/lib/crabbox/browser.env
chmod 0644 /var/lib/crabbox/browser.env
fi`)
}
if cfg.Code {
parts = append(parts, ` retry apt-get install -y --no-install-recommends libatomic1
retry env HOME=/root sh -c 'curl -fsSL https://code-server.dev/install.sh | sh -s -- --method=standalone --prefix=/usr/local'
/usr/local/bin/code-server --version >/dev/null`)
}
return strings.Join(parts, "\n")
}

View File

@ -115,6 +115,26 @@ func TestCloudInitBrowserProfile(t *testing.T) {
}
}
func TestCloudInitCodeProfile(t *testing.T) {
cfg := baseConfig()
cfg.Code = true
got := cloudInit(cfg, "ssh-ed25519 test")
for _, want := range []string{
"https://code-server.dev/install.sh",
"env HOME=/root",
"--method=standalone --prefix=/usr/local",
"/usr/local/bin/code-server --version >/dev/null",
"test -x /usr/local/bin/code-server",
} {
if !strings.Contains(got, want) {
t.Fatalf("cloudInit(code) missing %q", want)
}
}
if strings.Contains(cloudInit(baseConfig(), "ssh-ed25519 test"), "code-server") {
t.Fatal("cloudInit should not install code-server by default")
}
}
func TestCloudInitTailscaleProfile(t *testing.T) {
cfg := baseConfig()
cfg.SSHUser = "runner"

View File

@ -11,6 +11,8 @@ import (
const (
desktopDisplay = ":99"
managedVNCPort = "5900"
managedCodePort = "8080"
codeServerBinary = "/usr/local/bin/code-server"
vncPasswordPath = "/var/lib/crabbox/vnc.password"
windowsVNCPasswordPath = `C:\ProgramData\crabbox\vnc.password`
macOSVNCPasswordPath = "/var/db/crabbox/vnc.password"
@ -24,9 +26,10 @@ type vncEndpoint struct {
Managed bool
}
func applyCapabilityFlags(cfg *Config, desktop, browser bool) {
func applyCapabilityFlags(cfg *Config, desktop, browser, code bool) {
cfg.Desktop = desktop
cfg.Browser = browser
cfg.Code = code
}
func validateRequestedCapabilities(cfg Config) error {
@ -36,6 +39,12 @@ func validateRequestedCapabilities(cfg Config) error {
if cfg.Browser && isBlacksmithProvider(cfg.Provider) {
return exit(2, "browser provisioning is not supported for provider=%s; use Blacksmith workflow setup for headless browser automation", cfg.Provider)
}
if cfg.Code && isBlacksmithProvider(cfg.Provider) {
return exit(2, "web code is not supported for provider=%s; Blacksmith owns machine connectivity", cfg.Provider)
}
if cfg.Code && cfg.TargetOS != targetLinux {
return exit(2, "web code currently supports managed Linux leases only")
}
return nil
}
@ -49,6 +58,9 @@ func enforceManagedLeaseCapabilities(cfg Config, server Server, leaseID string)
if cfg.Browser && !labelBool(server.Labels["browser"]) {
return exit(2, "lease %s was not created with browser=true; warm a new lease with --browser", leaseID)
}
if cfg.Code && !labelBool(server.Labels["code"]) {
return exit(2, "lease %s was not created with code=true; warm a new lease with --code", leaseID)
}
return nil
}

View File

@ -37,6 +37,7 @@ type crabboxKongCLI struct {
Ssh sshKongCmd `cmd:"" name:"ssh" passthrough:"" help:"Print the SSH command for a lease."`
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."`
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."`
@ -171,6 +172,9 @@ type vncKongCmd struct {
type webvncKongCmd struct {
Args []string `arg:"" optional:""`
}
type codeKongCmd struct {
Args []string `arg:"" optional:""`
}
type screenshotKongCmd struct {
Args []string `arg:"" optional:""`
}
@ -309,6 +313,7 @@ func (c *usageKongCmd) Run(ctx context.Context, app App) error { return app.u
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 *screenshotKongCmd) Run(ctx context.Context, app App) error {
return app.screenshot(ctx, c.Args)
}

730
internal/cli/code.go Normal file
View File

@ -0,0 +1,730 @@
package cli
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"nhooyr.io/websocket"
)
type coordinatorCodeTicket struct {
Ticket string `json:"ticket"`
LeaseID string `json:"leaseID"`
ExpiresAt string `json:"expiresAt"`
}
type codeProxyMessage struct {
Type string `json:"type"`
ID string `json:"id"`
Method string `json:"method,omitempty"`
Path string `json:"path,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
Status int `json:"status,omitempty"`
Body string `json:"body,omitempty"`
Error string `json:"error,omitempty"`
Code int `json:"code,omitempty"`
Reason string `json:"reason,omitempty"`
Frame string `json:"frame,omitempty"`
ChunkID string `json:"chunkID,omitempty"`
}
const (
maxCodeBridgeBodyChunkBytes = 15 * 1024
maxCodeBridgeReadBytes = 64 * 1024 * 1024
maxPendingCodeBridgeWebSocketMessages = 32
codeBridgeBodyChunkDelay = 5 * time.Millisecond
)
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")
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")
openPortal := fs.Bool("open", false, "open the web portal code page")
networkFlags := registerNetworkModeFlag(fs, defaults)
targetFlags := registerTargetFlags(fs, defaults)
if err := parseFlags(fs, args); err != nil {
return err
}
if *id == "" && fs.NArg() > 0 {
*id = fs.Arg(0)
}
if *id == "" {
return exit(2, "usage: crabbox code --id <lease-id-or-slug>")
}
cfg, err := loadConfig()
if err != nil {
return err
}
cfg.Provider = *provider
cfg.Code = true
if err := applyNetworkModeFlagOverride(&cfg, fs, networkFlags); err != nil {
return err
}
if err := applyTargetFlagOverrides(&cfg, fs, targetFlags); err != nil {
return err
}
if err := validateRequestedCapabilities(cfg); err != nil {
return err
}
if isBlacksmithProvider(cfg.Provider) || isStaticProvider(cfg.Provider) {
return exit(2, "code currently supports coordinator-backed hetzner/aws Linux leases")
}
coord, useCoordinator, err := newTargetCoordinatorClient(cfg)
if err != nil {
return err
}
if !useCoordinator || coord == nil || coord.Token == "" {
return exit(2, "code requires a configured coordinator login; run crabbox login first")
}
server, target, leaseID, err := a.resolveLeaseTarget(ctx, cfg, *id)
if err != nil {
return err
}
if resolved, err := resolveNetworkTarget(ctx, cfg, server, target); err != nil {
return err
} else {
target = resolved.Target
}
if err := enforceManagedLeaseCapabilities(cfg, server, leaseID); err != nil {
return err
}
repo, err := findRepo()
if err != nil {
return err
}
if err := claimLeaseForRepoConfig(leaseID, serverSlug(server), cfg, repo.Root, cfg.IdleTimeout, *reclaim); err != nil {
return err
}
a.touchActiveLeaseBestEffort(ctx, cfg, server, leaseID)
workspace, folder, hydratedByActions := codeWorkspace(ctx, target, cfg, leaseID, repo)
if hydratedByActions {
fmt.Fprintf(a.Stderr, "using GitHub Actions workspace %s\n", workspace)
}
if folder != workspace {
fmt.Fprintf(a.Stderr, "opening remote folder %s\n", folder)
}
if err := ensureRemoteCodeServer(ctx, target, workspace); err != nil {
return err
}
if *localPort == "" {
*localPort = availableLocalCodePort()
}
tunnel, err := startVNCForegroundTunnel(ctx, target, *localPort, "127.0.0.1", managedCodePort)
if err != nil {
return err
}
defer stopProcess(tunnel)
portal := webCodePortalURL(coord.BaseURL, leaseID, folder)
opened := false
for {
bridge, err := connectCodeBridge(ctx, coord, leaseID, "127.0.0.1", *localPort)
if err != nil {
return err
}
fmt.Fprintln(a.Stdout, "bridge: connected; keep this process running while using Code")
fmt.Fprintf(a.Stdout, "code: %s\n", portal)
if *openPortal && !opened {
if err := openLocalURL(portal); err != nil {
bridge.Close(websocket.StatusNormalClosure, "bridge stopped")
return err
}
opened = true
fmt.Fprintf(a.Stdout, "opened: %s\n", portal)
}
err = bridge.Serve(ctx)
if ctx.Err() != nil {
return context.Cause(ctx)
}
if !isRetryableCodeBridgeError(err) {
return err
}
fmt.Fprintln(a.Stdout, "bridge: disconnected; reconnecting")
time.Sleep(300 * time.Millisecond)
}
}
func ensureRemoteCodeServer(ctx context.Context, target SSHTarget, workdir string) error {
if err := runSSHQuiet(ctx, target, startCodeServerCommand(workdir)); err != nil {
return exit(5, "start code-server: %v", err)
}
deadline := time.Now().Add(20 * time.Second)
for time.Now().Before(deadline) {
if ctx.Err() != nil {
return context.Cause(ctx)
}
if err := runSSHQuiet(ctx, target, codeServerReadyCommand()); err == nil {
return nil
}
time.Sleep(500 * time.Millisecond)
}
return exit(5, "timed out waiting for code-server on 127.0.0.1:%s", managedCodePort)
}
func codeWorkspace(ctx context.Context, target SSHTarget, cfg Config, leaseID string, repo Repo) (string, string, bool) {
workspace := remoteJoin(cfg, leaseID, repo.Name)
hydrated := false
if state, err := readActionsHydrationState(ctx, target, leaseID); err == nil && state.Workspace != "" {
workspace = state.Workspace
hydrated = true
}
return workspace, mappedRemoteCodeFolder(workspace, repo), hydrated
}
func mappedRemoteCodeFolder(workspace string, repo Repo) string {
wd, err := os.Getwd()
if err != nil || repo.Root == "" {
return workspace
}
rel, err := filepath.Rel(repo.Root, wd)
if err != nil || rel == "." {
return workspace
}
rel = filepath.ToSlash(rel)
if rel == ".." || strings.HasPrefix(rel, "../") {
return workspace
}
return path.Join(workspace, rel)
}
func codeServerReadyCommand() string {
return "curl -fsS http://127.0.0.1:" + managedCodePort + "/healthz >/dev/null || curl -fsS http://127.0.0.1:" + managedCodePort + "/ >/dev/null"
}
func startCodeServerCommand(workdir string) string {
pidfile := "/tmp/crabbox-code-server.pid"
return strings.Join([]string{
"mkdir -p " + shellQuote(workdir),
"pidfile=" + shellQuote(pidfile) + "; if [ -s \"$pidfile\" ]; then oldpid=$(cat \"$pidfile\" 2>/dev/null || true); if [ -n \"$oldpid\" ] && kill -0 \"$oldpid\" 2>/dev/null; then kill \"$oldpid\" 2>/dev/null || true; for i in 1 2 3 4 5 6 7 8 9 10; do kill -0 \"$oldpid\" 2>/dev/null || break; sleep 0.2; done; if kill -0 \"$oldpid\" 2>/dev/null; then kill -9 \"$oldpid\" 2>/dev/null || true; fi; fi; fi",
codeServerSettingsCommand(),
"(nohup env VSCODE_PROXY_URI='./proxy/{{port}}' " + codeServerBinary +
" --auth none --bind-addr 127.0.0.1:" + managedCodePort +
" --disable-telemetry --disable-update-check " + shellQuote(workdir) +
" >/tmp/crabbox-code-server.log 2>&1 & echo $! >" + shellQuote(pidfile) + ")",
}, " && ")
}
func codeServerSettingsCommand() string {
return `settings="$HOME/.local/share/code-server/User/settings.json"; mkdir -p "$(dirname "$settings")"; tmp=$(mktemp); if [ -s "$settings" ] && command -v jq >/dev/null 2>&1 && jq '. + {"workbench.colorTheme":"Default Dark Modern"}' "$settings" > "$tmp"; then mv "$tmp" "$settings"; else printf '%s\n' '{"workbench.colorTheme":"Default Dark Modern"}' > "$settings"; rm -f "$tmp"; fi`
}
type codeBridge struct {
ws *websocket.Conn
baseURL string
client *http.Client
debug bool
mu sync.Mutex
writeMu sync.Mutex
upstream map[string]*websocket.Conn
pending map[string][]codeProxyMessage
incomingFrames map[string]codePendingWebSocketFrame
chunkSeq atomic.Uint64
}
type codePendingWebSocketFrame struct {
id string
frame string
chunks []string
}
func connectCodeBridge(ctx context.Context, coord *CoordinatorClient, leaseID, host, port string) (*codeBridge, error) {
ticket, err := coord.CreateCodeTicket(ctx, leaseID)
if err != nil {
return nil, err
}
ws, _, err := websocket.Dial(ctx, webCodeAgentURL(coord.BaseURL, leaseID, ticket.Ticket), &websocket.DialOptions{
HTTPHeader: coord.webVNCAccessHeaders(),
})
if err != nil {
return nil, err
}
ws.SetReadLimit(maxCodeBridgeReadBytes)
return &codeBridge{
ws: ws,
baseURL: "http://" + host + ":" + port,
client: &http.Client{
Timeout: 30 * time.Second,
},
debug: os.Getenv("CRABBOX_CODE_DEBUG") == "1",
upstream: map[string]*websocket.Conn{},
pending: map[string][]codeProxyMessage{},
incomingFrames: map[string]codePendingWebSocketFrame{},
}, nil
}
func (b *codeBridge) Serve(ctx context.Context) error {
defer b.Close(websocket.StatusNormalClosure, "bridge stopped")
for {
_, data, err := b.ws.Read(ctx)
if err != nil {
b.trace("agent_read_error error=%v", err)
return err
}
var msg codeProxyMessage
if err := json.Unmarshal(data, &msg); err != nil {
continue
}
switch msg.Type {
case "http":
go b.handleHTTP(ctx, msg)
case "ws_open":
go b.openUpstreamWebSocket(ctx, msg)
case "ws_data":
b.writeUpstreamWebSocket(ctx, msg)
case "ws_start":
b.startIncomingWebSocketFrame(msg)
case "ws_body":
b.appendIncomingWebSocketFrame(msg)
case "ws_end":
b.finishIncomingWebSocketFrame(ctx, msg)
case "ws_close":
b.closeUpstreamWebSocket(msg.ID, websocket.StatusCode(msg.Code), msg.Reason)
}
}
}
func (b *codeBridge) Close(code websocket.StatusCode, reason string) {
if b == nil {
return
}
if b.ws != nil {
_ = b.ws.Close(code, reason)
}
b.mu.Lock()
defer b.mu.Unlock()
for id, conn := range b.upstream {
_ = conn.Close(websocket.StatusNormalClosure, "bridge stopped")
delete(b.upstream, id)
}
for id := range b.pending {
delete(b.pending, id)
}
for id := range b.incomingFrames {
delete(b.incomingFrames, id)
}
}
func (b *codeBridge) handleHTTP(ctx context.Context, msg codeProxyMessage) {
body, _ := base64.StdEncoding.DecodeString(msg.Body)
upstreamPath := codeUpstreamPath(msg.Path)
upstream := b.baseURL + upstreamPath
req, err := http.NewRequestWithContext(ctx, msg.Method, upstream, bytes.NewReader(body))
if err != nil {
_ = b.writeJSON(ctx, codeProxyMessage{Type: "http", ID: msg.ID, Status: 502, Error: err.Error()})
return
}
for key, value := range msg.Headers {
req.Header.Set(key, value)
}
resp, err := b.client.Do(req)
if err != nil {
_ = b.writeJSON(ctx, codeProxyMessage{Type: "http", ID: msg.ID, Status: 502, Error: err.Error()})
return
}
defer resp.Body.Close()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 25*1024*1024))
if err != nil {
_ = b.writeJSON(ctx, codeProxyMessage{Type: "http", ID: msg.ID, Status: 502, Error: err.Error()})
return
}
if fallbackBody, fallbackHeaders, ok := codeServerStaticFallback(upstreamPath, resp.StatusCode); ok {
respBody = fallbackBody
resp.Header = fallbackHeaders
resp.StatusCode = http.StatusOK
}
if isCodeHTML(resp.Header.Get("content-type")) {
respBody = rewriteCodeHTML(respBody)
}
headers := map[string]string{}
for key, values := range resp.Header {
if len(values) > 0 {
headers[key] = values[0]
}
}
message := codeProxyMessage{
Type: "http",
ID: msg.ID,
Status: resp.StatusCode,
Headers: headers,
}
if len(respBody) <= maxCodeBridgeBodyChunkBytes {
message.Body = base64.StdEncoding.EncodeToString(respBody)
_ = b.writeJSON(ctx, message)
return
}
message.Type = "http_start"
if err := b.writeJSON(ctx, message); err != nil {
return
}
for len(respBody) > 0 {
n := min(len(respBody), maxCodeBridgeBodyChunkBytes)
if err := b.writeJSON(ctx, codeProxyMessage{
Type: "http_body",
ID: msg.ID,
Body: base64.StdEncoding.EncodeToString(respBody[:n]),
}); err != nil {
return
}
respBody = respBody[n:]
if len(respBody) > 0 {
select {
case <-ctx.Done():
return
case <-time.After(codeBridgeBodyChunkDelay):
}
}
}
_ = b.writeJSON(ctx, codeProxyMessage{Type: "http_end", ID: msg.ID})
}
func (b *codeBridge) openUpstreamWebSocket(ctx context.Context, msg codeProxyMessage) {
upstream := "ws" + strings.TrimPrefix(b.baseURL, "http") + codeUpstreamPath(msg.Path)
b.trace("ws_open id=%s path=%s upstream=%s", msg.ID, msg.Path, upstream)
header, subprotocols := codeWebSocketDialHeaders(b.baseURL, msg.Headers)
b.trace("ws_open_headers id=%s cookie=%t origin=%q subprotocols=%d", msg.ID, header.Get("Cookie") != "", header.Get("Origin"), len(subprotocols))
conn, _, err := websocket.Dial(ctx, upstream, &websocket.DialOptions{
HTTPHeader: header,
Subprotocols: subprotocols,
})
if err != nil {
b.trace("ws_open_error id=%s error=%v", msg.ID, err)
_ = b.writeJSON(ctx, codeProxyMessage{Type: "ws_close", ID: msg.ID, Code: int(websocket.StatusInternalError), Reason: err.Error()})
return
}
conn.SetReadLimit(maxCodeBridgeReadBytes)
b.mu.Lock()
b.upstream[msg.ID] = conn
pending := append([]codeProxyMessage(nil), b.pending[msg.ID]...)
delete(b.pending, msg.ID)
b.mu.Unlock()
b.trace("ws_open_ok id=%s subprotocols=%d pending=%d", msg.ID, len(subprotocols), len(pending))
for _, pendingMessage := range pending {
if err := b.writeUpstreamFrame(ctx, conn, pendingMessage); err != nil {
b.trace("ws_pending_write_error id=%s error=%v", msg.ID, err)
b.closeUpstreamWebSocket(msg.ID, websocket.StatusInternalError, err.Error())
_ = b.writeJSON(ctx, codeProxyMessage{Type: "ws_close", ID: msg.ID, Code: int(websocket.StatusInternalError), Reason: err.Error()})
return
}
}
go b.readUpstreamWebSocket(ctx, msg.ID, conn)
}
func (b *codeBridge) readUpstreamWebSocket(ctx context.Context, id string, conn *websocket.Conn) {
for {
typ, data, err := conn.Read(ctx)
if err != nil {
reason := err.Error()
var closeErr websocket.CloseError
code := int(websocket.StatusNormalClosure)
if errors.As(err, &closeErr) {
code = int(closeErr.Code)
reason = closeErr.Reason
}
b.trace("ws_upstream_close id=%s code=%d reason=%s", id, code, reason)
_ = b.writeJSON(ctx, codeProxyMessage{Type: "ws_close", ID: id, Code: code, Reason: reason})
b.closeUpstreamWebSocket(id, websocket.StatusNormalClosure, "closed")
return
}
b.trace("ws_upstream_data id=%s frame=%s bytes=%d", id, codeFrameType(typ), len(data))
_ = b.writeWebSocketData(ctx, id, typ, data)
}
}
func (b *codeBridge) writeWebSocketData(ctx context.Context, id string, typ websocket.MessageType, data []byte) error {
frame := codeFrameType(typ)
if len(data) <= maxCodeBridgeBodyChunkBytes {
return b.writeJSON(ctx, codeProxyMessage{Type: "ws_data", ID: id, Frame: frame, Body: base64.StdEncoding.EncodeToString(data)})
}
chunkID := fmt.Sprintf("%s-%d", id, b.chunkSeq.Add(1))
if err := b.writeJSON(ctx, codeProxyMessage{Type: "ws_start", ID: id, Frame: frame, ChunkID: chunkID}); err != nil {
return err
}
for len(data) > 0 {
n := min(len(data), maxCodeBridgeBodyChunkBytes)
if err := b.writeJSON(ctx, codeProxyMessage{Type: "ws_body", ID: id, ChunkID: chunkID, Body: base64.StdEncoding.EncodeToString(data[:n])}); err != nil {
return err
}
data = data[n:]
if len(data) > 0 {
select {
case <-ctx.Done():
return context.Cause(ctx)
case <-time.After(codeBridgeBodyChunkDelay):
}
}
}
return b.writeJSON(ctx, codeProxyMessage{Type: "ws_end", ID: id, ChunkID: chunkID})
}
func (b *codeBridge) writeUpstreamWebSocket(ctx context.Context, msg codeProxyMessage) {
b.mu.Lock()
conn := b.upstream[msg.ID]
if conn == nil {
pending := b.pending[msg.ID]
if len(pending) >= maxPendingCodeBridgeWebSocketMessages {
b.mu.Unlock()
b.trace("ws_downstream_drop id=%s frame=%s pending=%d", msg.ID, msg.Frame, len(pending))
_ = b.writeJSON(ctx, codeProxyMessage{Type: "ws_close", ID: msg.ID, Code: int(websocket.StatusPolicyViolation), Reason: "too many pending websocket messages"})
return
}
b.pending[msg.ID] = append(pending, msg)
b.mu.Unlock()
b.trace("ws_downstream_buffered id=%s frame=%s pending=%d", msg.ID, msg.Frame, len(pending)+1)
return
}
b.mu.Unlock()
if err := b.writeUpstreamFrame(ctx, conn, msg); err != nil {
b.trace("ws_downstream_write_error id=%s error=%v", msg.ID, err)
b.closeUpstreamWebSocket(msg.ID, websocket.StatusInternalError, err.Error())
_ = b.writeJSON(ctx, codeProxyMessage{Type: "ws_close", ID: msg.ID, Code: int(websocket.StatusInternalError), Reason: err.Error()})
}
}
func (b *codeBridge) writeUpstreamFrame(ctx context.Context, conn *websocket.Conn, msg codeProxyMessage) error {
data, err := base64.StdEncoding.DecodeString(msg.Body)
if err != nil {
return err
}
frameType := websocketMessageType(msg.Frame)
b.trace("ws_downstream_data id=%s frame=%s bytes=%d", msg.ID, codeFrameType(frameType), len(data))
return conn.Write(ctx, frameType, data)
}
func (b *codeBridge) startIncomingWebSocketFrame(msg codeProxyMessage) {
if msg.ChunkID == "" {
return
}
b.mu.Lock()
b.incomingFrames[msg.ChunkID] = codePendingWebSocketFrame{id: msg.ID, frame: msg.Frame}
b.mu.Unlock()
}
func (b *codeBridge) appendIncomingWebSocketFrame(msg codeProxyMessage) {
if msg.ChunkID == "" {
return
}
b.mu.Lock()
frame, ok := b.incomingFrames[msg.ChunkID]
if ok {
frame.chunks = append(frame.chunks, msg.Body)
b.incomingFrames[msg.ChunkID] = frame
}
b.mu.Unlock()
}
func (b *codeBridge) finishIncomingWebSocketFrame(ctx context.Context, msg codeProxyMessage) {
if msg.ChunkID == "" {
return
}
b.mu.Lock()
frame, ok := b.incomingFrames[msg.ChunkID]
delete(b.incomingFrames, msg.ChunkID)
b.mu.Unlock()
if !ok {
return
}
b.writeUpstreamWebSocket(ctx, codeProxyMessage{
Type: "ws_data",
ID: frame.id,
Frame: frame.frame,
Body: strings.Join(frame.chunks, ""),
})
}
func (b *codeBridge) closeUpstreamWebSocket(id string, code websocket.StatusCode, reason string) {
if code == 0 {
code = websocket.StatusNormalClosure
}
b.mu.Lock()
conn := b.upstream[id]
delete(b.upstream, id)
delete(b.pending, id)
b.mu.Unlock()
if conn != nil {
_ = conn.Close(code, reason)
}
}
func (b *codeBridge) writeJSON(ctx context.Context, msg codeProxyMessage) 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 *codeBridge) trace(format string, args ...any) {
if !b.debug {
return
}
fmt.Fprintf(os.Stderr, "code-debug: "+format+"\n", args...)
}
func isRetryableCodeBridgeError(err error) bool {
if err == nil {
return false
}
var closeErr websocket.CloseError
if errors.As(err, &closeErr) {
return closeErr.Code == websocket.StatusInternalError || closeErr.Code == websocket.StatusServiceRestart
}
text := err.Error()
return strings.Contains(text, "failed to read frame header: EOF") ||
strings.Contains(text, "tls: bad record MAC")
}
func codeUpstreamPath(path string) string {
u, err := url.Parse(path)
if err != nil {
return "/"
}
parts := strings.Split(strings.TrimPrefix(u.Path, "/"), "/")
if len(parts) >= 4 && parts[0] == "portal" && parts[1] == "leases" && parts[3] == "code" {
tail := strings.Join(parts[4:], "/")
if tail == "" {
u.Path = "/"
} else {
u.Path = "/" + tail
}
return u.RequestURI()
}
return u.RequestURI()
}
func isCodeHTML(contentType string) bool {
return strings.HasPrefix(strings.ToLower(contentType), "text/html")
}
func rewriteCodeHTML(body []byte) []byte {
return bytes.ReplaceAll(body, []byte(`<script type="module" src=""></script>`), nil)
}
func codeServerStaticFallback(path string, status int) ([]byte, http.Header, bool) {
if status != http.StatusNotFound {
return nil, nil, false
}
headers := http.Header{}
switch {
case strings.HasSuffix(path, "/node_modules/vsda/rust/web/vsda.js"):
headers.Set("content-type", "text/javascript")
headers.Set("cache-control", "no-store")
return []byte(`define([],()=>globalThis.vsda_web={default:async()=>{},sign:v=>v,validator:class{createNewMessage(v){return v}validate(){return "ok"}free(){}}});`), headers, true
case strings.HasSuffix(path, "/node_modules/vsda/rust/web/vsda_bg.wasm"):
headers.Set("content-type", "application/wasm")
headers.Set("cache-control", "no-store")
return []byte{0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00}, headers, true
default:
return nil, nil, false
}
}
func codeFrameType(typ websocket.MessageType) string {
if typ == websocket.MessageText {
return "text"
}
return "binary"
}
func websocketMessageType(frame string) websocket.MessageType {
if frame == "text" {
return websocket.MessageText
}
return websocket.MessageBinary
}
func codeWebSocketDialHeaders(baseURL string, values map[string]string) (http.Header, []string) {
headers := http.Header{}
for key, value := range values {
headers.Set(key, value)
}
subprotocols := websocketSubprotocols(headers)
headers.Del("Sec-WebSocket-Protocol")
if headers.Get("Origin") != "" {
headers.Set("Origin", baseURL)
}
return headers, subprotocols
}
func websocketSubprotocols(headers http.Header) []string {
var out []string
for _, value := range headers.Values("Sec-WebSocket-Protocol") {
for _, part := range strings.Split(value, ",") {
protocol := strings.TrimSpace(part)
if protocol != "" {
out = append(out, protocol)
}
}
}
return out
}
func availableLocalCodePort() string {
for port := 8081; port <= 8180; port++ {
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
if err != nil {
continue
}
_ = ln.Close()
return fmt.Sprint(port)
}
return "8081"
}
func webCodeAgentURL(base, leaseID, 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) + "/code/agent"
values := url.Values{}
values.Set("ticket", ticket)
u.RawQuery = values.Encode()
u.Fragment = ""
return u.String()
}
func webCodePortalURL(base, leaseID string, folder ...string) string {
u, err := url.Parse(base)
if err != nil {
return base
}
u.Path = strings.TrimRight(u.Path, "/") + "/portal/leases/" + url.PathEscape(leaseID) + "/code/"
values := url.Values{}
if len(folder) > 0 && folder[0] != "" {
values.Set("folder", folder[0])
}
u.RawQuery = values.Encode()
u.Fragment = ""
return u.String()
}
func (c *CoordinatorClient) CreateCodeTicket(ctx context.Context, leaseID string) (coordinatorCodeTicket, error) {
var res coordinatorCodeTicket
err := c.do(ctx, http.MethodPost, "/v1/leases/"+url.PathEscape(leaseID)+"/code/ticket", map[string]any{}, &res)
return res, err
}

189
internal/cli/code_test.go Normal file
View File

@ -0,0 +1,189 @@
package cli
import (
"encoding/base64"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"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" {
t.Fatalf("agent 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)
}
if got := webCodePortalURL("https://crabbox.openclaw.ai/", "cbx_abcdef123456", "/work/cbx/repo/worker"); got != "https://crabbox.openclaw.ai/portal/leases/cbx_abcdef123456/code/?folder=%2Fwork%2Fcbx%2Frepo%2Fworker" {
t.Fatalf("portal URL with folder=%q", got)
}
}
func TestMappedRemoteCodeFolderTracksCurrentSubdirectory(t *testing.T) {
root := t.TempDir()
subdir := filepath.Join(root, "worker", "src")
if err := os.MkdirAll(subdir, 0o755); err != nil {
t.Fatal(err)
}
t.Chdir(subdir)
got := mappedRemoteCodeFolder("/work/cbx/repo", Repo{Root: root})
if got != "/work/cbx/repo/worker/src" {
t.Fatalf("mapped folder=%q", got)
}
t.Chdir(t.TempDir())
if got := mappedRemoteCodeFolder("/work/cbx/repo", Repo{Root: root}); got != "/work/cbx/repo" {
t.Fatalf("outside repo folder=%q", got)
}
}
func TestCodeUpstreamPathStripsPortalLeasePrefix(t *testing.T) {
tests := map[string]string{
"/portal/leases/cbx_abcdef123456/code/": "/",
"/portal/leases/cbx_abcdef123456/code/static/main.js": "/static/main.js",
"/portal/leases/cbx_abcdef123456/code/?folder=/work/repo": "/?folder=/work/repo",
"/portal/leases/blue-lobster/code/vscode-remote-resource": "/vscode-remote-resource",
"/portal/leases/blue-lobster/vnc/viewer": "/portal/leases/blue-lobster/vnc/viewer",
"/portal/leases/blue-lobster/code/proxy/3000/?q=hello+you": "/proxy/3000/?q=hello+you",
}
for input, want := range tests {
if got := codeUpstreamPath(input); got != want {
t.Fatalf("codeUpstreamPath(%q)=%q want %q", input, got, want)
}
}
}
func TestCodeBridgeBodyChunkStaysBelowWebSocketFrameLimit(t *testing.T) {
if maxCodeBridgeBodyChunkBytes%3 != 0 {
t.Fatalf("chunk length=%d must be divisible by 3 to avoid mid-stream base64 padding", maxCodeBridgeBodyChunkBytes)
}
encoded := base64.StdEncoding.EncodeToString(make([]byte, maxCodeBridgeBodyChunkBytes))
if len(encoded) >= 64*1024 {
t.Fatalf("encoded chunk length=%d should stay below 64KiB websocket frame budget", len(encoded))
}
if codeBridgeBodyChunkDelay <= 0 {
t.Fatal("large bridge responses should be paced")
}
if maxCodeBridgeReadBytes < 16*1024*1024 {
t.Fatalf("read limit=%d should allow VS Code websocket messages", maxCodeBridgeReadBytes)
}
}
func TestStartCodeServerCommand(t *testing.T) {
got := startCodeServerCommand("/work/crabbox/cbx_abcdef123456/repo")
for _, want := range []string{
"/usr/local/bin/code-server",
"--auth none",
"--bind-addr 127.0.0.1:8080",
"VSCODE_PROXY_URI='./proxy/{{port}}'",
"workbench.colorTheme",
"Default Dark Modern",
"/tmp/crabbox-code-server.log",
"/tmp/crabbox-code-server.pid",
} {
if !strings.Contains(got, want) {
t.Fatalf("startCodeServerCommand missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "pkill -f") {
t.Fatalf("startCodeServerCommand should not use pkill -f:\n%s", got)
}
}
func TestRewriteCodeHTMLRemovesEmptyLocaleScript(t *testing.T) {
input := []byte(`<script type="module" src=""></script><script type="module" src="workbench.js"></script>`)
got := string(rewriteCodeHTML(input))
if strings.Contains(got, `src=""`) {
t.Fatalf("rewriteCodeHTML left empty script:\n%s", got)
}
if !strings.Contains(got, `src="workbench.js"`) {
t.Fatalf("rewriteCodeHTML removed non-empty script:\n%s", got)
}
}
func TestCodeServerStaticFallbackServesVSDAStub(t *testing.T) {
body, headers, ok := codeServerStaticFallback("/stable/static/node_modules/vsda/rust/web/vsda.js", 404)
if !ok {
t.Fatal("expected vsda.js fallback")
}
if headers.Get("content-type") != "text/javascript" {
t.Fatalf("content-type=%q", headers.Get("content-type"))
}
text := string(body)
if !strings.Contains(text, "define(") || !strings.Contains(text, "globalThis.vsda_web") {
t.Fatalf("fallback body missing AMD vsda_web stub:\n%s", body)
}
wasm, headers, ok := codeServerStaticFallback("/stable/static/node_modules/vsda/rust/web/vsda_bg.wasm", 404)
if !ok {
t.Fatal("expected vsda wasm fallback")
}
if headers.Get("content-type") != "application/wasm" {
t.Fatalf("wasm content-type=%q", headers.Get("content-type"))
}
if string(wasm[:4]) != "\x00asm" {
t.Fatalf("wasm header=%v", wasm[:4])
}
if _, _, ok := codeServerStaticFallback("/missing.js", 404); ok {
t.Fatal("unexpected fallback for unrelated path")
}
if _, _, ok := codeServerStaticFallback("/stable/static/node_modules/vsda/rust/web/vsda.js", 500); ok {
t.Fatal("unexpected fallback for non-404")
}
}
func TestCodeFrameType(t *testing.T) {
if got := codeFrameType(websocket.MessageText); got != "text" {
t.Fatalf("text frame=%q", got)
}
if got := codeFrameType(websocket.MessageBinary); got != "binary" {
t.Fatalf("binary frame=%q", got)
}
if got := websocketMessageType("text"); got != websocket.MessageText {
t.Fatalf("websocketMessageType text=%v", got)
}
if got := websocketMessageType("binary"); got != websocket.MessageBinary {
t.Fatalf("websocketMessageType binary=%v", got)
}
if got := websocketMessageType(""); got != websocket.MessageBinary {
t.Fatalf("websocketMessageType default=%v", got)
}
}
func TestWebSocketSubprotocols(t *testing.T) {
headers := http.Header{}
headers.Add("Sec-WebSocket-Protocol", "vscode-remote, crabbox")
headers.Add("Sec-WebSocket-Protocol", " second-token ")
got := websocketSubprotocols(headers)
want := []string{"vscode-remote", "crabbox", "second-token"}
if strings.Join(got, "|") != strings.Join(want, "|") {
t.Fatalf("websocketSubprotocols=%q want %q", got, want)
}
}
func TestCodeWebSocketDialHeadersRewritesOrigin(t *testing.T) {
headers, subprotocols := codeWebSocketDialHeaders("http://127.0.0.1:8081", map[string]string{
"cookie": "vscode-tkn=remote-token",
"origin": "https://crabbox.openclaw.ai",
"sec-websocket-protocol": "proto-a, proto-b",
})
if headers.Get("Origin") != "http://127.0.0.1:8081" {
t.Fatalf("origin=%q", headers.Get("Origin"))
}
if headers.Get("Cookie") != "vscode-tkn=remote-token" {
t.Fatalf("cookie=%q", headers.Get("Cookie"))
}
if headers.Get("Sec-WebSocket-Protocol") != "" {
t.Fatalf("raw subprotocol header should be removed: %q", headers.Get("Sec-WebSocket-Protocol"))
}
if strings.Join(subprotocols, "|") != "proto-a|proto-b" {
t.Fatalf("subprotocols=%q", subprotocols)
}
}

View File

@ -19,6 +19,7 @@ type Config struct {
WindowsMode string
Desktop bool
Browser bool
Code bool
Network NetworkMode
Class string
ServerType string
@ -182,7 +183,7 @@ func baseConfig() Config {
SSHPort: "2222",
SSHFallbackPorts: []string{"22"},
ProviderKey: "crabbox-steipete",
WorkRoot: "/work/crabbox",
WorkRoot: defaultPOSIXWorkRoot,
TTL: 90 * time.Minute,
IdleTimeout: 30 * time.Minute,
Sync: SyncConfig{
@ -229,6 +230,7 @@ type fileConfig struct {
Windows *fileWindowsConfig `yaml:"windows,omitempty"`
Desktop *bool `yaml:"desktop,omitempty"`
Browser *bool `yaml:"browser,omitempty"`
Code *bool `yaml:"code,omitempty"`
Network string `yaml:"network,omitempty"`
Class string `yaml:"class,omitempty"`
ServerType string `yaml:"serverType,omitempty"`
@ -496,6 +498,9 @@ func applyFileConfig(cfg *Config, file fileConfig) {
if file.Browser != nil {
cfg.Browser = *file.Browser
}
if file.Code != nil {
cfg.Code = *file.Code
}
if file.Network != "" {
cfg.Network = NetworkMode(strings.ToLower(strings.TrimSpace(file.Network)))
}
@ -781,6 +786,9 @@ func applyEnv(cfg *Config) {
if value, ok := getenvBool("CRABBOX_BROWSER"); ok {
cfg.Browser = value
}
if value, ok := getenvBool("CRABBOX_CODE"); ok {
cfg.Code = value
}
if network := os.Getenv("CRABBOX_NETWORK"); network != "" {
cfg.Network = NetworkMode(strings.ToLower(strings.TrimSpace(network)))
}

View File

@ -32,6 +32,7 @@ type CoordinatorLease struct {
WindowsMode string `json:"windowsMode,omitempty"`
Desktop bool `json:"desktop,omitempty"`
Browser bool `json:"browser,omitempty"`
Code bool `json:"code,omitempty"`
Tailscale *TailscaleMetadata `json:"tailscale,omitempty"`
Owner string `json:"owner"`
Org string `json:"org"`
@ -315,6 +316,7 @@ func (c *CoordinatorClient) CreateLease(ctx context.Context, cfg Config, publicK
"windowsMode": cfg.WindowsMode,
"desktop": cfg.Desktop,
"browser": cfg.Browser,
"code": cfg.Code,
"tailscale": cfg.Tailscale.Enabled,
"tailscaleTags": cfg.Tailscale.Tags,
"tailscaleHostname": cfg.Tailscale.Hostname,
@ -846,6 +848,7 @@ func leaseToServerTarget(lease CoordinatorLease, cfg Config) (Server, SSHTarget,
"windows_mode": blank(lease.WindowsMode, cfg.WindowsMode),
"desktop": fmt.Sprint(lease.Desktop),
"browser": fmt.Sprint(lease.Browser),
"code": fmt.Sprint(lease.Code),
"expires_at": lease.ExpiresAt,
"last_touched_at": lease.LastTouchedAt,
"idle_timeout_secs": fmt.Sprint(lease.IdleTimeoutSeconds),

View File

@ -16,11 +16,19 @@ func TestValidateCoordinatorLeaseCapabilitiesRequiresBrowserEcho(t *testing.T) {
}
}
func TestValidateCoordinatorLeaseCapabilitiesRequiresCodeEcho(t *testing.T) {
err := validateCoordinatorLeaseCapabilities(Config{Code: true}, CoordinatorLease{ID: "cbx_test"})
if err == nil {
t.Fatal("expected code capability mismatch")
}
}
func TestValidateCoordinatorLeaseCapabilitiesAcceptsRequestedCapabilities(t *testing.T) {
err := validateCoordinatorLeaseCapabilities(Config{Desktop: true, Browser: true}, CoordinatorLease{
err := validateCoordinatorLeaseCapabilities(Config{Desktop: true, Browser: true, Code: true}, CoordinatorLease{
ID: "cbx_test",
Desktop: true,
Browser: true,
Code: true,
})
if err != nil {
t.Fatalf("validateCoordinatorLeaseCapabilities error: %v", err)

View File

@ -17,6 +17,7 @@ type leaseCreateFlagValues struct {
Idle *time.Duration
Desktop *bool
Browser *bool
Code *bool
Blacksmith blacksmithFlagValues
Target targetFlagValues
Network networkFlagValues
@ -33,6 +34,7 @@ func registerLeaseCreateFlags(fs *flag.FlagSet, defaults Config) leaseCreateFlag
Idle: fs.Duration("idle-timeout", defaults.IdleTimeout, "idle timeout"),
Desktop: fs.Bool("desktop", defaults.Desktop, "provision or require a visible desktop/VNC session"),
Browser: fs.Bool("browser", defaults.Browser, "provision or require a browser binary"),
Code: fs.Bool("code", defaults.Code, "provision or require web code-server capability"),
Blacksmith: registerBlacksmithFlags(fs, defaults),
Target: registerTargetFlags(fs, defaults),
Network: registerNetworkFlags(fs, defaults),
@ -43,7 +45,7 @@ func applyLeaseCreateFlags(cfg *Config, fs *flag.FlagSet, values leaseCreateFlag
cfg.Provider = *values.Provider
cfg.Profile = *values.Profile
cfg.Class = *values.Class
applyCapabilityFlags(cfg, *values.Desktop, *values.Browser)
applyCapabilityFlags(cfg, *values.Desktop, *values.Browser, *values.Code)
if err := applyTargetFlagOverrides(cfg, fs, values.Target); err != nil {
return err
}

View File

@ -41,6 +41,9 @@ func directLeaseLabels(cfg Config, leaseID, slug, provider, market string, keep
if cfg.Browser {
labels["browser"] = "true"
}
if cfg.Code {
labels["code"] = "true"
}
if cfg.Tailscale.Enabled {
labels["tailscale"] = "true"
labels["tailscale_state"] = "requested"

View File

@ -16,6 +16,7 @@ func TestDirectLeaseLabelsAreProviderSafe(t *testing.T) {
ServerType: "cpx62",
Desktop: true,
Browser: true,
Code: true,
TTL: 15 * time.Minute,
IdleTimeout: 4 * time.Minute,
}
@ -35,7 +36,7 @@ func TestDirectLeaseLabelsAreProviderSafe(t *testing.T) {
if labels["ttl_secs"] != "900" {
t.Fatalf("ttl_secs=%q want 900", labels["ttl_secs"])
}
if labels["desktop"] != "true" || labels["browser"] != "true" {
if labels["desktop"] != "true" || labels["browser"] != "true" || labels["code"] != "true" {
t.Fatalf("capability labels missing: %#v", labels)
}
if labels["expires_at"] != "1777637040" {

View File

@ -70,6 +70,38 @@ func configuredExcludes(cfg Config) []string {
return appendUniqueStrings(defaultExcludes(), cfg.Sync.Excludes...)
}
func syncExcludes(root string, cfg Config) ([]string, error) {
excludes := configuredExcludes(cfg)
ignore, err := readCrabboxIgnore(root)
if err != nil {
return nil, err
}
return appendUniqueStrings(excludes, ignore...), nil
}
func readCrabboxIgnore(root string) ([]string, error) {
if root == "" {
return nil, nil
}
data, err := os.ReadFile(filepath.Join(root, ".crabboxignore"))
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, exit(2, "read .crabboxignore: %v", err)
}
lines := strings.Split(string(data), "\n")
patterns := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
patterns = append(patterns, line)
}
return patterns, nil
}
func allowedEnv(allow []string) map[string]string {
out := map[string]string{}
for _, env := range os.Environ() {
@ -113,6 +145,13 @@ func gitOutput(root string, args ...string) string {
return strings.TrimSpace(string(out))
}
func remoteGitSeedCandidate(repo Repo) bool {
if repo.Root == "" || repo.RemoteURL == "" || repo.Head == "" {
return false
}
return gitOutput(repo.Root, "for-each-ref", "--contains", repo.Head, "--format=%(refname)", "refs/remotes") != ""
}
func defaultBaseRef(root string) string {
originHead := gitOutput(root, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD")
if originHead != "" {
@ -126,18 +165,22 @@ func defaultBaseRef(root string) string {
}
func syncFingerprint(repo Repo, cfg Config) (string, error) {
manifest, err := syncManifest(repo.Root, configuredExcludes(cfg))
excludes, err := syncExcludes(repo.Root, cfg)
if err != nil {
return "", err
}
return syncFingerprintForManifest(repo, cfg, manifest)
manifest, err := syncManifest(repo.Root, excludes)
if err != nil {
return "", err
}
return syncFingerprintForManifest(repo, cfg, manifest, excludes)
}
func syncFingerprintForManifest(repo Repo, cfg Config, manifest SyncManifest) (string, error) {
func syncFingerprintForManifest(repo Repo, cfg Config, manifest SyncManifest, excludes []string) (string, error) {
if repo.Head == "" {
return "", nil
}
paths, err := changedSyncPaths(repo.Root, configuredExcludes(cfg))
paths, err := changedSyncPaths(repo.Root, excludes)
if err != nil {
return "", err
}
@ -146,7 +189,7 @@ func syncFingerprintForManifest(repo Repo, cfg Config, manifest SyncManifest) (s
fmt.Fprintf(h, "delete=%t\nchecksum=%t\n", cfg.Sync.Delete, cfg.Sync.Checksum)
fmt.Fprintf(h, "manifest=%x\n", sha256.Sum256(manifest.NUL()))
fmt.Fprintf(h, "deleted=%x\n", sha256.Sum256(manifest.DeletedNUL()))
for _, exclude := range configuredExcludes(cfg) {
for _, exclude := range excludes {
fmt.Fprintf(h, "exclude=%s\n", exclude)
}
for _, rel := range paths {

View File

@ -89,6 +89,79 @@ func TestSyncManifestPrunesAppleDoubleSidecars(t *testing.T) {
}
}
func TestCrabboxIgnoreExtendsSyncExcludes(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init")
runGit(t, dir, "config", "user.email", "test@example.com")
runGit(t, dir, "config", "user.name", "Test")
writeFile(t, filepath.Join(dir, ".crabboxignore"), "# local-only artifacts\nlocal-artifacts\n*.tmp\n\n")
writeFile(t, filepath.Join(dir, "src", "main.go"), "package main\n")
writeFile(t, filepath.Join(dir, "local-artifacts", "cache.bin"), "cache")
writeFile(t, filepath.Join(dir, "notes.tmp"), "tmp")
runGit(t, dir, "add", ".")
runGit(t, dir, "commit", "-m", "init")
excludes, err := syncExcludes(dir, baseConfig())
if err != nil {
t.Fatal(err)
}
manifest, err := syncManifest(dir, excludes)
if err != nil {
t.Fatal(err)
}
got := strings.Join(manifest.Files, ",")
if !strings.Contains(got, "src/main.go") {
t.Fatalf("manifest missing source file: %q", got)
}
for _, notWant := range []string{"local-artifacts/cache.bin", "notes.tmp"} {
if strings.Contains(got, notWant) {
t.Fatalf("manifest %q should exclude .crabboxignore pattern %q", got, notWant)
}
}
}
func TestCrabboxIgnorePrunesDeletedPaths(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init")
runGit(t, dir, "config", "user.email", "test@example.com")
runGit(t, dir, "config", "user.name", "Test")
writeFile(t, filepath.Join(dir, ".crabboxignore"), "generated.bin\n")
writeFile(t, filepath.Join(dir, "generated.bin"), "old")
writeFile(t, filepath.Join(dir, "deleted.txt"), "old")
runGit(t, dir, "add", ".")
runGit(t, dir, "commit", "-m", "init")
if err := os.Remove(filepath.Join(dir, "generated.bin")); err != nil {
t.Fatal(err)
}
if err := os.Remove(filepath.Join(dir, "deleted.txt")); err != nil {
t.Fatal(err)
}
excludes, err := syncExcludes(dir, baseConfig())
if err != nil {
t.Fatal(err)
}
manifest, err := syncManifest(dir, excludes)
if err != nil {
t.Fatal(err)
}
if strings.Join(manifest.Deleted, ",") != "deleted.txt" {
t.Fatalf("deleted manifest should omit .crabboxignore patterns: %v", manifest.Deleted)
}
}
func TestReadCrabboxIgnoreSkipsBlankAndCommentLines(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, ".crabboxignore"), "\n# comment\n build-output \n*.tmp\r\n")
got, err := readCrabboxIgnore(dir)
if err != nil {
t.Fatal(err)
}
if strings.Join(got, ",") != "build-output,*.tmp" {
t.Fatalf("patterns=%q", got)
}
}
func TestSyncManifestRecordsTrackedDeletes(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init")
@ -140,6 +213,26 @@ func TestSyncManifestDoesNotDeleteRecreatedStagedDelete(t *testing.T) {
}
}
func TestRemoteGitSeedCandidateRequiresRemoteTrackingRef(t *testing.T) {
dir := t.TempDir()
runGit(t, dir, "init")
runGit(t, dir, "config", "user.email", "test@example.com")
runGit(t, dir, "config", "user.name", "Test")
writeFile(t, filepath.Join(dir, "foo.txt"), "old")
runGit(t, dir, "add", ".")
runGit(t, dir, "commit", "-m", "init")
head := gitOutput(dir, "rev-parse", "HEAD")
repo := Repo{Root: dir, RemoteURL: "https://github.com/openclaw/crabbox.git", Head: head}
if remoteGitSeedCandidate(repo) {
t.Fatal("unpublished head should not be a seed candidate")
}
runGit(t, dir, "update-ref", "refs/remotes/origin/main", head)
if !remoteGitSeedCandidate(repo) {
t.Fatal("head in a remote-tracking ref should be a seed candidate")
}
}
func TestCheckSyncPreflightFailsLargeCandidate(t *testing.T) {
cfg := baseConfig()
cfg.Sync.FailFiles = 2

View File

@ -343,17 +343,19 @@ func (a App) runCommand(ctx context.Context, args []string) (err error) {
}
recorder.Event("sync.started", "sync", "")
timings.syncSteps.sshReady = time.Since(stepStart)
stepStart = time.Now()
mkdirCommand := remoteMkdir(workdir)
excludes, err := syncExcludes(repo.Root, cfg)
if err != nil {
return recordFailure(err)
}
if isWindowsNativeTarget(target) {
mkdirCommand = windowsRemoteMkdir(workdir)
stepStart = time.Now()
if err := runSSHQuiet(ctx, target, windowsRemoteMkdir(workdir)); err != nil {
return recordFailure(exit(7, "create remote workdir: %v", err))
}
timings.syncSteps.mkdir = time.Since(stepStart)
}
if err := runSSHQuiet(ctx, target, mkdirCommand); err != nil {
return recordFailure(exit(7, "create remote workdir: %v", err))
}
timings.syncSteps.mkdir = time.Since(stepStart)
stepStart = time.Now()
manifest, err := syncManifest(repo.Root, configuredExcludes(cfg))
manifest, err := syncManifest(repo.Root, excludes)
if err != nil {
return recordFailure(exit(6, "build sync file list: %v", err))
}
@ -377,7 +379,7 @@ func (a App) runCommand(ctx context.Context, args []string) (err error) {
fingerprint := ""
if cfg.Sync.Fingerprint {
stepStart = time.Now()
fingerprint, err = syncFingerprintForManifest(repo, cfg, manifest)
fingerprint, err = syncFingerprintForManifest(repo, cfg, manifest, excludes)
timings.syncSteps.fingerprintLocal = time.Since(stepStart)
if err != nil {
fmt.Fprintf(a.Stderr, "warning: sync fingerprint failed: %v\n", err)
@ -394,7 +396,7 @@ func (a App) runCommand(ctx context.Context, args []string) (err error) {
}
}
}
if cfg.Sync.GitSeed {
if cfg.Sync.GitSeed && remoteGitSeedCandidate(repo) {
stepStart = time.Now()
if err := runSSHQuiet(ctx, target, remoteGitSeed(workdir, repo.RemoteURL, repo.Head)); err != nil {
fmt.Fprintf(a.Stderr, "warning: remote git seed failed: %v\n", err)
@ -403,11 +405,9 @@ func (a App) runCommand(ctx context.Context, args []string) (err error) {
}
manifestData := manifest.NUL()
stepStart = time.Now()
if err := runSSHInputQuiet(ctx, target, remoteWriteSyncManifestNew(workdir), string(manifestData)); err != nil {
return recordFailure(exit(7, "write sync manifest: %v", err))
}
if err := runSSHInputQuiet(ctx, target, remoteWriteSyncDeletedNew(workdir), string(manifest.DeletedNUL())); err != nil {
return recordFailure(exit(7, "write sync delete manifest: %v", err))
manifestInput := fmt.Sprintf("%d\n", len(manifestData)) + string(manifestData) + string(manifest.DeletedNUL())
if err := runSSHInputQuiet(ctx, target, remoteWriteSyncManifestsNew(workdir), manifestInput); err != nil {
return recordFailure(exit(7, "write sync manifests: %v", err))
}
timings.syncSteps.manifestWrite = time.Since(stepStart)
if cfg.Sync.Delete {
@ -418,53 +418,37 @@ func (a App) runCommand(ctx context.Context, args []string) (err error) {
timings.syncSteps.prune = time.Since(stepStart)
}
stepStart = time.Now()
if err := rsync(ctx, target, repo.Root, workdir, configuredExcludes(cfg), a.Stdout, a.Stderr, rsyncOptions{Debug: *debugSync, Delete: cfg.Sync.Delete, Checksum: cfg.Sync.Checksum, UseFilesFrom: true, FilesFrom: manifestData, Timeout: cfg.Sync.Timeout, HeartbeatInterval: 15 * time.Second}); err != nil {
if err := rsync(ctx, target, repo.Root, workdir, excludes, a.Stdout, a.Stderr, rsyncOptions{Debug: *debugSync, Delete: cfg.Sync.Delete, Checksum: cfg.Sync.Checksum, UseFilesFrom: true, FilesFrom: manifestData, Timeout: cfg.Sync.Timeout, HeartbeatInterval: 15 * time.Second}); err != nil {
return recordFailure(exit(6, "rsync failed: %v", err))
}
timings.syncSteps.rsync = time.Since(stepStart)
stepStart = time.Now()
if err := runSSHQuiet(ctx, target, remoteApplySyncManifest(workdir)); err != nil {
return recordFailure(exit(6, "remote sync manifest apply failed: %v", err))
}
timings.syncSteps.manifestApply = time.Since(stepStart)
stepStart = time.Now()
if out, err := runSSHCombinedOutput(ctx, target, remoteSyncSanity(workdir, os.Getenv("CRABBOX_ALLOW_MASS_DELETIONS") == "1")); err != nil {
if out != "" {
return recordFailure(exit(6, "remote sync sanity failed: %s: %v", out, err))
}
return recordFailure(exit(6, "remote sync sanity failed: %v", err))
}
timings.syncSteps.sanity = time.Since(stepStart)
baseSHA := gitHydrateBaseSHA(repo, cfg.Sync.BaseRef)
hydrateGit := true
if hydratedByActions {
stepStart = time.Now()
reason, err := runSSHOutput(ctx, target, remoteGitHydrateStatus(workdir, cfg.Sync.BaseRef, baseSHA))
if err == nil && reason != "" {
timings.syncSteps.gitHydrateSkipped = true
timings.syncSteps.gitHydrateSkipReason = reason
hydrateGit = false
fmt.Fprintf(a.Stderr, "skipping git hydrate: %s\n", reason)
}
}
if !timings.syncSteps.gitHydrateSkipped {
stepStart = time.Now()
if err := runSSHQuiet(ctx, target, remoteGitHydrate(workdir, cfg.Sync.BaseRef)); err != nil {
fmt.Fprintf(a.Stderr, "warning: remote git hydrate failed: %v\n", err)
stepStart = time.Now()
finalizeCommand := remoteFinalizeSync(workdir, remoteSyncFinalizeOptions{
AllowMassDeletions: os.Getenv("CRABBOX_ALLOW_MASS_DELETIONS") == "1",
HydrateGit: hydrateGit,
BaseRef: cfg.Sync.BaseRef,
BaseSHA: baseSHA,
Fingerprint: fingerprint,
})
if out, err := runSSHCombinedOutput(ctx, target, finalizeCommand); err != nil {
if out != "" {
return recordFailure(exit(6, "remote sync finalize failed: %s: %v", out, err))
}
timings.syncSteps.gitHydrate = time.Since(stepStart)
}
if cfg.Sync.BaseRef != "" && baseSHA != "" {
stepStart = time.Now()
if err := runSSHQuiet(ctx, target, remoteWriteGitHydrateMarker(workdir, cfg.Sync.BaseRef, baseSHA)); err != nil {
fmt.Fprintf(a.Stderr, "warning: write git hydrate marker failed: %v\n", err)
}
}
if fingerprint != "" {
stepStart = time.Now()
if err := runSSHQuiet(ctx, target, remoteWriteSyncFingerprint(workdir, fingerprint)); err != nil {
fmt.Fprintf(a.Stderr, "warning: write sync fingerprint failed: %v\n", err)
}
timings.syncSteps.fingerprintWrite = time.Since(stepStart)
return recordFailure(exit(6, "remote sync finalize failed: %v", err))
}
timings.syncSteps.finalize = time.Since(stepStart)
timings.sync = time.Since(syncStart)
fmt.Fprintf(a.Stderr, "sync complete in %s\n", timings.sync.Round(time.Millisecond))
recorder.Event("sync.finished", "synced", fmt.Sprintf("duration=%s skipped=false", timings.sync.Round(time.Millisecond)))
@ -595,6 +579,7 @@ type syncStepTimings struct {
manifestApply time.Duration
sanity time.Duration
gitHydrate time.Duration
finalize time.Duration
gitHydrateSkipped bool
gitHydrateSkipReason string
fingerprintWrite time.Duration
@ -638,6 +623,7 @@ func formatSyncStepTimings(steps syncStepTimings) string {
} else {
appendStep("git_hydrate", steps.gitHydrate)
}
appendStep("finalize", steps.finalize)
appendStep("fingerprint_write", steps.fingerprintWrite)
return strings.Join(parts, ",")
}
@ -744,6 +730,9 @@ func validateCoordinatorLeaseCapabilities(cfg Config, lease CoordinatorLease) er
if cfg.Browser && !lease.Browser {
return exit(5, "coordinator did not provision browser=true for lease %s; deploy the coordinator with browser support", blank(lease.ID, "-"))
}
if cfg.Code && !lease.Code {
return exit(5, "coordinator did not provision code=true for lease %s; deploy the coordinator with web code support", blank(lease.ID, "-"))
}
if cfg.Tailscale.Enabled && (lease.Tailscale == nil || !lease.Tailscale.Enabled) {
return exit(5, "coordinator did not provision tailscale=true for lease %s; deploy the coordinator with Tailscale support", blank(lease.ID, "-"))
}

View File

@ -168,6 +168,7 @@ func TestApplyServerTypeFlagOverridesUsesTargetAwareAWSDefaults(t *testing.T) {
WindowsMode: windowsModeNormal,
Class: "beast",
ServerType: "c7a.48xlarge",
WorkRoot: defaultWindowsWorkRoot,
}
fs := newFlagSet("test", io.Discard)
provider := fs.String("provider", cfg.Provider, "")
@ -186,6 +187,9 @@ func TestApplyServerTypeFlagOverridesUsesTargetAwareAWSDefaults(t *testing.T) {
if cfg.ServerType != tt.want {
t.Fatalf("serverType=%q want %q", cfg.ServerType, tt.want)
}
if cfg.WindowsMode == windowsModeWSL2 && cfg.WorkRoot != defaultPOSIXWorkRoot {
t.Fatalf("workRoot=%q want %q", cfg.WorkRoot, defaultPOSIXWorkRoot)
}
if cfg.ServerTypeExplicit {
t.Fatal("ServerTypeExplicit=true, want false")
}
@ -193,6 +197,62 @@ func TestApplyServerTypeFlagOverridesUsesTargetAwareAWSDefaults(t *testing.T) {
}
}
func TestApplyTargetFlagOverridesRefreshesDefaultWorkRoot(t *testing.T) {
tests := []struct {
name string
cfg Config
args []string
want string
}{
{
name: "native windows to wsl2",
cfg: Config{
TargetOS: targetWindows,
WindowsMode: windowsModeNormal,
WorkRoot: defaultWindowsWorkRoot,
},
args: []string{"--windows-mode", "wsl2"},
want: defaultPOSIXWorkRoot,
},
{
name: "wsl2 to native windows",
cfg: Config{
TargetOS: targetWindows,
WindowsMode: windowsModeWSL2,
WorkRoot: defaultPOSIXWorkRoot,
},
args: []string{"--windows-mode", "normal"},
want: defaultWindowsWorkRoot,
},
{
name: "custom root is preserved",
cfg: Config{
TargetOS: targetWindows,
WindowsMode: windowsModeNormal,
WorkRoot: `/custom/root`,
},
args: []string{"--windows-mode", "wsl2"},
want: `/custom/root`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fs := newFlagSet("test", io.Discard)
targetFlags := registerTargetFlags(fs, tt.cfg)
if err := parseFlags(fs, tt.args); err != nil {
t.Fatal(err)
}
cfg := tt.cfg
if err := applyTargetFlagOverrides(&cfg, fs, targetFlags); err != nil {
t.Fatal(err)
}
if cfg.WorkRoot != tt.want {
t.Fatalf("workRoot=%q want %q", cfg.WorkRoot, tt.want)
}
})
}
}
func TestApplyServerTypeFlagOverridesPreservesExplicitType(t *testing.T) {
cfg := Config{
Provider: "aws",

View File

@ -625,9 +625,10 @@ func remoteGitSeed(workdir, remoteURL, head string) string {
"mkdir -p " + shellQuote(parent) + "; " +
"tmp=$(mktemp -d " + shellQuote(parent+"/.seed.XXXXXX") + "); " +
"if git clone --quiet --filter=blob:none --no-checkout " + shellQuote(remoteURL) + " \"$tmp\" >/dev/null 2>&1; then " +
"(cd \"$tmp\" && (git fetch --quiet --depth=1 origin " + shellQuote(head) + " || true) && (git checkout --quiet " + shellQuote(head) + " || git checkout --quiet FETCH_HEAD || true)); " +
"if (cd \"$tmp\" && (git fetch --quiet --depth=1 origin " + shellQuote(head) + " || true) && (git checkout --quiet " + shellQuote(head) + " || git checkout --quiet FETCH_HEAD)); then " +
"rm -rf " + shellQuote(workdir) + " && mv \"$tmp\" " + shellQuote(workdir) + "; " +
"else rm -rf \"$tmp\"; fi; " +
"else rm -rf \"$tmp\"; fi; " +
"fi"
}
@ -648,6 +649,14 @@ func remoteWriteSyncFingerprint(workdir, fingerprint string) string {
return "bash -lc " + shellQuote(script)
}
type remoteSyncFinalizeOptions struct {
AllowMassDeletions bool
HydrateGit bool
BaseRef string
BaseSHA string
Fingerprint string
}
func remoteWriteSyncManifestNew(workdir string) string {
script := "cd " + shellQuote(workdir) + " && " + remoteSyncMetaDirScript() + "mkdir -p \"$meta_dir\" && cat > \"$meta_dir/sync-manifest.new\""
return "bash -lc " + shellQuote(script)
@ -658,6 +667,20 @@ func remoteWriteSyncDeletedNew(workdir string) string {
return "bash -lc " + shellQuote(script)
}
func remoteWriteSyncManifestsNew(workdir string) string {
python := `import pathlib
import sys
manifest_len = int(sys.stdin.buffer.readline())
manifest = sys.stdin.buffer.read(manifest_len)
deleted = sys.stdin.buffer.read()
pathlib.Path(sys.argv[1]).write_bytes(manifest)
pathlib.Path(sys.argv[2]).write_bytes(deleted)
`
script := "mkdir -p " + shellQuote(workdir) + " && cd " + shellQuote(workdir) + " && " + remoteSyncMetaDirScript() + "mkdir -p \"$meta_dir\" && python3 -c " + shellQuote(python) + " \"$meta_dir/sync-manifest.new\" \"$meta_dir/sync-deleted.new\""
return "bash -lc " + shellQuote(script)
}
func remotePruneSyncManifest(workdir string) string {
script := "set -e\ncd " + shellQuote(workdir) + `
` + remoteSyncMetaDirScript() + `
@ -703,6 +726,46 @@ func remoteApplySyncManifest(workdir string) string {
return "bash -lc " + shellQuote(script)
}
func remoteFinalizeSync(workdir string, opts remoteSyncFinalizeOptions) string {
allowValue := ""
if opts.AllowMassDeletions {
allowValue = "1"
}
script := `set -e
cd ` + shellQuote(workdir) + `
` + remoteSyncMetaDirScript() + `
mkdir -p "$meta_dir"
new="$meta_dir/sync-manifest.new"
deleted="$meta_dir/sync-deleted.new"
rm -f "$deleted"
mv "$new" "$meta_dir/sync-manifest"
if test -d .git && git status --short >/tmp/crabbox-git-status 2>/dev/null; then
deletions=$(awk '/^ D|^D / { n++ } END { print n+0 }' /tmp/crabbox-git-status)
if [ ` + shellQuote(allowValue) + ` != '1' ] && [ "$deletions" -ge 200 ]; then
echo "remote sync sanity failed: $deletions tracked deletions" >&2
awk '/^ D|^D / { print " " substr($0,4) }' /tmp/crabbox-git-status | head -20 >&2
exit 66
fi
fi
`
if opts.HydrateGit && opts.BaseRef != "" {
refspec := "+refs/heads/" + opts.BaseRef + ":refs/remotes/origin/" + opts.BaseRef
script += `if git rev-parse --is-inside-work-tree >/dev/null 2>&1 && git remote get-url origin >/dev/null 2>&1; then
git fetch --quiet --unshallow origin ` + shellQuote(refspec) + ` || git fetch --quiet --depth=1000 origin ` + shellQuote(refspec) + ` || git fetch --quiet origin ` + shellQuote(refspec) + ` || git fetch --quiet origin ` + shellQuote(opts.BaseRef) + ` || true
fi
`
}
if opts.BaseRef != "" && opts.BaseSHA != "" {
script += `printf %s ` + shellQuote(opts.BaseRef+" "+opts.BaseSHA+"\n") + ` > "$meta_dir/git-hydrate-base" || true
`
}
if opts.Fingerprint != "" {
script += `printf %s ` + shellQuote(opts.Fingerprint) + ` > "$meta_dir/sync-fingerprint" || true
`
}
return "bash -lc " + shellQuote(script)
}
func remoteSyncMetaDirScript() string {
return "meta_dir=$(if [ -d .git ]; then printf %s .git/crabbox; else printf %s .crabbox; fi); "
}

View File

@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/base64"
"fmt"
"os"
"os/exec"
"path/filepath"
@ -395,6 +396,72 @@ func TestRemoteApplySyncManifestOnlyCommitsManifest(t *testing.T) {
}
}
func TestRemoteFinalizeSyncCommitsMetadataInOneCommand(t *testing.T) {
workdir := t.TempDir()
if err := os.Mkdir(filepath.Join(workdir, ".git"), 0o755); err != nil {
t.Fatal(err)
}
metaDir := filepath.Join(workdir, ".git", "crabbox")
if err := os.MkdirAll(metaDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(metaDir, "sync-manifest.new"), []byte("tracked.txt\x00"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(metaDir, "sync-deleted.new"), []byte("deleted.txt\x00"), 0o644); err != nil {
t.Fatal(err)
}
cmd := exec.Command("bash", "-lc", remoteFinalizeSync(workdir, remoteSyncFinalizeOptions{
BaseRef: "main",
BaseSHA: "abc123",
Fingerprint: "fp123",
}))
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("remote finalize failed: %v\n%s", err, out)
}
if _, err := os.Stat(filepath.Join(metaDir, "sync-deleted.new")); !os.IsNotExist(err) {
t.Fatalf("deleted manifest should be removed, stat err=%v", err)
}
manifest, err := os.ReadFile(filepath.Join(metaDir, "sync-manifest"))
if err != nil {
t.Fatal(err)
}
if string(manifest) != "tracked.txt\x00" {
t.Fatalf("unexpected manifest: %q", manifest)
}
marker, err := os.ReadFile(filepath.Join(metaDir, "git-hydrate-base"))
if err != nil {
t.Fatal(err)
}
if string(marker) != "main abc123\n" {
t.Fatalf("unexpected hydrate marker: %q", marker)
}
fingerprint, err := os.ReadFile(filepath.Join(metaDir, "sync-fingerprint"))
if err != nil {
t.Fatal(err)
}
if string(fingerprint) != "fp123" {
t.Fatalf("unexpected fingerprint: %q", fingerprint)
}
}
func TestRemoteGitSeedRemovesFailedCheckout(t *testing.T) {
got := remoteGitSeed("/work/repo", "https://github.com/openclaw/crabbox.git", "missing-sha")
for _, want := range []string{
"if (cd \"$tmp\"",
"git checkout --quiet 'missing-sha' || git checkout --quiet FETCH_HEAD",
"else rm -rf \"$tmp\"; fi",
} {
if !strings.Contains(got, want) {
t.Fatalf("remoteGitSeed missing %q in %q", want, got)
}
}
if strings.Contains(got, "git checkout --quiet FETCH_HEAD || true") {
t.Fatalf("remoteGitSeed should not keep failed checkouts: %q", got)
}
}
func TestRemoteGitHydrateStatusUsesMarkerAndRemoteBase(t *testing.T) {
got := remoteGitHydrateStatus("/work/repo", "main", "abc123")
for _, want := range []string{
@ -426,6 +493,33 @@ func TestRemoteWriteSyncDeletedNew(t *testing.T) {
}
}
func TestRemoteWriteSyncManifestsNew(t *testing.T) {
workdir := t.TempDir()
manifest := "keep.txt\x00"
deleted := "old.txt\x00"
input := fmt.Sprintf("%d\n", len(manifest)) + manifest + deleted
cmd := exec.Command("bash", "-lc", remoteWriteSyncManifestsNew(workdir))
cmd.Stdin = strings.NewReader(input)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("write manifests failed: %v\n%s", err, out)
}
metaDir := filepath.Join(workdir, ".crabbox")
gotManifest, err := os.ReadFile(filepath.Join(metaDir, "sync-manifest.new"))
if err != nil {
t.Fatal(err)
}
if string(gotManifest) != manifest {
t.Fatalf("unexpected manifest: %q", gotManifest)
}
gotDeleted, err := os.ReadFile(filepath.Join(metaDir, "sync-deleted.new"))
if err != nil {
t.Fatal(err)
}
if string(gotDeleted) != deleted {
t.Fatalf("unexpected deleted manifest: %q", gotDeleted)
}
}
func TestRemoteSyncMetadataUsesGitDirForGitWorktree(t *testing.T) {
workdir := t.TempDir()
if err := os.Mkdir(filepath.Join(workdir, ".git"), 0o755); err != nil {

View File

@ -32,7 +32,11 @@ func (a App) syncPlan(ctx context.Context, args []string) error {
if err != nil {
return err
}
manifest, err := syncManifest(repo.Root, configuredExcludes(cfg))
excludes, err := syncExcludes(repo.Root, cfg)
if err != nil {
return err
}
manifest, err := syncManifest(repo.Root, excludes)
if err != nil {
return exit(6, "build sync file list: %v", err)
}

View File

@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"time"
)
@ -12,7 +13,7 @@ func syncWindowsNative(ctx context.Context, target SSHTarget, repo Repo, cfg Con
if err := runSSHQuiet(ctx, target, windowsPrepareWorkdir(workdir, cfg.Sync.Delete)); err != nil {
return exit(7, "prepare remote workdir: %v", err)
}
if cfg.Sync.GitSeed {
if cfg.Sync.GitSeed && remoteGitSeedCandidate(repo) {
if err := runSSHQuiet(ctx, target, windowsGitSeed(workdir, repo.RemoteURL, repo.Head)); err != nil {
fmt.Fprintf(stderr, "warning: remote git seed failed: %v\n", err)
}
@ -21,6 +22,7 @@ func syncWindowsNative(ctx context.Context, target SSHTarget, repo Repo, cfg Con
input.Write(manifest.NUL())
cmd := exec.CommandContext(ctx, "tar", "-czf", "-", "-C", repo.Root, "--null", "-T", "-")
cmd.Stdin = &input
cmd.Env = append(os.Environ(), "COPYFILE_DISABLE=1")
var archive bytes.Buffer
cmd.Stdout = &archive
cmd.Stderr = stderr

View File

@ -12,17 +12,16 @@ const (
windowsModeNormal = "normal"
windowsModeWSL2 = "wsl2"
defaultPOSIXWorkRoot = "/work/crabbox"
defaultWindowsWorkRoot = `C:\crabbox`
)
func normalizeTargetConfig(cfg *Config) {
cfg.TargetOS = normalizeTargetOS(cfg.TargetOS)
cfg.WindowsMode = normalizeWindowsMode(cfg.WindowsMode)
if cfg.TargetOS == targetWindows && cfg.WorkRoot == "/work/crabbox" {
if cfg.WindowsMode == windowsModeWSL2 {
cfg.WorkRoot = "/work/crabbox"
} else {
cfg.WorkRoot = `C:\crabbox`
}
if isDefaultWorkRoot(cfg.WorkRoot) {
cfg.WorkRoot = defaultWorkRootForTarget(cfg.TargetOS, cfg.WindowsMode)
}
if cfg.Provider == "aws" && cfg.TargetOS == targetMacOS && cfg.SSHUser == baseConfig().SSHUser {
cfg.SSHUser = "ec2-user"
@ -41,6 +40,22 @@ func normalizeTargetConfig(cfg *Config) {
}
}
func isDefaultWorkRoot(value string) bool {
switch value {
case "", defaultPOSIXWorkRoot, defaultWindowsWorkRoot:
return true
default:
return false
}
}
func defaultWorkRootForTarget(targetOS, windowsMode string) string {
if targetOS == targetWindows && windowsMode == windowsModeNormal {
return defaultWindowsWorkRoot
}
return defaultPOSIXWorkRoot
}
func normalizeTargetOS(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", "linux", "ubuntu":

View File

@ -76,6 +76,7 @@ func syncTimingPhases(steps syncStepTimings) []timingPhase {
if steps.gitHydrateSkipped {
phases = append(phases, timingPhase{Name: "git_hydrate", Skipped: true, Reason: steps.gitHydrateSkipReason})
}
appendDuration("finalize", steps.finalize)
appendDuration("fingerprint_write", steps.fingerprintWrite)
return phases
}

View File

@ -318,6 +318,12 @@ function optionalReadyChecks(config: LeaseConfig): string {
' "$BROWSER" --version >/dev/null',
);
}
if (config.code) {
lines.push(
" test -x /usr/local/bin/code-server",
" /usr/local/bin/code-server --version >/dev/null",
);
}
return lines.join("\n");
}
@ -453,6 +459,11 @@ function optionalBootstrap(config: LeaseConfig): string {
chmod 0644 /var/lib/crabbox/browser.env
fi`);
}
if (config.code) {
parts.push(` retry apt-get install -y --no-install-recommends libatomic1
retry env HOME=/root sh -c 'curl -fsSL https://code-server.dev/install.sh | sh -s -- --method=standalone --prefix=/usr/local'
/usr/local/bin/code-server --version >/dev/null`);
}
return parts.join("\n");
}

View File

@ -6,6 +6,7 @@ export interface LeaseConfig {
windowsMode: WindowsMode;
desktop: boolean;
browser: boolean;
code: boolean;
tailscale: boolean;
tailscaleTags: string[];
tailscaleHostname: string;
@ -84,6 +85,7 @@ export function leaseConfig(input: LeaseRequest): LeaseConfig {
windowsMode,
desktop: input.desktop ?? false,
browser: input.browser ?? false,
code: input.code ?? false,
tailscale: input.tailscale ?? false,
tailscaleTags: normalizeTailscaleTags(input.tailscaleTags ?? ["tag:crabbox"]),
tailscaleHostname: input.tailscaleHostname ?? "",

View File

@ -4,7 +4,7 @@ import { leaseConfig, validCIDRs } from "./config";
import { HetznerClient } from "./hetzner";
import { errorMessage, json, pathParts, readJson, requestOwner } from "./http";
import { githubAuthRoute, githubPortalLogin, githubPortalLogout } from "./oauth";
import { portalError, portalHome, portalVNC, webVNCBridgeCommand } from "./portal";
import { portalCode, portalError, portalHome, portalVNC, webVNCBridgeCommand } from "./portal";
import { leaseSlugFromID, normalizeLeaseSlug, slugWithCollisionSuffix } from "./slug";
import {
createTailscaleAuthKey,
@ -37,7 +37,10 @@ const fleetID = "default";
const maxStoredRunLogBytes = 8 * 1024 * 1024;
const runLogChunkBytes = 64 * 1024;
const webVNCTicketTTLSeconds = 120;
const codeTicketTTLSeconds = 120;
const maxPendingWebVNCBytes = 1024 * 1024;
const maxCodeRequestBytes = 10 * 1024 * 1024;
const maxCodeWebSocketFrameChunkBytes = 15 * 1024;
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
@ -50,16 +53,109 @@ interface WebVNCTicketRecord {
expiresAt: string;
}
interface CodeTicketRecord {
ticket: string;
leaseID: string;
owner: string;
org: string;
createdAt: string;
expiresAt: string;
}
interface CodeProxyRequest {
type: "http";
id: string;
method: string;
path: string;
headers: Record<string, string>;
body?: string;
}
interface CodeProxyResponse {
type: "http";
id: string;
status: number;
headers?: Record<string, string>;
body?: string;
error?: string;
}
interface CodePendingRequest {
resolve: (response: CodeProxyResponse) => void;
timeout: ReturnType<typeof setTimeout>;
response?: CodeProxyResponse;
chunks: string[];
}
interface CodeWebSocketOpen {
type: "ws_open";
id: string;
path: string;
headers: Record<string, string>;
}
interface CodeWebSocketData {
type: "ws_data";
id: string;
body: string;
frame?: "text" | "binary";
}
interface CodeWebSocketFrameStart {
type: "ws_start";
id: string;
chunkID: string;
frame?: "text" | "binary";
}
interface CodeWebSocketFrameBody {
type: "ws_body";
id?: string;
chunkID: string;
body: string;
}
interface CodeWebSocketFrameEnd {
type: "ws_end";
id?: string;
chunkID: string;
}
interface CodeWebSocketClose {
type: "ws_close";
id: string;
code?: number;
reason?: string;
}
interface CodePendingWebSocketFrame {
id: string;
frame: "text" | "binary";
chunks: string[];
}
type BridgeAttachment =
| { kind: "webvnc-agent"; leaseID: string }
| { kind: "webvnc-viewer"; leaseID: string }
| { kind: "code-agent"; leaseID: string }
| { kind: "code-viewer"; leaseID: string; id: string };
export class FleetDurableObject implements DurableObject {
private readonly webVNCAgents = new Map<string, WebSocket>();
private readonly webVNCViewers = new Map<string, WebSocket>();
private readonly pendingWebVNCToViewer = new Map<string, WebVNCBuffer>();
private readonly codeAgents = new Map<string, WebSocket>();
private readonly codeViewers = new Map<string, WebSocket>();
private readonly pendingCodeRequests = new Map<string, CodePendingRequest>();
private readonly pendingCodeFrames = new Map<string, CodePendingWebSocketFrame>();
constructor(
private readonly state: DurableObjectState,
private readonly env: Env,
private readonly testProviders: Partial<Record<Provider, CloudProvider>> = {},
) {}
) {
this.restoreBridgeWebSockets();
}
async fetch(request: Request): Promise<Response> {
try {
@ -138,6 +234,24 @@ export class FleetDurableObject implements DurableObject {
) {
return await this.webVNCAgent(request, parts[2]);
}
if (
parts[0] === "v1" &&
parts[1] === "leases" &&
parts[2] &&
parts[3] === "code" &&
parts[4] === "ticket"
) {
return await this.createCodeTicket(request, parts[2]);
}
if (
parts[0] === "v1" &&
parts[1] === "leases" &&
parts[2] &&
parts[3] === "code" &&
parts[4] === "agent"
) {
return await this.codeAgent(request, parts[2]);
}
if (parts[0] === "v1" && parts[1] === "leases" && parts[2]) {
return await this.leaseRoute(request, parts[2], parts[3]);
}
@ -147,6 +261,130 @@ export class FleetDurableObject implements DurableObject {
}
}
async webSocketMessage(socket: WebSocket, message: string | ArrayBuffer): Promise<void> {
const attachment = bridgeAttachment(socket);
if (!attachment) {
return;
}
await this.handleBridgeMessage(socket, attachment, message);
}
webSocketClose(socket: WebSocket, code: number, reason: string, _wasClean: boolean): void {
this.handleBridgeClose(socket, code, reason);
}
webSocketError(socket: WebSocket, _error: unknown): void {
this.handleBridgeClose(socket, 1011, "bridge socket error");
}
private restoreBridgeWebSockets(): void {
if (typeof this.state.getWebSockets !== "function") {
return;
}
for (const socket of this.state.getWebSockets()) {
const attachment = bridgeAttachment(socket);
if (!attachment || socket.readyState !== WebSocket.OPEN) {
continue;
}
this.trackBridgeSocket(socket, attachment);
}
}
private acceptBridgeWebSocket(socket: WebSocket, attachment: BridgeAttachment): void {
if (typeof this.state.acceptWebSocket === "function") {
this.state.acceptWebSocket(socket, bridgeTags(attachment));
socket.serializeAttachment(attachment);
} else {
socket.accept();
socket.addEventListener("message", (event) => {
void this.handleBridgeMessage(socket, attachment, event.data);
});
socket.addEventListener("close", (event) => {
this.handleBridgeClose(socket, event.code, event.reason);
});
socket.addEventListener("error", () => {
this.handleBridgeClose(socket, 1011, "bridge socket error");
});
}
}
private trackBridgeSocket(socket: WebSocket, attachment: BridgeAttachment): void {
switch (attachment.kind) {
case "webvnc-agent":
this.webVNCAgents.set(attachment.leaseID, socket);
break;
case "webvnc-viewer":
this.webVNCViewers.set(attachment.leaseID, socket);
break;
case "code-agent":
this.codeAgents.set(attachment.leaseID, socket);
break;
case "code-viewer":
this.codeViewers.set(attachment.id, socket);
break;
}
}
private async handleBridgeMessage(
socket: WebSocket,
attachment: BridgeAttachment,
message: string | ArrayBuffer | Blob,
): Promise<void> {
switch (attachment.kind) {
case "webvnc-agent":
await forwardOrBufferWebVNC(
message,
this.webVNCViewers.get(attachment.leaseID),
this.pendingWebVNCToViewer,
attachment.leaseID,
);
break;
case "webvnc-viewer":
await forwardWebVNC(message, this.webVNCAgents.get(attachment.leaseID));
break;
case "code-agent":
await this.handleCodeAgentMessage(attachment.leaseID, message);
break;
case "code-viewer": {
const agent = this.codeAgents.get(attachment.leaseID);
if (agent?.readyState !== WebSocket.OPEN) {
return;
}
const data = await normalizeWebVNCData(message);
const bytes = typeof data === "string" ? textEncoder.encode(data) : new Uint8Array(data);
this.sendCodeWebSocketData(agent, {
type: "ws_data",
id: attachment.id,
frame: typeof data === "string" ? "text" : "binary",
body: bytesToBase64(bytes),
});
break;
}
}
void socket;
}
private handleBridgeClose(socket: WebSocket, code: number, reason: string): void {
const attachment = bridgeAttachment(socket);
if (!attachment) {
return;
}
switch (attachment.kind) {
case "webvnc-agent":
this.clearWebVNCAgent(attachment.leaseID, socket);
break;
case "webvnc-viewer":
this.clearWebVNCViewer(attachment.leaseID, socket);
break;
case "code-agent":
this.clearCodeAgent(attachment.leaseID, socket);
break;
case "code-viewer":
this.clearCodeViewer(attachment.leaseID, attachment.id, socket, code, reason);
break;
}
}
async alarm(): Promise<void> {
await this.expireLeases();
await this.scheduleAlarm();
@ -195,6 +433,7 @@ export class FleetDurableObject implements DurableObject {
target: config.target,
desktop: config.desktop,
browser: config.browser,
code: config.code,
cloudID: "",
owner,
org,
@ -441,6 +680,9 @@ export class FleetDurableObject implements DurableObject {
) {
return await this.webVNCViewer(request, parts[2]);
}
if (parts[1] === "leases" && parts[2] && parts[3] === "code") {
return await this.codePortalProxy(request, parts[2], parts.slice(4));
}
return json({ error: "not_found" }, { status: 404 });
}
@ -469,21 +711,11 @@ export class FleetDurableObject implements DurableObject {
const pair = new WebSocketPair();
const client = pair[0];
const agent = pair[1];
agent.accept();
closeSocket(this.webVNCAgents.get(lease.id), 1012, "replaced by a newer WebVNC bridge");
this.pendingWebVNCToViewer.delete(lease.id);
this.webVNCAgents.set(lease.id, agent);
agent.addEventListener("message", (event) => {
forwardOrBufferWebVNC(
event.data,
this.webVNCViewers.get(lease.id),
this.pendingWebVNCToViewer,
lease.id,
);
});
agent.addEventListener("close", () => this.clearWebVNCAgent(lease.id, agent));
agent.addEventListener("error", () => this.clearWebVNCAgent(lease.id, agent));
this.acceptBridgeWebSocket(agent, { kind: "webvnc-agent", leaseID: lease.id });
return new Response(null, { status: 101, webSocket: client });
}
@ -543,6 +775,353 @@ export class FleetDurableObject implements DurableObject {
});
}
private async createCodeTicket(request: Request, identifier: string): Promise<Response> {
if (request.method.toUpperCase() !== "POST") {
return json({ error: "not_found" }, { status: 404 });
}
const lease = await this.resolveLease(identifier, request, false);
if (!lease) {
return notFound();
}
const error = codeLeaseError(lease);
if (error) {
return json({ error: "code_unavailable", message: error }, { status: 409 });
}
await this.cleanupExpiredCodeTickets();
const now = new Date();
const ticket: CodeTicketRecord = {
ticket: newCodeTicket(),
leaseID: lease.id,
owner: requestOwner(request),
org: requestOrg(request, this.env),
createdAt: now.toISOString(),
expiresAt: new Date(now.getTime() + codeTicketTTLSeconds * 1000).toISOString(),
};
await this.state.storage.put(codeTicketKey(ticket.ticket), ticket);
return json({
ticket: ticket.ticket,
leaseID: ticket.leaseID,
expiresAt: ticket.expiresAt,
});
}
private async codeAgent(request: Request, identifier: string): Promise<Response> {
if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
return json(
{ error: "upgrade_required", message: "code bridge requires a websocket upgrade" },
{ status: 426 },
);
}
const ticket = await this.consumeCodeTicket(request);
if (!ticket) {
return json(
{ error: "code_ticket_required", message: "valid code bridge ticket required" },
{ status: 401 },
);
}
const lease = await this.getLease(ticket.leaseID);
if (!lease || !identifierMatchesLease(identifier, lease)) {
return notFound();
}
const error = codeLeaseError(lease);
if (error) {
return json({ error: "code_unavailable", message: error }, { status: 409 });
}
const pair = new WebSocketPair();
const client = pair[0];
const agent = pair[1];
closeSocket(this.codeAgents.get(lease.id), 1012, "replaced by a newer code bridge");
this.clearCodeLease(lease.id);
this.codeAgents.set(lease.id, agent);
this.acceptBridgeWebSocket(agent, { kind: "code-agent", leaseID: lease.id });
return new Response(null, { status: 101, webSocket: client });
}
private async codePortalProxy(
request: Request,
identifier: string,
_rest: string[],
): Promise<Response> {
const lease = await this.resolveLease(identifier, request, false);
if (!lease) {
return portalError(
"Lease not found",
"That lease is not active or is not visible to you.",
404,
);
}
const error = codeLeaseError(lease);
if (error) {
return portalError("Code unavailable", error, 409);
}
const agent = this.codeAgents.get(lease.id);
if (request.method.toUpperCase() === "GET" && _rest.length === 1 && _rest[0] === "health") {
return this.codePortalHealth(lease, agent);
}
if (!agent || agent.readyState !== WebSocket.OPEN) {
return portalCode(lease);
}
if (request.headers.get("upgrade")?.toLowerCase() === "websocket") {
return this.codeViewerWebSocket(request, lease, agent);
}
return await this.codeProxyHTTP(request, lease, agent);
}
private codePortalHealth(lease: LeaseRecord, agent: WebSocket | undefined): Response {
return json({
lease: {
id: lease.id,
slug: lease.slug,
state: lease.state,
code: lease.code === true,
},
code: {
agentConnected: agent?.readyState === WebSocket.OPEN,
pendingRequests: this.pendingCodeRequests.size,
},
});
}
private async codeProxyHTTP(
request: Request,
lease: LeaseRecord,
agent: WebSocket,
): Promise<Response> {
const bodyBytes = new Uint8Array(await request.arrayBuffer());
if (bodyBytes.byteLength > maxCodeRequestBytes) {
return json({ error: "request_too_large" }, { status: 413 });
}
const id = crypto.randomUUID();
const url = new URL(request.url);
const message: CodeProxyRequest = {
type: "http",
id,
method: request.method,
path: `${url.pathname}${url.search}`,
headers: codeForwardHeaders(request.headers),
};
if (bodyBytes.byteLength > 0) {
message.body = bytesToBase64(bodyBytes);
}
const response = await new Promise<CodeProxyResponse>((resolve) => {
const timeout = setTimeout(() => {
this.pendingCodeRequests.delete(id);
resolve({ type: "http", id, status: 504, error: "code bridge timed out" });
}, 30_000);
this.pendingCodeRequests.set(id, { resolve, timeout, chunks: [] });
agent.send(JSON.stringify(message));
});
if (response.error) {
return json(
{ error: "code_proxy_error", message: response.error },
{ status: response.status || 502 },
);
}
return new Response(response.body ? base64ToBytes(response.body) : null, {
status: response.status || 502,
headers: codeResponseHeaders(response.headers ?? {}),
});
}
private codeViewerWebSocket(request: Request, lease: LeaseRecord, agent: WebSocket): Response {
const pair = new WebSocketPair();
const client = pair[0];
const viewer = pair[1];
const id = crypto.randomUUID();
this.codeViewers.set(id, viewer);
this.acceptBridgeWebSocket(viewer, { kind: "code-viewer", leaseID: lease.id, id });
const url = new URL(request.url);
const open: CodeWebSocketOpen = {
type: "ws_open",
id,
path: `${url.pathname}${url.search}`,
headers: codeForwardHeaders(request.headers),
};
agent.send(JSON.stringify(open));
return new Response(null, { status: 101, webSocket: client });
}
private sendCodeWebSocketData(agent: WebSocket, message: CodeWebSocketData): void {
const data = base64ToBytes(message.body);
if (data.byteLength <= maxCodeWebSocketFrameChunkBytes) {
agent.send(JSON.stringify(message));
return;
}
const chunkID = crypto.randomUUID();
const frame = message.frame ?? "binary";
const start: CodeWebSocketFrameStart = {
type: "ws_start",
id: message.id,
chunkID,
frame,
};
agent.send(JSON.stringify(start));
for (let offset = 0; offset < data.byteLength; offset += maxCodeWebSocketFrameChunkBytes) {
const body: CodeWebSocketFrameBody = {
type: "ws_body",
id: message.id,
chunkID,
body: bytesToBase64(data.slice(offset, offset + maxCodeWebSocketFrameChunkBytes)),
};
agent.send(JSON.stringify(body));
}
const end: CodeWebSocketFrameEnd = { type: "ws_end", id: message.id, chunkID };
agent.send(JSON.stringify(end));
}
private sendCodeDataToViewer(message: CodeWebSocketData): void {
const viewer = this.codeViewers.get(message.id);
if (viewer?.readyState !== WebSocket.OPEN) {
return;
}
const data = base64ToBytes(message.body);
viewer.send(message.frame === "text" ? textDecoder.decode(data) : data);
}
private async handleCodeAgentMessage(leaseID: string, rawData: unknown): Promise<void> {
const raw = await normalizeWebVNCData(rawData);
const text = typeof raw === "string" ? raw : textDecoder.decode(raw);
let message:
| CodeProxyResponse
| CodeWebSocketData
| CodeWebSocketFrameStart
| CodeWebSocketFrameBody
| CodeWebSocketFrameEnd
| CodeWebSocketClose
| { type?: string; id?: string; error?: string };
try {
message = JSON.parse(text);
} catch {
return;
}
if (message.type === "http" && message.id) {
const pending = this.pendingCodeRequests.get(message.id);
if (!pending) {
return;
}
clearTimeout(pending.timeout);
this.pendingCodeRequests.delete(message.id);
pending.resolve(message as CodeProxyResponse);
return;
}
if (message.type === "http_start" && message.id) {
const pending = this.pendingCodeRequests.get(message.id);
if (!pending) {
return;
}
pending.response = { ...(message as CodeProxyResponse), type: "http", body: "" };
pending.chunks = [];
return;
}
if (message.type === "http_body" && message.id) {
const pending = this.pendingCodeRequests.get(message.id);
if (!pending) {
return;
}
pending.chunks.push((message as CodeProxyResponse).body ?? "");
return;
}
if (message.type === "http_end" && message.id) {
const pending = this.pendingCodeRequests.get(message.id);
if (!pending) {
return;
}
clearTimeout(pending.timeout);
this.pendingCodeRequests.delete(message.id);
pending.resolve({
...(pending.response ?? { type: "http", id: message.id, status: 502 }),
body: pending.chunks.join(""),
});
return;
}
if (message.type === "ws_data" && message.id) {
this.sendCodeDataToViewer(message as CodeWebSocketData);
return;
}
if (message.type === "ws_start" && message.id) {
const start = message as CodeWebSocketFrameStart;
this.pendingCodeFrames.set(start.chunkID, {
id: start.id,
frame: start.frame ?? "binary",
chunks: [],
});
return;
}
if (message.type === "ws_body") {
const body = message as CodeWebSocketFrameBody;
const pending = this.pendingCodeFrames.get(body.chunkID);
if (pending) {
pending.chunks.push(body.body);
}
return;
}
if (message.type === "ws_end") {
const end = message as CodeWebSocketFrameEnd;
const pending = this.pendingCodeFrames.get(end.chunkID);
this.pendingCodeFrames.delete(end.chunkID);
if (pending) {
this.sendCodeDataToViewer({
type: "ws_data",
id: pending.id,
frame: pending.frame,
body: pending.chunks.join(""),
});
}
return;
}
if (message.type === "ws_close" && message.id) {
const viewer = this.codeViewers.get(message.id);
this.codeViewers.delete(message.id);
closeSocket(
viewer,
(message as CodeWebSocketClose).code ?? 1000,
(message as CodeWebSocketClose).reason ?? "code socket closed",
);
return;
}
void leaseID;
}
private clearCodeAgent(leaseID: string, socket: WebSocket): void {
if (this.codeAgents.get(leaseID) !== socket) {
return;
}
this.codeAgents.delete(leaseID);
this.clearCodeLease(leaseID);
}
private clearCodeViewer(
leaseID: string,
id: string,
socket: WebSocket,
code = 1000,
reason = "viewer closed",
): void {
if (this.codeViewers.get(id) !== socket) {
return;
}
this.codeViewers.delete(id);
const agent = this.codeAgents.get(leaseID);
const message: CodeWebSocketClose = { type: "ws_close", id, code, reason };
if (agent?.readyState === WebSocket.OPEN) {
agent.send(JSON.stringify(message));
}
}
private clearCodeLease(_leaseID: string): void {
for (const [id, viewer] of this.codeViewers) {
this.codeViewers.delete(id);
closeSocket(viewer, 1011, "code bridge disconnected");
}
for (const [id, pending] of this.pendingCodeRequests) {
clearTimeout(pending.timeout);
this.pendingCodeRequests.delete(id);
pending.resolve({ type: "http", id, status: 502, error: "code bridge disconnected" });
}
this.pendingCodeFrames.clear();
}
private async webVNCViewer(request: Request, identifier: string): Promise<Response> {
if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
return json(
@ -588,15 +1167,10 @@ export class FleetDurableObject implements DurableObject {
const pair = new WebSocketPair();
const client = pair[0];
const viewer = pair[1];
viewer.accept();
this.webVNCViewers.set(lease.id, viewer);
this.acceptBridgeWebSocket(viewer, { kind: "webvnc-viewer", leaseID: lease.id });
flushPendingWebVNC(this.pendingWebVNCToViewer, lease.id, viewer);
viewer.addEventListener("message", (event) => {
forwardWebVNC(event.data, this.webVNCAgents.get(lease.id));
});
viewer.addEventListener("close", () => this.clearWebVNCViewer(lease.id, viewer));
viewer.addEventListener("error", () => this.clearWebVNCViewer(lease.id, viewer));
return new Response(null, { status: 101, webSocket: client });
}
@ -653,6 +1227,35 @@ export class FleetDurableObject implements DurableObject {
);
}
private async consumeCodeTicket(request: Request): Promise<CodeTicketRecord | undefined> {
const value = new URL(request.url).searchParams.get("ticket") ?? "";
if (!validCodeTicket(value)) {
return undefined;
}
const key = codeTicketKey(value);
const ticket = await this.state.storage.get<CodeTicketRecord>(key);
if (!ticket || ticket.ticket !== value) {
return undefined;
}
await this.state.storage.delete(key);
if (Date.parse(ticket.expiresAt) <= Date.now()) {
return undefined;
}
return ticket;
}
private async cleanupExpiredCodeTickets(): Promise<void> {
const tickets = await this.state.storage.list<CodeTicketRecord>({
prefix: codeTicketPrefix(),
});
const now = Date.now();
await Promise.all(
[...tickets.entries()]
.filter(([, ticket]) => Date.parse(ticket.expiresAt) <= now)
.map(([key]) => this.state.storage.delete(key)),
);
}
private async pool(request: Request): Promise<Response> {
const url = new URL(request.url);
const provider = url.searchParams.get("provider");
@ -1187,6 +1790,14 @@ function webVNCTicketKey(ticket: string): string {
return `${webVNCTicketPrefix()}${ticket}`;
}
function codeTicketPrefix(): string {
return "code-ticket:";
}
function codeTicketKey(ticket: string): string {
return `${codeTicketPrefix()}${ticket}`;
}
function newLeaseID(): string {
const bytes = new Uint8Array(6);
crypto.getRandomValues(bytes);
@ -1205,6 +1816,12 @@ function newWebVNCTicket(): string {
return `wvnc_${[...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
}
function newCodeTicket(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return `code_${[...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
}
function validLeaseID(value: string | undefined): value is string {
return typeof value === "string" && /^cbx_[a-f0-9]{12}$/.test(value);
}
@ -1213,6 +1830,10 @@ function validWebVNCTicket(value: string | undefined): value is string {
return typeof value === "string" && /^wvnc_[a-f0-9]{32}$/.test(value);
}
function validCodeTicket(value: string | undefined): value is string {
return typeof value === "string" && /^code_[a-f0-9]{32}$/.test(value);
}
function validImageID(value: string | undefined): value is string {
return typeof value === "string" && /^ami-[a-f0-9]{8,32}$/.test(value);
}
@ -1314,6 +1935,135 @@ function webVNCLeaseError(lease: LeaseRecord): string {
return "";
}
function codeLeaseError(lease: LeaseRecord): string {
if (lease.state !== "active") {
return "lease is not active";
}
if (!lease.code) {
return "lease was not created with code=true";
}
if (lease.target && lease.target !== "linux") {
return "code is currently available for Linux leases only";
}
if (!lease.host) {
return "lease has no reachable host yet";
}
return "";
}
export function codeForwardHeaders(headers: Headers): Record<string, string> {
const out: Record<string, string> = {};
const allowed = new Set([
"accept",
"accept-language",
"cache-control",
"content-type",
"origin",
"pragma",
"sec-websocket-protocol",
"user-agent",
]);
for (const [key, value] of headers) {
const lower = key.toLowerCase();
if (allowed.has(lower) || lower.startsWith("x-")) {
out[lower] = value;
} else if (lower === "cookie") {
const cookie = codeForwardCookie(value);
if (cookie) {
out["cookie"] = cookie;
}
}
}
return out;
}
function codeForwardCookie(value: string): string | undefined {
const tokens = value
.split(";")
.map((part) => part.trim())
.filter((part) => part.startsWith("vscode-tkn="));
return tokens.length > 0 ? tokens.join("; ") : undefined;
}
const codePortalContentSecurityPolicy = [
"default-src 'self'",
"base-uri 'self'",
"child-src 'self' blob:",
"connect-src 'self' ws: wss: https:",
"font-src 'self' data: blob:",
"frame-src 'self' https://*.vscode-cdn.net data:",
"img-src 'self' https: data: blob:",
"manifest-src 'self'",
"media-src 'self'",
"object-src 'none'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: https://static.cloudflareinsights.com",
"style-src 'self' 'unsafe-inline'",
"worker-src 'self' data: blob:",
].join("; ");
export function codeResponseHeaders(values: Record<string, string>): Headers {
const headers = new Headers();
for (const [key, value] of Object.entries(values)) {
const lower = key.toLowerCase();
if (
lower === "connection" ||
lower === "content-security-policy" ||
lower === "content-encoding" ||
lower === "content-length" ||
lower === "transfer-encoding" ||
lower === "upgrade"
) {
continue;
}
headers.set(key, value);
}
if ((headers.get("content-type") || "").toLowerCase().startsWith("text/html")) {
headers.set("cache-control", "no-store, no-transform");
}
headers.set("content-security-policy", codePortalContentSecurityPolicy);
return headers;
}
function bridgeAttachment(socket: WebSocket): BridgeAttachment | undefined {
const attachment = socket.deserializeAttachment?.() as BridgeAttachment | undefined;
if (!attachment || typeof attachment !== "object") {
return undefined;
}
switch (attachment.kind) {
case "webvnc-agent":
case "webvnc-viewer":
case "code-agent":
return typeof attachment.leaseID === "string" ? attachment : undefined;
case "code-viewer":
return typeof attachment.leaseID === "string" && typeof attachment.id === "string"
? attachment
: undefined;
default:
return undefined;
}
}
function bridgeTags(attachment: BridgeAttachment): string[] {
return [`lease:${attachment.leaseID}`, attachment.kind];
}
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
function base64ToBytes(value: string): Uint8Array {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
function identifierMatchesLease(identifier: string, lease: LeaseRecord): boolean {
return (
identifier === lease.id || normalizeLeaseSlug(identifier) === normalizeLeaseSlug(lease.slug)
@ -1325,12 +2075,13 @@ export interface WebVNCBuffer {
bytes: number;
}
export function forwardOrBufferWebVNC(
data: string | ArrayBuffer,
export async function forwardOrBufferWebVNC(
rawData: unknown,
socket: WebSocket | undefined,
buffers: Map<string, WebVNCBuffer>,
leaseID: string,
): void {
): Promise<void> {
const data = await normalizeWebVNCData(rawData);
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(data);
return;
@ -1373,13 +2124,24 @@ export function resetWebVNCBridge(
buffers.delete(leaseID);
}
function forwardWebVNC(data: string | ArrayBuffer, socket: WebSocket | undefined): void {
async function forwardWebVNC(rawData: unknown, socket: WebSocket | undefined): Promise<void> {
if (!socket || socket.readyState !== WebSocket.OPEN) {
return;
}
const data = await normalizeWebVNCData(rawData);
socket.send(data);
}
async function normalizeWebVNCData(data: unknown): Promise<string | ArrayBuffer> {
if (typeof data === "string" || data instanceof ArrayBuffer) {
return data;
}
if (data instanceof Blob) {
return await data.arrayBuffer();
}
return String(data);
}
function webVNCDataBytes(data: string | ArrayBuffer): number {
return typeof data === "string" ? textEncoder.encode(data).byteLength : data.byteLength;
}

View File

@ -26,7 +26,7 @@ export default {
const id = env.FLEET.idFromName("default");
return env.FLEET.get(id).fetch(request);
}
if (isWebVNCAgentUpgrade(request, url)) {
if (isWebVNCAgentUpgrade(request, url) || isCodeAgentUpgrade(request, url)) {
const id = env.FLEET.idFromName("default");
return env.FLEET.get(id).fetch(request);
}
@ -72,6 +72,14 @@ function isWebVNCAgentUpgrade(request: Request, url: URL): boolean {
);
}
function isCodeAgentUpgrade(request: Request, url: URL): boolean {
return (
request.method === "GET" &&
request.headers.get("upgrade")?.toLowerCase() === "websocket" &&
/^\/v1\/leases\/[^/]+\/code\/agent$/.test(url.pathname)
);
}
function canonicalPortalRedirect(request: Request, env: Env, url: URL): Response | undefined {
if (
request.method !== "GET" ||

View File

@ -29,7 +29,7 @@ export function portalHome(leases: LeaseRecord[], request: Request): Response {
<th>provider</th>
<th>target</th>
<th>class</th>
<th>desktop</th>
<th>access</th>
<th>expires</th>
<th></th>
</tr>
@ -43,41 +43,42 @@ export function portalHome(leases: LeaseRecord[], request: Request): Response {
export function portalVNC(lease: LeaseRecord): Response {
const nonce = scriptNonce();
const title = `WebVNC ${lease.slug || lease.id}`;
const slug = lease.slug || lease.id;
const title = `WebVNC ${slug}`;
const wsPath = `/portal/leases/${encodeURIComponent(lease.id)}/vnc/viewer`;
const statusPath = `/portal/leases/${encodeURIComponent(lease.id)}/vnc/status`;
const bridgeCmd = webVNCBridgeCommand(lease);
const fullscreenIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 9V4h5"/><path d="M20 9V4h-5"/><path d="M4 15v5h5"/><path d="M20 15v5h-5"/></svg>`;
const copyIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>`;
const reconnectIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 4v5h-5"/></svg>`;
return html(
title,
`<main class="vnc-page">
<header class="top">
<div>
<h1>${escapeHTML(lease.slug || lease.id)}</h1>
<p>${escapeHTML(lease.provider)} / ${escapeHTML(lease.target)} / ${escapeHTML(lease.id)}</p>
<header class="vnc-bar">
<div class="vnc-meta">
<h1>${escapeHTML(slug)}</h1>
<p><span>${escapeHTML(lease.provider)}</span><span class="vnc-dot"></span><span>${escapeHTML(lease.target)}</span><span class="vnc-dot"></span><span class="vnc-id">${escapeHTML(lease.id)}</span></p>
</div>
<nav>
<div class="vnc-actions">
<span id="status" class="status-pill">waiting for bridge</span>
<button id="vnc-reconnect" class="icon-btn" type="button" title="reconnect" aria-label="reconnect">${reconnectIcon}</button>
<button id="vnc-fullscreen" class="icon-btn" type="button" title="fullscreen" aria-label="toggle fullscreen">${fullscreenIcon}</button>
<a class="button secondary" href="/portal">leases</a>
<a class="button secondary" href="/portal/logout">log out</a>
</nav>
</header>
<section id="status" class="status">checking bridge</section>
<section id="screen" class="screen" aria-label="WebVNC display"></section>
<section class="panel commands">
<h2>bridge</h2>
<p>run this locally while the browser tab is open:</p>
<div class="command-row">
<code id="bridgeCommand">${escapeHTML(bridgeCmd)}</code>
<button id="copyBridge" class="button secondary" type="button">copy</button>
</div>
</section>
</header>
<section id="screen" class="screen" aria-label="WebVNC display"></section>
<footer class="vnc-bridge">
<span class="vnc-bridge-label">bridge</span>
<code id="vnc-bridge-cmd" class="vnc-bridge-cmd">${escapeHTML(bridgeCmd)}</code>
<button id="vnc-copy" class="icon-btn" type="button" title="copy command" aria-label="copy bridge command">${copyIcon}</button>
</footer>
</main>
<script type="module" nonce="${nonce}">
import RFBModule from ${JSON.stringify(novncModuleURL)};
const RFB = RFBModule.default || RFBModule;
const status = document.getElementById("status");
const screen = document.getElementById("screen");
const copyBridge = document.getElementById("copyBridge");
const bridgeCommand = document.getElementById("bridgeCommand")?.textContent || "";
const wsURL = new URL(${JSON.stringify(wsPath)}, window.location.href);
wsURL.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const statusURL = new URL(${JSON.stringify(statusPath)}, window.location.href);
@ -163,21 +164,47 @@ export function portalVNC(lease: LeaseRecord): Response {
scheduleRetry(error instanceof Error ? error.message : String(error));
}
}
copyBridge?.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(bridgeCommand);
copyBridge.textContent = "copied";
window.setTimeout(() => { copyBridge.textContent = "copy"; }, 1400);
} catch {
copyBridge.textContent = "failed";
window.setTimeout(() => { copyBridge.textContent = "copy"; }, 1400);
}
});
window.addEventListener("beforeunload", () => {
stopped = true;
window.clearTimeout(retryTimer);
rfb?.disconnect();
});
const reconnectBtn = document.getElementById("vnc-reconnect");
reconnectBtn?.addEventListener("click", () => {
window.clearTimeout(retryTimer);
retryAttempt = 0;
stopped = false;
try { rfb?.disconnect(); } catch (_) {}
connect();
});
const fullscreenBtn = document.getElementById("vnc-fullscreen");
fullscreenBtn?.addEventListener("click", () => {
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
document.documentElement.requestFullscreen?.().catch(() => {});
}
});
const copyBtn = document.getElementById("vnc-copy");
const cmdEl = document.getElementById("vnc-bridge-cmd");
let copyResetTimer;
copyBtn?.addEventListener("click", async () => {
const text = cmdEl?.textContent || "";
try {
await navigator.clipboard.writeText(text);
} catch (_) {
const range = document.createRange();
if (cmdEl) {
range.selectNodeContents(cmdEl);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
}
copyBtn.dataset.state = "ok";
window.clearTimeout(copyResetTimer);
copyResetTimer = window.setTimeout(() => { delete copyBtn.dataset.state; }, 1200);
});
connect();
</script>`,
200,
@ -199,6 +226,31 @@ export function portalError(title: string, message: string, status = 400): Respo
);
}
export function portalCode(lease: LeaseRecord): Response {
const slug = lease.slug || lease.id;
const bridgeCmd = `crabbox code --id ${slug} --open`;
return html(
`Code ${slug}`,
`<main>
<header class="top">
<div>
<h1>${escapeHTML(slug)}</h1>
<p>${escapeHTML(lease.provider)} code workspace</p>
</div>
<div class="vnc-actions">
<a class="button secondary" href="/portal">leases</a>
<a class="button secondary" href="/portal/logout">log out</a>
</div>
</header>
<section class="panel error">
<h2>code bridge</h2>
<p class="muted">start the local bridge, then reload this page.</p>
<code>${escapeHTML(bridgeCmd)}</code>
</section>
</main>`,
);
}
export function webVNCBridgeCommand(lease: LeaseRecord): string {
const target = lease.target || "linux";
const args = [
@ -230,14 +282,17 @@ function leaseRow(lease: LeaseRecord): string {
const vnc = lease.desktop
? `<a class="button" href="/portal/leases/${encodeURIComponent(lease.id)}/vnc">open</a>`
: `<span class="muted">no desktop</span>`;
const code = lease.code
? `<a class="button secondary" href="/portal/leases/${encodeURIComponent(lease.id)}/code/">code</a>`
: `<span class="muted">no code</span>`;
return `<tr>
<td><strong>${escapeHTML(label)}</strong><small>${escapeHTML(lease.id)}</small></td>
<td>${escapeHTML(lease.provider)}</td>
<td>${escapeHTML(lease.target)}</td>
<td>${escapeHTML(lease.class)}</td>
<td>${lease.desktop ? "yes" : "no"}</td>
<td><div class="actions-cell">${vnc}${code}</div></td>
<td>${escapeHTML(shortTime(lease.expiresAt))}</td>
<td>${vnc}</td>
<td></td>
</tr>`;
}
@ -249,17 +304,20 @@ function html(title: string, body: string, status = 200, nonce = ""): Response {
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#0b0d0f">
<title>${escapeHTML(title)}</title>
<style>
:root { color-scheme: dark; --bg:#111315; --fg:#f3f5f7; --muted:#9ca3af; --line:#30363d; --panel:#1b1f23; --accent:#38bdf8; --bad:#f87171; --warn:#fbbf24; --ok:#34d399; }
:root { color-scheme: dark; --bg:#0b0d0f; --fg:#f3f5f7; --muted:#9ca3af; --line:#262b31; --line-soft:#1d2126; --panel:#15181c; --panel-2:#0f1215; --accent:#38bdf8; --bad:#f87171; --warn:#fbbf24; --ok:#34d399; --mono: ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
* { box-sizing: border-box; }
html { background:var(--bg); }
body { margin:0; min-height:100vh; background:var(--bg); color:var(--fg); font:14px/1.45 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
main { width:min(1180px, calc(100vw - 32px)); margin:0 auto; padding:24px 0; }
h1,h2,p { margin:0; }
h1 { font-size:22px; font-weight:700; }
h2 { font-size:14px; text-transform:uppercase; color:var(--muted); }
a { color:inherit; }
code { display:block; overflow:auto; padding:12px; border:1px solid var(--line); border-radius:6px; background:#0c0e10; color:#d1fae5; }
code { display:block; overflow:auto; padding:12px; border:1px solid var(--line); border-radius:6px; background:#0c0e10; color:#d1fae5; font-family:var(--mono); }
table { width:100%; border-collapse:collapse; table-layout:fixed; }
th,td { padding:12px; border-bottom:1px solid var(--line); text-align:left; vertical-align:middle; }
th { color:var(--muted); font-weight:600; }
@ -268,20 +326,47 @@ function html(title: string, body: string, status = 200, nonce = ""): Response {
.top p,.muted,.empty { color:var(--muted); }
.panel { border:1px solid var(--line); border-radius:8px; background:var(--panel); overflow:hidden; }
.section-head { display:flex; justify-content:space-between; align-items:center; padding:14px 16px; border-bottom:1px solid var(--line); }
.button { display:inline-flex; align-items:center; justify-content:center; min-height:32px; padding:0 12px; border-radius:6px; background:var(--accent); color:#001018; text-decoration:none; font-weight:700; }
.button.secondary { background:transparent; color:var(--fg); border:1px solid var(--line); }
.vnc-page { width:100vw; height:100vh; padding:12px; display:grid; grid-template-rows:auto auto 1fr auto; gap:10px; }
.screen { min-height:0; border:1px solid var(--line); border-radius:8px; background:#050607; overflow:hidden; }
.button { display:inline-flex; align-items:center; justify-content:center; min-height:32px; padding:0 12px; border-radius:8px; background:var(--accent); color:#001018; text-decoration:none; font-weight:700; }
.button.secondary { background:transparent; color:var(--fg); border:1px solid var(--line); font-weight:500; }
.button.secondary:hover { background:#1b1f24; border-color:#3a4046; }
.actions-cell { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.vnc-page { width:100vw; height:100vh; padding:10px 12px 10px; display:grid; grid-template-rows:auto 1fr auto; gap:10px; }
.vnc-bar { display:flex; align-items:center; justify-content:space-between; gap:16px; min-height:44px; padding:0 4px; }
.vnc-meta { display:flex; align-items:baseline; gap:12px; min-width:0; }
.vnc-meta h1 { font-size:18px; font-weight:700; letter-spacing:-0.01em; white-space:nowrap; }
.vnc-meta p { display:inline-flex; align-items:center; gap:8px; color:var(--muted); font-size:12px; min-width:0; overflow:hidden; }
.vnc-meta .vnc-id { font-family:var(--mono); font-size:11px; opacity:0.85; }
.vnc-meta .vnc-dot { width:3px; height:3px; border-radius:50%; background:#3a4046; flex-shrink:0; }
.vnc-actions { display:flex; align-items:center; gap:8px; flex-shrink:0; }
.status-pill { display:inline-flex; align-items:center; gap:8px; height:32px; padding:0 12px 0 11px; border-radius:8px; background:var(--panel-2); border:1px solid var(--line); font-size:12px; color:var(--muted); white-space:nowrap; transition:color 0.2s, border-color 0.2s; }
.status-pill::before { content:""; width:8px; height:8px; border-radius:50%; background:currentColor; box-shadow:0 0 0 3px color-mix(in srgb, currentColor 18%, transparent); flex-shrink:0; }
.status-pill[data-tone="ok"] { color:var(--ok); border-color:color-mix(in srgb, var(--ok) 35%, var(--line)); }
.status-pill[data-tone="warn"] { color:var(--warn); border-color:color-mix(in srgb, var(--warn) 35%, var(--line)); }
.status-pill[data-tone="bad"] { color:var(--bad); border-color:color-mix(in srgb, var(--bad) 45%, var(--line)); }
.icon-btn { display:inline-flex; align-items:center; justify-content:center; width:32px; height:32px; padding:0; border-radius:8px; background:transparent; color:var(--fg); border:1px solid var(--line); cursor:pointer; transition:background 0.15s, border-color 0.15s, color 0.15s; }
.icon-btn:hover { background:#1b1f24; border-color:#3a4046; }
.icon-btn:active { background:#22272d; }
.icon-btn[data-state="ok"] { color:var(--ok); border-color:color-mix(in srgb, var(--ok) 45%, var(--line)); }
.screen { min-height:0; border:1px solid var(--line); border-radius:8px; background:var(--bg); overflow:hidden; box-shadow:inset 0 0 0 1px rgba(255,255,255,0.02); }
.screen div { margin:0 auto; }
.status { border:1px solid var(--line); border-radius:6px; padding:8px 10px; color:var(--muted); }
.status[data-tone="ok"] { color:var(--ok); }
.status[data-tone="warn"] { color:var(--warn); }
.status[data-tone="bad"] { color:var(--bad); }
.vnc-bridge { display:flex; align-items:center; gap:10px; padding:6px 10px; border:1px solid var(--line); border-radius:8px; background:var(--panel); }
.vnc-bridge-label { font-size:10px; text-transform:uppercase; letter-spacing:0.08em; color:var(--muted); flex-shrink:0; padding-left:4px; }
.vnc-bridge-cmd { display:block; flex:1; min-width:0; padding:6px 10px; border:none; border-radius:5px; background:transparent; color:#d1fae5; font-family:var(--mono); font-size:13px; overflow-x:auto; white-space:nowrap; }
.commands { padding:12px; display:grid; gap:8px; }
.command-row { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:stretch; }
.command-row code { min-width:0; }
.error { margin-top:20vh; padding:24px; display:grid; gap:12px; }
@media (max-width: 760px) { main { width:min(100vw - 20px, 1180px); padding:10px 0; } th:nth-child(4),td:nth-child(4),th:nth-child(6),td:nth-child(6){ display:none; } .top{align-items:flex-start;} }
@media (max-width: 760px) {
main { width:min(100vw - 20px, 1180px); padding:10px 0; }
th:nth-child(4),td:nth-child(4),th:nth-child(6),td:nth-child(6){ display:none; }
.top{align-items:flex-start;}
.vnc-bar { flex-wrap:wrap; gap:8px; min-height:0; padding:4px 0; }
.vnc-meta { flex-wrap:wrap; gap:4px 10px; }
.vnc-meta p .vnc-id { display:none; }
.vnc-actions { gap:6px; }
.vnc-actions .button { min-height:30px; padding:0 10px; }
.vnc-bridge-label { display:none; }
}
</style>
</head>
<body>${body}</body>

View File

@ -50,6 +50,7 @@ export interface LeaseRequest {
windowsMode?: WindowsMode;
desktop?: boolean;
browser?: boolean;
code?: boolean;
tailscale?: boolean;
tailscaleTags?: string[];
tailscaleHostname?: string;
@ -97,6 +98,7 @@ export interface LeaseRecord {
windowsMode?: WindowsMode;
desktop?: boolean;
browser?: boolean;
code?: boolean;
tailscale?: TailscaleMetadata;
cloudID: string;
region?: string;

View File

@ -9,6 +9,7 @@ const config: LeaseConfig = {
windowsMode: "normal",
desktop: false,
browser: false,
code: false,
tailscale: false,
tailscaleTags: ["tag:crabbox"],
tailscaleHostname: "",
@ -129,6 +130,17 @@ describe("cloud-init bootstrap", () => {
expect(got).not.toContain("\nEOF");
});
it("adds code-server setup only when requested", () => {
const plain = cloudInit(config);
expect(plain).not.toContain("code-server");
const got = cloudInit({ ...config, code: true });
expect(got).toContain("https://code-server.dev/install.sh");
expect(got).toContain("env HOME=/root");
expect(got).toContain("--method=standalone --prefix=/usr/local");
expect(got).toContain("/usr/local/bin/code-server --version >/dev/null");
expect(got).toContain("test -x /usr/local/bin/code-server");
});
it("adds Tailscale setup only when requested", () => {
const plain = cloudInit(config);
expect(plain).not.toContain("tailscale up");

View File

@ -154,17 +154,20 @@ describe("lease config", () => {
expect(config.capacityStrategy).toBe("most-available");
expect(config.desktop).toBe(false);
expect(config.browser).toBe(false);
expect(config.code).toBe(false);
expect(config.ttlSeconds).toBe(86_400);
});
it("preserves requested desktop and browser capabilities", () => {
it("preserves requested desktop, browser, and code capabilities", () => {
const config = leaseConfig({
sshPublicKey: "ssh-ed25519 test",
desktop: true,
browser: true,
code: true,
});
expect(config.desktop).toBe(true);
expect(config.browser).toBe(true);
expect(config.code).toBe(true);
});
it("preserves Tailscale lease capability requests", () => {

View File

@ -2,6 +2,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import {
FleetDurableObject,
codeForwardHeaders,
codeResponseHeaders,
flushPendingWebVNC,
forwardOrBufferWebVNC,
resetWebVNCBridge,
@ -473,6 +475,7 @@ describe("fleet lease identity and idle", () => {
owner: "peter@example.com",
org: "openclaw",
desktop: true,
code: true,
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
}),
);
@ -499,9 +502,114 @@ describe("fleet lease identity and idle", () => {
const body = await response.text();
expect(body).toContain("blue-lobster");
expect(body).toContain("/portal/leases/cbx_000000000001/vnc");
expect(body).toContain("/portal/leases/cbx_000000000001/code/");
expect(body).not.toContain("amber-krill");
});
it("serves code pages only for code leases and requires a bridge ticket", async () => {
const storage = new MemoryStorage();
const fleet = testFleet(storage);
const headers = {
"x-crabbox-owner": "peter@example.com",
"x-crabbox-org": "openclaw",
};
storage.seed(
"lease:cbx_000000000001",
testLease({
id: "cbx_000000000001",
slug: "blue-lobster",
owner: "peter@example.com",
org: "openclaw",
code: true,
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
}),
);
storage.seed(
"lease:cbx_000000000002",
testLease({
id: "cbx_000000000002",
slug: "plain-lobster",
owner: "peter@example.com",
org: "openclaw",
code: false,
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
}),
);
const page = await fleet.fetch(
request("GET", "/portal/leases/blue-lobster/code/", { headers }),
);
expect(page.status).toBe(200);
const pageBody = await page.text();
expect(pageBody).toContain("crabbox code --id blue-lobster --open");
const health = await fleet.fetch(
request("GET", "/portal/leases/blue-lobster/code/health", { headers }),
);
expect(health.status).toBe(200);
const healthBody = (await health.json()) as {
lease: { id: string; code: boolean };
code: { agentConnected: boolean };
};
expect(healthBody.lease).toMatchObject({ id: "cbx_000000000001", code: true });
expect(healthBody.code.agentConnected).toBe(false);
const plain = await fleet.fetch(
request("GET", "/portal/leases/plain-lobster/code/", { headers }),
);
expect(plain.status).toBe(409);
const ticket = await fleet.fetch(
request("POST", "/v1/leases/blue-lobster/code/ticket", { headers, body: {} }),
);
expect(ticket.status).toBe(200);
const ticketBody = (await ticket.json()) as { ticket: string; leaseID: string };
expect(ticketBody.ticket).toMatch(/^code_[a-f0-9]{32}$/);
expect(ticketBody.leaseID).toBe("cbx_000000000001");
const agent = await fleet.fetch(
request("GET", "/v1/leases/blue-lobster/code/agent", { headers }),
);
expect(agent.status).toBe(426);
const missingTicket = await fleet.fetch(
request("GET", "/v1/leases/blue-lobster/code/agent", {
headers: { upgrade: "websocket" },
}),
);
expect(missingTicket.status).toBe(401);
});
it("uses a VS Code-compatible CSP for code proxy responses", () => {
const headers = codeResponseHeaders({
"content-security-policy": "default-src 'none'; script-src 'self'",
"content-length": "123",
"content-type": "text/html",
"cache-control": "public, max-age=31536000",
});
const csp = headers.get("content-security-policy") || "";
expect(csp).toContain("script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:");
expect(csp).toContain("https://static.cloudflareinsights.com");
expect(csp).toContain("worker-src 'self' data: blob:");
expect(headers.get("content-length")).toBeNull();
expect(headers.get("content-type")).toBe("text/html");
expect(headers.get("cache-control")).toBe("no-store, no-transform");
});
it("forwards only the VS Code token cookie to code-server", () => {
const headers = codeForwardHeaders(
new Headers({
cookie: "crabbox_session=secret; vscode-tkn=remote-token; other=value",
origin: "https://crabbox.openclaw.ai",
}),
);
expect(headers["cookie"]).toBe("vscode-tkn=remote-token");
expect(headers["cookie"]).not.toContain("crabbox_session");
expect(headers.origin).toBe("https://crabbox.openclaw.ai");
});
it("serves WebVNC pages only for desktop leases and requires an agent upgrade", async () => {
const storage = new MemoryStorage();
const fleet = testFleet(storage);
@ -542,7 +650,7 @@ describe("fleet lease identity and idle", () => {
expect(pageBody).toContain("/portal/assets/novnc/rfb.js");
expect(pageBody).toContain("function scheduleRetry");
expect(pageBody).toContain("/portal/leases/cbx_000000000001/vnc/status");
expect(pageBody).toContain("copyBridge");
expect(pageBody).toContain("vnc-copy");
expect(pageBody).toContain("no bridge connected; run the bridge command below");
expect(pageBody).toContain('fragment.get("username")');
expect(pageBody).toContain('types.includes("username")');
@ -586,7 +694,7 @@ describe("fleet lease identity and idle", () => {
expect(missingTicket.status).toBe(401);
});
it("buffers initial WebVNC bridge bytes until the viewer attaches", () => {
it("buffers initial WebVNC bridge bytes until the viewer attaches", async () => {
const buffers = new Map<string, WebVNCBuffer>();
const sent: Array<string | ArrayBuffer> = [];
const viewer = {
@ -596,7 +704,7 @@ describe("fleet lease identity and idle", () => {
},
} as WebSocket;
forwardOrBufferWebVNC("RFB 003.008\n", undefined, buffers, "cbx_000000000001");
await forwardOrBufferWebVNC("RFB 003.008\n", undefined, buffers, "cbx_000000000001");
expect(sent).toEqual([]);
expect(buffers.get("cbx_000000000001")).toMatchObject({
chunks: ["RFB 003.008\n"],
@ -608,6 +716,23 @@ describe("fleet lease identity and idle", () => {
expect(buffers.has("cbx_000000000001")).toBe(false);
});
it("converts WebVNC Blob frames before forwarding", async () => {
const buffers = new Map<string, WebVNCBuffer>();
const sent: Array<string | ArrayBuffer> = [];
const viewer = {
readyState: WebSocket.OPEN,
send(data: string | ArrayBuffer) {
sent.push(data);
},
} as WebSocket;
await forwardOrBufferWebVNC(new Blob(["RFB 003.008\n"]), viewer, buffers, "cbx_000000000001");
expect(sent).toHaveLength(1);
expect(new TextDecoder().decode(sent[0] as ArrayBuffer)).toBe("RFB 003.008\n");
expect(buffers.has("cbx_000000000001")).toBe(false);
});
it("resets the WebVNC bridge when the viewer goes away", () => {
const buffers = new Map<string, WebVNCBuffer>();
buffers.set("cbx_000000000001", { chunks: ["RFB 003.008\n"], bytes: 12 });