diff --git a/CHANGELOG.md b/CHANGELOG.md index 79b79c748..273b54012 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.35.1 - Unreleased +### Added + +- Added GitHub Codespaces direct Linux SSH leases with token-scope preflight, repository and machine selection, claim-bound ownership, generated OpenSSH configuration, and guarded lifecycle smoke coverage. Thanks @coygeek. + ### Changed - Renamed the `apple-vz` provider to `apple-vm`; the old provider name/aliases, `appleVZ:` config keys, `--apple-vz-*` flags, and `CRABBOX_APPLE_VZ_*` environment variables keep working as deprecated aliases, existing leases and claims stay manageable, and the state directory migrates automatically. @@ -77,6 +81,7 @@ - Required exact resource-bound local lease claims before Apple Container, local-container, or Apple VZ stop operations can delete provider resources; legacy unbound claims require explicit `--reclaim` adoption before stop. Thanks @coygeek. - Hardened Azure Windows snapshot forks to fail closed through credential rehydration and quarantine cleanup, reuse only writable NIC payloads, reject unknown differential disks, and retry in-use security-group cleanup. Thanks @fcoury-oai. - Rolled back brokered Hetzner servers when post-create readiness fails, deleting only lease-owned SSH keys created by the failed attempt after server cleanup succeeds while preserving explicit no-delete retention until a later delete. Thanks @coygeek. +- Prevented repository-local configuration from retaining billable GitHub Codespaces or overriding trusted deletion policy. Thanks @coygeek. ## 0.34.0 - 2026-07-02 diff --git a/docs/operations.md b/docs/operations.md index 7454beaa2..bfe469cf4 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -82,6 +82,7 @@ CRABBOX_LIVE=1 CRABBOX_LIVE_PROVIDERS=nvidia-brev scripts/live-smoke.sh CRABBOX_LIVE=1 CRABBOX_LIVE_PROVIDERS=phala CRABBOX_LIVE_COORDINATOR=0 CRABBOX_BIN=./bin/crabbox scripts/live-smoke.sh CRABBOX_LIVE=1 CRABBOX_LIVE_PROVIDERS=anthropic-sandbox-runtime scripts/live-smoke.sh CRABBOX_LIVE=1 CRABBOX_LIVE_PROVIDERS=opensandbox CRABBOX_LIVE_COORDINATOR=0 scripts/live-smoke.sh +CRABBOX_LIVE=1 CRABBOX_LIVE_PROVIDERS=github-codespaces CRABBOX_LIVE_COORDINATOR=0 CRABBOX_GITHUB_CODESPACES_SMOKE_REPO=example-org/my-app GH_TOKEN=... scripts/live-smoke.sh CRABBOX_LIVE=1 CRABBOX_LIVE_PROVIDERS=proxmox CRABBOX_LIVE_COORDINATOR=0 CRABBOX_BIN=./bin/crabbox scripts/live-smoke.sh CRABBOX_LIVE=1 CRABBOX_LIVE_PROVIDERS=xcp-ng CRABBOX_LIVE_COORDINATOR=0 CRABBOX_BIN=./bin/crabbox scripts/live-smoke.sh CRABBOX_LIVE=1 CRABBOX_LIVE_PROVIDERS=agent-sandbox CRABBOX_LIVE_COORDINATOR=0 scripts/live-smoke.sh @@ -222,6 +223,13 @@ Per-provider smoke prerequisites: `scripts/live-opensandbox-smoke.sh` is coordinator-free, proves archive sync, off-argv environment forwarding, retained sandbox reuse, staged `sync.delete` replacement, list/status, and cleanup. +- **GitHub Codespaces** — an authenticated `gh` CLI, an explicit + `CRABBOX_GITHUB_CODESPACES_SMOKE_REPO`, and either `GH_TOKEN`, + `GITHUB_TOKEN`, or `CRABBOX_GITHUB_CODESPACES_USE_GH_AUTH=1`. + `scripts/live-smoke.sh` delegates to + `scripts/live-github-codespaces-smoke.sh`, which is coordinator-free, + creates one short-lived Codespace lease, verifies status, sync/run, SSH + command generation, list, stop, and dry-run cleanup. - **Proxmox** — a locally built Crabbox binary (`CRABBOX_BIN`), Proxmox API credentials/config, and `jq`/`perl`. `scripts/proxmox-live-smoke.sh` is coordinator-free and read-only by default; set `CRABBOX_PROXMOX_LIVE_SMOKE=1` diff --git a/docs/providers/README.md b/docs/providers/README.md index 3c133ae4a..ed2bca962 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: 71 providers (41 SSH lease, 28 delegated run, 2 service control). +Current built-in surface: 72 providers (42 SSH lease, 28 delegated run, 2 service control). Access terms: @@ -99,6 +99,7 @@ Access terms: | [firecracker](firecracker.md) | built-in; `ssh-lease` · self-hosted-virtualization | Crabbox-managed SSH; `crabbox-sync` · direct only; features: `ssh`, `crabbox-sync`, `cleanup` | `linux`; Firecracker microVM | `self-hosted`; GPU: no | Crabbox direct lifecycle; microVM and local artifact cleanup | Self-hosted Linux KVM host with prepared Firecracker kernel, rootfs, and CNI | Requires Linux, /dev/kvm, Firecracker assets, and a working CNI setup on the host | | [freestyle](freestyle.md) | built-in; `delegated-run` · delegated-sandbox | No SSH; `archive-sync` · direct only; features: `archive-sync`, `run-session` | `linux`; Freestyle VM | `provider-managed`; GPU: unknown | Freestyle; provider VM cleanup | Hosted delegated Linux VM execution | No Crabbox-managed SSH path | | [gcp](gcp.md) (`google`, `google-cloud`) | built-in; `ssh-lease` · brokerable-cloud | Crabbox-managed SSH; `crabbox-sync` · coordinator optional; features: `ssh`, `crabbox-sync`, `cleanup`, `tailscale` | `linux`; Google Compute Engine VM | `cloud`; GPU: optional | Crabbox or coordinator; instance and firewall cleanup | Linux compute with broad machine selection | Project, IAM, quota, and firewall setup required | +| [github-codespaces](github-codespaces.md) (`codespaces`, `gh-codespaces`) | built-in; `ssh-lease` · direct-cloud | Crabbox-managed SSH; `crabbox-sync` · direct only; features: `ssh`, `crabbox-sync`, `cleanup` | `linux`; GitHub Codespace | `provider-managed`; GPU: no | GitHub Codespaces; delete or stop claim-owned Codespace | Repository-backed Linux devcontainer over SSH | Requires gh auth, Codespaces quota, and an SSH-enabled devcontainer | | [hetzner](hetzner.md) | built-in; `ssh-lease` · brokerable-cloud | Crabbox-managed SSH; `crabbox-sync` · coordinator optional; features: `ssh`, `crabbox-sync`, `cleanup`, `desktop`, `browser`, `code`, `tailscale` | `linux`; Hetzner Cloud server | `cloud`; GPU: no | Crabbox or coordinator; server delete | Cost-effective high-CPU Linux VM | Linux-only and capacity varies by location | | [hostinger](hostinger.md) | built-in; `ssh-lease` · direct-cloud | Crabbox-managed SSH; `crabbox-sync` · direct only; features: `ssh`, `crabbox-sync`, `cleanup` | `linux`; Hostinger VPS | `cloud`; GPU: no | Hostinger subscription; stop only | Direct Linux VPS with persistent subscription | Purchase needs opt-in and release does not cancel billing | | [hyperv](hyperv.md) | built-in; `ssh-lease` · local-vm | Crabbox-managed SSH; `crabbox-sync` · direct only; features: `ssh`, `crabbox-sync`, `cleanup` | `windows/normal`; Microsoft Hyper-V VM | `local`; GPU: no | Crabbox; VM delete | Local native Windows VM | Windows host with Hyper-V required | diff --git a/docs/providers/github-codespaces.md b/docs/providers/github-codespaces.md new file mode 100644 index 000000000..247b262ce --- /dev/null +++ b/docs/providers/github-codespaces.md @@ -0,0 +1,273 @@ +# GitHub Codespaces Provider + +Read this when you are: + +- choosing `provider: github-codespaces`; +- validating a direct GitHub Codespaces SSH lease; +- changing `internal/providers/githubcodespaces` or the guarded live smoke. + +GitHub Codespaces is a Linux-only **SSH lease** provider. Crabbox creates a +Codespace from a GitHub repository, asks `gh codespace ssh --config` for the +OpenSSH connection details, stores that generated SSH config in Crabbox state, +and then uses the normal Crabbox SSH sync, `run`, `ssh`, `status`, and +`stop` paths. + +The provider is **direct-only** in this release. It never routes through the +coordinator, so the local CLI must have GitHub CLI authentication and the +operator owns quota, billing, retention, and cleanup. + +## When To Use It + +Use GitHub Codespaces when the desired execution surface is a repository-backed +Codespace and the project already has an SSH-enabled Linux devcontainer. Prefer +AWS, Azure, GCP, Hetzner, Linode, or DigitalOcean when you need a plain VM, +coordinator-side credentials, broad OS support, or cloud-specific cost controls. + +## Commands + +```sh +crabbox doctor --provider github-codespaces --github-codespaces-repo example-org/my-app +crabbox warmup --provider github-codespaces --github-codespaces-repo example-org/my-app --type basicLinux32gb +crabbox run --provider github-codespaces --id my-app -- pnpm test +crabbox ssh --provider github-codespaces --id my-app +crabbox stop --provider github-codespaces my-app +crabbox cleanup --provider github-codespaces --dry-run +``` + +Aliases: `codespaces`, `gh-codespaces`. + +`--id` accepts the canonical lease id (`cbx_...`), the friendly slug, or the +GitHub Codespace name when a matching local Crabbox claim exists. Crabbox +refuses to manage an unclaimed Codespace by name. + +## Requirements + +- Install the GitHub CLI as `gh`, or point Crabbox at it with + `githubCodespaces.ghPath`, `CRABBOX_GITHUB_CODESPACES_GH_PATH`, or + `--github-codespaces-gh-path`. +- Authenticate `gh` with an account that can create Codespaces for the selected + repository: + + ```sh + gh auth login + gh auth status + ``` + +- Ensure `GH_TOKEN`, `GITHUB_TOKEN`, or the `gh` credential store has a token + with access to Codespaces and the selected repository. For local `gh` auth, + refresh the missing OAuth scope before live smoke: + ```sh + gh auth refresh -h github.com -s codespace + gh codespace list --limit 1 + ``` +- Configure the repository with an SSH-enabled Linux devcontainer. The image + must run an SSH server and include Git, `rsync`, and `tar`. A common + devcontainer feature is `ghcr.io/devcontainers/features/sshd:1`. +- Keep local OpenSSH and `rsync` available for Crabbox's data plane. + +The provider asks GitHub for an OpenSSH config rather than shelling through +`gh codespace ssh -- `. That keeps the normal Crabbox sync/run/ssh +behavior intact, including `rsync -e "ssh -F ..."`. + +## Configuration + +Use the full example in trusted user config or an explicitly selected +`CRABBOX_CONFIG` file. Repository-local config cannot change the repository, +GitHub API or CLI routing, or release deletion policy. + +```yaml +provider: github-codespaces +target: linux +githubCodespaces: + repo: example-org/my-app + ref: main + machine: basicLinux32gb + devcontainerPath: .devcontainer/devcontainer.json + workingDirectory: /workspaces/my-app + geo: "" + idleTimeout: 30m + retentionPeriod: 168h + deleteOnRelease: true + ghPath: gh + workRoot: /workspaces/my-app +``` + +Config keys under `githubCodespaces:`: + +| Key | Default | Notes | +| --- | --- | --- | +| `apiUrl` | `https://api.github.com` | Trusted config only; useful for GitHub Enterprise-style API routing when supported by the environment. | +| `ghPath` | `gh` | Trusted config only; local GitHub CLI executable. | +| `repo` | inferred from the GitHub remote when possible | Repository in `owner/name` form. Trusted config, environment, or CLI flag only; repo-local config cannot redirect Codespaces creation. Required when no GitHub remote can be inferred. | +| `ref` | empty | Git ref for new Codespaces. Empty uses GitHub's default behavior. | +| `machine` | `basicLinux32gb` | GitHub Codespaces machine slug. `--type` is an alias for this value. | +| `devcontainerPath` | empty | Optional devcontainer path for creation. | +| `workingDirectory` | empty | Optional Codespaces working directory setting. | +| `geo` | empty | Optional GitHub geographic location preference. | +| `idleTimeout` | `30m` | Codespaces idle timeout sent to GitHub on create. | +| `retentionPeriod` | `168h` | Codespaces retention period sent to GitHub on create. | +| `deleteOnRelease` | `true` | Trusted config, environment, or CLI only; repository-local config is ignored. Delete on `stop` unless a retained lease claim says release by stopping. | +| `workRoot` | `/workspaces/` when repo is known | Remote path Crabbox syncs to and runs from. | + +Provider flags: + +```text +--github-codespaces-repo +--github-codespaces-ref +--github-codespaces-machine +--github-codespaces-devcontainer-path +--github-codespaces-working-directory +--github-codespaces-geo +--github-codespaces-idle-timeout +--github-codespaces-retention-period +--github-codespaces-delete-on-release +--github-codespaces-gh-path +--github-codespaces-work-root +``` + +Environment overrides: + +```text +CRABBOX_GITHUB_CODESPACES_API_URL +CRABBOX_GITHUB_CODESPACES_GH_PATH +CRABBOX_GITHUB_CODESPACES_REPO +CRABBOX_GITHUB_CODESPACES_REF +CRABBOX_GITHUB_CODESPACES_MACHINE +CRABBOX_GITHUB_CODESPACES_DEVCONTAINER_PATH +CRABBOX_GITHUB_CODESPACES_WORKING_DIRECTORY +CRABBOX_GITHUB_CODESPACES_GEO +CRABBOX_GITHUB_CODESPACES_IDLE_TIMEOUT +CRABBOX_GITHUB_CODESPACES_RETENTION_PERIOD +CRABBOX_GITHUB_CODESPACES_DELETE_ON_RELEASE +CRABBOX_GITHUB_CODESPACES_WORK_ROOT +``` + +Do not put GitHub tokens in Crabbox config or on command lines. Use +`GH_TOKEN`, `GITHUB_TOKEN`, or the GitHub CLI credential store. + +## Lifecycle + +1. Read GitHub CLI auth state and login identity. +2. Resolve the repository from `githubCodespaces.repo`, flags, env, or the + current GitHub remote. +3. Check available Codespaces machines for the repo/ref. +4. Create a Codespace with the configured machine, ref, devcontainer path, + working directory, geo, idle timeout, retention period, and Crabbox display + name. +5. Store a local Crabbox claim that binds the lease id, slug, Codespace name, + repository, machine, and GitHub login. +6. Wait for the Codespace to become available. +7. Ask `gh codespace ssh --config -c ` for OpenSSH config, store it + under Crabbox state, select the matching target, and wait for SSH readiness. +8. Use normal Crabbox SSH and rsync behavior for `run`, `sync`, and `ssh`. +9. On `stop`, delete or stop the claim-owned Codespace according to the release + policy. + +If a retained Codespace is stopped, resolving it later starts it and waits for +availability before refreshing the generated SSH config. + +## Ownership And Cleanup + +GitHub Codespaces does not expose custom user labels. Crabbox therefore uses a +local claim as the ownership predicate. Release and cleanup require the claim to +match the provider, Codespace name, and creating GitHub login. + +Deletion is conservative: + +- Crabbox refuses to release a Codespace without a local claim. +- Crabbox refuses to delete when GitHub reports uncommitted or unpushed changes. +- Cleanup mutates only expired claim-owned Codespaces. +- Account switches are rejected when the current `gh` login differs from the + claim login. + +Use dry-run cleanup before mutation: + +```sh +crabbox list --provider github-codespaces --json +crabbox cleanup --provider github-codespaces --dry-run +crabbox cleanup --provider github-codespaces +``` + +## SSHD And Devcontainer Contract + +`gh codespace ssh --config` requires an SSH server inside the Codespace. A plain +devcontainer image that does not start `sshd` is not enough for Crabbox because +Crabbox needs direct OpenSSH and rsync access. + +For a devcontainer-based smoke, include an SSH feature such as: + +```json +{ + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "features": { + "ghcr.io/devcontainers/features/sshd:1": {} + } +} +``` + +The ready path also expects Git, `rsync`, `tar`, and a writable work root. + +## Guarded Live Smoke + +The repeatable live check is opt-in and local-only: + +```sh +CRABBOX_LIVE=1 \ +CRABBOX_LIVE_PROVIDERS=github-codespaces \ +CRABBOX_GITHUB_CODESPACES_SMOKE_REPO=example-org/my-app \ +GH_TOKEN=... \ +scripts/live-smoke.sh +``` + +The script defaults to a skipped classification and does not call Crabbox unless +`CRABBOX_LIVE=1`, the provider filter selects `github-codespaces`, a smoke repo +is supplied, and GitHub credentials are explicitly available. It runs a read-only +doctor first, creates a short-lived Codespace lease, runs a command through the +normal synced Crabbox path, prints the Crabbox SSH command, releases the lease, +runs dry-run cleanup, and verifies the claim-owned inventory is empty. +`scripts/live-smoke.sh` delegates this provider to the standalone +`scripts/live-github-codespaces-smoke.sh`, so the standalone script can also be +run directly when isolating Codespaces smoke failures. + +Final classifications include: + +```text +classification=live_github_codespaces_smoke_passed +classification=environment_blocked +classification=credential_bound +classification=quota_blocked +classification=validation_failed +classification=cleanup_failed +``` + +If credentials, entitlement, quota, or local `gh` auth are unavailable, report +the classification instead of treating the live smoke as a provider failure. + +## Capabilities + +- **OS target**: Linux only. +- **SSH**: yes, from `gh codespace ssh --config`. +- **Crabbox sync**: yes, through normal OpenSSH/rsync. +- **Coordinator**: never; direct CLI only. +- **Desktop / browser / code**: not advertised in this release. +- **Tailscale**: not advertised; GitHub's SSH path is used. +- **Cleanup**: yes, claim-owned only. + +## Gotchas + +- `--class` is not supported. Use `--type ` or + `--github-codespaces-machine `. +- `provider=github-codespaces` supports `target=linux` only. +- A Codespace without an SSH server fails during SSH config or readiness. +- Manual Codespaces are intentionally invisible to Crabbox unless a local + Crabbox claim exists. +- `deleteOnRelease: true` still refuses deletion when GitHub reports uncommitted + or unpushed work. + +## Related Docs + +- [Provider reference](README.md) +- [Provider backends](../provider-backends.md) +- [Provider feature overview](../features/providers.md) +- [providers command](../commands/providers.md) +- [ssh command](../commands/ssh.md) diff --git a/docs/providers/provider-metadata.json b/docs/providers/provider-metadata.json index a281f456b..c91b54016 100644 --- a/docs/providers/provider-metadata.json +++ b/docs/providers/provider-metadata.json @@ -391,6 +391,20 @@ "caveat": "Project, IAM, quota, and firewall setup required", "docs": "gcp.md" }, + "github-codespaces": { + "status": "built-in", + "category": "direct-cloud", + "substrate": "GitHub Codespace", + "location": "provider-managed", + "ssh": "crabbox-managed", + "sync": "crabbox-sync", + "gpu": "no", + "lifecycle": "GitHub Codespaces", + "cleanup": "delete or stop claim-owned Codespace", + "bestFit": "Repository-backed Linux devcontainer over SSH", + "caveat": "Requires gh auth, Codespaces quota, and an SSH-enabled devcontainer", + "docs": "github-codespaces.md" + }, "hetzner": { "status": "built-in", "category": "brokerable-cloud", diff --git a/docs/source-map.md b/docs/source-map.md index d75d2694d..b7f118551 100644 --- a/docs/source-map.md +++ b/docs/source-map.md @@ -82,6 +82,8 @@ SSH-lease providers: - DigitalOcean Droplets: `internal/providers/digitalocean`, with config glue in `internal/cli/config.go` - Vultr instances: `internal/providers/vultr`, with config glue in `internal/cli/config.go` - OVHcloud Public Cloud: `internal/providers/ovh`, with config glue in `internal/cli/config.go` +- GitHub Codespaces: `internal/providers/githubcodespaces`, with config glue + and env overrides in `internal/cli/config.go` - Parallels (macOS VM host): `internal/providers/parallels`, with CLI helpers in `internal/cli/parallels.go` - Proxmox VE: `internal/providers/proxmox`, with CLI helpers in `internal/cli/proxmox.go` - XCP-ng (`xcp-ng`): `internal/providers/xcpng` @@ -159,7 +161,7 @@ Actions hydration or repo scripts. Provider docs: - Per-provider feature notes: `docs/features/aws.md`, `docs/features/azure.md`, `docs/features/hetzner.md`, `docs/features/blacksmith-testbox.md`, `docs/features/namespace-devbox.md`, `docs/features/namespace-devbox-setup.md`, `docs/features/semaphore.md`, `docs/features/sprites.md`, `docs/features/daytona.md`, `docs/features/islo.md`, `docs/features/e2b.md` -- Per-provider reference: `docs/providers/README.md` plus one file per provider under `docs/providers/`, including `docs/providers/aws-lambda-microvm.md` for Lambda MicroVM delegated execution and its runner image, `docs/providers/lambda.md` for the direct Lambda GPU SSH lease provider, `docs/providers/vast.md` for the direct Vast.ai GPU SSH lease provider, `docs/providers/blaxel.md` for delegated Blaxel sandbox execution, `docs/providers/apple-vm.md` for the local Apple Silicon `Virtualization.framework` path, `docs/providers/digitalocean.md` for the direct Droplet provider, `docs/providers/vultr.md` for the direct Vultr provider, `docs/providers/ovh.md` for the direct OVHcloud provider, `docs/providers/incus.md` for the separate local live validation contract, `docs/providers/superserve.md` for delegated Superserve execution and live proof, and `docs/providers/cloudflare-sandbox.md` for Cloudflare Sandbox bridge-backed delegated Linux execution +- Per-provider reference: `docs/providers/README.md` plus one file per provider under `docs/providers/`, including `docs/providers/aws-lambda-microvm.md` for Lambda MicroVM delegated execution and its runner image, `docs/providers/lambda.md` for the direct Lambda GPU SSH lease provider, `docs/providers/vast.md` for the direct Vast.ai GPU SSH lease provider, `docs/providers/blaxel.md` for delegated Blaxel sandbox execution, `docs/providers/apple-vm.md` for the local Apple Silicon `Virtualization.framework` path, `docs/providers/digitalocean.md` for the direct Droplet provider, `docs/providers/github-codespaces.md` for the direct Codespaces SSH lease, `docs/providers/vultr.md` for the direct Vultr provider, `docs/providers/ovh.md` for the direct OVHcloud provider, `docs/providers/incus.md` for the separate local live validation contract, `docs/providers/superserve.md` for delegated Superserve execution and live proof, and `docs/providers/cloudflare-sandbox.md` for Cloudflare Sandbox bridge-backed delegated Linux execution - Provider selection, landscape, live-smoke, and backend authoring guide: `docs/features/provider-selection.md`, `docs/features/provider-landscape.md`, `docs/features/provider-live-smoke.md`, `docs/provider-backends.md`, `docs/features/provider-authoring.md` - Delegated runner contract for non-SSH hosted APIs: `docs/features/delegated-runner-contract.md` - Tailscale contract: `docs/features/tailscale.md` @@ -248,5 +250,5 @@ Provider docs: - Release workflow and Homebrew tap fallback: `.github/workflows/release.yml` - GoReleaser archives and Homebrew formula config: `.goreleaser.yaml` - Docs command-surface check, link check, site builder, and Pages deploy: `scripts/check-command-docs.mjs`, `scripts/check-docs-links.mjs`, `scripts/build-docs-site.mjs`, `.github/workflows/pages.yml` -- Live provider smoke coverage: `scripts/live-smoke.sh`, plus provider-specific guarded smokes such as `scripts/live-blaxel-smoke.sh`, `scripts/live-digitalocean-smoke.sh`, `scripts/live-vultr-smoke.sh`, `scripts/live-vast-smoke.sh`, and `scripts/live-superserve-smoke.sh` +- Live provider smoke coverage: `scripts/live-smoke.sh`, plus provider-specific guarded smokes such as `scripts/live-blaxel-smoke.sh`, `scripts/live-digitalocean-smoke.sh`, `scripts/live-github-codespaces-smoke.sh`, `scripts/live-vultr-smoke.sh`, `scripts/live-vast-smoke.sh`, and `scripts/live-superserve-smoke.sh` - Live coordinator auth smoke coverage: `scripts/live-auth-smoke.sh` diff --git a/internal/cli/claim.go b/internal/cli/claim.go index a421e2b2f..cd45e7b2f 100644 --- a/internal/cli/claim.go +++ b/internal/cli/claim.go @@ -543,7 +543,7 @@ func applyLeaseClaimEndpoint(claim *leaseClaim, server Server, target SSHTarget) func claimEndpointInactiveState(state string) bool { state = strings.TrimSpace(state) - return statusTerminalState(state) || strings.EqualFold(state, "paused") || strings.EqualFold(state, "deleting") + return statusTerminalState(state) || strings.EqualFold(state, "stopped") || strings.EqualFold(state, "paused") || strings.EqualFold(state, "deleting") } // updateLeaseClaimTailscale records a tailnet endpoint on an existing claim. diff --git a/internal/cli/config.go b/internal/cli/config.go index 4dbb47178..0e2bf5ce2 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -112,6 +112,7 @@ type Config struct { Linode LinodeConfig linodeImageExplicit bool linodeTypeExplicit bool + GitHubCodespaces GitHubCodespacesConfig Lambda LambdaConfig lambdaImageExplicit bool lambdaImageFamilyExplicit bool @@ -305,6 +306,24 @@ type LinodeConfig struct { SSHCIDRs []string } +// GitHubCodespacesConfig is intentionally token-free. Authentication comes +// from the GitHub CLI credential store or GitHub's standard environment +// variables at the point of use, never from Crabbox config or argv. +type GitHubCodespacesConfig struct { + APIURL string + GHPath string + Repo string + Ref string + Machine string + DevcontainerPath string + WorkingDirectory string + Geo string + IdleTimeout time.Duration + RetentionPeriod time.Duration + DeleteOnRelease bool + WorkRoot string +} + type LambdaConfig struct { Region string Type string @@ -1798,6 +1817,42 @@ func applyProviderConfigDefaults(cfg *Config) error { normalizeTargetConfig(cfg) return validateTargetConfig(*cfg) } + if cfg.Provider == "github-codespaces" { + if cfg.GitHubCodespaces.GHPath == "" { + cfg.GitHubCodespaces.GHPath = "gh" + } + if cfg.GitHubCodespaces.Machine == "" { + cfg.GitHubCodespaces.Machine = "basicLinux32gb" + } + if cfg.GitHubCodespaces.IdleTimeout == 0 { + cfg.GitHubCodespaces.IdleTimeout = 30 * time.Minute + } + if cfg.GitHubCodespaces.RetentionPeriod == 0 { + cfg.GitHubCodespaces.RetentionPeriod = 7 * 24 * time.Hour + } + if cfg.GitHubCodespaces.WorkRoot == "" { + cfg.GitHubCodespaces.WorkRoot = "/workspaces/crabbox" + } + if !IsTargetExplicit(cfg) { + cfg.TargetOS = targetLinux + } + cfg.SSHFallbackPorts = nil + if cfg.explicitWorkRoot != "" { + cfg.WorkRoot = cfg.explicitWorkRoot + } else { + cfg.WorkRoot = cfg.GitHubCodespaces.WorkRoot + } + if cfg.explicitSSHPort != "" { + cfg.SSHPort = cfg.explicitSSHPort + } else { + cfg.SSHPort = "22" + } + if !cfg.ServerTypeExplicit && cfg.GitHubCodespaces.Machine != "" { + cfg.ServerType = cfg.GitHubCodespaces.Machine + } + normalizeTargetConfig(cfg) + return validateTargetConfig(*cfg) + } if cfg.Provider == "nebius" { if cfg.Nebius.CLI == "" { cfg.Nebius.CLI = "nebius" @@ -2654,6 +2709,15 @@ func baseConfig() Config { Image: linodeImage, Type: "g6-standard-1", }, + GitHubCodespaces: GitHubCodespacesConfig{ + APIURL: "https://api.github.com", + GHPath: "gh", + Machine: "basicLinux32gb", + IdleTimeout: 30 * time.Minute, + RetentionPeriod: 7 * 24 * time.Hour, + DeleteOnRelease: true, + WorkRoot: "/workspaces/crabbox", + }, Lambda: LambdaConfig{ Region: "us-west-1", Type: "gpu_1x_a10", @@ -3102,6 +3166,7 @@ type fileConfig struct { DigitalOcean *fileDigitalOceanConfig `yaml:"digitalocean,omitempty"` Vultr *fileVultrConfig `yaml:"vultr,omitempty"` Linode *fileLinodeConfig `yaml:"linode,omitempty"` + GitHubCodespaces *fileGitHubCodespacesConfig `yaml:"githubCodespaces,omitempty"` Lambda *fileLambdaConfig `yaml:"lambda,omitempty"` Nebius *fileNebiusConfig `yaml:"nebius,omitempty"` OVH *fileOVHConfig `yaml:"ovh,omitempty"` @@ -3242,6 +3307,21 @@ type fileLinodeConfig struct { SSHCIDRs []string `yaml:"sshCIDRs,omitempty"` } +type fileGitHubCodespacesConfig struct { + APIURL string `yaml:"apiUrl,omitempty"` + GHPath string `yaml:"ghPath,omitempty"` + Repo string `yaml:"repo,omitempty"` + Ref string `yaml:"ref,omitempty"` + Machine string `yaml:"machine,omitempty"` + DevcontainerPath string `yaml:"devcontainerPath,omitempty"` + WorkingDirectory string `yaml:"workingDirectory,omitempty"` + Geo string `yaml:"geo,omitempty"` + IdleTimeout string `yaml:"idleTimeout,omitempty"` + RetentionPeriod string `yaml:"retentionPeriod,omitempty"` + DeleteOnRelease *bool `yaml:"deleteOnRelease,omitempty"` + WorkRoot string `yaml:"workRoot,omitempty"` +} + type fileLambdaConfig struct { Region string `yaml:"region,omitempty"` Type string `yaml:"type,omitempty"` @@ -4886,6 +4966,41 @@ func applyFileConfigWithTrust(cfg *Config, file fileConfig, trusted bool) error cfg.Linode.SSHCIDRs = file.Linode.SSHCIDRs } } + if file.GitHubCodespaces != nil { + if trusted && file.GitHubCodespaces.APIURL != "" { + cfg.GitHubCodespaces.APIURL = file.GitHubCodespaces.APIURL + } + if trusted && file.GitHubCodespaces.GHPath != "" { + cfg.GitHubCodespaces.GHPath = expandUserPath(file.GitHubCodespaces.GHPath) + } + if trusted && file.GitHubCodespaces.Repo != "" { + cfg.GitHubCodespaces.Repo = file.GitHubCodespaces.Repo + } + if file.GitHubCodespaces.Ref != "" { + cfg.GitHubCodespaces.Ref = file.GitHubCodespaces.Ref + } + if file.GitHubCodespaces.Machine != "" { + cfg.GitHubCodespaces.Machine = file.GitHubCodespaces.Machine + } + if file.GitHubCodespaces.DevcontainerPath != "" { + cfg.GitHubCodespaces.DevcontainerPath = file.GitHubCodespaces.DevcontainerPath + } + if file.GitHubCodespaces.WorkingDirectory != "" { + cfg.GitHubCodespaces.WorkingDirectory = file.GitHubCodespaces.WorkingDirectory + } + if file.GitHubCodespaces.Geo != "" { + cfg.GitHubCodespaces.Geo = file.GitHubCodespaces.Geo + } + applyLeaseDuration(&cfg.GitHubCodespaces.IdleTimeout, file.GitHubCodespaces.IdleTimeout) + applyLeaseDuration(&cfg.GitHubCodespaces.RetentionPeriod, file.GitHubCodespaces.RetentionPeriod) + if trusted && file.GitHubCodespaces.DeleteOnRelease != nil { + cfg.GitHubCodespaces.DeleteOnRelease = *file.GitHubCodespaces.DeleteOnRelease + MarkDeleteOnReleaseExplicit(cfg, "github-codespaces") + } + if file.GitHubCodespaces.WorkRoot != "" { + cfg.GitHubCodespaces.WorkRoot = file.GitHubCodespaces.WorkRoot + } + } if file.Lambda != nil { lambdaImageSet := false lambdaImageFamilySet := false @@ -7656,6 +7771,25 @@ func applyEnv(cfg *Config) error { if cidrs := os.Getenv("CRABBOX_LINODE_SSH_CIDRS"); cidrs != "" { cfg.Linode.SSHCIDRs = splitCommaList(cidrs) } + cfg.GitHubCodespaces.APIURL = getenv("CRABBOX_GITHUB_CODESPACES_API_URL", cfg.GitHubCodespaces.APIURL) + cfg.GitHubCodespaces.GHPath = expandUserPath(getenv("CRABBOX_GITHUB_CODESPACES_GH_PATH", cfg.GitHubCodespaces.GHPath)) + cfg.GitHubCodespaces.Repo = getenv("CRABBOX_GITHUB_CODESPACES_REPO", cfg.GitHubCodespaces.Repo) + cfg.GitHubCodespaces.Ref = getenv("CRABBOX_GITHUB_CODESPACES_REF", cfg.GitHubCodespaces.Ref) + cfg.GitHubCodespaces.Machine = getenv("CRABBOX_GITHUB_CODESPACES_MACHINE", cfg.GitHubCodespaces.Machine) + cfg.GitHubCodespaces.DevcontainerPath = getenv("CRABBOX_GITHUB_CODESPACES_DEVCONTAINER_PATH", cfg.GitHubCodespaces.DevcontainerPath) + cfg.GitHubCodespaces.WorkingDirectory = getenv("CRABBOX_GITHUB_CODESPACES_WORKING_DIRECTORY", cfg.GitHubCodespaces.WorkingDirectory) + cfg.GitHubCodespaces.Geo = getenv("CRABBOX_GITHUB_CODESPACES_GEO", cfg.GitHubCodespaces.Geo) + if idleTimeout := os.Getenv("CRABBOX_GITHUB_CODESPACES_IDLE_TIMEOUT"); idleTimeout != "" { + applyLeaseDuration(&cfg.GitHubCodespaces.IdleTimeout, idleTimeout) + } + if retentionPeriod := os.Getenv("CRABBOX_GITHUB_CODESPACES_RETENTION_PERIOD"); retentionPeriod != "" { + applyLeaseDuration(&cfg.GitHubCodespaces.RetentionPeriod, retentionPeriod) + } + if value, ok := getenvBool("CRABBOX_GITHUB_CODESPACES_DELETE_ON_RELEASE"); ok { + cfg.GitHubCodespaces.DeleteOnRelease = value + MarkDeleteOnReleaseExplicit(cfg, "github-codespaces") + } + cfg.GitHubCodespaces.WorkRoot = getenv("CRABBOX_GITHUB_CODESPACES_WORK_ROOT", cfg.GitHubCodespaces.WorkRoot) cfg.Lambda.Region = getenv("CRABBOX_LAMBDA_REGION", cfg.Lambda.Region) if lambdaType := os.Getenv("CRABBOX_LAMBDA_TYPE"); lambdaType != "" { cfg.Lambda.Type = lambdaType diff --git a/internal/cli/config_cmd.go b/internal/cli/config_cmd.go index 25dfca68d..3f0c2f8fe 100644 --- a/internal/cli/config_cmd.go +++ b/internal/cli/config_cmd.go @@ -235,6 +235,21 @@ func configShowView(cfg Config) map[string]any { "firewall": cfg.Linode.FirewallID, "sshCIDRs": cfg.Linode.SSHCIDRs, }, + "githubCodespaces": map[string]any{ + "apiUrl": redactedConfigURL(cfg.GitHubCodespaces.APIURL), + "ghPath": cfg.GitHubCodespaces.GHPath, + "auth": "gh", + "repo": cfg.GitHubCodespaces.Repo, + "ref": cfg.GitHubCodespaces.Ref, + "machine": cfg.GitHubCodespaces.Machine, + "devcontainerPath": cfg.GitHubCodespaces.DevcontainerPath, + "workingDirectory": cfg.GitHubCodespaces.WorkingDirectory, + "geo": cfg.GitHubCodespaces.Geo, + "idleTimeout": cfg.GitHubCodespaces.IdleTimeout.String(), + "retentionPeriod": cfg.GitHubCodespaces.RetentionPeriod.String(), + "deleteOnRelease": cfg.GitHubCodespaces.DeleteOnRelease, + "workRoot": cfg.GitHubCodespaces.WorkRoot, + }, "lambda": map[string]any{ "region": cfg.Lambda.Region, "type": cfg.Lambda.Type, @@ -759,6 +774,7 @@ func writeConfigShowText(w io.Writer, cfg Config) { fmt.Fprintf(w, "digitalocean region=%s image=%s vpc=%s ssh_cidrs=%s\n", cfg.DigitalOcean.Region, cfg.DigitalOcean.Image, blank(cfg.DigitalOcean.VPCUUID, "-"), blank(strings.Join(cfg.DigitalOcean.SSHCIDRs, ","), "-")) fmt.Fprintf(w, "vultr region=%s os=%s image=%s snapshot=%s firewall_group=%s vpc_ids=%s ssh_cidrs=%s user_scheme=%s\n", cfg.Vultr.Region, blank(cfg.Vultr.OS, "-"), blank(cfg.Vultr.Image, "-"), blank(cfg.Vultr.Snapshot, "-"), blank(cfg.Vultr.FirewallGroup, "-"), blank(strings.Join(cfg.Vultr.VPCIDs, ","), "-"), blank(strings.Join(cfg.Vultr.SSHCIDRs, ","), "-"), blank(cfg.Vultr.UserScheme, "-")) fmt.Fprintf(w, "linode region=%s image=%s type=%s firewall=%s ssh_cidrs=%s\n", cfg.Linode.Region, cfg.Linode.Image, cfg.Linode.Type, blank(cfg.Linode.FirewallID, "-"), blank(strings.Join(cfg.Linode.SSHCIDRs, ","), "-")) + fmt.Fprintf(w, "github_codespaces api_url=%s gh_path=%s repo=%s ref=%s machine=%s devcontainer_path=%s working_directory=%s geo=%s idle_timeout=%s retention_period=%s delete_on_release=%t work_root=%s auth=gh\n", blank(redactedConfigURL(cfg.GitHubCodespaces.APIURL), "-"), blank(cfg.GitHubCodespaces.GHPath, "-"), blank(cfg.GitHubCodespaces.Repo, "-"), blank(cfg.GitHubCodespaces.Ref, "-"), blank(cfg.GitHubCodespaces.Machine, "-"), blank(cfg.GitHubCodespaces.DevcontainerPath, "-"), blank(cfg.GitHubCodespaces.WorkingDirectory, "-"), blank(cfg.GitHubCodespaces.Geo, "-"), cfg.GitHubCodespaces.IdleTimeout, cfg.GitHubCodespaces.RetentionPeriod, cfg.GitHubCodespaces.DeleteOnRelease, blank(cfg.GitHubCodespaces.WorkRoot, "-")) fmt.Fprintf(w, "lambda region=%s type=%s image=%s image_family=%s firewall_ruleset=%s ssh_cidrs=%s filesystems=%s mounts=%d auth=%s\n", cfg.Lambda.Region, cfg.Lambda.Type, blank(cfg.Lambda.Image, "-"), blank(cfg.Lambda.ImageFamily, "-"), blank(cfg.Lambda.FirewallRuleset, "-"), blank(strings.Join(cfg.Lambda.SSHCIDRs, ","), "-"), blank(strings.Join(cfg.Lambda.FilesystemNames, ","), "-"), len(cfg.Lambda.FilesystemMounts), lambdaAuthState()) fmt.Fprintf(w, "vast api_url=%s instance_type=%s gpu_name=%s gpu_count=%d image=%s template_id=%s runtype=%s disk_gb=%d max_dph_total=%.4g min_reliability=%.4g order=%s user=%s work_root=%s release_action=%s auth=%s\n", blank(redactedConfigURL(cfg.Vast.APIURL), "-"), blank(cfg.Vast.InstanceType, "-"), blank(cfg.Vast.GPUName, "-"), cfg.Vast.GPUCount, blank(cfg.Vast.Image, "-"), blank(cfg.Vast.TemplateID, "-"), blank(cfg.Vast.Runtype, "-"), cfg.Vast.DiskGB, cfg.Vast.MaxDphTotal, cfg.Vast.MinReliability, blank(cfg.Vast.Order, "-"), blank(cfg.Vast.User, "-"), blank(cfg.Vast.WorkRoot, "-"), blank(cfg.Vast.ReleaseAction, "-"), tokenState(cfg.Vast.APIKey)) fmt.Fprintf(w, "nvidia_brev cli=%s org=%s type=%s gpu_name=%s provider=%s mode=%s launchable=%s startup_script=%s release_action=%s target=%s user=%s work_root=%s auth=cli\n", blank(cfg.NvidiaBrev.CLI, "-"), blank(cfg.NvidiaBrev.Org, "-"), blank(cfg.NvidiaBrev.Type, "-"), blank(cfg.NvidiaBrev.GPUName, "-"), blank(cfg.NvidiaBrev.Provider, "-"), blank(cfg.NvidiaBrev.Mode, "-"), blank(cfg.NvidiaBrev.Launchable, "-"), blank(cfg.NvidiaBrev.StartupScript, "-"), blank(cfg.NvidiaBrev.ReleaseAction, "-"), blank(cfg.NvidiaBrev.Target, "-"), blank(cfg.NvidiaBrev.User, "-"), blank(cfg.NvidiaBrev.WorkRoot, "-")) diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go index 6203f4acc..1deb41edf 100644 --- a/internal/cli/config_test.go +++ b/internal/cli/config_test.go @@ -524,6 +524,18 @@ func clearConfigEnv(t *testing.T) { "CRABBOX_NVIDIA_BREV_TARGET", "CRABBOX_NVIDIA_BREV_USER", "CRABBOX_NVIDIA_BREV_WORK_ROOT", + "CRABBOX_GITHUB_CODESPACES_API_URL", + "CRABBOX_GITHUB_CODESPACES_GH_PATH", + "CRABBOX_GITHUB_CODESPACES_REPO", + "CRABBOX_GITHUB_CODESPACES_REF", + "CRABBOX_GITHUB_CODESPACES_MACHINE", + "CRABBOX_GITHUB_CODESPACES_DEVCONTAINER_PATH", + "CRABBOX_GITHUB_CODESPACES_WORKING_DIRECTORY", + "CRABBOX_GITHUB_CODESPACES_GEO", + "CRABBOX_GITHUB_CODESPACES_IDLE_TIMEOUT", + "CRABBOX_GITHUB_CODESPACES_RETENTION_PERIOD", + "CRABBOX_GITHUB_CODESPACES_DELETE_ON_RELEASE", + "CRABBOX_GITHUB_CODESPACES_WORK_ROOT", "HOSTINGER_API_TOKEN", "CRABBOX_HOSTINGER_API_TOKEN", "HOSTINGER_API_URL", @@ -698,6 +710,199 @@ func TestNvidiaBrevConfigDefaultsFileAndEnv(t *testing.T) { } } +func TestGitHubCodespacesConfigDefaultsFileEnvAndShow(t *testing.T) { + clearConfigEnv(t) + cfg := baseConfig() + if cfg.GitHubCodespaces.APIURL != "https://api.github.com" || + cfg.GitHubCodespaces.GHPath != "gh" || + cfg.GitHubCodespaces.Machine != "basicLinux32gb" || + cfg.GitHubCodespaces.IdleTimeout != 30*time.Minute || + cfg.GitHubCodespaces.RetentionPeriod != 7*24*time.Hour || + !cfg.GitHubCodespaces.DeleteOnRelease || + cfg.GitHubCodespaces.WorkRoot != "/workspaces/crabbox" { + t.Fatalf("githubCodespaces defaults not applied: %#v", cfg.GitHubCodespaces) + } + + deleteOnRelease := true + if err := applyFileConfig(&cfg, fileConfig{ + Provider: "github-codespaces", + GitHubCodespaces: &fileGitHubCodespacesConfig{ + APIURL: "https://api.github.example", + GHPath: "/opt/gh", + Repo: "example-org/my-app", + Ref: "main", + Machine: "standardLinux32gb", + DevcontainerPath: ".devcontainer/devcontainer.json", + WorkingDirectory: "/workspaces/my-app", + Geo: "UsWest", + IdleTimeout: "45m", + RetentionPeriod: "48h", + DeleteOnRelease: &deleteOnRelease, + WorkRoot: "/workspaces/my-app", + }, + }); err != nil { + t.Fatal(err) + } + if cfg.Provider != "github-codespaces" || + cfg.GitHubCodespaces.APIURL != "https://api.github.example" || + cfg.GitHubCodespaces.GHPath != "/opt/gh" || + cfg.GitHubCodespaces.Repo != "example-org/my-app" || + cfg.GitHubCodespaces.Ref != "main" || + cfg.GitHubCodespaces.Machine != "standardLinux32gb" || + cfg.GitHubCodespaces.DevcontainerPath != ".devcontainer/devcontainer.json" || + cfg.GitHubCodespaces.WorkingDirectory != "/workspaces/my-app" || + cfg.GitHubCodespaces.Geo != "UsWest" || + cfg.GitHubCodespaces.IdleTimeout != 45*time.Minute || + cfg.GitHubCodespaces.RetentionPeriod != 48*time.Hour || + !cfg.GitHubCodespaces.DeleteOnRelease || + cfg.GitHubCodespaces.WorkRoot != "/workspaces/my-app" { + t.Fatalf("file githubCodespaces config not applied: %#v", cfg.GitHubCodespaces) + } + if !DeleteOnReleaseExplicit(cfg, "github-codespaces") { + t.Fatal("file githubCodespaces delete-on-release not marked explicit") + } + + t.Setenv("CRABBOX_GITHUB_CODESPACES_API_URL", "https://api.env.example") + t.Setenv("CRABBOX_GITHUB_CODESPACES_GH_PATH", "/usr/local/bin/gh") + t.Setenv("CRABBOX_GITHUB_CODESPACES_REPO", "example-org/env-app") + t.Setenv("CRABBOX_GITHUB_CODESPACES_REF", "env-main") + t.Setenv("CRABBOX_GITHUB_CODESPACES_MACHINE", "premiumLinux") + t.Setenv("CRABBOX_GITHUB_CODESPACES_DEVCONTAINER_PATH", ".devcontainer/env.json") + t.Setenv("CRABBOX_GITHUB_CODESPACES_WORKING_DIRECTORY", "/workspaces/env-app") + t.Setenv("CRABBOX_GITHUB_CODESPACES_GEO", "EuropeWest") + t.Setenv("CRABBOX_GITHUB_CODESPACES_IDLE_TIMEOUT", "1h") + t.Setenv("CRABBOX_GITHUB_CODESPACES_RETENTION_PERIOD", "72h") + t.Setenv("CRABBOX_GITHUB_CODESPACES_DELETE_ON_RELEASE", "false") + t.Setenv("CRABBOX_GITHUB_CODESPACES_WORK_ROOT", "/workspaces/env-app") + if err := applyEnv(&cfg); err != nil { + t.Fatal(err) + } + if cfg.GitHubCodespaces.APIURL != "https://api.env.example" || + cfg.GitHubCodespaces.GHPath != "/usr/local/bin/gh" || + cfg.GitHubCodespaces.Repo != "example-org/env-app" || + cfg.GitHubCodespaces.Ref != "env-main" || + cfg.GitHubCodespaces.Machine != "premiumLinux" || + cfg.GitHubCodespaces.DevcontainerPath != ".devcontainer/env.json" || + cfg.GitHubCodespaces.WorkingDirectory != "/workspaces/env-app" || + cfg.GitHubCodespaces.Geo != "EuropeWest" || + cfg.GitHubCodespaces.IdleTimeout != time.Hour || + cfg.GitHubCodespaces.RetentionPeriod != 72*time.Hour || + cfg.GitHubCodespaces.DeleteOnRelease || + cfg.GitHubCodespaces.WorkRoot != "/workspaces/env-app" { + t.Fatalf("env githubCodespaces config not applied: %#v", cfg.GitHubCodespaces) + } + if !DeleteOnReleaseExplicit(cfg, "github-codespaces") { + t.Fatal("env githubCodespaces delete-on-release not marked explicit") + } + + view := configShowView(cfg)["githubCodespaces"].(map[string]any) + if view["auth"] != "gh" { + t.Fatalf("auth=%#v", view["auth"]) + } + if _, ok := view["token"]; ok { + t.Fatalf("config show exposed token key: %#v", view) + } +} + +func TestGitHubCodespacesProviderDefaults(t *testing.T) { + t.Run("provider defaults", func(t *testing.T) { + cfg := baseConfig() + cfg.Provider = "github-codespaces" + cfg.GitHubCodespaces = GitHubCodespacesConfig{} + cfg.TargetOS = "" + cfg.SSHFallbackPorts = []string{"2222"} + cfg.WorkRoot = "" + cfg.SSHPort = "" + cfg.ServerType = "" + + if err := applyProviderConfigDefaults(&cfg); err != nil { + t.Fatal(err) + } + if cfg.GitHubCodespaces.GHPath != "gh" || + cfg.GitHubCodespaces.Machine != "basicLinux32gb" || + cfg.GitHubCodespaces.IdleTimeout != 30*time.Minute || + cfg.GitHubCodespaces.RetentionPeriod != 7*24*time.Hour || + cfg.GitHubCodespaces.WorkRoot != "/workspaces/crabbox" { + t.Fatalf("provider defaults not applied: %#v", cfg.GitHubCodespaces) + } + if cfg.TargetOS != targetLinux || cfg.WorkRoot != "/workspaces/crabbox" || cfg.SSHPort != "22" || cfg.ServerType != "basicLinux32gb" || cfg.SSHFallbackPorts != nil { + t.Fatalf("generic defaults not applied: %#v", cfg) + } + }) + + t.Run("explicit generic values", func(t *testing.T) { + cfg := baseConfig() + cfg.Provider = "github-codespaces" + cfg.GitHubCodespaces = GitHubCodespacesConfig{ + GHPath: "/opt/gh", + Machine: "premiumLinux", + IdleTimeout: time.Hour, + RetentionPeriod: 48 * time.Hour, + WorkRoot: "/workspaces/provider", + } + cfg.TargetOS = targetLinux + cfg.targetExplicit = true + cfg.WorkRoot = "/workspaces/explicit" + cfg.explicitWorkRoot = cfg.WorkRoot + cfg.SSHPort = "2222" + cfg.explicitSSHPort = cfg.SSHPort + cfg.ServerType = "custom-machine" + cfg.ServerTypeExplicit = true + + if err := applyProviderConfigDefaults(&cfg); err != nil { + t.Fatal(err) + } + if cfg.WorkRoot != "/workspaces/explicit" || cfg.SSHPort != "2222" || cfg.ServerType != "custom-machine" || cfg.SSHFallbackPorts != nil { + t.Fatalf("explicit generic values not preserved: %#v", cfg) + } + }) +} + +func TestGitHubCodespacesUntrustedConfigCannotRedirectOrChangeReleasePolicy(t *testing.T) { + cfg := baseConfig() + cfg.GitHubCodespaces.APIURL = "https://api.trusted.example" + cfg.GitHubCodespaces.GHPath = "/trusted/gh" + cfg.GitHubCodespaces.Repo = "trusted-org/trusted-app" + untrustedRetain := false + if err := applyFileConfigWithTrust(&cfg, fileConfig{GitHubCodespaces: &fileGitHubCodespacesConfig{ + APIURL: "https://api.untrusted.example", + GHPath: "./payload", + Repo: "attacker-org/payload", + DeleteOnRelease: &untrustedRetain, + }}, false); err != nil { + t.Fatal(err) + } + if cfg.GitHubCodespaces.APIURL != "https://api.trusted.example" || cfg.GitHubCodespaces.GHPath != "/trusted/gh" { + t.Fatalf("untrusted redirect applied: %#v", cfg.GitHubCodespaces) + } + if cfg.GitHubCodespaces.Repo != "trusted-org/trusted-app" { + t.Fatalf("untrusted repo redirect applied: %#v", cfg.GitHubCodespaces) + } + if !cfg.GitHubCodespaces.DeleteOnRelease || DeleteOnReleaseExplicit(cfg, "github-codespaces") { + t.Fatalf("untrusted retention policy applied: %#v", cfg.GitHubCodespaces) + } + + trustedRetain := false + if err := applyFileConfigWithTrust(&cfg, fileConfig{GitHubCodespaces: &fileGitHubCodespacesConfig{ + DeleteOnRelease: &trustedRetain, + }}, true); err != nil { + t.Fatal(err) + } + if cfg.GitHubCodespaces.DeleteOnRelease || !DeleteOnReleaseExplicit(cfg, "github-codespaces") { + t.Fatalf("trusted retention policy not applied: %#v", cfg.GitHubCodespaces) + } + + untrustedDelete := true + if err := applyFileConfigWithTrust(&cfg, fileConfig{GitHubCodespaces: &fileGitHubCodespacesConfig{ + DeleteOnRelease: &untrustedDelete, + }}, false); err != nil { + t.Fatal(err) + } + if cfg.GitHubCodespaces.DeleteOnRelease || !DeleteOnReleaseExplicit(cfg, "github-codespaces") { + t.Fatalf("untrusted deletion policy replaced trusted retention: %#v", cfg.GitHubCodespaces) + } +} + func TestNvidiaBrevUntrustedConfigCannotRedirectCLI(t *testing.T) { cfg := baseConfig() cfg.NvidiaBrev.CLI = "/trusted/brev" diff --git a/internal/cli/provider_categories_generated.go b/internal/cli/provider_categories_generated.go index f838d2ffb..fac61bbd0 100644 --- a/internal/cli/provider_categories_generated.go +++ b/internal/cli/provider_categories_generated.go @@ -31,6 +31,7 @@ var benchmarkProviderCategories = map[string]string{ "firecracker": "self-hosted-virtualization", "freestyle": "delegated-sandbox", "gcp": "brokerable-cloud", + "github-codespaces": "direct-cloud", "hetzner": "brokerable-cloud", "hostinger": "direct-cloud", "hyperv": "local-vm", diff --git a/internal/providers/all/all.go b/internal/providers/all/all.go index 348920ab1..2a9ea4fa9 100644 --- a/internal/providers/all/all.go +++ b/internal/providers/all/all.go @@ -29,6 +29,7 @@ import ( _ "github.com/openclaw/crabbox/internal/providers/firecracker" _ "github.com/openclaw/crabbox/internal/providers/freestyle" _ "github.com/openclaw/crabbox/internal/providers/gcp" + _ "github.com/openclaw/crabbox/internal/providers/githubcodespaces" _ "github.com/openclaw/crabbox/internal/providers/hetzner" _ "github.com/openclaw/crabbox/internal/providers/hostinger" _ "github.com/openclaw/crabbox/internal/providers/hyperv" diff --git a/internal/providers/all/all_test.go b/internal/providers/all/all_test.go index e1d699df4..fd85a3ffd 100644 --- a/internal/providers/all/all_test.go +++ b/internal/providers/all/all_test.go @@ -457,6 +457,30 @@ func TestCodeSandboxRegistersCanonicalAndAliases(t *testing.T) { } } +func TestGitHubCodespacesRegistersCanonicalAndAliases(t *testing.T) { + for _, name := range []string{"github-codespaces", "codespaces", "gh-codespaces"} { + provider, err := core.ProviderFor(name) + if err != nil { + t.Fatalf("ProviderFor(%q): %v", name, err) + } + if provider.Name() != "github-codespaces" { + t.Fatalf("ProviderFor(%q).Name=%q want github-codespaces", name, provider.Name()) + } + } + spec := mustProvider(t, "github-codespaces").Spec() + if spec.Family != "github-codespaces" || spec.Kind != core.ProviderKindSSHLease || spec.Coordinator != core.CoordinatorNever { + t.Fatalf("github-codespaces spec=%#v", spec) + } + if len(spec.Targets) != 1 || spec.Targets[0].OS != core.TargetLinux { + t.Fatalf("github-codespaces targets=%#v", spec.Targets) + } + for _, feature := range []core.Feature{core.FeatureSSH, core.FeatureCrabboxSync, core.FeatureCleanup} { + if !spec.Features.Has(feature) { + t.Fatalf("github-codespaces features=%v missing %s", spec.Features, feature) + } + } +} + func TestIncusRegistersAsBuiltInProvider(t *testing.T) { provider, err := core.ProviderFor("incus") if err != nil { @@ -1225,6 +1249,7 @@ func allBuiltInProviderNames() []string { "firecracker", "freestyle", "gcp", + "github-codespaces", "hetzner", "hostinger", "hyperv", diff --git a/internal/providers/githubcodespaces/backend.go b/internal/providers/githubcodespaces/backend.go new file mode 100644 index 000000000..5a9c93c42 --- /dev/null +++ b/internal/providers/githubcodespaces/backend.go @@ -0,0 +1,883 @@ +package githubcodespaces + +import ( + "context" + "errors" + "fmt" + "io" + "net/url" + "os" + "path" + "strconv" + "strings" + "time" +) + +type codespacesAPI interface { + createCodespace(context.Context, createCodespaceRequest) (codespace, error) + listCodespaces(context.Context) ([]codespace, error) + getCodespace(context.Context, string) (codespace, error) + startCodespace(context.Context, string) (codespace, error) + stopCodespace(context.Context, string) error + deleteCodespace(context.Context, string) error + listMachines(context.Context, string, string) ([]codespaceMachine, error) +} + +type githubCLI interface { + authStatus(context.Context) error + authToken(context.Context) (string, error) + userLogin(context.Context) (string, error) + codespaceSSHConfig(context.Context, string) (string, error) +} + +type backend struct { + spec ProviderSpec + cfg Config + rt Runtime + clientFactory func(string) codespacesAPI + ghFactory func() githubCLI + waitSSH func(context.Context, *SSHTarget, string, time.Duration) error + now func() time.Time + pollInterval time.Duration + readyTimeout time.Duration +} + +const githubCodespacesRollbackTimeout = 30 * time.Second + +const ( + labelCodespaceName = "codespace_name" + labelEnvironmentID = "codespace_environment_id" + labelRepository = "github_repository" + labelRef = "github_ref" + labelMachine = "github_machine" + labelLogin = "github_login" + labelRelease = "release" + labelState = "state" + releaseDelete = "delete" + releaseStop = "stop" + defaultPollInterval = 3 * time.Second + defaultReadyTimeout = 10 * time.Minute +) + +func newBackend(spec ProviderSpec, cfg Config, rt Runtime) *backend { + b := &backend{spec: spec, cfg: cfg, rt: rt, pollInterval: defaultPollInterval, readyTimeout: defaultReadyTimeout} + b.clientFactory = func(token string) codespacesAPI { + return newClient(cfg.GitHubCodespaces, rt, token) + } + b.ghFactory = func() githubCLI { + return newGHRunner(cfg.GitHubCodespaces, rt) + } + b.waitSSH = func(ctx context.Context, target *SSHTarget, phase string, timeout time.Duration) error { + return waitForSSHReady(ctx, target, b.stderr(), phase, timeout) + } + b.now = func() time.Time { + if rt.Clock != nil { + return rt.Clock.Now() + } + return time.Now() + } + return b +} + +func (b *backend) Spec() ProviderSpec { return b.spec } + +func (b *backend) Acquire(ctx context.Context, req AcquireRequest) (LeaseTarget, error) { + gh, api, login, err := b.controlPlane(ctx) + if err != nil { + return LeaseTarget{}, err + } + repo, err := b.resolveRepo(req.Repo) + if err != nil { + return LeaseTarget{}, err + } + cfg := b.repoConfig(repo) + if _, err := api.listMachines(ctx, repo, b.cfg.GitHubCodespaces.Ref); err != nil { + return LeaseTarget{}, err + } + live, err := api.listCodespaces(ctx) + if err != nil { + return LeaseTarget{}, err + } + existing, err := b.serversFromCodespaces(live) + if err != nil { + return LeaseTarget{}, err + } + leaseID := strings.TrimSpace(req.RequestedLeaseID) + if leaseID == "" { + leaseID = newLeaseID() + } + slug, err := allocateDirectLeaseSlug(leaseID, req.RequestedSlug, existing) + if err != nil { + return LeaseTarget{}, err + } + release := releaseDelete + if !githubCodespacesDeleteOnRelease(LeaseTarget{}, cfg) { + release = releaseStop + } + created, err := api.createCodespace(ctx, createCodespaceRequest{ + Repo: repo, + Ref: strings.TrimSpace(b.cfg.GitHubCodespaces.Ref), + Machine: strings.TrimSpace(b.cfg.GitHubCodespaces.Machine), + DevcontainerPath: strings.TrimSpace(b.cfg.GitHubCodespaces.DevcontainerPath), + WorkingDirectory: strings.TrimSpace(b.cfg.GitHubCodespaces.WorkingDirectory), + Geo: strings.TrimSpace(b.cfg.GitHubCodespaces.Geo), + IdleTimeout: b.githubIdleTimeout(), + RetentionPeriod: b.cfg.GitHubCodespaces.RetentionPeriod, + DisplayName: githubCodespacesDisplayName(leaseID, slug), + }) + if err != nil { + return LeaseTarget{}, err + } + server := b.serverFromCodespace(created, b.labelsFor(leaseID, slug, repo, login, req.Keep, release, created, "provisioning")) + repoRoot, err := repoRootForClaim(req.Repo) + if err != nil { + if !req.Keep { + err = errors.Join(err, b.rollbackCreatedCodespace(api, leaseID, created.Name, false)) + } + return LeaseTarget{}, err + } + if err := claimLeaseTargetForRepoConfig(leaseID, slug, cfg, server, SSHTarget{}, repoRoot, cfg.IdleTimeout, req.Reclaim); err != nil { + if !req.Keep { + err = errors.Join(err, b.rollbackCreatedCodespace(api, leaseID, created.Name, false)) + } + return LeaseTarget{}, err + } + available, err := b.waitForAvailable(ctx, api, created.Name) + if err != nil { + if !req.Keep { + err = errors.Join(err, b.rollbackCreatedCodespace(api, leaseID, created.Name, true)) + } + return LeaseTarget{}, err + } + server = b.serverFromCodespace(available, b.labelsFor(leaseID, slug, repo, login, req.Keep, release, available, "ready")) + target, err := b.sshTarget(ctx, gh, leaseID, available.Name, repo, true) + if err != nil { + if !req.Keep { + err = errors.Join(err, b.rollbackCreatedCodespace(api, leaseID, available.Name, true)) + } + return LeaseTarget{}, b.sshPrerequisiteError(err) + } + if err := b.waitSSH(ctx, &target, "github-codespaces ssh", b.readyTimeout); err != nil { + if !req.Keep { + err = errors.Join(err, b.rollbackCreatedCodespace(api, leaseID, available.Name, true)) + } + return LeaseTarget{}, b.sshPrerequisiteError(err) + } + lease := LeaseTarget{Server: server, SSH: target, LeaseID: leaseID} + if req.OnAcquired != nil { + if err := req.OnAcquired(lease); err != nil { + if !req.Keep { + err = errors.Join(err, b.rollbackCreatedCodespace(api, leaseID, available.Name, true)) + } + return LeaseTarget{}, err + } + } + if err := updateLeaseClaimEndpoint(leaseID, server, target); err != nil { + if !req.Keep { + err = errors.Join(err, b.rollbackCreatedCodespace(api, leaseID, available.Name, true)) + } + return LeaseTarget{}, err + } + fmt.Fprintf(b.stderr(), "provisioned provider=github-codespaces lease=%s slug=%s codespace=%s repo=%s state=%s\n", leaseID, slug, available.Name, repo, available.State) + return lease, nil +} + +func (b *backend) rollbackCreatedCodespace(api codespacesAPI, leaseID, name string, claimed bool) error { + var expectedClaim LeaseClaim + if claimed { + claim, ok, err := readLeaseClaimWithPresence(leaseID) + if err != nil { + return err + } + if !ok || strings.TrimSpace(claim.CloudID) != name { + return exit(4, "refusing github-codespaces rollback for lease=%s codespace=%s without its exact claim", leaseID, name) + } + expectedClaim = claim + } + ctx, cancel := context.WithTimeout(context.Background(), githubCodespacesRollbackTimeout) + defer cancel() + if err := api.deleteCodespace(ctx, name); err != nil && !isGitHubNotFound(err) { + return fmt.Errorf("rollback github-codespaces codespace=%s lease=%s: %w", name, leaseID, err) + } + if claimed { + return removeLeaseClaimIfUnchangedAfter(leaseID, expectedClaim, func() error { + return removeStoredSSHConfig(leaseID) + }) + } + return removeStoredSSHConfig(leaseID) +} + +func (b *backend) Resolve(ctx context.Context, req ResolveRequest) (LeaseTarget, error) { + gh, api, login, err := b.controlPlane(ctx) + if err != nil { + return LeaseTarget{}, err + } + live, err := api.listCodespaces(ctx) + if err != nil { + return LeaseTarget{}, err + } + server, leaseID, err := b.resolveServer(live, req.ID) + if err != nil { + return LeaseTarget{}, err + } + if server.CloudID == "" { + return LeaseTarget{}, exit(4, "github-codespaces lease not found: %s", req.ID) + } + claim, claimOK, err := readLeaseClaimWithPresence(leaseID) + if err != nil { + return LeaseTarget{}, err + } + if claimOK { + if err := b.validateClaimForServer(claim, server, login); err != nil { + return LeaseTarget{}, err + } + } + item, err := api.getCodespace(ctx, server.CloudID) + if err != nil { + if req.ReleaseOnly && isGitHubNotFound(err) && claimOK { + server.Status = "deleted" + server.Labels[labelState] = "deleted" + return LeaseTarget{Server: server, LeaseID: leaseID}, nil + } + return LeaseTarget{}, err + } + server = b.mergeLiveServer(server, item) + if req.ReleaseOnly || (req.StatusOnly && !req.ReadyProbe) { + return LeaseTarget{Server: server, LeaseID: leaseID}, nil + } + if codespaceStopped(item.State) { + item, err = api.startCodespace(ctx, item.Name) + if err != nil { + return LeaseTarget{}, err + } + item, err = b.waitForAvailable(ctx, api, item.Name) + if err != nil { + return LeaseTarget{}, err + } + server = b.mergeLiveServer(server, item) + } + if codespaceTerminal(item.State) { + return LeaseTarget{}, exit(5, "github-codespaces codespace %s entered terminal state=%s", item.Name, item.State) + } + repo := firstNonEmpty(server.Labels[labelRepository], item.Repository.FullName) + cfg := b.repoConfig(repo) + if server.Labels == nil { + server.Labels = map[string]string{} + } + server.Labels["work_root"] = cfg.WorkRoot + server.Labels = touchDirectLeaseLabels(server.Labels, cfg, "ready", b.now().UTC()) + target, err := b.sshTarget(ctx, gh, leaseID, item.Name, repo, !req.NoLocalStateMutations) + if err != nil { + return LeaseTarget{}, b.sshPrerequisiteError(err) + } + if req.ReadyProbe { + if err := b.waitSSH(ctx, &target, "github-codespaces ssh", b.readyTimeout); err != nil { + return LeaseTarget{}, b.sshPrerequisiteError(err) + } + } + if !req.NoLocalStateMutations { + if err := updateLeaseClaimEndpoint(leaseID, server, target); err != nil { + return LeaseTarget{}, err + } + } + return LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, nil +} + +func (b *backend) List(ctx context.Context, _ ListRequest) ([]LeaseView, error) { + _, api, _, err := b.controlPlane(ctx) + if err != nil { + return nil, err + } + live, err := api.listCodespaces(ctx) + if err != nil { + return nil, err + } + return b.serversFromCodespaces(live) +} + +func (b *backend) Touch(ctx context.Context, req TouchRequest) (Server, error) { + server := req.Lease.Server + if server.Labels == nil { + server.Labels = map[string]string{} + } + server.Labels = touchDirectLeaseLabels(server.Labels, b.cfg, req.State, b.now().UTC()) + if server.Labels[labelCodespaceName] == "" && server.CloudID != "" { + server.Labels[labelCodespaceName] = server.CloudID + } + if req.Lease.LeaseID != "" { + if err := updateLeaseClaimEndpoint(req.Lease.LeaseID, server, req.Lease.SSH); err != nil { + return Server{}, err + } + } + return server, nil +} + +func (b *backend) ReleaseLease(ctx context.Context, req ReleaseLeaseRequest) error { + _, api, login, err := b.controlPlane(ctx) + if err != nil { + return err + } + leaseID := strings.TrimSpace(req.Lease.LeaseID) + if leaseID == "" { + return exit(2, "github-codespaces release requires a lease id") + } + server := req.Lease.Server + claim, claimOK, err := readLeaseClaimWithPresence(leaseID) + if err != nil { + return err + } + if !claimOK { + return exit(2, "github-codespaces release requires a local claim for lease %s", leaseID) + } + if server.CloudID == "" { + server = serverFromClaim(claim) + } + if err := b.validateClaimForServer(claim, server, login); err != nil { + return err + } + name := firstNonEmpty(server.CloudID, server.Name, server.Labels[labelCodespaceName]) + if name == "" { + return exit(2, "github-codespaces release requires a claim-backed codespace name") + } + if githubCodespacesDeleteOnRelease(req.Lease, b.cfg) { + item, err := api.getCodespace(ctx, name) + if err != nil && !isGitHubNotFound(err) { + return err + } + if err == nil { + if err := validateDeleteSafe(item); err != nil { + return b.stopCodespaceAndRetain(ctx, api, leaseID, claim, server, name) + } + } + err = api.deleteCodespace(ctx, name) + if err != nil && !isGitHubNotFound(err) { + return err + } + if err := removeLeaseClaimIfUnchanged(leaseID, claim); err != nil { + return err + } + return removeStoredSSHConfig(leaseID) + } + return b.stopCodespaceAndRetain(ctx, api, leaseID, claim, server, name) +} + +func (b *backend) stopCodespaceAndRetain(ctx context.Context, api codespacesAPI, leaseID string, claim LeaseClaim, server Server, name string) error { + server.Provider = providerName + server.CloudID = name + server.Name = name + server.Status = "stopped" + if server.Labels == nil { + server.Labels = map[string]string{} + } + // Core treats "stopped" as an inactive claim state, so an empty SSHTarget clears stale endpoints. + server.Labels[labelState] = "stopped" + server.Labels[labelRelease] = releaseStop + server.Labels[labelCodespaceName] = name + _, err := updateLeaseClaimEndpointIfUnchangedAfter(leaseID, claim, server, SSHTarget{}, func() error { + return api.stopCodespace(ctx, name) + }) + return err +} + +func (b *backend) ReleaseLeaseMessage(lease LeaseTarget) string { + if githubCodespacesClaimRelease(lease.LeaseID) == releaseStop { + return fmt.Sprintf("stopped github-codespaces lease=%s codespace=%s retained=true", lease.LeaseID, firstNonEmpty(lease.Server.CloudID, lease.Server.Name)) + } + if githubCodespacesDeleteOnRelease(lease, b.cfg) { + return fmt.Sprintf("deleted github-codespaces lease=%s codespace=%s", lease.LeaseID, firstNonEmpty(lease.Server.CloudID, lease.Server.Name)) + } + return fmt.Sprintf("stopped github-codespaces lease=%s codespace=%s retained=true", lease.LeaseID, firstNonEmpty(lease.Server.CloudID, lease.Server.Name)) +} + +func (b *backend) RetainLeaseClaimAfterRelease(lease LeaseTarget) bool { + switch githubCodespacesClaimRelease(lease.LeaseID) { + case releaseStop: + return true + case releaseDelete: + return false + } + return !githubCodespacesDeleteOnRelease(lease, b.cfg) +} + +func githubCodespacesClaimRelease(leaseID string) string { + claim, ok, err := readLeaseClaimWithPresence(strings.TrimSpace(leaseID)) + if err != nil || !ok { + return "" + } + return strings.ToLower(strings.TrimSpace(claim.Labels[labelRelease])) +} + +func (b *backend) Cleanup(ctx context.Context, req CleanupRequest) error { + _, api, login, err := b.controlPlane(ctx) + if err != nil { + return err + } + live, err := api.listCodespaces(ctx) + if err != nil { + return err + } + servers, err := b.serversFromCodespaces(live) + if err != nil { + return err + } + now := b.now().UTC() + for _, server := range servers { + shouldDelete, reason := shouldCleanupServer(server, now) + if !shouldDelete { + fmt.Fprintf(b.stderr(), "skip codespace=%s reason=%s\n", server.DisplayID(), reason) + continue + } + claim, ok, err := readLeaseClaimWithPresence(server.Labels["lease"]) + if err != nil { + return err + } + if !ok { + return exit(3, "refusing to cleanup github-codespaces codespace=%s without local claim", server.DisplayID()) + } + if err := b.validateClaimForServer(claim, server, login); err != nil { + return err + } + fmt.Fprintf(b.stderr(), "delete codespace=%s lease=%s dry_run=%t\n", server.DisplayID(), claim.LeaseID, req.DryRun) + if req.DryRun { + continue + } + item, err := api.getCodespace(ctx, server.CloudID) + if err != nil && !isGitHubNotFound(err) { + return err + } + if err == nil { + if err := validateDeleteSafe(item); err != nil { + return err + } + } + if err := api.deleteCodespace(ctx, server.CloudID); err != nil && !isGitHubNotFound(err) { + return err + } + if err := removeLeaseClaimIfUnchanged(claim.LeaseID, claim); err != nil { + return err + } + if err := removeStoredSSHConfig(claim.LeaseID); err != nil { + return err + } + } + return nil +} + +func (b *backend) Doctor(ctx context.Context, _ DoctorRequest) (DoctorResult, error) { + _, api, _, err := b.controlPlane(ctx) + checks := []DoctorCheck{} + if err != nil { + return DoctorResult{ + Provider: providerName, + Status: "failed", + Message: "auth=failed control_plane=unchecked inventory=unchecked mutation=false", + Checks: append(checks, DoctorCheck{Status: "failed", Check: "auth", Message: err.Error()}), + }, err + } + repo, repoErr := b.resolveRepo(Repo{}) + if repoErr == nil { + if _, err := api.listMachines(ctx, repo, b.cfg.GitHubCodespaces.Ref); err != nil { + checks = append(checks, DoctorCheck{Status: "failed", Check: "machines", Message: err.Error(), Details: map[string]string{"repo": repo}}) + return DoctorResult{Provider: providerName, Status: "failed", Message: "auth=ready control_plane=failed inventory=unchecked mutation=false", Checks: checks}, err + } + checks = append(checks, DoctorCheck{Status: "ok", Check: "machines", Details: map[string]string{"repo": repo}}) + } else { + checks = append(checks, DoctorCheck{Status: "warning", Check: "repo", Message: repoErr.Error()}) + } + live, err := api.listCodespaces(ctx) + if err != nil { + checks = append(checks, DoctorCheck{Status: "failed", Check: "inventory", Message: err.Error()}) + return DoctorResult{Provider: providerName, Status: "failed", Message: "auth=ready control_plane=ready inventory=failed mutation=false", Checks: checks}, err + } + leases := 0 + servers, err := b.serversFromCodespaces(live) + if err == nil { + leases = len(servers) + } + checks = append(checks, DoctorCheck{Status: "ok", Check: "inventory", Details: map[string]string{"leases": strconv.Itoa(leases)}}) + return DoctorResult{Provider: providerName, Message: fmt.Sprintf("auth=ready control_plane=ready inventory=ready api=list mutation=false leases=%d runtime=unchecked", leases), Checks: checks}, nil +} + +func (b *backend) controlPlane(ctx context.Context) (githubCLI, codespacesAPI, string, error) { + gh := b.ghFactory() + if err := gh.authStatus(ctx); err != nil { + return nil, nil, "", err + } + login, err := gh.userLogin(ctx) + if err != nil { + return nil, nil, "", err + } + token := strings.TrimSpace(os.Getenv("GH_TOKEN")) + if token == "" { + token = strings.TrimSpace(os.Getenv("GITHUB_TOKEN")) + } + if token == "" { + token, err = gh.authToken(ctx) + if err != nil { + return nil, nil, "", err + } + } + return gh, b.clientFactory(token), login, nil +} + +func (b *backend) waitForAvailable(ctx context.Context, api codespacesAPI, name string) (codespace, error) { + waitCtx := ctx + cancel := func() {} + if b.readyTimeout > 0 { + waitCtx, cancel = context.WithTimeout(ctx, b.readyTimeout) + } + defer cancel() + + for { + item, err := api.getCodespace(waitCtx, name) + if err != nil { + return codespace{}, err + } + if codespaceAvailable(item.State) { + return item, nil + } + if codespaceTerminal(item.State) { + return codespace{}, exit(5, "github-codespaces codespace %s entered terminal state=%s", name, item.State) + } + select { + case <-waitCtx.Done(): + return codespace{}, waitCtx.Err() + case <-time.After(b.pollInterval): + } + } +} + +func (b *backend) sshTarget(ctx context.Context, gh githubCLI, leaseID, codespaceName, repo string, store bool) (SSHTarget, error) { + data, err := gh.codespaceSSHConfig(ctx, codespaceName) + if err != nil { + return SSHTarget{}, err + } + if store { + if _, err := storeSSHConfig(leaseID, data); err != nil { + return SSHTarget{}, err + } + } + cfg := b.cfg + cfg = b.repoConfig(repo) + return selectSSHTarget(cfg, data, codespaceName) +} + +func (b *backend) sshPrerequisiteError(err error) error { + if err == nil { + return nil + } + return fmt.Errorf("%w; github-codespaces requires an SSH server in the devcontainer image (for example ghcr.io/devcontainers/features/sshd:1) and git, rsync, and tar in the codespace", err) +} + +func (b *backend) resolveRepo(repo Repo) (string, error) { + if configured := strings.TrimSpace(b.cfg.GitHubCodespaces.Repo); configured != "" { + if !validRepo(configured) { + return "", exit(2, "github-codespaces repo must be owner/name") + } + return configured, nil + } + if parsed := repoFromRemote(repo.RemoteURL); parsed != "" { + return parsed, nil + } + return "", exit(2, "github-codespaces repo is required; set githubCodespaces.repo or --github-codespaces-repo") +} + +func (b *backend) githubIdleTimeout() time.Duration { + if b.cfg.GitHubCodespaces.IdleTimeout > 0 { + return b.cfg.GitHubCodespaces.IdleTimeout + } + if b.cfg.IdleTimeout > 0 { + return b.cfg.IdleTimeout + } + return time.Duration(defaultIdleTimeoutMinutes) * time.Minute +} + +func (b *backend) effectiveWorkRoot(repo string) string { + workRoot := strings.TrimSpace(b.cfg.GitHubCodespaces.WorkRoot) + if workRootExplicit(&b.cfg) && strings.TrimSpace(b.cfg.WorkRoot) != "" && (workRoot == "" || workRoot == defaultWorkRoot) { + return strings.TrimSpace(b.cfg.WorkRoot) + } + repoName := repoName(repo) + if workRoot == "" { + if repoName != "" { + return "/workspaces/" + repoName + } + return defaultWorkRoot + } + if workRoot == defaultWorkRoot && repoName != "" && repoName != "crabbox" { + return "/workspaces/" + repoName + } + return workRoot +} + +func (b *backend) repoConfig(repo string) Config { + cfg := b.cfg + cfg.GitHubCodespaces.WorkRoot = b.effectiveWorkRoot(repo) + cfg.WorkRoot = cfg.GitHubCodespaces.WorkRoot + return cfg +} + +func githubCodespacesDisplayName(leaseID, slug string) string { + const maxDisplayNameLength = 48 + name := leaseProviderName(leaseID, slug) + if len(name) <= maxDisplayNameLength { + return name + } + const prefix = "crabbox-" + if !strings.HasPrefix(name, prefix) || len(name) <= len(prefix)+9 { + return name[:maxDisplayNameLength] + } + suffix := name[len(name)-9:] + slug = strings.Trim(name[len(prefix):len(name)-len(suffix)], "-") + maxSlug := maxDisplayNameLength - len(prefix) - len(suffix) + if maxSlug <= 0 { + return (prefix + suffix[1:])[:maxDisplayNameLength] + } + if len(slug) > maxSlug { + slug = strings.Trim(slug[:maxSlug], "-") + } + if slug == "" { + slug = "lease" + } + return prefix + slug + suffix +} + +func (b *backend) labelsFor(leaseID, slug, repo, login string, keep bool, release string, item codespace, state string) map[string]string { + cfg := b.repoConfig(repo) + labels := directLeaseLabels(cfg, leaseID, slug, providerName, "", keep, b.now().UTC()) + labels[labelState] = state + labels[labelRelease] = release + labels[labelCodespaceName] = item.Name + labels[labelEnvironmentID] = item.EnvironmentID + labels[labelRepository] = firstNonEmpty(item.Repository.FullName, repo) + labels[labelRef] = strings.TrimSpace(b.cfg.GitHubCodespaces.Ref) + labels[labelMachine] = firstNonEmpty(item.Machine.Name, b.cfg.GitHubCodespaces.Machine) + labels[labelLogin] = strings.TrimSpace(login) + labels["work_root"] = cfg.WorkRoot + return labels +} + +func (b *backend) serverFromCodespace(item codespace, labels map[string]string) Server { + server := Server{ + CloudID: item.Name, + Provider: providerName, + Name: item.Name, + Status: item.State, + Labels: cloneLabels(labels), + } + server.ServerType.Name = firstNonEmpty(item.Machine.Name, b.cfg.GitHubCodespaces.Machine) + return server +} + +func (b *backend) serversFromCodespaces(items []codespace) ([]LeaseView, error) { + claims, err := listLeaseClaims() + if err != nil { + return nil, err + } + byName := map[string]LeaseClaim{} + for _, claim := range claims { + if claim.Provider != providerName { + continue + } + name := firstNonEmpty(claim.CloudID, claim.Labels[labelCodespaceName]) + if name != "" { + byName[name] = claim + } + } + servers := make([]LeaseView, 0, len(items)) + for _, item := range items { + claim, ok := byName[item.Name] + if !ok { + continue + } + server := b.serverFromCodespace(item, cloneLabels(claim.Labels)) + server.Labels[labelCodespaceName] = item.Name + server.Labels[labelEnvironmentID] = firstNonEmpty(item.EnvironmentID, server.Labels[labelEnvironmentID]) + server.Labels[labelRepository] = firstNonEmpty(item.Repository.FullName, server.Labels[labelRepository]) + server.Labels[labelMachine] = firstNonEmpty(item.Machine.Name, server.Labels[labelMachine]) + servers = append(servers, server) + } + return servers, nil +} + +func (b *backend) resolveServer(items []codespace, id string) (Server, string, error) { + servers, err := b.serversFromCodespaces(items) + if err != nil { + return Server{}, "", err + } + server, leaseID, err := findServerByAlias(servers, id) + if err != nil { + return Server{}, "", err + } + if leaseID != "" || server.CloudID != "" { + return server, leaseID, nil + } + claim, ok, err := resolveLeaseClaimForProvider(id, providerName) + if err != nil { + return Server{}, "", err + } + if ok { + name := firstNonEmpty(claim.CloudID, claim.Labels[labelCodespaceName]) + for _, item := range items { + if item.Name == name { + return b.serverFromCodespace(item, cloneLabels(claim.Labels)), claim.LeaseID, nil + } + } + if name != "" { + return serverFromClaim(claim), claim.LeaseID, nil + } + } + for _, item := range items { + if item.Name == id { + claim, ok, err := resolveLeaseClaimForProvider(item.Name, providerName) + if err != nil { + return Server{}, "", err + } + if !ok { + return Server{}, "", exit(3, "refusing unmanaged github-codespaces codespace=%s without local claim", item.Name) + } + return b.serverFromCodespace(item, cloneLabels(claim.Labels)), claim.LeaseID, nil + } + } + return Server{}, "", nil +} + +func (b *backend) mergeLiveServer(server Server, item codespace) Server { + server.CloudID = item.Name + server.Provider = providerName + server.Name = item.Name + server.Status = item.State + if server.Labels == nil { + server.Labels = map[string]string{} + } + server.Labels[labelCodespaceName] = item.Name + server.Labels[labelEnvironmentID] = firstNonEmpty(item.EnvironmentID, server.Labels[labelEnvironmentID]) + server.Labels[labelRepository] = firstNonEmpty(item.Repository.FullName, server.Labels[labelRepository]) + server.Labels[labelMachine] = firstNonEmpty(item.Machine.Name, server.Labels[labelMachine]) + server.ServerType.Name = firstNonEmpty(item.Machine.Name, server.ServerType.Name) + return server +} + +func (b *backend) validateClaimForServer(claim LeaseClaim, server Server, login string) error { + if claim.Provider != providerName { + return exit(4, "%q is claimed by provider %s", claim.LeaseID, claim.Provider) + } + if strings.TrimSpace(claim.CloudID) != "" && server.CloudID != "" && claim.CloudID != server.CloudID { + return exit(3, "github-codespaces claim cloud id mismatch: claim=%s live=%s", claim.CloudID, server.CloudID) + } + expectedName := strings.TrimSpace(claim.Labels[labelCodespaceName]) + if expectedName != "" && server.CloudID != "" && expectedName != server.CloudID { + return exit(3, "github-codespaces claim codespace mismatch: claim=%s live=%s", expectedName, server.CloudID) + } + if expectedLogin := strings.TrimSpace(claim.Labels[labelLogin]); expectedLogin != "" && login != "" && expectedLogin != login { + return exit(3, "github-codespaces login mismatch: current login %s does not match lease login %s", login, expectedLogin) + } + return nil +} + +func (b *backend) stderr() io.Writer { + if b.rt.Stderr != nil { + return b.rt.Stderr + } + return io.Discard +} + +func githubCodespacesDeleteOnRelease(lease LeaseTarget, cfg Config) bool { + if deleteOnReleaseExplicit(cfg) { + return cfg.GitHubCodespaces.DeleteOnRelease + } + if lease.Server.Labels != nil { + switch strings.ToLower(strings.TrimSpace(lease.Server.Labels[labelRelease])) { + case releaseDelete: + return true + case releaseStop: + return false + } + } + return cfg.GitHubCodespaces.DeleteOnRelease +} + +func codespaceAvailable(state string) bool { + return strings.EqualFold(strings.TrimSpace(state), "available") +} + +func codespaceStopped(state string) bool { + switch strings.ToLower(strings.TrimSpace(state)) { + case "shutdown", "shut_down", "stopped": + return true + default: + return false + } +} + +func codespaceTerminal(state string) bool { + switch strings.ToLower(strings.TrimSpace(state)) { + case "failed", "unavailable", "deleted": + return true + default: + return false + } +} + +func validateDeleteSafe(item codespace) error { + status := item.GitStatus + if status.HasUncommittedChanges || status.HasUnpushedChanges || status.Ahead > 0 { + return exit(3, "refusing to delete github-codespaces codespace=%s with uncommitted or unpushed changes", item.Name) + } + return nil +} + +func serverFromClaim(claim LeaseClaim) Server { + server := Server{ + CloudID: firstNonEmpty(claim.CloudID, claim.Labels[labelCodespaceName]), + Provider: providerName, + Name: firstNonEmpty(claim.CloudID, claim.Labels[labelCodespaceName]), + Status: claim.Labels[labelState], + Labels: cloneLabels(claim.Labels), + } + server.ServerType.Name = claim.Labels[labelMachine] + return server +} + +func repoFromRemote(remote string) string { + remote = strings.TrimSpace(remote) + if remote == "" { + return "" + } + if strings.HasPrefix(remote, "git@github.com:") { + return strings.TrimSuffix(strings.TrimPrefix(remote, "git@github.com:"), ".git") + } + parsed, err := url.Parse(remote) + if err == nil && strings.EqualFold(parsed.Host, "github.com") { + clean := strings.Trim(path.Clean(parsed.Path), "/") + return strings.TrimSuffix(clean, ".git") + } + return "" +} + +func repoName(repo string) string { + _, name, ok := strings.Cut(strings.TrimSpace(repo), "/") + if !ok { + return "" + } + return strings.TrimSuffix(name, ".git") +} + +func cloneLabels(labels map[string]string) map[string]string { + out := map[string]string{} + for key, value := range labels { + out[key] = value + } + return out +} + +func repoRootForClaim(repo Repo) (string, error) { + if strings.TrimSpace(repo.Root) != "" { + return repo.Root, nil + } + wd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("resolve github-codespaces claim working directory: %w", err) + } + return wd, nil +} diff --git a/internal/providers/githubcodespaces/backend_test.go b/internal/providers/githubcodespaces/backend_test.go new file mode 100644 index 000000000..2aa9ad59b --- /dev/null +++ b/internal/providers/githubcodespaces/backend_test.go @@ -0,0 +1,618 @@ +package githubcodespaces + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + core "github.com/openclaw/crabbox/internal/cli" +) + +func TestAcquireCreatesClaimGeneratesSSHConfigAndWaitsReady(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + fc := newFakeCodespacesClient() + fc.getSeq["cs-1"] = []codespace{ + fakeCodespace("cs-1", "Provisioning"), + fakeCodespace("cs-1", "Available"), + } + fg := &fakeGH{login: "alice", token: "ghp_this_token_value_is_redacted"} + b := newTestBackend(t, fc, fg) + + lease, err := b.Acquire(context.Background(), AcquireRequest{ + Repo: Repo{Root: t.TempDir(), Name: "my-app"}, + RequestedSlug: "green-box", + }) + if err != nil { + t.Fatal(err) + } + if lease.LeaseID == "" || lease.Server.CloudID != "cs-1" || lease.Server.Labels[labelCodespaceName] != "cs-1" { + t.Fatalf("lease=%#v", lease) + } + if lease.Server.Labels[labelRepository] != "example-org/my-app" || lease.Server.Labels[labelLogin] != "alice" || lease.Server.Labels[labelRelease] != releaseDelete { + t.Fatalf("labels=%#v", lease.Server.Labels) + } + if len(fc.creates) != 1 { + t.Fatalf("creates=%#v", fc.creates) + } + create := fc.creates[0] + if create.Repo != "example-org/my-app" || create.Ref != "main" || create.Machine != "standardLinux32gb" || + create.DevcontainerPath != ".devcontainer/devcontainer.json" || create.WorkingDirectory != "/workspaces/my-app" || + create.Geo != "UsWest" || !strings.HasPrefix(create.DisplayName, "crabbox-green-box-") { + t.Fatalf("create=%#v", create) + } + if len(b.waits) != 1 { + t.Fatalf("waits=%#v", b.waits) + } + wait := b.waits[0] + if wait.User != "vscode" || wait.Host != "cs.cs-1.main" || wait.Key != "/tmp/codespaces/key" || !wait.SSHConfigProxy { + t.Fatalf("wait target=%#v", wait) + } + if !strings.Contains(wait.ReadyCheck, "test -d '/workspaces/my-app'") { + t.Fatalf("ready check=%q", wait.ReadyCheck) + } + claim, ok, err := resolveLeaseClaimForProvider(lease.LeaseID, providerName) + if err != nil || !ok { + t.Fatalf("claim ok=%t err=%v", ok, err) + } + if claim.CloudID != "cs-1" || claim.SSHHost != "cs.cs-1.main" || claim.Labels[labelEnvironmentID] != "env-cs-1" || claim.Labels["work_root"] != "/workspaces/my-app" { + t.Fatalf("claim=%#v", claim) + } + if fg.configFor != "cs-1" { + t.Fatalf("ssh config generated for %q", fg.configFor) + } +} + +func TestAcquireKeepDoesNotOverrideDeleteOnReleasePolicy(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + fc := newFakeCodespacesClient() + fc.getSeq["cs-1"] = []codespace{ + fakeCodespace("cs-1", "Provisioning"), + fakeCodespace("cs-1", "Available"), + } + fg := &fakeGH{login: "alice", token: "ghp_this_token_value_is_redacted"} + b := newTestBackend(t, fc, fg) + + lease, err := b.Acquire(context.Background(), AcquireRequest{ + Repo: Repo{Root: t.TempDir(), Name: "my-app"}, + Keep: true, + RequestedSlug: "warm-box", + }) + if err != nil { + t.Fatal(err) + } + if lease.Server.Labels["keep"] != "true" || lease.Server.Labels[labelRelease] != releaseDelete { + t.Fatalf("labels=%#v", lease.Server.Labels) + } + claim, ok, err := resolveLeaseClaimForProvider(lease.LeaseID, providerName) + if err != nil || !ok { + t.Fatalf("claim ok=%t err=%v", ok, err) + } + if claim.Labels["keep"] != "true" || claim.Labels[labelRelease] != releaseDelete { + t.Fatalf("claim labels=%#v", claim.Labels) + } +} + +func TestAcquireRetainsClaimWhenRollbackDeleteFails(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + fc := newFakeCodespacesClient() + fc.getSeq["cs-1"] = []codespace{fakeCodespace("cs-1", "Failed")} + fc.deleteErr = errors.New("delete temporarily unavailable") + fg := &fakeGH{login: "alice", token: "ghp_this_token_value_is_redacted"} + b := newTestBackend(t, fc, fg) + + _, err := b.Acquire(context.Background(), AcquireRequest{ + Repo: Repo{Root: t.TempDir(), Name: "my-app"}, + RequestedLeaseID: "cbx_123456789abc", + RequestedSlug: "rollback-box", + }) + if err == nil { + t.Fatal("acquire unexpectedly succeeded") + } + for _, want := range []string{"terminal state=Failed", "rollback github-codespaces", "delete temporarily unavailable", "cs-1"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("err=%q missing %q", err, want) + } + } + if _, ok, err := resolveLeaseClaimForProvider("cbx_123456789abc", providerName); err != nil || !ok { + t.Fatalf("recovery claim missing ok=%t err=%v", ok, err) + } + if !fc.deleteDeadline { + t.Fatal("rollback delete context had no deadline") + } +} + +func TestResolveStartsStoppedCodespaceAndRefreshesTarget(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + fc := newFakeCodespacesClient() + fc.items["cs-stopped"] = fakeCodespace("cs-stopped", "Shutdown") + fc.getSeq["cs-stopped"] = []codespace{ + fakeCodespace("cs-stopped", "Shutdown"), + fakeCodespace("cs-stopped", "Starting"), + fakeCodespace("cs-stopped", "Available"), + } + fg := &fakeGH{login: "alice", token: "ghp_this_token_value_is_redacted"} + b := newTestBackend(t, fc, fg) + leaseID := "cbx_123456789abc" + server := b.serverFromCodespace(fc.items["cs-stopped"], b.labelsFor(leaseID, "sleepy-box", "example-org/my-app", "alice", true, releaseStop, fc.items["cs-stopped"], "stopped")) + if err := claimLeaseTargetForRepoConfig(leaseID, "sleepy-box", b.cfg, server, SSHTarget{}, t.TempDir(), time.Hour, false); err != nil { + t.Fatal(err) + } + + lease, err := b.Resolve(context.Background(), ResolveRequest{ID: "sleepy-box", ReadyProbe: true}) + if err != nil { + t.Fatal(err) + } + if len(fc.starts) != 1 || fc.starts[0] != "cs-stopped" { + t.Fatalf("starts=%#v", fc.starts) + } + if lease.Server.Status != "Available" || lease.SSH.Host != "cs.cs-stopped.main" { + t.Fatalf("lease=%#v", lease) + } + claim, ok, err := resolveLeaseClaimForProvider(leaseID, providerName) + if err != nil || !ok { + t.Fatalf("claim ok=%t err=%v", ok, err) + } + if claim.Labels[labelState] != "ready" || claim.SSHHost != "cs.cs-stopped.main" { + t.Fatalf("claim=%#v", claim) + } +} + +func TestResolveNoLocalStateMutationsDoesNotStoreSSHConfig(t *testing.T) { + stateHome := t.TempDir() + t.Setenv("XDG_STATE_HOME", stateHome) + fc := newFakeCodespacesClient() + fc.items["cs-readonly"] = fakeCodespace("cs-readonly", "Available") + fg := &fakeGH{login: "alice", token: "ghp_this_token_value_is_redacted"} + b := newTestBackend(t, fc, fg) + leaseID := "cbx_123456789ac3" + server := b.serverFromCodespace(fc.items["cs-readonly"], b.labelsFor(leaseID, "readonly-box", "example-org/my-app", "alice", true, releaseStop, fc.items["cs-readonly"], "ready")) + if err := claimLeaseTargetForRepoConfig(leaseID, "readonly-box", b.cfg, server, SSHTarget{}, t.TempDir(), time.Hour, false); err != nil { + t.Fatal(err) + } + + lease, err := b.Resolve(context.Background(), ResolveRequest{ID: "readonly-box", NoLocalStateMutations: true}) + if err != nil { + t.Fatal(err) + } + if lease.SSH.Host != "cs.cs-readonly.main" { + t.Fatalf("lease=%#v", lease) + } + stored := filepath.Join(stateHome, "crabbox", "github-codespaces", leaseID+".ssh_config") + if _, err := os.Stat(stored); !os.IsNotExist(err) { + t.Fatalf("stored config err=%v path=%s", err, stored) + } +} + +func TestResolveStatusOnlyReadyProbeBuildsSSHTarget(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + fc := newFakeCodespacesClient() + fc.items["cs-status"] = fakeCodespace("cs-status", "Available") + fg := &fakeGH{login: "alice", token: "ghp_this_token_value_is_redacted"} + b := newTestBackend(t, fc, fg) + leaseID := "cbx_123456789ac4" + server := b.serverFromCodespace(fc.items["cs-status"], b.labelsFor(leaseID, "status-box", "example-org/my-app", "alice", false, releaseDelete, fc.items["cs-status"], "ready")) + if err := claimLeaseTargetForRepoConfig(leaseID, "status-box", b.cfg, server, SSHTarget{}, t.TempDir(), time.Hour, false); err != nil { + t.Fatal(err) + } + + lease, err := b.Resolve(context.Background(), ResolveRequest{ID: "status-box", StatusOnly: true, ReadyProbe: true}) + if err != nil { + t.Fatal(err) + } + if lease.SSH.Host != "cs.cs-status.main" || len(b.waits) != 1 { + t.Fatalf("lease=%#v waits=%#v", lease, b.waits) + } +} + +func TestReleaseDeleteRemovesOnlyClaimBackedCodespaceAndConfig(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + fc := newFakeCodespacesClient() + fc.items["cs-delete"] = fakeCodespace("cs-delete", "Available") + fg := &fakeGH{login: "alice", token: "ghp_this_token_value_is_redacted"} + b := newTestBackend(t, fc, fg) + leaseID := "cbx_123456789abd" + server := b.serverFromCodespace(fc.items["cs-delete"], b.labelsFor(leaseID, "delete-box", "example-org/my-app", "alice", false, releaseDelete, fc.items["cs-delete"], "ready")) + if err := claimLeaseTargetForRepoConfig(leaseID, "delete-box", b.cfg, server, SSHTarget{Host: "cs-delete", Port: "22"}, t.TempDir(), time.Hour, false); err != nil { + t.Fatal(err) + } + if _, err := storeSSHConfig(leaseID, fg.config("cs-delete")); err != nil { + t.Fatal(err) + } + + if err := b.ReleaseLease(context.Background(), ReleaseLeaseRequest{Lease: LeaseTarget{LeaseID: leaseID, Server: server}}); err != nil { + t.Fatal(err) + } + if strings.Join(fc.deletes, ",") != "cs-delete" { + t.Fatalf("deletes=%#v", fc.deletes) + } + if _, ok, err := resolveLeaseClaimForProvider(leaseID, providerName); err != nil || ok { + t.Fatalf("claim remains ok=%t err=%v", ok, err) + } +} + +func TestReleaseDeleteRequiresLocalClaim(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + fc := newFakeCodespacesClient() + fc.items["cs-orphan"] = fakeCodespace("cs-orphan", "Available") + fg := &fakeGH{login: "alice", token: "ghp_this_token_value_is_redacted"} + b := newTestBackend(t, fc, fg) + server := b.serverFromCodespace(fc.items["cs-orphan"], b.labelsFor("cbx_123456789ad0", "orphan-box", "example-org/my-app", "alice", false, releaseDelete, fc.items["cs-orphan"], "ready")) + + err := b.ReleaseLease(context.Background(), ReleaseLeaseRequest{Lease: LeaseTarget{LeaseID: "cbx_123456789ad0", Server: server}}) + if err == nil || !strings.Contains(err.Error(), "requires a local claim") { + t.Fatalf("err=%v", err) + } + if len(fc.deletes) != 0 || len(fc.stops) != 0 { + t.Fatalf("provider action without claim deletes=%#v stops=%#v", fc.deletes, fc.stops) + } +} + +func TestReleaseDeleteFallsBackToStopForDirtyCodespace(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + fc := newFakeCodespacesClient() + item := fakeCodespace("cs-dirty", "Available") + item.GitStatus.HasUncommittedChanges = true + fc.items["cs-dirty"] = item + fg := &fakeGH{login: "alice", token: "ghp_this_token_value_is_redacted"} + b := newTestBackend(t, fc, fg) + leaseID := "cbx_123456789ac2" + server := b.serverFromCodespace(item, b.labelsFor(leaseID, "dirty-box", "example-org/my-app", "alice", false, releaseDelete, item, "ready")) + if err := claimLeaseTargetForRepoConfig(leaseID, "dirty-box", b.cfg, server, SSHTarget{Host: "cs-dirty", Port: "22"}, t.TempDir(), time.Hour, false); err != nil { + t.Fatal(err) + } + + if err := b.ReleaseLease(context.Background(), ReleaseLeaseRequest{Lease: LeaseTarget{LeaseID: leaseID, Server: server}}); err != nil { + t.Fatal(err) + } + if strings.Join(fc.stops, ",") != "cs-dirty" || len(fc.deletes) != 0 { + t.Fatalf("stops=%#v deletes=%#v", fc.stops, fc.deletes) + } + claim, ok, err := resolveLeaseClaimForProvider(leaseID, providerName) + if err != nil || !ok { + t.Fatalf("claim ok=%t err=%v", ok, err) + } + if claim.SSHHost != "" || claim.SSHPort != 0 || claim.Labels[labelRelease] != releaseStop || claim.Labels[labelState] != "stopped" { + t.Fatalf("claim=%#v", claim) + } + if !b.RetainLeaseClaimAfterRelease(LeaseTarget{LeaseID: leaseID, Server: server}) { + t.Fatal("dirty release fallback should retain local claim") + } + if got := b.ReleaseLeaseMessage(LeaseTarget{LeaseID: leaseID, Server: server}); !strings.Contains(got, "retained=true") { + t.Fatalf("message=%q", got) + } +} + +func TestReleaseRetainedStopsAndClearsEndpoint(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + fc := newFakeCodespacesClient() + fc.items["cs-stop"] = fakeCodespace("cs-stop", "Available") + fg := &fakeGH{login: "alice", token: "ghp_this_token_value_is_redacted"} + b := newTestBackend(t, fc, fg) + b.cfg.GitHubCodespaces.DeleteOnRelease = false + leaseID := "cbx_123456789abe" + server := b.serverFromCodespace(fc.items["cs-stop"], b.labelsFor(leaseID, "stop-box", "example-org/my-app", "alice", true, releaseStop, fc.items["cs-stop"], "ready")) + if err := claimLeaseTargetForRepoConfig(leaseID, "stop-box", b.cfg, server, SSHTarget{Host: "cs-stop", Port: "22"}, t.TempDir(), time.Hour, false); err != nil { + t.Fatal(err) + } + + if err := b.ReleaseLease(context.Background(), ReleaseLeaseRequest{Lease: LeaseTarget{LeaseID: leaseID, Server: server}}); err != nil { + t.Fatal(err) + } + if strings.Join(fc.stops, ",") != "cs-stop" || len(fc.deletes) != 0 { + t.Fatalf("stops=%#v deletes=%#v", fc.stops, fc.deletes) + } + claim, ok, err := resolveLeaseClaimForProvider(leaseID, providerName) + if err != nil || !ok { + t.Fatalf("claim ok=%t err=%v", ok, err) + } + if claim.SSHHost != "" || claim.SSHPort != 0 || claim.Labels[labelRelease] != releaseStop || claim.Labels[labelState] != "stopped" { + t.Fatalf("claim=%#v", claim) + } +} + +func TestCleanupDryRunKeepsProviderNonMutating(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + fc := newFakeCodespacesClient() + fc.items["cs-expired"] = fakeCodespace("cs-expired", "Available") + fc.items["cs-unclaimed"] = fakeCodespace("cs-unclaimed", "Available") + fg := &fakeGH{login: "alice", token: "ghp_this_token_value_is_redacted"} + b := newTestBackend(t, fc, fg) + leaseID := "cbx_123456789abf" + server := b.serverFromCodespace(fc.items["cs-expired"], b.labelsFor(leaseID, "expired-box", "example-org/my-app", "alice", false, releaseDelete, fc.items["cs-expired"], "ready")) + server.Labels["expires_at"] = time.Now().Add(-time.Hour).UTC().Format(time.RFC3339) + if err := claimLeaseTargetForRepoConfig(leaseID, "expired-box", b.cfg, server, SSHTarget{}, t.TempDir(), time.Hour, false); err != nil { + t.Fatal(err) + } + + if err := b.Cleanup(context.Background(), CleanupRequest{DryRun: true}); err != nil { + t.Fatal(err) + } + if len(fc.deletes) != 0 { + t.Fatalf("dry run deleted: %#v", fc.deletes) + } + if err := b.Cleanup(context.Background(), CleanupRequest{}); err != nil { + t.Fatal(err) + } + if strings.Join(fc.deletes, ",") != "cs-expired" { + t.Fatalf("deletes=%#v", fc.deletes) + } +} + +func TestCleanupRefusesIdentityMismatch(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + fc := newFakeCodespacesClient() + fc.items["cs-mismatch"] = fakeCodespace("cs-mismatch", "Available") + fg := &fakeGH{login: "bob", token: "ghp_this_token_value_is_redacted"} + b := newTestBackend(t, fc, fg) + leaseID := "cbx_123456789ac1" + server := b.serverFromCodespace(fc.items["cs-mismatch"], b.labelsFor(leaseID, "mismatch-box", "example-org/my-app", "alice", false, releaseDelete, fc.items["cs-mismatch"], "ready")) + server.Labels["expires_at"] = time.Now().Add(-time.Hour).UTC().Format(time.RFC3339) + if err := claimLeaseTargetForRepoConfig(leaseID, "mismatch-box", b.cfg, server, SSHTarget{}, t.TempDir(), time.Hour, false); err != nil { + t.Fatal(err) + } + err := b.Cleanup(context.Background(), CleanupRequest{}) + if err == nil || !strings.Contains(err.Error(), "login mismatch") { + t.Fatalf("err=%v", err) + } + if len(fc.deletes) != 0 { + t.Fatalf("deleted on mismatch: %#v", fc.deletes) + } +} + +func TestDoctorIsNonMutating(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + fc := newFakeCodespacesClient() + fg := &fakeGH{login: "alice", token: "ghp_this_token_value_is_redacted"} + b := newTestBackend(t, fc, fg) + + result, err := b.Doctor(context.Background(), DoctorRequest{}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(result.Message, "mutation=false") || !strings.Contains(result.Message, "inventory=ready") { + t.Fatalf("result=%#v", result) + } + if len(fc.creates) != 0 || len(fc.starts) != 0 || len(fc.stops) != 0 || len(fc.deletes) != 0 { + t.Fatalf("doctor mutated: creates=%#v starts=%#v stops=%#v deletes=%#v", fc.creates, fc.starts, fc.stops, fc.deletes) + } +} + +func TestControlPlanePrefersGitHubCLITokenPrecedence(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + t.Setenv("GH_TOKEN", "gh-token") + t.Setenv("GITHUB_TOKEN", "github-token") + fc := newFakeCodespacesClient() + fg := &fakeGH{login: "alice", token: "fallback-token"} + b := newTestBackend(t, fc, fg) + var gotToken string + b.clientFactory = func(token string) codespacesAPI { + gotToken = token + return fc + } + + _, _, login, err := b.controlPlane(context.Background()) + if err != nil { + t.Fatal(err) + } + if login != "alice" { + t.Fatalf("login=%q", login) + } + if gotToken != "gh-token" { + t.Fatalf("token=%q", gotToken) + } +} + +func TestWaitForAvailableUsesReadyTimeout(t *testing.T) { + t.Setenv("XDG_STATE_HOME", t.TempDir()) + fc := newFakeCodespacesClient() + fc.items["cs-slow"] = fakeCodespace("cs-slow", "Provisioning") + fg := &fakeGH{login: "alice", token: "ghp_this_token_value_is_redacted"} + b := newTestBackend(t, fc, fg) + b.readyTimeout = time.Nanosecond + b.pollInterval = time.Hour + + _, err := b.waitForAvailable(context.Background(), fc, "cs-slow") + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("err=%v", err) + } +} + +func TestEffectiveWorkRootHonorsExplicitGenericWorkRoot(t *testing.T) { + cfg := Config{ + Provider: providerName, + WorkRoot: "/custom/workspace", + GitHubCodespaces: GitHubCodespacesConfig{ + WorkRoot: defaultWorkRoot, + }, + } + core.MarkWorkRootExplicit(&cfg) + b := newBackend(Provider{}.Spec(), cfg, Runtime{}) + + if got := b.effectiveWorkRoot("example-org/my-app"); got != "/custom/workspace" { + t.Fatalf("work root=%q", got) + } +} + +func TestLabelsCarryEffectiveWorkRoot(t *testing.T) { + fc := newFakeCodespacesClient() + fg := &fakeGH{login: "alice", token: "ghp_this_token_value_is_redacted"} + b := newTestBackend(t, fc, fg) + + labels := b.labelsFor("cbx_123456789abc", "work-box", "example-org/my-app", "alice", false, releaseDelete, fakeCodespace("cs-1", "Available"), "ready") + if labels["work_root"] != "/workspaces/my-app" { + t.Fatalf("work_root=%q", labels["work_root"]) + } +} + +func TestDisplayNameFitsGitHubCodespacesLimit(t *testing.T) { + name := githubCodespacesDisplayName("cbx_abcdef123456", strings.Repeat("a", 41)) + if len(name) > 48 { + t.Fatalf("display name length=%d name=%q", len(name), name) + } + if !strings.HasPrefix(name, "crabbox-") || !strings.HasSuffix(name, "-c80c2195") { + t.Fatalf("display name=%q", name) + } +} + +type testBackend struct { + *backend + waits []SSHTarget +} + +func newTestBackend(t *testing.T, fc *fakeCodespacesClient, fg *fakeGH) *testBackend { + t.Helper() + cfg := Config{ + Provider: providerName, + TargetOS: targetLinux, + SSHUser: "vscode", + SSHPort: "22", + IdleTimeout: time.Hour, + GitHubCodespaces: GitHubCodespacesConfig{ + GHPath: "gh", + Repo: "example-org/my-app", + Ref: "main", + Machine: "standardLinux32gb", + DevcontainerPath: ".devcontainer/devcontainer.json", + WorkingDirectory: "/workspaces/my-app", + Geo: "UsWest", + IdleTimeout: 45 * time.Minute, + RetentionPeriod: 48 * time.Hour, + DeleteOnRelease: true, + WorkRoot: defaultWorkRoot, + }, + } + rt := Runtime{} + b := newBackend(Provider{}.Spec(), cfg, rt) + b.pollInterval = time.Nanosecond + tb := &testBackend{backend: b} + b.clientFactory = func(string) codespacesAPI { return fc } + b.ghFactory = func() githubCLI { return fg } + b.waitSSH = func(_ context.Context, target *SSHTarget, _ string, _ time.Duration) error { + tb.waits = append(tb.waits, *target) + return nil + } + return tb +} + +type fakeCodespacesClient struct { + items map[string]codespace + getSeq map[string][]codespace + creates []createCodespaceRequest + starts []string + stops []string + deletes []string + deleteErr error + deleteDeadline bool +} + +func newFakeCodespacesClient() *fakeCodespacesClient { + return &fakeCodespacesClient{ + items: map[string]codespace{}, + getSeq: map[string][]codespace{}, + } +} + +func (f *fakeCodespacesClient) createCodespace(_ context.Context, req createCodespaceRequest) (codespace, error) { + f.creates = append(f.creates, req) + name := fmt.Sprintf("cs-%d", len(f.creates)) + item := fakeCodespace(name, "Provisioning") + item.DisplayName = req.DisplayName + item.Repository.FullName = req.Repo + item.Machine.Name = req.Machine + f.items[name] = item + return item, nil +} + +func (f *fakeCodespacesClient) listCodespaces(context.Context) ([]codespace, error) { + out := make([]codespace, 0, len(f.items)) + for _, item := range f.items { + out = append(out, item) + } + return out, nil +} + +func (f *fakeCodespacesClient) getCodespace(_ context.Context, name string) (codespace, error) { + if seq := f.getSeq[name]; len(seq) > 0 { + item := seq[0] + f.getSeq[name] = seq[1:] + f.items[name] = item + return item, nil + } + item, ok := f.items[name] + if !ok { + return codespace{}, githubAPIError(404, "", `{"message":"Not Found"}`) + } + return item, nil +} + +func (f *fakeCodespacesClient) startCodespace(_ context.Context, name string) (codespace, error) { + f.starts = append(f.starts, name) + item := f.items[name] + item.State = "Starting" + f.items[name] = item + return item, nil +} + +func (f *fakeCodespacesClient) stopCodespace(_ context.Context, name string) error { + f.stops = append(f.stops, name) + item := f.items[name] + item.State = "Shutdown" + f.items[name] = item + return nil +} + +func (f *fakeCodespacesClient) deleteCodespace(ctx context.Context, name string) error { + _, f.deleteDeadline = ctx.Deadline() + f.deletes = append(f.deletes, name) + if f.deleteErr != nil { + return f.deleteErr + } + delete(f.items, name) + return nil +} + +func (f *fakeCodespacesClient) listMachines(context.Context, string, string) ([]codespaceMachine, error) { + return []codespaceMachine{{Name: "standardLinux32gb"}}, nil +} + +type fakeGH struct { + login string + token string + configFor string +} + +func (f *fakeGH) authStatus(context.Context) error { return nil } +func (f *fakeGH) authToken(context.Context) (string, error) { + return f.token, nil +} +func (f *fakeGH) userLogin(context.Context) (string, error) { + return f.login, nil +} +func (f *fakeGH) codespaceSSHConfig(_ context.Context, codespace string) (string, error) { + f.configFor = codespace + return f.config(codespace), nil +} +func (f *fakeGH) config(codespace string) string { + return fmt.Sprintf(`Host cs.%s.main + User vscode + IdentityFile "/tmp/codespaces/key" + UserKnownHostsFile /dev/null + ProxyCommand gh codespace ssh -c %s --stdio +`, codespace, codespace) +} + +func fakeCodespace(name, state string) codespace { + return codespace{ + Name: name, + DisplayName: "Crabbox", + State: state, + EnvironmentID: "env-" + name, + Repository: repositoryRef{FullName: "example-org/my-app"}, + Machine: machineRef{Name: "standardLinux32gb"}, + } +} diff --git a/internal/providers/githubcodespaces/client.go b/internal/providers/githubcodespaces/client.go new file mode 100644 index 000000000..185920c04 --- /dev/null +++ b/internal/providers/githubcodespaces/client.go @@ -0,0 +1,381 @@ +package githubcodespaces + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +type client struct { + httpClient *http.Client + baseURL string + token string +} + +type createCodespaceRequest struct { + Repo string + Ref string + Machine string + DevcontainerPath string + WorkingDirectory string + Geo string + IdleTimeout time.Duration + RetentionPeriod time.Duration + DisplayName string +} + +type codespace struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + State string `json:"state"` + EnvironmentID string `json:"environment_id"` + Repository repositoryRef `json:"repository"` + Machine machineRef `json:"machine"` + GitStatus gitStatus `json:"git_status"` +} + +type gitStatus struct { + Ahead int `json:"ahead"` + Behind int `json:"behind"` + HasUnpushedChanges bool `json:"has_unpushed_changes"` + HasUncommittedChanges bool `json:"has_uncommitted_changes"` + Ref string `json:"ref"` +} + +type codespaceMachine struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` +} + +type repositoryRef struct { + FullName string +} + +type machineRef struct { + Name string +} + +func (r *repositoryRef) UnmarshalJSON(data []byte) error { + var object struct { + FullName string `json:"full_name"` + } + if err := json.Unmarshal(data, &object); err == nil && object.FullName != "" { + r.FullName = object.FullName + return nil + } + var value string + if err := json.Unmarshal(data, &value); err != nil { + return err + } + r.FullName = value + return nil +} + +func (m *machineRef) UnmarshalJSON(data []byte) error { + var object struct { + Name string `json:"name"` + } + if err := json.Unmarshal(data, &object); err == nil && object.Name != "" { + m.Name = object.Name + return nil + } + var value string + if err := json.Unmarshal(data, &value); err != nil { + return err + } + m.Name = value + return nil +} + +func newClient(cfg GitHubCodespacesConfig, rt Runtime, token string) client { + httpClient := rt.HTTP + if httpClient == nil { + httpClient = http.DefaultClient + } + baseURL := strings.TrimRight(strings.TrimSpace(cfg.APIURL), "/") + if baseURL == "" { + baseURL = defaultAPIURL + } + return client{httpClient: httpClient, baseURL: baseURL, token: token} +} + +func (c client) createCodespace(ctx context.Context, req createCodespaceRequest) (codespace, error) { + owner, repo, ok := strings.Cut(strings.TrimSpace(req.Repo), "/") + if !ok || owner == "" || repo == "" { + return codespace{}, exit(2, "github-codespaces repo must be owner/name") + } + body := map[string]any{} + if req.Ref != "" { + body["ref"] = req.Ref + } + if req.Machine != "" { + body["machine"] = req.Machine + } + if req.DevcontainerPath != "" { + body["devcontainer_path"] = req.DevcontainerPath + } + if req.WorkingDirectory != "" { + body["working_directory"] = req.WorkingDirectory + } + if req.Geo != "" { + body["geo"] = req.Geo + } + if req.IdleTimeout > 0 { + body["idle_timeout_minutes"] = durationMinutesCeil(req.IdleTimeout) + } + if req.RetentionPeriod > 0 { + body["retention_period_minutes"] = durationMinutesCeil(req.RetentionPeriod) + } + if req.DisplayName != "" { + body["display_name"] = req.DisplayName + } + return c.doJSON(ctx, http.MethodPost, "/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(repo)+"/codespaces", body) +} + +func (c client) listCodespaces(ctx context.Context) ([]codespace, error) { + path := "/user/codespaces?per_page=100" + var all []codespace + for path != "" { + var out struct { + Codespaces []codespace `json:"codespaces"` + } + header, err := c.doWithHeader(ctx, http.MethodGet, path, nil, &out, nil) + if err != nil { + return nil, err + } + all = append(all, out.Codespaces...) + path, err = nextLinkPath(header.Get("Link"), c.baseURL) + if err != nil { + return nil, err + } + } + return all, nil +} + +func (c client) getCodespace(ctx context.Context, name string) (codespace, error) { + name = strings.TrimSpace(name) + if name == "" { + return codespace{}, exit(2, "github-codespaces codespace name is required") + } + var out codespace + if err := c.do(ctx, http.MethodGet, "/user/codespaces/"+url.PathEscape(name), nil, &out, nil); err != nil { + return codespace{}, err + } + return out, nil +} + +func (c client) startCodespace(ctx context.Context, name string) (codespace, error) { + name = strings.TrimSpace(name) + if name == "" { + return codespace{}, exit(2, "github-codespaces codespace name is required") + } + var out codespace + if err := c.do(ctx, http.MethodPost, "/user/codespaces/"+url.PathEscape(name)+"/start", nil, &out, map[int]bool{http.StatusNotModified: true}); err != nil { + return codespace{}, err + } + if out.Name == "" { + out.Name = name + } + return out, nil +} + +func (c client) stopCodespace(ctx context.Context, name string) error { + name = strings.TrimSpace(name) + if name == "" { + return exit(2, "github-codespaces codespace name is required") + } + return c.do(ctx, http.MethodPost, "/user/codespaces/"+url.PathEscape(name)+"/stop", nil, nil, nil) +} + +func (c client) deleteCodespace(ctx context.Context, name string) error { + name = strings.TrimSpace(name) + if name == "" { + return exit(2, "github-codespaces codespace name is required") + } + return c.do(ctx, http.MethodDelete, "/user/codespaces/"+url.PathEscape(name), nil, nil, map[int]bool{http.StatusNotModified: true}) +} + +func (c client) listMachines(ctx context.Context, repo, ref string) ([]codespaceMachine, error) { + owner, name, ok := strings.Cut(strings.TrimSpace(repo), "/") + if !ok || owner == "" || name == "" { + return nil, exit(2, "github-codespaces repo must be owner/name") + } + path := "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(name) + "/codespaces/machines" + if strings.TrimSpace(ref) != "" { + path += "?ref=" + url.QueryEscape(strings.TrimSpace(ref)) + } + var out struct { + Machines []codespaceMachine `json:"machines"` + } + if err := c.do(ctx, http.MethodGet, path, nil, &out, nil); err != nil { + return nil, err + } + return out.Machines, nil +} + +func (c client) doJSON(ctx context.Context, method, path string, body any) (codespace, error) { + var out codespace + if err := c.do(ctx, method, path, body, &out, nil); err != nil { + return codespace{}, err + } + return out, nil +} + +func (c client) do(ctx context.Context, method, path string, body any, out any, accepted map[int]bool) error { + _, err := c.doWithHeader(ctx, method, path, body, out, accepted) + return err +} + +func (c client) doWithHeader(ctx context.Context, method, path string, body any, out any, accepted map[int]bool) (http.Header, error) { + var reader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + reader = bytes.NewReader(data) + } + reqURL := path + if !strings.HasPrefix(reqURL, "http://") && !strings.HasPrefix(reqURL, "https://") { + reqURL = c.baseURL + path + } + httpReq, err := http.NewRequestWithContext(ctx, method, reqURL, reader) + if err != nil { + return nil, err + } + httpReq.Header.Set("Accept", "application/vnd.github+json") + httpReq.Header.Set("X-GitHub-Api-Version", "2022-11-28") + if body != nil { + httpReq.Header.Set("Content-Type", "application/json") + } + if strings.TrimSpace(c.token) != "" { + httpReq.Header.Set("Authorization", "Bearer "+c.token) + } + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if readErr != nil { + return nil, readErr + } + if accepted == nil { + accepted = map[int]bool{} + } + if accepted[resp.StatusCode] || (resp.StatusCode >= 200 && resp.StatusCode < 300) { + if out == nil || len(strings.TrimSpace(string(data))) == 0 { + return resp.Header, nil + } + return resp.Header, json.Unmarshal(data, out) + } + return nil, githubAPIError(resp.StatusCode, resp.Header.Get("Retry-After"), string(data)) +} + +func githubAPIError(status int, retryAfter, body string) error { + message := http.StatusText(status) + if strings.TrimSpace(body) != "" { + var parsed struct { + Message string `json:"message"` + } + if json.Unmarshal([]byte(body), &parsed) == nil && parsed.Message != "" { + message = parsed.Message + } + } + message = redactSecretText(message) + action := "check GitHub Codespaces access" + switch status { + case http.StatusUnauthorized, http.StatusForbidden: + action = "check gh auth or GH_TOKEN/GITHUB_TOKEN scopes; " + + "GitHub Codespaces requires the codespace scope (for gh, run gh auth refresh -h github.com -s codespace)" + case http.StatusNotFound: + action = "check repository and Codespaces availability" + case http.StatusConflict: + action = "wait for the pending Codespaces operation to finish" + case http.StatusUnprocessableEntity: + action = "check Codespaces repo/ref/machine/devcontainer settings" + case http.StatusServiceUnavailable: + action = "retry after GitHub service recovery" + } + if retryAfter != "" { + if seconds, err := strconv.Atoi(strings.TrimSpace(retryAfter)); err == nil { + return fmt.Errorf("github-codespaces API status=%d retry_after=%s: %s; %s", status, (time.Duration(seconds) * time.Second).String(), message, action) + } + return fmt.Errorf("github-codespaces API status=%d retry_after=%s: %s; %s", status, retryAfter, message, action) + } + return fmt.Errorf("github-codespaces API status=%d: %s; %s", status, message, action) +} + +func isGitHubNotFound(err error) bool { + return err != nil && strings.Contains(err.Error(), "github-codespaces API status=404") +} + +func nextLinkPath(linkHeader, baseURL string) (string, error) { + for _, part := range strings.Split(linkHeader, ",") { + sections := strings.Split(part, ";") + if len(sections) < 2 { + continue + } + if !strings.Contains(strings.Join(sections[1:], ";"), `rel="next"`) { + continue + } + raw := strings.TrimSpace(sections[0]) + raw = strings.TrimPrefix(raw, "<") + raw = strings.TrimSuffix(raw, ">") + if raw == "" { + return "", nil + } + parsed, err := url.Parse(raw) + if err != nil { + return raw, nil + } + if parsed.IsAbs() { + allowed, err := sameAPIBase(parsed, baseURL) + if err != nil { + return "", err + } + if !allowed { + return "", fmt.Errorf("github-codespaces pagination link outside configured API base: %s://%s%s", parsed.Scheme, parsed.Host, parsed.EscapedPath()) + } + return raw, nil + } + return raw, nil + } + return "", nil +} + +func sameAPIBase(next *url.URL, baseURL string) (bool, error) { + base, err := url.Parse(strings.TrimRight(strings.TrimSpace(baseURL), "/")) + if err != nil { + return false, err + } + if !strings.EqualFold(next.Scheme, base.Scheme) || !strings.EqualFold(next.Host, base.Host) { + return false, nil + } + basePath := strings.TrimRight(base.Path, "/") + if basePath == "" || basePath == "/" { + return true, nil + } + return next.Path == basePath || strings.HasPrefix(next.Path, basePath+"/"), nil +} + +func durationMinutesCeil(value time.Duration) int { + if value <= 0 { + return 0 + } + minutes := int(value / time.Minute) + if value%time.Minute != 0 { + minutes++ + } + if minutes < 1 { + return 1 + } + return minutes +} diff --git a/internal/providers/githubcodespaces/client_test.go b/internal/providers/githubcodespaces/client_test.go new file mode 100644 index 000000000..fb2da4c99 --- /dev/null +++ b/internal/providers/githubcodespaces/client_test.go @@ -0,0 +1,197 @@ +package githubcodespaces + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestClientCreateCodespaceRequestShape(t *testing.T) { + var gotMethod, gotPath, gotAuth string + var gotBody map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + if r.Header.Get("Accept") != "application/vnd.github+json" || r.Header.Get("X-GitHub-Api-Version") != "2022-11-28" { + t.Fatalf("missing GitHub headers: %#v", r.Header) + } + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatal(err) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"name":"codespace-1","display_name":"Crabbox","state":"Available","environment_id":"env_1","repository":{"full_name":"example-org/my-app"},"machine":{"name":"standardLinux32gb"}}`)) + })) + defer server.Close() + + c := newClient(GitHubCodespacesConfig{APIURL: server.URL}, Runtime{HTTP: server.Client()}, "ghp_this_token_value_is_redacted") + created, err := c.createCodespace(context.Background(), createCodespaceRequest{ + Repo: "example-org/my-app", + Ref: "main", + Machine: "standardLinux32gb", + DevcontainerPath: ".devcontainer/devcontainer.json", + WorkingDirectory: "/workspaces/my-app", + Geo: "UsWest", + IdleTimeout: 90 * time.Second, + RetentionPeriod: 24 * time.Hour, + DisplayName: "Crabbox", + }) + if err != nil { + t.Fatal(err) + } + if gotMethod != http.MethodPost || gotPath != "/repos/example-org/my-app/codespaces" { + t.Fatalf("request=%s %s", gotMethod, gotPath) + } + if gotAuth != "Bearer ghp_this_token_value_is_redacted" { + t.Fatalf("auth=%q", gotAuth) + } + if gotBody["ref"] != "main" || + gotBody["machine"] != "standardLinux32gb" || + gotBody["devcontainer_path"] != ".devcontainer/devcontainer.json" || + gotBody["working_directory"] != "/workspaces/my-app" || + gotBody["geo"] != "UsWest" || + gotBody["idle_timeout_minutes"].(float64) != 2 || + gotBody["retention_period_minutes"].(float64) != 1440 || + gotBody["display_name"] != "Crabbox" { + t.Fatalf("body=%#v", gotBody) + } + if _, ok := gotBody["location"]; ok { + t.Fatalf("body used legacy location key: %#v", gotBody) + } + if created.Repository.FullName != "example-org/my-app" || created.EnvironmentID != "env_1" || created.Machine.Name != "standardLinux32gb" { + t.Fatalf("created=%#v", created) + } +} + +func TestFlexibleRefsDecodeRESTObjectsAndGHStrings(t *testing.T) { + for _, data := range []string{ + `{"repository":{"full_name":"example-org/my-app"},"machine":{"name":"standardLinux32gb"}}`, + `{"repository":"example-org/my-app","machine":"standardLinux32gb"}`, + } { + var item codespace + if err := json.Unmarshal([]byte(data), &item); err != nil { + t.Fatalf("decode %s: %v", data, err) + } + if item.Repository.FullName != "example-org/my-app" { + t.Fatalf("repository=%q", item.Repository.FullName) + } + if item.Machine.Name != "standardLinux32gb" { + t.Fatalf("machine=%q", item.Machine.Name) + } + } +} + +func TestClientLifecycleOperationsRequestShape(t *testing.T) { + var calls []string + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.RequestURI()) + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.RequestURI() == "/api/v3/user/codespaces?per_page=100": + w.Header().Set("Link", `<`+server.URL+`/api/v3/user/codespaces?per_page=100&page=2>; rel="next"`) + _, _ = w.Write([]byte(`{"codespaces":[{"name":"space-1","state":"Available","repository":"example-org/my-app","machine":"standardLinux32gb"}]}`)) + case r.Method == http.MethodGet && r.URL.RequestURI() == "/api/v3/user/codespaces?per_page=100&page=2": + _, _ = w.Write([]byte(`{"codespaces":[{"name":"space-2","state":"Shutdown","repository":"example-org/my-app","machine":"standardLinux32gb"}]}`)) + case r.Method == http.MethodGet && r.URL.RequestURI() == "/api/v3/user/codespaces/space-1": + _, _ = w.Write([]byte(`{"name":"space-1","state":"Available","repository":"example-org/my-app","machine":"standardLinux32gb"}`)) + case r.Method == http.MethodPost && r.URL.RequestURI() == "/api/v3/user/codespaces/space-1/start": + _, _ = w.Write([]byte(`{"name":"space-1","state":"Starting","repository":"example-org/my-app","machine":"standardLinux32gb"}`)) + case r.Method == http.MethodPost && r.URL.RequestURI() == "/api/v3/user/codespaces/space-2/start": + w.WriteHeader(http.StatusNotModified) + case r.Method == http.MethodPost && r.URL.RequestURI() == "/api/v3/user/codespaces/space-1/stop": + w.WriteHeader(http.StatusAccepted) + case r.Method == http.MethodDelete && r.URL.RequestURI() == "/api/v3/user/codespaces/space-1": + w.WriteHeader(http.StatusAccepted) + case r.Method == http.MethodDelete && r.URL.RequestURI() == "/api/v3/user/codespaces/space-2": + w.WriteHeader(http.StatusNotModified) + case r.Method == http.MethodGet && r.URL.RequestURI() == "/api/v3/repos/example-org/my-app/codespaces/machines?ref=main": + _, _ = w.Write([]byte(`{"machines":[{"name":"standardLinux32gb","display_name":"Standard"}]}`)) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.RequestURI()) + } + })) + defer server.Close() + + c := newClient(GitHubCodespacesConfig{APIURL: server.URL + "/api/v3"}, Runtime{HTTP: server.Client()}, "token") + listed, err := c.listCodespaces(context.Background()) + if err != nil || len(listed) != 2 || listed[0].Name != "space-1" || listed[1].Name != "space-2" { + t.Fatalf("listed=%#v err=%v", listed, err) + } + if got, err := c.getCodespace(context.Background(), "space-1"); err != nil || got.Name != "space-1" { + t.Fatalf("get=%#v err=%v", got, err) + } + if got, err := c.startCodespace(context.Background(), "space-1"); err != nil || got.State != "Starting" { + t.Fatalf("start=%#v err=%v", got, err) + } + if got, err := c.startCodespace(context.Background(), "space-2"); err != nil || got.Name != "space-2" { + t.Fatalf("start no-op=%#v err=%v", got, err) + } + if err := c.stopCodespace(context.Background(), "space-1"); err != nil { + t.Fatal(err) + } + if err := c.deleteCodespace(context.Background(), "space-1"); err != nil { + t.Fatal(err) + } + if err := c.deleteCodespace(context.Background(), "space-2"); err != nil { + t.Fatal(err) + } + machines, err := c.listMachines(context.Background(), "example-org/my-app", "main") + if err != nil || len(machines) != 1 || machines[0].Name != "standardLinux32gb" { + t.Fatalf("machines=%#v err=%v", machines, err) + } + want := strings.Join([]string{ + "GET /api/v3/user/codespaces?per_page=100", + "GET /api/v3/user/codespaces?per_page=100&page=2", + "GET /api/v3/user/codespaces/space-1", + "POST /api/v3/user/codespaces/space-1/start", + "POST /api/v3/user/codespaces/space-2/start", + "POST /api/v3/user/codespaces/space-1/stop", + "DELETE /api/v3/user/codespaces/space-1", + "DELETE /api/v3/user/codespaces/space-2", + "GET /api/v3/repos/example-org/my-app/codespaces/machines?ref=main", + }, "\n") + if got := strings.Join(calls, "\n"); got != want { + t.Fatalf("calls:\n%s\nwant:\n%s", got, want) + } +} + +func TestClientListCodespacesRejectsCrossOriginPagination(t *testing.T) { + var calls []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.RequestURI()) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Link", `; rel="next"`) + _, _ = w.Write([]byte(`{"codespaces":[{"name":"space-1","state":"Available","repository":"example-org/my-app","machine":"standardLinux32gb"}]}`)) + })) + defer server.Close() + + c := newClient(GitHubCodespacesConfig{APIURL: server.URL + "/api/v3"}, Runtime{HTTP: server.Client()}, "token") + _, err := c.listCodespaces(context.Background()) + if err == nil || !strings.Contains(err.Error(), "outside configured API base") { + t.Fatalf("err=%v", err) + } + if got := strings.Join(calls, "\n"); got != "GET /api/v3/user/codespaces?per_page=100" { + t.Fatalf("calls=%q", got) + } +} + +func TestGitHubAPIErrorRedactsTokensAndReportsRetryAfter(t *testing.T) { + err := githubAPIError(http.StatusForbidden, "3", `{"message":"bad token ghp_this_token_value_is_redacted"}`) + if err == nil { + t.Fatal("expected error") + } + text := err.Error() + if strings.Contains(text, "ghp_this_token_value_is_redacted") { + t.Fatalf("token leaked: %s", text) + } + for _, want := range []string{"status=403", "retry_after=3s", "check gh auth", "codespace scope"} { + if !strings.Contains(text, want) { + t.Fatalf("error %q missing %q", text, want) + } + } +} diff --git a/internal/providers/githubcodespaces/core.go b/internal/providers/githubcodespaces/core.go new file mode 100644 index 000000000..4dce81345 --- /dev/null +++ b/internal/providers/githubcodespaces/core.go @@ -0,0 +1,136 @@ +package githubcodespaces + +import ( + "context" + "flag" + "io" + "time" + + core "github.com/openclaw/crabbox/internal/cli" +) + +type Config = core.Config +type GitHubCodespacesConfig = core.GitHubCodespacesConfig +type ProviderSpec = core.ProviderSpec +type Runtime = core.Runtime +type Backend = core.Backend +type DoctorRequest = core.DoctorRequest +type DoctorResult = core.DoctorResult +type DoctorCheck = core.DoctorCheck +type AcquireRequest = core.AcquireRequest +type ResolveRequest = core.ResolveRequest +type ListRequest = core.ListRequest +type LeaseView = core.LeaseView +type ReleaseLeaseRequest = core.ReleaseLeaseRequest +type TouchRequest = core.TouchRequest +type CleanupRequest = core.CleanupRequest +type LeaseTarget = core.LeaseTarget +type Server = core.Server +type SSHTarget = core.SSHTarget +type LocalCommandRequest = core.LocalCommandRequest +type LocalCommandResult = core.LocalCommandResult +type LeaseClaim = core.LeaseClaim +type Repo = core.Repo + +const ( + providerName = "github-codespaces" + providerFamily = "github-codespaces" + defaultGHPath = "gh" + defaultWorkRoot = "/workspaces/crabbox" + defaultSSHConfigFileMode = 0o600 + defaultAPIURL = "https://api.github.com" + defaultCodespaceMachine = "basicLinux32gb" + defaultIdleTimeoutMinutes = 30 + defaultRetentionPeriodDays = 7 + targetLinux = core.TargetLinux + networkPublic = core.NetworkPublic + defaultSSHPort = "22" +) + +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 markDeleteOnReleaseExplicit(cfg *Config) { + core.MarkDeleteOnReleaseExplicit(cfg, providerName) +} + +func deleteOnReleaseExplicit(cfg Config) bool { + return core.DeleteOnReleaseExplicit(cfg, providerName) +} + +func workRootExplicit(cfg *Config) bool { + return core.IsWorkRootExplicit(cfg) +} + +func newLeaseID() string { + return core.NewLeaseID() +} + +func allocateDirectLeaseSlug(leaseID, requested string, servers []Server) (string, error) { + return core.AllocateDirectLeaseSlug(leaseID, requested, servers) +} + +func directLeaseLabels(cfg Config, leaseID, slug, provider, market string, keep bool, now time.Time) map[string]string { + return core.DirectLeaseLabels(cfg, leaseID, slug, provider, market, keep, now) +} + +func touchDirectLeaseLabels(labels map[string]string, cfg Config, state string, now time.Time) map[string]string { + return core.TouchDirectLeaseLabels(labels, cfg, state, now) +} + +func claimLeaseTargetForRepoConfig(leaseID, slug string, cfg Config, server Server, target SSHTarget, repoRoot string, idleTimeout time.Duration, reclaim bool) error { + return core.ClaimLeaseTargetForRepoConfig(leaseID, slug, cfg, server, target, repoRoot, idleTimeout, reclaim) +} + +func resolveLeaseClaimForProvider(identifier, provider string) (LeaseClaim, bool, error) { + return core.ResolveLeaseClaimForProvider(identifier, provider) +} + +func readLeaseClaimWithPresence(leaseID string) (LeaseClaim, bool, error) { + return core.ReadLeaseClaimWithPresence(leaseID) +} + +func listLeaseClaims() ([]LeaseClaim, error) { + return core.ListLeaseClaims() +} + +func updateLeaseClaimEndpoint(leaseID string, server Server, target SSHTarget) error { + return core.UpdateLeaseClaimEndpoint(leaseID, server, target) +} + +func updateLeaseClaimEndpointIfUnchangedAfter(leaseID string, expected LeaseClaim, server Server, target SSHTarget, action func() error) (LeaseClaim, error) { + return core.UpdateLeaseClaimEndpointIfUnchangedAfter(leaseID, expected, server, target, action) +} + +func removeLeaseClaimIfUnchanged(leaseID string, expected LeaseClaim) error { + return core.RemoveLeaseClaimIfUnchanged(leaseID, expected) +} + +func removeLeaseClaimIfUnchangedAfter(leaseID string, expected LeaseClaim, action func() error) error { + return core.RemoveLeaseClaimIfUnchangedAfter(leaseID, expected, action) +} + +func shouldCleanupServer(server Server, now time.Time) (bool, string) { + return core.ShouldCleanupServer(server, now) +} + +func findServerByAlias(servers []Server, id string) (Server, string, error) { + return core.FindServerByAlias(servers, id) +} + +func leaseProviderName(leaseID, slug string) string { + return core.LeaseProviderName(leaseID, slug) +} + +func crabboxStateDir() (string, error) { + return core.CrabboxStateDir() +} + +func waitForSSHReady(ctx context.Context, target *SSHTarget, stderr io.Writer, phase string, timeout time.Duration) error { + return core.WaitForSSHReady(ctx, target, stderr, phase, timeout) +} diff --git a/internal/providers/githubcodespaces/flags.go b/internal/providers/githubcodespaces/flags.go new file mode 100644 index 000000000..42715199f --- /dev/null +++ b/internal/providers/githubcodespaces/flags.go @@ -0,0 +1,145 @@ +package githubcodespaces + +import ( + "flag" + "strings" + "time" +) + +type flagValues struct { + Repo *string + Ref *string + Machine *string + Devcontainer *string + WorkingDir *string + Geo *string + IdleTimeout *time.Duration + RetentionPeriod *time.Duration + DeleteOnRelease *bool + GHPath *string + WorkRoot *string +} + +func RegisterGitHubCodespacesProviderFlags(fs *flag.FlagSet, defaults Config) any { + return flagValues{ + Repo: fs.String("github-codespaces-repo", defaults.GitHubCodespaces.Repo, "GitHub repository owner/name for Codespaces"), + Ref: fs.String("github-codespaces-ref", defaults.GitHubCodespaces.Ref, "Git ref for a new GitHub Codespace"), + Machine: fs.String("github-codespaces-machine", defaults.GitHubCodespaces.Machine, "GitHub Codespaces machine slug"), + Devcontainer: fs.String("github-codespaces-devcontainer-path", defaults.GitHubCodespaces.DevcontainerPath, "devcontainer path for a new GitHub Codespace"), + WorkingDir: fs.String("github-codespaces-working-directory", defaults.GitHubCodespaces.WorkingDirectory, "working directory inside the GitHub Codespace"), + Geo: fs.String("github-codespaces-geo", defaults.GitHubCodespaces.Geo, "GitHub Codespaces geographic location preference"), + IdleTimeout: fs.Duration("github-codespaces-idle-timeout", defaults.GitHubCodespaces.IdleTimeout, "GitHub Codespaces idle timeout"), + RetentionPeriod: fs.Duration("github-codespaces-retention-period", defaults.GitHubCodespaces.RetentionPeriod, "GitHub Codespaces retention period"), + DeleteOnRelease: fs.Bool("github-codespaces-delete-on-release", defaults.GitHubCodespaces.DeleteOnRelease, "delete claim-owned GitHub Codespaces on release"), + GHPath: fs.String("github-codespaces-gh-path", defaults.GitHubCodespaces.GHPath, "GitHub CLI executable path"), + WorkRoot: fs.String("github-codespaces-work-root", defaults.GitHubCodespaces.WorkRoot, "work root inside GitHub Codespaces"), + } +} + +func ApplyGitHubCodespacesProviderFlags(cfg *Config, fs *flag.FlagSet, values any) error { + if isGitHubCodespacesProviderName(cfg.Provider) { + if flagWasSet(fs, "class") { + return exit(2, "--class is not supported for provider=github-codespaces; use --type or --github-codespaces-machine for a Codespaces machine slug") + } + if cfg.TargetOS != "" && strings.ToLower(strings.TrimSpace(cfg.TargetOS)) != targetLinux { + return exit(2, "provider=github-codespaces supports target=linux only") + } + if flagWasSet(fs, "type") && !flagWasSet(fs, "github-codespaces-machine") { + if flag := fs.Lookup("type"); flag != nil { + cfg.GitHubCodespaces.Machine = strings.TrimSpace(flag.Value.String()) + } + } + } + v, ok := values.(flagValues) + if !ok { + return nil + } + if flagWasSet(fs, "github-codespaces-repo") { + cfg.GitHubCodespaces.Repo = *v.Repo + } + if flagWasSet(fs, "github-codespaces-ref") { + cfg.GitHubCodespaces.Ref = *v.Ref + } + if flagWasSet(fs, "github-codespaces-machine") { + cfg.GitHubCodespaces.Machine = *v.Machine + } + if flagWasSet(fs, "github-codespaces-devcontainer-path") { + cfg.GitHubCodespaces.DevcontainerPath = *v.Devcontainer + } + if flagWasSet(fs, "github-codespaces-working-directory") { + cfg.GitHubCodespaces.WorkingDirectory = *v.WorkingDir + } + if flagWasSet(fs, "github-codespaces-geo") { + cfg.GitHubCodespaces.Geo = *v.Geo + } + if flagWasSet(fs, "github-codespaces-idle-timeout") { + cfg.GitHubCodespaces.IdleTimeout = *v.IdleTimeout + } + if flagWasSet(fs, "github-codespaces-retention-period") { + cfg.GitHubCodespaces.RetentionPeriod = *v.RetentionPeriod + } + if flagWasSet(fs, "github-codespaces-delete-on-release") { + cfg.GitHubCodespaces.DeleteOnRelease = *v.DeleteOnRelease + markDeleteOnReleaseExplicit(cfg) + } + if flagWasSet(fs, "github-codespaces-gh-path") { + cfg.GitHubCodespaces.GHPath = *v.GHPath + } + if flagWasSet(fs, "github-codespaces-work-root") { + cfg.GitHubCodespaces.WorkRoot = *v.WorkRoot + } + return ValidateGitHubCodespacesConfig(*cfg) +} + +func ValidateGitHubCodespacesConfig(cfg Config) error { + if isGitHubCodespacesProviderName(cfg.Provider) && strings.TrimSpace(cfg.TargetOS) != "" && strings.ToLower(strings.TrimSpace(cfg.TargetOS)) != targetLinux { + return exit(2, "provider=github-codespaces supports target=linux only") + } + c := cfg.GitHubCodespaces + if strings.TrimSpace(c.Repo) != "" && !validRepo(c.Repo) { + return exit(2, "github-codespaces repo must be owner/name") + } + for label, value := range map[string]time.Duration{ + "idle timeout": c.IdleTimeout, + "retention period": c.RetentionPeriod, + } { + if value < 0 { + return exit(2, "github-codespaces %s must be non-negative", label) + } + } + if strings.TrimSpace(c.WorkRoot) != "" && !strings.HasPrefix(strings.TrimSpace(c.WorkRoot), "/") { + return exit(2, "github-codespaces work root must be absolute") + } + if strings.TrimSpace(c.GHPath) == "" { + return exit(2, "github-codespaces gh path is required") + } + return nil +} + +func validRepo(repo string) bool { + owner, name, ok := strings.Cut(strings.TrimSpace(repo), "/") + return ok && validRepoPart(owner) && validRepoPart(name) +} + +func isGitHubCodespacesProviderName(provider string) bool { + switch strings.ToLower(strings.TrimSpace(provider)) { + case providerName, "codespaces", "gh-codespaces": + return true + default: + return false + } +} + +func validRepoPart(value string) bool { + value = strings.TrimSpace(value) + if value == "" || strings.Contains(value, "/") || strings.HasPrefix(value, ".") || strings.HasSuffix(value, ".") { + return false + } + for _, r := range value { + if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' { + continue + } + return false + } + return true +} diff --git a/internal/providers/githubcodespaces/gh.go b/internal/providers/githubcodespaces/gh.go new file mode 100644 index 000000000..f89a5f3b1 --- /dev/null +++ b/internal/providers/githubcodespaces/gh.go @@ -0,0 +1,111 @@ +package githubcodespaces + +import ( + "context" + "fmt" + "strings" +) + +type ghRunner struct { + cfg GitHubCodespacesConfig + rt Runtime +} + +func newGHRunner(cfg GitHubCodespacesConfig, rt Runtime) ghRunner { + return ghRunner{cfg: cfg, rt: rt} +} + +func (r ghRunner) authStatus(ctx context.Context) error { + _, err := r.run(ctx, "auth", "status") + return err +} + +func (r ghRunner) authToken(ctx context.Context) (string, error) { + result, err := r.run(ctx, "auth", "token") + if err != nil { + return "", err + } + token := strings.TrimSpace(result.Stdout) + if token == "" { + return "", fmt.Errorf("github-codespaces gh auth token returned empty token") + } + return token, nil +} + +func (r ghRunner) userLogin(ctx context.Context) (string, error) { + result, err := r.run(ctx, "api", "user", "--jq", ".login") + if err != nil { + return "", err + } + login := strings.TrimSpace(result.Stdout) + if login == "" { + return "", fmt.Errorf("github-codespaces gh api user returned empty login") + } + return login, nil +} + +func (r ghRunner) codespaceSSHConfig(ctx context.Context, codespace string) (string, error) { + result, err := r.run(ctx, "codespace", "ssh", "--config", "-c", strings.TrimSpace(codespace)) + if err != nil { + return "", err + } + return result.Stdout, nil +} + +func (r ghRunner) run(ctx context.Context, args ...string) (LocalCommandResult, error) { + if r.rt.Exec == nil { + return LocalCommandResult{}, exit(2, "provider=github-codespaces requires local command runner") + } + name := strings.TrimSpace(r.cfg.GHPath) + if name == "" { + name = defaultGHPath + } + result, err := r.rt.Exec.Run(ctx, LocalCommandRequest{Name: name, Args: args}) + if err != nil { + return result, fmt.Errorf("github-codespaces gh %s failed: %s", strings.Join(redactGHArgs(args), " "), redactSecretText(result.Stderr+" "+err.Error())) + } + if result.ExitCode != 0 { + return result, fmt.Errorf("github-codespaces gh %s failed with exit=%d: %s", strings.Join(redactGHArgs(args), " "), result.ExitCode, redactSecretText(result.Stderr)) + } + return result, nil +} + +func redactGHArgs(args []string) []string { + out := make([]string, 0, len(args)) + for i := 0; i < len(args); i++ { + arg := args[i] + lower := strings.ToLower(arg) + if lower == "--token" || lower == "--api-key" || lower == "-t" { + out = append(out, arg, "") + i++ + continue + } + if strings.HasPrefix(lower, "--token=") || strings.HasPrefix(lower, "--api-key=") { + before, _, _ := strings.Cut(arg, "=") + out = append(out, before+"=") + continue + } + out = append(out, redactSecretText(arg)) + } + return out +} + +func redactSecretText(text string) string { + fields := strings.Fields(text) + for i, field := range fields { + if looksLikeGitHubToken(field) { + fields[i] = "" + } + } + return strings.Join(fields, " ") +} + +func looksLikeGitHubToken(value string) bool { + value = strings.Trim(value, `"'.,;:()[]{}<>`) + for _, prefix := range []string{"ghp_", "github_pat_", "gho_", "ghu_", "ghs_", "ghr_"} { + if strings.HasPrefix(value, prefix) && len(value) >= len(prefix)+12 { + return true + } + } + return false +} diff --git a/internal/providers/githubcodespaces/gh_test.go b/internal/providers/githubcodespaces/gh_test.go new file mode 100644 index 000000000..86d2b7903 --- /dev/null +++ b/internal/providers/githubcodespaces/gh_test.go @@ -0,0 +1,75 @@ +package githubcodespaces + +import ( + "context" + "fmt" + "strings" + "testing" +) + +func TestGHRunnerCodespaceSSHConfigArgv(t *testing.T) { + runner := &recordingRunner{result: LocalCommandResult{Stdout: "Host sturdy-space\n"}} + gh := newGHRunner(GitHubCodespacesConfig{GHPath: "/opt/gh"}, Runtime{Exec: runner}) + out, err := gh.codespaceSSHConfig(context.Background(), "sturdy-space") + if err != nil { + t.Fatal(err) + } + if out != "Host sturdy-space\n" { + t.Fatalf("out=%q", out) + } + call := runner.onlyCall(t) + if call.Name != "/opt/gh" || strings.Join(call.Args, " ") != "codespace ssh --config -c sturdy-space" { + t.Fatalf("call=%#v", call) + } + for _, arg := range call.Args { + if looksLikeGitHubToken(arg) { + t.Fatalf("token arg leaked: %#v", call.Args) + } + } +} + +func TestGHRunnerAuthStatusReadOnly(t *testing.T) { + runner := &recordingRunner{} + gh := newGHRunner(GitHubCodespacesConfig{GHPath: "gh"}, Runtime{Exec: runner}) + if err := gh.authStatus(context.Background()); err != nil { + t.Fatal(err) + } + call := runner.onlyCall(t) + if strings.Join(call.Args, " ") != "auth status" { + t.Fatalf("call=%#v", call) + } +} + +func TestGHRunnerErrorRedactsToken(t *testing.T) { + runner := &recordingRunner{ + result: LocalCommandResult{ExitCode: 1, Stderr: "denied ghp_this_token_value_is_redacted"}, + err: fmt.Errorf("ghp_this_token_value_is_redacted failed"), + } + gh := newGHRunner(GitHubCodespacesConfig{GHPath: "gh"}, Runtime{Exec: runner}) + _, err := gh.codespaceSSHConfig(context.Background(), "sturdy-space") + if err == nil { + t.Fatal("expected error") + } + if strings.Contains(err.Error(), "ghp_this_token_value_is_redacted") { + t.Fatalf("token leaked: %v", err) + } +} + +type recordingRunner struct { + calls []LocalCommandRequest + result LocalCommandResult + err error +} + +func (r *recordingRunner) Run(_ context.Context, req LocalCommandRequest) (LocalCommandResult, error) { + r.calls = append(r.calls, req) + return r.result, r.err +} + +func (r *recordingRunner) onlyCall(t *testing.T) LocalCommandRequest { + t.Helper() + if len(r.calls) != 1 { + t.Fatalf("calls=%#v", r.calls) + } + return r.calls[0] +} diff --git a/internal/providers/githubcodespaces/provider.go b/internal/providers/githubcodespaces/provider.go new file mode 100644 index 000000000..5c9a95475 --- /dev/null +++ b/internal/providers/githubcodespaces/provider.go @@ -0,0 +1,76 @@ +package githubcodespaces + +import ( + "flag" + + core "github.com/openclaw/crabbox/internal/cli" +) + +func init() { + coreRegisterProvider(Provider{}) +} + +var coreRegisterProvider = func(provider Provider) { + core.RegisterProvider(provider) +} + +type Provider struct{} + +func (Provider) Name() string { return providerName } + +func (Provider) Aliases() []string { + return []string{"codespaces", "gh-codespaces"} +} + +func (Provider) Spec() ProviderSpec { + return ProviderSpec{ + Name: providerName, + Family: providerFamily, + Kind: core.ProviderKindSSHLease, + Targets: []core.TargetSpec{{OS: targetLinux}}, + Features: core.FeatureSet{core.FeatureSSH, core.FeatureCrabboxSync, core.FeatureCleanup}, + Coordinator: core.CoordinatorNever, + } +} + +func (Provider) RegisterFlags(fs *flag.FlagSet, defaults Config) any { + return RegisterGitHubCodespacesProviderFlags(fs, defaults) +} + +func (Provider) ApplyFlags(cfg *Config, fs *flag.FlagSet, values any) error { + return ApplyGitHubCodespacesProviderFlags(cfg, fs, values) +} + +func (Provider) ServerTypeForConfig(cfg Config) string { + if cfg.ServerTypeExplicit && cfg.ServerType != "" { + return cfg.ServerType + } + if cfg.GitHubCodespaces.Machine != "" { + return cfg.GitHubCodespaces.Machine + } + return defaultCodespaceMachine +} + +func (Provider) ServerTypeForClass(string) string { + return defaultCodespaceMachine +} + +func (p Provider) Configure(cfg Config, rt Runtime) (Backend, error) { + cfg.Provider = providerName + if err := ValidateGitHubCodespacesConfig(cfg); err != nil { + return nil, err + } + return newBackend(p.Spec(), cfg, rt), nil +} + +func (p Provider) ConfigureDoctor(cfg Config, rt 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, exit(2, "github-codespaces doctor backend unavailable") + } + return doctor, nil +} diff --git a/internal/providers/githubcodespaces/provider_test.go b/internal/providers/githubcodespaces/provider_test.go new file mode 100644 index 000000000..46dcdd2be --- /dev/null +++ b/internal/providers/githubcodespaces/provider_test.go @@ -0,0 +1,183 @@ +package githubcodespaces + +import ( + "flag" + "strings" + "testing" + "time" + + core "github.com/openclaw/crabbox/internal/cli" +) + +func TestProviderSpec(t *testing.T) { + spec := Provider{}.Spec() + if spec.Name != providerName || spec.Family != providerFamily || spec.Kind != core.ProviderKindSSHLease || spec.Coordinator != core.CoordinatorNever { + t.Fatalf("spec=%#v", spec) + } + if len(spec.Targets) != 1 || spec.Targets[0].OS != core.TargetLinux { + t.Fatalf("targets=%#v", spec.Targets) + } + for _, feature := range []core.Feature{core.FeatureSSH, core.FeatureCrabboxSync, core.FeatureCleanup} { + if !spec.Features.Has(feature) { + t.Fatalf("features=%#v missing %s", spec.Features, feature) + } + } +} + +func TestProviderAliases(t *testing.T) { + got := strings.Join(Provider{}.Aliases(), ",") + if got != "codespaces,gh-codespaces" { + t.Fatalf("aliases=%q", got) + } +} + +func TestServerTypeForConfigUsesMachineOrExplicitType(t *testing.T) { + provider := Provider{} + if got := provider.ServerTypeForConfig(core.Config{GitHubCodespaces: core.GitHubCodespacesConfig{Machine: "standardLinux32gb"}}); got != "standardLinux32gb" { + t.Fatalf("machine ServerTypeForConfig=%q", got) + } + if got := provider.ServerTypeForConfig(core.Config{ServerType: "premiumLinux", ServerTypeExplicit: true, GitHubCodespaces: core.GitHubCodespacesConfig{Machine: "standardLinux32gb"}}); got != "premiumLinux" { + t.Fatalf("explicit ServerTypeForConfig=%q", got) + } + if got := provider.ServerTypeForClass("beast"); got != defaultCodespaceMachine { + t.Fatalf("ServerTypeForClass=%q", got) + } +} + +func TestApplyFlagsSetsCodespacesConfigAndRejectsClass(t *testing.T) { + cfg := core.Config{Provider: providerName, TargetOS: core.TargetLinux} + fs := flag.NewFlagSet("test", flag.ContinueOnError) + values := RegisterGitHubCodespacesProviderFlags(fs, core.Config{ + GitHubCodespaces: core.GitHubCodespacesConfig{ + GHPath: "gh", + Machine: defaultCodespaceMachine, + IdleTimeout: 30 * time.Minute, + RetentionPeriod: 7 * 24 * time.Hour, + WorkRoot: defaultWorkRoot, + }, + }) + fs.String("class", "", "") + fs.String("type", "", "") + args := []string{ + "--github-codespaces-repo", "example-org/my-app", + "--github-codespaces-ref", "main", + "--github-codespaces-machine", "standardLinux32gb", + "--github-codespaces-devcontainer-path", ".devcontainer/devcontainer.json", + "--github-codespaces-working-directory", "/workspaces/my-app", + "--github-codespaces-geo", "UsWest", + "--github-codespaces-idle-timeout", "45m", + "--github-codespaces-retention-period", "48h", + "--github-codespaces-delete-on-release", + "--github-codespaces-gh-path", "/usr/local/bin/gh", + "--github-codespaces-work-root", "/workspaces/my-app", + } + if err := fs.Parse(args); err != nil { + t.Fatal(err) + } + if err := ApplyGitHubCodespacesProviderFlags(&cfg, fs, values); err != nil { + t.Fatal(err) + } + if cfg.GitHubCodespaces.Repo != "example-org/my-app" || + cfg.GitHubCodespaces.Ref != "main" || + cfg.GitHubCodespaces.Machine != "standardLinux32gb" || + cfg.GitHubCodespaces.DevcontainerPath != ".devcontainer/devcontainer.json" || + cfg.GitHubCodespaces.WorkingDirectory != "/workspaces/my-app" || + cfg.GitHubCodespaces.Geo != "UsWest" || + cfg.GitHubCodespaces.IdleTimeout != 45*time.Minute || + cfg.GitHubCodespaces.RetentionPeriod != 48*time.Hour || + !cfg.GitHubCodespaces.DeleteOnRelease || + cfg.GitHubCodespaces.GHPath != "/usr/local/bin/gh" || + cfg.GitHubCodespaces.WorkRoot != "/workspaces/my-app" { + t.Fatalf("config=%#v", cfg.GitHubCodespaces) + } + if !core.DeleteOnReleaseExplicit(cfg, providerName) { + t.Fatal("delete-on-release flag not marked explicit") + } + + typeAliasDefaults := core.Config{ + GitHubCodespaces: core.GitHubCodespacesConfig{ + GHPath: "gh", + Machine: defaultCodespaceMachine, + IdleTimeout: 30 * time.Minute, + RetentionPeriod: 7 * 24 * time.Hour, + WorkRoot: defaultWorkRoot, + }, + } + for _, provider := range []string{providerName, "codespaces", "gh-codespaces"} { + typeAlias := typeAliasDefaults + typeAlias.Provider = provider + typeAlias.TargetOS = core.TargetLinux + typeFS := flag.NewFlagSet("test", flag.ContinueOnError) + typeValues := RegisterGitHubCodespacesProviderFlags(typeFS, typeAliasDefaults) + typeFS.String("class", "", "") + typeFS.String("type", "", "") + if err := typeFS.Parse([]string{"--type", "premiumLinux"}); err != nil { + t.Fatal(err) + } + if err := ApplyGitHubCodespacesProviderFlags(&typeAlias, typeFS, typeValues); err != nil { + t.Fatal(err) + } + if typeAlias.GitHubCodespaces.Machine != "premiumLinux" { + t.Fatalf("provider=%s --type machine=%q", provider, typeAlias.GitHubCodespaces.Machine) + } + } + + reject := flag.NewFlagSet("test", flag.ContinueOnError) + rejectValues := RegisterGitHubCodespacesProviderFlags(reject, core.Config{}) + reject.String("class", "", "") + reject.String("type", "", "") + if err := reject.Parse([]string{"--class", "beast"}); err != nil { + t.Fatal(err) + } + if err := ApplyGitHubCodespacesProviderFlags(&cfg, reject, rejectValues); err == nil || !strings.Contains(err.Error(), "--class is not supported") { + t.Fatalf("class err=%v", err) + } +} + +func TestNoTokenFlagRegistered(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + RegisterGitHubCodespacesProviderFlags(fs, core.Config{}) + fs.VisitAll(func(f *flag.Flag) { + if strings.Contains(strings.ToLower(f.Name), "token") { + t.Fatalf("token-bearing flag registered: %s", f.Name) + } + }) +} + +func TestValidateGitHubCodespacesConfig(t *testing.T) { + valid := core.Config{ + Provider: providerName, + TargetOS: core.TargetLinux, + GitHubCodespaces: core.GitHubCodespacesConfig{ + GHPath: "gh", + Repo: "example-org/my-app", + IdleTimeout: time.Minute, + RetentionPeriod: time.Hour, + WorkRoot: "/workspaces/my-app", + }, + } + if err := ValidateGitHubCodespacesConfig(valid); err != nil { + t.Fatal(err) + } + tests := []struct { + name string + mut func(*core.Config) + want string + }{ + {name: "non-linux", mut: func(cfg *core.Config) { cfg.TargetOS = core.TargetMacOS }, want: "target=linux only"}, + {name: "bad repo", mut: func(cfg *core.Config) { cfg.GitHubCodespaces.Repo = "example-org" }, want: "owner/name"}, + {name: "negative idle", mut: func(cfg *core.Config) { cfg.GitHubCodespaces.IdleTimeout = -time.Second }, want: "non-negative"}, + {name: "relative work root", mut: func(cfg *core.Config) { cfg.GitHubCodespaces.WorkRoot = "workspace" }, want: "absolute"}, + {name: "missing gh", mut: func(cfg *core.Config) { cfg.GitHubCodespaces.GHPath = "" }, want: "gh path is required"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := valid + tt.mut(&cfg) + err := ValidateGitHubCodespacesConfig(cfg) + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("err=%v want %q", err, tt.want) + } + }) + } +} diff --git a/internal/providers/githubcodespaces/ssh_config.go b/internal/providers/githubcodespaces/ssh_config.go new file mode 100644 index 000000000..db125d64b --- /dev/null +++ b/internal/providers/githubcodespaces/ssh_config.go @@ -0,0 +1,343 @@ +package githubcodespaces + +import ( + "bufio" + "os" + "path/filepath" + "strconv" + "strings" + "unicode" +) + +type sshConfigEntry struct { + Aliases []string + HostName string + Port string + User string + IdentityFile string + KnownHostsFile string + ProxyCommand string +} + +func parseSSHConfig(data string) ([]sshConfigEntry, error) { + var entries []sshConfigEntry + var current *sshConfigEntry + scanner := bufio.NewScanner(strings.NewReader(data)) + for scanner.Scan() { + line := stripSSHConfigComment(scanner.Text()) + if strings.TrimSpace(line) == "" { + continue + } + key, value := splitSSHConfigDirective(line) + if key == "" { + continue + } + if strings.EqualFold(key, "Host") { + aliases := splitSSHConfigFields(value) + if len(aliases) == 0 { + current = nil + continue + } + entries = append(entries, sshConfigEntry{Aliases: aliases}) + current = &entries[len(entries)-1] + continue + } + if current == nil { + continue + } + switch strings.ToLower(key) { + case "hostname": + current.HostName = unquoteSSHConfigValue(value) + case "port": + current.Port = unquoteSSHConfigValue(value) + case "user": + current.User = unquoteSSHConfigValue(value) + case "identityfile": + current.IdentityFile = unquoteSSHConfigValue(value) + case "userknownhostsfile": + current.KnownHostsFile = unquoteSSHConfigValue(value) + case "proxycommand": + current.ProxyCommand = strings.TrimSpace(value) + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + return entries, nil +} + +func selectSSHTarget(cfg Config, data, alias string) (SSHTarget, error) { + entry, selectedAlias, err := selectSSHConfigEntry(data, alias) + if err != nil { + return SSHTarget{}, err + } + user := firstNonEmpty(entry.User, cfg.SSHUser) + if user == "" { + return SSHTarget{}, exit(2, "github-codespaces SSH config entry %q is missing User", selectedAlias) + } + if !validSSHUser(user) { + return SSHTarget{}, exit(2, "github-codespaces SSH config entry %q has invalid User %q", selectedAlias, user) + } + if strings.TrimSpace(entry.IdentityFile) == "" { + return SSHTarget{}, exit(2, "github-codespaces SSH config entry %q is missing IdentityFile", selectedAlias) + } + host := strings.TrimSpace(entry.HostName) + proxy := strings.TrimSpace(entry.ProxyCommand) + if host == "" && proxy == "" { + return SSHTarget{}, exit(2, "github-codespaces SSH config entry %q is missing HostName or ProxyCommand", selectedAlias) + } + if host == "" { + host = selectedAlias + } + port := strings.TrimSpace(entry.Port) + if port == "" { + port = defaultSSHPort + } + if _, err := strconv.Atoi(port); err != nil { + return SSHTarget{}, exit(2, "github-codespaces SSH config entry %q has invalid Port %q", selectedAlias, port) + } + target := SSHTarget{ + User: user, + Host: host, + Key: entry.IdentityFile, + KnownHostsFile: entry.KnownHostsFile, + Port: port, + TargetOS: targetLinux, + ReadyCheck: githubCodespacesReadyCheck(cfg), + NetworkKind: networkPublic, + } + if proxy != "" { + target.SSHConfigProxy = true + target.ProxyCommand = rewriteProxyCommandGHPath(proxy, cfg.GitHubCodespaces.GHPath) + } + return target, nil +} + +func selectSSHConfigEntry(data, alias string) (sshConfigEntry, string, error) { + alias = strings.TrimSpace(alias) + if alias == "" { + return sshConfigEntry{}, "", exit(2, "github-codespaces SSH config host alias is required") + } + entries, err := parseSSHConfig(data) + if err != nil { + return sshConfigEntry{}, "", err + } + matches := make([]sshConfigEntry, 0, 1) + matchAliases := make([]string, 0, 1) + for _, entry := range entries { + for _, candidate := range entry.Aliases { + if candidate == alias { + matches = append(matches, entry) + matchAliases = append(matchAliases, candidate) + break + } + } + } + if len(matches) == 0 { + for _, entry := range entries { + if !proxyCommandReferencesCodespace(entry.ProxyCommand, alias) { + continue + } + matches = append(matches, entry) + matchAliases = append(matchAliases, firstNonEmpty(firstSSHConfigAlias(entry), alias)) + } + } + if len(matches) == 0 { + return sshConfigEntry{}, "", exit(4, "github-codespaces SSH config entry not found for host %q", alias) + } + if len(matches) > 1 { + return sshConfigEntry{}, "", exit(2, "github-codespaces SSH config entry for host %q is ambiguous", alias) + } + return matches[0], matchAliases[0], nil +} + +func firstSSHConfigAlias(entry sshConfigEntry) string { + for _, alias := range entry.Aliases { + if strings.TrimSpace(alias) != "" { + return strings.TrimSpace(alias) + } + } + return "" +} + +func proxyCommandReferencesCodespace(command, name string) bool { + name = strings.TrimSpace(name) + if name == "" { + return false + } + fields := splitSSHConfigFields(command) + for i, field := range fields { + switch { + case field == "-c" || field == "--codespace": + return i+1 < len(fields) && fields[i+1] == name + case strings.HasPrefix(field, "-c="): + return strings.TrimPrefix(field, "-c=") == name + case strings.HasPrefix(field, "--codespace="): + return strings.TrimPrefix(field, "--codespace=") == name + } + } + return false +} + +func rewriteProxyCommandGHPath(command, ghPath string) string { + ghPath = strings.TrimSpace(ghPath) + if ghPath == "" || ghPath == defaultGHPath { + return command + } + fields := splitSSHConfigFields(command) + if len(fields) == 0 || fields[0] != defaultGHPath { + return command + } + return shellQuote(ghPath) + " " + strings.Join(fields[1:], " ") +} + +func validatePrivateSSHConfigFile(path string) error { + info, err := os.Stat(path) + if err != nil { + return err + } + if info.IsDir() { + return exit(2, "github-codespaces SSH config path %q is a directory", path) + } + mode := info.Mode().Perm() + if mode != defaultSSHConfigFileMode { + return exit(2, "github-codespaces SSH config path %q must have mode 0600, got %04o", path, mode) + } + return nil +} + +func storeSSHConfig(leaseID, data string) (string, error) { + leaseID = strings.TrimSpace(leaseID) + if leaseID == "" { + return "", exit(2, "github-codespaces lease id is required for SSH config storage") + } + dir, err := sshConfigDir() + if err != nil { + return "", err + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + path := filepath.Join(dir, leaseID+".ssh_config") + if err := os.WriteFile(path, []byte(data), defaultSSHConfigFileMode); err != nil { + return "", err + } + if err := os.Chmod(path, defaultSSHConfigFileMode); err != nil { + return "", err + } + if err := validatePrivateSSHConfigFile(path); err != nil { + return "", err + } + return path, nil +} + +func removeStoredSSHConfig(leaseID string) error { + if strings.TrimSpace(leaseID) == "" { + return nil + } + dir, err := sshConfigDir() + if err != nil { + return err + } + err = os.Remove(filepath.Join(dir, strings.TrimSpace(leaseID)+".ssh_config")) + if err == nil || os.IsNotExist(err) { + return nil + } + return err +} + +func sshConfigDir() (string, error) { + stateDir, err := crabboxStateDir() + if err != nil { + return "", err + } + return filepath.Join(stateDir, "github-codespaces"), nil +} + +func githubCodespacesReadyCheck(cfg Config) string { + workRoot := strings.TrimSpace(cfg.GitHubCodespaces.WorkRoot) + if workRoot == "" { + workRoot = strings.TrimSpace(cfg.WorkRoot) + } + if workRoot == "" { + workRoot = defaultWorkRoot + } + return "command -v git >/dev/null && command -v rsync >/dev/null && command -v tar >/dev/null && test -d " + shellQuote(workRoot) +} + +func stripSSHConfigComment(line string) string { + var quoted byte + for i := 0; i < len(line); i++ { + c := line[i] + if quoted != 0 { + if c == quoted { + quoted = 0 + } + continue + } + if c == '\'' || c == '"' { + quoted = c + continue + } + if c == '#' { + return line[:i] + } + } + return line +} + +func splitSSHConfigDirective(line string) (string, string) { + line = strings.TrimSpace(line) + if line == "" { + return "", "" + } + for i, r := range line { + if r == ' ' || r == '\t' { + return strings.TrimSpace(line[:i]), strings.TrimSpace(line[i:]) + } + } + return line, "" +} + +func splitSSHConfigFields(value string) []string { + var out []string + for _, field := range strings.Fields(value) { + field = unquoteSSHConfigValue(field) + if field != "" { + out = append(out, field) + } + } + return out +} + +func unquoteSSHConfigValue(value string) string { + value = strings.TrimSpace(value) + if len(value) >= 2 { + if (value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'') { + return value[1 : len(value)-1] + } + } + return value +} + +func validSSHUser(user string) bool { + if user == "" || strings.HasPrefix(user, "-") || strings.Contains(user, "@") { + return false + } + return strings.IndexFunc(user, func(r rune) bool { + return unicode.IsSpace(r) || unicode.IsControl(r) + }) == -1 +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} diff --git a/internal/providers/githubcodespaces/ssh_config_test.go b/internal/providers/githubcodespaces/ssh_config_test.go new file mode 100644 index 000000000..9199d3c19 --- /dev/null +++ b/internal/providers/githubcodespaces/ssh_config_test.go @@ -0,0 +1,140 @@ +package githubcodespaces + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSSHConfigParsesProxyTarget(t *testing.T) { + target, err := selectSSHTarget(Config{GitHubCodespaces: GitHubCodespacesConfig{WorkRoot: "/workspaces/my-app"}}, `Host sturdy-space + User vscode + IdentityFile "/tmp/codespaces/key" + UserKnownHostsFile /dev/null + ProxyCommand gh codespace ssh -c sturdy-space --stdio +`, "sturdy-space") + if err != nil { + t.Fatal(err) + } + if target.User != "vscode" || target.Host != "sturdy-space" || target.Port != "22" || target.Key != "/tmp/codespaces/key" { + t.Fatalf("target=%#v", target) + } + if !target.SSHConfigProxy || target.ProxyCommand != "gh codespace ssh -c sturdy-space --stdio" { + t.Fatalf("proxy target=%#v", target) + } + if target.KnownHostsFile != "/dev/null" || target.TargetOS != targetLinux || target.NetworkKind != networkPublic { + t.Fatalf("target metadata=%#v", target) + } + for _, want := range []string{"git", "rsync", "tar", "test -d '/workspaces/my-app'"} { + if !strings.Contains(target.ReadyCheck, want) { + t.Fatalf("ready check %q missing %q", target.ReadyCheck, want) + } + } +} + +func TestSSHConfigSelectsGeneratedGitHubCLIAliasByProxyCodespace(t *testing.T) { + target, err := selectSSHTarget(Config{GitHubCodespaces: GitHubCodespacesConfig{GHPath: "/opt/github/bin/gh"}}, `Host cs.sturdy-space.main + User vscode + IdentityFile "/tmp/codespaces/key" + UserKnownHostsFile /dev/null + ProxyCommand gh codespace ssh -c sturdy-space --stdio +`, "sturdy-space") + if err != nil { + t.Fatal(err) + } + if target.Host != "cs.sturdy-space.main" || !target.SSHConfigProxy { + t.Fatalf("target=%#v", target) + } + if target.ProxyCommand != "'/opt/github/bin/gh' codespace ssh -c sturdy-space --stdio" { + t.Fatalf("proxy=%q", target.ProxyCommand) + } +} + +func TestSSHConfigParsesDirectTarget(t *testing.T) { + target, err := selectSSHTarget(Config{}, `Host sturdy-space + HostName 127.0.0.1 + User vscode + Port 2222 + IdentityFile "/tmp/codespaces/key" +`, "sturdy-space") + if err != nil { + t.Fatal(err) + } + if target.Host != "127.0.0.1" || target.Port != "2222" || target.SSHConfigProxy { + t.Fatalf("target=%#v", target) + } +} + +func TestSSHConfigRejectsMissingFieldsAndAmbiguousAlias(t *testing.T) { + tests := []struct { + name string + data string + want string + }{ + {name: "missing user", data: `Host sturdy + IdentityFile "/tmp/key" + ProxyCommand gh codespace ssh -c sturdy --stdio +`, want: "missing User"}, + {name: "missing identity", data: `Host sturdy + User vscode + ProxyCommand gh codespace ssh -c sturdy --stdio +`, want: "missing IdentityFile"}, + {name: "missing route", data: `Host sturdy + User vscode + IdentityFile "/tmp/key" +`, want: "missing HostName or ProxyCommand"}, + {name: "missing alias", data: `Host other + User vscode + IdentityFile "/tmp/key" + ProxyCommand gh codespace ssh -c other --stdio +`, want: "not found"}, + {name: "ambiguous", data: `Host sturdy + User vscode + IdentityFile "/tmp/key" + ProxyCommand gh codespace ssh -c sturdy --stdio + +Host sturdy + User vscode + IdentityFile "/tmp/key" + ProxyCommand gh codespace ssh -c sturdy --stdio +`, want: "ambiguous"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := selectSSHTarget(Config{}, tt.data, "sturdy") + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("err=%v want %q", err, tt.want) + } + }) + } +} + +func TestSSHConfigRejectsInvalidUsers(t *testing.T) { + for _, user := range []string{"-oProxyCommand=sh", "alice@example.com", "alice bob", "alice\tbob"} { + _, err := selectSSHTarget(Config{}, `Host sturdy + User `+user+` + IdentityFile "/tmp/key" + ProxyCommand gh codespace ssh -c sturdy --stdio +`, "sturdy") + if err == nil || !strings.Contains(err.Error(), "invalid User") { + t.Fatalf("user=%q err=%v", user, err) + } + } +} + +func TestValidatePrivateSSHConfigFileRequires0600(t *testing.T) { + path := filepath.Join(t.TempDir(), "config") + if err := os.WriteFile(path, []byte("Host sturdy\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := validatePrivateSSHConfigFile(path); err != nil { + t.Fatal(err) + } + if err := os.Chmod(path, 0o644); err != nil { + t.Fatal(err) + } + if err := validatePrivateSSHConfigFile(path); err == nil || !strings.Contains(err.Error(), "0600") { + t.Fatalf("err=%v", err) + } +} diff --git a/scripts/fixtures/github-codespaces/devcontainer.json b/scripts/fixtures/github-codespaces/devcontainer.json new file mode 100644 index 000000000..93839449b --- /dev/null +++ b/scripts/fixtures/github-codespaces/devcontainer.json @@ -0,0 +1,8 @@ +{ + "name": "Crabbox GitHub Codespaces smoke", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "features": { + "ghcr.io/devcontainers/features/sshd:1": {} + }, + "postCreateCommand": "sudo apt-get update && sudo apt-get install -y --no-install-recommends rsync" +} diff --git a/scripts/live-github-codespaces-smoke.sh b/scripts/live-github-codespaces-smoke.sh new file mode 100755 index 000000000..6c726d685 --- /dev/null +++ b/scripts/live-github-codespaces-smoke.sh @@ -0,0 +1,373 @@ +#!/usr/bin/env bash +set -euo pipefail + +provider_enabled() { + local list="${CRABBOX_LIVE_PROVIDERS:-github-codespaces}" + local item + IFS=',' read -ra items <<<"$list" + for item in "${items[@]}"; do + item="${item//[[:space:]]/}" + if [[ "$item" == "github-codespaces" || "$item" == "codespaces" || "$item" == "gh-codespaces" ]]; then + return 0 + fi + done + return 1 +} + +redact_output() { + local text="$1" + local secret + for secret in "${GH_TOKEN:-}" "${GITHUB_TOKEN:-}"; do + if [[ -n "$secret" ]]; then + text="${text//$secret/}" + fi + done + printf '%s' "$text" | sed -E 's/(ghp_|github_pat_|gho_|ghu_|ghs_|ghr_)[A-Za-z0-9_]+//g' +} + +classify_blocker() { + local command="$1" + local status="$2" + local output="$3" + local classification="environment_blocked" + local lower + lower="$(printf '%s' "$output" | tr '[:upper:]' '[:lower:]')" + if [[ "$lower" == *quota* || "$lower" == *"rate limit"* || "$lower" == *capacity* || "$lower" == *billing* || "$lower" == *"spending limit"* || "$lower" == *"too many requests"* ]]; then + classification="quota_blocked" + elif [[ "$lower" == *credential* || "$lower" == *authentication* || "$lower" == *authorization* || "$lower" == *unauthorized* || "$lower" == *forbidden* || "$lower" == *"bad credentials"* || "$lower" == *"requires authentication"* || "$lower" == *scope* || "$lower" == *token* ]]; then + classification="credential_bound" + fi + printf 'classification=%s command=%q exit=%s\n' "$classification" "$command" "$status" >&2 + redact_output "$output" >&2 + printf '\n' >&2 +} + +classify_validation_failure() { + local command="$1" + local status="$2" + local output="$3" + printf 'classification=validation_failed command=%q exit=%s\n' "$command" "$status" >&2 + redact_output "$output" >&2 + printf '\n' >&2 +} + +run_capture() { + local command="$1" + shift + local output + set +e + output="$("$@" 2>&1)" + local status=$? + set -e + if [[ "$status" -ne 0 ]]; then + classify_blocker "$command" "$status" "$output" + exit "$status" + fi + printf '%s\n' "$output" +} + +validate_list_json_contains_slug() { + local command="$1" + local output="$2" + local validation_output="" + local status=0 + set +e + validation_output="$(CRABBOX_SMOKE_SLUG="$slug" python3 -c ' +import json +import os +import sys + +slug = os.environ["CRABBOX_SMOKE_SLUG"] +try: + payload = json.load(sys.stdin) +except Exception as exc: + print(f"invalid JSON: {exc}", file=sys.stderr) + sys.exit(1) + +def has_slug(value): + if isinstance(value, dict): + labels = value.get("labels") + if isinstance(labels, dict) and labels.get("slug") == slug: + return True + if value.get("slug") == slug or value.get("name") == slug or value.get("id") == slug or value.get("leaseId") == slug: + return True + return any(has_slug(child) for child in value.values()) + if isinstance(value, list): + return any(has_slug(child) for child in value) + return False + +if not has_slug(payload): + print(f"list JSON did not include slug {slug}", file=sys.stderr) + sys.exit(1) +' <<<"$output" 2>&1)" + status=$? + set -e + if [[ "$status" -ne 0 ]]; then + classify_validation_failure "$command" "$status" "$validation_output" + exit "$status" + fi +} + +validate_list_json_missing_slug() { + local command="$1" + local output="$2" + local validation_output="" + local status=0 + set +e + validation_output="$(CRABBOX_SMOKE_SLUG="$slug" python3 -c ' +import json +import os +import sys + +slug = os.environ["CRABBOX_SMOKE_SLUG"] +try: + payload = json.load(sys.stdin) +except Exception as exc: + print(f"invalid JSON: {exc}", file=sys.stderr) + sys.exit(1) + +def has_slug(value): + if isinstance(value, dict): + labels = value.get("labels") + if isinstance(labels, dict) and labels.get("slug") == slug: + return True + if value.get("slug") == slug or value.get("name") == slug or value.get("id") == slug or value.get("leaseId") == slug: + return True + return any(has_slug(child) for child in value.values()) + if isinstance(value, list): + return any(has_slug(child) for child in value) + return False + +if has_slug(payload): + print(f"list JSON still included slug {slug}", file=sys.stderr) + sys.exit(1) +' <<<"$output" 2>&1)" + status=$? + set -e + if [[ "$status" -ne 0 ]]; then + classify_validation_failure "$command" "$status" "$validation_output" + exit "$status" + fi +} + +validate_remote_list_json_missing_slug() { + local command="$1" + local output="$2" + local validation_output="" + local status=0 + set +e + validation_output="$(CRABBOX_SMOKE_SLUG="$slug" python3 -c ' +import json +import os +import sys + +slug = os.environ["CRABBOX_SMOKE_SLUG"] +try: + payload = json.load(sys.stdin) +except Exception as exc: + print(f"invalid JSON: {exc}", file=sys.stderr) + sys.exit(1) + +for item in payload: + name = str(item.get("name", "")) + display_name = str(item.get("displayName", "")) + if slug in name or slug in display_name: + print(f"remote Codespaces inventory still included slug {slug}", file=sys.stderr) + sys.exit(1) +' <<<"$output" 2>&1)" + status=$? + set -e + if [[ "$status" -ne 0 ]]; then + classify_validation_failure "$command" "$status" "$validation_output" + exit "$status" + fi +} + +remote_codespace_names_for_slug() { + CRABBOX_SMOKE_SLUG="$slug" python3 -c ' +import json +import os +import sys + +slug = os.environ["CRABBOX_SMOKE_SLUG"] +for item in json.load(sys.stdin): + name = str(item.get("name", "")) + display_name = str(item.get("displayName", "")) + if name and (slug in name or slug in display_name): + print(name) +' +} + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$root" + +cleanup_armed=0 +slug="gcs-$(date +%Y%m%d%H%M%S)-$$" +crabbox_bin="${CRABBOX_BIN:-bin/crabbox}" + +cleanup() { + local status=$? + if [[ "$cleanup_armed" -eq 1 ]]; then + local cleanup_output="" + local cleanup_status=0 + set +e + cleanup_output="$("$crabbox_bin" stop --provider github-codespaces "$slug" 2>&1)" + cleanup_status=$? + set -e + if [[ "$cleanup_status" -ne 0 ]]; then + printf 'classification=cleanup_failed command=%q exit=%s slug=%s\n' "$crabbox_bin stop --provider github-codespaces $slug" "$cleanup_status" "$slug" >&2 + redact_output "$cleanup_output" >&2 + printf '\n' >&2 + if [[ "$status" -eq 0 ]]; then + status="$cleanup_status" + fi + fi + + local remote_output="" + local remote_status=0 + set +e + remote_output="$("$gh_bin" codespace list --repo "$repo" --limit 100 --json name,displayName 2>&1)" + remote_status=$? + set -e + if [[ "$remote_status" -eq 0 ]]; then + local remote_name="" + while IFS= read -r remote_name; do + [[ -n "$remote_name" ]] || continue + set +e + cleanup_output="$("$gh_bin" codespace delete --codespace "$remote_name" --force 2>&1)" + cleanup_status=$? + set -e + if [[ "$cleanup_status" -ne 0 ]]; then + printf 'classification=cleanup_failed command=%q exit=%s slug=%s\n' "$gh_bin codespace delete --codespace $remote_name --force" "$cleanup_status" "$slug" >&2 + redact_output "$cleanup_output" >&2 + printf '\n' >&2 + if [[ "$status" -eq 0 ]]; then + status="$cleanup_status" + fi + fi + done < <(printf '%s' "$remote_output" | remote_codespace_names_for_slug) + else + printf 'classification=cleanup_failed command=%q exit=%s slug=%s\n' "$gh_bin codespace list --repo $repo --limit 100 --json name,displayName" "$remote_status" "$slug" >&2 + redact_output "$remote_output" >&2 + printf '\n' >&2 + if [[ "$status" -eq 0 ]]; then + status="$remote_status" + fi + fi + cleanup_armed=0 + fi + exit "$status" +} +trap cleanup EXIT + +if [[ "${CRABBOX_LIVE:-}" != "1" ]]; then + printf 'classification=environment_blocked reason=CRABBOX_LIVE_not_enabled\n' + exit 0 +fi + +if ! provider_enabled; then + printf 'classification=environment_blocked reason=github_codespaces_not_selected providers=%q\n' "${CRABBOX_LIVE_PROVIDERS:-}" + exit 0 +fi + +repo="${CRABBOX_GITHUB_CODESPACES_SMOKE_REPO:-${CRABBOX_GITHUB_CODESPACES_REPO:-}}" +if [[ -z "$repo" ]]; then + printf 'classification=environment_blocked reason=CRABBOX_GITHUB_CODESPACES_SMOKE_REPO_missing\n' + exit 0 +fi + +if [[ -z "${GH_TOKEN:-}" && -z "${GITHUB_TOKEN:-}" && "${CRABBOX_GITHUB_CODESPACES_USE_GH_AUTH:-}" != "1" ]]; then + printf 'classification=credential_bound reason=github_token_missing_or_gh_auth_not_enabled\n' + exit 0 +fi + +gh_bin="${CRABBOX_GITHUB_CODESPACES_GH_PATH:-gh}" +if ! command -v "$gh_bin" >/dev/null 2>&1; then + printf 'classification=environment_blocked reason=gh_missing gh_path=%q\n' "$gh_bin" + exit 0 +fi + +auth_output="" +auth_status=0 +set +e +auth_output="$("$gh_bin" auth status 2>&1)" +auth_status=$? +set -e +if [[ "$auth_status" -ne 0 ]]; then + printf 'classification=credential_bound command=%q exit=%s\n' "$gh_bin auth status" "$auth_status" >&2 + redact_output "$auth_output" >&2 + printf '\n' >&2 + exit 0 +fi + +scope_output="" +scope_status=0 +set +e +scope_output="$("$gh_bin" codespace list --limit 1 2>&1)" +scope_status=$? +set -e +if [[ "$scope_status" -ne 0 ]]; then + printf 'classification=credential_bound command=%q exit=%s reason=github_codespaces_scope_missing\n' "$gh_bin codespace list --limit 1" "$scope_status" >&2 + redact_output "$scope_output" >&2 + printf '\n' >&2 + exit 0 +fi + +if [[ ! -x "$crabbox_bin" ]]; then + mkdir -p bin + go build -trimpath -o bin/crabbox ./cmd/crabbox + crabbox_bin="bin/crabbox" +fi + +ref="${CRABBOX_GITHUB_CODESPACES_SMOKE_REF:-${CRABBOX_GITHUB_CODESPACES_REF:-main}}" +machine="${CRABBOX_GITHUB_CODESPACES_SMOKE_MACHINE:-${CRABBOX_GITHUB_CODESPACES_MACHINE:-basicLinux32gb}}" +devcontainer="${CRABBOX_GITHUB_CODESPACES_SMOKE_DEVCONTAINER_PATH:-${CRABBOX_GITHUB_CODESPACES_DEVCONTAINER_PATH:-scripts/fixtures/github-codespaces/devcontainer.json}}" +working_directory="${CRABBOX_GITHUB_CODESPACES_SMOKE_WORKING_DIRECTORY:-${CRABBOX_GITHUB_CODESPACES_WORKING_DIRECTORY:-}}" +geo="${CRABBOX_GITHUB_CODESPACES_SMOKE_GEO:-${CRABBOX_GITHUB_CODESPACES_GEO:-}}" + +provider_args=(--provider github-codespaces --github-codespaces-repo "$repo" --github-codespaces-ref "$ref" --github-codespaces-machine "$machine" --github-codespaces-delete-on-release=true) +if [[ -n "$devcontainer" ]]; then + provider_args+=(--github-codespaces-devcontainer-path "$devcontainer") +fi +if [[ -n "$working_directory" ]]; then + provider_args+=(--github-codespaces-working-directory "$working_directory") +fi +if [[ -n "$geo" ]]; then + provider_args+=(--github-codespaces-geo "$geo") +fi + +run_capture "$crabbox_bin doctor ${provider_args[*]}" "$crabbox_bin" doctor "${provider_args[@]}" >/dev/null + +cleanup_armed=1 +run_capture "$crabbox_bin warmup ${provider_args[*]} --slug $slug --keep=false --ttl 20m --idle-timeout 5m" \ + "$crabbox_bin" warmup "${provider_args[@]}" --slug "$slug" --keep=false --ttl 20m --idle-timeout 5m >/dev/null + +run_capture "$crabbox_bin status --provider github-codespaces --id $slug --wait --wait-timeout 600s" \ + "$crabbox_bin" status --provider github-codespaces --id "$slug" --wait --wait-timeout 600s >/dev/null + +run_output="$(run_capture "$crabbox_bin run --provider github-codespaces --id $slug --full-resync -- sh -lc 'test -f go.mod && echo github-codespaces-smoke-ok'" \ + "$crabbox_bin" run --provider github-codespaces --id "$slug" --full-resync -- sh -lc 'test -f go.mod && echo github-codespaces-smoke-ok')" +if [[ "$run_output" != *"github-codespaces-smoke-ok"* ]]; then + classify_validation_failure "$crabbox_bin run --provider github-codespaces --id $slug" 1 "remote smoke marker not found" + exit 1 +fi + +ssh_output="$(run_capture "$crabbox_bin ssh --provider github-codespaces --id $slug" "$crabbox_bin" ssh --provider github-codespaces --id "$slug")" +if [[ "$ssh_output" != ssh* ]]; then + classify_validation_failure "$crabbox_bin ssh --provider github-codespaces --id $slug" 1 "ssh command was not printed" + exit 1 +fi + +list_output="$(run_capture "$crabbox_bin list --provider github-codespaces --json" "$crabbox_bin" list --provider github-codespaces --json)" +validate_list_json_contains_slug "$crabbox_bin list --provider github-codespaces --json" "$list_output" + +run_capture "$crabbox_bin stop --provider github-codespaces $slug" "$crabbox_bin" stop --provider github-codespaces "$slug" >/dev/null + +run_capture "$crabbox_bin cleanup --provider github-codespaces --dry-run" "$crabbox_bin" cleanup --provider github-codespaces --dry-run >/dev/null +final_list="$(run_capture "$crabbox_bin list --provider github-codespaces --json" "$crabbox_bin" list --provider github-codespaces --json)" +validate_list_json_missing_slug "$crabbox_bin list --provider github-codespaces --json" "$final_list" +remote_list="$(run_capture "$gh_bin codespace list --repo $repo --limit 100 --json name,displayName" "$gh_bin" codespace list --repo "$repo" --limit 100 --json name,displayName)" +validate_remote_list_json_missing_slug "$gh_bin codespace list --repo $repo --limit 100 --json name,displayName" "$remote_list" +cleanup_armed=0 + +printf 'classification=live_github_codespaces_smoke_passed slug=%s repo=%s machine=%s\n' "$slug" "$repo" "$machine" diff --git a/scripts/live-github-codespaces-smoke.test.js b/scripts/live-github-codespaces-smoke.test.js new file mode 100644 index 000000000..aca1d02a0 --- /dev/null +++ b/scripts/live-github-codespaces-smoke.test.js @@ -0,0 +1,347 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +const repoRoot = path.resolve(import.meta.dirname, ".."); + +function writeExecutable(file, body) { + fs.writeFileSync(file, body, "utf8"); + fs.chmodSync(file, 0o755); +} + +function prepareSmokeRepo(dir) { + const tempRoot = path.join(dir, "repo"); + const tempScripts = path.join(tempRoot, "scripts"); + const fixtureDir = path.join(tempScripts, "fixtures", "github-codespaces"); + const smokeScript = path.join(tempScripts, "live-github-codespaces-smoke.sh"); + fs.mkdirSync(fixtureDir, { recursive: true }); + fs.copyFileSync(path.join(repoRoot, "scripts", "live-github-codespaces-smoke.sh"), smokeScript); + fs.copyFileSync( + path.join(repoRoot, "scripts", "fixtures", "github-codespaces", "devcontainer.json"), + path.join(fixtureDir, "devcontainer.json"), + ); + fs.chmodSync(smokeScript, 0o755); + fs.writeFileSync(path.join(tempRoot, "go.mod"), "module example.org/smoke\n", "utf8"); + return { tempRoot, smokeScript }; +} + +function writeFakeGH(binDir, callsFile) { + writeExecutable( + path.join(binDir, "gh"), + `#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "$*" >>${JSON.stringify(callsFile)} +if [[ "$*" == "auth status" ]]; then + exit 0 +fi +if [[ "$*" == "codespace list --limit 1" ]]; then + printf '[]\\n' + exit 0 +fi +if [[ "$*" == codespace\\ list\\ --repo\\ *\\ --limit\\ 100\\ --json\\ name,displayName ]]; then + if [[ -n "\${CRABBOX_FAKE_REMOTE_SLUG_FILE:-}" && -f "$CRABBOX_FAKE_REMOTE_SLUG_FILE" ]]; then + slug="$(cat "$CRABBOX_FAKE_REMOTE_SLUG_FILE")" + printf '[{"name":"remote-owned-codespace","displayName":"%s"}]\\n' "$slug" + exit 0 + fi + printf '%s\\n' "\${CRABBOX_FAKE_REMOTE_CODESPACES:-[]}" + exit 0 +fi +if [[ "$*" == codespace\\ delete\\ --codespace\\ *\\ --force ]]; then + exit 0 +fi +printf 'unexpected gh args: %s\\n' "$*" >&2 +exit 97 +`, + ); +} + +function writeFakeCrabbox(file, body) { + writeExecutable( + file, + `#!/usr/bin/env bash +set -euo pipefail +${body} +`, + ); +} + +test("live github codespaces smoke skips unless opted in", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "crabbox-live-ghcs-skip-")); + const { tempRoot, smokeScript } = prepareSmokeRepo(dir); + const crabbox = path.join(dir, "crabbox"); + writeFakeCrabbox(crabbox, "exit 99"); + + const result = spawnSync("bash", [smokeScript], { + cwd: tempRoot, + env: { ...process.env, CRABBOX_BIN: crabbox, CRABBOX_LIVE: "", GH_TOKEN: "" }, + encoding: "utf8", + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /classification=environment_blocked reason=CRABBOX_LIVE_not_enabled/); +}); + +test("live github codespaces smoke skips unless provider filter selects it", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "crabbox-live-ghcs-filter-")); + const { tempRoot, smokeScript } = prepareSmokeRepo(dir); + const crabbox = path.join(dir, "crabbox"); + writeFakeCrabbox(crabbox, "exit 99"); + + const result = spawnSync("bash", [smokeScript], { + cwd: tempRoot, + env: { + ...process.env, + CRABBOX_BIN: crabbox, + CRABBOX_LIVE: "1", + CRABBOX_LIVE_PROVIDERS: "linode,digitalocean", + CRABBOX_GITHUB_CODESPACES_SMOKE_REPO: "example-org/my-app", + GH_TOKEN: "test-secret-token", + }, + encoding: "utf8", + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /classification=environment_blocked reason=github_codespaces_not_selected/); +}); + +test("live github codespaces smoke requires explicit credential source before gh", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "crabbox-live-ghcs-token-")); + const binDir = path.join(dir, "bin"); + const { tempRoot, smokeScript } = prepareSmokeRepo(dir); + const ghCalls = path.join(dir, "gh.log"); + fs.mkdirSync(binDir, { recursive: true }); + writeFakeGH(binDir, ghCalls); + + const result = spawnSync("bash", [smokeScript], { + cwd: tempRoot, + env: { + ...process.env, + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, + CRABBOX_LIVE: "1", + CRABBOX_LIVE_PROVIDERS: "github-codespaces", + CRABBOX_GITHUB_CODESPACES_SMOKE_REPO: "example-org/my-app", + GH_TOKEN: "", + GITHUB_TOKEN: "", + CRABBOX_GITHUB_CODESPACES_USE_GH_AUTH: "", + }, + encoding: "utf8", + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /classification=credential_bound reason=github_token_missing_or_gh_auth_not_enabled/); + assert.equal(fs.existsSync(ghCalls), false); +}); + +test("live github codespaces smoke stops before mutation when codespace scope is missing", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "crabbox-live-ghcs-scope-")); + const binDir = path.join(dir, "bin"); + const { tempRoot, smokeScript } = prepareSmokeRepo(dir); + const ghCalls = path.join(dir, "gh.log"); + fs.mkdirSync(binDir, { recursive: true }); + writeExecutable( + path.join(binDir, "gh"), + `#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "$*" >>${JSON.stringify(ghCalls)} +if [[ "$*" == "auth status" ]]; then + exit 0 +fi +if [[ "$*" == "codespace list --limit 1" ]]; then + printf 'HTTP 403: Must have admin rights to Repository. This API operation needs the "codespace" scope. token test-secret-token\\n' >&2 + exit 1 +fi +exit 97 +`, + ); + + const result = spawnSync("bash", [smokeScript], { + cwd: tempRoot, + env: { + ...process.env, + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, + CRABBOX_LIVE: "1", + CRABBOX_LIVE_PROVIDERS: "github-codespaces", + CRABBOX_GITHUB_CODESPACES_SMOKE_REPO: "example-org/my-app", + GH_TOKEN: "test-secret-token", + }, + encoding: "utf8", + }); + + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.match(result.stderr, /classification=credential_bound/); + assert.match(result.stderr, /reason=github_codespaces_scope_missing/); + assert.doesNotMatch(result.stdout + result.stderr, /test-secret-token/); + assert.deepEqual(fs.readFileSync(ghCalls, "utf8").trim().split("\n"), [ + "auth status", + "codespace list --limit 1", + ]); +}); + +test("live github codespaces smoke runs guarded lifecycle and redacts token", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "crabbox-live-ghcs-")); + const binDir = path.join(dir, "bin"); + const { tempRoot, smokeScript } = prepareSmokeRepo(dir); + const calls = path.join(dir, "calls.log"); + const ghCalls = path.join(dir, "gh.log"); + const slugFile = path.join(dir, "slug.txt"); + fs.mkdirSync(binDir, { recursive: true }); + writeFakeGH(binDir, ghCalls); + writeFakeCrabbox( + path.join(dir, "crabbox"), + `printf '%s\\n' "$*" >>${JSON.stringify(calls)} +if [[ "\${GH_TOKEN:-}" != "test-secret-token" ]]; then + printf 'missing token\\n' >&2 + exit 91 +fi +case "$1" in + doctor) + printf 'auth=ready control_plane=ready inventory=ready api=list mutation=false leases=0 runtime=unchecked\\n' + ;; + warmup) + for ((i=1; i<=$#; i++)); do + if [[ "\${!i}" == "--slug" ]]; then + j=$((i + 1)) + printf '%s\\n' "\${!j}" >${JSON.stringify(slugFile)} + fi + done + ;; + status) + printf 'status=ready\\n' + ;; + run) + printf 'github-codespaces-smoke-ok\\n' + ;; + ssh) + printf 'ssh -F /tmp/github-codespaces.conf codespace.example\\n' + ;; + list) + slug="$(cat ${JSON.stringify(slugFile)} 2>/dev/null || true)" + if [[ -z "$slug" || -f ${JSON.stringify(`${slugFile}.stopped`)} ]]; then + printf '[{"labels":{"slug":"unrelated-retained-lease"},"name":"unrelated-retained-lease"}]\\n' + else + printf '[{"labels":{"slug":"%s"},"name":"%s"}]\\n' "$slug" "$slug" + fi + ;; + stop) + printf stopped >${JSON.stringify(`${slugFile}.stopped`)} + ;; + cleanup) + printf 'skip codespace=none reason=missing claim\\n' + ;; + *) + printf 'unexpected args: %s\\n' "$*" >&2 + exit 99 + ;; +esac +`, + ); + + const result = spawnSync("bash", [smokeScript], { + cwd: tempRoot, + env: { + ...process.env, + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, + CRABBOX_BIN: path.join(dir, "crabbox"), + CRABBOX_LIVE: "1", + CRABBOX_LIVE_PROVIDERS: "codespaces", + CRABBOX_GITHUB_CODESPACES_SMOKE_REPO: "example-org/my-app", + CRABBOX_GITHUB_CODESPACES_SMOKE_REF: "main", + CRABBOX_GITHUB_CODESPACES_SMOKE_MACHINE: "basicLinux32gb", + GH_TOKEN: "test-secret-token", + }, + encoding: "utf8", + }); + + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.match(result.stdout, /classification=live_github_codespaces_smoke_passed/); + assert.doesNotMatch(result.stdout + result.stderr, /test-secret-token/); + + const seen = fs.readFileSync(calls, "utf8").trim().split("\n"); + const smokeSlug = seen[1].match(/--slug (\S+)/)?.[1] ?? ""; + assert.ok(smokeSlug.length > 0 && smokeSlug.length <= 31, smokeSlug); + assert.equal( + seen[0], + "doctor --provider github-codespaces --github-codespaces-repo example-org/my-app --github-codespaces-ref main --github-codespaces-machine basicLinux32gb --github-codespaces-delete-on-release=true --github-codespaces-devcontainer-path scripts/fixtures/github-codespaces/devcontainer.json", + ); + assert.match( + seen[1], + /^warmup --provider github-codespaces --github-codespaces-repo example-org\/my-app --github-codespaces-ref main --github-codespaces-machine basicLinux32gb --github-codespaces-delete-on-release=true --github-codespaces-devcontainer-path scripts\/fixtures\/github-codespaces\/devcontainer\.json --slug gcs-\d{14}-\d+ --keep=false --ttl 20m --idle-timeout 5m$/, + ); + assert.match(seen[2], /^status --provider github-codespaces --id gcs-\d{14}-\d+ --wait --wait-timeout 600s$/); + assert.match(seen[3], /^run --provider github-codespaces --id gcs-\d{14}-\d+ --full-resync -- sh -lc test -f go\.mod && echo github-codespaces-smoke-ok$/); + assert.match(seen[4], /^ssh --provider github-codespaces --id gcs-\d{14}-\d+$/); + assert.equal(seen[5], "list --provider github-codespaces --json"); + assert.match(seen[6], /^stop --provider github-codespaces gcs-\d{14}-\d+$/); + assert.equal(seen[7], "cleanup --provider github-codespaces --dry-run"); + assert.equal(seen[8], "list --provider github-codespaces --json"); + assert.deepEqual(fs.readFileSync(ghCalls, "utf8").trim().split("\n"), [ + "auth status", + "codespace list --limit 1", + "codespace list --repo example-org/my-app --limit 100 --json name,displayName", + ]); +}); + +test("live github codespaces smoke attempts cleanup after partial failure", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "crabbox-live-ghcs-fail-")); + const binDir = path.join(dir, "bin"); + const { tempRoot, smokeScript } = prepareSmokeRepo(dir); + const stopped = path.join(dir, "stopped.log"); + const remoteSlug = path.join(dir, "remote-slug.txt"); + const calls = path.join(dir, "calls.log"); + const ghCalls = path.join(dir, "gh.log"); + fs.mkdirSync(binDir, { recursive: true }); + writeFakeGH(binDir, ghCalls); + writeFakeCrabbox( + path.join(dir, "crabbox"), + `printf '%s\\n' "$*" >>${JSON.stringify(calls)} +if [[ "$1" == "doctor" ]]; then + printf 'auth=ready\\n' + exit 0 +fi +if [[ "$1" == "warmup" ]]; then + for ((i=1; i<=$#; i++)); do + if [[ "\${!i}" == "--slug" ]]; then + j=$((i + 1)) + printf '%s\\n' "\${!j}" >${JSON.stringify(remoteSlug)} + fi + done + printf 'created codespace before failing\\n' >&2 + exit 37 +fi +if [[ "$1" == "stop" ]]; then + printf '%s\\n' "$4" >>${JSON.stringify(stopped)} + exit 0 +fi +exit 99 +`, + ); + + const result = spawnSync("bash", [smokeScript], { + cwd: tempRoot, + env: { + ...process.env, + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, + CRABBOX_BIN: path.join(dir, "crabbox"), + CRABBOX_LIVE: "1", + CRABBOX_LIVE_PROVIDERS: "github-codespaces", + CRABBOX_GITHUB_CODESPACES_SMOKE_REPO: "example-org/my-app", + CRABBOX_FAKE_REMOTE_SLUG_FILE: remoteSlug, + GH_TOKEN: "test-secret-token", + }, + encoding: "utf8", + }); + + assert.equal(result.status, 37, result.stdout + result.stderr); + assert.match(result.stderr, /classification=environment_blocked/); + assert.match(result.stderr, /created codespace before failing/); + assert.match(fs.readFileSync(stopped, "utf8"), /^gcs-\d{14}-\d+\n$/); + assert.match( + fs.readFileSync(ghCalls, "utf8"), + /codespace delete --codespace remote-owned-codespace --force/, + ); + assert.doesNotMatch(result.stdout + result.stderr, /test-secret-token/); +}); diff --git a/scripts/live-smoke.sh b/scripts/live-smoke.sh index 5de3e1d93..6c386c654 100755 --- a/scripts/live-smoke.sh +++ b/scripts/live-smoke.sh @@ -1040,6 +1040,10 @@ if has_provider opensandbox; then "$root/scripts/live-opensandbox-smoke.sh" fi +if has_provider github-codespaces || has_provider codespaces || has_provider gh-codespaces; then + "$root/scripts/live-github-codespaces-smoke.sh" +fi + if has_provider proxmox; then "$root/scripts/proxmox-live-smoke.sh" fi diff --git a/scripts/live-smoke.test.js b/scripts/live-smoke.test.js index 4e672b175..4eccbd107 100644 --- a/scripts/live-smoke.test.js +++ b/scripts/live-smoke.test.js @@ -40,6 +40,55 @@ test("OpenSandbox live smoke dispatches to the provider-specific script", () => assert.match(result.stderr, /admin active-lease check skipped/); }); +test("GitHub Codespaces live smoke dispatches to the provider-specific script", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "crabbox-live-ghcs-shared-")); + const fakeCrabbox = path.join(dir, "crabbox"); + const log = path.join(dir, "calls.log"); + writeExecutable( + fakeCrabbox, + `#!/usr/bin/env bash +set -euo pipefail +printf '%s\\n' "$*" >>"${log}" +case "$*" in + "config path") + exit 0 + ;; + *) + printf 'unexpected crabbox args: %s\\n' "$*" >&2 + exit 99 + ;; +esac +`, + ); + + const result = spawnSync("bash", ["scripts/live-smoke.sh"], { + cwd: repoRoot, + env: { + ...process.env, + CRABBOX_BIN: fakeCrabbox, + CRABBOX_GITHUB_CODESPACES_REPO: "", + CRABBOX_GITHUB_CODESPACES_SMOKE_REPO: "", + CRABBOX_LIVE: "1", + CRABBOX_LIVE_COORDINATOR: "0", + CRABBOX_LIVE_PROVIDERS: "gh-codespaces", + CRABBOX_LIVE_REPO: repoRoot, + GH_TOKEN: "", + GITHUB_TOKEN: "", + }, + encoding: "utf8", + }); + + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.match(result.stdout, /classification=environment_blocked reason=CRABBOX_GITHUB_CODESPACES_SMOKE_REPO_missing/); + assert.match(result.stderr, /admin active-lease check skipped/); + const calls = fs.readFileSync(log, "utf8"); + assert.match(calls, /^config path$/m); + assert.doesNotMatch(calls, /^doctor --provider github-codespaces/m); + assert.doesNotMatch(calls, /^warmup /m); + assert.doesNotMatch(calls, /^run /m); + assert.doesNotMatch(calls, /^stop /m); +}); + test("Proxmox live smoke dispatches to the provider-specific proof script", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "crabbox-live-proxmox-")); const fakeCrabbox = path.join(dir, "crabbox");