diff --git a/docs/features/provider-live-smoke.md b/docs/features/provider-live-smoke.md index b157c410a..a7820d007 100644 --- a/docs/features/provider-live-smoke.md +++ b/docs/features/provider-live-smoke.md @@ -151,7 +151,7 @@ hermetic lifecycle tests, `scripts/live-smoke.sh`, dedicated live runners, and `//go:build smoke` tests. Regenerate it with `node scripts/generate-provider-matrix.mjs`; docs CI rejects drift. -Current coverage: 72 providers; 4 with convention-named hermetic lifecycle tests, 52 with a live runner, 3 with tagged Go smoke tests, and 19 with none of those lifecycle surfaces. +Current coverage: 73 providers; 4 with convention-named hermetic lifecycle tests, 53 with a live runner, 4 with tagged Go smoke tests, and 19 with none of those lifecycle surfaces. | Provider | Hermetic lifecycle | Live runner | Tagged Go smoke | | --- | --- | --- | --- | @@ -203,6 +203,7 @@ Current coverage: 72 providers; 4 with convention-named hermetic lifecycle tests | [nvidia-brev](../providers/nvidia-brev.md) | — | dedicated + matrix | — | | [opencomputer](../providers/opencomputer.md) | — | — | — | | [opensandbox](../providers/opensandbox.md) | — | dedicated + matrix | — | +| [orgo](../providers/orgo.md) | — | matrix | yes | | [ovh](../providers/ovh.md) | — | dedicated + matrix | — | | [parallels](../providers/parallels.md) | — | — | — | | [phala](../providers/phala.md) | — | dedicated + matrix | — | diff --git a/docs/providers/README.md b/docs/providers/README.md index 293f09078..e47929fab 100644 --- a/docs/providers/README.md +++ b/docs/providers/README.md @@ -61,7 +61,7 @@ selection metadata. Regenerate it with `node scripts/generate-provider-matrix.mj `scripts/check-docs.sh` fails when provider registration, metadata, docs paths, or this generated table drift. -Current built-in surface: 72 providers (42 SSH lease, 28 delegated run, 2 service control). +Current built-in surface: 73 providers (42 SSH lease, 29 delegated run, 2 service control). Access terms: @@ -119,6 +119,7 @@ Access terms: | [nvidia-brev](nvidia-brev.md) (`brev`, `nvidia`) | built-in; `ssh-lease` · gpu-cloud | Crabbox-managed SSH; `crabbox-sync` · direct only; features: `ssh`, `crabbox-sync`, `cleanup` | `linux`; NVIDIA Brev GPU workspace | `provider-managed`; GPU: yes | NVIDIA Brev CLI; delete by default; optional stop | Managed NVIDIA GPU workspace over SSH | Requires Brev CLI auth, quota, and available GPU capacity | | [opencomputer](opencomputer.md) (`oc`, `open-computer`) | built-in; `delegated-run` · delegated-sandbox | No SSH; `archive-sync` · direct only; features: `archive-sync`, `run-session` | `linux`; OpenComputer Linux VM | `provider-managed`; GPU: unknown | OpenComputer; VM delete | Hosted delegated Linux VM execution | REST execution contract, not an SSH lease | | [opensandbox](opensandbox.md) | built-in; `delegated-run` · delegated-sandbox | No SSH; `archive-sync` · direct only; features: `archive-sync`, `cleanup`, `run-session` | `linux`; OpenSandbox sandbox | `provider-managed`; GPU: unknown | OpenSandbox; sandbox delete | Hosted delegated sandbox through an open SDK | Requires compatible OpenSandbox control and exec endpoints | +| [orgo](orgo.md) (`orgo-ai`) | built-in; `delegated-run` · delegated-sandbox | No SSH; `provider-owned` · direct only; features: none | `linux`; Orgo cloud computer | `provider-managed`; GPU: unknown | Orgo; computer and temporary workspace delete | Managed Linux computer execution through an HTTP API | No normal SSH lease or Crabbox workspace sync | | [ovh](ovh.md) | built-in; `ssh-lease` · direct-cloud | Crabbox-managed SSH; `crabbox-sync` · direct only; features: `ssh`, `crabbox-sync`, `cleanup`, `tailscale` | `linux`; OVHcloud Public Cloud instance | `cloud`; GPU: optional | Crabbox; instance, key, and local claim delete | OVHcloud Public Cloud Linux VM | Direct-only; credentials use OVH signed requests and local claims | | [parallels](parallels.md) | built-in; `ssh-lease` · local-vm | Crabbox-managed SSH; `crabbox-sync` · direct only; features: `ssh`, `crabbox-sync`, `cleanup`, `desktop`, `browser`, `code`, `workspace-checkpoint`, `workspace-fork`, `workspace-restore`, `provider-snapshot` | `linux`, `macos`, `windows/normal`, `windows/wsl2`; Parallels linked-clone VM | `local`; GPU: no | Crabbox; clone delete | Local macOS, Linux, or Windows VM with snapshots | Requires prepared Parallels source VMs and SSH | | [phala](phala.md) (`phala-cloud`, `dstack`) | built-in; `ssh-lease` · direct-cloud | Crabbox-managed SSH; `crabbox-sync` · direct only; features: `ssh`, `crabbox-sync`, `cleanup` | `linux`; Phala Cloud confidential Intel TDX CVM | `provider-managed`; GPU: no | Phala phala CLI; CVM delete | Short-lived confidential Linux compute over SSH | Requires the phala CLI and its stored auth; verifies Intel TDX attestation by default (needs outbound Intel PCS network; --phala-skip-attestation to opt out) | diff --git a/docs/providers/orgo.md b/docs/providers/orgo.md new file mode 100644 index 000000000..26b124710 --- /dev/null +++ b/docs/providers/orgo.md @@ -0,0 +1,146 @@ +# Orgo Provider + +Read when: + +- choosing `provider: orgo` (alias `orgo-ai`); +- running a command on an [Orgo](https://www.orgo.ai/) cloud computer; +- changing `internal/providers/orgo`. + +Orgo provides Linux cloud computers with an HTTP control API. Crabbox uses the +REST API directly: it creates a workspace when needed, creates a computer, runs +commands through `POST /computers/{id}/bash`, and deletes resources on cleanup. + +This is a delegated-run provider. Crabbox does not provision an SSH lease, does +not rsync the local workspace, and does not use the coordinator broker. + +## When to use + +Use Orgo when you want a managed Linux desktop/computer surface with API-driven +shell execution and fast startup. Pick an SSH-lease provider such as AWS, +Hetzner, Daytona, or Static SSH when you need Crabbox-managed SSH, rsync, +Actions hydration, or interactive `crabbox ssh`. + +Orgo is Linux-only in Crabbox. Desktop, browser, code-server, normal SSH, +Actions hydration, and brokered coordinator routing are not available through +this adapter. + +## Commands + +```sh +crabbox run --provider orgo --no-sync -- echo crabbox-orgo-ok +crabbox warmup --provider orgo --slug orgo-smoke +crabbox run --provider orgo --id orgo-smoke --no-sync -- uname -a +crabbox status --provider orgo --id orgo-smoke --wait +crabbox stop --provider orgo orgo-smoke +crabbox list --provider orgo +crabbox doctor --provider orgo +``` + +Without `--id`, `run` creates a new computer, runs the command, then deletes the +computer unless `--keep` is set. With `--id `, it runs the +command on the existing computer and leaves it running. + +`warmup` creates a persistent computer and records a local Crabbox slug so later +commands can use `--id `. + +## Auth + +Resolve the Orgo API key in this precedence: + +1. `CRABBOX_ORGO_API_KEY` — explicit per-run override, CI-friendly. +2. `orgo.apiKey` — trusted user or explicit `CRABBOX_CONFIG` value; repository + config cannot supply provider secrets. +3. `ORGO_API_KEY` — canonical Orgo environment variable. + +The API key is never exposed as a CLI flag; do not pass secrets on the command +line. + +## Config + +```yaml +provider: orgo +target: linux +orgo: + apiBase: https://www.orgo.ai/api + workspaceID: 550e8400-e29b-41d4-a716-446655440000 + ramGB: 4 + cpus: 1 + diskGB: 8 + resolution: 1280x720x24 +``` + +If `workspaceID` is omitted, Crabbox creates a temporary workspace for each new +computer and deletes that workspace during cleanup. For workspace-scoped Orgo +keys, set `workspaceID` because scoped keys cannot create workspaces. + +Provider flags, each overriding the matching `orgo.*` config key: + +```text +--orgo-api-base +--orgo-workspace-id +--orgo-ram +--orgo-cpu +--orgo-disk +--orgo-resolution +``` + +Environment overrides: + +- `CRABBOX_ORGO_API_BASE`, then `ORGO_API_BASE_URL` +- `CRABBOX_ORGO_WORKSPACE_ID`, then `ORGO_WORKSPACE_ID` +- `CRABBOX_ORGO_RAM_GB` +- `CRABBOX_ORGO_CPUS` +- `CRABBOX_ORGO_DISK_GB` +- `CRABBOX_ORGO_RESOLUTION` + +Defaults: API base `https://www.orgo.ai/api`, 4 GB RAM, 1 CPU, 8 GB disk, +and resolution `1280x720x24`. + +Repository config cannot redirect an inherited Orgo API key. Set +`CRABBOX_ORGO_API_BASE` or pass `--orgo-api-base` to explicitly approve a +custom credential destination. Non-loopback API endpoints must use HTTPS. + +## Lifecycle + +`crabbox run`: + +1. With `--id `, resolve the computer and call the bash + endpoint. +2. Otherwise, create a workspace when needed, create a computer, claim a local + Crabbox slug, run the command, then delete the computer and any temporary + workspace unless `--keep` is set. + +`--keep-on-failure` preserves a newly created computer when the command fails, +so it can be inspected with Orgo tooling before manual cleanup. + +## Capabilities + +- SSH: no — Orgo command execution is HTTP API delegated. +- Crabbox sync: no — use `--no-sync`. +- Desktop / browser / code: no through Crabbox flags. +- Actions hydration: no. +- Coordinator: no — direct CLI-to-Orgo API calls only. + +## Live Smoke + +Keep the API key in the environment: + +```sh +export CRABBOX_ORGO_API_KEY=your-orgo-api-key +go build -trimpath -o bin/crabbox ./cmd/crabbox + +bin/crabbox doctor --provider orgo +bin/crabbox run --provider orgo --no-sync -- echo crabbox-orgo-ok +``` + +Or run the shared live provider harness: + +```sh +CRABBOX_LIVE=1 CRABBOX_LIVE_PROVIDERS=orgo scripts/live-smoke.sh +``` + +Related docs: + +- [Provider reference](README.md) +- [Provider authoring](../features/provider-authoring.md) +- [Orgo API reference](https://docs.orgo.ai/api-reference/introduction) diff --git a/docs/providers/provider-metadata.json b/docs/providers/provider-metadata.json index feba82ba8..3a9f5883d 100644 --- a/docs/providers/provider-metadata.json +++ b/docs/providers/provider-metadata.json @@ -713,6 +713,20 @@ "caveat": "Requires compatible OpenSandbox control and exec endpoints", "docs": "opensandbox.md" }, + "orgo": { + "status": "built-in", + "category": "delegated-sandbox", + "substrate": "Orgo cloud computer", + "location": "provider-managed", + "ssh": "no", + "sync": "provider-owned", + "gpu": "unknown", + "lifecycle": "Orgo", + "cleanup": "computer and temporary workspace delete", + "bestFit": "Managed Linux computer execution through an HTTP API", + "caveat": "No normal SSH lease or Crabbox workspace sync", + "docs": "orgo.md" + }, "parallels": { "status": "built-in", "category": "local-vm", diff --git a/internal/cli/config.go b/internal/cli/config.go index 7d1246f4f..9a712af13 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -182,6 +182,7 @@ type Config struct { hostingerUserExplicit bool hostingerWorkRootExplicit bool Wandb WandbConfig + Orgo OrgoConfig Islo IsloConfig isloImageExplicit bool isloVCPUsExplicit bool @@ -724,6 +725,18 @@ type WandbConfig struct { MaxLifetimeSeconds int } +// OrgoConfig drives the Orgo delegated-run provider. The API key is resolved +// from env/config only; it must not be passed on the command line. +type OrgoConfig struct { + APIKey string + APIBase string + WorkspaceID string + RAMGB int + CPUs int + DiskGB int + Resolution string +} + type IsloConfig struct { APIKey string BaseURL string @@ -2816,6 +2829,13 @@ func baseConfig() Config { WorkRoot: "/tmp/crabbox", WakeOnSSH: true, }, + Orgo: OrgoConfig{ + APIBase: "https://www.orgo.ai/api", + RAMGB: 4, + CPUs: 1, + DiskGB: 8, + Resolution: "1280x720x24", + }, Daytona: DaytonaConfig{ APIURL: "https://app.daytona.io/api", User: "daytona", @@ -3196,6 +3216,7 @@ type fileConfig struct { NvidiaBrev *fileNvidiaBrevConfig `yaml:"nvidiaBrev,omitempty"` Hostinger *fileHostingerConfig `yaml:"hostinger,omitempty"` Wandb *fileWandbConfig `yaml:"wandb,omitempty"` + Orgo *fileOrgoConfig `yaml:"orgo,omitempty"` Islo *fileIsloConfig `yaml:"islo,omitempty"` Freestyle *fileFreestyleConfig `yaml:"freestyle,omitempty"` Tenki *fileTenkiConfig `yaml:"tenki,omitempty"` @@ -3849,6 +3870,16 @@ type fileWandbConfig struct { MaxLifetimeSeconds int `yaml:"maxLifetimeSeconds,omitempty"` } +type fileOrgoConfig struct { + APIKey string `yaml:"apiKey,omitempty"` + APIBase string `yaml:"apiBase,omitempty"` + WorkspaceID string `yaml:"workspaceID,omitempty"` + RAMGB int `yaml:"ramGB,omitempty"` + CPUs int `yaml:"cpus,omitempty"` + DiskGB int `yaml:"diskGB,omitempty"` + Resolution string `yaml:"resolution,omitempty"` +} + type fileIsloConfig struct { BaseURL string `yaml:"baseUrl,omitempty"` Image string `yaml:"image,omitempty"` @@ -6317,6 +6348,31 @@ func applyFileConfigWithTrust(cfg *Config, file fileConfig, trusted bool) error cfg.Wandb.MaxLifetimeSeconds = file.Wandb.MaxLifetimeSeconds } } + if file.Orgo != nil { + if trusted && file.Orgo.APIKey != "" { + cfg.Orgo.APIKey = file.Orgo.APIKey + cfg.credentialProvenance.orgoAPIKey = credentialSource + } + if file.Orgo.APIBase != "" { + cfg.Orgo.APIBase = file.Orgo.APIBase + cfg.credentialProvenance.orgoAPIBase = credentialSource + } + if file.Orgo.WorkspaceID != "" { + cfg.Orgo.WorkspaceID = file.Orgo.WorkspaceID + } + if file.Orgo.RAMGB > 0 { + cfg.Orgo.RAMGB = file.Orgo.RAMGB + } + if file.Orgo.CPUs > 0 { + cfg.Orgo.CPUs = file.Orgo.CPUs + } + if file.Orgo.DiskGB > 0 { + cfg.Orgo.DiskGB = file.Orgo.DiskGB + } + if file.Orgo.Resolution != "" { + cfg.Orgo.Resolution = file.Orgo.Resolution + } + } if file.Islo != nil { if file.Islo.BaseURL != "" { cfg.Islo.BaseURL = file.Islo.BaseURL @@ -8407,6 +8463,24 @@ func applyEnv(cfg *Config) error { cfg.Wandb.APIKey = getenv("CRABBOX_WANDB_API_KEY", cfg.Wandb.APIKey) cfg.Wandb.DefaultImage = getenv("CRABBOX_WANDB_DEFAULT_IMAGE", getenv("WANDB_DEFAULT_IMAGE", cfg.Wandb.DefaultImage)) cfg.Wandb.MaxLifetimeSeconds = getenvInt("CRABBOX_WANDB_MAX_LIFETIME_SECONDS", getenvInt("WANDB_MAX_LIFETIME_SECONDS", cfg.Wandb.MaxLifetimeSeconds)) + if value := os.Getenv("CRABBOX_ORGO_API_KEY"); value != "" { + cfg.Orgo.APIKey = value + cfg.credentialProvenance.orgoAPIKey = credentialSourceEnvironment + } else if cfg.Orgo.APIKey == "" { + if value := os.Getenv("ORGO_API_KEY"); value != "" { + cfg.Orgo.APIKey = value + cfg.credentialProvenance.orgoAPIKey = credentialSourceEnvironment + } + } + if value, ok := firstNonEmptyEnv("CRABBOX_ORGO_API_BASE", "ORGO_API_BASE_URL"); ok { + cfg.Orgo.APIBase = value + cfg.credentialProvenance.orgoAPIBase = credentialSourceEnvironment + } + cfg.Orgo.WorkspaceID = getenv("CRABBOX_ORGO_WORKSPACE_ID", getenv("ORGO_WORKSPACE_ID", cfg.Orgo.WorkspaceID)) + cfg.Orgo.RAMGB = getenvInt("CRABBOX_ORGO_RAM_GB", cfg.Orgo.RAMGB) + cfg.Orgo.CPUs = getenvInt("CRABBOX_ORGO_CPUS", cfg.Orgo.CPUs) + cfg.Orgo.DiskGB = getenvInt("CRABBOX_ORGO_DISK_GB", cfg.Orgo.DiskGB) + cfg.Orgo.Resolution = getenv("CRABBOX_ORGO_RESOLUTION", cfg.Orgo.Resolution) if value, ok := firstNonEmptyEnv("CRABBOX_ISLO_API_KEY", "ISLO_API_KEY"); ok { cfg.Islo.APIKey = value cfg.credentialProvenance.isloAPIKey = credentialSourceEnvironment diff --git a/internal/cli/credential_provenance.go b/internal/cli/credential_provenance.go index 8a0fb0c40..8e023ed11 100644 --- a/internal/cli/credential_provenance.go +++ b/internal/cli/credential_provenance.go @@ -47,6 +47,8 @@ type credentialDestinationProvenance struct { railwayAPIToken credentialValueSource fastAPICloudAPIURL credentialValueSource fastAPICloudToken credentialValueSource + orgoAPIBase credentialValueSource + orgoAPIKey credentialValueSource runpodAPIURL credentialValueSource runpodAPIKey credentialValueSource vastAPIURL credentialValueSource @@ -168,6 +170,9 @@ func markCredentialDestinationFlagSources(cfg *Config, fs *flag.FlagSet) { if flagWasSet(fs, "fastapi-cloud-url") { provenance.fastAPICloudAPIURL = credentialSourceFlag } + if flagWasSet(fs, "orgo-api-base") { + provenance.orgoAPIBase = credentialSourceFlag + } if flagWasSet(fs, "runpod-url") { provenance.runpodAPIURL = credentialSourceFlag } @@ -312,6 +317,11 @@ func validateProviderCredentialDestination(cfg Config) error { inheritedCredential(sourcedCredential{cfg.FastAPICloud.Token, provenance.fastAPICloudToken}) { return repositoryCredentialDestinationError("fastapi-cloud", "fastapiCloud.apiUrl", "CRABBOX_FASTAPI_CLOUD_API_URL or --fastapi-cloud-url") } + case "orgo": + if provenance.orgoAPIBase == credentialSourceRepository && + inheritedCredential(sourcedCredential{cfg.Orgo.APIKey, provenance.orgoAPIKey}) { + return repositoryCredentialDestinationError("orgo", "orgo.apiBase", "CRABBOX_ORGO_API_BASE or --orgo-api-base") + } case "runpod": if provenance.runpodAPIURL == credentialSourceRepository && inheritedCredential(sourcedCredential{cfg.Runpod.APIKey, provenance.runpodAPIKey}) { diff --git a/internal/cli/credential_provenance_test.go b/internal/cli/credential_provenance_test.go index f3a51287b..053da9da0 100644 --- a/internal/cli/credential_provenance_test.go +++ b/internal/cli/credential_provenance_test.go @@ -126,6 +126,18 @@ func TestRepositoryCredentialDestinationsRejectInheritedCredentials(t *testing.T }, want: "fastapiCloud.apiUrl", }, + { + name: "orgo api", + cfg: Config{ + Provider: "orgo", + Orgo: OrgoConfig{APIBase: "https://repo.example.test", APIKey: "secret"}, + credentialProvenance: credentialDestinationProvenance{ + orgoAPIBase: credentialSourceRepository, + orgoAPIKey: credentialSourceEnvironment, + }, + }, + want: "orgo.apiBase", + }, { name: "runpod api", cfg: Config{ @@ -645,6 +657,13 @@ func TestConfigMergeSourceBindsDirectProviderCredentials(t *testing.T) { credentialEnv: "CRABBOX_RAILWAY_API_TOKEN", approveEnv: "CRABBOX_RAILWAY_API_URL", }, + { + name: "orgo", + provider: "orgo", + file: fileConfig{Orgo: &fileOrgoConfig{APIBase: "https://repo.example.test"}}, + credentialEnv: "CRABBOX_ORGO_API_KEY", + approveEnv: "CRABBOX_ORGO_API_BASE", + }, { name: "runpod", provider: "runpod", @@ -1687,3 +1706,19 @@ func TestRepositorySSHDestinationsAllowExplicitFlagOverride(t *testing.T) { } }) } + +func TestConfigMergeIgnoresRepositoryOrgoCredential(t *testing.T) { + cfg := baseConfig() + cfg.Provider = "orgo" + if err := applyFileConfigWithTrust(&cfg, fileConfig{ + Orgo: &fileOrgoConfig{ + APIBase: "https://repo.example.test", + APIKey: "project-secret", + }, + }, false); err != nil { + t.Fatal(err) + } + if cfg.Orgo.APIKey != "" { + t.Fatal("repository Orgo API key was loaded") + } +} diff --git a/internal/cli/provider_categories_generated.go b/internal/cli/provider_categories_generated.go index 845d61584..7533c76fd 100644 --- a/internal/cli/provider_categories_generated.go +++ b/internal/cli/provider_categories_generated.go @@ -51,6 +51,7 @@ var benchmarkProviderCategories = map[string]string{ "nvidia-brev": "gpu-cloud", "opencomputer": "delegated-sandbox", "opensandbox": "delegated-sandbox", + "orgo": "delegated-sandbox", "ovh": "direct-cloud", "parallels": "local-vm", "phala": "direct-cloud", diff --git a/internal/cli/webvnc.go b/internal/cli/webvnc.go index 667a470d4..9e4208e68 100644 --- a/internal/cli/webvnc.go +++ b/internal/cli/webvnc.go @@ -857,6 +857,7 @@ func (a App) webVNCStatusCommand(ctx context.Context, args []string) error { if err := validateWebVNCResolvedProviderIdentity(cfg, server, target, leaseID, expectedIdentity); err != nil { return err } + commandCfg := resolvedWebVNCCommandConfig(cfg, server, target) if err := enforceManagedLeaseCapabilities(cfg, server, leaseID); err != nil { return err } @@ -880,7 +881,7 @@ func (a App) webVNCStatusCommand(ctx context.Context, args []string) error { } } fmt.Fprintf(a.Stdout, "lease: %s slug=%s provider=%s target=%s\n", leaseID, blank(serverSlug(server), "-"), blank(server.Provider, cfg.Provider), blank(target.TargetOS, cfg.TargetOS)) - rescueCtx := rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID} + rescueCtx := rescueContext{Cfg: commandCfg, Target: target, LeaseID: leaseID} if daemonErr != nil { fmt.Fprintf(a.Stdout, "webvnc daemon: error=%v\n", daemonErr) } else { @@ -926,7 +927,7 @@ func (a App) webVNCStatusCommand(ctx context.Context, args []string) error { fmt.Fprintf(a.Stdout, "username: %s\n", strings.TrimSpace(username)) } } - fmt.Fprintf(a.Stdout, "fallback: %s\n", nativeVNCOpenCommand(cfg, target, leaseID)) + fmt.Fprintf(a.Stdout, "fallback: %s\n", nativeVNCOpenCommand(commandCfg, target, leaseID)) if statusErr == nil && !status.BridgeConnected { printRescue(a.Stdout, rescueVNCBridgeNotRunning, "portal has no active WebVNC bridge for this lease", webVNCDaemonStartRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx)) } else if statusErr == nil && webVNCObserverSlotsExhausted(status) { @@ -986,6 +987,7 @@ func (a App) webVNCResetCommand(ctx context.Context, args []string) error { if err := enforceManagedLeaseCapabilities(cfg, server, leaseID); err != nil { return err } + commandCfg := resolvedWebVNCCommandConfig(cfg, server, target) if _, err := coord.ResetWebVNC(ctx, leaseID); err != nil { fmt.Fprintf(a.Stdout, "portal reset: skipped (%v)\n", err) } @@ -998,7 +1000,7 @@ func (a App) webVNCResetCommand(ctx context.Context, args []string) error { if _, err := a.stopWebVNCDaemonIfRunning(leaseID); err != nil { return err } - rescueCtx := rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID} + rescueCtx := rescueContext{Cfg: commandCfg, Target: target, LeaseID: leaseID} if out, err := runSSHCombinedOutput(ctx, target, webVNCResetRemoteCommand(target)); err != nil { printRescue(a.Stdout, classifyDesktopFailure(out), trimFailureDetail(out), desktopDoctorCommand(rescueCtx)) return exit(5, "reset target WebVNC/input stack: %v", err) @@ -1010,7 +1012,7 @@ func (a App) webVNCResetCommand(ctx context.Context, args []string) error { } password, _ = runSSHOutput(ctx, target, vncPasswordCommand(target)) portal := webVNCPortalURL(coord.BaseURL, leaseID, username, password, webVNCPortalOptions{TakeControl: *takeControl}) - daemonArgs := webVNCBridgeArgs(cfg, target, leaseID, *openPortal, *takeControl) + daemonArgs := webVNCBridgeArgs(commandCfg, target, leaseID, *openPortal, *takeControl) daemonName := *id if strings.TrimSpace(daemonName) == "" { daemonName = leaseID @@ -1023,7 +1025,7 @@ func (a App) webVNCResetCommand(ctx context.Context, args []string) error { if strings.TrimSpace(password) != "" { fmt.Fprintf(a.Stdout, "password: %s\n", strings.TrimSpace(password)) } - fmt.Fprintf(a.Stdout, "fallback: %s\n", nativeVNCOpenCommand(cfg, target, leaseID)) + fmt.Fprintf(a.Stdout, "fallback: %s\n", nativeVNCOpenCommand(commandCfg, target, leaseID)) return nil } @@ -2045,6 +2047,19 @@ func nativeVNCOpenCommand(cfg Config, target SSHTarget, leaseID string) string { return strings.Join(readableShellWords(args), " ") } +func resolvedWebVNCCommandConfig(cfg Config, server Server, target SSHTarget) Config { + if provider := strings.TrimSpace(server.Provider); provider != "" { + cfg.Provider = provider + } + if targetOS := strings.TrimSpace(target.TargetOS); targetOS != "" { + cfg.TargetOS = targetOS + } + if windowsMode := strings.TrimSpace(target.WindowsMode); windowsMode != "" { + cfg.WindowsMode = windowsMode + } + return cfg +} + func stripLegacyWebVNCDaemonFlags(args []string) []string { out := make([]string, 0, len(args)) for _, arg := range args { diff --git a/internal/cli/webvnc_test.go b/internal/cli/webvnc_test.go index 65bb539e9..14f1bcd99 100644 --- a/internal/cli/webvnc_test.go +++ b/internal/cli/webvnc_test.go @@ -1450,6 +1450,26 @@ func TestNativeVNCFallbackCommandCarriesNetworkOverride(t *testing.T) { } } +func TestResolvedWebVNCCommandConfigPrefersResolvedLeaseProvider(t *testing.T) { + cfg := resolvedWebVNCCommandConfig( + Config{Provider: "azure", TargetOS: targetLinux}, + Server{Provider: "aws"}, + SSHTarget{TargetOS: targetWindows, WindowsMode: windowsModeWSL2}, + ) + got := nativeVNCOpenCommand(cfg, SSHTarget{TargetOS: targetWindows, WindowsMode: windowsModeWSL2}, "cbx_1") + if got != "crabbox vnc --provider aws --target windows --windows-mode wsl2 --id cbx_1 --open" { + t.Fatalf("fallback=%q", got) + } + + bridge := strings.Join( + webVNCBridgeArgs(cfg, SSHTarget{TargetOS: targetWindows, WindowsMode: windowsModeWSL2}, "cbx_1", true, false), + " ", + ) + if bridge != "--provider aws --target windows --windows-mode wsl2 --id cbx_1 --open" { + t.Fatalf("bridge args=%q", bridge) + } +} + func TestWebVNCBridgeArgsCarriesNetworkOverride(t *testing.T) { got := strings.Join(webVNCBridgeArgs( Config{Provider: "aws", TargetOS: targetLinux, Network: NetworkTailscale}, diff --git a/internal/providers/all/all.go b/internal/providers/all/all.go index 518c90ba2..94a9fd88b 100644 --- a/internal/providers/all/all.go +++ b/internal/providers/all/all.go @@ -49,6 +49,7 @@ import ( _ "github.com/openclaw/crabbox/internal/providers/nvidiabrev" _ "github.com/openclaw/crabbox/internal/providers/opencomputer" _ "github.com/openclaw/crabbox/internal/providers/opensandbox" + _ "github.com/openclaw/crabbox/internal/providers/orgo" _ "github.com/openclaw/crabbox/internal/providers/ovh" _ "github.com/openclaw/crabbox/internal/providers/parallels" _ "github.com/openclaw/crabbox/internal/providers/phala" diff --git a/internal/providers/all/all_test.go b/internal/providers/all/all_test.go index afaf08fba..8c07c46dc 100644 --- a/internal/providers/all/all_test.go +++ b/internal/providers/all/all_test.go @@ -1286,6 +1286,7 @@ func allBuiltInProviderNames() []string { "nvidia-brev", "opencomputer", "opensandbox", + "orgo", "ovh", "parallels", "phala", diff --git a/internal/providers/orgo/backend.go b/internal/providers/orgo/backend.go new file mode 100644 index 000000000..8d517dc5a --- /dev/null +++ b/internal/providers/orgo/backend.go @@ -0,0 +1,703 @@ +package orgo + +import ( + "context" + "errors" + "fmt" + "regexp" + "sort" + "strings" + "time" +) + +const ( + orgoCreatedWorkspaceLabel = "orgo_workspace_created" + orgoWorkspaceLabel = "orgo_workspace_id" + orgoReadyTimeout = 5 * time.Minute + orgoReadyPollInterval = 250 * time.Millisecond + orgoCleanupTimeout = 30 * time.Second +) + +var orgoEnvNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +func NewOrgoBackend(spec ProviderSpec, cfg Config, rt Runtime) Backend { + cfg.Provider = providerName + applyOrgoDefaults(&cfg) + return &orgoBackend{spec: spec, cfg: cfg, rt: rt} +} + +type orgoBackend struct { + spec ProviderSpec + cfg Config + rt Runtime + client orgoAPI +} + +type orgoLease struct { + LeaseID string + Slug string + Computer orgoComputer + CreatedWorkspace string +} + +func (b *orgoBackend) Spec() ProviderSpec { return b.spec } + +func (b *orgoBackend) Warmup(ctx context.Context, req WarmupRequest) error { + if req.ActionsRunner { + return exit(2, "--actions-runner is not supported for provider=%s", providerName) + } + if req.Options.Tailscale.Enabled { + return exit(2, "provider=%s is delegated-run only and does not support Tailscale options", providerName) + } + started := b.now() + client, err := b.api() + if err != nil { + return err + } + lease, err := b.createComputer(ctx, client, req.Repo, req.RequestedSlug, req.Reclaim) + if err != nil { + return err + } + fmt.Fprintf(b.rt.Stdout, "leased %s slug=%s provider=%s computer=%s workspace=%s\n", lease.LeaseID, lease.Slug, providerName, lease.Computer.ID, lease.Computer.WorkspaceID) + if !req.Keep { + fmt.Fprintf(b.rt.Stderr, "warning: orgo warmup keeps the computer until explicit stop\n") + } + total := b.now().Sub(started) + fmt.Fprintf(b.rt.Stdout, "warmup complete total=%s\n", total.Round(time.Millisecond)) + if req.TimingJSON { + return writeTimingJSON(b.rt.Stderr, timingReport{ + Provider: providerName, + LeaseID: lease.LeaseID, + Slug: lease.Slug, + TotalMs: total.Milliseconds(), + ExitCode: 0, + }) + } + return nil +} + +func (b *orgoBackend) Run(ctx context.Context, req RunRequest) (RunResult, error) { + if err := b.rejectRunOptions(req); err != nil { + return RunResult{}, err + } + if len(req.Command) == 0 { + return RunResult{}, exit(2, "missing command") + } + started := b.now() + client, err := b.api() + if err != nil { + return RunResult{}, err + } + lease := orgoLease{} + acquired := false + if strings.TrimSpace(req.ID) == "" { + lease, err = b.createComputer(ctx, client, req.Repo, req.RequestedSlug, req.Reclaim) + if err != nil { + return RunResult{}, err + } + acquired = true + fmt.Fprintf(b.rt.Stderr, "leased %s slug=%s provider=%s computer=%s workspace=%s\n", lease.LeaseID, lease.Slug, providerName, lease.Computer.ID, lease.Computer.WorkspaceID) + } else { + lease, err = b.resolveComputer(ctx, client, req.ID) + if err != nil { + return RunResult{}, err + } + lease.Computer, err = b.ensureComputerRunning(ctx, client, lease.Computer) + if err != nil { + return RunResult{}, err + } + } + + shouldStop := acquired && !req.Keep + defer func() { + if !shouldStop { + return + } + if err := b.cleanupLease(client, lease); err != nil { + fmt.Fprintf(b.rt.Stderr, "warning: orgo cleanup failed for %s: %v\n", lease.Computer.ID, err) + } + }() + + command, err := b.buildCommand(req) + if err != nil { + return RunResult{}, err + } + commandText := orgoCommandText(req) + if req.EnvSummary { + printEnvForwardingSummary(b.rt.Stderr, providerName, "forwarded", req.Options.EnvAllow, req.Env) + } + commandStarted := b.now() + exitCode, runErr := client.RunBash(ctx, lease.Computer.ID, command, b.rt.Stdout, b.rt.Stderr) + commandDuration := b.now().Sub(commandStarted) + result := RunResult{ + ExitCode: exitCode, + Command: commandDuration, + Total: b.now().Sub(started), + SyncDelegated: true, + Provider: providerName, + LeaseID: lease.LeaseID, + Slug: lease.Slug, + CommandText: commandText, + } + fmt.Fprintf(b.rt.Stderr, "orgo run summary sync_delegated=true command=%s total=%s exit=%d\n", commandDuration.Round(time.Millisecond), result.Total.Round(time.Millisecond), result.ExitCode) + if req.TimingJSON { + if err := writeTimingJSON(b.rt.Stderr, timingReport{ + Provider: providerName, + LeaseID: lease.LeaseID, + Slug: lease.Slug, + SyncDelegated: true, + SyncSkipped: true, + CommandMs: commandDuration.Milliseconds(), + TotalMs: result.Total.Milliseconds(), + ExitCode: result.ExitCode, + Label: strings.TrimSpace(req.Label), + }); err != nil { + return result, err + } + } + if runErr != nil { + handleDelegatedRunFailure(b.rt.Stderr, req, providerName, lease.LeaseID, lease.Slug, b.cfg.IdleTimeout, b.cfg.TTL, acquired, &shouldStop) + return result, runErr + } + if result.ExitCode != 0 { + handleDelegatedRunFailure(b.rt.Stderr, req, providerName, lease.LeaseID, lease.Slug, b.cfg.IdleTimeout, b.cfg.TTL, acquired, &shouldStop) + return result, ExitError{Code: result.ExitCode, Message: fmt.Sprintf("%s computer exit=%d", providerName, result.ExitCode)} + } + return result, nil +} + +func (b *orgoBackend) List(ctx context.Context, _ ListRequest) ([]LeaseView, error) { + client, err := b.api() + if err != nil { + return nil, err + } + computers, err := b.listComputers(ctx, client) + if err != nil { + return nil, err + } + claimsByComputer := map[string]LeaseClaim{} + if claims, err := listLeaseClaims(); err == nil { + for _, claim := range claims { + if claim.Provider == providerName && strings.TrimSpace(claim.CloudID) != "" { + claimsByComputer[claim.CloudID] = claim + } + } + } + servers := make([]Server, 0, len(computers)) + for _, computer := range computers { + claim := claimsByComputer[computer.ID] + servers = append(servers, orgoComputerServer(computer, claim)) + } + return servers, nil +} + +func (b *orgoBackend) Status(ctx context.Context, req StatusRequest) (StatusView, error) { + if strings.TrimSpace(req.ID) == "" { + return StatusView{}, exit(2, "provider=%s status requires --id ", providerName) + } + client, err := b.api() + if err != nil { + return StatusView{}, err + } + lease, err := b.resolveComputer(ctx, client, req.ID) + if err != nil { + return StatusView{}, err + } + timeout := req.WaitTimeout + if timeout <= 0 { + timeout = 5 * time.Minute + } + deadline := b.now().Add(timeout) + for { + view := orgoStatusView(lease) + if !req.Wait || view.Ready { + return view, nil + } + switch view.State { + case "error", "failed", "deleted": + return view, exit(5, "orgo computer %s entered %s state", lease.Computer.ID, view.State) + } + if b.now().After(deadline) { + return StatusView{}, exit(5, "timed out waiting for orgo computer %s to become ready", lease.Computer.ID) + } + select { + case <-ctx.Done(): + return StatusView{}, ctx.Err() + case <-time.After(1 * time.Second): + } + computer, err := client.GetComputer(ctx, lease.Computer.ID) + if err != nil { + return StatusView{}, err + } + lease.Computer = computer + } +} + +func (b *orgoBackend) Stop(ctx context.Context, req StopRequest) error { + if strings.TrimSpace(req.ID) == "" { + return exit(2, "provider=%s stop requires --id ", providerName) + } + client, err := b.api() + if err != nil { + return err + } + lease, err := b.resolveClaimedComputer(ctx, client, req.ID) + if err != nil { + return err + } + return b.deleteLease(ctx, client, lease) +} + +func (b *orgoBackend) resolveClaimedComputer(ctx context.Context, client orgoAPI, identifier string) (orgoLease, error) { + id := strings.TrimSpace(identifier) + claim, ok, err := resolveLeaseClaimForProviderCloudID(id) + if err != nil { + return orgoLease{}, err + } + if !ok { + claim, ok, err = resolveLeaseClaimForProvider(id) + if err != nil { + return orgoLease{}, err + } + } + if !ok { + return orgoLease{}, exit(4, "provider=%s refuses to stop unclaimed computer %s", providerName, id) + } + computerID := strings.TrimSpace(claim.CloudID) + if computerID == "" { + return orgoLease{}, exit(4, "provider=%s claim %s has no computer identity", providerName, claim.LeaseID) + } + lease := orgoLease{ + LeaseID: claim.LeaseID, + Slug: claim.Slug, + Computer: orgoComputer{ + ID: computerID, + WorkspaceID: strings.TrimSpace(claim.Labels[orgoWorkspaceLabel]), + }, + CreatedWorkspace: strings.TrimSpace(claim.Labels[orgoCreatedWorkspaceLabel]), + } + computer, err := client.GetComputer(ctx, computerID) + if err != nil { + if !isOrgoNotFound(err) { + return orgoLease{}, err + } + workspaceID := lease.Computer.WorkspaceID + if workspaceID == "" { + return orgoLease{}, err + } + workspace, workspaceErr := client.GetWorkspace(ctx, workspaceID) + if workspaceErr != nil { + return orgoLease{}, errors.Join(err, fmt.Errorf("verify orgo workspace inventory: %w", workspaceErr)) + } + if workspace.Computers == nil { + return orgoLease{}, errors.Join(err, errors.New("verify orgo workspace inventory: response omitted computers")) + } + for _, candidate := range workspace.Computers { + if candidate.ID == computerID { + return orgoLease{}, err + } + } + // A readable, complete workspace inventory proves the claimed computer is + // absent. Keep the workspace identity so a prior partial cleanup can retry. + lease.Computer.ID = "" + return lease, nil + } + if computer.WorkspaceID == "" { + computer.WorkspaceID = lease.Computer.WorkspaceID + } + lease.Computer = computer + return lease, nil +} + +func (b *orgoBackend) Doctor(ctx context.Context, _ DoctorRequest) (DoctorResult, error) { + client, err := b.api() + if err != nil { + return DoctorResult{}, err + } + computers, err := b.listComputers(ctx, client) + if err != nil { + return DoctorResult{}, err + } + return inventoryDoctorResult(providerName, len(computers)), nil +} + +func (b *orgoBackend) api() (orgoAPI, error) { + if b.client != nil { + return b.client, nil + } + client, err := newOrgoClient(b.cfg, b.rt) + if err != nil { + return nil, err + } + b.client = client + return client, nil +} + +func (b *orgoBackend) createComputer(ctx context.Context, client orgoAPI, repo Repo, requestedSlug string, reclaim bool) (orgoLease, error) { + leaseID := newLeaseID() + slug, err := allocateClaimLeaseSlug(leaseID, requestedSlug) + if err != nil { + return orgoLease{}, err + } + workspaceID := strings.TrimSpace(b.cfg.Orgo.WorkspaceID) + createdWorkspace := "" + if workspaceID == "" { + workspaceName := "crabbox-" + leaseID + workspace, err := client.CreateWorkspace(ctx, workspaceName) + if err != nil { + return orgoLease{}, fmt.Errorf("create orgo workspace name=%q: %w", workspaceName, err) + } + workspaceID = workspace.ID + createdWorkspace = workspace.ID + } + req := b.createComputerRequest(workspaceID, leaseID) + computer, err := client.CreateComputer(ctx, req) + if err != nil { + err = fmt.Errorf("create orgo computer workspace=%s name=%q: %w", workspaceID, req.Name, err) + if createdWorkspace != "" { + if cleanupErr := b.cleanupLease(client, orgoLease{CreatedWorkspace: createdWorkspace}); cleanupErr != nil { + return orgoLease{}, errors.Join(err, fmt.Errorf("rollback orgo workspace %s: %w", createdWorkspace, cleanupErr)) + } + } + return orgoLease{}, err + } + if computer.WorkspaceID == "" { + computer.WorkspaceID = workspaceID + } + computer, err = b.waitForComputerRunning(ctx, client, computer, false) + if err != nil { + cleanupErr := b.cleanupLease(client, orgoLease{ + Computer: computer, + CreatedWorkspace: createdWorkspace, + }) + if cleanupErr != nil { + return orgoLease{}, errors.Join(err, fmt.Errorf("rollback orgo computer %s workspace %s: %w", computer.ID, computer.WorkspaceID, cleanupErr)) + } + return orgoLease{}, err + } + lease := orgoLease{LeaseID: leaseID, Slug: slug, Computer: computer, CreatedWorkspace: createdWorkspace} + if err := b.claimLease(repo, lease, reclaim); err != nil { + if cleanupErr := b.cleanupLease(client, lease); cleanupErr != nil { + return orgoLease{}, errors.Join(err, fmt.Errorf("rollback orgo computer %s workspace %s: %w", computer.ID, computer.WorkspaceID, cleanupErr)) + } + return orgoLease{}, err + } + return lease, nil +} + +func (b *orgoBackend) ensureComputerRunning(ctx context.Context, client orgoAPI, computer orgoComputer) (orgoComputer, error) { + return b.waitForComputerRunning(ctx, client, computer, true) +} + +func (b *orgoBackend) waitForComputerRunning(ctx context.Context, client orgoAPI, computer orgoComputer, startStopped bool) (orgoComputer, error) { + deadline := b.now().Add(orgoReadyTimeout) + startRequested := false + for { + state := normalizeOrgoStatus(computer.Status) + switch state { + case "running": + return computer, nil + case "error", "failed", "deleted": + return computer, exit(5, "orgo computer %s entered %s state while starting", computer.ID, state) + case "stopped", "suspended": + if startStopped && !startRequested { + if err := client.StartComputer(ctx, computer.ID); err != nil { + return computer, err + } + startRequested = true + computer.Status = "starting" + continue + } + } + if !b.now().Before(deadline) { + return computer, exit(5, "timed out waiting for orgo computer %s to become running (last state=%s)", computer.ID, state) + } + select { + case <-ctx.Done(): + return computer, ctx.Err() + case <-time.After(orgoReadyPollInterval): + } + refreshed, err := client.GetComputer(ctx, computer.ID) + if err != nil { + return computer, err + } + if refreshed.WorkspaceID == "" { + refreshed.WorkspaceID = computer.WorkspaceID + } + computer = refreshed + } +} + +func (b *orgoBackend) createComputerRequest(workspaceID, leaseID string) orgoCreateComputerRequest { + cfg := b.cfg.Orgo + return orgoCreateComputerRequest{ + WorkspaceID: workspaceID, + Name: "crabbox-" + leaseID, + OS: "linux", + RAMGB: cfg.RAMGB, + CPUs: cfg.CPUs, + DiskGB: cfg.DiskGB, + Resolution: strings.TrimSpace(cfg.Resolution), + } +} + +func (b *orgoBackend) claimLease(repo Repo, lease orgoLease, reclaim bool) error { + labels := map[string]string{ + "provider": providerName, + orgoWorkspaceLabel: lease.Computer.WorkspaceID, + "orgo_instance_id": lease.Computer.InstanceID, + "orgo_connectionURL": lease.Computer.ConnectionURL, + "target": targetLinux, + } + if lease.CreatedWorkspace != "" { + labels[orgoCreatedWorkspaceLabel] = lease.CreatedWorkspace + } + server := orgoComputerServer(lease.Computer, LeaseClaim{ + LeaseID: lease.LeaseID, + Slug: lease.Slug, + Labels: labels, + }) + return claimLeaseForRepoProviderEndpoint(lease.LeaseID, lease.Slug, repo.Root, b.cfg.IdleTimeout, reclaim, server) +} + +func (b *orgoBackend) resolveComputer(ctx context.Context, client orgoAPI, identifier string) (orgoLease, error) { + id := strings.TrimSpace(identifier) + leaseID, slug := id, "" + createdWorkspace := "" + workspaceID := "" + // Exact provider resource identity wins over friendly slug matching. This is + // required for destructive operations when a slug happens to equal another + // computer's ID. + claim, ok, err := resolveLeaseClaimForProviderCloudID(id) + if err != nil { + return orgoLease{}, err + } + if !ok { + claim, ok, err = resolveLeaseClaimForProvider(id) + if err != nil { + return orgoLease{}, err + } + } + if ok { + leaseID = claim.LeaseID + slug = claim.Slug + createdWorkspace = strings.TrimSpace(claim.Labels[orgoCreatedWorkspaceLabel]) + workspaceID = strings.TrimSpace(claim.Labels[orgoWorkspaceLabel]) + if strings.TrimSpace(claim.CloudID) != "" { + id = claim.CloudID + } + } + computer, err := client.GetComputer(ctx, id) + if err != nil { + return orgoLease{}, err + } + if computer.WorkspaceID == "" { + computer.WorkspaceID = workspaceID + } + if slug == "" { + slug = computer.Name + } + return orgoLease{LeaseID: leaseID, Slug: slug, Computer: computer, CreatedWorkspace: createdWorkspace}, nil +} + +func (b *orgoBackend) deleteLease(ctx context.Context, client orgoAPI, lease orgoLease) error { + var computerErr error + if strings.TrimSpace(lease.Computer.ID) != "" { + if err := client.DeleteComputer(ctx, lease.Computer.ID); err != nil { + computerErr = err + } + } + cleanupErr := computerErr + if strings.TrimSpace(lease.CreatedWorkspace) != "" { + if err := client.DeleteWorkspace(ctx, lease.CreatedWorkspace); err != nil { + cleanupErr = errors.Join(computerErr, err) + } else { + // Deleting a Crabbox-created workspace cascades to its computers, so a + // successful workspace delete supersedes an earlier computer error. + cleanupErr = nil + } + } + if cleanupErr == nil && strings.HasPrefix(lease.LeaseID, "cbx_") { + removeLeaseClaim(lease.LeaseID) + } + return cleanupErr +} + +func isOrgoNotFound(err error) bool { + var exitErr ExitError + return errors.As(err, &exitErr) && exitErr.Code == 4 +} + +func (b *orgoBackend) cleanupLease(client orgoAPI, lease orgoLease) error { + ctx, cancel := context.WithTimeout(context.Background(), orgoCleanupTimeout) + defer cancel() + return b.deleteLease(ctx, client, lease) +} + +func (b *orgoBackend) listComputers(ctx context.Context, client orgoAPI) ([]orgoComputer, error) { + workspaceID := strings.TrimSpace(b.cfg.Orgo.WorkspaceID) + if workspaceID != "" { + workspace, err := client.GetWorkspace(ctx, workspaceID) + if err != nil { + return nil, err + } + return orgoComputersForWorkspace(workspace), nil + } + workspaces, err := client.ListWorkspaces(ctx) + if err != nil { + return nil, err + } + var computers []orgoComputer + for _, workspace := range workspaces { + if workspace.Computers == nil { + full, err := client.GetWorkspace(ctx, workspace.ID) + if err != nil { + return nil, err + } + workspace = full + } + computers = append(computers, orgoComputersForWorkspace(workspace)...) + } + return computers, nil +} + +func orgoComputersForWorkspace(workspace orgoWorkspace) []orgoComputer { + computers := append([]orgoComputer(nil), workspace.Computers...) + for i := range computers { + if computers[i].WorkspaceID == "" { + computers[i].WorkspaceID = workspace.ID + } + } + return computers +} + +func (b *orgoBackend) buildCommand(req RunRequest) (string, error) { + command := orgoCommandText(req) + if len(req.Env) == 0 { + return command, nil + } + names := make([]string, 0, len(req.Env)) + for name := range req.Env { + if !orgoEnvNamePattern.MatchString(name) { + return "", exit(2, "provider=%s cannot forward invalid env var name %q", providerName, name) + } + names = append(names, name) + } + sort.Strings(names) + var bld strings.Builder + for _, name := range names { + fmt.Fprintf(&bld, "export %s=%s\n", name, shellQuote(req.Env[name])) + } + bld.WriteString(command) + return bld.String(), nil +} + +func orgoCommandText(req RunRequest) string { + if req.ShellMode { + return strings.Join(req.Command, " ") + } + return shellScriptFromArgv(req.Command) +} + +func (b *orgoBackend) rejectRunOptions(req RunRequest) error { + if err := rejectDelegatedSyncOptionsForSpec(b.spec, req); err != nil { + return err + } + if req.Options.Tailscale.Enabled { + return exit(2, "provider=%s is delegated-run only and does not support Tailscale options", providerName) + } + if req.Options.Desktop || req.Options.Browser || req.Options.Code { + return exit(2, "provider=%s does not support desktop, browser, or code-server options", providerName) + } + return nil +} + +func orgoComputerServer(computer orgoComputer, claim LeaseClaim) Server { + labels := map[string]string{ + "provider": providerName, + orgoWorkspaceLabel: computer.WorkspaceID, + "target": targetLinux, + } + if claim.LeaseID != "" { + labels["lease"] = claim.LeaseID + } + if claim.Slug != "" { + labels["slug"] = claim.Slug + } + for key, value := range claim.Labels { + if value != "" { + labels[key] = value + } + } + server := Server{ + CloudID: computer.ID, + Provider: providerName, + Name: blank(computer.Name, computer.ID), + Status: normalizeOrgoStatus(computer.Status), + Labels: labels, + } + server.ServerType.Name = "orgo-computer" + if strings.TrimSpace(computer.ConnectionURL) != "" { + server.PublicNet.IPv4.IP = computer.ConnectionURL + } else { + server.PublicNet.IPv4.IP = computer.Hostname + } + return server +} + +func orgoStatusView(lease orgoLease) StatusView { + state := normalizeOrgoStatus(lease.Computer.Status) + return StatusView{ + ID: blank(lease.LeaseID, lease.Computer.ID), + Slug: lease.Slug, + Provider: providerName, + TargetOS: targetLinux, + State: state, + ServerID: lease.Computer.ID, + ServerType: "orgo-computer", + Host: blank(lease.Computer.ConnectionURL, lease.Computer.Hostname), + Network: networkPublic, + Ready: state == "running", + Labels: map[string]string{ + orgoWorkspaceLabel: lease.Computer.WorkspaceID, + "orgo_instance_id": lease.Computer.InstanceID, + }, + } +} + +func normalizeOrgoStatus(status string) string { + status = strings.ToLower(strings.TrimSpace(status)) + if status == "" { + return "unknown" + } + return status +} + +func applyOrgoDefaults(cfg *Config) { + cfg.Provider = providerName + if cfg.TargetOS == "" { + cfg.TargetOS = targetLinux + } + if strings.TrimSpace(cfg.Orgo.APIBase) == "" { + cfg.Orgo.APIBase = defaultAPIBase + } + if cfg.Orgo.RAMGB <= 0 { + cfg.Orgo.RAMGB = 4 + } + if cfg.Orgo.CPUs <= 0 { + cfg.Orgo.CPUs = 1 + } + if cfg.Orgo.DiskGB <= 0 { + cfg.Orgo.DiskGB = 8 + } + if strings.TrimSpace(cfg.Orgo.Resolution) == "" { + cfg.Orgo.Resolution = "1280x720x24" + } +} + +func (b *orgoBackend) now() time.Time { + if b.rt.Clock != nil { + return b.rt.Clock.Now() + } + return time.Now() +} diff --git a/internal/providers/orgo/backend_e2e_test.go b/internal/providers/orgo/backend_e2e_test.go new file mode 100644 index 000000000..787240b14 --- /dev/null +++ b/internal/providers/orgo/backend_e2e_test.go @@ -0,0 +1,113 @@ +package orgo + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +// TestOrgoBackendEndToEndCreateRunDelete drives the real *orgoHTTPClient through +// backend.Run against a fake Orgo REST API, exercising the full delegated-run +// lifecycle: create workspace -> create computer -> run bash -> delete computer +// -> delete workspace. +// +// No real secrets: the API key is a dummy value supplied via +// CRABBOX_ORGO_API_KEY and the resolved base URL is the in-process httptest +// server, so the test never reaches the live Orgo API. +func TestOrgoBackendEndToEndCreateRunDelete(t *testing.T) { + // Isolate the on-disk lease claim so the test never touches real state. + t.Setenv("XDG_STATE_HOME", t.TempDir()) + + const ( + dummyKey = "dummy-orgo-key" + workspaceID = "ws_e2e" + computerID = "computer_e2e" + ) + + var ( + mu sync.Mutex + hit = map[string]bool{} + ) + mark := func(k string) { mu.Lock(); hit[k] = true; mu.Unlock() } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer "+dummyKey { + t.Errorf("authorization=%q, want Bearer %s", got, dummyKey) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPost && r.URL.Path == "/workspaces": + mark("create_workspace") + _ = json.NewEncoder(w).Encode(orgoWorkspace{ID: workspaceID, Name: "crabbox-e2e", Status: "active"}) + case r.Method == http.MethodPost && r.URL.Path == "/computers": + mark("create_computer") + var req orgoCreateComputerRequest + _ = json.NewDecoder(r.Body).Decode(&req) + if req.WorkspaceID != workspaceID { + t.Errorf("create computer workspace_id=%q, want %q", req.WorkspaceID, workspaceID) + } + _ = json.NewEncoder(w).Encode(orgoComputer{ + ID: computerID, InstanceID: "instance_e2e", Name: req.Name, + WorkspaceID: req.WorkspaceID, Status: "running", + ConnectionURL: "https://www.orgo.ai/desktops/instance_e2e", + }) + case r.Method == http.MethodPost && r.URL.Path == "/computers/"+computerID+"/bash": + mark("run_bash") + var req map[string]any + _ = json.NewDecoder(r.Body).Decode(&req) + if cmd, _ := req["command"].(string); !strings.Contains(cmd, "crabbox-orgo-ok") { + t.Errorf("bash command=%q, want it to contain crabbox-orgo-ok", req["command"]) + } + zero := 0 + _ = json.NewEncoder(w).Encode(orgoBashResponse{Stdout: "crabbox-orgo-ok\n", ExitCodeCamel: &zero}) + case r.Method == http.MethodDelete && r.URL.Path == "/computers/"+computerID: + mark("delete_computer") + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodDelete && r.URL.Path == "/workspaces/"+workspaceID: + mark("delete_workspace") + w.WriteHeader(http.StatusNoContent) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + http.Error(w, "not found", http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + + // Dummy key + resolved fake base URL; the production client is routed at the test server. + t.Setenv("CRABBOX_ORGO_API_KEY", dummyKey) + + var out, errBuf bytes.Buffer + backend := NewOrgoBackend(Provider{}.Spec(), Config{Orgo: OrgoConfig{APIBase: server.URL}}, Runtime{ + Stdout: &out, Stderr: &errBuf, HTTP: server.Client(), + }).(*orgoBackend) + + result, err := backend.Run(context.Background(), RunRequest{ + Repo: Repo{Root: t.TempDir()}, + NoSync: true, + Command: []string{"echo", "crabbox-orgo-ok"}, + }) + if err != nil { + t.Fatalf("Run: %v (stderr=%q)", err, errBuf.String()) + } + if result.ExitCode != 0 { + t.Fatalf("ExitCode=%d, want 0 (result=%#v)", result.ExitCode, result) + } + if !result.SyncDelegated { + t.Fatalf("SyncDelegated=false, want true for a delegated-run provider (result=%#v)", result) + } + if !strings.Contains(out.String(), "crabbox-orgo-ok") { + t.Fatalf("stdout=%q, want it to contain crabbox-orgo-ok", out.String()) + } + for _, k := range []string{"create_workspace", "create_computer", "run_bash", "delete_computer", "delete_workspace"} { + if !hit[k] { + t.Fatalf("missing expected Orgo API call %q (hits=%v)", k, hit) + } + } +} diff --git a/internal/providers/orgo/backend_test.go b/internal/providers/orgo/backend_test.go new file mode 100644 index 000000000..e1930b45c --- /dev/null +++ b/internal/providers/orgo/backend_test.go @@ -0,0 +1,672 @@ +package orgo + +import ( + "bytes" + "context" + "errors" + "flag" + "io" + "strings" + "testing" + "time" +) + +type fakeOrgoAPI struct { + workspaces []orgoWorkspace + computers map[string]orgoComputer + createdWorkspace string + deletedComputers []string + deletedWorkspaces []string + deleteComputerDeadline bool + deleteWorkspaceDeadline bool + startedComputers []string + deleteComputerErr error + deleteWorkspaceErr error + getWorkspaceErr error + createComputerErr error + missingDeleteNotFound bool + bashCommands []string + bashExitCode int + bashStdout string + bashStderr string + omitWorkspaceID bool + computerStatuses []string + getComputerCalls int + bashStatuses []string +} + +func newFakeOrgoAPI() *fakeOrgoAPI { + return &fakeOrgoAPI{ + computers: map[string]orgoComputer{}, + bashStdout: "crabbox-orgo-ok\n", + } +} + +func (f *fakeOrgoAPI) CreateWorkspace(_ context.Context, name string) (orgoWorkspace, error) { + f.createdWorkspace = name + return orgoWorkspace{ID: "ws_created", Name: name, Status: "active"}, nil +} + +func (f *fakeOrgoAPI) DeleteWorkspace(ctx context.Context, id string) error { + _, f.deleteWorkspaceDeadline = ctx.Deadline() + f.deletedWorkspaces = append(f.deletedWorkspaces, id) + return f.deleteWorkspaceErr +} + +func (f *fakeOrgoAPI) ListWorkspaces(context.Context) ([]orgoWorkspace, error) { + return f.workspaces, nil +} + +func (f *fakeOrgoAPI) GetWorkspace(_ context.Context, id string) (orgoWorkspace, error) { + if f.getWorkspaceErr != nil { + return orgoWorkspace{}, f.getWorkspaceErr + } + for _, workspace := range f.workspaces { + if workspace.ID == id { + return workspace, nil + } + } + computers := []orgoComputer{} + for _, computer := range f.computers { + if computer.WorkspaceID == id { + computers = append(computers, computer) + } + } + return orgoWorkspace{ID: id, Status: "active", Computers: computers}, nil +} + +func (f *fakeOrgoAPI) CreateComputer(_ context.Context, req orgoCreateComputerRequest) (orgoComputer, error) { + if f.createComputerErr != nil { + return orgoComputer{}, f.createComputerErr + } + status := "running" + if len(f.computerStatuses) > 0 { + status = f.computerStatuses[0] + f.computerStatuses = f.computerStatuses[1:] + } + computer := orgoComputer{ + ID: "computer_test", + InstanceID: "instance_test", + Name: req.Name, + Status: status, + OS: req.OS, + RAMGB: req.RAMGB, + CPUs: req.CPUs, + DiskGB: req.DiskGB, + Resolution: req.Resolution, + ConnectionURL: "https://www.orgo.ai/desktops/instance_test", + } + if !f.omitWorkspaceID { + computer.WorkspaceID = req.WorkspaceID + } + f.computers[computer.ID] = computer + return computer, nil +} + +func (f *fakeOrgoAPI) GetComputer(_ context.Context, id string) (orgoComputer, error) { + computer, ok := f.computers[id] + if !ok { + return orgoComputer{}, exit(4, "missing computer %s", id) + } + f.getComputerCalls++ + if len(f.computerStatuses) > 0 { + computer.Status = f.computerStatuses[0] + f.computerStatuses = f.computerStatuses[1:] + f.computers[id] = computer + } + return computer, nil +} + +func (f *fakeOrgoAPI) StartComputer(_ context.Context, id string) error { + f.startedComputers = append(f.startedComputers, id) + computer := f.computers[id] + computer.Status = "running" + f.computers[id] = computer + return nil +} + +func (f *fakeOrgoAPI) DeleteComputer(ctx context.Context, id string) error { + _, f.deleteComputerDeadline = ctx.Deadline() + f.deletedComputers = append(f.deletedComputers, id) + if _, ok := f.computers[id]; !ok && f.missingDeleteNotFound { + return exit(4, "missing computer %s", id) + } + delete(f.computers, id) + return f.deleteComputerErr +} + +func (f *fakeOrgoAPI) RunBash(_ context.Context, id string, command string, stdout, stderr io.Writer) (int, error) { + f.bashStatuses = append(f.bashStatuses, f.computers[id].Status) + f.bashCommands = append(f.bashCommands, command) + if f.bashStdout != "" { + _, _ = io.WriteString(stdout, f.bashStdout) + } + if f.bashStderr != "" { + _, _ = io.WriteString(stderr, f.bashStderr) + } + return f.bashExitCode, nil +} + +func TestProviderRegistersSecretSafeFlags(t *testing.T) { + fs := flag.NewFlagSet("orgo", flag.ContinueOnError) + cfg := Config{} + values := RegisterOrgoProviderFlags(fs, cfg) + if fs.Lookup("orgo-api-key") != nil { + t.Fatalf("Orgo API key must not be registered as a CLI flag") + } + if err := fs.Parse([]string{ + "--orgo-api-base", "https://orgo.test/api", + "--orgo-workspace-id", "ws_test", + "--orgo-ram", "8", + "--orgo-cpu", "2", + "--orgo-disk", "32", + "--orgo-resolution", "1440x900x24", + }); err != nil { + t.Fatal(err) + } + cfg.Provider = providerName + if err := ApplyOrgoProviderFlags(&cfg, fs, values); err != nil { + t.Fatal(err) + } + if cfg.Orgo.APIBase != "https://orgo.test/api" || cfg.Orgo.WorkspaceID != "ws_test" || cfg.Orgo.RAMGB != 8 || cfg.Orgo.CPUs != 2 || cfg.Orgo.DiskGB != 32 || cfg.Orgo.Resolution != "1440x900x24" { + t.Fatalf("applied cfg=%#v", cfg.Orgo) + } +} + +func TestProviderAliasesRejectUnsupportedMachineFlags(t *testing.T) { + for _, provider := range []string{providerName, "orgo-ai", " ORGO-AI "} { + t.Run(provider, func(t *testing.T) { + fs := flag.NewFlagSet(provider, flag.ContinueOnError) + cfg := Config{Provider: provider} + values := RegisterOrgoProviderFlags(fs, cfg) + fs.String("class", "", "") + if err := fs.Parse([]string{"--class", "large"}); err != nil { + t.Fatal(err) + } + if err := ApplyOrgoProviderFlags(&cfg, fs, values); err == nil || !strings.Contains(err.Error(), "--class is not supported") { + t.Fatalf("err=%v", err) + } + }) + } +} + +func TestRunCreatesExecutesAndDeletesTemporaryWorkspace(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + fake := newFakeOrgoAPI() + var stdout, stderr bytes.Buffer + backend := NewOrgoBackend(Provider{}.Spec(), Config{Orgo: OrgoConfig{APIKey: "test-key"}}, Runtime{Stdout: &stdout, Stderr: &stderr}).(*orgoBackend) + backend.client = fake + + result, err := backend.Run(context.Background(), RunRequest{ + Repo: Repo{Root: t.TempDir()}, + NoSync: true, + Command: []string{"printf", "crabbox-orgo-ok"}, + Env: map[string]string{"EXAMPLE_TOKEN": "secret value"}, + EnvSummary: true, + }) + if err != nil { + t.Fatal(err) + } + if result.ExitCode != 0 || !result.SyncDelegated { + t.Fatalf("result=%#v", result) + } + if strings.Contains(result.CommandText, "EXAMPLE_TOKEN") || strings.Contains(result.CommandText, "secret value") { + t.Fatalf("proof command leaked forwarded env: %q", result.CommandText) + } + if got := stdout.String(); !strings.Contains(got, "crabbox-orgo-ok") { + t.Fatalf("stdout=%q", got) + } + if !strings.Contains(stderr.String(), "env forwarding provider=orgo behavior=forwarded") { + t.Fatalf("stderr missing env summary: %q", stderr.String()) + } + if fake.createdWorkspace == "" || !strings.HasPrefix(fake.createdWorkspace, "crabbox-cbx_") { + t.Fatalf("workspace not created with lease name: %q", fake.createdWorkspace) + } + if len(fake.bashCommands) != 1 || !strings.Contains(fake.bashCommands[0], "export EXAMPLE_TOKEN=") || !strings.Contains(fake.bashCommands[0], "crabbox-orgo-ok") { + t.Fatalf("bash commands=%#v", fake.bashCommands) + } + if got := strings.Join(fake.deletedComputers, ","); got != "computer_test" { + t.Fatalf("deleted computers=%q", got) + } + if got := strings.Join(fake.deletedWorkspaces, ","); got != "ws_created" { + t.Fatalf("deleted workspaces=%q", got) + } + if !fake.deleteComputerDeadline || !fake.deleteWorkspaceDeadline { + t.Fatalf("cleanup context missing deadline: computer=%t workspace=%t", fake.deleteComputerDeadline, fake.deleteWorkspaceDeadline) + } +} + +func TestRunWaitsForNewComputerBeforeBash(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + fake := newFakeOrgoAPI() + fake.computerStatuses = []string{"creating", "running"} + backend := NewOrgoBackend(Provider{}.Spec(), Config{Orgo: OrgoConfig{APIKey: "test-key"}}, Runtime{Stdout: io.Discard, Stderr: io.Discard}).(*orgoBackend) + backend.client = fake + + result, err := backend.Run(context.Background(), RunRequest{ + Repo: Repo{Root: t.TempDir()}, + NoSync: true, + Command: []string{"true"}, + }) + if err != nil { + t.Fatal(err) + } + if result.ExitCode != 0 { + t.Fatalf("exit=%d", result.ExitCode) + } + if fake.getComputerCalls == 0 { + t.Fatal("new computer readiness was not polled") + } + if got := strings.Join(fake.bashStatuses, ","); got != "running" { + t.Fatalf("bash states=%q, want running", got) + } +} + +func TestRunStartsReusedComputerBeforeBash(t *testing.T) { + for _, tt := range []struct { + name string + statuses []string + }{ + {name: "suspended", statuses: []string{"suspended", "running"}}, + {name: "finishes stopping", statuses: []string{"stopping", "stopped", "running"}}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + fake := newFakeOrgoAPI() + fake.computers["computer_test"] = orgoComputer{ + ID: "computer_test", Name: "orgo-reused", WorkspaceID: "ws_existing", Status: tt.statuses[0], + } + fake.computerStatuses = append([]string(nil), tt.statuses...) + backend := NewOrgoBackend(Provider{}.Spec(), Config{Orgo: OrgoConfig{APIKey: "test-key"}}, Runtime{Stdout: io.Discard, Stderr: io.Discard}).(*orgoBackend) + backend.client = fake + + result, err := backend.Run(context.Background(), RunRequest{ID: "computer_test", NoSync: true, Command: []string{"true"}}) + if err != nil { + t.Fatal(err) + } + if result.ExitCode != 0 { + t.Fatalf("exit=%d", result.ExitCode) + } + if got := strings.Join(fake.startedComputers, ","); got != "computer_test" { + t.Fatalf("started computers=%q", got) + } + if got := strings.Join(fake.bashStatuses, ","); got != "running" { + t.Fatalf("bash states=%q, want running", got) + } + }) + } +} + +func TestCreateComputerCleansUpTerminalStartupFailure(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + fake := newFakeOrgoAPI() + fake.computerStatuses = []string{"creating", "error"} + backend := NewOrgoBackend(Provider{}.Spec(), Config{Orgo: OrgoConfig{APIKey: "test-key"}}, Runtime{Stdout: io.Discard, Stderr: io.Discard}).(*orgoBackend) + backend.client = fake + + _, err := backend.Run(context.Background(), RunRequest{ + Repo: Repo{Root: t.TempDir()}, + NoSync: true, + Command: []string{"true"}, + }) + if err == nil || !strings.Contains(err.Error(), "entered error state while starting") { + t.Fatalf("err=%v", err) + } + if len(fake.bashCommands) != 0 { + t.Fatalf("bash ran before readiness: %#v", fake.bashCommands) + } + if got := strings.Join(fake.deletedComputers, ","); got != "computer_test" { + t.Fatalf("deleted computers=%q", got) + } + if got := strings.Join(fake.deletedWorkspaces, ","); got != "ws_created" { + t.Fatalf("deleted workspaces=%q", got) + } +} + +func TestCreateComputerReportsRollbackFailureWithResourceIdentity(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + fake := newFakeOrgoAPI() + fake.computerStatuses = []string{"creating", "failed"} + fake.deleteComputerErr = errors.New("computer cleanup failed") + fake.deleteWorkspaceErr = errors.New("workspace cleanup failed") + backend := NewOrgoBackend(Provider{}.Spec(), Config{Orgo: OrgoConfig{APIKey: "test-key"}}, Runtime{Stdout: io.Discard, Stderr: io.Discard}).(*orgoBackend) + + _, err := backend.createComputer(context.Background(), fake, Repo{Root: t.TempDir()}, "rollback-proof", false) + if err == nil { + t.Fatal("create unexpectedly succeeded") + } + for _, want := range []string{"entered failed state", "computer_test", "ws_created", "computer cleanup failed", "workspace cleanup failed"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("err=%q missing %q", err, want) + } + } +} + +func TestCreateComputerReportsWorkspaceRollbackFailureAfterCreateError(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + fake := newFakeOrgoAPI() + fake.createComputerErr = &orgoHTTPError{StatusCode: 400, Body: "computer create failed"} + fake.deleteWorkspaceErr = errors.New("workspace cleanup failed") + backend := NewOrgoBackend(Provider{}.Spec(), Config{Orgo: OrgoConfig{APIKey: "test-key"}}, Runtime{Stdout: io.Discard, Stderr: io.Discard}).(*orgoBackend) + + _, err := backend.createComputer(context.Background(), fake, Repo{Root: t.TempDir()}, "rollback-proof", false) + if err == nil { + t.Fatal("create unexpectedly succeeded") + } + for _, want := range []string{"computer create failed", "ws_created", "workspace cleanup failed"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("err=%q missing %q", err, want) + } + } +} + +func TestCreateComputerPreservesRequestedWorkspaceWhenResponseOmitsIt(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + fake := newFakeOrgoAPI() + fake.omitWorkspaceID = true + backend := NewOrgoBackend(Provider{}.Spec(), Config{Orgo: OrgoConfig{APIKey: "test-key"}}, Runtime{Stdout: io.Discard, Stderr: io.Discard}).(*orgoBackend) + lease, err := backend.createComputer(context.Background(), fake, Repo{Root: t.TempDir()}, "workspace-proof", false) + if err != nil { + t.Fatal(err) + } + if lease.Computer.WorkspaceID != "ws_created" { + t.Fatalf("workspace id=%q", lease.Computer.WorkspaceID) + } + if lease.Computer.Name != "crabbox-"+lease.LeaseID { + t.Fatalf("computer name=%q, want lease-unique identity", lease.Computer.Name) + } + if err := backend.deleteLease(context.Background(), fake, lease); err != nil { + t.Fatal(err) + } +} + +func TestStopByComputerIDDeletesTemporaryWorkspaceAndClaim(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + fake := newFakeOrgoAPI() + backend := NewOrgoBackend(Provider{}.Spec(), Config{Orgo: OrgoConfig{APIKey: "test-key"}}, Runtime{Stdout: io.Discard, Stderr: io.Discard}).(*orgoBackend) + backend.client = fake + lease, err := backend.createComputer(context.Background(), fake, Repo{Root: t.TempDir()}, "cloud-id-stop", false) + if err != nil { + t.Fatal(err) + } + otherLeaseID := "cbx_slug_collision_1234567890" + if err := claimLeaseForRepoProviderEndpoint(otherLeaseID, lease.Computer.ID, t.TempDir(), time.Minute, false, Server{ + CloudID: "other-computer", + Provider: providerName, + Name: "other-computer", + }); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { removeLeaseClaim(otherLeaseID) }) + if err := backend.Stop(context.Background(), StopRequest{ID: lease.Computer.ID}); err != nil { + t.Fatal(err) + } + if got := strings.Join(fake.deletedComputers, ","); got != lease.Computer.ID { + t.Fatalf("deleted computers=%q", got) + } + if got := strings.Join(fake.deletedWorkspaces, ","); got != "ws_created" { + t.Fatalf("deleted workspaces=%q", got) + } + if _, ok, err := resolveLeaseClaimForProvider(lease.LeaseID); err != nil || ok { + t.Fatalf("claim retained ok=%t err=%v", ok, err) + } +} + +func TestStopRefusesUnclaimedComputerID(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + fake := newFakeOrgoAPI() + fake.computers["computer_unclaimed"] = orgoComputer{ID: "computer_unclaimed", Status: "running"} + backend := NewOrgoBackend(Provider{}.Spec(), Config{Orgo: OrgoConfig{APIKey: "test-key"}}, Runtime{Stdout: io.Discard, Stderr: io.Discard}).(*orgoBackend) + backend.client = fake + + err := backend.Stop(context.Background(), StopRequest{ID: "computer_unclaimed"}) + if err == nil || !strings.Contains(err.Error(), "refuses to stop unclaimed") { + t.Fatalf("err=%v", err) + } + if len(fake.deletedComputers) != 0 { + t.Fatalf("deleted unclaimed computer: %#v", fake.deletedComputers) + } +} + +func TestStopRetriesWorkspaceCleanupAfterComputerWasDeleted(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + fake := newFakeOrgoAPI() + fake.missingDeleteNotFound = true + backend := NewOrgoBackend(Provider{}.Spec(), Config{Orgo: OrgoConfig{APIKey: "test-key"}}, Runtime{Stdout: io.Discard, Stderr: io.Discard}).(*orgoBackend) + backend.client = fake + lease, err := backend.createComputer(context.Background(), fake, Repo{Root: t.TempDir()}, "partial-cleanup", false) + if err != nil { + t.Fatal(err) + } + fake.deleteWorkspaceErr = errors.New("transient workspace delete failure") + if err := backend.Stop(context.Background(), StopRequest{ID: lease.LeaseID}); err == nil { + t.Fatal("first stop unexpectedly succeeded") + } + fake.deleteWorkspaceErr = nil + if err := backend.Stop(context.Background(), StopRequest{ID: lease.LeaseID}); err != nil { + t.Fatal(err) + } + if got := strings.Join(fake.deletedComputers, ","); got != "computer_test" { + t.Fatalf("deleted computers=%q", got) + } + if got := strings.Join(fake.deletedWorkspaces, ","); got != "ws_created,ws_created" { + t.Fatalf("deleted workspaces=%q", got) + } + if _, ok, err := resolveLeaseClaimForProvider(lease.LeaseID); err != nil || ok { + t.Fatalf("claim retained ok=%t err=%v", ok, err) + } +} + +func TestStopRetainsClaimWhenNotFoundCouldBeAuthorizationFailure(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + fake := newFakeOrgoAPI() + backend := NewOrgoBackend(Provider{}.Spec(), Config{Orgo: OrgoConfig{APIKey: "test-key"}}, Runtime{Stdout: io.Discard, Stderr: io.Discard}).(*orgoBackend) + backend.client = fake + lease, err := backend.createComputer(context.Background(), fake, Repo{Root: t.TempDir()}, "ambiguous-not-found", false) + if err != nil { + t.Fatal(err) + } + delete(fake.computers, lease.Computer.ID) + fake.getWorkspaceErr = exit(4, "workspace unavailable") + if err := backend.Stop(context.Background(), StopRequest{ID: lease.LeaseID}); err == nil { + t.Fatal("stop unexpectedly accepted ambiguous absence") + } + if len(fake.deletedComputers) != 0 || len(fake.deletedWorkspaces) != 0 { + t.Fatalf("ambiguous absence triggered deletion: computers=%#v workspaces=%#v", fake.deletedComputers, fake.deletedWorkspaces) + } + if _, ok, err := resolveLeaseClaimForProvider(lease.LeaseID); err != nil || !ok { + t.Fatalf("claim missing ok=%t err=%v", ok, err) + } +} + +func TestWarmupClaimsSlugForStatusAndStop(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + fake := newFakeOrgoAPI() + var stdout, stderr bytes.Buffer + cfg := Config{Orgo: OrgoConfig{APIKey: "test-key", WorkspaceID: "ws_existing"}} + backend := NewOrgoBackend(Provider{}.Spec(), cfg, Runtime{Stdout: &stdout, Stderr: &stderr}).(*orgoBackend) + backend.client = fake + + if err := backend.Warmup(context.Background(), WarmupRequest{ + Repo: Repo{Root: t.TempDir()}, + RequestedSlug: "orgo-smoke", + }); err != nil { + t.Fatal(err) + } + if !strings.Contains(stdout.String(), "slug=orgo-smoke") { + t.Fatalf("stdout=%q", stdout.String()) + } + view, err := backend.Status(context.Background(), StatusRequest{ID: "orgo-smoke", Wait: true}) + if err != nil { + t.Fatal(err) + } + if view.ServerID != "computer_test" || view.Slug != "orgo-smoke" || !view.Ready { + t.Fatalf("status=%#v", view) + } + if err := backend.Stop(context.Background(), StopRequest{ID: "orgo-smoke"}); err != nil { + t.Fatal(err) + } + if got := strings.Join(fake.deletedComputers, ","); got != "computer_test" { + t.Fatalf("deleted computers=%q", got) + } + if len(fake.deletedWorkspaces) != 0 { + t.Fatalf("explicit workspace should not be deleted: %#v", fake.deletedWorkspaces) + } +} + +func TestStatusWaitStopsOnTerminalStates(t *testing.T) { + for _, state := range []string{"error", "failed", "deleted"} { + t.Run(state, func(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + fake := newFakeOrgoAPI() + fake.computers["computer_test"] = orgoComputer{ID: "computer_test", Status: state} + backend := NewOrgoBackend(Provider{}.Spec(), Config{Orgo: OrgoConfig{APIKey: "test-key"}}, Runtime{Stdout: io.Discard, Stderr: io.Discard}).(*orgoBackend) + backend.client = fake + _, err := backend.Status(context.Background(), StatusRequest{ID: "computer_test", Wait: true}) + if err == nil || !strings.Contains(err.Error(), "entered "+state+" state") { + t.Fatalf("err=%v", err) + } + }) + } +} + +func TestListMergesLocalClaimLabels(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + fake := newFakeOrgoAPI() + var stdout, stderr bytes.Buffer + cfg := Config{Orgo: OrgoConfig{APIKey: "test-key", WorkspaceID: "ws_existing"}} + backend := NewOrgoBackend(Provider{}.Spec(), cfg, Runtime{Stdout: &stdout, Stderr: &stderr}).(*orgoBackend) + backend.client = fake + + if err := backend.Warmup(context.Background(), WarmupRequest{ + Repo: Repo{Root: t.TempDir()}, + RequestedSlug: "orgo-list", + }); err != nil { + t.Fatal(err) + } + + claims, err := listLeaseClaims() + if err != nil { + t.Fatal(err) + } + if len(claims) != 1 { + t.Fatalf("claims=%#v", claims) + } + if got := claims[0].Labels["lease"]; got != claims[0].LeaseID { + t.Fatalf("claim lease label=%q, want %q", got, claims[0].LeaseID) + } + if got := claims[0].Labels["slug"]; got != "orgo-list" { + t.Fatalf("claim slug label=%q", got) + } + + views, err := backend.List(context.Background(), ListRequest{}) + if err != nil { + t.Fatal(err) + } + if len(views) != 1 { + t.Fatalf("views=%#v", views) + } + view := views[0] + if view.CloudID != "computer_test" { + t.Fatalf("cloud id=%q", view.CloudID) + } + if got := view.Labels["lease"]; got != claims[0].LeaseID { + t.Fatalf("view lease label=%q, want %q", got, claims[0].LeaseID) + } + if got := view.Labels["slug"]; got != "orgo-list" { + t.Fatalf("view slug label=%q", got) + } + if got := view.Labels[orgoWorkspaceLabel]; got != "ws_existing" { + t.Fatalf("workspace label=%q", got) + } +} + +func TestDoctorCountsInventoryComputers(t *testing.T) { + fake := newFakeOrgoAPI() + fake.computers["computer_one"] = orgoComputer{ID: "computer_one", WorkspaceID: "ws_existing", Status: "running"} + fake.computers["computer_two"] = orgoComputer{ID: "computer_two", WorkspaceID: "ws_existing", Status: "stopped"} + backend := NewOrgoBackend(Provider{}.Spec(), Config{Orgo: OrgoConfig{APIKey: "test-key", WorkspaceID: "ws_existing"}}, Runtime{Stdout: io.Discard, Stderr: io.Discard}).(*orgoBackend) + backend.client = fake + + result, err := backend.Doctor(context.Background(), DoctorRequest{}) + if err != nil { + t.Fatal(err) + } + if result.Provider != providerName { + t.Fatalf("provider=%q", result.Provider) + } + if !strings.Contains(result.Message, "inventory=ready") || !strings.Contains(result.Message, "leases=2") { + t.Fatalf("doctor message=%q", result.Message) + } +} + +func TestBuildCommandQuotesForwardedEnvValues(t *testing.T) { + backend := NewOrgoBackend(Provider{}.Spec(), Config{Orgo: OrgoConfig{APIKey: "test-key"}}, Runtime{Stdout: io.Discard, Stderr: io.Discard}).(*orgoBackend) + + command, err := backend.buildCommand(RunRequest{ + Command: []string{"printf", "ok"}, + Env: map[string]string{ + "PIPE": "|", + "SEMI": ";", + }, + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(command, "export PIPE='|'\n") { + t.Fatalf("PIPE export was not quoted: %q", command) + } + if !strings.Contains(command, "export SEMI=';'\n") { + t.Fatalf("SEMI export was not quoted: %q", command) + } + if strings.Contains(command, "export PIPE=|\n") || strings.Contains(command, "export SEMI=;\n") { + t.Fatalf("control operator leaked unquoted: %q", command) + } +} + +func TestRunKeepOnFailurePreservesComputer(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + fake := newFakeOrgoAPI() + fake.bashExitCode = 7 + backend := NewOrgoBackend(Provider{}.Spec(), Config{Orgo: OrgoConfig{APIKey: "test-key", WorkspaceID: "ws_existing"}}, Runtime{Stdout: io.Discard, Stderr: io.Discard}).(*orgoBackend) + backend.client = fake + + result, err := backend.Run(context.Background(), RunRequest{ + Repo: Repo{Root: t.TempDir()}, + NoSync: true, + KeepOnFailure: true, + Command: []string{"false"}, + }) + if err == nil { + t.Fatalf("expected failing command") + } + if result.ExitCode != 7 { + t.Fatalf("exit=%d", result.ExitCode) + } + if len(fake.deletedComputers) != 0 { + t.Fatalf("keep-on-failure deleted computer: %#v", fake.deletedComputers) + } +} + +func TestDeleteLeaseTreatsOwnedWorkspaceDeletionAsAuthoritative(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + computerErr := errors.New("computer delete failed") + fake := newFakeOrgoAPI() + fake.deleteComputerErr = computerErr + backend := NewOrgoBackend(Provider{}.Spec(), Config{Orgo: OrgoConfig{APIKey: "test-key"}}, Runtime{Stdout: io.Discard, Stderr: io.Discard}).(*orgoBackend) + + err := backend.deleteLease(context.Background(), fake, orgoLease{ + LeaseID: "lease_test", + Computer: orgoComputer{ID: "computer_test"}, + CreatedWorkspace: "ws_created", + }) + if err != nil { + t.Fatalf("err=%v", err) + } + if got := strings.Join(fake.deletedComputers, ","); got != "computer_test" { + t.Fatalf("deleted computers=%q", got) + } + if got := strings.Join(fake.deletedWorkspaces, ","); got != "ws_created" { + t.Fatalf("deleted workspaces=%q", got) + } +} diff --git a/internal/providers/orgo/client.go b/internal/providers/orgo/client.go new file mode 100644 index 000000000..4bad40f6d --- /dev/null +++ b/internal/providers/orgo/client.go @@ -0,0 +1,331 @@ +package orgo + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" +) + +const defaultAPIBase = "https://www.orgo.ai/api" + +type orgoAPI interface { + CreateWorkspace(ctx context.Context, name string) (orgoWorkspace, error) + DeleteWorkspace(ctx context.Context, id string) error + ListWorkspaces(ctx context.Context) ([]orgoWorkspace, error) + GetWorkspace(ctx context.Context, id string) (orgoWorkspace, error) + CreateComputer(ctx context.Context, req orgoCreateComputerRequest) (orgoComputer, error) + GetComputer(ctx context.Context, id string) (orgoComputer, error) + StartComputer(ctx context.Context, id string) error + DeleteComputer(ctx context.Context, id string) error + RunBash(ctx context.Context, id, command string, stdout, stderr io.Writer) (int, error) +} + +type orgoWorkspace struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + Computers []orgoComputer `json:"desktops"` +} + +type orgoComputer struct { + ID string `json:"id"` + InstanceID string `json:"instance_id"` + Name string `json:"name"` + WorkspaceID string `json:"workspace_id"` + Status string `json:"status"` + OS string `json:"os"` + RAMGB int `json:"ram"` + CPUs int `json:"cpu"` + DiskGB int `json:"disk_size_gb"` + Resolution string `json:"resolution"` + Hostname string `json:"hostname"` + ConnectionURL string `json:"connection_url"` + CreatedAt string `json:"created_at"` +} + +type orgoCreateComputerRequest struct { + WorkspaceID string `json:"workspace_id"` + Name string `json:"name"` + OS string `json:"os"` + RAMGB int `json:"ram"` + CPUs int `json:"cpu"` + DiskGB int `json:"disk_size_gb"` + Resolution string `json:"resolution,omitempty"` +} + +type orgoBashResponse struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + Output string `json:"output"` + Result string `json:"result"` + Text string `json:"text"` + Message string `json:"message"` + ExitCode *int `json:"exit_code"` + ExitCodeCamel *int `json:"exitCode"` + Success *bool `json:"success"` +} + +type orgoActionResponse struct { + Success *bool `json:"success"` + Status string `json:"status"` +} + +type orgoHTTPClient struct { + baseURL string + apiKey string + http *http.Client +} + +type orgoHTTPError struct { + StatusCode int + Body string +} + +func (e *orgoHTTPError) Error() string { + body := strings.TrimSpace(e.Body) + if body == "" { + return fmt.Sprintf("orgo API http %d", e.StatusCode) + } + return fmt.Sprintf("orgo API http %d: %s", e.StatusCode, body) +} + +func (e *orgoHTTPError) As(target any) bool { + t, ok := target.(*ExitError) + if !ok { + return false + } + code := 1 + switch { + case e.StatusCode == http.StatusNotFound: + code = 4 + case e.StatusCode == http.StatusUnauthorized || e.StatusCode == http.StatusForbidden: + code = 77 + case e.StatusCode == http.StatusTooManyRequests || e.StatusCode >= 500: + code = 69 + } + *t = ExitError{Code: code, Message: e.Error()} + return true +} + +func newOrgoClient(cfg Config, rt Runtime) (orgoAPI, error) { + apiKey := strings.TrimSpace(os.Getenv("CRABBOX_ORGO_API_KEY")) + if apiKey == "" { + apiKey = strings.TrimSpace(cfg.Orgo.APIKey) + } + if apiKey == "" { + apiKey = strings.TrimSpace(os.Getenv("ORGO_API_KEY")) + } + if apiKey == "" { + return nil, exit(2, "provider=%s requires CRABBOX_ORGO_API_KEY, orgo.apiKey, or ORGO_API_KEY", providerName) + } + baseURL := strings.TrimSpace(cfg.Orgo.APIBase) + if baseURL == "" { + baseURL = strings.TrimSpace(os.Getenv("CRABBOX_ORGO_API_BASE")) + } + if baseURL == "" { + baseURL = strings.TrimSpace(os.Getenv("ORGO_API_BASE_URL")) + } + if baseURL == "" { + baseURL = defaultAPIBase + } + parsed, err := url.Parse(baseURL) + if err != nil { + return nil, exit(2, "provider=%s invalid Orgo API base URL %q: %v", providerName, baseURL, err) + } + if parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return nil, exit(2, "provider=%s invalid Orgo API base URL %q", providerName, baseURL) + } + if parsed.Scheme != "https" && !isOrgoLoopbackHTTP(parsed) { + return nil, exit(2, "provider=%s API base URL %q must use https unless it targets localhost", providerName, baseURL) + } + client := rt.HTTP + if client == nil { + client = http.DefaultClient + } + return &orgoHTTPClient{ + baseURL: strings.TrimRight(baseURL, "/"), + apiKey: apiKey, + http: client, + }, nil +} + +func isOrgoLoopbackHTTP(parsed *url.URL) bool { + if parsed.Scheme != "http" { + return false + } + switch strings.ToLower(parsed.Hostname()) { + case "localhost", "127.0.0.1", "::1": + return true + default: + return false + } +} + +func (c *orgoHTTPClient) CreateWorkspace(ctx context.Context, name string) (orgoWorkspace, error) { + var workspace orgoWorkspace + err := c.doJSON(ctx, http.MethodPost, "/workspaces", map[string]string{"name": name}, &workspace) + return workspace, err +} + +func (c *orgoHTTPClient) DeleteWorkspace(ctx context.Context, id string) error { + return c.deleteResource(ctx, "/workspaces/"+url.PathEscape(id), "workspace", id) +} + +func (c *orgoHTTPClient) ListWorkspaces(ctx context.Context) ([]orgoWorkspace, error) { + var envelope struct { + Projects []orgoWorkspace `json:"projects"` + Workspaces []orgoWorkspace `json:"workspaces"` + Data []orgoWorkspace `json:"data"` + Items []orgoWorkspace `json:"items"` + } + var raw json.RawMessage + if err := c.doJSON(ctx, http.MethodGet, "/workspaces", nil, &raw); err != nil { + return nil, err + } + if err := json.Unmarshal(raw, &envelope); err == nil { + switch { + case envelope.Projects != nil: + return envelope.Projects, nil + case envelope.Workspaces != nil: + return envelope.Workspaces, nil + case envelope.Data != nil: + return envelope.Data, nil + case envelope.Items != nil: + return envelope.Items, nil + } + } + var workspaces []orgoWorkspace + if err := json.Unmarshal(raw, &workspaces); err != nil { + return nil, fmt.Errorf("decode orgo workspaces: %w", err) + } + return workspaces, nil +} + +func (c *orgoHTTPClient) GetWorkspace(ctx context.Context, id string) (orgoWorkspace, error) { + var workspace orgoWorkspace + err := c.doJSON(ctx, http.MethodGet, "/workspaces/"+url.PathEscape(id), nil, &workspace) + return workspace, err +} + +func (c *orgoHTTPClient) CreateComputer(ctx context.Context, req orgoCreateComputerRequest) (orgoComputer, error) { + var computer orgoComputer + err := c.doJSON(ctx, http.MethodPost, "/computers", req, &computer) + return computer, err +} + +func (c *orgoHTTPClient) GetComputer(ctx context.Context, id string) (orgoComputer, error) { + var computer orgoComputer + err := c.doJSON(ctx, http.MethodGet, "/computers/"+url.PathEscape(id), nil, &computer) + return computer, err +} + +func (c *orgoHTTPClient) StartComputer(ctx context.Context, id string) error { + var res orgoActionResponse + if err := c.doJSON(ctx, http.MethodPost, "/computers/"+url.PathEscape(id)+"/start", nil, &res); err != nil { + return err + } + if res.Success == nil || !*res.Success { + return fmt.Errorf("orgo did not start computer %s (status=%s)", id, strings.TrimSpace(res.Status)) + } + return nil +} + +func (c *orgoHTTPClient) DeleteComputer(ctx context.Context, id string) error { + return c.deleteResource(ctx, "/computers/"+url.PathEscape(id), "computer", id) +} + +func (c *orgoHTTPClient) deleteResource(ctx context.Context, path, kind, id string) error { + var res orgoActionResponse + if err := c.doJSON(ctx, http.MethodDelete, path, nil, &res); err != nil { + return err + } + if res.Success != nil && !*res.Success { + return fmt.Errorf("orgo did not delete %s %s (status=%s)", kind, id, strings.TrimSpace(res.Status)) + } + return nil +} + +func (c *orgoHTTPClient) RunBash(ctx context.Context, id, command string, stdout, stderr io.Writer) (int, error) { + var res orgoBashResponse + if err := c.doJSON(ctx, http.MethodPost, "/computers/"+url.PathEscape(id)+"/bash", map[string]string{"command": command}, &res); err != nil { + return 1, err + } + if res.Stdout != "" { + fmt.Fprint(stdout, res.Stdout) + } else if res.Output != "" { + fmt.Fprint(stdout, res.Output) + } else if res.Result != "" { + fmt.Fprint(stdout, res.Result) + } else if res.Text != "" { + fmt.Fprint(stdout, res.Text) + } else if res.Message != "" { + fmt.Fprint(stdout, res.Message) + } + if res.Stderr != "" { + fmt.Fprint(stderr, res.Stderr) + } + if res.ExitCodeCamel != nil { + return *res.ExitCodeCamel, nil + } + if res.ExitCode != nil { + return *res.ExitCode, nil + } + if res.Success != nil && !*res.Success { + return 1, nil + } + return 0, nil +} + +func (c *orgoHTTPClient) doJSON(ctx context.Context, method, path string, body any, out any) error { + var reader io.Reader + if body != nil { + var buf bytes.Buffer + encoder := json.NewEncoder(&buf) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(body); err != nil { + return err + } + reader = &buf + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.apiKey) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + res, err := c.http.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + data, err := io.ReadAll(io.LimitReader(res.Body, 4<<20)) + if err != nil { + return err + } + if res.StatusCode < 200 || res.StatusCode >= 300 { + return &orgoHTTPError{StatusCode: res.StatusCode, Body: string(data)} + } + if out == nil { + return nil + } + if raw, ok := out.(*json.RawMessage); ok { + *raw = append((*raw)[:0], data...) + return nil + } + if len(bytes.TrimSpace(data)) == 0 { + return nil + } + if err := json.Unmarshal(data, out); err != nil { + return fmt.Errorf("decode orgo response: %w", err) + } + return nil +} diff --git a/internal/providers/orgo/client_smoke_test.go b/internal/providers/orgo/client_smoke_test.go new file mode 100644 index 000000000..de296bec1 --- /dev/null +++ b/internal/providers/orgo/client_smoke_test.go @@ -0,0 +1,93 @@ +//go:build smoke + +package orgo + +import ( + "bytes" + "context" + "os" + "strings" + "testing" + "time" +) + +// TestSmokeCreateRunAndDelete hits the live Orgo REST API. +// Requires CRABBOX_ORGO_API_KEY or ORGO_API_KEY. When CRABBOX_ORGO_WORKSPACE_ID +// and ORGO_WORKSPACE_ID are unset, the test creates and deletes a temporary +// workspace. +// +// go test -tags smoke -run TestSmokeCreateRunAndDelete -v ./internal/providers/orgo/ +func TestSmokeCreateRunAndDelete(t *testing.T) { + if os.Getenv("CRABBOX_ORGO_API_KEY") == "" && os.Getenv("ORGO_API_KEY") == "" { + t.Skip("set CRABBOX_ORGO_API_KEY or ORGO_API_KEY to run smoke") + } + if testing.Short() { + t.Skip("live Orgo smoke skipped in -short mode") + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + api, err := newOrgoClient(Config{}, Runtime{}) + if err != nil { + t.Fatalf("newOrgoClient: %v", err) + } + + workspaceID := strings.TrimSpace(os.Getenv("CRABBOX_ORGO_WORKSPACE_ID")) + if workspaceID == "" { + workspaceID = strings.TrimSpace(os.Getenv("ORGO_WORKSPACE_ID")) + } + createdWorkspace := "" + if workspaceID == "" { + workspace, err := api.CreateWorkspace(ctx, "crabbox-smoke-"+time.Now().UTC().Format("20060102150405")) + if err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + workspaceID = workspace.ID + createdWorkspace = workspace.ID + t.Logf("created workspace id=%s", workspace.ID) + } + if createdWorkspace != "" { + defer func() { + stopCtx, stopCancel := context.WithTimeout(context.Background(), 20*time.Second) + defer stopCancel() + if err := api.DeleteWorkspace(stopCtx, createdWorkspace); err != nil { + t.Logf("DeleteWorkspace returned: %v (ignored at teardown)", err) + } + }() + } + + computer, err := api.CreateComputer(ctx, orgoCreateComputerRequest{ + WorkspaceID: workspaceID, + Name: "crabbox-smoke", + OS: "linux", + RAMGB: 4, + CPUs: 1, + DiskGB: 8, + Resolution: "1280x720x24", + }) + if err != nil { + t.Fatalf("CreateComputer: %v", err) + } + t.Logf("created computer id=%s status=%s", computer.ID, computer.Status) + defer func() { + stopCtx, stopCancel := context.WithTimeout(context.Background(), 20*time.Second) + defer stopCancel() + if err := api.DeleteComputer(stopCtx, computer.ID); err != nil { + t.Logf("DeleteComputer returned: %v (ignored at teardown)", err) + } + }() + + var out, errBuf bytes.Buffer + code, err := api.RunBash(ctx, computer.ID, "echo crabbox-orgo-ok && uname -a", &out, &errBuf) + if err != nil { + t.Fatalf("RunBash: %v", err) + } + t.Logf("bash exit=%d stdout=%q stderr=%q", code, out.String(), errBuf.String()) + if code != 0 { + t.Fatalf("expected exit 0, got %d (stderr=%q)", code, errBuf.String()) + } + if !bytes.Contains(out.Bytes(), []byte("crabbox-orgo-ok")) { + t.Fatalf("stdout missing crabbox-orgo-ok: got %q", out.String()) + } +} diff --git a/internal/providers/orgo/client_test.go b/internal/providers/orgo/client_test.go new file mode 100644 index 000000000..4dd0d2268 --- /dev/null +++ b/internal/providers/orgo/client_test.go @@ -0,0 +1,206 @@ +package orgo + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestRunBashExitCodeFieldPresence(t *testing.T) { + tests := []struct { + name string + body string + want int + }{ + { + name: "explicit camel zero wins over snake fallback", + body: `{"stdout":"ok\n","exitCode":0,"exit_code":7}`, + want: 0, + }, + { + name: "snake fallback", + body: `{"stdout":"ok\n","exit_code":7}`, + want: 7, + }, + { + name: "missing exit code defaults to success", + body: `{"stdout":"ok\n"}`, + want: 0, + }, + { + name: "explicit failure without exit code", + body: `{"stdout":"ok\n","success":false}`, + want: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("method=%s", r.Method) + } + if r.URL.Path != "/computers/computer_test/bash" { + t.Fatalf("path=%s", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer test-key" { + t.Fatalf("authorization=%q", got) + } + var req map[string]string + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode request: %v", err) + } + if got := req["command"]; got != "printf ok" { + t.Fatalf("command=%q", got) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintln(w, tt.body) + })) + t.Cleanup(server.Close) + + client := &orgoHTTPClient{baseURL: server.URL, apiKey: "test-key", http: server.Client()} + var stdout, stderr bytes.Buffer + code, err := client.RunBash(context.Background(), "computer_test", "printf ok", &stdout, &stderr) + if err != nil { + t.Fatal(err) + } + if code != tt.want { + t.Fatalf("exit=%d, want %d", code, tt.want) + } + if stdout.String() != "ok\n" { + t.Fatalf("stdout=%q", stdout.String()) + } + if stderr.String() != "" { + t.Fatalf("stderr=%q", stderr.String()) + } + }) + } +} + +func TestStartComputerUsesOfficialActionEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("method=%s", r.Method) + } + if r.URL.Path != "/computers/computer_test/start" { + t.Fatalf("path=%s", r.URL.Path) + } + fmt.Fprint(w, `{"success":true,"status":"starting"}`) + })) + t.Cleanup(server.Close) + + client := &orgoHTTPClient{baseURL: server.URL, apiKey: "test-key", http: server.Client()} + if err := client.StartComputer(context.Background(), "computer_test"); err != nil { + t.Fatal(err) + } +} + +func TestStartComputerRejectsUnsuccessfulResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprint(w, `{"success":false,"status":"stopped"}`) + })) + t.Cleanup(server.Close) + + client := &orgoHTTPClient{baseURL: server.URL, apiKey: "test-key", http: server.Client()} + if err := client.StartComputer(context.Background(), "computer_test"); err == nil || !strings.Contains(err.Error(), "did not start") { + t.Fatalf("err=%v", err) + } +} + +func TestDeleteResourceRejectsUnsuccessfulResponse(t *testing.T) { + for _, tt := range []struct { + name string + path string + run func(*orgoHTTPClient) error + }{ + {name: "computer", path: "/computers/computer_test", run: func(client *orgoHTTPClient) error { + return client.DeleteComputer(context.Background(), "computer_test") + }}, + {name: "workspace", path: "/workspaces/workspace_test", run: func(client *orgoHTTPClient) error { + return client.DeleteWorkspace(context.Background(), "workspace_test") + }}, + } { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete || r.URL.Path != tt.path { + t.Fatalf("request=%s %s", r.Method, r.URL.Path) + } + fmt.Fprint(w, `{"success":false,"status":"still_running"}`) + })) + t.Cleanup(server.Close) + client := &orgoHTTPClient{baseURL: server.URL, apiKey: "test-key", http: server.Client()} + if err := tt.run(client); err == nil || !strings.Contains(err.Error(), "did not delete") { + t.Fatalf("err=%v", err) + } + }) + } +} + +func TestGetWorkspaceReadsOfficialDesktopsField(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/workspaces/workspace_test" { + t.Fatalf("path=%s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"id":"workspace_test","desktops":[{"id":"computer_test","status":"running"}]}`) + })) + t.Cleanup(server.Close) + + client := &orgoHTTPClient{baseURL: server.URL, apiKey: "test-key", http: server.Client()} + workspace, err := client.GetWorkspace(context.Background(), "workspace_test") + if err != nil { + t.Fatal(err) + } + if len(workspace.Computers) != 1 || workspace.Computers[0].ID != "computer_test" { + t.Fatalf("computers=%#v", workspace.Computers) + } + computers := orgoComputersForWorkspace(workspace) + if computers[0].WorkspaceID != "workspace_test" { + t.Fatalf("workspace id=%q", computers[0].WorkspaceID) + } +} + +func TestListWorkspacesReadsLiveProjectsEnvelope(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/workspaces" { + t.Fatalf("path=%s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"projects":[{"id":"workspace_test","name":"Test"}]}`) + })) + t.Cleanup(server.Close) + + client := &orgoHTTPClient{baseURL: server.URL, apiKey: "test-key", http: server.Client()} + workspaces, err := client.ListWorkspaces(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(workspaces) != 1 || workspaces[0].ID != "workspace_test" { + t.Fatalf("workspaces=%#v", workspaces) + } +} + +func TestNewOrgoClientRejectsInsecureNonLoopbackAPIBase(t *testing.T) { + t.Setenv("CRABBOX_ORGO_API_KEY", "test-key") + t.Setenv("CRABBOX_ORGO_API_BASE", "http://api.example.test") + if _, err := newOrgoClient(Config{}, Runtime{}); err == nil || !strings.Contains(err.Error(), "must use https") { + t.Fatalf("err=%v, want HTTPS requirement", err) + } +} + +func TestNewOrgoClientUsesResolvedConfigBeforeAmbientAPIBase(t *testing.T) { + t.Setenv("CRABBOX_ORGO_API_KEY", "test-key") + t.Setenv("CRABBOX_ORGO_API_BASE", "https://ambient.example.test") + client, err := newOrgoClient(Config{Orgo: OrgoConfig{APIBase: "https://flag-selected.example.test"}}, Runtime{}) + if err != nil { + t.Fatal(err) + } + if got := client.(*orgoHTTPClient).baseURL; got != "https://flag-selected.example.test" { + t.Fatalf("base URL=%q", got) + } +} diff --git a/internal/providers/orgo/core.go b/internal/providers/orgo/core.go new file mode 100644 index 000000000..0c6dd05f0 --- /dev/null +++ b/internal/providers/orgo/core.go @@ -0,0 +1,104 @@ +package orgo + +import ( + "flag" + "io" + "time" + + core "github.com/openclaw/crabbox/internal/cli" +) + +type Config = core.Config +type OrgoConfig = core.OrgoConfig +type ProviderSpec = core.ProviderSpec +type Runtime = core.Runtime +type Backend = core.Backend +type WarmupRequest = core.WarmupRequest +type RunRequest = core.RunRequest +type RunResult = core.RunResult +type ListRequest = core.ListRequest +type LeaseView = core.LeaseView +type StatusRequest = core.StatusRequest +type StatusView = core.StatusView +type StopRequest = core.StopRequest +type DoctorRequest = core.DoctorRequest +type DoctorResult = core.DoctorResult +type Server = core.Server +type Repo = core.Repo +type LeaseClaim = core.LeaseClaim +type ExitError = core.ExitError +type timingReport = core.TimingReport + +const ( + providerName = "orgo" + 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 allocateClaimLeaseSlug(leaseID, requested string) (string, error) { + return core.AllocateClaimLeaseSlug(leaseID, requested) +} + +func claimLeaseForRepoProviderEndpoint(leaseID, slug, repoRoot string, idleTimeout time.Duration, reclaim bool, server Server) error { + return core.ClaimLeaseForRepoProviderScopePondEndpoint(leaseID, slug, providerName, "", "", repoRoot, idleTimeout, reclaim, server, core.SSHTarget{TargetOS: targetLinux}) +} + +func resolveLeaseClaimForProvider(identifier string) (core.LeaseClaim, bool, error) { + return core.ResolveLeaseClaimForProvider(identifier, providerName) +} + +func resolveLeaseClaimForProviderCloudID(cloudID string) (core.LeaseClaim, bool, error) { + return core.ResolveLeaseClaimForProviderCloudID(cloudID, providerName) +} + +func listLeaseClaims() ([]core.LeaseClaim, error) { + return core.ListLeaseClaims() +} + +func removeLeaseClaim(leaseID string) { + core.RemoveLeaseClaim(leaseID) +} + +func writeTimingJSON(w io.Writer, report timingReport) error { + return core.WriteTimingJSON(w, report) +} + +func handleDelegatedRunFailure(w io.Writer, req RunRequest, provider, leaseID, slug string, idleTimeout, ttl time.Duration, acquired bool, shouldStop *bool) { + core.HandleDelegatedRunFailure(w, req, provider, leaseID, slug, idleTimeout, ttl, acquired, shouldStop) +} + +func printEnvForwardingSummary(w io.Writer, provider, behavior string, allow []string, env map[string]string) { + core.PrintEnvForwardingSummary(w, provider, behavior, allow, env) +} + +func shellScriptFromArgv(command []string) string { + return core.ShellScriptFromArgv(command) +} + +func shellQuote(value string) string { + return core.ShellQuote(value) +} + +func rejectDelegatedSyncOptionsForSpec(spec ProviderSpec, req RunRequest) error { + return core.RejectDelegatedSyncOptionsForSpec(spec, req) +} + +func inventoryDoctorResult(provider string, leases int) DoctorResult { + return core.InventoryDoctorResult(provider, leases) +} diff --git a/internal/providers/orgo/flags.go b/internal/providers/orgo/flags.go new file mode 100644 index 000000000..6ac9dceb4 --- /dev/null +++ b/internal/providers/orgo/flags.go @@ -0,0 +1,63 @@ +package orgo + +import ( + "flag" + "strings" +) + +type orgoFlagValues struct { + APIBase *string + WorkspaceID *string + RAMGB *int + CPUs *int + DiskGB *int + Resolution *string +} + +// RegisterOrgoProviderFlags exposes non-secret Orgo settings. The API key is +// intentionally not a flag; secrets are read from env/config only. +func RegisterOrgoProviderFlags(fs *flag.FlagSet, defaults Config) any { + return orgoFlagValues{ + APIBase: fs.String("orgo-api-base", defaults.Orgo.APIBase, "Orgo API base URL"), + WorkspaceID: fs.String("orgo-workspace-id", defaults.Orgo.WorkspaceID, "Existing Orgo workspace ID to create computers in"), + RAMGB: fs.Int("orgo-ram", defaults.Orgo.RAMGB, "Orgo computer RAM in GB"), + CPUs: fs.Int("orgo-cpu", defaults.Orgo.CPUs, "Orgo computer CPU count"), + DiskGB: fs.Int("orgo-disk", defaults.Orgo.DiskGB, "Orgo computer disk size in GB"), + Resolution: fs.String("orgo-resolution", defaults.Orgo.Resolution, "Orgo desktop resolution, for example 1280x720x24"), + } +} + +func ApplyOrgoProviderFlags(cfg *Config, fs *flag.FlagSet, values any) error { + switch strings.ToLower(strings.TrimSpace(cfg.Provider)) { + case providerName, "orgo-ai": + 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.(orgoFlagValues) + if !ok { + return nil + } + if flagWasSet(fs, "orgo-api-base") { + cfg.Orgo.APIBase = *v.APIBase + } + if flagWasSet(fs, "orgo-workspace-id") { + cfg.Orgo.WorkspaceID = *v.WorkspaceID + } + if flagWasSet(fs, "orgo-ram") { + cfg.Orgo.RAMGB = *v.RAMGB + } + if flagWasSet(fs, "orgo-cpu") { + cfg.Orgo.CPUs = *v.CPUs + } + if flagWasSet(fs, "orgo-disk") { + cfg.Orgo.DiskGB = *v.DiskGB + } + if flagWasSet(fs, "orgo-resolution") { + cfg.Orgo.Resolution = *v.Resolution + } + return nil +} diff --git a/internal/providers/orgo/provider.go b/internal/providers/orgo/provider.go new file mode 100644 index 000000000..228933b66 --- /dev/null +++ b/internal/providers/orgo/provider.go @@ -0,0 +1,51 @@ +package orgo + +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{"orgo-ai"} } + +func (Provider) Spec() core.ProviderSpec { + return core.ProviderSpec{ + Name: providerName, + Family: "orgo", + Kind: core.ProviderKindDelegatedRun, + Targets: []core.TargetSpec{{OS: core.TargetLinux}}, + Coordinator: core.CoordinatorNever, + } +} + +func (Provider) RegisterFlags(fs *flag.FlagSet, defaults core.Config) any { + return RegisterOrgoProviderFlags(fs, defaults) +} + +func (Provider) ApplyFlags(cfg *core.Config, fs *flag.FlagSet, values any) error { + return ApplyOrgoProviderFlags(cfg, fs, values) +} + +func (p Provider) Configure(cfg core.Config, rt core.Runtime) (core.Backend, error) { + return NewOrgoBackend(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, "orgo doctor backend unavailable") + } + return doctor, nil +} diff --git a/scripts/live-smoke.sh b/scripts/live-smoke.sh index 5772b8c57..968fb1fed 100755 --- a/scripts/live-smoke.sh +++ b/scripts/live-smoke.sh @@ -13,6 +13,7 @@ providers=",${CRABBOX_LIVE_PROVIDERS-aws,hetzner}," default_live_command='if [ -f go.mod ]; then test -f go.mod; elif [ -f package.json ]; then test -f package.json; else test -d .; fi; printf crabbox-live-ok; printf " pwd=%s\n" "$PWD"' live_command="${CRABBOX_LIVE_COMMAND:-$default_live_command}" config_paths=() +trusted_config_path="" run_in_repo() { (cd "$repo" && "$@") @@ -29,8 +30,21 @@ add_config_path() { if [[ -n "${CRABBOX_CONFIG:-}" ]]; then add_config_path "$CRABBOX_CONFIG" + if [[ "$CRABBOX_CONFIG" == /* ]]; then + trusted_config_path="$CRABBOX_CONFIG" + else + trusted_config_path="$repo/$CRABBOX_CONFIG" + fi else - add_config_path "$(run_in_repo "$cb" config path 2>/dev/null || true)" + user_config_path="$(run_in_repo "$cb" config path 2>/dev/null || true)" + add_config_path "$user_config_path" + if [[ -n "$user_config_path" ]]; then + if [[ "$user_config_path" == /* ]]; then + trusted_config_path="$user_config_path" + else + trusted_config_path="$repo/$user_config_path" + fi + fi add_config_path "$repo/crabbox.yaml" add_config_path "$repo/.crabbox.yaml" fi @@ -69,6 +83,40 @@ config_value() { return 1 } +config_value_path() { + local key_path="$1" + command -v ruby >/dev/null 2>&1 || return 1 + local found="" + local path + for path in "${config_paths[@]}"; do + [[ -r "$path" ]] || continue + if ruby -ryaml -e ' + value = ARGV[1].split(".").reduce(YAML.load_file(ARGV[0])) do |memo, key| + memo.is_a?(Hash) ? memo[key] : nil + end + exit 3 if value.nil? || value.to_s.empty? + ' "$path" "$key_path" 2>/dev/null; then + found="$path" + fi + done + [[ -n "$found" ]] || return 1 + printf '%s' "$found" +} + +trusted_config_value() { + local key_path="$1" + command -v ruby >/dev/null 2>&1 || return 1 + local path="$trusted_config_path" + [[ -r "$path" ]] || return 1 + ruby -ryaml -e ' + value = ARGV[1].split(".").reduce(YAML.load_file(ARGV[0])) do |memo, key| + memo.is_a?(Hash) ? memo[key] : nil + end + exit 3 if value.nil? || value.to_s.empty? + print value + ' "$path" "$key_path" 2>/dev/null +} + capture_run() { local __name="$1" shift @@ -1161,6 +1209,145 @@ morph_smoke() { lease="" } +orgo_smoke() { + need_tool curl + need_tool jq + + local configured_api_key="$(trusted_config_value orgo.apiKey || true)" + local configured_api_key_path="" + if [[ -n "$configured_api_key" ]]; then + configured_api_key_path="$trusted_config_path" + fi + local api_key_source="inherited" + local api_key="${CRABBOX_ORGO_API_KEY:-}" + if [[ -z "$api_key" && -n "$configured_api_key" ]]; then + api_key="$configured_api_key" + api_key_source="config" + fi + api_key="${api_key:-${ORGO_API_KEY:-}}" + if [[ -z "$api_key" ]]; then + echo "orgo smoke requires CRABBOX_ORGO_API_KEY, ORGO_API_KEY, or orgo.apiKey" >&2 + return 2 + fi + if [[ "$api_key" == *$'\r'* || "$api_key" == *$'\n'* ]]; then + echo "orgo smoke API key must not contain line breaks" >&2 + return 2 + fi + + local configured_api_base="$(config_value orgo.apiBase || true)" + local configured_api_base_path="$(config_value_path orgo.apiBase || true)" + if [[ -n "$configured_api_base" && -z "${CRABBOX_ORGO_API_BASE:-}" && -z "${ORGO_API_BASE_URL:-}" ]]; then + if [[ "$api_key_source" != "config" || "$configured_api_key_path" != "$configured_api_base_path" ]]; then + echo "orgo smoke refuses configured orgo.apiBase with an inherited API key; set CRABBOX_ORGO_API_BASE or ORGO_API_BASE_URL to approve it" >&2 + return 2 + fi + fi + local api_base="${CRABBOX_ORGO_API_BASE:-${ORGO_API_BASE_URL:-$configured_api_base}}" + api_base="${api_base:-https://www.orgo.ai/api}" + api_base="${api_base%/}" + case "$api_base" in + https://*) ;; + http://localhost | http://localhost:* | http://localhost/* | http://127.0.0.1 | http://127.0.0.1:* | http://127.0.0.1/* | 'http://[::1]' | 'http://[::1]':* | 'http://[::1]'/*) ;; + *) + echo "orgo smoke API base must use HTTPS unless it targets localhost" >&2 + return 2 + ;; + esac + local workspace_id="${CRABBOX_ORGO_WORKSPACE_ID:-${ORGO_WORKSPACE_ID:-$(config_value orgo.workspaceID || true)}}" + local created_workspace="" + local computer_id="" + + orgo_request() { + local method="$1" + local path="$2" + local data="${3:-}" + local args=(-fsS -X "$method" "$api_base$path") + if [[ -n "$data" ]]; then + args+=(-H "Content-Type: application/json" -d "$data") + fi + # Keep the bearer token out of process argv and ordinary command tracing. + printf 'Authorization: Bearer %s\n' "$api_key" | curl -H @- "${args[@]}" + } + + cleanup() { + trap - RETURN ERR + if [[ -n "$computer_id" ]]; then + orgo_request DELETE "/computers/$computer_id" >/dev/null || true + computer_id="" + fi + if [[ -n "$created_workspace" ]]; then + orgo_request DELETE "/workspaces/$created_workspace" >/dev/null || true + created_workspace="" + fi + } + trap cleanup RETURN ERR + + local suffix="${CRABBOX_LIVE_ORGO_SUFFIX:-$(date -u +%Y%m%d%H%M%S)-$$}" + if [[ -z "$workspace_id" ]]; then + local workspace_json + workspace_json="$(orgo_request POST /workspaces "$(jq -n --arg name "crabbox-smoke-$suffix" '{name:$name}')")" + workspace_id="$(printf '%s\n' "$workspace_json" | jq -r '.id // empty')" + if [[ -z "$workspace_id" ]]; then + echo "orgo smoke could not create a workspace" >&2 + return 1 + fi + created_workspace="$workspace_id" + fi + + local computer_json + computer_json="$(orgo_request POST /computers "$(jq -n \ + --arg workspace_id "$workspace_id" \ + --arg name "crabbox-smoke-$suffix" \ + '{workspace_id:$workspace_id,name:$name,os:"linux",ram:4,cpu:1,disk_size_gb:8}')")" + computer_id="$(printf '%s\n' "$computer_json" | jq -r '.id // empty')" + if [[ -z "$computer_id" ]]; then + echo "orgo smoke could not create a computer" >&2 + return 1 + fi + printf 'orgo computer=%s workspace=%s\n' "$computer_id" "$workspace_id" + + local computer_state + computer_state="$(printf '%s\n' "$computer_json" | jq -r '(.status // "") | ascii_downcase')" + local ready_deadline=$((SECONDS + 300)) + while [[ "$computer_state" != "running" ]]; do + case "$computer_state" in + error|failed|deleted) + echo "orgo computer $computer_id entered $computer_state state while starting" >&2 + return 1 + ;; + esac + if ((SECONDS >= ready_deadline)); then + echo "timed out waiting for orgo computer $computer_id to become running (last state=$computer_state)" >&2 + return 1 + fi + computer_json="$(orgo_request GET "/computers/$computer_id")" + computer_state="$(printf '%s\n' "$computer_json" | jq -r '(.status // "") | ascii_downcase')" + [[ "$computer_state" == "running" ]] || sleep 2 + done + + local command="${CRABBOX_LIVE_ORGO_COMMAND:-printf crabbox-orgo-ok}" + local bash_json + bash_json="$(orgo_request POST "/computers/$computer_id/bash" "$(jq -n --arg command "$command" '{command:$command}')")" + printf '%s\n' "$bash_json" | jq -r ' + .stdout // .output // .result // .text // .message // empty + ' + if ! printf '%s\n' "$bash_json" | jq -e ' + .success == true and + ((.exit_code // .exitCode // 0) == 0) and + ((.stdout // .output // .result // .text // .message // "") | contains("crabbox-orgo-ok")) + ' >/dev/null; then + echo "orgo smoke command did not return crabbox-orgo-ok" >&2 + return 1 + fi + + orgo_request DELETE "/computers/$computer_id" >/dev/null + computer_id="" + if [[ -n "$created_workspace" ]]; then + orgo_request DELETE "/workspaces/$created_workspace" >/dev/null + created_workspace="" + fi +} + run_coordinator_preamble() { run_in_repo "$cb" whoami --json run_in_repo "$cb" doctor @@ -1388,6 +1575,10 @@ if has_provider morph; then morph_smoke fi +if has_provider orgo; then + orgo_smoke +fi + if needs_admin_audit; then admin_status=0 admin_out="$(run_in_repo "$cb" admin leases --state active --json 2>&1)" || admin_status=$? diff --git a/scripts/live-smoke.test.js b/scripts/live-smoke.test.js index 838b86318..835befab1 100644 --- a/scripts/live-smoke.test.js +++ b/scripts/live-smoke.test.js @@ -3735,3 +3735,430 @@ exit 99 const calls = fs.existsSync(crabboxLog) ? fs.readFileSync(crabboxLog, "utf8") : ""; assert.doesNotMatch(calls, /--provider morph/, "no morph-specific crabbox call may be issued when the snapshot is missing"); }); + +test("orgo live smoke creates a workspace and deletes all live resources", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "crabbox-live-orgo-")); + const bin = path.join(dir, "bin"); + const fakeCurl = path.join(bin, "curl"); + const curlLog = path.join(dir, "curl.log"); + fs.mkdirSync(bin); + + writeExecutable( + fakeCurl, + `#!/usr/bin/env bash +set -euo pipefail +method=GET +url= +data= +auth_header= +while [[ $# -gt 0 ]]; do + case "$1" in + -X) + method="$2" + shift 2 + ;; + -d) + data="$2" + shift 2 + ;; + -H) + if [[ "$2" == "@-" ]]; then + IFS= read -r auth_header + elif [[ "$2" == Authorization:* ]]; then + auth_header="$2" + fi + shift 2 + ;; + -f|-s|-S|-fsS) + shift + ;; + *) + url="$1" + shift + ;; + esac +done +if [[ "$auth_header" != "Authorization: Bearer dummy-orgo-key" ]]; then + printf 'missing expected Authorization header\\n' >&2 + exit 98 +fi +printf '%s %s data=%s\\n' "$method" "$url" "$data" >>"\${CRABBOX_FAKE_CURL_LOG:?}" +case "$method $url" in + "POST https://orgo.test/workspaces") + printf '{"id":"ws_test"}\\n' + ;; + "POST https://orgo.test/computers") + printf '{"id":"computer_test","status":"creating"}\\n' + ;; + "GET https://orgo.test/computers/computer_test") + printf '{"id":"computer_test","status":"running"}\\n' + ;; + "POST https://orgo.test/computers/computer_test/bash") + printf '{"success":%s,"stdout":"crabbox-orgo-ok\\\\n","exit_code":%s}\\n' "\${CRABBOX_FAKE_ORGO_SUCCESS:-true}" "\${CRABBOX_FAKE_ORGO_EXIT_CODE:-0}" + ;; + "DELETE https://orgo.test/computers/computer_test"|"DELETE https://orgo.test/workspaces/ws_test") + printf '{}\\n' + ;; + *) + printf 'unexpected curl request: %s %s\\n' "$method" "$url" >&2 + exit 99 + ;; +esac +`, + ); + + const result = spawnSync("bash", ["scripts/live-smoke.sh"], { + cwd: repoRoot, + env: { + ...process.env, + PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`, + CRABBOX_CONFIG: path.join(dir, "missing-crabbox.yaml"), + CRABBOX_FAKE_CURL_LOG: curlLog, + CRABBOX_LIVE: "1", + CRABBOX_LIVE_COORDINATOR: "0", + CRABBOX_LIVE_ORGO_SUFFIX: "test", + CRABBOX_LIVE_PROVIDERS: "orgo", + CRABBOX_LIVE_REPO: repoRoot, + CRABBOX_ORGO_API_BASE: "https://orgo.test", + ORGO_API_KEY: "dummy-orgo-key", + }, + encoding: "utf8", + }); + + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.match(result.stdout, /orgo computer=computer_test workspace=ws_test/); + assert.match(result.stdout, /crabbox-orgo-ok/); + assert.doesNotMatch(result.stdout + result.stderr, /dummy-orgo-key/); + + const curlCalls = fs.readFileSync(curlLog, "utf8"); + assert.match(curlCalls, /POST https:\/\/orgo\.test\/workspaces data=\{\n "name": "crabbox-smoke-test"\n\}/); + assert.match(curlCalls, /POST https:\/\/orgo\.test\/computers data=\{[\s\S]*"workspace_id": "ws_test"/); + assert.match(curlCalls, /^GET https:\/\/orgo\.test\/computers\/computer_test data=$/m); + assert.match(curlCalls, /POST https:\/\/orgo\.test\/computers\/computer_test\/bash data=\{\n "command": "printf crabbox-orgo-ok"\n\}/); + assert.match(curlCalls, /^DELETE https:\/\/orgo\.test\/computers\/computer_test data=$/m); + assert.match(curlCalls, /^DELETE https:\/\/orgo\.test\/workspaces\/ws_test data=$/m); + assert.doesNotMatch(curlCalls, /dummy-orgo-key/); + + const failedResult = spawnSync("bash", ["scripts/live-smoke.sh"], { + cwd: repoRoot, + env: { + ...process.env, + PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`, + CRABBOX_CONFIG: path.join(dir, "missing-crabbox.yaml"), + CRABBOX_FAKE_CURL_LOG: curlLog, + CRABBOX_FAKE_ORGO_SUCCESS: "false", + CRABBOX_LIVE: "1", + CRABBOX_LIVE_COORDINATOR: "0", + CRABBOX_LIVE_ORGO_SUFFIX: "test-failure", + CRABBOX_LIVE_PROVIDERS: "orgo", + CRABBOX_LIVE_REPO: repoRoot, + CRABBOX_ORGO_API_BASE: "https://orgo.test", + ORGO_API_KEY: "dummy-orgo-key", + }, + encoding: "utf8", + }); + assert.equal(failedResult.status, 1, failedResult.stdout + failedResult.stderr); + assert.match(failedResult.stderr, /did not return crabbox-orgo-ok/); + + const nonzeroResult = spawnSync("bash", ["scripts/live-smoke.sh"], { + cwd: repoRoot, + env: { + ...process.env, + PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`, + CRABBOX_CONFIG: path.join(dir, "missing-crabbox.yaml"), + CRABBOX_FAKE_CURL_LOG: curlLog, + CRABBOX_FAKE_ORGO_EXIT_CODE: "7", + CRABBOX_LIVE: "1", + CRABBOX_LIVE_COORDINATOR: "0", + CRABBOX_LIVE_ORGO_SUFFIX: "test-nonzero", + CRABBOX_LIVE_PROVIDERS: "orgo", + CRABBOX_LIVE_REPO: repoRoot, + CRABBOX_ORGO_API_BASE: "https://orgo.test", + ORGO_API_KEY: "dummy-orgo-key", + }, + encoding: "utf8", + }); + assert.equal(nonzeroResult.status, 1, nonzeroResult.stdout + nonzeroResult.stderr); + assert.match(nonzeroResult.stderr, /did not return crabbox-orgo-ok/); +}); + +test("orgo live smoke reuses an explicit workspace", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "crabbox-live-orgo-workspace-")); + const bin = path.join(dir, "bin"); + const config = path.join(dir, "crabbox.yaml"); + const fakeCurl = path.join(bin, "curl"); + const curlLog = path.join(dir, "curl.log"); + fs.mkdirSync(bin); + fs.writeFileSync( + config, + `orgo: + apiKey: config-orgo-key + apiBase: https://orgo.test +`, + "utf8", + ); + + writeExecutable( + fakeCurl, + `#!/usr/bin/env bash +set -euo pipefail +method=GET +url= +data= +auth_header= +while [[ $# -gt 0 ]]; do + case "$1" in + -X) method="$2"; shift 2 ;; + -d) data="$2"; shift 2 ;; + -H) + if [[ "$2" == "@-" ]]; then + IFS= read -r auth_header + elif [[ "$2" == Authorization:* ]]; then + auth_header="$2" + fi + shift 2 + ;; + -f|-s|-S|-fsS) shift ;; + *) url="$1"; shift ;; + esac +done +if [[ "$auth_header" != "Authorization: Bearer config-orgo-key" ]]; then + printf 'unexpected auth header: %s\\n' "$auth_header" >&2 + exit 98 +fi +printf '%s %s data=%s\\n' "$method" "$url" "$data" >>"\${CRABBOX_FAKE_CURL_LOG:?}" +case "$method $url" in + "POST https://orgo.test/computers") + printf '{"id":"computer_test","status":"running"}\\n' + ;; + "POST https://orgo.test/computers/computer_test/bash") + printf '{"success":true,"output":"crabbox-orgo-ok\\\\n"}\\n' + ;; + "DELETE https://orgo.test/computers/computer_test") + printf '{}\\n' + ;; + *) + printf 'unexpected curl request: %s %s\\n' "$method" "$url" >&2 + exit 99 + ;; +esac +`, + ); + + const result = spawnSync("bash", ["scripts/live-smoke.sh"], { + cwd: repoRoot, + env: { + ...process.env, + PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`, + CRABBOX_CONFIG: config, + CRABBOX_FAKE_CURL_LOG: curlLog, + CRABBOX_LIVE: "1", + CRABBOX_LIVE_COORDINATOR: "0", + CRABBOX_LIVE_ORGO_SUFFIX: "test", + CRABBOX_LIVE_PROVIDERS: "orgo", + CRABBOX_LIVE_REPO: repoRoot, + ORGO_API_KEY: "wrong-env-key", + ORGO_WORKSPACE_ID: "ws_existing", + }, + encoding: "utf8", + }); + + assert.equal(result.status, 0, result.stdout + result.stderr); + const curlCalls = fs.readFileSync(curlLog, "utf8"); + assert.doesNotMatch(curlCalls, /\/workspaces/); + assert.match(curlCalls, /POST https:\/\/orgo\.test\/computers data=\{[\s\S]*"workspace_id": "ws_existing"/); + assert.match(curlCalls, /^DELETE https:\/\/orgo\.test\/computers\/computer_test data=$/m); +}); + +test("orgo live smoke aborts cleanly when no API key is configured", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "crabbox-live-orgo-nokey-")); + const bin = path.join(dir, "bin"); + const fakeCurl = path.join(bin, "curl"); + const curlLog = path.join(dir, "curl.log"); + fs.mkdirSync(bin); + writeExecutable( + fakeCurl, + `#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "$*" >>"\${CRABBOX_FAKE_CURL_LOG:?}" +exit 99 +`, + ); + + const env = { ...process.env }; + delete env.CRABBOX_ORGO_API_KEY; + delete env.ORGO_API_KEY; + + const result = spawnSync("bash", ["scripts/live-smoke.sh"], { + cwd: repoRoot, + env: { + ...env, + PATH: `${bin}${path.delimiter}${env.PATH ?? ""}`, + CRABBOX_CONFIG: path.join(dir, "missing-crabbox.yaml"), + CRABBOX_FAKE_CURL_LOG: curlLog, + CRABBOX_LIVE: "1", + CRABBOX_LIVE_COORDINATOR: "0", + CRABBOX_LIVE_PROVIDERS: "orgo", + CRABBOX_LIVE_REPO: repoRoot, + }, + encoding: "utf8", + }); + + assert.equal(result.status, 2, result.stdout + result.stderr); + assert.match(result.stderr, /CRABBOX_ORGO_API_KEY/); + assert.match(result.stderr, /ORGO_API_KEY/); + assert.match(result.stderr, /orgo\.apiKey/); + const calls = fs.existsSync(curlLog) ? fs.readFileSync(curlLog, "utf8") : ""; + assert.equal(calls, ""); +}); + +test("orgo live smoke rejects line breaks in API keys before curl", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "crabbox-live-orgo-key-lines-")); + const bin = path.join(dir, "bin"); + const curlLog = path.join(dir, "curl.log"); + fs.mkdirSync(bin); + writeExecutable( + path.join(bin, "curl"), + `#!/usr/bin/env bash +printf '%s\\n' "$*" >>"\${CRABBOX_FAKE_CURL_LOG:?}" +exit 99 +`, + ); + + const result = spawnSync("bash", ["scripts/live-smoke.sh"], { + cwd: repoRoot, + env: { + ...process.env, + PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`, + CRABBOX_CONFIG: path.join(dir, "missing-crabbox.yaml"), + CRABBOX_FAKE_CURL_LOG: curlLog, + CRABBOX_LIVE: "1", + CRABBOX_LIVE_COORDINATOR: "0", + CRABBOX_LIVE_PROVIDERS: "orgo", + CRABBOX_LIVE_REPO: repoRoot, + ORGO_API_KEY: "dummy-orgo-key\nurl = https://attacker.invalid", + }, + encoding: "utf8", + }); + + assert.equal(result.status, 2, result.stdout + result.stderr); + assert.match(result.stderr, /API key must not contain line breaks/); + assert.doesNotMatch(result.stdout + result.stderr, /dummy-orgo-key|attacker/); + const calls = fs.existsSync(curlLog) ? fs.readFileSync(curlLog, "utf8") : ""; + assert.equal(calls, ""); +}); + +test("orgo live smoke ignores repository API keys when user config discovery fails", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "crabbox-live-orgo-repo-key-")); + const bin = path.join(dir, "bin"); + const fakeCrabbox = path.join(bin, "crabbox"); + const curlLog = path.join(dir, "curl.log"); + fs.mkdirSync(bin); + fs.writeFileSync(path.join(dir, "crabbox.yaml"), "orgo:\n apiKey: repo-secret\n", "utf8"); + writeExecutable(fakeCrabbox, "#!/usr/bin/env bash\nexit 1\n"); + writeExecutable( + path.join(bin, "curl"), + `#!/usr/bin/env bash +printf '%s\\n' "$*" >>"\${CRABBOX_FAKE_CURL_LOG:?}" +exit 99 +`, + ); + const env = { ...process.env }; + delete env.CRABBOX_CONFIG; + delete env.CRABBOX_ORGO_API_KEY; + delete env.ORGO_API_KEY; + + const result = spawnSync("bash", ["scripts/live-smoke.sh"], { + cwd: repoRoot, + env: { + ...env, + PATH: `${bin}${path.delimiter}${env.PATH ?? ""}`, + CRABBOX_BIN: fakeCrabbox, + CRABBOX_FAKE_CURL_LOG: curlLog, + CRABBOX_LIVE: "1", + CRABBOX_LIVE_COORDINATOR: "0", + CRABBOX_LIVE_PROVIDERS: "orgo", + CRABBOX_LIVE_REPO: dir, + }, + encoding: "utf8", + }); + + assert.equal(result.status, 2, result.stdout + result.stderr); + assert.match(result.stderr, /orgo smoke requires/); + assert.doesNotMatch(result.stdout + result.stderr, /repo-secret/); + const calls = fs.existsSync(curlLog) ? fs.readFileSync(curlLog, "utf8") : ""; + assert.equal(calls, ""); +}); + +test("orgo live smoke refuses a config-selected credential destination", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "crabbox-live-orgo-destination-")); + const bin = path.join(dir, "bin"); + const config = path.join(dir, "crabbox.yaml"); + const curlLog = path.join(dir, "curl.log"); + fs.mkdirSync(bin); + fs.writeFileSync(config, "orgo:\n apiBase: https://repo.example.test/api\n", "utf8"); + writeExecutable( + path.join(bin, "curl"), + `#!/usr/bin/env bash +printf '%s\\n' "$*" >>"\${CRABBOX_FAKE_CURL_LOG:?}" +exit 99 +`, + ); + + const result = spawnSync("bash", ["scripts/live-smoke.sh"], { + cwd: repoRoot, + env: { + ...process.env, + PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`, + CRABBOX_CONFIG: config, + CRABBOX_FAKE_CURL_LOG: curlLog, + CRABBOX_LIVE: "1", + CRABBOX_LIVE_COORDINATOR: "0", + CRABBOX_LIVE_PROVIDERS: "orgo", + CRABBOX_LIVE_REPO: repoRoot, + ORGO_API_KEY: "dummy-orgo-key", + }, + encoding: "utf8", + }); + + assert.equal(result.status, 2, result.stdout + result.stderr); + assert.match(result.stderr, /refuses configured orgo\.apiBase with an inherited API key/); + const calls = fs.existsSync(curlLog) ? fs.readFileSync(curlLog, "utf8") : ""; + assert.equal(calls, ""); +}); + +test("orgo live smoke refuses cleartext remote credential destinations", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "crabbox-live-orgo-cleartext-")); + const bin = path.join(dir, "bin"); + const curlLog = path.join(dir, "curl.log"); + fs.mkdirSync(bin); + writeExecutable( + path.join(bin, "curl"), + `#!/usr/bin/env bash +printf '%s\\n' "$*" >>"\${CRABBOX_FAKE_CURL_LOG:?}" +exit 99 +`, + ); + + const result = spawnSync("bash", ["scripts/live-smoke.sh"], { + cwd: repoRoot, + env: { + ...process.env, + PATH: `${bin}${path.delimiter}${process.env.PATH ?? ""}`, + CRABBOX_CONFIG: path.join(dir, "missing-crabbox.yaml"), + CRABBOX_FAKE_CURL_LOG: curlLog, + CRABBOX_LIVE: "1", + CRABBOX_LIVE_COORDINATOR: "0", + CRABBOX_LIVE_PROVIDERS: "orgo", + CRABBOX_LIVE_REPO: repoRoot, + CRABBOX_ORGO_API_BASE: "http://api.example.test", + ORGO_API_KEY: "dummy-orgo-key", + }, + encoding: "utf8", + }); + + assert.equal(result.status, 2, result.stdout + result.stderr); + assert.match(result.stderr, /must use HTTPS/); + const calls = fs.existsSync(curlLog) ? fs.readFileSync(curlLog, "utf8") : ""; + assert.equal(calls, ""); +});