Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

- Added Azure `--azure-os-disk ephemeral-preview` / `azure.osDisk: ephemeral-preview` for opt-in ephemeral OS disk full caching through Azure Compute API `2025-04-01`. Thanks @jwmoss.
- Added configurable capacity-admin owner caps for coordinators that need elevated active lease limits for trusted operators.
- Added `provider: ascii-box` for [ASCII Box](https://box.ascii.dev) Ubuntu sandbox SSH leases, using the documented `box --json` CLI for create/list/status/delete and standard Crabbox SSH sync/run. Thanks @zozo123.

### Changed

Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ Targets: **L**inux, **M**acOS, **W**indows.
| [Sprites](docs/providers/sprites.md) | `sprites` | L | direct | Sprites microVMs through `sprite proxy`. |
| [Daytona](docs/providers/daytona.md) | `daytona` | L | direct | Daytona-managed dev sandbox over SSH. |
| [RunPod](docs/providers/runpod.md) | `runpod` (`run-pod`, `runpodio`) | L | direct | RunPod GPU pods with public SSH. |
| [ASCII Box](docs/providers/ascii-box.md) | `ascii-box` (`ascii`, `asciibox`) | L | direct | ASCII Box Ubuntu sandboxes exposed as SSH leases. |

### Delegated-run providers (sandbox/proof runners, no SSH lease)

Expand Down Expand Up @@ -386,10 +387,10 @@ blacksmith:

Keep provider tokens in environment variables, not repo config (for example
`CRABBOX_SEMAPHORE_TOKEN`, `CRABBOX_SPRITES_TOKEN`, `RUNPOD_API_KEY`,
`E2B_API_KEY`, `DAYTONA_API_KEY`). The full env-var reference, per-provider
sections, and per-command flags are in [docs/cli.md](docs/cli.md),
[Configuration](docs/features/configuration.md), and the
[provider docs](docs/providers/README.md).
`ASCII_BOX_API_KEY`, `E2B_API_KEY`, `DAYTONA_API_KEY`). The full env-var
reference, per-provider sections, and per-command flags are in
[docs/cli.md](docs/cli.md), [Configuration](docs/features/configuration.md),
and the [provider docs](docs/providers/README.md).

## OpenClaw plugin

Expand Down
3 changes: 3 additions & 0 deletions docs/providers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ Each page below maps to an adapter under `internal/providers/<dir>`. The
| [Sprites](sprites.md) | `sprites` | — | Linux | no (direct) |
| [Daytona](daytona.md) | `daytona` | — | Linux | no (direct) |
| [RunPod](runpod.md) | `runpod` | `run-pod`, `runpodio` | Linux | no (direct) |
| [ASCII Box](ascii-box.md) | `ascii-box` | `ascii`, `asciibox` | Linux | no (direct) |

### Delegated run

Expand Down Expand Up @@ -90,6 +91,8 @@ reports.
distinct adapters.
- Tensorlake is Crabbox's Firecracker-backed delegated provider; Crabbox does
not provision raw Firecracker instances directly.
- ASCII Box is an SSH-lease provider. Crabbox uses the documented `box --json`
CLI for lifecycle/status/delete, then runs normal sync and commands over SSH.
- Capability flags (`--desktop`, `--browser`, `--code`, VNC) are validated
against each provider's declared feature set. Among the SSH-lease providers,
desktop/browser/code surfaces are richest on `aws`, `azure`, `hetzner`,
Expand Down
97 changes: 97 additions & 0 deletions docs/providers/ascii-box.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# ASCII Box Provider

Read when:

- choosing `provider: ascii-box`;
- configuring the ASCII Box API endpoint or workdir;
- changing `internal/providers/asciibox`.

