Merge pull request #30 from openclaw/work/reapply-main-work-20260504233337
feat: stage desktop and WebVNC updates
This commit is contained in:
commit
31c95eb7bf
@ -55,4 +55,4 @@ brews:
|
||||
install: |
|
||||
bin.install "crabbox"
|
||||
test: |
|
||||
system "#{bin}/crabbox", "--version"
|
||||
system bin/"crabbox", "--version"
|
||||
|
||||
10
CHANGELOG.md
10
CHANGELOG.md
@ -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.
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
97
docs/commands/code.md
Normal 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.
|
||||
@ -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.
|
||||
|
||||
@ -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:
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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
730
internal/cli/code.go
Normal 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
189
internal/cli/code_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)))
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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" {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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, "-"))
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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); "
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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":
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
|
||||
@ -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 ?? "",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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" ||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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 });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user