feat: add Daytona and Islo providers

This commit is contained in:
Peter Steinberger 2026-05-06 07:52:15 +01:00
parent 6ba12e4872
commit e0a85bc780
No known key found for this signature in database
42 changed files with 2781 additions and 103 deletions

View File

@ -12,6 +12,8 @@
- Added `.crabboxignore` for repo-local sync-only exclude patterns shared by `run` and `sync-plan`.
- Documented the prebaked runner image boundary: provider-owned AMIs/snapshots hold machine capabilities while repo/runtime caches stay in QA workflows or warm leases.
- Added a provider backend registry and authoring guide so delegated and SSH-backed providers can live in provider-owned packages while core keeps command parsing, rendering, and capability validation.
- Added `provider: daytona` for Daytona sandbox leases using Daytona's SDK/toolbox for sync and command execution, with short-lived SSH access available through `crabbox ssh`.
- Added `provider: islo` for delegated Islo sandbox runs using the Islo Go SDK.
### Fixed

View File

@ -12,7 +12,7 @@ Crabbox is an open-source remote testbox runner for maintainers and AI agents. L
crabbox run -- pnpm test
```
Behind that single command: a Go CLI on your laptop, a Cloudflare Worker broker that owns provider credentials and lease state, and a managed runner on Hetzner Cloud or AWS EC2. Crabbox can also wrap Blacksmith Testboxes when you choose `provider: blacksmith-testbox`, or use `provider: ssh` for existing macOS and Windows targets.
Behind that single command: a Go CLI on your laptop, a Cloudflare Worker broker that owns provider credentials and lease state, and a managed runner on Hetzner Cloud or AWS EC2. Crabbox can also wrap Blacksmith Testboxes when you choose `provider: blacksmith-testbox`, use Daytona or Islo sandboxes for direct-provider workflows, or use `provider: ssh` for existing macOS and Windows targets.
---
@ -76,6 +76,7 @@ For the full mental model, see [How Crabbox Works](docs/how-it-works.md). For th
- **Brokered cloud.** Maintainers and agents share infra without sharing provider tokens. Hetzner and AWS EC2 are first-class managed providers; AWS also owns managed Windows and EC2 Mac targets. Linux defaults to Spot unless capacity config says otherwise. Providers fall back across compatible instance families when capacity or quota rejects a request.
- **macOS and Windows static hosts.** `provider: ssh` reuses existing machines; it does not create macOS or Windows Crabbox boxes. macOS and Windows WSL2 use the POSIX rsync path; native Windows uses PowerShell plus tar archive sync.
- **Blacksmith Testbox wrapper.** Set `provider: blacksmith-testbox` to delegate warmup/run/list/status/stop to the Blacksmith CLI while Crabbox keeps local slugs, repo claims, timing summaries, and config conventions.
- **Daytona and Islo sandboxes.** Set `provider: daytona` for Daytona SDK/toolbox execution from a snapshot with explicit SSH access when needed, or `provider: islo` for delegated Islo sandbox execution through the Islo Go SDK.
- **Trusted AWS images.** Operators can create AMIs from active brokered AWS leases and promote a known-good image as the coordinator default.
- **Cost guardrails.** Per-lease and monthly spend caps. Live pricing from EC2 Spot history or Hetzner server-type prices, with static fallbacks. `crabbox usage` summarizes spend by user, org, provider, and type.
- **GitHub Actions hydration.** `crabbox actions hydrate` registers a leased box as an ephemeral Actions runner, so the repo's own workflow installs runtimes, services, and secrets. Crabbox does not parse Actions YAML.
@ -155,6 +156,24 @@ blacksmith:
idleTimeout: 90m
```
Optional Daytona sandbox:
```yaml
provider: daytona
daytona:
snapshot: crabbox-ready
workRoot: /home/daytona/crabbox
```
Optional Islo sandbox:
```yaml
provider: islo
islo:
image: docker.io/library/ubuntu:24.04
workdir: crabbox
```
Optional static macOS or Windows target:
```yaml

View File

@ -78,7 +78,7 @@ Pick whichever matches your intent:
- **Get the mental model:** [How Crabbox Works](how-it-works.md), [Architecture](architecture.md), [Orchestrator](orchestrator.md).
- **Use the CLI:** [CLI](cli.md), [Commands](commands/README.md), [Features](features/README.md), [Actions hydration](features/actions-hydration.md).
- **Pick or add a target:** [Providers](features/providers.md), [Provider backends](provider-backends.md), [AWS](features/aws.md), [Hetzner](features/hetzner.md), [Blacksmith Testbox](features/blacksmith-testbox.md), [Interactive desktop and VNC](features/interactive-desktop-vnc.md).
- **Pick or add a target:** [Providers](features/providers.md), [Provider backends](provider-backends.md), [AWS](features/aws.md), [Hetzner](features/hetzner.md), [Blacksmith Testbox](features/blacksmith-testbox.md), [Daytona](features/daytona.md), [Islo](features/islo.md), [Interactive desktop and VNC](features/interactive-desktop-vnc.md).
- **Operate it:** [Operations](operations.md), [Observability](observability.md), [Troubleshooting](troubleshooting.md), [Performance](performance.md).
- **Set it up or audit it:** [Infrastructure](infrastructure.md), [Security](security.md), [Source Map](source-map.md), [MVP Plan](mvp-plan.md).

View File

@ -33,8 +33,8 @@ crabbox init [--force]
crabbox config show [--json]
crabbox config path
crabbox config set-broker --url <url> --token-stdin [--provider hetzner|aws]
crabbox warmup [--provider hetzner|aws|ssh|blacksmith-testbox] [--target linux|macos|windows] [--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 warmup [--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo] [--target linux|macos|windows] [--desktop] [--browser] [--code] [--tailscale] [--network auto|tailscale|public] [--profile <name>] [--idle-timeout <duration>] [--timing-json]
crabbox run [--id <lease-id-or-slug>] [--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo] [--target linux|macos|windows] [--windows-mode normal|wsl2] [--desktop] [--browser] [--code] [--tailscale] [--network auto|tailscale|public] [--shell] [--checksum] [--debug] [--force-sync-large] [--timing-json] [--blacksmith-workflow <workflow>] -- <command...>
crabbox desktop launch --id <lease-id-or-slug> [--browser] [--url <url>] [--webvnc] [--open] [-- <command...>]
crabbox code --id <lease-id-or-slug> [--open]
crabbox media preview --input <video> --output <preview.gif> [--trimmed-video-output <change.mp4>]
@ -247,7 +247,7 @@ Flags:
```text
--id <lease-id-or-slug> reuse an existing lease
--provider <name> hetzner, aws, ssh, or blacksmith-testbox
--provider <name> hetzner, aws, ssh, blacksmith-testbox, daytona, or islo
--target <name> linux, macos, or windows
--windows-mode <mode> normal or wsl2
--static-host <host> existing SSH host for provider=ssh
@ -290,6 +290,14 @@ Crabbox stores local lease claims under its state directory. `warmup` and first
With `provider: blacksmith-testbox`, Crabbox delegates machine setup, sync, and command transport to the Blacksmith CLI. `--sync-only` is unsupported, sync timing is reported as `sync=delegated`, and Blacksmith auth is handled by `blacksmith auth login`, not `crabbox login`.
With `provider: daytona`, Crabbox creates Daytona sandboxes from
`daytona.snapshot`, uploads workspaces through Daytona toolbox file APIs, and
runs commands through Daytona toolbox process APIs. `crabbox ssh` mints
short-lived Daytona SSH tokens and redacts those tokens from output. With
`provider: islo`, Crabbox delegates sandbox setup and command execution to the
Islo Go SDK; sync is delegated and `--sync-only`, `--checksum`, and
`--force-sync-large` are unsupported.
## Exit Codes
```text

View File

@ -7,6 +7,8 @@ crabbox list
crabbox list --provider aws
crabbox list --provider ssh --target macos --static-host mac-studio.local
crabbox list --provider blacksmith-testbox
crabbox list --provider daytona
crabbox list --provider islo
crabbox list --json
```
@ -19,10 +21,13 @@ same Crabbox list shape as other providers. `--json` keeps the compatibility
shape parsed from the Blacksmith table: id, status, repo, workflow, job, ref,
and created time when the upstream table exposes those columns.
In `daytona` and `islo` modes, rendering is core-owned: human output and `--json`
use the normalized Crabbox lease view.
Flags:
```text
--provider hetzner|aws|ssh|blacksmith-testbox
--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>

View File

@ -13,6 +13,8 @@ crabbox run --desktop --browser --shell 'echo "$DISPLAY"; "$BROWSER" --version'
crabbox run --id blue-lobster --shell 'pnpm install --frozen-lockfile && pnpm test'
crabbox run --id cbx_abcdef123456 --junit junit.xml -- go test ./...
crabbox run --provider blacksmith-testbox --blacksmith-workflow .github/workflows/ci-check-testbox.yml --blacksmith-job test -- pnpm test
crabbox run --provider daytona --daytona-snapshot crabbox-ready -- pnpm test
crabbox run --provider islo --islo-image docker.io/library/ubuntu:24.04 -- pnpm test
crabbox run --provider ssh --target macos --static-host mac-studio.local -- xcodebuild test
crabbox run --provider ssh --target windows --windows-mode normal --static-host win-dev.local -- dotnet test
crabbox run --provider ssh --target windows --windows-mode normal --static-host win-dev.local --shell 'Write-Output ("BROWSER=" + $env:BROWSER)'
@ -23,6 +25,16 @@ If `--id` is omitted, Crabbox creates a fresh non-kept lease and releases it whe
With `--provider blacksmith-testbox`, `--id` accepts a Blacksmith `tbx_...` ID or a local Crabbox slug. Crabbox forwards the command to `blacksmith testbox run`, delegates sync to Blacksmith, and prints `sync=delegated` in the final timing summary.
With `--provider daytona`, `--id` accepts a Daytona-backed Crabbox `cbx_...` ID
or local slug. Crabbox uploads the sync archive through Daytona toolbox file
APIs, extracts it in the sandbox, and runs the command through Daytona toolbox
process APIs. The final timing summary reports `sync=delegated`.
With `--provider islo`, `--id` accepts an `isb_<crabbox-sandbox-name>` lease ID,
a Crabbox-created sandbox name, or a local Crabbox slug. Islo owns sandbox
workspace setup and command execution, so sync is delegated and the final timing
summary reports `sync=delegated`.
When the lease has been hydrated by `crabbox actions hydrate`, `run` reads the remote marker under `$HOME/.crabbox/actions`, syncs into the workflow's `$GITHUB_WORKSPACE`, and sources the non-secret env file written by the workflow. That preserves the setup the workflow performed: checkout path, installed dependencies, service containers, caches, runner temp/toolcache paths, and any project-specific preparation. GitHub secrets and OIDC request tokens remain workflow-step scoped unless the project explicitly persists its own short-lived credentials.
If a configured Actions hydration workflow exists and a package-manager command such as `pnpm`, `npm`, `node`, or `corepack` is run before a hydration marker exists, Crabbox warns that the raw box may not have the project runtime installed. Hydrate first for CI-like setup, or include the runtime setup explicitly in the command.
@ -77,7 +89,7 @@ Flags:
```text
--id <lease-id-or-slug>
--provider hetzner|aws|ssh|blacksmith-testbox
--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>

View File

@ -5,16 +5,17 @@
```sh
crabbox ssh --id blue-lobster
crabbox ssh --id blue-lobster --network tailscale
crabbox ssh --provider daytona --id blue-lobster
crabbox ssh --provider ssh --target macos --static-host mac-studio.local
```
The output includes the per-lease private key path when Crabbox created one. Printing an SSH command touches coordinator leases because it signals intended manual use. In `provider=ssh` mode it resolves the configured static target.
The output includes the per-lease private key path when Crabbox created one. Printing an SSH command touches coordinator leases because it signals intended manual use. In `provider=ssh` mode it resolves the configured static target. In `provider=daytona` mode the short-lived SSH token is redacted by default; pass `--show-secret` only when you need a pasteable command in a trusted terminal.
Flags:
```text
--id <lease-id-or-slug>
--provider hetzner|aws|ssh
--provider hetzner|aws|ssh|daytona
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>
@ -23,6 +24,7 @@ Flags:
--static-work-root <path>
--network auto|tailscale|public
--reclaim
--show-secret
```
`ssh` touches the lease and validates the local repo claim. Use `--reclaim` when intentionally taking over a lease from another repo.

View File

@ -7,12 +7,17 @@ crabbox status --id blue-lobster
crabbox status --id blue-lobster --network tailscale
crabbox status --id blue-lobster --wait --wait-timeout 10m
crabbox status --id blue-lobster --json
crabbox status --provider daytona --id blue-lobster
crabbox status --provider islo --id blue-lobster
crabbox status --provider ssh --target macos --static-host mac-studio.local
```
`--id` accepts the canonical `cbx_...` ID or active slug. In
`blacksmith-testbox` mode it accepts a `tbx_...` ID or local slug and derives a
normalized Crabbox status view from `blacksmith testbox list --all`. In
`daytona` mode it resolves Crabbox labels and sandbox state through Daytona
APIs. In `islo` mode it accepts an `isb_...` ID, Crabbox-created sandbox name,
or local slug and renders SDK status through the core status view. In
`provider=ssh` mode `--id` is optional and resolves the configured static target
or local claim. Plain status is read-only; `--wait` touches the lease while
waiting for Crabbox brokered leases.
@ -21,7 +26,7 @@ Flags:
```text
--id <lease-id-or-slug>
--provider hetzner|aws|ssh|blacksmith-testbox
--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>

View File

@ -4,16 +4,18 @@
```sh
crabbox stop blue-lobster
crabbox stop --provider daytona blue-lobster
crabbox stop --provider islo blue-lobster
crabbox stop --provider ssh --static-host mac-studio.local mac-studio.local
```
`crabbox release` remains as a compatibility alias.
The argument accepts the stable `cbx_...` ID or an active friendly slug. In `blacksmith-testbox` mode it accepts a `tbx_...` ID or local slug and forwards to `blacksmith testbox stop`. In `provider=ssh` mode it removes the local claim for the configured static target; it never deletes the host.
The argument accepts the stable `cbx_...` ID or an active friendly slug. In `blacksmith-testbox` mode it accepts a `tbx_...` ID or local slug and forwards to `blacksmith testbox stop`. In `daytona` mode it deletes the Daytona sandbox. In `islo` mode it accepts an `isb_...` ID, Crabbox-created sandbox name, or local slug and deletes the Islo sandbox. In `provider=ssh` mode it removes the local claim for the configured static target; it never deletes the host.
Flags:
```text
--provider hetzner|aws|ssh|blacksmith-testbox
--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>

View File

@ -12,6 +12,8 @@ crabbox warmup --provider aws --target windows --desktop
crabbox warmup --provider aws --target macos --desktop --market on-demand --type mac2.metal
crabbox warmup --actions-runner
crabbox warmup --provider blacksmith-testbox --blacksmith-workflow .github/workflows/ci-check-testbox.yml --blacksmith-job test
crabbox warmup --provider daytona --daytona-snapshot crabbox-ready
crabbox warmup --provider islo --islo-image docker.io/library/ubuntu:24.04
crabbox warmup --provider ssh --target macos --static-host mac-studio.local
crabbox warmup --provider ssh --target windows --windows-mode normal --static-host win-dev.local --static-work-root 'C:\crabbox' --browser
```
@ -20,6 +22,15 @@ The command returns a stable `cbx_...` lease ID and a friendly slug. Reuse eithe
With `--provider blacksmith-testbox`, the canonical ID is the Blacksmith `tbx_...` ID returned by `blacksmith testbox warmup`; Crabbox still assigns and stores a local slug for reuse.
With `--provider daytona`, the canonical ID is a Crabbox `cbx_...` lease backed
by a Daytona sandbox created from `daytona.snapshot`. `run` uses Daytona
SDK/toolbox APIs; `ssh` mints short-lived Daytona SSH access tokens and redacts
them from output.
With `--provider islo`, the canonical ID is
`isb_<crabbox-sandbox-name>`. Crabbox stores a local slug, but Islo owns sandbox
setup and command execution.
With `--provider ssh`, warmup claims an existing static SSH host instead of
creating cloud capacity. Use `--target macos`, `--target windows
--windows-mode normal`, or `--target windows --windows-mode wsl2` to select the
@ -58,7 +69,7 @@ On success, `warmup` prints a concise total duration line. Add `--timing-json` t
Flags:
```text
--provider hetzner|aws|ssh|blacksmith-testbox
--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>

View File

@ -17,6 +17,8 @@ Core features:
- [AWS](aws.md): EC2 Linux, Windows, WSL2, EC2 Mac, capacity, AMIs, and security groups.
- [Hetzner](hetzner.md): Linux-only managed Hetzner behavior, classes, and cleanup.
- [Blacksmith Testbox](blacksmith-testbox.md): delegated Testbox backend behavior.
- [Daytona](daytona.md): Daytona SDK/toolbox sandbox leases with optional short-lived SSH access.
- [Islo](islo.md): delegated Islo sandbox runs using the Islo Go SDK.
- [Tailscale](tailscale.md): optional tailnet reachability for managed Linux leases and static hosts.
- [Runner bootstrap](runner-bootstrap.md): cloud-init, installed tools, SSH port, and readiness.
- [Prebaked runner images](prebaked-images.md): provider-owned image storage and the image/cache/state boundary.

75
docs/features/daytona.md Normal file
View File

@ -0,0 +1,75 @@
# Daytona
Read when:
- choosing `provider: daytona`;
- configuring Daytona auth, snapshots, or SSH access;
- reviewing Daytona provider behavior.
`provider: daytona` provisions Daytona sandboxes from snapshots. `run` and
`warmup` use Daytona's SDK/toolbox for workspace upload and command execution;
`ssh` mints a short-lived Daytona SSH token only when interactive shell access is
requested.
## Auth
Set one of:
```sh
export DAYTONA_API_KEY=...
```
or:
```sh
export DAYTONA_JWT_TOKEN=...
export DAYTONA_ORGANIZATION_ID=...
```
`DAYTONA_ORGANIZATION_ID` is required when JWT auth is used. `DAYTONA_API_URL`
or `daytona.apiUrl` can override the default `https://app.daytona.io/api`.
## Config
Daytona's first Crabbox integration is snapshot-first. The snapshot owns CPU,
memory, disk, and installed tooling. Crabbox does not expose Daytona resource
flags in this mode.
```yaml
provider: daytona
target: linux
daytona:
snapshot: crabbox-ready
target: ""
user: daytona
workRoot: /home/daytona/crabbox
sshGatewayHost: ssh.app.daytona.io # fallback when the API omits sshCommand
sshAccessMinutes: 30
```
Equivalent flags:
```sh
crabbox warmup --provider daytona --daytona-snapshot crabbox-ready
crabbox run --provider daytona --id <slug> -- pnpm test
crabbox stop --provider daytona <slug>
```
## Behavior
- `warmup` creates a Daytona sandbox from `daytona.snapshot`, waits for the
sandbox, records Crabbox labels, then prints a normal Crabbox lease ID and
slug.
- `run --id` resolves a Daytona sandbox, uploads a Crabbox manifest archive
through Daytona toolbox file APIs, extracts it in the sandbox, and executes the
command through Daytona toolbox process APIs.
- `list`, `status`, and `stop` use Daytona sandbox labels to find Crabbox-owned
sandboxes.
- `ssh` mints a fresh Daytona SSH token, parses the host and port returned by
Daytona's `sshCommand`, and redacts the token as `<token>` unless
`--show-secret` is used.
Daytona is a hybrid backend: core rendering, lease labels, sync manifests, and
repo claim checks stay Crabbox-owned, while the actual `run` transport is
Daytona SDK/toolbox. Actions runner hydration is not supported for Daytona
warmup because it requires a normal long-lived SSH runner host.

