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 +}