[ASCII Box](https://box.ascii.dev) provides Ubuntu sandbox VMs. Crabbox uses the
documented `box --json` CLI as the control plane, lets `box ssh` prepare the
CLI-managed SSH key, and then runs normal Crabbox sync and commands over SSH.
The provider does not depend on private exec, upload, or command-stream REST
endpoints.

## When To Use

Use ASCII Box when commands should run in ASCII-managed Ubuntu sandboxes through
the `box` CLI's SSH endpoint. Use a delegated provider such as Upstash Box,
Modal, E2B, Islo, or Cloudflare when the provider owns command execution instead
of exposing SSH.

## Prerequisites

- Create an ASCII Box account at <https://box.ascii.dev>.
- Export the API key as `ASCII_BOX_API_KEY` or `CRABBOX_ASCII_BOX_API_KEY`.
- Install the official `box` CLI. Crabbox writes a private Box CLI config from
the API key under its state directory and does not require a pre-existing
`box login`.

## Commands

```sh
crabbox warmup --provider ascii-box
crabbox run --provider ascii-box -- pnpm test
crabbox run --provider ascii-box --id blue-lobster --shell 'pnpm install && pnpm test'
crabbox status --provider ascii-box --id blue-lobster
crabbox stop --provider ascii-box blue-lobster
```

## Auth

```sh
export ASCII_BOX_API_KEY=...
```

`CRABBOX_ASCII_BOX_BASE_URL` or `asciiBox.baseUrl` can override the default
`https://ascii.dev`.

## Config

```yaml
provider: ascii-box
target: linux
asciiBox:
baseUrl: https://ascii.dev
cliPath: box
workdir: /home/user/crabbox
```

Provider flags:

```text
--ascii-box-base-url
--ascii-box-cli
--ascii-box-workdir
```

Environment overrides:

```text
CRABBOX_ASCII_BOX_API_KEY / ASCII_BOX_API_KEY
CRABBOX_ASCII_BOX_BASE_URL / ASCII_BOX_BASE_URL
CRABBOX_ASCII_BOX_CLI / BOX_CLI
CRABBOX_ASCII_BOX_HOME
CRABBOX_ASCII_BOX_WORKDIR
```

## Lifecycle

1. `crabbox warmup --provider ascii-box` creates a Box through `box new --json`,
stores the returned Box id in a local lease claim, prepares the SSH key with
`box ssh <id> -- true`, waits for SSH, and keeps the Box until
`crabbox stop`. The default SSH key lives in the private Box CLI home
(`CRABBOX_ASCII_BOX_HOME`, otherwise Crabbox state).
2. `crabbox run --provider ascii-box` provisions a Box for one run, or reuses an
existing lease/slug/id, then uses the standard SSH sync and run path.
3. `crabbox status` resolves the local lease claim or raw Box id and reads Box
state through `box info --json`.
4. `crabbox stop` releases the Box with `box stop --json`, removes the Box
record with `box delete --json`, and removes the local lease claim.

## Limitations

- `--class`, `--type`, image, size, and keep-alive Box options are not exposed
because the public CLI lifecycle surface does not document them.
- Desktop/VNC/code features are not advertised through Crabbox for this
provider. Use the official Box tools directly for interactive sessions.
35 changes: 35 additions & 0 deletions internal/cli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ type Config struct {
Tensorlake TensorlakeConfig
Modal ModalConfig
UpstashBox UpstashBoxConfig
AsciiBox AsciiBoxConfig
Cloudflare CloudflareConfig
Semaphore SemaphoreConfig
Sprites SpritesConfig
Expand Down Expand Up @@ -318,6 +319,13 @@ type UpstashBoxConfig struct {
KeepAlive bool
}

type AsciiBoxConfig struct {
APIKey string
BaseURL string
CLIPath string
Workdir string
}

type CloudflareConfig struct {
APIURL string
Token string
Expand Down Expand Up @@ -797,6 +805,11 @@ func baseConfig() Config {
Size: "small",
Workdir: "/workspace/home/crabbox",
},
AsciiBox: AsciiBoxConfig{
BaseURL: "https://ascii.dev",
CLIPath: "box",
Workdir: "/home/user/crabbox",
},
Cloudflare: CloudflareConfig{
Workdir: "/workspace/crabbox",
},
Expand Down Expand Up @@ -879,6 +892,7 @@ type fileConfig struct {
Tensorlake *fileTensorlakeConfig `yaml:"tensorlake,omitempty"`
Modal *fileModalConfig `yaml:"modal,omitempty"`
UpstashBox *fileUpstashBoxConfig `yaml:"upstashBox,omitempty"`
AsciiBox *fileAsciiBoxConfig `yaml:"asciiBox,omitempty"`
Cloudflare *fileCloudflareConfig `yaml:"cloudflare,omitempty"`
Semaphore *fileSemaphoreConfig `yaml:"semaphore,omitempty"`
Sprites *fileSpritesConfig `yaml:"sprites,omitempty"`
Expand Down Expand Up @@ -1194,6 +1208,12 @@ type fileUpstashBoxConfig struct {
KeepAlive *bool `yaml:"keepAlive,omitempty"`
}

type fileAsciiBoxConfig struct {
BaseURL string `yaml:"baseUrl,omitempty"`
CLIPath string `yaml:"cliPath,omitempty"`
Workdir string `yaml:"workdir,omitempty"`
}

type fileCloudflareConfig struct {
APIURL string `yaml:"apiUrl,omitempty"`
Token string `yaml:"token,omitempty"`
Expand Down Expand Up @@ -2209,6 +2229,17 @@ func applyFileConfig(cfg *Config, file fileConfig) {
cfg.UpstashBox.KeepAlive = *file.UpstashBox.KeepAlive
}
}
if file.AsciiBox != nil {
if file.AsciiBox.BaseURL != "" {
cfg.AsciiBox.BaseURL = file.AsciiBox.BaseURL
}
if file.AsciiBox.CLIPath != "" {
cfg.AsciiBox.CLIPath = file.AsciiBox.CLIPath
}
if file.AsciiBox.Workdir != "" {
cfg.AsciiBox.Workdir = file.AsciiBox.Workdir
}
}
applyCloudflareFileConfig(cfg, file.Cloudflare)
if file.Semaphore != nil {
if file.Semaphore.Host != "" {
Expand Down Expand Up @@ -2913,6 +2944,10 @@ func applyEnv(cfg *Config) {
if value, ok := getenvBool("CRABBOX_UPSTASH_BOX_KEEP_ALIVE"); ok {
cfg.UpstashBox.KeepAlive = value
}
cfg.AsciiBox.APIKey = getenv("CRABBOX_ASCII_BOX_API_KEY", getenv("ASCII_BOX_API_KEY", cfg.AsciiBox.APIKey))
cfg.AsciiBox.BaseURL = getenv("CRABBOX_ASCII_BOX_BASE_URL", getenv("ASCII_BOX_BASE_URL", cfg.AsciiBox.BaseURL))
cfg.AsciiBox.CLIPath = getenv("CRABBOX_ASCII_BOX_CLI", getenv("BOX_CLI", cfg.AsciiBox.CLIPath))
cfg.AsciiBox.Workdir = getenv("CRABBOX_ASCII_BOX_WORKDIR", cfg.AsciiBox.Workdir)
cfg.Cloudflare.APIURL = getenv("CRABBOX_CLOUDFLARE_RUNNER_URL", cfg.Cloudflare.APIURL)
cfg.Cloudflare.Token = getenv("CRABBOX_CLOUDFLARE_RUNNER_TOKEN", cfg.Cloudflare.Token)
cfg.Cloudflare.Workdir = getenv("CRABBOX_CLOUDFLARE_WORKDIR", cfg.Cloudflare.Workdir)
Expand Down
7 changes: 7 additions & 0 deletions internal/cli/config_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,12 @@ func configShowView(cfg Config) map[string]any {
"workdir": cfg.UpstashBox.Workdir,
"keepAlive": cfg.UpstashBox.KeepAlive,
},
"asciiBox": map[string]any{
"baseUrl": cfg.AsciiBox.BaseURL,
"auth": tokenState(cfg.AsciiBox.APIKey),
"cliPath": cfg.AsciiBox.CLIPath,
"workdir": cfg.AsciiBox.Workdir,
},
"static": map[string]any{
"id": cfg.Static.ID,
"name": cfg.Static.Name,
Expand Down Expand Up @@ -232,6 +238,7 @@ func writeConfigShowText(w io.Writer, cfg Config) {
fmt.Fprintf(w, "namespace image=%s size=%s repository=%s site=%s volume_size_gb=%d auto_stop_idle_timeout=%s work_root=%s delete_on_release=%t\n", cfg.Namespace.Image, blank(cfg.Namespace.Size, "-"), blank(cfg.Namespace.Repository, "-"), blank(cfg.Namespace.Site, "-"), cfg.Namespace.VolumeSizeGB, cfg.Namespace.AutoStopIdleTimeout, cfg.Namespace.WorkRoot, cfg.Namespace.DeleteOnRelease)
fmt.Fprintf(w, "e2b api_url=%s domain=%s template=%s workdir=%s user=%s\n", cfg.E2B.APIURL, cfg.E2B.Domain, cfg.E2B.Template, cfg.E2B.Workdir, blank(cfg.E2B.User, "-"))
fmt.Fprintf(w, "upstash_box base_url=%s runtime=%s size=%s workdir=%s keep_alive=%t auth=%s\n", cfg.UpstashBox.BaseURL, cfg.UpstashBox.Runtime, cfg.UpstashBox.Size, cfg.UpstashBox.Workdir, cfg.UpstashBox.KeepAlive, tokenState(cfg.UpstashBox.APIKey))
fmt.Fprintf(w, "ascii_box base_url=%s cli=%s workdir=%s auth=%s\n", cfg.AsciiBox.BaseURL, cfg.AsciiBox.CLIPath, cfg.AsciiBox.Workdir, tokenState(cfg.AsciiBox.APIKey))
fmt.Fprintf(w, "cloudflare api_url=%s workdir=%s auth=%s\n", blank(cfg.Cloudflare.APIURL, "-"), cfg.Cloudflare.Workdir, tokenState(cfg.Cloudflare.Token))
fmt.Fprintf(w, "static id=%s name=%s host=%s user=%s port=%s work_root=%s\n", blank(cfg.Static.ID, "-"), blank(cfg.Static.Name, "-"), blank(cfg.Static.Host, "-"), blank(cfg.Static.User, "-"), blank(cfg.Static.Port, "-"), blank(cfg.Static.WorkRoot, "-"))
fmt.Fprintf(w, "results junit=%s auto=%t\n", blank(strings.Join(cfg.Results.JUnit, ","), "-"), cfg.Results.Auto)
Expand Down
34 changes: 34 additions & 0 deletions internal/cli/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,13 @@ func clearConfigEnv(t *testing.T) {
"CRABBOX_TENSORLAKE_DISK_MB",
"CRABBOX_TENSORLAKE_TIMEOUT_SECS",
"CRABBOX_TENSORLAKE_NO_INTERNET",
"CRABBOX_ASCII_BOX_API_KEY",
"ASCII_BOX_API_KEY",
"CRABBOX_ASCII_BOX_BASE_URL",
"ASCII_BOX_BASE_URL",
"CRABBOX_ASCII_BOX_CLI",
"BOX_CLI",
"CRABBOX_ASCII_BOX_WORKDIR",
"CRABBOX_WANDB_API_KEY",
"WANDB_API_KEY",
"CRABBOX_WANDB_DEFAULT_IMAGE",
Expand Down Expand Up @@ -172,6 +179,33 @@ func clearConfigEnv(t *testing.T) {
}
}

func TestAsciiBoxConfigDefaultsFileAndEnv(t *testing.T) {
clearConfigEnv(t)
cfg := baseConfig()
applyFileConfig(&cfg, fileConfig{
Provider: "ascii-box",
AsciiBox: &fileAsciiBoxConfig{
BaseURL: "https://box.example.test",
CLIPath: "/tmp/box",
Workdir: "/home/user/project",
},
})
if cfg.Provider != "ascii-box" || cfg.AsciiBox.BaseURL != "https://box.example.test" || cfg.AsciiBox.CLIPath != "/tmp/box" || cfg.AsciiBox.Workdir != "/home/user/project" {
t.Fatalf("file asciiBox config not applied: %#v", cfg.AsciiBox)
}

t.Setenv("ASCII_BOX_API_KEY", "fallback-key")
t.Setenv("ASCII_BOX_BASE_URL", "https://fallback.example.test")
t.Setenv("CRABBOX_ASCII_BOX_API_KEY", "override-key")
t.Setenv("CRABBOX_ASCII_BOX_BASE_URL", "https://override.example.test")
t.Setenv("CRABBOX_ASCII_BOX_CLI", "/opt/box")
t.Setenv("CRABBOX_ASCII_BOX_WORKDIR", "/home/user/env-project")
applyEnv(&cfg)
if cfg.AsciiBox.APIKey != "override-key" || cfg.AsciiBox.BaseURL != "https://override.example.test" || cfg.AsciiBox.CLIPath != "/opt/box" || cfg.AsciiBox.Workdir != "/home/user/env-project" {
t.Fatalf("env asciiBox config not applied: %#v", cfg.AsciiBox)
}
}

func TestRepoConfigBareEnvWildcardDoesNotForwardEveryLocalVariable(t *testing.T) {
clearConfigEnv(t)
home := t.TempDir()
Expand Down
27 changes: 14 additions & 13 deletions internal/cli/ssh.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,19 @@ import (
)

type SSHTarget struct {
User string
Host string
Key string
Port string
FallbackPorts []string
TargetOS string
WindowsMode string
ReadyCheck string
AuthSecret bool
NetworkKind NetworkMode
SSHConfigProxy bool
ProxyCommand string
User string
Host string
Key string
Port string
FallbackPorts []string
TargetOS string
WindowsMode string
ReadyCheck string
AuthSecret bool
NoControlMaster bool
NetworkKind NetworkMode
SSHConfigProxy bool
ProxyCommand string
}

func isLocalMacTarget(target SSHTarget) bool {
Expand Down Expand Up @@ -492,7 +493,7 @@ func sshBaseArgsWithOptions(target SSHTarget, connectTimeout, connectionAttempts
"-o", "ServerAliveCountMax=2",
"-p", target.Port,
}
if target.AuthSecret {
if target.AuthSecret || target.NoControlMaster {
args = append(args, "-o", "ControlMaster=no")
} else if runtime.GOOS == "windows" {
// Windows OpenSSH does not support Unix domain sockets for
Expand Down
19 changes: 19 additions & 0 deletions internal/cli/ssh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,25 @@ func TestSSHArgsAuthSecretDisablesControlMaster(t *testing.T) {
}
}

func TestSSHArgsNoControlMaster(t *testing.T) {
t.Setenv("HOME", "/tmp/crabbox-home")
got := strings.Join(sshArgs(SSHTarget{
User: "user",
Host: "203.0.113.10",
Port: "22",
Key: "/tmp/key",
NoControlMaster: true,
}, "true"), "\n")
for _, unwanted := range []string{"ControlMaster=auto", "ControlPersist=", "ControlPath="} {
if strings.Contains(got, unwanted) {
t.Fatalf("sshArgs() should omit mux option %q: %q", unwanted, got)
}
}
if !strings.Contains(got, "ControlMaster=no") {
t.Fatalf("sshArgs() missing ControlMaster=no: %q", got)
}
}

func TestShouldRetrySSHPortOnlyForTransportExit(t *testing.T) {
if !shouldRetrySSHPort(exec.Command("sh", "-c", "exit 255").Run()) {
t.Fatal("ssh transport exit 255 should retry fallback ports")
Expand Down
1 change: 1 addition & 0 deletions internal/providers/all/all.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package all

import (
_ "github.com/openclaw/crabbox/internal/providers/asciibox"
_ "github.com/openclaw/crabbox/internal/providers/aws"
_ "github.com/openclaw/crabbox/internal/providers/azure"
_ "github.com/openclaw/crabbox/internal/providers/azuredynamicsessions"
Expand Down
1 change: 1 addition & 0 deletions internal/providers/all/all_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ func TestAllBuiltInProvidersExposeDoctor(t *testing.T) {
"ssh",
"tensorlake",
"upstash-box",
"ascii-box",
"wandb",
}
for _, name := range providers {
Expand Down
Loading