diff --git a/CHANGELOG.md b/CHANGELOG.md
index a21d9338..f1ca94f1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/README.md b/README.md
index 68b6b293..388e35fa 100644
--- a/README.md
+++ b/README.md
@@ -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)
@@ -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
diff --git a/docs/providers/README.md b/docs/providers/README.md
index 1318e9ef..9a3e626a 100644
--- a/docs/providers/README.md
+++ b/docs/providers/README.md
@@ -63,6 +63,7 @@ Each page below maps to an adapter under `internal/providers/
`. 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
@@ -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`,
diff --git a/docs/providers/ascii-box.md b/docs/providers/ascii-box.md
new file mode 100644
index 00000000..e7bd6613
--- /dev/null
+++ b/docs/providers/ascii-box.md
@@ -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 .
+- 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 -- 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.
diff --git a/internal/cli/config.go b/internal/cli/config.go
index 85a2e758..32ac6ef3 100644
--- a/internal/cli/config.go
+++ b/internal/cli/config.go
@@ -113,6 +113,7 @@ type Config struct {
Tensorlake TensorlakeConfig
Modal ModalConfig
UpstashBox UpstashBoxConfig
+ AsciiBox AsciiBoxConfig
Cloudflare CloudflareConfig
Semaphore SemaphoreConfig
Sprites SpritesConfig
@@ -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
@@ -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",
},
@@ -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"`
@@ -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"`
@@ -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 != "" {
@@ -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)
diff --git a/internal/cli/config_cmd.go b/internal/cli/config_cmd.go
index 10c47231..2446011c 100644
--- a/internal/cli/config_cmd.go
+++ b/internal/cli/config_cmd.go
@@ -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,
@@ -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)
diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go
index 2818fb08..ebff6640 100644
--- a/internal/cli/config_test.go
+++ b/internal/cli/config_test.go
@@ -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",
@@ -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()
diff --git a/internal/cli/ssh.go b/internal/cli/ssh.go
index 0a0fd5b7..a830a13e 100644
--- a/internal/cli/ssh.go
+++ b/internal/cli/ssh.go
@@ -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 {
@@ -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
diff --git a/internal/cli/ssh_test.go b/internal/cli/ssh_test.go
index 46ba59cb..65ce0720 100644
--- a/internal/cli/ssh_test.go
+++ b/internal/cli/ssh_test.go
@@ -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")
diff --git a/internal/providers/all/all.go b/internal/providers/all/all.go
index 371ab3e0..902dc6e6 100644
--- a/internal/providers/all/all.go
+++ b/internal/providers/all/all.go
@@ -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"
diff --git a/internal/providers/all/all_test.go b/internal/providers/all/all_test.go
index be701d8f..9b4b6e03 100644
--- a/internal/providers/all/all_test.go
+++ b/internal/providers/all/all_test.go
@@ -30,6 +30,7 @@ func TestAllBuiltInProvidersExposeDoctor(t *testing.T) {
"ssh",
"tensorlake",
"upstash-box",
+ "ascii-box",
"wandb",
}
for _, name := range providers {
diff --git a/internal/providers/asciibox/backend.go b/internal/providers/asciibox/backend.go
new file mode 100644
index 00000000..e338c223
--- /dev/null
+++ b/internal/providers/asciibox/backend.go
@@ -0,0 +1,585 @@
+package asciibox
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "path"
+ "regexp"
+ "strings"
+ "time"
+)
+
+type backend struct {
+ spec ProviderSpec
+ cfg Config
+ rt Runtime
+}
+
+func NewBackend(spec ProviderSpec, cfg Config, rt Runtime) Backend {
+ cfg.Provider = providerName
+ cfg.TargetOS = targetLinux
+ cfg.Network = networkPublic
+ if cleaned, err := cleanWorkdir(workdir(cfg)); err == nil {
+ cfg.WorkRoot = cleaned
+ }
+ return &backend{spec: spec, cfg: cfg, rt: rt}
+}
+
+func (b *backend) Spec() ProviderSpec { return b.spec }
+
+func (b *backend) Acquire(ctx context.Context, req AcquireRequest) (LeaseTarget, error) {
+ cfg, err := b.configForRun()
+ if err != nil {
+ return LeaseTarget{}, err
+ }
+ client, err := newAPI(cfg, b.rt)
+ if err != nil {
+ return LeaseTarget{}, err
+ }
+ leaseID := newLeaseID()
+ slug, err := allocateClaimLeaseSlug(leaseID, req.RequestedSlug)
+ if err != nil {
+ return LeaseTarget{}, err
+ }
+ ttl := req.Options.TTL
+ if ttl <= 0 {
+ ttl = cfg.TTL
+ }
+ fmt.Fprintf(b.rt.Stderr, "provisioning provider=%s lease=%s slug=%s ttl=%s\n", providerName, leaseID, slug, blank(ttl.String(), "-"))
+ box, err := client.CreateBox(ctx, createRequest{TTL: ttl})
+ if err != nil {
+ if box.ID != "" {
+ _ = client.ReleaseBox(context.Background(), box.ID)
+ }
+ return LeaseTarget{}, err
+ }
+ if err := claimLeaseForRepoProviderScope(leaseID, slug, providerName, boxScope(box.ID), req.Repo.Root, cfg.IdleTimeout, req.Reclaim); err != nil {
+ _ = client.ReleaseBox(context.Background(), box.ID)
+ return LeaseTarget{}, err
+ }
+ if err := client.PrepareSSH(ctx, box.ID); err != nil {
+ if !req.Keep {
+ _ = client.ReleaseBox(context.Background(), box.ID)
+ removeLeaseClaim(leaseID)
+ }
+ return LeaseTarget{}, err
+ }
+ lease, err := b.leaseFromBox(ctx, cfg, box, leaseID, slug, req.Keep, true)
+ if err != nil {
+ if !req.Keep {
+ _ = client.ReleaseBox(context.Background(), box.ID)
+ removeLeaseClaim(leaseID)
+ }
+ return LeaseTarget{}, err
+ }
+ fmt.Fprintf(b.rt.Stderr, "provisioned lease=%s slug=%s box=%s host=%s state=%s\n", leaseID, slug, box.ID, boxHost(box), boxState(box))
+ return lease, nil
+}
+
+func (b *backend) Resolve(ctx context.Context, req ResolveRequest) (LeaseTarget, error) {
+ cfg, err := b.configForRun()
+ if err != nil {
+ return LeaseTarget{}, err
+ }
+ client, err := newAPI(cfg, b.rt)
+ if err != nil {
+ return LeaseTarget{}, err
+ }
+ leaseID, boxID, slug, err := b.resolveBoxID(ctx, client, req.ID, req.Repo.Root, cfg.IdleTimeout, req.Reclaim)
+ if err != nil {
+ return LeaseTarget{}, err
+ }
+ box, err := client.GetBox(ctx, boxID)
+ if err != nil {
+ return LeaseTarget{}, err
+ }
+ if req.ReleaseOnly {
+ return LeaseTarget{Server: boxToServer(cfg, box, leaseID, slug, true), LeaseID: leaseID}, nil
+ }
+ if err := client.PrepareSSH(ctx, box.ID); err != nil {
+ return LeaseTarget{}, err
+ }
+ return b.leaseFromBox(ctx, cfg, box, leaseID, slug, true, true)
+}
+
+func (b *backend) List(ctx context.Context, req ListRequest) ([]LeaseView, error) {
+ _ = req
+ cfg, err := b.configForRun()
+ if err != nil {
+ return nil, err
+ }
+ client, err := newAPI(cfg, b.rt)
+ if err != nil {
+ return nil, err
+ }
+ boxes, err := client.ListBoxes(ctx)
+ if err != nil {
+ return nil, err
+ }
+ claims, err := boxClaimsByID()
+ if err != nil {
+ return nil, err
+ }
+ out := make([]Server, 0, len(boxes))
+ for _, box := range boxes {
+ leaseID, slug, ok := b.boxLeaseMetadata(box, claims)
+ if !ok {
+ continue
+ }
+ out = append(out, boxToServer(cfg, box, leaseID, slug, true))
+ }
+ return out, nil
+}
+
+func (b *backend) Doctor(ctx context.Context, _ DoctorRequest) (DoctorResult, error) {
+ cfg, err := b.configForRun()
+ if err != nil {
+ return DoctorResult{}, err
+ }
+ client, err := newAPI(cfg, b.rt)
+ if err != nil {
+ return DoctorResult{}, err
+ }
+ if err := client.Check(ctx); err != nil {
+ return DoctorResult{}, err
+ }
+ return DoctorResult{
+ Provider: providerName,
+ Message: "auth=ready cli=ready control_plane=ready limits=ready mutation=false runtime=unchecked",
+ }, nil
+}
+
+func (b *backend) Status(ctx context.Context, req StatusRequest) (StatusView, error) {
+ cfg, err := b.configForRun()
+ if err != nil {
+ return StatusView{}, err
+ }
+ client, err := newAPI(cfg, b.rt)
+ if err != nil {
+ return StatusView{}, err
+ }
+ leaseID, boxID, slug, err := b.resolveBoxID(ctx, client, req.ID, "", 0, 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 {
+ box, err := client.GetBox(ctx, boxID)
+ if err != nil {
+ return StatusView{}, err
+ }
+ view := statusFromBox(cfg, box, leaseID, slug)
+ if !req.Wait || view.Ready {
+ return view, nil
+ }
+ if boxStateFailed(view.State) {
+ return view, nil
+ }
+ if b.now().After(deadline) {
+ return StatusView{}, exit(5, "timed out waiting for ascii-box %s to become ready", boxID)
+ }
+ select {
+ case <-ctx.Done():
+ return StatusView{}, ctx.Err()
+ case <-time.After(2 * time.Second):
+ }
+ }
+}
+
+func (b *backend) ReleaseLease(ctx context.Context, req ReleaseLeaseRequest) error {
+ cfg, err := b.configForRun()
+ if err != nil {
+ return err
+ }
+ client, err := newAPI(cfg, b.rt)
+ if err != nil {
+ return err
+ }
+ boxID := strings.TrimSpace(req.Lease.Server.CloudID)
+ if boxID == "" {
+ boxID = strings.TrimSpace(req.Lease.Server.Labels["box_id"])
+ }
+ if boxID == "" && req.Lease.LeaseID != "" {
+ _, resolvedBoxID, _, err := b.resolveBoxID(ctx, client, req.Lease.LeaseID, "", 0, false)
+ if err != nil {
+ return err
+ }
+ boxID = resolvedBoxID
+ }
+ if boxID == "" {
+ return exit(2, "provider=%s requires an ASCII Box id to release", providerName)
+ }
+ if err := client.ReleaseBox(ctx, boxID); err != nil {
+ return err
+ }
+ removeLeaseClaim(req.Lease.LeaseID)
+ return nil
+}
+
+func (b *backend) ReleaseLeaseMessage(lease LeaseTarget) string {
+ return fmt.Sprintf("released lease=%s box=%s", lease.LeaseID, blank(lease.Server.CloudID, lease.Server.Labels["box_id"]))
+}
+
+func (b *backend) Touch(_ context.Context, req TouchRequest) (Server, error) {
+ cfg, err := b.configForRun()
+ if err != nil {
+ return Server{}, err
+ }
+ server := req.Lease.Server
+ if server.Labels == nil {
+ server.Labels = map[string]string{}
+ }
+ server.Labels = touchDirectLeaseLabels(server.Labels, cfg, req.State, time.Now().UTC())
+ server.Status = req.State
+ return server, nil
+}
+
+func (b *backend) leaseFromBox(ctx context.Context, cfg Config, box boxData, leaseID, slug string, keep, waitSSH bool) (LeaseTarget, error) {
+ server := boxToServer(cfg, box, leaseID, slug, keep)
+ target, err := boxSSHTarget(cfg, box)
+ if err != nil {
+ return LeaseTarget{}, err
+ }
+ if waitSSH {
+ if err := waitForSSHReadyFunc(ctx, &target, b.rt.Stderr, "ascii-box ssh", bootstrapWaitTimeout(cfg)); err != nil {
+ return LeaseTarget{}, err
+ }
+ server.Labels["state"] = "ready"
+ server.Status = "ready"
+ }
+ return LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, nil
+}
+
+func (b *backend) resolveBoxID(ctx context.Context, client api, id, repoRoot string, idleTimeout time.Duration, reclaim bool) (string, string, string, error) {
+ id = strings.TrimSpace(id)
+ if id == "" {
+ return "", "", "", exit(2, "provider=%s requires a Crabbox lease id, slug, or ASCII Box id", providerName)
+ }
+ if claim, ok, err := resolveLeaseClaimForProvider(id, providerName); err != nil {
+ return "", "", "", err
+ } else if ok {
+ boxID := boxIDFromScope(claim.ProviderScope)
+ if boxID == "" {
+ box, err := resolveLegacyBoxByLease(ctx, client, claim.LeaseID)
+ if err != nil {
+ return "", "", "", err
+ }
+ boxID = box.ID
+ }
+ if repoRoot != "" {
+ if err := claimLeaseForRepoProviderScope(claim.LeaseID, claim.Slug, providerName, boxScope(boxID), repoRoot, time.Duration(claim.IdleTimeoutSeconds)*time.Second, reclaim); err != nil {
+ return "", "", "", err
+ }
+ }
+ return claim.LeaseID, boxID, claim.Slug, nil
+ }
+ if box, err := client.GetBox(ctx, id); err == nil {
+ leaseID := boxLeaseID(box)
+ slug := boxSlug(leaseID, box)
+ if repoRoot != "" {
+ if err := claimLeaseForRepoProviderScope(leaseID, slug, providerName, boxScope(box.ID), repoRoot, idleTimeout, reclaim); err != nil {
+ return "", "", "", err
+ }
+ }
+ return leaseID, box.ID, slug, nil
+ } else if err != nil && !isNotFound(err) {
+ return "", "", "", err
+ }
+ if strings.HasPrefix(id, "cbx_") {
+ box, err := resolveLegacyBoxByLease(ctx, client, id)
+ if err != nil {
+ return "", "", "", err
+ }
+ return id, box.ID, boxSlug(id, box), nil
+ }
+ box, err := resolveLegacyBoxBySlug(ctx, client, id)
+ if err != nil {
+ return "", "", "", err
+ }
+ leaseID := boxLeaseID(box)
+ return leaseID, box.ID, boxSlug(leaseID, box), nil
+}
+
+func resolveLegacyBoxByLease(ctx context.Context, client api, leaseID string) (boxData, error) {
+ boxes, err := client.ListBoxes(ctx)
+ if err != nil {
+ return boxData{}, err
+ }
+ for _, box := range boxes {
+ if isCrabboxBox(box) && boxLeaseID(box) == leaseID {
+ return box, nil
+ }
+ }
+ return boxData{}, exit(4, "ascii-box lease %q was not found", leaseID)
+}
+
+func resolveLegacyBoxBySlug(ctx context.Context, client api, slug string) (boxData, error) {
+ boxes, err := client.ListBoxes(ctx)
+ if err != nil {
+ return boxData{}, err
+ }
+ for _, box := range boxes {
+ if !isCrabboxBox(box) {
+ continue
+ }
+ leaseID := boxLeaseID(box)
+ if boxSlug(leaseID, box) == slug {
+ return box, nil
+ }
+ }
+ return boxData{}, exit(4, "ascii-box %q was not found", slug)
+}
+
+func (b *backend) boxLeaseMetadata(box boxData, claims map[string]LeaseClaim) (string, string, bool) {
+ if claim, ok := claims[box.ID]; ok {
+ return claim.LeaseID, claim.Slug, true
+ }
+ if isCrabboxBox(box) {
+ leaseID := boxLeaseID(box)
+ return leaseID, boxSlug(leaseID, box), true
+ }
+ return "", "", false
+}
+
+func (b *backend) configForRun() (Config, error) {
+ cfg := b.cfg
+ cfg.Provider = providerName
+ cfg.TargetOS = targetLinux
+ cfg.Network = networkPublic
+ cfg.SSHPort = blank(strings.TrimSpace(cfg.SSHPort), "22")
+ cleaned, err := cleanWorkdir(workdir(cfg))
+ if err != nil {
+ return Config{}, err
+ }
+ cfg.WorkRoot = cleaned
+ return cfg, nil
+}
+
+func (b *backend) now() time.Time {
+ return now(b.rt)
+}
+
+func boxToServer(cfg Config, box boxData, leaseID, slug string, keep bool) Server {
+ labels := directLeaseLabels(cfg, leaseID, slug, providerName, "", keep, time.Now().UTC())
+ labels["box_id"] = box.ID
+ labels["box_name"] = box.Name
+ labels["box_state"] = boxState(box)
+ labels["ssh_user"] = boxSSHUser(box)
+ labels["work_root"] = cfg.WorkRoot
+ if expiresAt := boxExpiresAt(box); expiresAt != "" {
+ labels["expires_at"] = expiresAt
+ }
+ server := Server{
+ Provider: providerName,
+ CloudID: box.ID,
+ Name: blank(box.Name, leaseProviderName(leaseID, slug)),
+ Status: boxState(box),
+ Labels: labels,
+ }
+ server.ServerType.Name = "ascii-box"
+ server.PublicNet.IPv4.IP = boxHost(box)
+ return server
+}
+
+func statusFromBox(cfg Config, box boxData, leaseID, slug string) StatusView {
+ server := boxToServer(cfg, box, leaseID, slug, true)
+ host := boxHost(box)
+ user := boxSSHUser(box)
+ return StatusView{
+ ID: leaseID,
+ Slug: slug,
+ Provider: providerName,
+ TargetOS: targetLinux,
+ State: boxState(box),
+ ServerID: box.ID,
+ ServerType: server.ServerType.Name,
+ Host: host,
+ Network: networkPublic,
+ SSHHost: host,
+ SSHUser: user,
+ SSHPort: "22",
+ SSHKey: boxSSHKey(cfg),
+ ExpiresAt: boxExpiresAt(box),
+ Labels: server.Labels,
+ HasHost: host != "",
+ Ready: boxReadyForSSH(box),
+ }
+}
+
+func boxSSHTarget(cfg Config, box boxData) (SSHTarget, error) {
+ host := boxHost(box)
+ if host == "" {
+ return SSHTarget{}, exit(5, "ascii-box %s is missing ip for SSH", box.ID)
+ }
+ user := boxSSHUser(box)
+ if user == "" {
+ return SSHTarget{}, exit(5, "ascii-box %s is missing SSH user", box.ID)
+ }
+ return SSHTarget{
+ User: user,
+ Host: host,
+ Key: boxSSHKey(cfg),
+ Port: "22",
+ TargetOS: targetLinux,
+ NetworkKind: networkPublic,
+ NoControlMaster: true,
+ ReadyCheck: "command -v git >/dev/null && command -v rsync >/dev/null && command -v tar >/dev/null && command -v python3 >/dev/null",
+ }, nil
+}
+
+func boxSSHKey(cfg Config) string {
+ return path.Join(asciiBoxCLIHome(), ".ssh", "ascii_box_ed25519")
+}
+
+func boxHost(box boxData) string {
+ return firstNonBlank(box.IP, box.MachineIP, box.MachineIPAlt, box.PublicIP)
+}
+
+func boxSSHUser(box boxData) string {
+ return firstNonBlank(box.SSHUser, box.SSHUserAlt, "user")
+}
+
+func boxState(box boxData) string {
+ return strings.ToLower(blank(firstNonBlank(box.Status, box.State), "provisioning"))
+}
+
+func boxExpiresAt(box boxData) string {
+ switch value := box.ExpiresAt.(type) {
+ case string:
+ return strings.TrimSpace(value)
+ case float64:
+ return fmt.Sprintf("%.0f", value)
+ case json.Number:
+ return value.String()
+ default:
+ switch value := box.ArchiveAfter.(type) {
+ case string:
+ return strings.TrimSpace(value)
+ case float64:
+ return fmt.Sprintf("%.0f", value)
+ case json.Number:
+ return value.String()
+ default:
+ return ""
+ }
+ }
+}
+
+func boxReadyForSSH(box boxData) bool {
+ return statusReady(boxState(box)) && boxHost(box) != "" && boxSSHUser(box) != ""
+}
+
+func boxStateFailed(status string) bool {
+ switch strings.ToLower(strings.TrimSpace(status)) {
+ case "error", "failed", "failure", "stopped", "terminated", "deleted":
+ return true
+ default:
+ return false
+ }
+}
+
+func statusReady(status string) bool {
+ switch strings.ToLower(strings.TrimSpace(status)) {
+ case "running", "ready", "idle", "paused", "active":
+ return true
+ default:
+ return false
+ }
+}
+
+var boxNamePattern = regexp.MustCompile(`^crabbox-(.+)-([0-9a-f]{12})$`)
+
+func isCrabboxBox(box boxData) bool {
+ return boxNamePattern.MatchString(strings.TrimSpace(box.Name))
+}
+
+func boxLeaseID(box boxData) string {
+ if match := boxNamePattern.FindStringSubmatch(strings.TrimSpace(box.Name)); len(match) == 3 {
+ return "cbx_" + match[2]
+ }
+ return "ascii_" + box.ID
+}
+
+func boxSlug(leaseID string, box boxData) string {
+ if match := boxNamePattern.FindStringSubmatch(strings.TrimSpace(box.Name)); len(match) == 3 {
+ return match[1]
+ }
+ return newLeaseSlug(leaseID)
+}
+
+func boxScope(boxID string) string {
+ if strings.TrimSpace(boxID) == "" {
+ return ""
+ }
+ return "box:" + strings.TrimSpace(boxID)
+}
+
+func boxIDFromScope(scope string) string {
+ scope = strings.TrimSpace(scope)
+ if !strings.HasPrefix(scope, "box:") {
+ return ""
+ }
+ return strings.TrimSpace(strings.TrimPrefix(scope, "box:"))
+}
+
+func boxClaimsByID() (map[string]LeaseClaim, error) {
+ claims, err := listLeaseClaims()
+ if err != nil {
+ return nil, err
+ }
+ out := map[string]LeaseClaim{}
+ for _, claim := range claims {
+ if claim.Provider != providerName {
+ continue
+ }
+ boxID := boxIDFromScope(claim.ProviderScope)
+ if boxID == "" {
+ continue
+ }
+ out[boxID] = claim
+ }
+ return out, nil
+}
+
+func isNotFound(err error) bool {
+ if err == nil {
+ return false
+ }
+ msg := strings.ToLower(err.Error())
+ return strings.Contains(msg, "404") || strings.Contains(msg, "not found")
+}
+
+func workdir(cfg Config) string {
+ return blank(strings.TrimSpace(cfg.AsciiBox.Workdir), "/home/user/crabbox")
+}
+
+func cleanWorkdir(workdir string) (string, error) {
+ trimmed := strings.TrimSpace(workdir)
+ if trimmed == "" {
+ return "", exit(2, "ascii-box workdir is empty")
+ }
+ clean := path.Clean(trimmed)
+ if !strings.HasPrefix(clean, "/") {
+ return "", exit(2, "ascii-box workdir %q must resolve to an absolute path", workdir)
+ }
+ switch clean {
+ case "/", "/bin", "/dev", "/etc", "/home", "/home/user", "/lib", "/lib64", "/opt", "/proc", "/root", "/sbin", "/sys", "/tmp", "/usr", "/var", "/workspace", "/workspace/home":
+ return "", exit(2, "ascii-box workdir %q is too broad; choose a dedicated subdirectory", clean)
+ }
+ return clean, nil
+}
+
+func firstNonBlank(values ...string) string {
+ for _, value := range values {
+ if strings.TrimSpace(value) != "" {
+ return strings.TrimSpace(value)
+ }
+ }
+ return ""
+}
+
+var waitForSSHReadyFunc = waitForSSHReady
diff --git a/internal/providers/asciibox/backend_test.go b/internal/providers/asciibox/backend_test.go
new file mode 100644
index 00000000..f4712bd4
--- /dev/null
+++ b/internal/providers/asciibox/backend_test.go
@@ -0,0 +1,449 @@
+package asciibox
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "io"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ core "github.com/openclaw/crabbox/internal/cli"
+)
+
+func TestProviderSpecAndAliases(t *testing.T) {
+ p := Provider{}
+ if p.Name() != providerName {
+ t.Fatalf("Name=%q want %s", p.Name(), providerName)
+ }
+ for _, alias := range []string{"ascii", "asciibox", "ascii-box"} {
+ got, err := core.ProviderFor(alias)
+ if err != nil {
+ t.Fatalf("ProviderFor(%q): %v", alias, err)
+ }
+ if got.Name() != providerName {
+ t.Fatalf("ProviderFor(%q).Name=%q", alias, got.Name())
+ }
+ }
+ spec := p.Spec()
+ if spec.Kind != core.ProviderKindSSHLease {
+ t.Fatalf("kind=%v want ssh-lease", spec.Kind)
+ }
+ if spec.Coordinator != core.CoordinatorNever {
+ t.Fatalf("coordinator=%v want never", spec.Coordinator)
+ }
+ if len(spec.Targets) != 1 || spec.Targets[0].OS != core.TargetLinux {
+ t.Fatalf("targets=%#v want linux", spec.Targets)
+ }
+ if !hasFeature(spec.Features, core.FeatureSSH) || !hasFeature(spec.Features, core.FeatureCrabboxSync) {
+ t.Fatalf("features=%#v want ssh and crabbox sync", spec.Features)
+ }
+}
+
+func TestClientUsesOfficialAsciiBoxCLI(t *testing.T) {
+ t.Setenv("BOX_API_KEY", "stale_key")
+ home := t.TempDir()
+ runner := &fakeCommandRunner{configPath: home + "/Library/Application Support/ascii/box/config.json"}
+ client := &client{apiKey: "box_key", apiURL: "https://ascii.dev", cliPath: "box", home: home, runner: runner}
+ box, err := client.CreateBox(context.Background(), createRequest{TTL: 30 * time.Minute})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if box.ID != "bx_1" || boxHost(box) != "203.0.113.10" || boxSSHUser(box) != "user" {
+ t.Fatalf("box=%#v", box)
+ }
+ if err := client.PrepareSSH(context.Background(), "bx_1"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := client.GetBox(context.Background(), "bx_1"); err != nil {
+ t.Fatal(err)
+ }
+ if boxes, err := client.ListBoxes(context.Background()); err != nil || len(boxes) != 1 {
+ t.Fatalf("boxes=%#v err=%v", boxes, err)
+ }
+ if err := client.ReleaseBox(context.Background(), "bx_1"); err != nil {
+ t.Fatal(err)
+ }
+ want := []string{
+ "box --no-update --json config",
+ "box --no-update --json --api-url https://ascii.dev new --ttl 1800",
+ "box --no-update --json config",
+ "box --no-update --json --api-url https://ascii.dev ssh bx_1 -- true",
+ "box --no-update --json config",
+ "box --no-update --json --api-url https://ascii.dev info bx_1",
+ "box --no-update --json config",
+ "box --no-update --json --api-url https://ascii.dev list",
+ "box --no-update --json config",
+ "box --no-update --json --api-url https://ascii.dev stop bx_1",
+ "box --no-update --json config",
+ "box --no-update --json --api-url https://ascii.dev delete bx_1",
+ }
+ if !reflect.DeepEqual(runner.commands, want) {
+ t.Fatalf("commands=%v want=%v", runner.commands, want)
+ }
+ for _, env := range runner.env {
+ if !hasEnv(env, "BOX_API_KEY=box_key") {
+ t.Fatalf("env missing BOX_API_KEY: %v", env)
+ }
+ if hasEnv(env, "BOX_API_KEY=stale_key") {
+ t.Fatalf("env kept stale BOX_API_KEY: %v", env)
+ }
+ if !hasEnv(env, "HOME="+home) {
+ t.Fatalf("env missing HOME: %v", env)
+ }
+ }
+ if !hasEnv(runner.env[3], "SSH_AUTH_SOCK=") {
+ t.Fatalf("ssh setup env should disable agent identities: %v", runner.env[3])
+ }
+}
+
+func TestClientPollsPartialCreateOutput(t *testing.T) {
+ home := t.TempDir()
+ runner := &fakeCommandRunner{
+ configPath: home + "/Library/Application Support/ascii/box/config.json",
+ newStdout: strings.Join([]string{
+ `{"event":"created","id":"bx_2","ttlSeconds":1800}`,
+ `{"event":"state","id":"bx_2","state":"provisioning"}`,
+ }, "\n"),
+ newErr: fmt.Errorf("exit status 1"),
+ infoResponses: []string{`{"box":{"id":"bx_2","state":"ready","ip":"203.0.113.20"}}`},
+ }
+ client := &client{apiKey: "box_key", apiURL: "https://ascii.dev", cliPath: "box", home: home, runner: runner}
+ box, err := client.CreateBox(context.Background(), createRequest{TTL: 30 * time.Minute})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if box.ID != "bx_2" || boxHost(box) != "203.0.113.20" {
+ t.Fatalf("box=%#v", box)
+ }
+ if !containsCommand(runner.commands, "box --no-update --json --api-url https://ascii.dev info bx_2") {
+ t.Fatalf("commands missing info poll: %v", runner.commands)
+ }
+}
+
+func TestRedactBoxSecrets(t *testing.T) {
+ got := redactBoxSecrets(`open https://box.ascii.dev/session?box_token=secret-value&ok=1 with box_realToken`)
+ if strings.Contains(got, "secret-value") || strings.Contains(got, "box_realToken") {
+ t.Fatalf("redacted=%q", got)
+ }
+}
+
+func TestAcquireClaimsBoxAndReturnsSSHTarget(t *testing.T) {
+ t.Setenv("XDG_STATE_HOME", t.TempDir())
+ fake := &fakeAPI{box: testBox()}
+ withFakeAPI(t, fake)
+ stubSSHWait(t)
+
+ backend := NewBackend(Provider{}.Spec(), testConfig(), testRuntime()).(*backend)
+ lease, err := backend.Acquire(context.Background(), AcquireRequest{
+ Repo: core.Repo{Name: "repo", Root: t.TempDir()},
+ Options: core.LeaseOptions{TTL: 45 * time.Minute},
+ Keep: true,
+ RequestedSlug: "proof",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if lease.LeaseID == "" || lease.SSH.Host != "203.0.113.10" || lease.SSH.User != "user" {
+ t.Fatalf("lease=%#v", lease)
+ }
+ if !strings.HasSuffix(lease.SSH.Key, ".ssh/ascii_box_ed25519") {
+ t.Fatalf("ssh key=%q", lease.SSH.Key)
+ }
+ if !lease.SSH.NoControlMaster {
+ t.Fatalf("ascii-box SSH target should disable ControlMaster")
+ }
+ if fake.createReq.TTL != 45*time.Minute {
+ t.Fatalf("create req=%#v", fake.createReq)
+ }
+ if !reflect.DeepEqual(fake.prepareIDs, []string{"bx_1"}) {
+ t.Fatalf("prepare ids=%v", fake.prepareIDs)
+ }
+ claim, ok, err := core.ResolveLeaseClaim(lease.LeaseID)
+ if err != nil || !ok {
+ t.Fatalf("claim ok=%t err=%v", ok, err)
+ }
+ if claim.Provider != providerName || claim.ProviderScope != "box:bx_1" || claim.Slug != "proof" {
+ t.Fatalf("claim=%#v", claim)
+ }
+}
+
+func TestResolveUsesClaimScopeAndReleaseDeletesBox(t *testing.T) {
+ t.Setenv("XDG_STATE_HOME", t.TempDir())
+ fake := &fakeAPI{box: testBox()}
+ withFakeAPI(t, fake)
+ stubSSHWait(t)
+ if err := claimLeaseForRepoProviderScope("cbx_123456789abc", "proof", providerName, "box:bx_1", t.TempDir(), time.Minute, false); err != nil {
+ t.Fatal(err)
+ }
+
+ backend := NewBackend(Provider{}.Spec(), testConfig(), testRuntime()).(*backend)
+ lease, err := backend.Resolve(context.Background(), ResolveRequest{ID: "proof"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if lease.LeaseID != "cbx_123456789abc" || lease.Server.CloudID != "bx_1" || lease.SSH.Host != "203.0.113.10" {
+ t.Fatalf("lease=%#v", lease)
+ }
+ if err := backend.ReleaseLease(context.Background(), ReleaseLeaseRequest{Lease: lease}); err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(fake.deletedIDs, []string{"bx_1"}) {
+ t.Fatalf("deleted=%v", fake.deletedIDs)
+ }
+ if _, ok, err := core.ResolveLeaseClaim("proof"); err != nil || ok {
+ t.Fatalf("claim ok=%t err=%v, want removed", ok, err)
+ }
+}
+
+func TestResolveReleaseOnlyDoesNotRequireSSHFields(t *testing.T) {
+ t.Setenv("XDG_STATE_HOME", t.TempDir())
+ fake := &fakeAPI{box: boxData{ID: "bx_booting", Status: "provisioning"}}
+ withFakeAPI(t, fake)
+ if err := claimLeaseForRepoProviderScope("cbx_abcdef123456", "booting", providerName, "box:bx_booting", t.TempDir(), time.Minute, false); err != nil {
+ t.Fatal(err)
+ }
+
+ backend := NewBackend(Provider{}.Spec(), testConfig(), testRuntime()).(*backend)
+ lease, err := backend.Resolve(context.Background(), ResolveRequest{ID: "booting", ReleaseOnly: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if lease.SSH.Host != "" || lease.Server.CloudID != "bx_booting" {
+ t.Fatalf("lease=%#v", lease)
+ }
+}
+
+func TestResolveRawBoxIDClaimsProviderScope(t *testing.T) {
+ t.Setenv("XDG_STATE_HOME", t.TempDir())
+ fake := &fakeAPI{box: boxData{ID: "bx_external", State: "ready", IP: "203.0.113.30"}}
+ withFakeAPI(t, fake)
+ stubSSHWait(t)
+
+ repoRoot := t.TempDir()
+ backend := NewBackend(Provider{}.Spec(), testConfig(), testRuntime()).(*backend)
+ lease, err := backend.Resolve(context.Background(), ResolveRequest{ID: "bx_external", Repo: core.Repo{Root: repoRoot}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ claim, ok, err := core.ResolveLeaseClaim(lease.LeaseID)
+ if err != nil || !ok {
+ t.Fatalf("claim ok=%t err=%v", ok, err)
+ }
+ if claim.ProviderScope != "box:bx_external" {
+ t.Fatalf("provider scope=%q", claim.ProviderScope)
+ }
+ resolved, err := backend.Resolve(context.Background(), ResolveRequest{ID: lease.LeaseID, ReleaseOnly: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if resolved.Server.CloudID != "bx_external" {
+ t.Fatalf("resolved=%#v", resolved)
+ }
+}
+
+func TestStatusMapsBoxAPIFields(t *testing.T) {
+ fake := &fakeAPI{box: testBox()}
+ withFakeAPI(t, fake)
+ backend := NewBackend(Provider{}.Spec(), testConfig(), testRuntime()).(*backend)
+ view, err := backend.Status(context.Background(), StatusRequest{ID: "bx_1"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if view.ID != "ascii_bx_1" || view.ServerID != "bx_1" || view.SSHHost != "203.0.113.10" || view.SSHUser != "user" || !view.Ready {
+ t.Fatalf("view=%#v", view)
+ }
+}
+
+func TestStatusWaitReturnsTerminalBoxState(t *testing.T) {
+ fake := &fakeAPI{box: boxData{ID: "bx_failed", State: "error", IP: "203.0.113.10"}}
+ withFakeAPI(t, fake)
+ backend := NewBackend(Provider{}.Spec(), testConfig(), testRuntime()).(*backend)
+ view, err := backend.Status(context.Background(), StatusRequest{ID: "bx_failed", Wait: true, WaitTimeout: time.Minute})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if view.State != "error" || view.Ready {
+ t.Fatalf("view=%#v", view)
+ }
+}
+
+func TestCleanWorkdirAndFlags(t *testing.T) {
+ if got, err := cleanWorkdir(" /home/user/crabbox/ "); err != nil || got != "/home/user/crabbox" {
+ t.Fatalf("workdir=%q err=%v", got, err)
+ }
+ for _, value := range []string{"", "repo", "/", "/home/user", "/workspace", "/tmp"} {
+ if _, err := cleanWorkdir(value); err == nil {
+ t.Fatalf("cleanWorkdir(%q) succeeded", value)
+ }
+ }
+
+ cfg := testConfig()
+ fs := flag.NewFlagSet("test", flag.ContinueOnError)
+ values := RegisterAsciiBoxProviderFlags(fs, cfg)
+ if err := fs.Parse([]string{"--ascii-box-cli", "/tmp/box", "--ascii-box-workdir", "/home/user/project"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := ApplyAsciiBoxProviderFlags(&cfg, fs, values); err != nil {
+ t.Fatal(err)
+ }
+ if cfg.AsciiBox.CLIPath != "/tmp/box" || cfg.WorkRoot != "/home/user/project" || cfg.AsciiBox.Workdir != "/home/user/project" {
+ t.Fatalf("cfg=%#v", cfg)
+ }
+}
+
+func hasFeature(features core.FeatureSet, want core.Feature) bool {
+ for _, feature := range features {
+ if feature == want {
+ return true
+ }
+ }
+ return false
+}
+
+func testConfig() Config {
+ return Config{
+ Provider: providerName,
+ SSHKey: "/tmp/global-crabbox-key",
+ AsciiBox: AsciiBoxConfig{
+ APIKey: "box_key",
+ BaseURL: "https://ascii.dev",
+ CLIPath: "box",
+ Workdir: "/home/user/crabbox",
+ },
+ }
+}
+
+func testRuntime() Runtime {
+ return Runtime{Stdout: io.Discard, Stderr: io.Discard}
+}
+
+func testBox() boxData {
+ return boxData{ID: "bx_1", State: "ready", IP: "203.0.113.10"}
+}
+
+func withFakeAPI(t *testing.T, fake *fakeAPI) {
+ t.Helper()
+ original := newAPI
+ newAPI = func(Config, Runtime) (api, error) { return fake, nil }
+ t.Cleanup(func() { newAPI = original })
+}
+
+func stubSSHWait(t *testing.T) {
+ t.Helper()
+ original := waitForSSHReadyFunc
+ waitForSSHReadyFunc = func(context.Context, *SSHTarget, io.Writer, string, time.Duration) error { return nil }
+ t.Cleanup(func() { waitForSSHReadyFunc = original })
+}
+
+type fakeAPI struct {
+ createReq createRequest
+ box boxData
+ prepareIDs []string
+ deletedIDs []string
+}
+
+func (f *fakeAPI) CreateBox(_ context.Context, req createRequest) (boxData, error) {
+ f.createReq = req
+ if f.box.ID == "" {
+ f.box = testBox()
+ }
+ return f.box, nil
+}
+
+func (f *fakeAPI) Check(context.Context) error { return nil }
+
+func (f *fakeAPI) PrepareSSH(_ context.Context, id string) error {
+ f.prepareIDs = append(f.prepareIDs, id)
+ return nil
+}
+
+func (f *fakeAPI) GetBox(_ context.Context, id string) (boxData, error) {
+ if f.box.ID == "" {
+ f.box = testBox()
+ }
+ if id != f.box.ID {
+ return boxData{}, fmt.Errorf("404 not found")
+ }
+ return f.box, nil
+}
+
+func (f *fakeAPI) ListBoxes(context.Context) ([]boxData, error) {
+ if f.box.ID == "" {
+ f.box = testBox()
+ }
+ return []boxData{f.box}, nil
+}
+
+func (f *fakeAPI) ReleaseBox(_ context.Context, id string) error {
+ f.deletedIDs = append(f.deletedIDs, id)
+ return nil
+}
+
+type fakeCommandRunner struct {
+ commands []string
+ env [][]string
+ configPath string
+ newStdout string
+ newErr error
+
+ infoResponses []string
+}
+
+func (r *fakeCommandRunner) Run(_ context.Context, req LocalCommandRequest) (LocalCommandResult, error) {
+ r.commands = append(r.commands, strings.Join(append([]string{req.Name}, req.Args...), " "))
+ r.env = append(r.env, req.Env)
+ joined := strings.Join(req.Args, " ")
+ switch {
+ case strings.Contains(joined, " config"):
+ return LocalCommandResult{Stdout: fmt.Sprintf(`{"loggedIn":false,"path":%q}`, r.configPath)}, nil
+ case strings.Contains(joined, " new "):
+ if r.newStdout != "" || r.newErr != nil {
+ return LocalCommandResult{Stdout: r.newStdout}, r.newErr
+ }
+ return LocalCommandResult{Stdout: strings.Join([]string{
+ `{"event":"created","id":"bx_1","ttlSeconds":1800}`,
+ `{"event":"state","id":"bx_1","state":"provisioning"}`,
+ `{"event":"ready","id":"bx_1","state":"ready","ip":"203.0.113.10","archiveAfter":"2026-05-30T20:00:00Z"}`,
+ }, "\n")}, nil
+ case strings.Contains(joined, " ssh bx_1 -- true"):
+ return LocalCommandResult{}, nil
+ case strings.Contains(joined, " info bx_1"):
+ return LocalCommandResult{Stdout: `{"box":{"id":"bx_1","state":"ready","ip":"203.0.113.10"}}`}, nil
+ case strings.Contains(joined, " info bx_2"):
+ if len(r.infoResponses) == 0 {
+ return LocalCommandResult{Stderr: "missing info response"}, fmt.Errorf("missing info response")
+ }
+ out := r.infoResponses[0]
+ r.infoResponses = r.infoResponses[1:]
+ return LocalCommandResult{Stdout: out}, nil
+ case strings.Contains(joined, " list"):
+ return LocalCommandResult{Stdout: `{"boxes":[{"id":"bx_1","state":"ready","ip":"203.0.113.10"}]}`}, nil
+ case strings.Contains(joined, " stop bx_1"):
+ return LocalCommandResult{Stdout: `{"id":"bx_1","status":"deleted"}`}, nil
+ case strings.Contains(joined, " delete bx_1"):
+ return LocalCommandResult{Stdout: `{"id":"bx_1","status":"deleted"}`}, nil
+ default:
+ return LocalCommandResult{Stderr: "unexpected command"}, fmt.Errorf("unexpected command")
+ }
+}
+
+func hasEnv(env []string, want string) bool {
+ for _, value := range env {
+ if value == want {
+ return true
+ }
+ }
+ return false
+}
+
+func containsCommand(commands []string, want string) bool {
+ for _, command := range commands {
+ if command == want {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/providers/asciibox/client.go b/internal/providers/asciibox/client.go
new file mode 100644
index 00000000..cf82d803
--- /dev/null
+++ b/internal/providers/asciibox/client.go
@@ -0,0 +1,434 @@
+package asciibox
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "time"
+)
+
+type api interface {
+ Check(context.Context) error
+ CreateBox(context.Context, createRequest) (boxData, error)
+ PrepareSSH(context.Context, string) error
+ GetBox(context.Context, string) (boxData, error)
+ ListBoxes(context.Context) ([]boxData, error)
+ ReleaseBox(context.Context, string) error
+}
+
+type client struct {
+ apiKey string
+ apiURL string
+ cliPath string
+ home string
+ runner CommandRunner
+}
+
+type createRequest struct {
+ TTL time.Duration
+}
+
+type boxData struct {
+ ID string `json:"id"`
+ Name string `json:"name,omitempty"`
+ State string `json:"state,omitempty"`
+ Status string `json:"status,omitempty"`
+ MachineIP string `json:"machineIp,omitempty"`
+ MachineIPAlt string `json:"machine_ip,omitempty"`
+ PublicIP string `json:"publicIp,omitempty"`
+ IP string `json:"ip,omitempty"`
+ SSHUser string `json:"sshUser,omitempty"`
+ SSHUserAlt string `json:"ssh_user,omitempty"`
+ URL string `json:"url,omitempty"`
+ DesktopURL string `json:"desktopUrl,omitempty"`
+ ArchiveAfter any `json:"archiveAfter,omitempty"`
+ ExpiresAt any `json:"expiresAt,omitempty"`
+ CreatedAt any `json:"createdAt,omitempty"`
+ UpdatedAt any `json:"updatedAt,omitempty"`
+}
+
+var newAPI = func(cfg Config, rt Runtime) (api, error) {
+ apiKey := strings.TrimSpace(cfg.AsciiBox.APIKey)
+ if apiKey == "" {
+ return nil, exit(2, "provider=%s requires ASCII_BOX_API_KEY", providerName)
+ }
+ if rt.Exec == nil {
+ return nil, exit(2, "provider=%s requires a local command runner", providerName)
+ }
+ cliPath := strings.TrimSpace(cfg.AsciiBox.CLIPath)
+ if cliPath == "" {
+ cliPath = "box"
+ }
+ apiURL := strings.TrimRight(blank(strings.TrimSpace(cfg.AsciiBox.BaseURL), "https://ascii.dev"), "/")
+ return &client{apiKey: apiKey, apiURL: apiURL, cliPath: cliPath, home: asciiBoxCLIHome(), runner: rt.Exec}, nil
+}
+
+func (c *client) CreateBox(ctx context.Context, req createRequest) (boxData, error) {
+ args := []string{"new"}
+ if req.TTL > 0 {
+ args = append(args, "--ttl", fmt.Sprintf("%d", int(req.TTL.Round(time.Second).Seconds())))
+ }
+ result, err := c.run(ctx, args...)
+ if err != nil {
+ if partial, parseErr := decodeNewBox(result.Stdout); parseErr == nil && partial.ID != "" {
+ if ready, waitErr := c.waitForBoxReady(ctx, partial); waitErr == nil {
+ return ready, nil
+ }
+ return partial, fmt.Errorf("ascii-box CLI new failed after creating %s: %s", partial.ID, c.formatError(result, err))
+ }
+ return boxData{}, fmt.Errorf("ascii-box CLI new failed: %s", c.formatError(result, err))
+ }
+ box, err := decodeNewBox(result.Stdout)
+ if err != nil {
+ return boxData{}, err
+ }
+ if strings.TrimSpace(box.ID) == "" {
+ return boxData{}, fmt.Errorf("ascii-box CLI new response missing box id")
+ }
+ if !boxReadyForSSH(box) {
+ if ready, err := c.waitForBoxReady(ctx, box); err == nil {
+ return ready, nil
+ }
+ }
+ return box, nil
+}
+
+func (c *client) Check(ctx context.Context) error {
+ result, err := c.run(ctx, "limits")
+ if err != nil {
+ return fmt.Errorf("ascii-box CLI limits failed: %s", c.formatError(result, err))
+ }
+ return nil
+}
+
+func (c *client) PrepareSSH(ctx context.Context, id string) error {
+ result, err := c.runWithEnv(ctx, c.sshEnv(), "ssh", id, "--", "true")
+ if err != nil {
+ return fmt.Errorf("ascii-box CLI ssh setup failed: %s", c.formatError(result, err))
+ }
+ return nil
+}
+
+func (c *client) GetBox(ctx context.Context, id string) (boxData, error) {
+ result, err := c.run(ctx, "info", id)
+ if err != nil {
+ return boxData{}, fmt.Errorf("ascii-box CLI info failed: %s", c.formatError(result, err))
+ }
+ return decodeBox([]byte(result.Stdout))
+}
+
+func (c *client) ListBoxes(ctx context.Context) ([]boxData, error) {
+ result, err := c.run(ctx, "list")
+ if err != nil {
+ return nil, fmt.Errorf("ascii-box CLI list failed: %s", c.formatError(result, err))
+ }
+ return decodeBoxes([]byte(result.Stdout))
+}
+
+func (c *client) ReleaseBox(ctx context.Context, id string) error {
+ stopResult, stopErr := c.run(ctx, "stop", id)
+ deleteResult, deleteErr := c.run(ctx, "delete", id)
+ if deleteErr != nil {
+ if stopErr != nil {
+ return fmt.Errorf("ascii-box CLI release failed: stop: %s; delete: %s", c.formatError(stopResult, stopErr), c.formatError(deleteResult, deleteErr))
+ }
+ return fmt.Errorf("ascii-box CLI delete failed: %s", c.formatError(deleteResult, deleteErr))
+ }
+ return nil
+}
+
+func (c *client) run(ctx context.Context, args ...string) (LocalCommandResult, error) {
+ return c.runWithEnv(ctx, c.env(), args...)
+}
+
+func (c *client) runWithEnv(ctx context.Context, env []string, args ...string) (LocalCommandResult, error) {
+ if err := c.ensureConfig(ctx); err != nil {
+ return LocalCommandResult{}, err
+ }
+ if _, ok := ctx.Deadline(); !ok {
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithTimeout(ctx, cliTimeout(args))
+ defer cancel()
+ }
+ argv := []string{"--no-update", "--json"}
+ if c.apiURL != "" {
+ argv = append(argv, "--api-url", c.apiURL)
+ }
+ argv = append(argv, args...)
+ return c.runner.Run(ctx, LocalCommandRequest{
+ Name: c.cliPath,
+ Args: argv,
+ Env: env,
+ })
+}
+
+func cliTimeout(args []string) time.Duration {
+ if len(args) == 0 {
+ return 30 * time.Second
+ }
+ switch args[0] {
+ case "new":
+ return 5 * time.Minute
+ case "ssh", "delete", "stop":
+ return 2 * time.Minute
+ default:
+ return 30 * time.Second
+ }
+}
+
+func (c *client) ensureConfig(ctx context.Context) error {
+ if _, ok := ctx.Deadline(); !ok {
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithTimeout(ctx, 30*time.Second)
+ defer cancel()
+ }
+ result, err := c.runner.Run(ctx, LocalCommandRequest{
+ Name: c.cliPath,
+ Args: []string{"--no-update", "--json", "config"},
+ Env: c.env(),
+ })
+ if err != nil {
+ return fmt.Errorf("ascii-box CLI config failed: %s", c.formatError(result, err))
+ }
+ var cfg struct {
+ Path string `json:"path"`
+ }
+ if err := json.Unmarshal([]byte(result.Stdout), &cfg); err != nil {
+ return fmt.Errorf("decode ascii-box CLI config: %w", err)
+ }
+ configPath := strings.TrimSpace(cfg.Path)
+ if configPath == "" {
+ return fmt.Errorf("ascii-box CLI config response missing path")
+ }
+ if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil {
+ return err
+ }
+ data, err := json.MarshalIndent(map[string]string{
+ "api_url": c.apiURL,
+ "token": c.apiKey,
+ "channel": "prod",
+ }, "", " ")
+ if err != nil {
+ return err
+ }
+ data = append(data, '\n')
+ if err := os.WriteFile(configPath, data, 0o600); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (c *client) waitForBoxReady(ctx context.Context, box boxData) (boxData, error) {
+ latest := box
+ deadline := time.NewTimer(5 * time.Minute)
+ defer deadline.Stop()
+ ticker := time.NewTicker(2 * time.Second)
+ defer ticker.Stop()
+ var lastErr error
+ for {
+ if boxReadyForSSH(latest) {
+ return latest, nil
+ }
+ if refreshed, err := c.GetBox(ctx, latest.ID); err == nil {
+ latest = mergeBox(latest, refreshed)
+ if boxReadyForSSH(latest) {
+ return latest, nil
+ }
+ } else {
+ lastErr = err
+ }
+ select {
+ case <-ctx.Done():
+ return latest, ctx.Err()
+ case <-deadline.C:
+ if lastErr != nil {
+ return latest, fmt.Errorf("timed out waiting for ascii-box %s to become ready: %w", latest.ID, lastErr)
+ }
+ return latest, fmt.Errorf("timed out waiting for ascii-box %s to become ready", latest.ID)
+ case <-ticker.C:
+ }
+ }
+}
+
+func (c *client) env() []string {
+ return setEnv(setEnv(os.Environ(), "HOME", c.home), "BOX_API_KEY", c.apiKey)
+}
+
+func (c *client) sshEnv() []string {
+ return setEnv(c.env(), "SSH_AUTH_SOCK", "")
+}
+
+func (c *client) formatError(result LocalCommandResult, err error) string {
+ message := strings.TrimSpace(result.Stderr)
+ if message == "" {
+ message = strings.TrimSpace(result.Stdout)
+ }
+ if message == "" && err != nil {
+ message = err.Error()
+ }
+ return redactBoxSecrets(blank(message, "unknown error"))
+}
+
+var (
+ boxTokenParamRE = regexp.MustCompile(`(?i)([?&](?:box_token|token|access_token|auth_token)=)[^&\s"']+`)
+ boxSecretRE = regexp.MustCompile(`box_[A-Za-z0-9_-]+`)
+)
+
+func redactBoxSecrets(value string) string {
+ value = boxTokenParamRE.ReplaceAllString(value, "${1}REDACTED")
+ return boxSecretRE.ReplaceAllString(value, "box_REDACTED")
+}
+
+func asciiBoxCLIHome() string {
+ if configured := strings.TrimSpace(os.Getenv("CRABBOX_ASCII_BOX_HOME")); configured != "" {
+ return expandUserPath(configured)
+ }
+ if home, err := os.UserHomeDir(); err == nil && home != "" {
+ return filepath.Join(home, ".local", "state", "crabbox", "ascii-box")
+ }
+ return filepath.Join(os.TempDir(), "crabbox-ascii-box")
+}
+
+func setEnv(env []string, key, value string) []string {
+ prefix := key + "="
+ out := make([]string, 0, len(env)+1)
+ set := false
+ for _, entry := range env {
+ if strings.HasPrefix(entry, prefix) {
+ out = append(out, prefix+value)
+ set = true
+ continue
+ }
+ out = append(out, entry)
+ }
+ if !set {
+ out = append(out, prefix+value)
+ }
+ return out
+}
+
+func decodeNewBox(output string) (boxData, error) {
+ var latest boxData
+ scanner := bufio.NewScanner(strings.NewReader(output))
+ for scanner.Scan() {
+ line := bytes.TrimSpace(scanner.Bytes())
+ if len(line) == 0 {
+ continue
+ }
+ var event struct {
+ Event string `json:"event"`
+ boxData
+ Data boxData `json:"data"`
+ Box boxData `json:"box"`
+ }
+ if err := json.Unmarshal(line, &event); err != nil {
+ return boxData{}, fmt.Errorf("decode ascii-box CLI new line: %w", err)
+ }
+ box := event.boxData
+ if box.ID == "" {
+ box = event.Data
+ }
+ if box.ID == "" {
+ box = event.Box
+ }
+ if box.ID != "" {
+ latest = mergeBox(latest, box)
+ }
+ if event.Event == "ready" && latest.ID != "" {
+ return latest, nil
+ }
+ if event.Event == "error" {
+ return boxData{}, fmt.Errorf("ascii-box CLI new failed: %s", redactBoxSecrets(string(line)))
+ }
+ }
+ if err := scanner.Err(); err != nil {
+ return boxData{}, err
+ }
+ if latest.ID == "" {
+ return boxData{}, fmt.Errorf("decode ascii-box CLI new: no box event")
+ }
+ return latest, nil
+}
+
+func mergeBox(base, update boxData) boxData {
+ if update.ID != "" {
+ base.ID = update.ID
+ }
+ if update.Name != "" {
+ base.Name = update.Name
+ }
+ if update.State != "" {
+ base.State = update.State
+ }
+ if update.Status != "" {
+ base.Status = update.Status
+ }
+ if update.IP != "" {
+ base.IP = update.IP
+ }
+ if update.MachineIP != "" {
+ base.MachineIP = update.MachineIP
+ }
+ if update.MachineIPAlt != "" {
+ base.MachineIPAlt = update.MachineIPAlt
+ }
+ if update.PublicIP != "" {
+ base.PublicIP = update.PublicIP
+ }
+ if update.SSHUser != "" {
+ base.SSHUser = update.SSHUser
+ }
+ if update.SSHUserAlt != "" {
+ base.SSHUserAlt = update.SSHUserAlt
+ }
+ if update.URL != "" {
+ base.URL = update.URL
+ }
+ if update.DesktopURL != "" {
+ base.DesktopURL = update.DesktopURL
+ }
+ if update.ArchiveAfter != nil {
+ base.ArchiveAfter = update.ArchiveAfter
+ }
+ if update.CreatedAt != nil {
+ base.CreatedAt = update.CreatedAt
+ }
+ if update.UpdatedAt != nil {
+ base.UpdatedAt = update.UpdatedAt
+ }
+ return base
+}
+
+func decodeBox(data []byte) (boxData, error) {
+ var wrapped struct {
+ Box boxData `json:"box"`
+ }
+ if err := json.Unmarshal(data, &wrapped); err == nil && strings.TrimSpace(wrapped.Box.ID) != "" {
+ return wrapped.Box, nil
+ }
+ var box boxData
+ if err := json.Unmarshal(data, &box); err != nil {
+ return boxData{}, fmt.Errorf("decode ascii-box box: %w", err)
+ }
+ return box, nil
+}
+
+func decodeBoxes(data []byte) ([]boxData, error) {
+ var wrapped struct {
+ Boxes []boxData `json:"boxes"`
+ }
+ if err := json.Unmarshal(data, &wrapped); err == nil && wrapped.Boxes != nil {
+ return wrapped.Boxes, nil
+ }
+ var boxes []boxData
+ if err := json.Unmarshal(data, &boxes); err != nil {
+ return nil, fmt.Errorf("decode ascii-box boxes: %w", err)
+ }
+ return boxes, nil
+}
diff --git a/internal/providers/asciibox/core.go b/internal/providers/asciibox/core.go
new file mode 100644
index 00000000..8cf07799
--- /dev/null
+++ b/internal/providers/asciibox/core.go
@@ -0,0 +1,115 @@
+package asciibox
+
+import (
+ "context"
+ "flag"
+ "io"
+ "time"
+
+ core "github.com/openclaw/crabbox/internal/cli"
+)
+
+type Config = core.Config
+type AsciiBoxConfig = core.AsciiBoxConfig
+type ProviderSpec = core.ProviderSpec
+type Runtime = core.Runtime
+type CommandRunner = core.CommandRunner
+type LocalCommandRequest = core.LocalCommandRequest
+type LocalCommandResult = core.LocalCommandResult
+type Backend = core.Backend
+type DoctorRequest = core.DoctorRequest
+type DoctorResult = core.DoctorResult
+type AcquireRequest = core.AcquireRequest
+type ResolveRequest = core.ResolveRequest
+type ReleaseLeaseRequest = core.ReleaseLeaseRequest
+type TouchRequest = core.TouchRequest
+type ListRequest = core.ListRequest
+type LeaseView = core.LeaseView
+type LeaseTarget = core.LeaseTarget
+type LeaseClaim = core.LeaseClaim
+type StatusRequest = core.StatusRequest
+type StatusView = core.StatusView
+type Server = core.Server
+type SSHTarget = core.SSHTarget
+
+const (
+ providerName = "ascii-box"
+ targetLinux = core.TargetLinux
+
+ networkPublic = core.NetworkPublic
+)
+
+func exit(code int, format string, args ...any) core.ExitError {
+ return core.Exit(code, format, args...)
+}
+
+func flagWasSet(fs *flag.FlagSet, name string) bool {
+ return core.FlagWasSet(fs, name)
+}
+
+func blank(value, fallback string) string {
+ return core.Blank(value, fallback)
+}
+
+func newLeaseID() string {
+ return core.NewLeaseID()
+}
+
+func newLeaseSlug(leaseID string) string {
+ return core.NewLeaseSlug(leaseID)
+}
+
+func leaseProviderName(leaseID, slug string) string {
+ return core.LeaseProviderName(leaseID, slug)
+}
+
+func allocateClaimLeaseSlug(leaseID, requested string) (string, error) {
+ return core.AllocateClaimLeaseSlug(leaseID, requested)
+}
+
+func directLeaseLabels(cfg Config, leaseID, slug, provider, market string, keep bool, now time.Time) map[string]string {
+ return core.DirectLeaseLabels(cfg, leaseID, slug, provider, market, keep, now)
+}
+
+func touchDirectLeaseLabels(labels map[string]string, cfg Config, state string, now time.Time) map[string]string {
+ return core.TouchDirectLeaseLabels(labels, cfg, state, now)
+}
+
+func claimLeaseForRepoProviderScope(leaseID, slug, provider, providerScope, repoRoot string, idleTimeout time.Duration, reclaim bool) error {
+ return core.ClaimLeaseForRepoProviderScope(leaseID, slug, provider, providerScope, repoRoot, idleTimeout, reclaim)
+}
+
+func resolveLeaseClaimForProvider(identifier, provider string) (core.LeaseClaim, bool, error) {
+ return core.ResolveLeaseClaimForProvider(identifier, provider)
+}
+
+func listLeaseClaims() ([]core.LeaseClaim, error) {
+ return core.ListLeaseClaims()
+}
+
+func removeLeaseClaim(leaseID string) {
+ core.RemoveLeaseClaim(leaseID)
+}
+
+func expandUserPath(path string) string {
+ return core.ExpandUserPath(path)
+}
+
+func waitForSSHReady(ctx context.Context, target *SSHTarget, stderr io.Writer, phase string, timeout time.Duration) error {
+ return core.WaitForSSHReady(ctx, target, stderr, phase, timeout)
+}
+
+func bootstrapWaitTimeout(cfg Config) time.Duration {
+ return core.BootstrapWaitTimeout(cfg)
+}
+
+func inventoryDoctorResult(provider string, leases int) DoctorResult {
+ return core.InventoryDoctorResult(provider, leases)
+}
+
+func now(rt Runtime) time.Time {
+ if rt.Clock != nil {
+ return rt.Clock.Now()
+ }
+ return time.Now()
+}
diff --git a/internal/providers/asciibox/flags.go b/internal/providers/asciibox/flags.go
new file mode 100644
index 00000000..aae0c995
--- /dev/null
+++ b/internal/providers/asciibox/flags.go
@@ -0,0 +1,51 @@
+package asciibox
+
+import (
+ "flag"
+)
+
+type flagValues struct {
+ BaseURL *string
+ CLIPath *string
+ Workdir *string
+}
+
+func RegisterAsciiBoxProviderFlags(fs *flag.FlagSet, defaults Config) any {
+ return flagValues{
+ BaseURL: fs.String("ascii-box-base-url", defaults.AsciiBox.BaseURL, "ASCII Box API base URL"),
+ CLIPath: fs.String("ascii-box-cli", defaults.AsciiBox.CLIPath, "ASCII Box CLI path"),
+ Workdir: fs.String("ascii-box-workdir", defaults.AsciiBox.Workdir, "absolute working directory inside the ASCII Box"),
+ }
+}
+
+func ApplyAsciiBoxProviderFlags(cfg *Config, fs *flag.FlagSet, values any) error {
+ if cfg.Provider == providerName || cfg.Provider == "ascii" || cfg.Provider == "asciibox" || cfg.Provider == "ascii-box" {
+ if flagWasSet(fs, "class") {
+ return exit(2, "--class is not supported for provider=%s", providerName)
+ }
+ if flagWasSet(fs, "type") {
+ return exit(2, "--type is not supported for provider=%s", providerName)
+ }
+ }
+ v, ok := values.(flagValues)
+ if !ok {
+ return nil
+ }
+ if flagWasSet(fs, "ascii-box-base-url") {
+ cfg.AsciiBox.BaseURL = *v.BaseURL
+ }
+ if flagWasSet(fs, "ascii-box-cli") {
+ cfg.AsciiBox.CLIPath = *v.CLIPath
+ }
+ if flagWasSet(fs, "ascii-box-workdir") {
+ cfg.AsciiBox.Workdir = *v.Workdir
+ }
+ if cfg.Provider == providerName || cfg.Provider == "ascii" || cfg.Provider == "asciibox" || cfg.Provider == "ascii-box" {
+ cleaned, err := cleanWorkdir(workdir(*cfg))
+ if err != nil {
+ return err
+ }
+ cfg.WorkRoot = cleaned
+ }
+ return nil
+}
diff --git a/internal/providers/asciibox/provider.go b/internal/providers/asciibox/provider.go
new file mode 100644
index 00000000..194c1be2
--- /dev/null
+++ b/internal/providers/asciibox/provider.go
@@ -0,0 +1,53 @@
+package asciibox
+
+import (
+ "flag"
+
+ core "github.com/openclaw/crabbox/internal/cli"
+)
+
+func init() {
+ core.RegisterProvider(Provider{})
+}
+
+type Provider struct{}
+
+func (Provider) Name() string { return providerName }
+
+func (Provider) Aliases() []string {
+ return []string{"ascii", "asciibox"}
+}
+
+func (Provider) Spec() core.ProviderSpec {
+ return core.ProviderSpec{
+ Name: providerName,
+ Kind: core.ProviderKindSSHLease,
+ Targets: []core.TargetSpec{{OS: core.TargetLinux}},
+ Features: core.FeatureSet{core.FeatureSSH, core.FeatureCrabboxSync},
+ Coordinator: core.CoordinatorNever,
+ }
+}
+
+func (Provider) RegisterFlags(fs *flag.FlagSet, defaults core.Config) any {
+ return RegisterAsciiBoxProviderFlags(fs, defaults)
+}
+
+func (Provider) ApplyFlags(cfg *core.Config, fs *flag.FlagSet, values any) error {
+ return ApplyAsciiBoxProviderFlags(cfg, fs, values)
+}
+
+func (p Provider) Configure(cfg core.Config, rt core.Runtime) (core.Backend, error) {
+ return NewBackend(p.Spec(), cfg, rt), nil
+}
+
+func (p Provider) ConfigureDoctor(cfg core.Config, rt core.Runtime) (core.DoctorBackend, error) {
+ backend, err := p.Configure(cfg, rt)
+ if err != nil {
+ return nil, err
+ }
+ doctor, ok := backend.(core.DoctorBackend)
+ if !ok {
+ return nil, core.Exit(2, "ascii-box doctor backend unavailable")
+ }
+ return doctor, nil
+}