62
docs/features/islo.md Normal file
View File

@ -0,0 +1,62 @@
# Islo
Read when:
- choosing `provider: islo`;
- configuring Islo sandbox image, sizing, or gateway profile;
- reviewing delegated provider behavior.
`provider: islo` delegates sandbox setup and command execution to Islo. Crabbox
uses the Islo Go SDK for auth, sandbox lifecycle, list, status, and stop. The
SDK's current exec stream helper coalesces output, so Crabbox keeps a small SSE
reader for `POST /sandboxes/{name}/exec/stream` while still using the SDK auth
provider.
## Auth
```sh
export ISLO_API_KEY=ak_...
```
`ISLO_BASE_URL` or `islo.baseUrl` can override the default
`https://api.islo.dev`.
## Config
```yaml
provider: islo
target: linux
islo:
image: docker.io/library/ubuntu:24.04
workdir: crabbox
gatewayProfile: ""
snapshotName: ""
vcpus: 2
memoryMB: 4096
diskGB: 20
```
Equivalent flags:
```sh
crabbox warmup --provider islo --islo-image docker.io/library/ubuntu:24.04
crabbox run --provider islo -- pnpm test
crabbox status --provider islo --id <slug>
crabbox stop --provider islo <slug>
```
## Behavior
- `warmup` creates a `crabbox-...` Islo sandbox and stores a local lease ID of
the form `isb_<crabbox-sandbox-name>` plus a Crabbox slug.
- `run` creates or reuses a sandbox, streams stdout/stderr from Islo's SSE exec
endpoint, and returns the remote exit code.
- Sync is delegated to Islo. `--sync-only`, `--checksum`, and
`--force-sync-large` are rejected because Crabbox cannot honor those local
rsync options.
- `list`, `status`, and `stop` use the Islo SDK and return core-rendered
Crabbox views for Crabbox-created sandboxes only.
Islo is not an SSH lease backend today. Commands that require a Crabbox SSH
target, such as `ssh`, `vnc`, `code`, and Actions runner hydration, should use
Hetzner, AWS, static SSH, or Daytona instead.

View File

@ -21,11 +21,20 @@ still exists for reusing existing macOS and Windows machines:
ssh Existing SSH host selected by static.host
```
Direct provider backends can also run without the Crabbox coordinator:
```text
daytona Daytona sandboxes with SDK/toolbox run and short-lived SSH access
islo Islo sandboxes with delegated command execution
```
## Provider Pages
- [AWS](aws.md): EC2 Linux, Windows, WSL2, EC2 Mac, capacity, AMIs, and security groups.
- [Hetzner](hetzner.md): Linux-only managed provider behavior, classes, and cleanup.
- [Blacksmith Testbox](blacksmith-testbox.md): delegated Testbox backend behavior.
- [Daytona](daytona.md): Daytona SDK/toolbox sandbox leases.
- [Islo](islo.md): delegated Islo sandbox execution.
- [Provider backends](../provider-backends.md): implementation guide for adding a new provider/backend/plugin.
## Hetzner Summary
@ -118,6 +127,15 @@ brokered Worker path.
Crabbox can also wrap Blacksmith Testboxes with `provider: blacksmith-testbox`. That backend does not use the Crabbox broker or direct cloud credentials. It shells out to the authenticated Blacksmith CLI for `testbox warmup`, `run`, `status`, `list`, and `stop`, while Crabbox keeps local slugs, repo claims, config, and timing summaries. See [Blacksmith Testbox](blacksmith-testbox.md).
Crabbox can use Daytona sandboxes with `provider: daytona`. Crabbox creates a
sandbox from `daytona.snapshot`, syncs and executes `run` through Daytona's
SDK/toolbox APIs, and mints short-lived SSH tokens only for explicit `ssh`
access. See [Daytona](daytona.md).
Crabbox can use Islo sandboxes with `provider: islo`. Islo is a delegated run
backend: the Islo Go SDK owns sandbox lifecycle and Crabbox streams command
output from Islo's exec SSE endpoint. See [Islo](islo.md).
Static SSH targets:
```yaml
@ -158,5 +176,7 @@ Related docs:
- [Hetzner](hetzner.md)
- [Tailscale](tailscale.md)
- [Blacksmith Testbox](blacksmith-testbox.md)
- [Daytona](daytona.md)
- [Islo](islo.md)
- [Runner bootstrap](runner-bootstrap.md)
- [Cost and usage](cost-usage.md)

View File

@ -30,7 +30,6 @@ Examples:
- Hetzner Cloud
- AWS EC2
- static SSH hosts
- a future Daytona sandbox if it exposes stable SSH access
Crabbox core owns the normal workflow after acquisition:
@ -69,7 +68,9 @@ Crabbox-managed SSH.
Examples:
- Blacksmith Testbox
- a future Islo backend if it owns workspace setup and command streaming
- Islo sandboxes, where Islo owns workspace setup and command streaming
- Daytona sandboxes for `run`, where Daytona toolbox owns file upload and
process execution while `crabbox ssh` still uses short-lived SSH tokens
- a future external runner service that accepts a command and streams output
The delegated backend owns warmup, command execution, output streaming, and
@ -152,6 +153,8 @@ internal/providers/hetzner
internal/providers/aws
internal/providers/ssh
internal/providers/blacksmith
internal/providers/daytona
internal/providers/islo
```
Each provider package owns registration, provider name, aliases, spec,
@ -176,6 +179,9 @@ internal/cli/provider_hetzner.go # Hetzner SSH lease backend implementation
internal/cli/provider_static.go # static SSH lease backend implementation
internal/cli/provider_coordinator.go # brokered coordinator lease backend
internal/cli/provider_blacksmith.go # existing delegated Blacksmith backend
internal/cli/provider_daytona.go # Daytona SSH access backend implementation
internal/cli/provider_daytona_delegated.go # Daytona SDK/toolbox run backend
internal/cli/provider_islo.go # Islo delegated backend implementation
```
This split is intentional. Existing built-ins still use a broad set of

View File

@ -16,15 +16,15 @@ context and migration notes; the authoring guide is the handrail for new code.
Crabbox has two real execution models.
The first model is SSH lease execution. Hetzner, AWS, static SSH, and Daytona
produce a machine or sandbox reachable through SSH. Crabbox owns the workflow:
claim, sync, command wrapping, stdout/stderr streaming, result collection,
timing, heartbeat, and release.
The first model is SSH lease execution. Hetzner, AWS, and static SSH produce a
machine reachable through SSH. Crabbox owns the workflow: claim, sync, command
wrapping, stdout/stderr streaming, result collection, timing, heartbeat, and
release.
The second model is delegated execution. Blacksmith Testboxes and Islo own
machine setup, file/workspace state, command execution, and output streaming.
Crabbox keeps provider selection, config, local claims/slugs, and timing
summaries, but it does not rsync into these providers.
The second model is delegated execution. Blacksmith Testboxes, Daytona `run`,
and Islo own machine setup or file/workspace transport, command execution, and
output streaming. Crabbox keeps provider selection, config, local claims/slugs,
and timing summaries, but it does not rsync into these providers.
Relevant pull requests:
@ -34,13 +34,12 @@ Relevant pull requests:
SDK/source checks:
- Daytona upstream has an official Go SDK at
`github.com/daytonaio/daytona/libs/sdk-go`, plus a lower-level generated API
client. The official SDK is large and brings more dependency surface than the
provider needs. The generated API client exposes the exact REST calls Crabbox
needs: create sandbox, create SSH access, list sandboxes, update labels, and
update last activity. Prefer a tiny Crabbox-owned REST client unless the SDK
becomes meaningfully simpler.
- Daytona upstream ships a generated Go API client at
`github.com/daytonaio/daytona/libs/api-client-go` and a toolbox SDK at
`github.com/daytonaio/daytona/libs/sdk-go`. Use both through narrow
Crabbox-owned adapters: the generated client for list/get/start/delete,
labels, last activity, and SSH access; the SDK/toolbox for sandbox create,
file upload, and command execution.
- Daytona snapshot creation does not accept CPU/memory/disk resources. Resource
fields live on image creation. Snapshot-only mode must not expose resource
flags that become no-ops.
@ -331,7 +330,7 @@ provider kind coordinator features
hetzner ssh-lease supported ssh, crabbox-sync, cleanup, tailscale
aws ssh-lease supported ssh, crabbox-sync, cleanup, desktop, browser, code
ssh ssh-lease never ssh, crabbox-sync, desktop, browser, code
daytona ssh-lease never ssh, crabbox-sync, cleanup
daytona ssh-lease never ssh, crabbox-sync
blacksmith-testbox delegated-run never delegated execution
islo delegated-run never delegated execution
```
@ -353,7 +352,7 @@ validation:
```text
provider=daytona managed provisioning supports target=linux only
desktop/VNC is not supported for provider=islo; islo sandboxes are headless
--actions-runner is not supported for provider=daytona
--actions-runner requires an SSH lease provider with target=linux
```
## Registry
@ -510,7 +509,7 @@ Provider-specific flags:
--daytona-target
--daytona-user
--daytona-work-root
--daytona-ssh-token-minutes
--daytona-ssh-access-minutes
--islo-image
--islo-workdir
@ -856,8 +855,8 @@ Daytona target example:
```go
SSHTarget{
User: token,
Host: "ssh.app.daytona.io",
Port: "22",
Host: parsedHostFromSSHCommand,
Port: parsedPortFromSSHCommand,
Key: "",
TargetOS: "linux",
ReadyCheck: "command -v git >/dev/null && command -v rsync >/dev/null && command -v tar >/dev/null",
@ -869,7 +868,7 @@ SSHTarget{
Normal output:
```text
ready ssh=<redacted>@ssh.app.daytona.io:22 network=public workroot=/home/daytona/crabbox
ready ssh=<redacted>@<daytona-ssh-host>:<daytona-ssh-port> network=public workroot=/home/daytona/crabbox
```
The actual interactive `crabbox ssh --provider daytona --id ...` command may
@ -1109,7 +1108,7 @@ Does not support provider cleanup or coordinator.
### Daytona
Backend: `SSHLeaseBackend`
Backend: hybrid `SSHLeaseBackend` + `DelegatedRunBackend`
Spec:
@ -1117,24 +1116,26 @@ Spec:
kind=ssh-lease
coordinator=never
targets=linux
features=ssh, crabbox-sync, cleanup
features=ssh, crabbox-sync
```
Owns:
- REST API auth and organization header;
- Daytona generated Go API client auth and organization header;
- Daytona SDK/toolbox auth;
- sandbox create/list/get/start/stop/delete;
- labels and last-activity touch;
- SSH access token minting;
- toolbox archive upload and command execution for `run`;
- Daytona sandbox to `Server` mapping;
- secret SSH user and public relay target metadata.
Reuses core:
- SSH sync/run;
- sync manifest and guardrails;
- claims;
- status rendering;
- cleanup policy.
- explicit release/stop.
Initial constraints:
@ -1142,15 +1143,15 @@ Initial constraints:
- No coordinator.
- No Tailscale.
- No VNC/screenshot/desktop/browser/code portal.
- No Actions runner.
- Actions runner hydration is not supported for Daytona warmup.
- Snapshot mode only unless image mode is implemented fully.
Rebase notes for https://github.com/openclaw/crabbox/pull/32:
- Implement `Provider.Configure` returning a Daytona `SSHLeaseBackend`.
- Keep raw REST instead of the official Daytona Go SDK.
- Keep the labels body fix: Daytona label update expects
`{ "labels": { ... } }`.
- Implement `Provider.Configure` returning a Daytona backend that supports
delegated `run` plus explicit SSH access.
- Use Daytona's generated Go API client and SDK/toolbox; do not duplicate REST
plumbing in Crabbox.
- Keep start-before-SSH for stopped sandboxes.
- Require `DAYTONA_ORGANIZATION_ID` when JWT auth is used unless Daytona docs
prove it is optional for the account shape.
@ -1294,9 +1295,11 @@ Expected behavior change: none for existing configs.
### Phase 7: Rebase Daytona
- Rebase https://github.com/openclaw/crabbox/pull/32 onto `SSHLeaseBackend`.
- Keep Daytona REST client isolated.
- Add tests for acquire/resolve/list/release/touch via backend.
- Rebase https://github.com/openclaw/crabbox/pull/32 onto a hybrid Daytona
backend: delegated SDK/toolbox `run`, explicit SSH access for `ssh`.
- Keep Daytona SDK access isolated behind the backend adapter.
- Add tests for acquire/resolve/list/release/touch plus delegated backend
selection.
- Add redaction tests for secret SSH user output.
- Add live smoke behind explicit env gates only.
@ -1370,8 +1373,8 @@ Daytona tests:
- labels body shape;
- snapshot mode omits unusable resource overrides;
- stopped sandbox starts before SSH target creation;
- SSH target uses relay host, empty key, secret user, public network, and ready
check;
- SSH target parses the API-returned `sshCommand`, uses empty key, secret user,
public network, and ready check;
- list/status/timing output, including JSON, redacts token-bearing user;
- release removes local claim.
@ -1401,7 +1404,7 @@ Docs tests:
- A fake SSH lease backend can be tested without editing command handlers.
- A fake delegated backend can be tested without editing command handlers.
- Hetzner/AWS still use the coordinator when configured.
- Daytona can be rebased by implementing `SSHLeaseBackend`.
- Daytona can be rebased by implementing the hybrid backend.
- Islo can be rebased by implementing `DelegatedRunBackend`.
- No new provider requires touching the main command flow unless it adds a new
top-level Crabbox feature.

View File

@ -37,20 +37,24 @@ This page maps user-facing behavior back to implementation files. Keep docs desc
- Direct AWS provider: `internal/cli/aws.go`
- Static SSH macOS/Windows provider: `internal/cli/static.go`
- Blacksmith Testbox argument/parsing helpers: `internal/cli/blacksmith.go`
- Daytona provider backend and SDK/toolbox wrapper: `internal/cli/provider_daytona.go`, `internal/cli/provider_daytona_delegated.go`, `internal/providers/daytona`
- Islo delegated backend and SDK wrapper: `internal/cli/provider_islo.go`, `internal/providers/islo`
- Provider backend interfaces, registry, and request/result types:
`internal/cli/provider_backend.go`
- Built-in provider registration packages:
`internal/providers/hetzner`, `internal/providers/aws`,
`internal/providers/ssh`, `internal/providers/blacksmith`,
`internal/providers/daytona`, `internal/providers/islo`,
`internal/providers/all`
- Built-in provider backend implementations:
`internal/cli/providers_common.go`, `internal/cli/provider_aws.go`,
`internal/cli/provider_hetzner.go`, `internal/cli/provider_static.go`,
`internal/cli/provider_coordinator.go`, `internal/cli/provider_blacksmith.go`
`internal/cli/provider_coordinator.go`, `internal/cli/provider_blacksmith.go`,
`internal/cli/provider_daytona.go`, `internal/cli/provider_islo.go`
- Worker Hetzner provider: `worker/src/hetzner.ts`
- Worker AWS EC2 provider: `worker/src/aws.ts`
- Worker AWS AMI create/read/promote routes: `worker/src/fleet.ts`, `worker/src/aws.ts`
- Provider feature docs: `docs/features/aws.md`, `docs/features/hetzner.md`, `docs/features/blacksmith-testbox.md`
- Provider feature docs: `docs/features/aws.md`, `docs/features/hetzner.md`, `docs/features/blacksmith-testbox.md`, `docs/features/daytona.md`, `docs/features/islo.md`
- Provider/backend authoring guide: `docs/provider-backends.md`
- CLI cloud-init bootstrap: `internal/cli/bootstrap.go`
- Worker cloud-init bootstrap: `worker/src/bootstrap.ts`

35
go.mod
View File

@ -9,24 +9,53 @@ require (
github.com/aws/aws-sdk-go-v2 v1.41.7
github.com/aws/aws-sdk-go-v2/config v1.32.17
github.com/aws/aws-sdk-go-v2/service/ec2 v1.299.1
github.com/daytonaio/daytona/libs/api-client-go v0.172.0
github.com/daytonaio/daytona/libs/sdk-go v0.172.0
github.com/islo-labs/go-sdk v0.0.0-20260503172735-c8bd46c2d7d5
gopkg.in/yaml.v3 v3.0.1
nhooyr.io/websocket v1.8.17
)
require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
github.com/aws/smithy-go v1.25.1 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/daytonaio/daytona/libs/toolbox-api-client-go v0.172.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

91
go.sum
View File

@ -6,6 +6,8 @@ github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU=
github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
@ -22,8 +24,14 @@ github.com/aws/aws-sdk-go-v2/service/ec2 v1.299.1 h1:gQ9fSyFk3Y9Vm2fVbphBeJfXJlk
github.com/aws/aws-sdk-go-v2/service/ec2 v1.299.1/go.mod h1:Y95W0Hm6FYLPa6o0hbnJ+sWgmdc4ifcLFjGkdobWVhY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
@ -34,23 +42,94 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOIt
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/daytonaio/daytona/libs/api-client-go v0.172.0 h1:JbKycJaayrP2nMRzq84/h+V3bWbzU+5r0euQClMLSSQ=
github.com/daytonaio/daytona/libs/api-client-go v0.172.0/go.mod h1:FzTxtFuR9utJw43WBUVdX9AF4rALJlSKvx4bddZthxs=
github.com/daytonaio/daytona/libs/sdk-go v0.172.0 h1:ickg8R4cCJ+16MQpDGkSpVD/oyY5FG77OUoSaEYzpHg=
github.com/daytonaio/daytona/libs/sdk-go v0.172.0/go.mod h1:a/rpZfveDUrT0+R8jlzndfIrV1u/DJmbZn/hAXu8yCs=
github.com/daytonaio/daytona/libs/toolbox-api-client-go v0.172.0 h1:1MyfR0Eu6sX3jGHFeXK6B7paMTSvqMufDuJvqZpaBFU=
github.com/daytonaio/daytona/libs/toolbox-api-client-go v0.172.0/go.mod h1:Y/IJdiDMtmm3Nz6NBiTRJ3uEiRUqNAvEZPNNCFdgkzo=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/islo-labs/go-sdk v0.0.0-20260503172735-c8bd46c2d7d5 h1:Jb/g064nT2bk/uS87kIlHP7sxp/1RVZE8oJoqabxH3Q=
github.com/islo-labs/go-sdk v0.0.0-20260503172735-c8bd46c2d7d5/go.mod h1:2REOOHkiu0f0vI8sybwSxjt3xT6d0h9Yop5fRgwOF84=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nhooyr.io/websocket v1.8.17 h1:KEVeLJkUywCKVsnLIDlD/5gtayKp8VoCkksHCGGfT9Y=

View File

@ -23,7 +23,7 @@ const envSchema = {
const providerSchema = {
type: "string",
enum: ["aws", "hetzner"],
enum: ["aws", "hetzner", "ssh", "blacksmith-testbox", "blacksmith", "daytona", "islo"],
};
function readConfig(api) {
@ -206,6 +206,7 @@ function registerRun(api, config) {
description: "Crabbox lease ID or friendly slug.",
},
command: commandArraySchema,
provider: providerSchema,
env: envSchema,
noSync: {
type: "boolean",
@ -246,6 +247,7 @@ function registerRun(api, config) {
throw new Error("crabbox_run is disabled by plugin config");
}
const args = ["run", "--id", readString(params, "id")];
maybePush(args, "--provider", readString(params, "provider"));
maybePushBool(args, "--no-sync", params?.noSync);
maybePushBool(args, "--sync-only", params?.syncOnly);
maybePushBool(args, "--force-sync-large", params?.forceSyncLarge);

View File

@ -68,6 +68,28 @@ test("crabbox_run executes the CLI without shell wrapping", async () => {
assert.equal(JSON.parse(result.details.stdout).env.CRABBOX_TEST_VALUE, "present");
});
test("crabbox_run passes selected provider", async () => {
const fake = createFakeCrabbox();
const tools = registerWithConfig({ binary: fake.file });
const result = await getTool(tools, "crabbox_run").execute("call-1", {
id: "blue-lobster",
provider: "islo",
command: ["go", "test", "./..."],
});
assert.equal(result.details.code, 0);
assert.deepEqual(JSON.parse(result.details.stdout).argv, [
"run",
"--id",
"blue-lobster",
"--provider",
"islo",
"--",
"go",
"test",
"./...",
]);
});
test("crabbox_status includes optional flags", async () => {
const fake = createFakeCrabbox();
const tools = registerWithConfig({ binary: fake.file });

View File

@ -207,7 +207,7 @@ Environment:
CRABBOX_ACCESS_CLIENT_ID Cloudflare Access service token client ID
CRABBOX_ACCESS_CLIENT_SECRET Cloudflare Access service token client secret
CRABBOX_ACCESS_TOKEN Cloudflare Access JWT for protected routes
CRABBOX_PROVIDER hetzner, aws, ssh, or blacksmith-testbox
CRABBOX_PROVIDER hetzner, aws, ssh, blacksmith-testbox, daytona, or islo
CRABBOX_TARGET linux, macos, or windows
CRABBOX_WINDOWS_MODE normal or wsl2
CRABBOX_DESKTOP Provision or require desktop/VNC capability

View File

@ -51,6 +51,8 @@ type Config struct {
Capacity CapacityConfig
Actions ActionsConfig
Blacksmith BlacksmithConfig
Daytona DaytonaConfig
Islo IsloConfig
Tailscale TailscaleConfig
Static StaticConfig
Results ResultsConfig
@ -100,6 +102,31 @@ type BlacksmithConfig struct {
Debug bool
}
type DaytonaConfig struct {
APIKey string
JWTToken string
OrganizationID string
APIURL string
Snapshot string
Target string
User string
WorkRoot string
SSHGatewayHost string
SSHAccessMinutes int
}
type IsloConfig struct {
APIKey string
BaseURL string
Image string
Workdir string
GatewayProfile string
SnapshotName string
VCPUs int
MemoryMB int
DiskGB int
}
type StaticConfig struct {
ID string
Name string
@ -207,6 +234,21 @@ func baseConfig() Config {
RunnerVersion: "latest",
Ephemeral: true,
},
Daytona: DaytonaConfig{
APIURL: "https://app.daytona.io/api",
User: "daytona",
WorkRoot: "/home/daytona/crabbox",
SSHGatewayHost: "ssh.app.daytona.io",
SSHAccessMinutes: 30,
},
Islo: IsloConfig{
BaseURL: "https://api.islo.dev",
Image: "docker.io/library/ubuntu:24.04",
Workdir: "crabbox",
VCPUs: 2,
MemoryMB: 4096,
DiskGB: 20,
},
Tailscale: TailscaleConfig{
Tags: []string{"tag:crabbox"},
HostnameTemplate: "crabbox-{slug}",
@ -245,6 +287,8 @@ type fileConfig struct {
Capacity *fileCapacityConfig `yaml:"capacity,omitempty"`
Actions *fileActionsConfig `yaml:"actions,omitempty"`
Blacksmith *fileBlacksmithConfig `yaml:"blacksmith,omitempty"`
Daytona *fileDaytonaConfig `yaml:"daytona,omitempty"`
Islo *fileIsloConfig `yaml:"islo,omitempty"`
Tailscale *fileTailscaleConfig `yaml:"tailscale,omitempty"`
Static *fileStaticConfig `yaml:"static,omitempty"`
Results *fileResultsConfig `yaml:"results,omitempty"`
@ -345,6 +389,27 @@ type fileBlacksmithConfig struct {
Debug *bool `yaml:"debug,omitempty"`
}
type fileDaytonaConfig struct {
APIURL string `yaml:"apiUrl,omitempty"`
Snapshot string `yaml:"snapshot,omitempty"`
Target string `yaml:"target,omitempty"`
User string `yaml:"user,omitempty"`
WorkRoot string `yaml:"workRoot,omitempty"`
SSHGatewayHost string `yaml:"sshGatewayHost,omitempty"`
SSHAccessMinutes int `yaml:"sshAccessMinutes,omitempty"`
}
type fileIsloConfig struct {
BaseURL string `yaml:"baseUrl,omitempty"`
Image string `yaml:"image,omitempty"`
Workdir string `yaml:"workdir,omitempty"`
GatewayProfile string `yaml:"gatewayProfile,omitempty"`
SnapshotName string `yaml:"snapshotName,omitempty"`
VCPUs int `yaml:"vcpus,omitempty"`
MemoryMB int `yaml:"memoryMB,omitempty"`
DiskGB int `yaml:"diskGB,omitempty"`
}
type fileTailscaleConfig struct {
Enabled *bool `yaml:"enabled,omitempty"`
Network string `yaml:"network,omitempty"`
@ -704,6 +769,55 @@ func applyFileConfig(cfg *Config, file fileConfig) {
cfg.Blacksmith.Debug = *file.Blacksmith.Debug
}
}
if file.Daytona != nil {
if file.Daytona.APIURL != "" {
cfg.Daytona.APIURL = file.Daytona.APIURL
}
if file.Daytona.Snapshot != "" {
cfg.Daytona.Snapshot = file.Daytona.Snapshot
}
if file.Daytona.Target != "" {
cfg.Daytona.Target = file.Daytona.Target
}
if file.Daytona.User != "" {
cfg.Daytona.User = file.Daytona.User
}
if file.Daytona.WorkRoot != "" {
cfg.Daytona.WorkRoot = file.Daytona.WorkRoot
}
if file.Daytona.SSHGatewayHost != "" {
cfg.Daytona.SSHGatewayHost = file.Daytona.SSHGatewayHost
}
if file.Daytona.SSHAccessMinutes > 0 {
cfg.Daytona.SSHAccessMinutes = file.Daytona.SSHAccessMinutes
}
}
if file.Islo != nil {
if file.Islo.BaseURL != "" {
cfg.Islo.BaseURL = file.Islo.BaseURL
}
if file.Islo.Image != "" {
cfg.Islo.Image = file.Islo.Image
}
if file.Islo.Workdir != "" {
cfg.Islo.Workdir = file.Islo.Workdir
}
if file.Islo.GatewayProfile != "" {
cfg.Islo.GatewayProfile = file.Islo.GatewayProfile
}
if file.Islo.SnapshotName != "" {
cfg.Islo.SnapshotName = file.Islo.SnapshotName
}
if file.Islo.VCPUs > 0 {
cfg.Islo.VCPUs = file.Islo.VCPUs
}
if file.Islo.MemoryMB > 0 {
cfg.Islo.MemoryMB = file.Islo.MemoryMB
}
if file.Islo.DiskGB > 0 {
cfg.Islo.DiskGB = file.Islo.DiskGB
}
}
if file.Tailscale != nil {
if file.Tailscale.Enabled != nil {
cfg.Tailscale.Enabled = *file.Tailscale.Enabled
@ -841,6 +955,25 @@ func applyEnv(cfg *Config) {
cfg.Blacksmith.Workflow = getenv("CRABBOX_BLACKSMITH_WORKFLOW", cfg.Blacksmith.Workflow)
cfg.Blacksmith.Job = getenv("CRABBOX_BLACKSMITH_JOB", cfg.Blacksmith.Job)
cfg.Blacksmith.Ref = getenv("CRABBOX_BLACKSMITH_REF", cfg.Blacksmith.Ref)
cfg.Daytona.APIKey = getenv("CRABBOX_DAYTONA_API_KEY", getenv("DAYTONA_API_KEY", cfg.Daytona.APIKey))
cfg.Daytona.JWTToken = getenv("CRABBOX_DAYTONA_JWT_TOKEN", getenv("DAYTONA_JWT_TOKEN", cfg.Daytona.JWTToken))
cfg.Daytona.OrganizationID = getenv("CRABBOX_DAYTONA_ORGANIZATION_ID", getenv("DAYTONA_ORGANIZATION_ID", cfg.Daytona.OrganizationID))
cfg.Daytona.APIURL = getenv("CRABBOX_DAYTONA_API_URL", getenv("DAYTONA_API_URL", cfg.Daytona.APIURL))
cfg.Daytona.Snapshot = getenv("CRABBOX_DAYTONA_SNAPSHOT", getenv("DAYTONA_SNAPSHOT", cfg.Daytona.Snapshot))
cfg.Daytona.Target = getenv("CRABBOX_DAYTONA_TARGET", getenv("DAYTONA_TARGET", cfg.Daytona.Target))
cfg.Daytona.User = getenv("CRABBOX_DAYTONA_USER", cfg.Daytona.User)
cfg.Daytona.WorkRoot = getenv("CRABBOX_DAYTONA_WORK_ROOT", cfg.Daytona.WorkRoot)
cfg.Daytona.SSHGatewayHost = getenv("CRABBOX_DAYTONA_SSH_GATEWAY_HOST", cfg.Daytona.SSHGatewayHost)
cfg.Daytona.SSHAccessMinutes = getenvInt("CRABBOX_DAYTONA_SSH_ACCESS_MINUTES", cfg.Daytona.SSHAccessMinutes)
cfg.Islo.APIKey = getenv("CRABBOX_ISLO_API_KEY", getenv("ISLO_API_KEY", cfg.Islo.APIKey))
cfg.Islo.BaseURL = getenv("CRABBOX_ISLO_BASE_URL", getenv("ISLO_BASE_URL", cfg.Islo.BaseURL))
cfg.Islo.Image = getenv("CRABBOX_ISLO_IMAGE", cfg.Islo.Image)
cfg.Islo.Workdir = getenv("CRABBOX_ISLO_WORKDIR", cfg.Islo.Workdir)
cfg.Islo.GatewayProfile = getenv("CRABBOX_ISLO_GATEWAY_PROFILE", cfg.Islo.GatewayProfile)
cfg.Islo.SnapshotName = getenv("CRABBOX_ISLO_SNAPSHOT_NAME", cfg.Islo.SnapshotName)
cfg.Islo.VCPUs = getenvInt("CRABBOX_ISLO_VCPUS", cfg.Islo.VCPUs)
cfg.Islo.MemoryMB = getenvInt("CRABBOX_ISLO_MEMORY_MB", cfg.Islo.MemoryMB)
cfg.Islo.DiskGB = getenvInt("CRABBOX_ISLO_DISK_GB", cfg.Islo.DiskGB)
if value, ok := getenvBool("CRABBOX_TAILSCALE"); ok {
cfg.Tailscale.Enabled = value
}
@ -946,9 +1079,12 @@ func serverTypeForClass(class string) string {
}
func serverTypeForConfig(cfg Config) string {
if isBlacksmithProvider(cfg.Provider) || isStaticProvider(cfg.Provider) {
if isBlacksmithProvider(cfg.Provider) || isStaticProvider(cfg.Provider) || cfg.Provider == "islo" {
return ""
}
if cfg.Provider == "daytona" {
return "snapshot"
}
if cfg.Provider == "aws" {
return awsInstanceTypeCandidatesForConfig(cfg)[0]
}
@ -956,9 +1092,12 @@ func serverTypeForConfig(cfg Config) string {
}
func serverTypeForProviderClass(provider, class string) string {
if isBlacksmithProvider(provider) || isStaticProvider(provider) {
if isBlacksmithProvider(provider) || isStaticProvider(provider) || provider == "islo" {
return ""
}
if provider == "daytona" {
return "snapshot"
}
if provider == "aws" {
return awsInstanceTypeCandidatesForClass(class)[0]
}

View File

@ -25,7 +25,7 @@ type leaseCreateFlagValues struct {
func registerLeaseCreateFlags(fs *flag.FlagSet, defaults Config) leaseCreateFlagValues {
return leaseCreateFlagValues{
Provider: fs.String("provider", defaults.Provider, "provider: hetzner, aws, ssh, or blacksmith-testbox"),
Provider: fs.String("provider", defaults.Provider, providerHelpAll()),
Profile: fs.String("profile", defaults.Profile, "profile"),
Class: fs.String("class", defaults.Class, "machine class"),
ServerType: fs.String("type", getenv("CRABBOX_SERVER_TYPE", ""), "provider server/instance type"),

View File

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

View File

@ -309,6 +309,14 @@ func normalizeProviderName(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}
func providerHelpAll() string {
return "provider: hetzner, aws, ssh, blacksmith-testbox, daytona, or islo"
}
func providerHelpSSH() string {
return "provider: hetzner, aws, ssh, or daytona"
}
type providerFlagValues map[string]any
func registerProviderFlags(fs *flag.FlagSet, defaults Config) providerFlagValues {
@ -375,23 +383,24 @@ func backendCoordinator(backend Backend) *CoordinatorClient {
func leaseOptionsFromConfig(cfg Config) LeaseOptions {
return LeaseOptions{
TargetOS: cfg.TargetOS,
WindowsMode: cfg.WindowsMode,
Class: cfg.Class,
ServerType: cfg.ServerType,
IdleTimeout: cfg.IdleTimeout,
TTL: cfg.TTL,
Desktop: cfg.Desktop,
Browser: cfg.Browser,
Code: cfg.Code,
Tailscale: cfg.Tailscale,
WorkRoot: cfg.WorkRoot,
SSHUser: cfg.SSHUser,
SSHPort: cfg.SSHPort,
SSHKey: cfg.SSHKey,
Sync: cfg.Sync,
Results: cfg.Results,
EnvAllow: cfg.EnvAllow,
TargetOS: cfg.TargetOS,
WindowsMode: cfg.WindowsMode,
Class: cfg.Class,
ServerType: cfg.ServerType,
IdleTimeout: cfg.IdleTimeout,
TTL: cfg.TTL,
Desktop: cfg.Desktop,
Browser: cfg.Browser,
Code: cfg.Code,
Tailscale: cfg.Tailscale,
WorkRoot: cfg.WorkRoot,
SSHUser: cfg.SSHUser,
SSHPort: cfg.SSHPort,
SSHKey: cfg.SSHKey,
Sync: cfg.Sync,
Results: cfg.Results,
EnvAllow: cfg.EnvAllow,
ActionsRunner: cfg.Actions.Workflow != "" || len(cfg.Actions.RunnerLabels) > 0,
}
}

View File

@ -3,6 +3,7 @@ package cli
import (
"context"
"io"
"strings"
"testing"
)
@ -22,7 +23,7 @@ func testRuntimeWithRunner(r CommandRunner) Runtime {
}
func TestProviderRegistryCanonicalAndAliases(t *testing.T) {
for _, name := range []string{"hetzner", "aws", "ssh", "static", "static-ssh", "blacksmith", "blacksmith-testbox"} {
for _, name := range []string{"hetzner", "aws", "ssh", "static", "static-ssh", "blacksmith", "blacksmith-testbox", "daytona", "islo"} {
if _, err := ProviderFor(name); err != nil {
t.Fatalf("ProviderFor(%q): %v", name, err)
}
@ -141,6 +142,126 @@ func TestBlacksmithBackendUsesInjectedCommandRunnerForListAndStatus(t *testing.T
}
}
func TestProviderFlagsApplyDaytonaAndIsloWithoutCoreEdits(t *testing.T) {
defaults := baseConfig()
fs := newFlagSet("test", io.Discard)
provider := fs.String("provider", defaults.Provider, "")
values := registerProviderFlags(fs, defaults)
if err := parseFlags(fs, []string{
"--provider", "daytona",
"--daytona-snapshot", "snap-crabbox",
"--daytona-target", "us",
"--daytona-work-root", "/home/daytona/work",
}); err != nil {
t.Fatal(err)
}
cfg := defaults
cfg.Provider = *provider
if err := applyProviderFlags(&cfg, fs, values); err != nil {
t.Fatal(err)
}
if cfg.Daytona.Snapshot != "snap-crabbox" || cfg.Daytona.Target != "us" || cfg.Daytona.WorkRoot != "/home/daytona/work" {
t.Fatalf("daytona flags not applied: %#v", cfg.Daytona)
}
fs = newFlagSet("test", io.Discard)
provider = fs.String("provider", defaults.Provider, "")
values = registerProviderFlags(fs, defaults)
if err := parseFlags(fs, []string{
"--provider", "islo",
"--islo-image", "ubuntu:24.04",
"--islo-vcpus", "4",
"--islo-memory-mb", "8192",
}); err != nil {
t.Fatal(err)
}
cfg = defaults
cfg.Provider = *provider
if err := applyProviderFlags(&cfg, fs, values); err != nil {
t.Fatal(err)
}
if cfg.Islo.Image != "ubuntu:24.04" || cfg.Islo.VCPUs != 4 || cfg.Islo.MemoryMB != 8192 {
t.Fatalf("islo flags not applied: %#v", cfg.Islo)
}
}
func TestDaytonaAuthRequiresOrganizationForJWT(t *testing.T) {
cfg := baseConfig()
cfg.Provider = daytonaProvider
cfg.Daytona.APIKey = ""
cfg.Daytona.JWTToken = "jwt"
cfg.Daytona.OrganizationID = ""
_, err := newDaytonaClient(cfg, Runtime{})
if err == nil || !strings.Contains(err.Error(), "DAYTONA_ORGANIZATION_ID") {
t.Fatalf("err=%v, want organization requirement", err)
}
}
func TestDaytonaSSHTargetUsesReturnedSSHCommand(t *testing.T) {
cfg := baseConfig()
cfg.Daytona.SSHGatewayHost = "fallback.example"
target, err := daytonaSSHTargetFromAccess(cfg, daytonaSSHAccess{
Token: "tok_live_secret",
Command: "ssh -p 2222 tok_live_secret@region-ssh.example.com",
})
if err != nil {
t.Fatal(err)
}
if target.User != "tok_live_secret" || target.Host != "region-ssh.example.com" || target.Port != "2222" {
t.Fatalf("target=%#v", target)
}
if target.Key != "" || !target.AuthSecret || target.NetworkKind != NetworkPublic {
t.Fatalf("target auth/network=%#v", target)
}
}
func TestDaytonaSSHTargetFallsBackWhenCommandMissing(t *testing.T) {
cfg := baseConfig()
cfg.Daytona.SSHGatewayHost = "fallback.example"
target, err := daytonaSSHTargetFromAccess(cfg, daytonaSSHAccess{Token: "tok_live_secret"})
if err != nil {
t.Fatal(err)
}
if target.User != "tok_live_secret" || target.Host != "fallback.example" || target.Port != "22" {
t.Fatalf("target=%#v", target)
}
}
func TestDaytonaBackendIsHybridSDKRunAndSSHAccess(t *testing.T) {
backend := NewDaytonaLeaseBackend(ProviderSpec{Name: daytonaProvider}, baseConfig(), Runtime{})
if _, ok := backend.(DelegatedRunBackend); !ok {
t.Fatal("daytona should use delegated SDK run path")
}
if _, ok := backend.(SSHLeaseBackend); !ok {
t.Fatal("daytona should still expose explicit SSH access")
}
}
func TestDaytonaCommandString(t *testing.T) {
if got := daytonaCommandString([]string{"go", "test", "./..."}, false); got != "'go' 'test' './...'" {
t.Fatalf("command=%q", got)
}
if got := daytonaCommandString([]string{"FOO=bar", "go", "test"}, false); !strings.Contains(got, "FOO=") || !strings.Contains(got, "go") {
t.Fatalf("shell command=%q", got)
}
if got := daytonaCommandString([]string{"echo hello && pwd"}, true); got != "echo hello && pwd" {
t.Fatalf("shell mode=%q", got)
}
}
func TestRedactedSSHUserOnlyForDaytona(t *testing.T) {
target := SSHTarget{User: "tok_live_secret"}
if got := redactedSSHUser(Config{Provider: "hetzner"}, Server{Provider: "hetzner"}, target); got != target.User {
t.Fatalf("redactedSSHUser hetzner=%q", got)
}
if got := redactedSSHUser(Config{Provider: "hetzner"}, Server{Provider: "hetzner"}, SSHTarget{User: "secret", AuthSecret: true}); got != daytonaTokenRedacted {
t.Fatalf("redactedSSHUser auth secret=%q", got)
}
if got := redactedSSHUser(Config{Provider: daytonaProvider}, Server{}, target); got != daytonaTokenRedacted {
t.Fatalf("redactedSSHUser daytona=%q", got)
}
}
func TestBlacksmithBackendListJSONKeepsParsedTableShape(t *testing.T) {
runner := &recordingCommandRunner{
result: LocalCommandResult{

View File

@ -0,0 +1,455 @@
package cli
import (
"context"
"flag"
"fmt"
"strings"
"time"
daytona "github.com/daytonaio/daytona/libs/api-client-go"
)
const (
daytonaProvider = "daytona"
daytonaTokenRedacted = "<token>"
)
type daytonaFlagValues struct {
APIURL *string
Snapshot *string
Target *string
User *string
WorkRoot *string
SSHGatewayHost *string
SSHAccessMinutes *int
}
func RegisterDaytonaProviderFlags(fs *flag.FlagSet, defaults Config) any {
return daytonaFlagValues{
APIURL: fs.String("daytona-api-url", defaults.Daytona.APIURL, "Daytona API URL"),
Snapshot: fs.String("daytona-snapshot", defaults.Daytona.Snapshot, "Daytona snapshot name"),
Target: fs.String("daytona-target", defaults.Daytona.Target, "Daytona compute target"),
User: fs.String("daytona-user", defaults.Daytona.User, "Daytona sandbox user"),
WorkRoot: fs.String("daytona-work-root", defaults.Daytona.WorkRoot, "Daytona sandbox work root"),
SSHGatewayHost: fs.String("daytona-ssh-gateway-host", defaults.Daytona.SSHGatewayHost, "Daytona SSH gateway host"),
SSHAccessMinutes: fs.Int("daytona-ssh-access-minutes", defaults.Daytona.SSHAccessMinutes, "Daytona SSH access token TTL in minutes"),
}
}
func ApplyDaytonaProviderFlags(cfg *Config, fs *flag.FlagSet, values any) error {
v, ok := values.(daytonaFlagValues)
if !ok {
return nil
}
if flagWasSet(fs, "daytona-api-url") {
cfg.Daytona.APIURL = *v.APIURL
}
if flagWasSet(fs, "daytona-snapshot") {
cfg.Daytona.Snapshot = *v.Snapshot
}
if flagWasSet(fs, "daytona-target") {
cfg.Daytona.Target = *v.Target
}
if flagWasSet(fs, "daytona-user") {
cfg.Daytona.User = *v.User
}
if flagWasSet(fs, "daytona-work-root") {
cfg.Daytona.WorkRoot = *v.WorkRoot
}
if flagWasSet(fs, "daytona-ssh-gateway-host") {
cfg.Daytona.SSHGatewayHost = *v.SSHGatewayHost
}
if flagWasSet(fs, "daytona-ssh-access-minutes") {
cfg.Daytona.SSHAccessMinutes = *v.SSHAccessMinutes
}
return nil
}
func NewDaytonaLeaseBackend(spec ProviderSpec, cfg Config, rt Runtime) Backend {
cfg.Provider = daytonaProvider
return &daytonaLeaseBackend{spec: spec, cfg: cfg, rt: rt}
}
type daytonaLeaseBackend struct {
spec ProviderSpec
cfg Config
rt Runtime
}
func (b *daytonaLeaseBackend) Spec() ProviderSpec { return b.spec }
func (b *daytonaLeaseBackend) Acquire(ctx context.Context, req AcquireRequest) (LeaseTarget, error) {
if strings.TrimSpace(b.cfg.Daytona.Snapshot) == "" {
return LeaseTarget{}, exit(2, "provider=daytona requires --daytona-snapshot or daytona.snapshot")
}
client, err := newDaytonaClient(b.cfg, b.rt)
if err != nil {
return LeaseTarget{}, err
}
leaseID := newLeaseID()
existing, err := client.ListCrabboxSandboxes(ctx)
if err != nil {
return LeaseTarget{}, daytonaError("list sandboxes", err)
}
slug := allocateDirectLeaseSlug(leaseID, daytonaSandboxesToServers(existing, b.cfg))
cfg := b.cfg
cfg.ServerType = "snapshot"
cfg.WorkRoot = daytonaWorkRoot(cfg)
cfg.SSHKey = ""
cfg.SSHUser = daytonaUser(cfg)
cfg.SSHPort = "22"
now := time.Now().UTC()
labels := directLeaseLabels(cfg, leaseID, slug, daytonaProvider, "", req.Keep, now)
labels["lease_name"] = leaseProviderName(leaseID, slug)
labels["work_root"] = cfg.WorkRoot
create := daytona.NewCreateSandbox()
create.SetName(labels["lease_name"])
create.SetSnapshot(strings.TrimSpace(cfg.Daytona.Snapshot))
create.SetLabels(labels)
if target := strings.TrimSpace(cfg.Daytona.Target); target != "" {
create.SetTarget(target)
}
if user := daytonaUser(cfg); user != "" {
create.SetUser(user)
}
autoStop := int32(durationMinutesCeil(cfg.IdleTimeout))
create.SetAutoStopInterval(autoStop)
fmt.Fprintf(b.rt.Stderr, "provisioning provider=daytona lease=%s slug=%s snapshot=%s target=%s keep=%v\n", leaseID, slug, cfg.Daytona.Snapshot, blank(cfg.Daytona.Target, "-"), req.Keep)
created, err := client.CreateSandbox(ctx, *create)
if err != nil {
return LeaseTarget{}, daytonaError("create sandbox", err)
}
sandbox, err := waitForDaytonaReady(ctx, client, created.GetId(), 5*time.Minute)
if err != nil {
if !req.Keep {
_ = client.DeleteSandbox(context.Background(), created.GetId())
}
return LeaseTarget{}, err
}
server := daytonaSandboxToServer(sandbox, cfg)
server.Labels["state"] = "ready"
if err := client.ReplaceLabels(ctx, server.CloudID, server.Labels); err != nil {
fmt.Fprintf(b.rt.Stderr, "warning: set labels: %v\n", daytonaError("replace labels", err))
}
target, err := daytonaSSHTargetFor(ctx, client, cfg, server)
if err != nil {
if !req.Keep {
_ = client.DeleteSandbox(context.Background(), server.CloudID)
}
return LeaseTarget{}, err
}
if err := waitForSSHReady(ctx, &target, b.rt.Stderr, "daytona ssh", bootstrapWaitTimeout(cfg)); err != nil {
if !req.Keep {
_ = client.DeleteSandbox(context.Background(), server.CloudID)
}
return LeaseTarget{}, err
}
fmt.Fprintf(b.rt.Stderr, "provisioned lease=%s sandbox=%s state=%s\n", leaseID, server.CloudID, server.Status)
return LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, nil
}
func (b *daytonaLeaseBackend) Resolve(ctx context.Context, req ResolveRequest) (LeaseTarget, error) {
client, err := newDaytonaClient(b.cfg, b.rt)
if err != nil {
return LeaseTarget{}, err
}
sandbox, leaseID, err := resolveDaytonaSandbox(ctx, client, b.cfg, req.ID)
if err != nil {
return LeaseTarget{}, err
}
if !daytonaStateReady(daytonaSandboxState(sandbox)) {
if daytonaStateFailed(daytonaSandboxState(sandbox)) {
return LeaseTarget{}, exit(5, "daytona sandbox %s entered terminal state=%s", sandbox.GetId(), daytonaSandboxState(sandbox))
}
sandbox, err = client.StartSandbox(ctx, sandbox.GetId())
if err != nil {
return LeaseTarget{}, daytonaError("start sandbox", err)
}
sandbox, err = waitForDaytonaReady(ctx, client, sandbox.GetId(), 5*time.Minute)
if err != nil {
return LeaseTarget{}, err
}
}
server := daytonaSandboxToServer(sandbox, b.cfg)
target, err := daytonaSSHTargetFor(ctx, client, b.cfg, server)
if err != nil {
return LeaseTarget{}, err
}
return LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, nil
}
func (b *daytonaLeaseBackend) List(ctx context.Context, req ListRequest) ([]LeaseView, error) {
_ = req
client, err := newDaytonaClient(b.cfg, b.rt)
if err != nil {
return nil, err
}
sandboxes, err := client.ListCrabboxSandboxes(ctx)
if err != nil {
return nil, daytonaError("list sandboxes", err)
}
return daytonaSandboxesToServers(sandboxes, b.cfg), nil
}
func (b *daytonaLeaseBackend) ReleaseLease(ctx context.Context, req ReleaseLeaseRequest) error {
client, err := newDaytonaClient(b.cfg, b.rt)
if err != nil {
return err
}
if req.Lease.Server.CloudID != "" {
if err := client.DeleteSandbox(ctx, req.Lease.Server.CloudID); err != nil {
return daytonaError("delete sandbox", err)
}
}
removeLeaseClaim(req.Lease.LeaseID)
return nil
}
func (b *daytonaLeaseBackend) Touch(ctx context.Context, req TouchRequest) (Server, error) {
client, err := newDaytonaClient(b.cfg, b.rt)
if err != nil {
return req.Lease.Server, err
}
server := req.Lease.Server
if server.Labels == nil {
server.Labels = map[string]string{}
}
server.Labels = touchDirectLeaseLabels(server.Labels, b.cfg, req.State, time.Now().UTC())
if server.CloudID != "" {
if err := client.ReplaceLabels(ctx, server.CloudID, server.Labels); err != nil {
return server, daytonaError("replace labels", err)
}
if err := client.UpdateLastActivity(ctx, server.CloudID); err != nil {
fmt.Fprintf(b.rt.Stderr, "warning: daytona last-activity: %v\n", daytonaError("update last activity", err))
}
}
return server, nil
}
func waitForDaytonaReady(ctx context.Context, client daytonaAPI, id string, timeout time.Duration) (*daytona.Sandbox, error) {
deadline := time.Now().Add(timeout)
for {
sandbox, err := client.GetSandbox(ctx, id)
if err != nil {
return nil, daytonaError("get sandbox", err)
}
state := daytonaSandboxState(sandbox)
if daytonaStateReady(state) {
return sandbox, nil
}
if daytonaStateFailed(state) {
return nil, exit(5, "daytona sandbox %s entered terminal state=%s", id, state)
}
if time.Now().After(deadline) {
return nil, exit(5, "timed out waiting for daytona sandbox %s (state=%s)", id, state)
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(3 * time.Second):
}
}
}
func resolveDaytonaSandbox(ctx context.Context, client daytonaAPI, cfg Config, id string) (*daytona.Sandbox, string, error) {
if id == "" {
return nil, "", exit(2, "provider=daytona requires --id <sandbox-id-or-slug>")
}
sandboxes, err := client.ListCrabboxSandboxes(ctx)
if err != nil {
return nil, "", daytonaError("list sandboxes", err)
}
if isCanonicalLeaseID(id) {
for i := range sandboxes {
if sandboxes[i].Labels["lease"] == id {
return &sandboxes[i], id, nil
}
}
}
slug := normalizeLeaseSlug(id)
var matches []*daytona.Sandbox
for i := range sandboxes {
if slug != "" && normalizeLeaseSlug(sandboxes[i].Labels["slug"]) == slug {
matches = append(matches, &sandboxes[i])
}
}
if len(matches) > 1 {
return nil, "", exit(4, "daytona slug %q matches multiple sandboxes", id)
}
if len(matches) == 1 {
return matches[0], matches[0].Labels["lease"], nil
}
for i := range sandboxes {
if sandboxes[i].GetId() == id || sandboxes[i].GetName() == id || sandboxes[i].Labels["lease_name"] == id {
return &sandboxes[i], blank(sandboxes[i].Labels["lease"], id), nil
}
}
if claim, ok, err := resolveLeaseClaim(id); err != nil {
return nil, "", err
} else if ok && claim.Provider == daytonaProvider {
for i := range sandboxes {
if sandboxes[i].Labels["lease"] == claim.LeaseID {
return &sandboxes[i], claim.LeaseID, nil
}
}
}
sandbox, err := client.GetSandbox(ctx, id)
if err == nil && sandbox != nil && sandbox.GetId() != "" {
return sandbox, blank(sandbox.Labels["lease"], id), nil
}
_ = cfg
return nil, "", exit(4, "daytona sandbox not found: %s", id)
}
func daytonaSSHTargetFor(ctx context.Context, client daytonaAPI, cfg Config, server Server) (SSHTarget, error) {
access, err := client.CreateSSHAccess(ctx, server.CloudID, time.Duration(daytonaSSHAccessMinutes(cfg))*time.Minute)
if err != nil {
return SSHTarget{}, daytonaError("create ssh access", err)
}
return daytonaSSHTargetFromAccess(cfg, access)
}
func daytonaSSHTargetFromAccess(cfg Config, access daytonaSSHAccess) (SSHTarget, error) {
user := strings.TrimSpace(access.Token)
host := daytonaSSHGatewayHost(cfg)
port := "22"
if command := strings.TrimSpace(access.Command); command != "" {
parsedUser, parsedHost, parsedPort, err := parseDaytonaSSHCommand(command)
if err != nil {
return SSHTarget{}, err
}
user = parsedUser
host = parsedHost
port = parsedPort
}
if user == "" {
return SSHTarget{}, fmt.Errorf("daytona ssh access response missing token")
}
return SSHTarget{
User: user,
Host: host,
Port: port,
Key: "",
TargetOS: targetLinux,
ReadyCheck: "command -v git >/dev/null && command -v rsync >/dev/null && command -v tar >/dev/null",
AuthSecret: true,
NetworkKind: NetworkPublic,
}, nil
}
func parseDaytonaSSHCommand(command string) (string, string, string, error) {
fields := strings.Fields(command)
if len(fields) == 0 {
return "", "", "", fmt.Errorf("daytona ssh command is empty")
}
if fields[0] == "ssh" {
fields = fields[1:]
}
port := "22"
destination := ""
for i := 0; i < len(fields); i++ {
field := fields[i]
switch {
case field == "-p":
if i+1 >= len(fields) || strings.TrimSpace(fields[i+1]) == "" {
return "", "", "", fmt.Errorf("daytona ssh command missing -p value: %q", command)
}
i++
port = fields[i]
case strings.HasPrefix(field, "-p") && len(field) > 2:
port = strings.TrimPrefix(field, "-p")
case strings.HasPrefix(field, "-"):
return "", "", "", fmt.Errorf("daytona ssh command has unsupported option %q", field)
default:
destination = field
}
}
user, host, ok := strings.Cut(destination, "@")
if !ok || strings.TrimSpace(user) == "" || strings.TrimSpace(host) == "" {
return "", "", "", fmt.Errorf("daytona ssh command missing user@host destination: %q", command)
}
return user, host, port, nil
}
func daytonaSandboxesToServers(sandboxes []daytona.Sandbox, cfg Config) []Server {
servers := make([]Server, 0, len(sandboxes))
for i := range sandboxes {
servers = append(servers, daytonaSandboxToServer(&sandboxes[i], cfg))
}
return servers
}
func daytonaSandboxToServer(sandbox *daytona.Sandbox, cfg Config) Server {
labels := map[string]string{}
if sandbox != nil && sandbox.Labels != nil {
for k, v := range sandbox.Labels {
labels[k] = v
}
}
server := Server{Provider: daytonaProvider, Labels: labels}
if sandbox != nil {
server.CloudID = sandbox.GetId()
server.Name = sandbox.GetName()
server.Status = daytonaSandboxState(sandbox)
}
if server.Name == "" {
server.Name = blank(labels["lease_name"], server.CloudID)
}
server.ServerType.Name = blank(labels["server_type"], serverTypeForProviderClass(cfg.Provider, cfg.Class))
return server
}
func daytonaSandboxState(sandbox *daytona.Sandbox) string {
if sandbox == nil || sandbox.State == nil {
return ""
}
return string(*sandbox.State)
}
func daytonaStateReady(state string) bool {
switch strings.ToLower(strings.TrimSpace(state)) {
case "started", "running", "ready", "active":
return true
default:
return false
}
}
func daytonaStateFailed(state string) bool {
switch strings.ToLower(strings.TrimSpace(state)) {
case "error", "errored", "failed", "build_failed", "destroyed", "deleted":
return true
default:
return false
}
}
func daytonaUser(cfg Config) string {
return blank(strings.TrimSpace(cfg.Daytona.User), "daytona")
}
func daytonaWorkRoot(cfg Config) string {
return blank(strings.TrimSpace(cfg.Daytona.WorkRoot), "/home/"+daytonaUser(cfg)+"/crabbox")
}
func daytonaSSHGatewayHost(cfg Config) string {
return blank(strings.TrimSpace(cfg.Daytona.SSHGatewayHost), "ssh.app.daytona.io")
}
func daytonaSSHAccessMinutes(cfg Config) int {
if cfg.Daytona.SSHAccessMinutes > 0 {
return cfg.Daytona.SSHAccessMinutes
}
return 30
}
func redactedSSHUser(cfg Config, server Server, target SSHTarget) string {
if target.AuthSecret {
return daytonaTokenRedacted
}
if cfg.Provider == daytonaProvider || server.Provider == daytonaProvider {
return daytonaTokenRedacted
}
return target.User
}

View File

@ -0,0 +1,201 @@
package cli
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
daytona "github.com/daytonaio/daytona/libs/api-client-go"
sdkdaytona "github.com/daytonaio/daytona/libs/sdk-go/pkg/daytona"
sdktypes "github.com/daytonaio/daytona/libs/sdk-go/pkg/types"
)
type daytonaAPI interface {
CreateSandbox(context.Context, daytona.CreateSandbox) (*daytona.Sandbox, error)
GetSandbox(context.Context, string) (*daytona.Sandbox, error)
ListCrabboxSandboxes(context.Context) ([]daytona.Sandbox, error)
StartSandbox(context.Context, string) (*daytona.Sandbox, error)
DeleteSandbox(context.Context, string) error
ReplaceLabels(context.Context, string, map[string]string) error
UpdateLastActivity(context.Context, string) error
CreateSSHAccess(context.Context, string, time.Duration) (daytonaSSHAccess, error)
}
type daytonaSSHAccess struct {
Token string
Command string
}
type daytonaSDKClient struct {
api *daytona.APIClient
token string
orgID string
}
func newDaytonaClient(cfg Config, rt Runtime) (daytonaAPI, error) {
auth, err := daytonaAuthConfig(cfg)
if err != nil {
return nil, err
}
apiURL := strings.TrimRight(blank(cfg.Daytona.APIURL, "https://app.daytona.io/api"), "/")
apiCfg := daytona.NewConfiguration()
apiCfg.Servers = daytona.ServerConfigurations{{URL: apiURL}}
if rt.HTTP != nil {
apiCfg.HTTPClient = rt.HTTP
}
return &daytonaSDKClient{api: daytona.NewAPIClient(apiCfg), token: auth.token(), orgID: auth.OrganizationID}, nil
}
type daytonaAuth struct {
APIKey string
JWTToken string
OrganizationID string
}
func (a daytonaAuth) token() string {
if a.APIKey != "" {
return a.APIKey
}
return a.JWTToken
}
func daytonaAuthConfig(cfg Config) (daytonaAuth, error) {
auth := daytonaAuth{
APIKey: strings.TrimSpace(cfg.Daytona.APIKey),
JWTToken: strings.TrimSpace(cfg.Daytona.JWTToken),
OrganizationID: strings.TrimSpace(cfg.Daytona.OrganizationID),
}
if auth.APIKey == "" && auth.JWTToken == "" {
return daytonaAuth{}, exit(3, "provider=daytona requires DAYTONA_API_KEY or DAYTONA_JWT_TOKEN")
}
if auth.APIKey == "" && auth.JWTToken != "" && auth.OrganizationID == "" {
return daytonaAuth{}, exit(3, "provider=daytona with DAYTONA_JWT_TOKEN requires DAYTONA_ORGANIZATION_ID")
}
return auth, nil
}
func newDaytonaToolboxClient(cfg Config) (*sdkdaytona.Client, error) {
auth, err := daytonaAuthConfig(cfg)
if err != nil {
return nil, err
}
return sdkdaytona.NewClientWithConfig(&sdktypes.DaytonaConfig{
APIKey: auth.APIKey,
JWTToken: auth.JWTToken,
OrganizationID: auth.OrganizationID,
APIUrl: strings.TrimRight(blank(cfg.Daytona.APIURL, "https://app.daytona.io/api"), "/"),
Target: strings.TrimSpace(cfg.Daytona.Target),
})
}
func (c *daytonaSDKClient) ctx(ctx context.Context) context.Context {
return context.WithValue(ctx, daytona.ContextAccessToken, c.token)
}
func (c *daytonaSDKClient) CreateSandbox(ctx context.Context, body daytona.CreateSandbox) (*daytona.Sandbox, error) {
req := c.api.SandboxAPI.CreateSandbox(c.ctx(ctx)).CreateSandbox(body)
if c.orgID != "" {
req = req.XDaytonaOrganizationID(c.orgID)
}
out, _, err := req.Execute()
return out, err
}
func (c *daytonaSDKClient) GetSandbox(ctx context.Context, id string) (*daytona.Sandbox, error) {
req := c.api.SandboxAPI.GetSandbox(c.ctx(ctx), id).Verbose(true)
if c.orgID != "" {
req = req.XDaytonaOrganizationID(c.orgID)
}
out, _, err := req.Execute()
return out, err
}
func (c *daytonaSDKClient) ListCrabboxSandboxes(ctx context.Context) ([]daytona.Sandbox, error) {
filter, _ := json.Marshal(map[string]string{"crabbox": "true"})
var all []daytona.Sandbox
for page := float32(1); ; page++ {
req := c.api.SandboxAPI.ListSandboxesPaginated(c.ctx(ctx)).Page(page).Limit(100).Labels(string(filter))
if c.orgID != "" {
req = req.XDaytonaOrganizationID(c.orgID)
}
out, _, err := req.Execute()
if err != nil {
return nil, err
}
if out == nil {
return all, nil
}
all = append(all, out.GetItems()...)
if out.GetTotalPages() <= page || len(out.GetItems()) == 0 {
return all, nil
}
}
}
func (c *daytonaSDKClient) StartSandbox(ctx context.Context, id string) (*daytona.Sandbox, error) {
req := c.api.SandboxAPI.StartSandbox(c.ctx(ctx), id)
if c.orgID != "" {
req = req.XDaytonaOrganizationID(c.orgID)
}
out, _, err := req.Execute()
return out, err
}
func (c *daytonaSDKClient) DeleteSandbox(ctx context.Context, id string) error {
req := c.api.SandboxAPI.DeleteSandbox(c.ctx(ctx), id)
if c.orgID != "" {
req = req.XDaytonaOrganizationID(c.orgID)
}
_, _, err := req.Execute()
return err
}
func (c *daytonaSDKClient) ReplaceLabels(ctx context.Context, id string, labels map[string]string) error {
req := c.api.SandboxAPI.ReplaceLabels(c.ctx(ctx), id).SandboxLabels(*daytona.NewSandboxLabels(labels))
if c.orgID != "" {
req = req.XDaytonaOrganizationID(c.orgID)
}
_, _, err := req.Execute()
return err
}
func (c *daytonaSDKClient) UpdateLastActivity(ctx context.Context, id string) error {
req := c.api.SandboxAPI.UpdateLastActivity(c.ctx(ctx), id)
if c.orgID != "" {
req = req.XDaytonaOrganizationID(c.orgID)
}
_, err := req.Execute()
return err
}
func (c *daytonaSDKClient) CreateSSHAccess(ctx context.Context, id string, ttl time.Duration) (daytonaSSHAccess, error) {
req := c.api.SandboxAPI.CreateSshAccess(c.ctx(ctx), id).ExpiresInMinutes(float32(durationMinutesCeil(ttl)))
if c.orgID != "" {
req = req.XDaytonaOrganizationID(c.orgID)
}
out, _, err := req.Execute()
if err != nil {
return daytonaSSHAccess{}, err
}
if out == nil || out.GetToken() == "" {
return daytonaSSHAccess{}, fmt.Errorf("daytona ssh access response missing token")
}
return daytonaSSHAccess{Token: out.GetToken(), Command: out.GetSshCommand()}, nil
}
func daytonaError(action string, err error) error {
if err == nil {
return nil
}
var apiErr *daytona.GenericOpenAPIError
if errors.As(err, &apiErr) {
body := strings.TrimSpace(summarizeJSON(apiErr.Body()))
if body != "" {
return fmt.Errorf("daytona %s: %s: %s", action, apiErr.Error(), body)
}
}
return fmt.Errorf("daytona %s: %w", action, err)
}

View File

@ -0,0 +1,424 @@
package cli
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path"
"strings"
"time"
apidaytona "github.com/daytonaio/daytona/libs/api-client-go"
sdkdaytona "github.com/daytonaio/daytona/libs/sdk-go/pkg/daytona"
sdkoptions "github.com/daytonaio/daytona/libs/sdk-go/pkg/options"
sdktypes "github.com/daytonaio/daytona/libs/sdk-go/pkg/types"
)
func (b *daytonaLeaseBackend) Warmup(ctx context.Context, req WarmupRequest) error {
if req.ActionsRunner {
return exit(2, "--actions-runner is not supported for provider=daytona SDK warmup")
}
started := time.Now()
sandbox, leaseID, slug, err := b.createDaytonaToolboxSandbox(ctx, req.Repo, req.Keep, req.Reclaim)
if err != nil {
return err
}
fmt.Fprintf(b.rt.Stdout, "leased %s slug=%s provider=daytona sandbox=%s\n", leaseID, slug, sandbox.ID)
fmt.Fprintf(b.rt.Stdout, "warmup complete total=%s\n", time.Since(started).Round(time.Millisecond))
if req.TimingJSON {
return writeTimingJSON(b.rt.Stderr, timingReport{
Provider: daytonaProvider,
LeaseID: leaseID,
Slug: slug,
TotalMs: time.Since(started).Milliseconds(),
ExitCode: 0,
})
}
return nil
}
func (b *daytonaLeaseBackend) Run(ctx context.Context, req RunRequest) (RunResult, error) {
started := time.Now()
client, err := newDaytonaToolboxClient(b.cfg)
if err != nil {
return RunResult{}, err
}
var sandbox *sdkdaytona.Sandbox
leaseID, slug := "", ""
acquired := false
if req.ID == "" {
sandbox, leaseID, slug, err = b.createDaytonaToolboxSandboxWithClient(ctx, client, req.Repo, req.Keep, req.Reclaim)
if err != nil {
return RunResult{}, err
}
fmt.Fprintf(b.rt.Stderr, "leased %s slug=%s provider=daytona sandbox=%s\n", leaseID, slug, sandbox.ID)
acquired = true
} else {
sandbox, leaseID, err = b.resolveDaytonaToolboxSandbox(ctx, client, req.ID)
if err != nil {
return RunResult{}, err
}
slug = newLeaseSlug(leaseID)
if claim, ok, claimErr := resolveLeaseClaim(req.ID); claimErr != nil {
return RunResult{}, claimErr
} else if ok && claim.Provider == daytonaProvider {
slug = claim.Slug
if err := claimLeaseForRepoConfig(claim.LeaseID, claim.Slug, b.cfg, req.Repo.Root, b.cfg.IdleTimeout, req.Reclaim); err != nil {
return RunResult{}, err
}
}
}
if acquired && !req.Keep {
defer b.deleteDaytonaToolboxSandbox(context.Background(), sandbox.ID, leaseID)
}
cfg := b.cfg
cfg.Provider = daytonaProvider
cfg.WorkRoot = daytonaWorkRoot(cfg)
workdir := remoteJoin(cfg, leaseID, req.Repo.Name)
var syncDuration time.Duration
var syncPhases []timingPhase
if !req.NoSync {
syncStarted := time.Now()
syncPhases, err = b.syncDaytonaToolbox(ctx, sandbox, req, workdir)
syncDuration = time.Since(syncStarted)
if err != nil {
return RunResult{Total: time.Since(started), SyncDelegated: true}, err
}
fmt.Fprintf(b.rt.Stderr, "sync complete in %s\n", syncDuration.Round(time.Millisecond))
} else {
if _, err := sandbox.Process.ExecuteCommand(ctx, "mkdir -p "+shellQuote(workdir)); err != nil {
return RunResult{}, fmt.Errorf("daytona create workdir: %w", err)
}
}
if req.SyncOnly {
result := RunResult{Total: time.Since(started), SyncDelegated: true}
fmt.Fprintf(b.rt.Stdout, "synced %s\n", workdir)
if req.TimingJSON {
err := writeTimingJSON(b.rt.Stderr, timingReport{
Provider: daytonaProvider,
LeaseID: leaseID,
Slug: slug,
SyncMs: syncDuration.Milliseconds(),
SyncPhases: syncPhases,
SyncSkipped: req.NoSync,
TotalMs: result.Total.Milliseconds(),
ExitCode: 0,
})
return result, err
}
return result, nil
}
command := daytonaCommandString(req.Command, req.ShellMode)
if command == "" {
return RunResult{}, exit(2, "missing command")
}
commandStarted := time.Now()
fmt.Fprintf(b.rt.Stderr, "running on daytona %s\n", strings.Join(req.Command, " "))
execOpts := []func(*sdkoptions.ExecuteCommand){sdkoptions.WithCwd(workdir)}
if env := allowedEnv(req.Options.EnvAllow); len(env) > 0 {
execOpts = append(execOpts, sdkoptions.WithCommandEnv(env))
}
response, err := sandbox.Process.ExecuteCommand(ctx, command, execOpts...)
commandDuration := time.Since(commandStarted)
result := RunResult{
ExitCode: responseExitCode(response),
Command: commandDuration,
Total: time.Since(started),
SyncDelegated: true,
}
if response != nil && response.Result != "" {
fmt.Fprint(b.rt.Stdout, response.Result)
if !strings.HasSuffix(response.Result, "\n") {
fmt.Fprintln(b.rt.Stdout)
}
}
fmt.Fprintf(b.rt.Stderr, "daytona run summary sync=%s command=%s total=%s exit=%d\n", syncDuration.Round(time.Millisecond), result.Command.Round(time.Millisecond), result.Total.Round(time.Millisecond), result.ExitCode)
if req.TimingJSON {
if timingErr := writeTimingJSON(b.rt.Stderr, timingReport{
Provider: daytonaProvider,
LeaseID: leaseID,
Slug: slug,
SyncMs: syncDuration.Milliseconds(),
SyncPhases: syncPhases,
SyncSkipped: req.NoSync,
CommandMs: commandDuration.Milliseconds(),
TotalMs: result.Total.Milliseconds(),
ExitCode: result.ExitCode,
}); timingErr != nil {
return result, timingErr
}
}
if err != nil {
return result, ExitError{Code: 1, Message: fmt.Sprintf("daytona run failed: %v", err)}
}
if result.ExitCode != 0 {
return result, ExitError{Code: result.ExitCode, Message: fmt.Sprintf("daytona run exited %d", result.ExitCode)}
}
return result, nil
}
func (b *daytonaLeaseBackend) Status(ctx context.Context, req StatusRequest) (statusView, error) {
client, err := newDaytonaClient(b.cfg, b.rt)
if err != nil {
return statusView{}, err
}
deadline := time.Now().Add(req.WaitTimeout)
if req.WaitTimeout <= 0 {
deadline = time.Now().Add(5 * time.Minute)
}
for {
sandbox, leaseID, err := resolveDaytonaSandbox(ctx, client, b.cfg, req.ID)
if err != nil {
return statusView{}, err
}
view := daytonaStatusView(leaseID, sandbox, b.cfg)
if !req.Wait || view.Ready {
return view, nil
}
if time.Now().After(deadline) {
return statusView{}, exit(5, "timed out waiting for sandbox %s to become ready", req.ID)
}
select {
case <-ctx.Done():
return statusView{}, ctx.Err()
case <-time.After(2 * time.Second):
}
}
}
func (b *daytonaLeaseBackend) Stop(ctx context.Context, req StopRequest) error {
client, err := newDaytonaClient(b.cfg, b.rt)
if err != nil {
return err
}
sandbox, leaseID, err := resolveDaytonaSandbox(ctx, client, b.cfg, req.ID)
if err != nil {
return err
}
if err := client.DeleteSandbox(ctx, sandbox.GetId()); err != nil {
return daytonaError("delete sandbox", err)
}
removeLeaseClaim(leaseID)
fmt.Fprintf(b.rt.Stderr, "released lease=%s sandbox=%s\n", leaseID, sandbox.GetId())
return nil
}
func (b *daytonaLeaseBackend) createDaytonaToolboxSandbox(ctx context.Context, repo Repo, keep, reclaim bool) (*sdkdaytona.Sandbox, string, string, error) {
client, err := newDaytonaToolboxClient(b.cfg)
if err != nil {
return nil, "", "", err
}
return b.createDaytonaToolboxSandboxWithClient(ctx, client, repo, keep, reclaim)
}
func (b *daytonaLeaseBackend) createDaytonaToolboxSandboxWithClient(ctx context.Context, client *sdkdaytona.Client, repo Repo, keep, reclaim bool) (*sdkdaytona.Sandbox, string, string, error) {
if strings.TrimSpace(b.cfg.Daytona.Snapshot) == "" {
return nil, "", "", exit(2, "provider=daytona requires --daytona-snapshot or daytona.snapshot")
}
apiClient, err := newDaytonaClient(b.cfg, b.rt)
if err != nil {
return nil, "", "", err
}
leaseID := newLeaseID()
existing, err := apiClient.ListCrabboxSandboxes(ctx)
if err != nil {
return nil, "", "", daytonaError("list sandboxes", err)
}
slug := allocateDirectLeaseSlug(leaseID, daytonaSandboxesToServers(existing, b.cfg))
cfg := b.cfg
cfg.Provider = daytonaProvider
cfg.ServerType = "snapshot"
cfg.WorkRoot = daytonaWorkRoot(cfg)
cfg.SSHUser = daytonaUser(cfg)
cfg.SSHPort = "22"
labels := directLeaseLabels(cfg, leaseID, slug, daytonaProvider, "", keep, time.Now().UTC())
labels["lease_name"] = leaseProviderName(leaseID, slug)
labels["work_root"] = cfg.WorkRoot
autoStop := durationMinutesCeil(cfg.IdleTimeout)
fmt.Fprintf(b.rt.Stderr, "provisioning provider=daytona lease=%s slug=%s snapshot=%s target=%s keep=%v mode=sdk\n", leaseID, slug, cfg.Daytona.Snapshot, blank(cfg.Daytona.Target, "-"), keep)
sandbox, err := client.Create(ctx, sdktypes.SnapshotParams{
Snapshot: strings.TrimSpace(cfg.Daytona.Snapshot),
SandboxBaseParams: sdktypes.SandboxBaseParams{
Name: labels["lease_name"],
User: daytonaUser(cfg),
Labels: labels,
Public: true,
AutoStopInterval: &autoStop,
},
}, sdkoptions.WithTimeout(5*time.Minute))
if err != nil {
return nil, "", "", daytonaError("create sandbox", err)
}
if err := claimLeaseForRepoConfig(leaseID, slug, cfg, repo.Root, cfg.IdleTimeout, reclaim); err != nil {
_ = sandbox.Delete(context.Background())
return nil, "", "", err
}
labels["state"] = "ready"
labels["last_touched_at"] = leaseLabelTime(time.Now().UTC())
if err := apiClient.ReplaceLabels(ctx, sandbox.ID, labels); err != nil {
fmt.Fprintf(b.rt.Stderr, "warning: set labels: %v\n", daytonaError("replace labels", err))
}
return sandbox, leaseID, slug, nil
}
func (b *daytonaLeaseBackend) resolveDaytonaToolboxSandbox(ctx context.Context, sdkClient *sdkdaytona.Client, id string) (*sdkdaytona.Sandbox, string, error) {
apiClient, err := newDaytonaClient(b.cfg, b.rt)
if err != nil {
return nil, "", err
}
apiSandbox, leaseID, err := resolveDaytonaSandbox(ctx, apiClient, b.cfg, id)
if err != nil {
return nil, "", err
}
if !daytonaStateReady(daytonaSandboxState(apiSandbox)) {
if _, err := apiClient.StartSandbox(ctx, apiSandbox.GetId()); err != nil {
return nil, "", daytonaError("start sandbox", err)
}
if _, err := waitForDaytonaReady(ctx, apiClient, apiSandbox.GetId(), 5*time.Minute); err != nil {
return nil, "", err
}
}
sandbox, err := sdkClient.Get(ctx, apiSandbox.GetId())
if err != nil {
return nil, "", daytonaError("get sandbox", err)
}
return sandbox, leaseID, nil
}
func (b *daytonaLeaseBackend) deleteDaytonaToolboxSandbox(ctx context.Context, sandboxID, leaseID string) {
client, err := newDaytonaClient(b.cfg, b.rt)
if err != nil {
fmt.Fprintf(b.rt.Stderr, "warning: daytona stop failed for %s: %v\n", sandboxID, err)
return
}
if err := client.DeleteSandbox(ctx, sandboxID); err != nil {
fmt.Fprintf(b.rt.Stderr, "warning: daytona stop failed for %s: %v\n", sandboxID, daytonaError("delete sandbox", err))
return
}
removeLeaseClaim(leaseID)
}
func (b *daytonaLeaseBackend) syncDaytonaToolbox(ctx context.Context, sandbox *sdkdaytona.Sandbox, req RunRequest, workdir string) ([]timingPhase, error) {
start := time.Now()
excludes, err := syncExcludes(req.Repo.Root, b.cfg)
if err != nil {
return nil, err
}
manifestStarted := time.Now()
manifest, err := syncManifest(req.Repo.Root, excludes)
if err != nil {
return nil, exit(6, "build sync file list: %v", err)
}
manifestDuration := time.Since(manifestStarted)
preflightStarted := time.Now()
if err := checkSyncPreflight(manifest, b.cfg, req.ForceSyncLarge, b.rt.Stderr); err != nil {
return nil, err
}
preflightDuration := time.Since(preflightStarted)
archiveStarted := time.Now()
archive, err := createDaytonaSyncArchive(ctx, req.Repo, manifest, b.rt.Stderr)
if err != nil {
return nil, err
}
archiveDuration := time.Since(archiveStarted)
uploadStarted := time.Now()
archivePath := path.Join("/tmp", "crabbox-"+newLeaseID()+".tgz")
if err := sandbox.FileSystem.UploadFile(ctx, archive, archivePath); err != nil {
return nil, fmt.Errorf("daytona upload archive: %w", err)
}
uploadDuration := time.Since(uploadStarted)
extractStarted := time.Now()
deletePrefix := ""
if b.cfg.Sync.Delete {
deletePrefix = "rm -rf " + shellQuote(workdir) + " && "
}
extractCommand := deletePrefix + "mkdir -p " + shellQuote(workdir) + " && tar -xzf " + shellQuote(archivePath) + " -C " + shellQuote(workdir) + " && rm -f " + shellQuote(archivePath)
if response, err := sandbox.Process.ExecuteCommand(ctx, extractCommand); err != nil {
return nil, fmt.Errorf("daytona extract archive: %w", err)
} else if responseExitCode(response) != 0 {
return nil, exit(responseExitCode(response), "daytona extract archive exited %d: %s", responseExitCode(response), response.Result)
}
extractDuration := time.Since(extractStarted)
manifestWriteStarted := time.Now()
metaDir := path.Join(workdir, ".crabbox")
if err := sandbox.FileSystem.CreateFolder(ctx, metaDir); err != nil {
return nil, fmt.Errorf("daytona create metadata dir: %w", err)
}
if err := sandbox.FileSystem.UploadFile(ctx, manifest.NUL(), path.Join(metaDir, "sync-manifest")); err != nil {
return nil, fmt.Errorf("daytona upload sync manifest: %w", err)
}
manifestWriteDuration := time.Since(manifestWriteStarted)
phases := []timingPhase{
{Name: "manifest", Ms: manifestDuration.Milliseconds()},
{Name: "preflight", Ms: preflightDuration.Milliseconds()},
{Name: "archive", Ms: archiveDuration.Milliseconds()},
{Name: "upload", Ms: uploadDuration.Milliseconds()},
{Name: "extract", Ms: extractDuration.Milliseconds()},
{Name: "manifest_write", Ms: manifestWriteDuration.Milliseconds()},
{Name: "toolbox_sync", Ms: time.Since(start).Milliseconds()},
}
return phases, nil
}
func createDaytonaSyncArchive(ctx context.Context, repo Repo, manifest SyncManifest, stderr anyWriter) ([]byte, error) {
var input bytes.Buffer
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
if err := cmd.Run(); err != nil {
return nil, exit(6, "create sync archive: %v", err)
}
return archive.Bytes(), nil
}
func daytonaCommandString(command []string, shellMode bool) string {
if len(command) == 0 {
return ""
}
if shellMode {
return strings.Join(command, " ")
}
if shouldUseShell(command) || leadingEnvAssignment(command) {
return shellScriptFromArgv(command)
}
return strings.Join(shellWords(command), " ")
}
func daytonaStatusView(leaseID string, sandbox *apidaytona.Sandbox, cfg Config) statusView {
server := daytonaSandboxToServer(sandbox, cfg)
state := server.Status
if !daytonaStateReady(state) {
state = blank(server.Labels["state"], state)
}
return statusView{
ID: leaseID,
Slug: serverSlug(server),
Provider: daytonaProvider,
TargetOS: targetLinux,
State: state,
ServerID: server.DisplayID(),
ServerType: server.ServerType.Name,
Network: NetworkPublic,
Ready: daytonaStateReady(state) || daytonaStateReady(server.Status),
HasHost: true,
LastTouchedAt: blank(leaseLabelTimeDisplay(server.Labels["last_touched_at"]),
server.Labels["last_touched_at"]),
IdleFor: idleForString(server.Labels["last_touched_at"], time.Now()),
IdleTimeout: leaseLabelDurationDisplay(server.Labels["idle_timeout_secs"], server.Labels["idle_timeout"]),
ExpiresAt: blank(leaseLabelTimeDisplay(server.Labels["expires_at"]), server.Labels["expires_at"]),
Labels: server.Labels,
}
}
func responseExitCode(response *sdktypes.ExecuteResponse) int {
if response == nil {
return 1
}
return response.ExitCode
}

View File

@ -0,0 +1,434 @@
package cli
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"flag"
"fmt"
"strings"
"time"
gosdk "github.com/islo-labs/go-sdk"
)
const (
isloProvider = "islo"
isloLeasePrefix = "isb_"
isloNamePrefix = "crabbox-"
)
type isloFlagValues struct {
BaseURL *string
Image *string
Workdir *string
GatewayProfile *string
SnapshotName *string
VCPUs *int
MemoryMB *int
DiskGB *int
}
func RegisterIsloProviderFlags(fs *flag.FlagSet, defaults Config) any {
return isloFlagValues{
BaseURL: fs.String("islo-base-url", defaults.Islo.BaseURL, "Islo API base URL"),
Image: fs.String("islo-image", defaults.Islo.Image, "Islo sandbox image"),
Workdir: fs.String("islo-workdir", defaults.Islo.Workdir, "Islo sandbox working directory under /workspace"),
GatewayProfile: fs.String("islo-gateway-profile", defaults.Islo.GatewayProfile, "Islo gateway profile name or id"),
SnapshotName: fs.String("islo-snapshot-name", defaults.Islo.SnapshotName, "Islo snapshot name"),
VCPUs: fs.Int("islo-vcpus", defaults.Islo.VCPUs, "Islo sandbox vCPUs"),
MemoryMB: fs.Int("islo-memory-mb", defaults.Islo.MemoryMB, "Islo sandbox memory in MB"),
DiskGB: fs.Int("islo-disk-gb", defaults.Islo.DiskGB, "Islo sandbox disk in GB"),
}
}
func ApplyIsloProviderFlags(cfg *Config, fs *flag.FlagSet, values any) error {
v, ok := values.(isloFlagValues)
if !ok {
return nil
}
if flagWasSet(fs, "islo-base-url") {
cfg.Islo.BaseURL = *v.BaseURL
}
if flagWasSet(fs, "islo-image") {
cfg.Islo.Image = *v.Image
}
if flagWasSet(fs, "islo-workdir") {
cfg.Islo.Workdir = *v.Workdir
}
if flagWasSet(fs, "islo-gateway-profile") {
cfg.Islo.GatewayProfile = *v.GatewayProfile
}
if flagWasSet(fs, "islo-snapshot-name") {
cfg.Islo.SnapshotName = *v.SnapshotName
}
if flagWasSet(fs, "islo-vcpus") {
cfg.Islo.VCPUs = *v.VCPUs
}
if flagWasSet(fs, "islo-memory-mb") {
cfg.Islo.MemoryMB = *v.MemoryMB
}
if flagWasSet(fs, "islo-disk-gb") {
cfg.Islo.DiskGB = *v.DiskGB
}
return nil
}
func NewIsloBackend(spec ProviderSpec, cfg Config, rt Runtime) Backend {
cfg.Provider = isloProvider
return &isloBackend{spec: spec, cfg: cfg, rt: rt}
}
type isloBackend struct {
spec ProviderSpec
cfg Config
rt Runtime
}
func (b *isloBackend) Spec() ProviderSpec { return b.spec }
func (b *isloBackend) Warmup(ctx context.Context, req WarmupRequest) error {
started := b.now()
client, err := newIsloClient(b.cfg, b.rt)
if err != nil {
return err
}
leaseID, name, slug, err := b.createSandbox(ctx, client, req.Repo, req.Reclaim)
if err != nil {
return err
}
fmt.Fprintf(b.rt.Stdout, "leased %s slug=%s provider=islo sandbox=%s\n", leaseID, slug, name)
if !req.Keep {
fmt.Fprintf(b.rt.Stderr, "warning: islo warmup keeps the sandbox until explicit stop\n")
}
total := b.now().Sub(started)
fmt.Fprintf(b.rt.Stdout, "warmup complete total=%s\n", total.Round(time.Millisecond))
if req.TimingJSON {
return writeTimingJSON(b.rt.Stderr, timingReport{
Provider: isloProvider,
LeaseID: leaseID,
Slug: slug,
TotalMs: total.Milliseconds(),
ExitCode: 0,
})
}
return nil
}
func (b *isloBackend) Run(ctx context.Context, req RunRequest) (RunResult, error) {
if err := rejectDelegatedSyncOptions(isloProvider, req); err != nil {
return RunResult{}, err
}
started := b.now()
client, err := newIsloClient(b.cfg, b.rt)
if err != nil {
return RunResult{}, err
}
leaseID, name := "", ""
acquired := false
if req.ID == "" {
var slug string
leaseID, name, slug, err = b.createSandbox(ctx, client, req.Repo, req.Reclaim)
if err != nil {
return RunResult{}, err
}
fmt.Fprintf(b.rt.Stderr, "leased %s slug=%s provider=islo sandbox=%s\n", leaseID, slug, name)
acquired = true
} else {
leaseID, name, err = resolveIsloLeaseID(req.ID, req.Repo.Root, req.Reclaim)
if err != nil {
return RunResult{}, err
}
}
if acquired && !req.Keep {
defer func() {
if err := client.DeleteSandbox(context.Background(), name); err != nil {
fmt.Fprintf(b.rt.Stderr, "warning: islo stop failed for %s: %v\n", name, err)
return
}
removeLeaseClaim(leaseID)
}()
}
fmt.Fprintf(b.rt.Stderr, "provider=islo lease=%s sandbox=%s\n", leaseID, name)
commandStart := b.now()
exitCode, runErr := b.exec(ctx, client, name, req.Command, req.ShellMode)
commandDuration := b.now().Sub(commandStart)
result := RunResult{
ExitCode: exitCode,
Command: commandDuration,
Total: b.now().Sub(started),
SyncDelegated: true,
}
fmt.Fprintf(b.rt.Stderr, "islo run summary command=%s total=%s exit=%d\n", result.Command.Round(time.Millisecond), result.Total.Round(time.Millisecond), exitCode)
if req.TimingJSON {
if err := writeTimingJSON(b.rt.Stderr, timingReport{
Provider: isloProvider,
LeaseID: leaseID,
SyncDelegated: true,
SyncPhases: []timingPhase{{Name: "delegated", Skipped: true, Reason: "islo owns sandbox state"}},
CommandMs: result.Command.Milliseconds(),
TotalMs: result.Total.Milliseconds(),
ExitCode: exitCode,
}); err != nil {
return result, err
}
}
if runErr != nil {
return result, ExitError{Code: 1, Message: fmt.Sprintf("islo run failed: %v", runErr)}
}
if exitCode != 0 {
return result, ExitError{Code: exitCode, Message: fmt.Sprintf("islo run exited %d", exitCode)}
}
return result, nil
}
func (b *isloBackend) List(ctx context.Context, req ListRequest) ([]LeaseView, error) {
_ = req
client, err := newIsloClient(b.cfg, b.rt)
if err != nil {
return nil, err
}
sandboxes, err := client.ListSandboxes(ctx)
if err != nil {
return nil, isloError("list sandboxes", err)
}
servers := make([]Server, 0, len(sandboxes))
for _, sandbox := range sandboxes {
if sandbox == nil || !isCrabboxIsloSandboxName(sandbox.GetName()) {
continue
}
servers = append(servers, isloSandboxToServer(sandbox))
}
return servers, nil
}
func (b *isloBackend) Status(ctx context.Context, req StatusRequest) (statusView, error) {
client, err := newIsloClient(b.cfg, b.rt)
if err != nil {
return statusView{}, err
}
leaseID, name, err := resolveIsloLeaseID(req.ID, "", false)
if err != nil {
return statusView{}, err
}
deadline := b.now().Add(req.WaitTimeout)
if req.WaitTimeout <= 0 {
deadline = b.now().Add(5 * time.Minute)
}
for {
sandbox, err := client.GetSandbox(ctx, name)
if err != nil {
return statusView{}, isloError("get sandbox", err)
}
view := isloStatusView(leaseID, sandbox)
if !req.Wait || view.Ready {
return view, nil
}
if b.now().After(deadline) {
return statusView{}, exit(5, "timed out waiting for sandbox %s to become ready", name)
}
select {
case <-ctx.Done():
return statusView{}, ctx.Err()
case <-time.After(2 * time.Second):
}
}
}
func (b *isloBackend) Stop(ctx context.Context, req StopRequest) error {
client, err := newIsloClient(b.cfg, b.rt)
if err != nil {
return err
}
leaseID, name, err := resolveIsloLeaseID(req.ID, "", false)
if err != nil {
return err
}
if err := client.DeleteSandbox(ctx, name); err != nil {
return isloError("delete sandbox", err)
}
removeLeaseClaim(leaseID)
fmt.Fprintf(b.rt.Stderr, "released lease=%s sandbox=%s\n", leaseID, name)
return nil
}
func (b *isloBackend) createSandbox(ctx context.Context, client isloAPI, repo Repo, reclaim bool) (string, string, string, error) {
name := newIsloSandboxName(repo)
create := &gosdk.SandboxCreate{Name: stringValue(name)}
if b.cfg.Islo.Image != "" {
create.Image = stringValue(b.cfg.Islo.Image)
}
if b.cfg.Islo.Workdir != "" {
create.Workdir = stringValue(b.cfg.Islo.Workdir)
}
if b.cfg.Islo.GatewayProfile != "" {
create.GatewayProfile = stringValue(b.cfg.Islo.GatewayProfile)
}
if b.cfg.Islo.SnapshotName != "" {
create.SnapshotName = stringValue(b.cfg.Islo.SnapshotName)
}
if b.cfg.Islo.VCPUs > 0 {
create.Vcpus = intValue(b.cfg.Islo.VCPUs)
}
if b.cfg.Islo.MemoryMB > 0 {
create.MemoryMb = intValue(b.cfg.Islo.MemoryMB)
}
if b.cfg.Islo.DiskGB > 0 {
create.DiskGb = intValue(b.cfg.Islo.DiskGB)
}
sandbox, err := client.CreateSandbox(ctx, create)
if err != nil {
return "", "", "", isloError("create sandbox", err)
}
if sandbox == nil || sandbox.GetName() == "" {
return "", "", "", exit(5, "islo create sandbox returned no name")
}
leaseID := isloLeasePrefix + sandbox.GetName()
slug := newLeaseSlug(leaseID)
if err := claimLeaseForRepoProvider(leaseID, slug, isloProvider, repo.Root, b.cfg.IdleTimeout, reclaim); err != nil {
_ = client.DeleteSandbox(context.Background(), sandbox.GetName())
return "", "", "", err
}
return leaseID, sandbox.GetName(), slug, nil
}
func (b *isloBackend) exec(ctx context.Context, client isloAPI, name string, command []string, shellMode bool) (int, error) {
if len(command) == 0 {
return 2, errors.New("missing command")
}
execCommand := command
if shellMode || shouldUseShell(command) || leadingEnvAssignment(command) {
execCommand = []string{"bash", "-lc", shellScriptFromArgv(command)}
}
req := &gosdk.ExecRequest{Command: execCommand}
if b.cfg.Islo.Workdir != "" {
req.Workdir = stringValue(b.cfg.Islo.Workdir)
}
return client.ExecStream(ctx, name, req, b.rt.Stdout, b.rt.Stderr)
}
func resolveIsloLeaseID(id, repoRoot string, reclaim bool) (string, string, error) {
if id == "" {
return "", "", exit(2, "provider=islo requires a Crabbox-created sandbox name, lease id, or slug")
}
if strings.HasPrefix(id, isloLeasePrefix) {
name := strings.TrimPrefix(id, isloLeasePrefix)
if !isCrabboxIsloSandboxName(name) {
return "", "", exit(4, "islo lease %q is not a Crabbox-owned sandbox", id)
}
return id, name, nil
}
if claim, ok, err := resolveLeaseClaim(id); err != nil {
return "", "", err
} else if ok && claim.Provider == isloProvider {
if repoRoot != "" {
if err := claimLeaseForRepoProvider(claim.LeaseID, claim.Slug, isloProvider, repoRoot, time.Duration(claim.IdleTimeoutSeconds)*time.Second, reclaim); err != nil {
return "", "", err
}
}
return claim.LeaseID, strings.TrimPrefix(claim.LeaseID, isloLeasePrefix), nil
}
if !isCrabboxIsloSandboxName(id) {
return "", "", exit(4, "islo sandbox %q is not claimed by Crabbox; use a Crabbox slug or %s<crabbox-sandbox-name>", id, isloLeasePrefix)
}
return isloLeasePrefix + id, id, nil
}
func isloSandboxToServer(sandbox *gosdk.SandboxResponse) Server {
if sandbox == nil {
return Server{Provider: isloProvider, Labels: map[string]string{"provider": isloProvider}}
}
labels := map[string]string{
"provider": isloProvider,
"lease": isloLeasePrefix + sandbox.GetName(),
"slug": newLeaseSlug(isloLeasePrefix + sandbox.GetName()),
"target": targetLinux,
"state": sandbox.GetStatus(),
}
return Server{
Provider: isloProvider,
CloudID: sandbox.GetID(),
Name: sandbox.GetName(),
Status: sandbox.GetStatus(),
Labels: labels,
}
}
func isloStatusView(leaseID string, sandbox *gosdk.SandboxResponse) statusView {
name := strings.TrimPrefix(leaseID, isloLeasePrefix)
status := ""
image := ""
if sandbox != nil {
name = sandbox.GetName()
status = sandbox.GetStatus()
image = sandbox.GetImage()
}
return statusView{
ID: leaseID,
Slug: newLeaseSlug(leaseID),
Provider: isloProvider,
TargetOS: targetLinux,
State: status,
ServerID: name,
ServerType: image,
Network: NetworkPublic,
Ready: isloStatusReady(status),
Labels: map[string]string{
"provider": isloProvider,
"lease": leaseID,
"state": status,
},
}
}
func isloStatusReady(status string) bool {
switch strings.ToLower(strings.TrimSpace(status)) {
case "ready", "running", "started", "active":
return true
default:
return false
}
}
func newIsloSandboxName(repo Repo) string {
base := normalizeLeaseSlug(repo.Name)
if base == "" {
base = "crabbox"
}
base = strings.TrimPrefix(base, strings.TrimSuffix(isloNamePrefix, "-")+"-")
return isloNamePrefix + base + "-" + isloRandomSuffix()
}
func isCrabboxIsloSandboxName(name string) bool {
return strings.HasPrefix(normalizeLeaseSlug(name), isloNamePrefix)
}
func isloRandomSuffix() string {
var b [3]byte
if _, err := rand.Read(b[:]); err != nil {
return fmt.Sprintf("%x", time.Now().UnixNano())[:6]
}
return hex.EncodeToString(b[:])
}
func leadingEnvAssignment(command []string) bool {
return len(command) > 1 && strings.Contains(command[0], "=") && !strings.HasPrefix(command[0], "-")
}
func stringValue(v string) *string { return &v }
func intValue(v int) *int { return &v }
func isloError(action string, err error) error {
if err == nil {
return nil
}
return fmt.Errorf("islo %s: %w", action, err)
}
func (b *isloBackend) now() time.Time {
if b.rt.Clock != nil {
return b.rt.Clock.Now()
}
return time.Now()
}

View File

@ -0,0 +1,172 @@
package cli
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
gosdk "github.com/islo-labs/go-sdk"
"github.com/islo-labs/go-sdk/client"
"github.com/islo-labs/go-sdk/customauth"
"github.com/islo-labs/go-sdk/option"
)
type isloAPI interface {
CreateSandbox(context.Context, *gosdk.SandboxCreate) (*gosdk.SandboxResponse, error)
GetSandbox(context.Context, string) (*gosdk.SandboxResponse, error)
ListSandboxes(context.Context) ([]*gosdk.SandboxResponse, error)
DeleteSandbox(context.Context, string) error
ExecStream(context.Context, string, *gosdk.ExecRequest, io.Writer, io.Writer) (int, error)
}
type isloSDKClient struct {
sdk *client.Client
auth *customauth.Provider
baseURL string
httpClient *http.Client
}
func newIsloClient(cfg Config, rt Runtime) (isloAPI, error) {
apiKey := strings.TrimSpace(cfg.Islo.APIKey)
if apiKey == "" {
return nil, exit(2, "provider=islo requires ISLO_API_KEY")
}
baseURL := strings.TrimRight(blank(cfg.Islo.BaseURL, "https://api.islo.dev"), "/")
httpClient := rt.HTTP
if httpClient == nil {
httpClient = http.DefaultClient
}
auth := customauth.NewProvider(baseURL, apiKey, 0, httpClient)
var baseTransport http.RoundTripper
var timeout time.Duration
if httpClient != nil {
baseTransport = httpClient.Transport
timeout = httpClient.Timeout
}
sdkHTTPClient := &http.Client{
Transport: customauth.NewTransport(baseTransport, auth),
Timeout: timeout,
}
sdk := client.NewClient(option.WithBaseURL(baseURL), option.WithHTTPClient(sdkHTTPClient))
return &isloSDKClient{sdk: sdk, auth: auth, baseURL: baseURL, httpClient: httpClient}, nil
}
func (c *isloSDKClient) CreateSandbox(ctx context.Context, req *gosdk.SandboxCreate) (*gosdk.SandboxResponse, error) {
return c.sdk.Sandboxes.CreateSandbox(ctx, req)
}
func (c *isloSDKClient) GetSandbox(ctx context.Context, name string) (*gosdk.SandboxResponse, error) {
return c.sdk.Sandboxes.GetSandbox(ctx, &gosdk.GetSandboxRequest{SandboxName: name})
}
func (c *isloSDKClient) ListSandboxes(ctx context.Context) ([]*gosdk.SandboxResponse, error) {
limit := 100
var all []*gosdk.SandboxResponse
for offset := 0; ; offset += limit {
page, err := c.sdk.Sandboxes.ListSandboxes(ctx, &gosdk.ListSandboxesRequest{Limit: &limit, Offset: &offset})
if err != nil {
return nil, err
}
if page == nil {
return all, nil
}
items := page.GetItems()
all = append(all, items...)
if len(items) < limit {
return all, nil
}
if total := page.GetTotal(); total > 0 && offset+len(items) >= total {
return all, nil
}
}
}
func (c *isloSDKClient) DeleteSandbox(ctx context.Context, name string) error {
_, err := c.sdk.Sandboxes.DeleteSandbox(ctx, &gosdk.DeleteSandboxRequest{SandboxName: name})
return err
}
func (c *isloSDKClient) ExecStream(ctx context.Context, name string, req *gosdk.ExecRequest, stdout, stderr io.Writer) (int, error) {
body, err := json.Marshal(req)
if err != nil {
return 1, fmt.Errorf("encode exec request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/sandboxes/"+name+"/exec/stream", bytes.NewReader(body))
if err != nil {
return 1, err
}
token, err := c.auth.Token(ctx)
if err != nil {
return 1, fmt.Errorf("islo auth: %w", err)
}
httpReq.Header.Set("Authorization", "Bearer "+token)
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "text/event-stream")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return 1, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return 1, fmt.Errorf("islo exec stream %s: %s", resp.Status, strings.TrimSpace(string(snippet)))
}
return parseIsloSSE(resp.Body, stdout, stderr)
}
func parseIsloSSE(r io.Reader, stdout, stderr io.Writer) (int, error) {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
exitCode := 0
event := ""
var data []string
flush := func() {
if event == "" && len(data) == 0 {
return
}
payload := strings.Join(data, "\n")
switch event {
case "stdout":
_, _ = stdout.Write([]byte(payload))
case "stderr":
_, _ = stderr.Write([]byte(payload))
case "exit":
if n, err := strconv.Atoi(strings.TrimSpace(payload)); err == nil {
exitCode = n
}
}
event = ""
data = data[:0]
}
for scanner.Scan() {
line := scanner.Text()
if line == "" {
flush()
continue
}
if strings.HasPrefix(line, ":") {
continue
}
field, value, found := strings.Cut(line, ":")
if !found {
field = line
value = ""
}
value = strings.TrimPrefix(value, " ")
switch field {
case "event":
event = value
case "data":
data = append(data, value)
}
}
flush()
return exitCode, scanner.Err()
}

View File

@ -0,0 +1,132 @@
package cli
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
)
func TestParseIsloSSE(t *testing.T) {
body := strings.Join([]string{
"event: stdout",
"data: hello",
"",
"event: stderr",
"data: warn",
"",
"event: exit",
"data: 7",
"",
}, "\n")
var stdout, stderr bytes.Buffer
code, err := parseIsloSSE(strings.NewReader(body), &stdout, &stderr)
if err != nil {
t.Fatal(err)
}
if code != 7 || stdout.String() != "hello" || stderr.String() != "warn" {
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
}
}
func TestLeadingEnvAssignmentUsesShell(t *testing.T) {
if !leadingEnvAssignment([]string{"FOO=bar", "pnpm", "test"}) {
t.Fatal("expected leading env assignment to require shell")
}
if leadingEnvAssignment([]string{"pnpm", "test"}) {
t.Fatal("plain argv should not require shell")
}
}
func TestIsloStatusReady(t *testing.T) {
for _, status := range []string{"ready", "running", "started", "active"} {
if !isloStatusReady(status) {
t.Fatalf("expected %q ready", status)
}
}
if isloStatusReady("stopped") {
t.Fatal("stopped should not be ready")
}
}
func TestResolveIsloLeaseIDRejectsUnclaimedRawSandbox(t *testing.T) {
if _, _, err := resolveIsloLeaseID("production", "", false); err == nil {
t.Fatal("expected raw non-Crabbox sandbox to be rejected")
}
leaseID, name, err := resolveIsloLeaseID("crabbox-repo-abcdef", "", false)
if err != nil {
t.Fatal(err)
}
if leaseID != "isb_crabbox-repo-abcdef" || name != "crabbox-repo-abcdef" {
t.Fatalf("lease=%q name=%q", leaseID, name)
}
}
func TestNewIsloSandboxNameUsesCrabboxPrefix(t *testing.T) {
name := newIsloSandboxName(Repo{Name: "repo"})
if !strings.HasPrefix(name, "crabbox-repo-") {
t.Fatalf("name=%q", name)
}
if !isCrabboxIsloSandboxName(name) {
t.Fatalf("expected %q to be recognized as Crabbox-owned", name)
}
}
func TestIsloSDKClientListUsesInjectedHTTPAndPaginates(t *testing.T) {
authHits := 0
listHits := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/auth/token":
authHits++
_ = json.NewEncoder(w).Encode(map[string]any{
"session_token": "jwt-from-test",
"cookie_max_age": 3600,
})
case "/sandboxes/":
listHits++
if got := r.Header.Get("Authorization"); got != "Bearer jwt-from-test" {
t.Fatalf("Authorization=%q", got)
}
offset := r.URL.Query().Get("offset")
offsetValue, _ := strconv.Atoi(offset)
items := []map[string]any{}
if offset == "0" {
for i := 0; i < 100; i++ {
items = append(items, map[string]any{"id": "id", "name": "crabbox-a", "status": "running", "image": "ubuntu"})
}
} else if offset == "100" {
items = append(items, map[string]any{"id": "id", "name": "crabbox-b", "status": "running", "image": "ubuntu"})
} else {
t.Fatalf("unexpected offset=%q", offset)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"items": items,
"total": 101,
"limit": 100,
"offset": offsetValue,
})
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
api, err := newIsloClient(Config{Islo: IsloConfig{APIKey: "ak_test", BaseURL: srv.URL}}, Runtime{HTTP: srv.Client()})
if err != nil {
t.Fatal(err)
}
items, err := api.ListSandboxes(t.Context())
if err != nil {
t.Fatal(err)
}
if len(items) != 101 {
t.Fatalf("items=%d", len(items))
}
if authHits != 1 || listHits != 2 {
t.Fatalf("authHits=%d listHits=%d", authHits, listHits)
}
}

View File

@ -7,6 +7,8 @@ func init() {
RegisterProvider(testAWSProvider{})
RegisterProvider(testStaticSSHProvider{})
RegisterProvider(testBlacksmithProvider{})
RegisterProvider(testDaytonaProvider{})
RegisterProvider(testIsloProvider{})
}
type testHetznerProvider struct{}
@ -108,3 +110,49 @@ func (testBlacksmithProvider) ApplyFlags(cfg *Config, fs *flag.FlagSet, values a
func (p testBlacksmithProvider) Configure(cfg Config, rt Runtime) (Backend, error) {
return NewBlacksmithBackend(p.Spec(), cfg, rt), nil
}
type testDaytonaProvider struct{}
func (testDaytonaProvider) Name() string { return "daytona" }
func (testDaytonaProvider) Aliases() []string { return nil }
func (testDaytonaProvider) Spec() ProviderSpec {
return ProviderSpec{
Name: daytonaProvider,
Kind: ProviderKindSSHLease,
Targets: []TargetSpec{{OS: targetLinux}},
Features: FeatureSet{FeatureSSH, FeatureCrabboxSync},
Coordinator: CoordinatorNever,
}
}
func (testDaytonaProvider) RegisterFlags(fs *flag.FlagSet, defaults Config) any {
return RegisterDaytonaProviderFlags(fs, defaults)
}
func (testDaytonaProvider) ApplyFlags(cfg *Config, fs *flag.FlagSet, values any) error {
return ApplyDaytonaProviderFlags(cfg, fs, values)
}
func (p testDaytonaProvider) Configure(cfg Config, rt Runtime) (Backend, error) {
return NewDaytonaLeaseBackend(p.Spec(), cfg, rt), nil
}
type testIsloProvider struct{}
func (testIsloProvider) Name() string { return isloProvider }
func (testIsloProvider) Aliases() []string { return nil }
func (testIsloProvider) Spec() ProviderSpec {
return ProviderSpec{
Name: isloProvider,
Kind: ProviderKindDelegatedRun,
Targets: []TargetSpec{{OS: targetLinux}},
Features: nil,
Coordinator: CoordinatorNever,
}
}
func (testIsloProvider) RegisterFlags(fs *flag.FlagSet, defaults Config) any {
return RegisterIsloProviderFlags(fs, defaults)
}
func (testIsloProvider) ApplyFlags(cfg *Config, fs *flag.FlagSet, values any) error {
return ApplyIsloProviderFlags(cfg, fs, values)
}
func (p testIsloProvider) Configure(cfg Config, rt Runtime) (Backend, error) {
return NewIsloBackend(p.Spec(), cfg, rt), nil
}

View File

@ -103,17 +103,14 @@ func (a App) warmup(ctx context.Context, args []string) error {
fmt.Fprintf(a.Stderr, "network fallback %s\n", resolved.FallbackReason)
}
}
network := NetworkPublic
if target.Host != server.PublicNet.IPv4.IP && target.Host != "" {
network = NetworkTailscale
}
network := readyNetworkDisplay(cfg, server, target)
meta := serverTailscaleMetadata(server)
tailscaleSummary := ""
if meta.Enabled {
tailscaleSummary = " tailscale=" + blank(tailscaleTargetHost(meta), blank(meta.State, "requested"))
}
fmt.Fprintf(a.Stdout, "leased %s slug=%s provider=%s server=%s type=%s ip=%s%s idle_timeout=%s expires=%s\n", leaseID, blank(serverSlug(server), "-"), cfg.Provider, server.DisplayID(), server.ServerType.Name, server.PublicNet.IPv4.IP, tailscaleSummary, cfg.IdleTimeout, blank(leaseLabelTimeDisplay(server.Labels["expires_at"]), server.Labels["expires_at"]))
fmt.Fprintf(a.Stdout, "ready ssh=%s@%s:%s network=%s workroot=%s\n", target.User, target.Host, target.Port, network, cfg.WorkRoot)
fmt.Fprintf(a.Stdout, "ready ssh=%s@%s:%s network=%s workroot=%s\n", redactedSSHUser(cfg, server, target), target.Host, target.Port, network, cfg.WorkRoot)
if *actionsRunner {
ghRepo, err := resolveGitHubRepo(repo, cfg.Actions.Repo)
if err != nil {
@ -708,6 +705,22 @@ func applyResolvedServerConfig(cfg *Config, server Server) {
if server.ServerType.Name != "" {
cfg.ServerType = server.ServerType.Name
}
if root := server.Labels["work_root"]; root != "" {
cfg.WorkRoot = root
}
}
func readyNetworkDisplay(cfg Config, server Server, target SSHTarget) NetworkMode {
if target.NetworkKind != "" {
return target.NetworkKind
}
if cfg.Provider == "daytona" || server.Provider == "daytona" {
return NetworkPublic
}
if target.Host != server.PublicNet.IPv4.IP && target.Host != "" {
return NetworkTailscale
}
return NetworkPublic
}
func coordinatorFallbackSummary(lease CoordinatorLease) string {
@ -924,7 +937,7 @@ func findServerByAlias(servers []Server, id string) (Server, string, error) {
func (a App) stop(ctx context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("stop", a.Stderr)
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, ssh, or blacksmith-testbox")
provider := fs.String("provider", defaults.Provider, providerHelpAll())
providerFlags := registerProviderFlags(fs, defaults)
targetFlags := registerTargetFlags(fs, defaults)
if err := parseFlags(fs, args); err != nil {

View File

@ -28,6 +28,8 @@ type SSHTarget struct {
TargetOS string
WindowsMode string
ReadyCheck string
AuthSecret bool
NetworkKind NetworkMode
}
func sshTargetFromConfig(cfg Config, host string) SSHTarget {
@ -285,9 +287,7 @@ func sshBaseArgs(target SSHTarget) []string {
}
func sshBaseArgsWithOptions(target SSHTarget, connectTimeout, connectionAttempts string) []string {
return []string{
"-i", target.Key,
"-o", "IdentitiesOnly=yes",
args := []string{
"-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UserKnownHostsFile=" + sshConfigFileValue(knownHostsFile(target)),
@ -295,11 +295,21 @@ func sshBaseArgsWithOptions(target SSHTarget, connectTimeout, connectionAttempts
"-o", "ConnectionAttempts=" + connectionAttempts,
"-o", "ServerAliveInterval=15",
"-o", "ServerAliveCountMax=2",
"-o", "ControlMaster=auto",
"-o", "ControlPersist=60s",
"-o", "ControlPath=" + sshControlPath(target),
"-p", target.Port,
}
if target.AuthSecret {
args = append(args, "-o", "ControlMaster=no")
} else {
args = append(args,
"-o", "ControlMaster=auto",
"-o", "ControlPersist=60s",
"-o", "ControlPath="+sshControlPath(target),
)
}
if target.Key != "" {
args = append([]string{"-i", target.Key, "-o", "IdentitiesOnly=yes"}, args...)
}
return args
}
func minDuration(left, right time.Duration) time.Duration {

View File

@ -3,14 +3,16 @@ package cli
import (
"context"
"fmt"
"strings"
)
func (a App) ssh(ctx context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("ssh", a.Stderr)
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or ssh")
provider := fs.String("provider", defaults.Provider, providerHelpSSH())
id := fs.String("id", "", "lease id or slug")
reclaim := fs.Bool("reclaim", false, "claim this lease for the current repo")
showSecret := fs.Bool("show-secret", false, "print secret auth material for token-based SSH providers")
targetFlags := registerTargetFlags(fs, defaults)
networkFlags := registerNetworkModeFlag(fs, defaults)
if err := parseFlags(fs, args); err != nil {
@ -31,6 +33,19 @@ func (a App) ssh(ctx context.Context, args []string) error {
if err := a.claimAndTouchLeaseTarget(ctx, cfg, server, leaseID, *reclaim); err != nil {
return err
}
fmt.Fprintf(a.Stdout, "ssh -i %s -p %s %s@%s\n", target.Key, target.Port, target.User, target.Host)
if target.AuthSecret && !*showSecret {
fmt.Fprintf(a.Stderr, "warning: ssh auth user is secret; rerun with --show-secret to print a pasteable command\n")
}
fmt.Fprintln(a.Stdout, sshCommandLine(target, target.AuthSecret && !*showSecret))
return nil
}
func sshCommandLine(target SSHTarget, redactSecret bool) string {
renderTarget := target
if redactSecret {
renderTarget.User = daytonaTokenRedacted
}
args := append([]string{"ssh"}, sshBaseArgs(renderTarget)...)
args = append(args, renderTarget.User+"@"+renderTarget.Host)
return strings.Join(shellWords(args), " ")
}

View File

@ -228,6 +228,61 @@ func TestSSHArgsIncludeReliabilityOptions(t *testing.T) {
}
}
func TestSSHArgsAllowTokenUserWithoutIdentityFile(t *testing.T) {
t.Setenv("HOME", "/tmp/crabbox-home")
got := strings.Join(sshArgs(SSHTarget{
User: "tok_live_secret",
Host: "ssh.app.daytona.io",
Port: "22",
}, "true"), "\n")
for _, unwanted := range []string{"-i\n", "IdentitiesOnly=yes"} {
if strings.Contains(got, unwanted) {
t.Fatalf("sshArgs() should omit key-only option %q when target key is empty: %q", unwanted, got)
}
}
if !strings.Contains(got, "tok_live_secret@ssh.app.daytona.io") {
t.Fatalf("sshArgs() missing token user target: %q", got)
}
}
func TestSSHArgsAuthSecretDisablesControlMaster(t *testing.T) {
t.Setenv("HOME", "/tmp/crabbox-home")
got := strings.Join(sshArgs(SSHTarget{
User: "tok_live_secret",
Host: "ssh.app.daytona.io",
Port: "22",
AuthSecret: true,
}, "true"), "\n")
for _, unwanted := range []string{"ControlMaster=auto", "ControlPersist=", "ControlPath="} {
if strings.Contains(got, unwanted) {
t.Fatalf("sshArgs() should omit mux option %q for secret auth target: %q", unwanted, got)
}
}
if !strings.Contains(got, "ControlMaster=no") {
t.Fatalf("sshArgs() missing ControlMaster=no for secret auth target: %q", got)
}
}
func TestSSHCommandLineRedactsSecretAuthUser(t *testing.T) {
target := SSHTarget{
User: "tok_live_secret",
Host: "ssh.app.daytona.io",
Port: "22",
AuthSecret: true,
}
redacted := sshCommandLine(target, true)
if strings.Contains(redacted, target.User) {
t.Fatalf("redacted command leaked token: %q", redacted)
}
if !strings.Contains(redacted, daytonaTokenRedacted+"@ssh.app.daytona.io") {
t.Fatalf("redacted command missing placeholder user: %q", redacted)
}
full := sshCommandLine(target, false)
if !strings.Contains(full, target.User+"@ssh.app.daytona.io") {
t.Fatalf("full command missing token user: %q", full)
}
}
func TestSSHTransportProbeDoesNotRequireCrabboxReady(t *testing.T) {
got := sshTransportProbeCommand(SSHTarget{Host: "100.64.0.10", Port: "2222"})
if strings.Contains(got, "crabbox-ready") || strings.Contains(got, "git --version") || strings.Contains(got, "/work/crabbox") {

View File

@ -10,7 +10,7 @@ import (
func (a App) status(ctx context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("status", a.Stderr)
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, ssh, or blacksmith-testbox")
provider := fs.String("provider", defaults.Provider, providerHelpAll())
id := fs.String("id", "", "lease id or slug")
wait := fs.Bool("wait", false, "wait until ready")
waitTimeout := fs.Duration("wait-timeout", 5*time.Minute, "maximum wait duration")
@ -87,6 +87,12 @@ func statusViewFromLeaseTarget(ctx context.Context, cfg Config, lease LeaseTarge
server := lease.Server
target := lease.SSH
hasHost := server.PublicNet.IPv4.IP != ""
if target.NetworkKind == NetworkPublic && target.Host != "" {
hasHost = true
}
if (cfg.Provider == "daytona" || server.Provider == "daytona") && target.Host != "" {
hasHost = true
}
resolved, err := resolveNetworkTarget(ctx, cfg, server, target)
if err != nil {
return statusView{}, err
@ -112,7 +118,7 @@ func statusViewFromLeaseTarget(ctx context.Context, cfg Config, lease LeaseTarge
Network: resolved.Network,
Tailscale: tailscale,
SSHHost: target.Host,
SSHUser: target.User,
SSHUser: redactedSSHUser(cfg, server, target),
SSHPort: target.Port,
SSHFallbackPorts: target.FallbackPorts,
SSHKey: target.Key,

View File

@ -3,6 +3,8 @@ package all
import (
_ "github.com/openclaw/crabbox/internal/providers/aws"
_ "github.com/openclaw/crabbox/internal/providers/blacksmith"
_ "github.com/openclaw/crabbox/internal/providers/daytona"
_ "github.com/openclaw/crabbox/internal/providers/hetzner"
_ "github.com/openclaw/crabbox/internal/providers/islo"
_ "github.com/openclaw/crabbox/internal/providers/ssh"
)

View File

@ -0,0 +1,36 @@
package daytona
import (
"flag"
"github.com/openclaw/crabbox/internal/cli"
)
func init() {
cli.RegisterProvider(Provider{})
}
type Provider struct{}
func (Provider) Name() string { return "daytona" }
func (Provider) Aliases() []string {
return nil
}
func (Provider) Spec() cli.ProviderSpec {
return cli.ProviderSpec{
Name: "daytona",
Kind: cli.ProviderKindSSHLease,
Targets: []cli.TargetSpec{{OS: "linux"}},
Features: cli.FeatureSet{cli.FeatureSSH, cli.FeatureCrabboxSync},
Coordinator: cli.CoordinatorNever,
}
}
func (Provider) RegisterFlags(fs *flag.FlagSet, defaults cli.Config) any {
return cli.RegisterDaytonaProviderFlags(fs, defaults)
}
func (Provider) ApplyFlags(cfg *cli.Config, fs *flag.FlagSet, values any) error {
return cli.ApplyDaytonaProviderFlags(cfg, fs, values)
}
func (p Provider) Configure(cfg cli.Config, rt cli.Runtime) (cli.Backend, error) {
return cli.NewDaytonaLeaseBackend(p.Spec(), cfg, rt), nil
}

View File

@ -0,0 +1,36 @@
package islo
import (
"flag"
"github.com/openclaw/crabbox/internal/cli"
)
func init() {
cli.RegisterProvider(Provider{})
}
type Provider struct{}
func (Provider) Name() string { return "islo" }
func (Provider) Aliases() []string {
return nil
}
func (Provider) Spec() cli.ProviderSpec {
return cli.ProviderSpec{
Name: "islo",
Kind: cli.ProviderKindDelegatedRun,
Targets: []cli.TargetSpec{{OS: "linux"}},
Features: nil,
Coordinator: cli.CoordinatorNever,
}
}
func (Provider) RegisterFlags(fs *flag.FlagSet, defaults cli.Config) any {
return cli.RegisterIsloProviderFlags(fs, defaults)
}
func (Provider) ApplyFlags(cfg *cli.Config, fs *flag.FlagSet, values any) error {
return cli.ApplyIsloProviderFlags(cfg, fs, values)
}
func (p Provider) Configure(cfg cli.Config, rt cli.Runtime) (cli.Backend, error) {
return cli.NewIsloBackend(p.Spec(), cfg, rt), nil
}