From 72602cd626dc3c69dcf8801c934f78458921985c Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Sat, 13 Jun 2026 19:11:46 -0700 Subject: [PATCH 01/17] feat: add GitHub Codespaces provider contract Add the discoverable github-codespaces provider foundation with typed config, provider flags, redaction-safe client and gh runner boundaries, and OpenSSH config parsing for the future SSH lease lifecycle. Keep live Codespaces lifecycle behavior intentionally deferred to the next plan while making doctor fail closed until readiness is implemented. --- internal/cli/config.go | 133 ++++++++++ internal/cli/config_cmd.go | 16 ++ internal/cli/config_test.go | 124 +++++++++ internal/providers/all/all.go | 1 + internal/providers/all/all_test.go | 25 ++ internal/providers/githubcodespaces/client.go | 213 +++++++++++++++ .../providers/githubcodespaces/client_test.go | 99 +++++++ internal/providers/githubcodespaces/core.go | 61 +++++ internal/providers/githubcodespaces/flags.go | 136 ++++++++++ internal/providers/githubcodespaces/gh.go | 87 +++++++ .../providers/githubcodespaces/gh_test.go | 75 ++++++ .../providers/githubcodespaces/provider.go | 113 ++++++++ .../githubcodespaces/provider_test.go | 193 ++++++++++++++ .../providers/githubcodespaces/ssh_config.go | 243 ++++++++++++++++++ .../githubcodespaces/ssh_config_test.go | 122 +++++++++ 15 files changed, 1641 insertions(+) create mode 100644 internal/providers/githubcodespaces/client.go create mode 100644 internal/providers/githubcodespaces/client_test.go create mode 100644 internal/providers/githubcodespaces/core.go create mode 100644 internal/providers/githubcodespaces/flags.go create mode 100644 internal/providers/githubcodespaces/gh.go create mode 100644 internal/providers/githubcodespaces/gh_test.go create mode 100644 internal/providers/githubcodespaces/provider.go create mode 100644 internal/providers/githubcodespaces/provider_test.go create mode 100644 internal/providers/githubcodespaces/ssh_config.go create mode 100644 internal/providers/githubcodespaces/ssh_config_test.go diff --git a/internal/cli/config.go b/internal/cli/config.go index 6dc1fec34..fb1b5ee01 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -107,6 +107,7 @@ type Config struct { Linode LinodeConfig linodeImageExplicit bool linodeTypeExplicit bool + GitHubCodespaces GitHubCodespacesConfig Lambda LambdaConfig lambdaImageExplicit bool lambdaImageFamilyExplicit bool @@ -278,6 +279,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 @@ -1614,6 +1633,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" @@ -2349,6 +2404,14 @@ 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, + WorkRoot: "/workspaces/crabbox", + }, Lambda: LambdaConfig{ Region: "us-west-1", Type: "gpu_1x_a10", @@ -2761,6 +2824,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"` @@ -2892,6 +2956,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"` @@ -4308,6 +4387,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 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 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 @@ -6767,6 +6881,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 122574195..8ac866140 100644 --- a/internal/cli/config_cmd.go +++ b/internal/cli/config_cmd.go @@ -195,6 +195,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, @@ -627,6 +642,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, "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, "-")) fmt.Fprintf(w, "nebius cli=%s profile=%s parent_id=%s subnet_id=%s platform=%s preset=%s image_family=%s disk_type=%s disk_size_gib=%d user=%s public_ip=%s security_group_ids=%s service_account_id=%s recovery_policy=%s auth=cli\n", blank(cfg.Nebius.CLI, "-"), blank(cfg.Nebius.Profile, "-"), blank(cfg.Nebius.ParentID, "-"), blank(cfg.Nebius.SubnetID, "-"), blank(cfg.Nebius.Platform, "-"), blank(cfg.Nebius.Preset, "-"), blank(cfg.Nebius.ImageFamily, "-"), blank(cfg.Nebius.DiskType, "-"), cfg.Nebius.DiskSizeGiB, blank(cfg.Nebius.User, "-"), blank(cfg.Nebius.PublicIP, "-"), blank(strings.Join(cfg.Nebius.SecurityGroupIDs, ","), "-"), blank(cfg.Nebius.ServiceAccountID, "-"), blank(cfg.Nebius.RecoveryPolicy, "-")) diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go index a9f472591..27900e4db 100644 --- a/internal/cli/config_test.go +++ b/internal/cli/config_test.go @@ -441,6 +441,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", @@ -579,6 +591,118 @@ 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.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 TestGitHubCodespacesUntrustedConfigCannotRedirectEndpointOrCLI(t *testing.T) { + cfg := baseConfig() + cfg.GitHubCodespaces.APIURL = "https://api.trusted.example" + cfg.GitHubCodespaces.GHPath = "/trusted/gh" + if err := applyFileConfigWithTrust(&cfg, fileConfig{GitHubCodespaces: &fileGitHubCodespacesConfig{ + APIURL: "https://api.untrusted.example", + GHPath: "./payload", + Repo: "example-org/my-app", + }}, 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 != "example-org/my-app" { + t.Fatalf("safe untrusted repo not applied: %#v", cfg.GitHubCodespaces) + } +} + func TestNvidiaBrevUntrustedConfigCannotRedirectCLI(t *testing.T) { cfg := baseConfig() cfg.NvidiaBrev.CLI = "/trusted/brev" diff --git a/internal/providers/all/all.go b/internal/providers/all/all.go index ef92e5f9b..1551b36cd 100644 --- a/internal/providers/all/all.go +++ b/internal/providers/all/all.go @@ -26,6 +26,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 e520076ac..93d427fcd 100644 --- a/internal/providers/all/all_test.go +++ b/internal/providers/all/all_test.go @@ -383,6 +383,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 { @@ -1144,6 +1168,7 @@ func allBuiltInProviderNames() []string { "firecracker", "freestyle", "gcp", + "github-codespaces", "hetzner", "hostinger", "hyperv", diff --git a/internal/providers/githubcodespaces/client.go b/internal/providers/githubcodespaces/client.go new file mode 100644 index 000000000..0331679ce --- /dev/null +++ b/internal/providers/githubcodespaces/client.go @@ -0,0 +1,213 @@ +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"` +} + +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["location"] = 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) doJSON(ctx context.Context, method, path string, body any) (codespace, error) { + var reader io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return codespace{}, err + } + reader = bytes.NewReader(data) + } + httpReq, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader) + if err != nil { + return codespace{}, 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 codespace{}, err + } + defer resp.Body.Close() + data, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if readErr != nil { + return codespace{}, readErr + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return codespace{}, githubAPIError(resp.StatusCode, resp.Header.Get("Retry-After"), string(data)) + } + var out codespace + if err := json.Unmarshal(data, &out); err != nil { + return codespace{}, err + } + return out, nil +} + +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" + 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 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..d35f3234d --- /dev/null +++ b/internal/providers/githubcodespaces/client_test.go @@ -0,0 +1,99 @@ +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["location"] != "UsWest" || + gotBody["idle_timeout_minutes"].(float64) != 2 || + gotBody["retention_period_minutes"].(float64) != 1440 || + gotBody["display_name"] != "Crabbox" { + t.Fatalf("body=%#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 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"} { + 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..06f4db75e --- /dev/null +++ b/internal/providers/githubcodespaces/core.go @@ -0,0 +1,61 @@ +package githubcodespaces + +import ( + "flag" + + 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 AcquireRequest = core.AcquireRequest +type ResolveRequest = core.ResolveRequest +type ListRequest = core.ListRequest +type LeaseView = core.LeaseView +type ReleaseLeaseRequest = core.ReleaseLeaseRequest +type TouchRequest = core.TouchRequest +type LeaseTarget = core.LeaseTarget +type Server = core.Server +type SSHTarget = core.SSHTarget +type LocalCommandRequest = core.LocalCommandRequest +type LocalCommandResult = core.LocalCommandResult + +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 blank(value, fallback string) string { + return core.Blank(value, fallback) +} diff --git a/internal/providers/githubcodespaces/flags.go b/internal/providers/githubcodespaces/flags.go new file mode 100644 index 000000000..826d566db --- /dev/null +++ b/internal/providers/githubcodespaces/flags.go @@ -0,0 +1,136 @@ +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 cfg.Provider == providerName { + 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 cfg.Provider == providerName && 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 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..d72733b08 --- /dev/null +++ b/internal/providers/githubcodespaces/gh.go @@ -0,0 +1,87 @@ +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) 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..3bc2c458a --- /dev/null +++ b/internal/providers/githubcodespaces/provider.go @@ -0,0 +1,113 @@ +package githubcodespaces + +import ( + "context" + "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 &BackendSkeleton{spec: p.Spec(), cfg: cfg, rt: 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 +} + +type BackendSkeleton struct { + spec ProviderSpec + cfg Config + rt Runtime +} + +func (b *BackendSkeleton) Spec() ProviderSpec { return b.spec } + +func (b *BackendSkeleton) Doctor(context.Context, DoctorRequest) (DoctorResult, error) { + return DoctorResult{ + Provider: providerName, + Message: "auth=gh control_plane=unimplemented inventory=unimplemented mutation=false", + Status: "failed", + }, exit(2, "provider=github-codespaces doctor is not implemented yet") +} + +func (b *BackendSkeleton) Acquire(context.Context, AcquireRequest) (LeaseTarget, error) { + return LeaseTarget{}, exit(2, "provider=github-codespaces acquire is not implemented yet") +} + +func (b *BackendSkeleton) Resolve(context.Context, ResolveRequest) (LeaseTarget, error) { + return LeaseTarget{}, exit(2, "provider=github-codespaces resolve is not implemented yet") +} + +func (b *BackendSkeleton) List(context.Context, ListRequest) ([]LeaseView, error) { + return nil, exit(2, "provider=github-codespaces list is not implemented yet") +} + +func (b *BackendSkeleton) Touch(context.Context, TouchRequest) (Server, error) { + return Server{}, exit(2, "provider=github-codespaces touch is not implemented yet") +} + +func (b *BackendSkeleton) ReleaseLease(context.Context, ReleaseLeaseRequest) error { + return exit(2, "provider=github-codespaces release is not implemented yet") +} diff --git a/internal/providers/githubcodespaces/provider_test.go b/internal/providers/githubcodespaces/provider_test.go new file mode 100644 index 000000000..4fac8dc14 --- /dev/null +++ b/internal/providers/githubcodespaces/provider_test.go @@ -0,0 +1,193 @@ +package githubcodespaces + +import ( + "context" + "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, + }, + } + typeAlias := typeAliasDefaults + typeAlias.Provider = providerName + 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("--type machine=%q", 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 TestDoctorFailsUntilLifecycleIsImplemented(t *testing.T) { + backend := &BackendSkeleton{spec: Provider{}.Spec()} + result, err := backend.Doctor(context.Background(), DoctorRequest{}) + if err == nil { + t.Fatal("expected doctor to fail until lifecycle is implemented") + } + if result.Status != "failed" || !strings.Contains(err.Error(), "doctor is not implemented yet") { + t.Fatalf("result=%#v err=%v", result, err) + } +} + +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..561f20a21 --- /dev/null +++ b/internal/providers/githubcodespaces/ssh_config.go @@ -0,0 +1,243 @@ +package githubcodespaces + +import ( + "bufio" + "os" + "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, 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", alias) + } + if !validSSHUser(user) { + return SSHTarget{}, exit(2, "github-codespaces SSH config entry %q has invalid User %q", alias, user) + } + if strings.TrimSpace(entry.IdentityFile) == "" { + return SSHTarget{}, exit(2, "github-codespaces SSH config entry %q is missing IdentityFile", alias) + } + 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", alias) + } + if host == "" { + host = alias + } + 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", alias, 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 = proxy + } + return target, nil +} + +func selectSSHConfigEntry(data, alias string) (sshConfigEntry, 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 + } + var matches []sshConfigEntry + for _, entry := range entries { + for _, candidate := range entry.Aliases { + if candidate == alias { + matches = append(matches, entry) + break + } + } + } + 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], nil +} + +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 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..2e65bdb72 --- /dev/null +++ b/internal/providers/githubcodespaces/ssh_config_test.go @@ -0,0 +1,122 @@ +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 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) + } +} From 513175bd259997ed27f4e55ab80c2c5bb4dd44c4 Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Sat, 13 Jun 2026 20:29:12 -0700 Subject: [PATCH 02/17] feat(github-codespaces): implement SSH lease lifecycle Add claim-backed acquire, resolve, list, release, touch, cleanup, and doctor behavior for GitHub Codespaces, including generated OpenSSH config targets and conservative delete safety checks. Release and cleanup mutations now require local ownership claims, refuse dirty or unpushed codespaces before delete, and keep retained lease labels/endpoints consistent across stop and wake flows. Verification: go test ./internal/providers/githubcodespaces; go test -race ./internal/providers/githubcodespaces ./internal/providers/all ./internal/cli --- internal/cli/claim.go | 2 +- .../providers/githubcodespaces/backend.go | 796 ++++++++++++++++++ .../githubcodespaces/backend_test.go | 440 ++++++++++ internal/providers/githubcodespaces/client.go | 184 +++- .../providers/githubcodespaces/client_test.go | 85 +- internal/providers/githubcodespaces/core.go | 79 ++ internal/providers/githubcodespaces/gh.go | 24 + .../providers/githubcodespaces/provider.go | 39 +- .../githubcodespaces/provider_test.go | 12 - .../providers/githubcodespaces/ssh_config.go | 49 ++ 10 files changed, 1646 insertions(+), 64 deletions(-) create mode 100644 internal/providers/githubcodespaces/backend.go create mode 100644 internal/providers/githubcodespaces/backend_test.go diff --git a/internal/cli/claim.go b/internal/cli/claim.go index 54a8cebda..012d78942 100644 --- a/internal/cli/claim.go +++ b/internal/cli/claim.go @@ -538,7 +538,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/providers/githubcodespaces/backend.go b/internal/providers/githubcodespaces/backend.go new file mode 100644 index 000000000..6570e97b7 --- /dev/null +++ b/internal/providers/githubcodespaces/backend.go @@ -0,0 +1,796 @@ +package githubcodespaces + +import ( + "context" + "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 ( + 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 + } + 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 req.Keep || !githubCodespacesDeleteOnRelease(LeaseTarget{}, b.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: leaseProviderName(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 { + _ = api.deleteCodespace(context.Background(), created.Name) + _ = removeStoredSSHConfig(leaseID) + } + return LeaseTarget{}, err + } + if err := claimLeaseTargetForRepoConfig(leaseID, slug, b.cfg, server, SSHTarget{}, repoRoot, b.cfg.IdleTimeout, req.Reclaim); err != nil { + if !req.Keep { + _ = api.deleteCodespace(context.Background(), created.Name) + _ = removeStoredSSHConfig(leaseID) + } + return LeaseTarget{}, err + } + available, err := b.waitForAvailable(ctx, api, created.Name) + if err != nil { + if !req.Keep { + _ = api.deleteCodespace(context.Background(), created.Name) + _ = removeStoredSSHConfig(leaseID) + removeLeaseClaim(leaseID) + } + 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 { + _ = api.deleteCodespace(context.Background(), available.Name) + _ = removeStoredSSHConfig(leaseID) + removeLeaseClaim(leaseID) + } + return LeaseTarget{}, b.sshPrerequisiteError(err) + } + if err := b.waitSSH(ctx, &target, "github-codespaces ssh", b.readyTimeout); err != nil { + if !req.Keep { + _ = api.deleteCodespace(context.Background(), available.Name) + _ = removeStoredSSHConfig(leaseID) + removeLeaseClaim(leaseID) + } + 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 { + _ = api.deleteCodespace(context.Background(), available.Name) + _ = removeStoredSSHConfig(leaseID) + removeLeaseClaim(leaseID) + } + return LeaseTarget{}, err + } + } + if err := updateLeaseClaimEndpoint(leaseID, server, target); err != nil { + if !req.Keep { + _ = api.deleteCodespace(context.Background(), available.Name) + _ = removeStoredSSHConfig(leaseID) + removeLeaseClaim(leaseID) + } + 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) 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 { + 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) + server.Labels = touchDirectLeaseLabels(server.Labels, b.cfg, "ready", b.now().UTC()) + } + if codespaceTerminal(item.State) { + return LeaseTarget{}, exit(5, "github-codespaces codespace %s entered terminal state=%s", item.Name, item.State) + } + target, err := b.sshTarget(ctx, gh, leaseID, item.Name, firstNonEmpty(server.Labels[labelRepository], item.Repository.FullName), !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 err + } + } + err = api.deleteCodespace(ctx, name) + if err != nil && !isGitHubNotFound(err) { + return err + } + if err := removeLeaseClaimIfUnchanged(leaseID, claim); err != nil { + return err + } + return removeStoredSSHConfig(leaseID) + } + 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 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 { + return !githubCodespacesDeleteOnRelease(lease, b.cfg) +} + +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("GITHUB_TOKEN")) + if token == "" { + token = strings.TrimSpace(os.Getenv("GH_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) { + for { + item, err := api.getCodespace(ctx, 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 <-ctx.Done(): + return codespace{}, ctx.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.GitHubCodespaces.WorkRoot = b.effectiveWorkRoot(repo) + cfg.WorkRoot = cfg.GitHubCodespaces.WorkRoot + 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) + 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) labelsFor(leaseID, slug, repo, login string, keep bool, release string, item codespace, state string) map[string]string { + labels := directLeaseLabels(b.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) + 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..85508b4fb --- /dev/null +++ b/internal/providers/githubcodespaces/backend_test.go @@ -0,0 +1,440 @@ +package githubcodespaces + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +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-1" || 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-1" || claim.Labels[labelEnvironmentID] != "env-cs-1" { + t.Fatalf("claim=%#v", claim) + } + if fg.configFor != "cs-1" { + t.Fatalf("ssh config generated for %q", fg.configFor) + } +} + +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-stopped" { + 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-stopped" { + 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-readonly" { + 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 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 TestReleaseDeleteRefusesDirtyCodespace(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) + } + + err := b.ReleaseLease(context.Background(), ReleaseLeaseRequest{Lease: LeaseTarget{LeaseID: leaseID, Server: server}}) + if err == nil || !strings.Contains(err.Error(), "uncommitted or unpushed changes") { + t.Fatalf("err=%v", err) + } + if len(fc.deletes) != 0 { + t.Fatalf("deleted dirty codespace: %#v", fc.deletes) + } +} + +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) + } +} + +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 +} + +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(_ context.Context, name string) error { + f.deletes = append(f.deletes, name) + 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 %s + 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 index 0331679ce..3996c00ad 100644 --- a/internal/providers/githubcodespaces/client.go +++ b/internal/providers/githubcodespaces/client.go @@ -38,6 +38,20 @@ type codespace struct { 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 { @@ -111,7 +125,7 @@ func (c client) createCodespace(ctx context.Context, req createCodespaceRequest) body["working_directory"] = req.WorkingDirectory } if req.Geo != "" { - body["location"] = req.Geo + body["geo"] = req.Geo } if req.IdleTimeout > 0 { body["idle_timeout_minutes"] = durationMinutesCeil(req.IdleTimeout) @@ -125,18 +139,109 @@ func (c client) createCodespace(ctx context.Context, req createCodespaceRequest) 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") + } + return c.doJSON(ctx, http.MethodPost, "/user/codespaces/"+url.PathEscape(name)+"/start", 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, nil) +} + +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 codespace{}, err + return nil, err } reader = bytes.NewReader(data) } - httpReq, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader) + 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 codespace{}, err + return nil, err } httpReq.Header.Set("Accept", "application/vnd.github+json") httpReq.Header.Set("X-GitHub-Api-Version", "2022-11-28") @@ -148,21 +253,23 @@ func (c client) doJSON(ctx context.Context, method, path string, body any) (code } resp, err := c.httpClient.Do(httpReq) if err != nil { - return codespace{}, err + return nil, err } defer resp.Body.Close() data, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if readErr != nil { - return codespace{}, readErr + return nil, readErr } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return codespace{}, githubAPIError(resp.StatusCode, resp.Header.Get("Retry-After"), string(data)) + if accepted == nil { + accepted = map[int]bool{} } - var out codespace - if err := json.Unmarshal(data, &out); err != nil { - return codespace{}, err + 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 out, nil + return nil, githubAPIError(resp.StatusCode, resp.Header.Get("Retry-After"), string(data)) } func githubAPIError(status int, retryAfter, body string) error { @@ -198,6 +305,59 @@ func githubAPIError(status int, retryAfter, body string) error { 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 diff --git a/internal/providers/githubcodespaces/client_test.go b/internal/providers/githubcodespaces/client_test.go index d35f3234d..c14ff372a 100644 --- a/internal/providers/githubcodespaces/client_test.go +++ b/internal/providers/githubcodespaces/client_test.go @@ -53,7 +53,7 @@ func TestClientCreateCodespaceRequestShape(t *testing.T) { gotBody["machine"] != "standardLinux32gb" || gotBody["devcontainer_path"] != ".devcontainer/devcontainer.json" || gotBody["working_directory"] != "/workspaces/my-app" || - gotBody["location"] != "UsWest" || + gotBody["geo"] != "UsWest" || gotBody["idle_timeout_minutes"].(float64) != 2 || gotBody["retention_period_minutes"].(float64) != 1440 || gotBody["display_name"] != "Crabbox" { @@ -82,6 +82,89 @@ func TestFlexibleRefsDecodeRESTObjectsAndGHStrings(t *testing.T) { } } +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-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.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 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) + } + 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-1/stop", + "DELETE /api/v3/user/codespaces/space-1", + "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 { diff --git a/internal/providers/githubcodespaces/core.go b/internal/providers/githubcodespaces/core.go index 06f4db75e..07bc4a7af 100644 --- a/internal/providers/githubcodespaces/core.go +++ b/internal/providers/githubcodespaces/core.go @@ -1,7 +1,10 @@ package githubcodespaces import ( + "context" "flag" + "io" + "time" core "github.com/openclaw/crabbox/internal/cli" ) @@ -13,17 +16,21 @@ 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" @@ -59,3 +66,75 @@ func deleteOnReleaseExplicit(cfg Config) bool { func blank(value, fallback string) string { return core.Blank(value, fallback) } + +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 removeLeaseClaim(leaseID string) { + core.RemoveLeaseClaim(leaseID) +} + +func removeLeaseClaimIfUnchanged(leaseID string, expected LeaseClaim) error { + return core.RemoveLeaseClaimIfUnchanged(leaseID, expected) +} + +func shouldCleanupServer(server Server, now time.Time) (bool, string) { + return core.ShouldCleanupServer(server, now) +} + +func serverSlug(server Server) string { + return core.ServerSlug(server) +} + +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/gh.go b/internal/providers/githubcodespaces/gh.go index d72733b08..f89a5f3b1 100644 --- a/internal/providers/githubcodespaces/gh.go +++ b/internal/providers/githubcodespaces/gh.go @@ -20,6 +20,30 @@ func (r ghRunner) authStatus(ctx context.Context) error { 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 { diff --git a/internal/providers/githubcodespaces/provider.go b/internal/providers/githubcodespaces/provider.go index 3bc2c458a..5c9a95475 100644 --- a/internal/providers/githubcodespaces/provider.go +++ b/internal/providers/githubcodespaces/provider.go @@ -1,7 +1,6 @@ package githubcodespaces import ( - "context" "flag" core "github.com/openclaw/crabbox/internal/cli" @@ -61,7 +60,7 @@ func (p Provider) Configure(cfg Config, rt Runtime) (Backend, error) { if err := ValidateGitHubCodespacesConfig(cfg); err != nil { return nil, err } - return &BackendSkeleton{spec: p.Spec(), cfg: cfg, rt: rt}, nil + return newBackend(p.Spec(), cfg, rt), nil } func (p Provider) ConfigureDoctor(cfg Config, rt Runtime) (core.DoctorBackend, error) { @@ -75,39 +74,3 @@ func (p Provider) ConfigureDoctor(cfg Config, rt Runtime) (core.DoctorBackend, e } return doctor, nil } - -type BackendSkeleton struct { - spec ProviderSpec - cfg Config - rt Runtime -} - -func (b *BackendSkeleton) Spec() ProviderSpec { return b.spec } - -func (b *BackendSkeleton) Doctor(context.Context, DoctorRequest) (DoctorResult, error) { - return DoctorResult{ - Provider: providerName, - Message: "auth=gh control_plane=unimplemented inventory=unimplemented mutation=false", - Status: "failed", - }, exit(2, "provider=github-codespaces doctor is not implemented yet") -} - -func (b *BackendSkeleton) Acquire(context.Context, AcquireRequest) (LeaseTarget, error) { - return LeaseTarget{}, exit(2, "provider=github-codespaces acquire is not implemented yet") -} - -func (b *BackendSkeleton) Resolve(context.Context, ResolveRequest) (LeaseTarget, error) { - return LeaseTarget{}, exit(2, "provider=github-codespaces resolve is not implemented yet") -} - -func (b *BackendSkeleton) List(context.Context, ListRequest) ([]LeaseView, error) { - return nil, exit(2, "provider=github-codespaces list is not implemented yet") -} - -func (b *BackendSkeleton) Touch(context.Context, TouchRequest) (Server, error) { - return Server{}, exit(2, "provider=github-codespaces touch is not implemented yet") -} - -func (b *BackendSkeleton) ReleaseLease(context.Context, ReleaseLeaseRequest) error { - return exit(2, "provider=github-codespaces release is not implemented yet") -} diff --git a/internal/providers/githubcodespaces/provider_test.go b/internal/providers/githubcodespaces/provider_test.go index 4fac8dc14..c94f0b6f0 100644 --- a/internal/providers/githubcodespaces/provider_test.go +++ b/internal/providers/githubcodespaces/provider_test.go @@ -1,7 +1,6 @@ package githubcodespaces import ( - "context" "flag" "strings" "testing" @@ -143,17 +142,6 @@ func TestNoTokenFlagRegistered(t *testing.T) { }) } -func TestDoctorFailsUntilLifecycleIsImplemented(t *testing.T) { - backend := &BackendSkeleton{spec: Provider{}.Spec()} - result, err := backend.Doctor(context.Background(), DoctorRequest{}) - if err == nil { - t.Fatal("expected doctor to fail until lifecycle is implemented") - } - if result.Status != "failed" || !strings.Contains(err.Error(), "doctor is not implemented yet") { - t.Fatalf("result=%#v err=%v", result, err) - } -} - func TestValidateGitHubCodespacesConfig(t *testing.T) { valid := core.Config{ Provider: providerName, diff --git a/internal/providers/githubcodespaces/ssh_config.go b/internal/providers/githubcodespaces/ssh_config.go index 561f20a21..4d420936c 100644 --- a/internal/providers/githubcodespaces/ssh_config.go +++ b/internal/providers/githubcodespaces/ssh_config.go @@ -3,6 +3,7 @@ package githubcodespaces import ( "bufio" "os" + "path/filepath" "strconv" "strings" "unicode" @@ -154,6 +155,54 @@ func validatePrivateSSHConfigFile(path string) error { 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 == "" { From a25937103d53d64e9e393845ec6484f86bca946c Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Sat, 13 Jun 2026 20:49:30 -0700 Subject: [PATCH 03/17] docs(github-codespaces): add docs and live smoke Document the direct GitHub Codespaces provider, add generated matrix metadata, and add a guarded live smoke with deterministic gating/redaction tests. --- docs/providers/README.md | 3 +- docs/providers/github-codespaces.md | 261 ++++++++++++++++++ docs/providers/provider-metadata.json | 14 + docs/source-map.md | 6 +- scripts/live-github-codespaces-smoke.sh | 262 ++++++++++++++++++ scripts/live-github-codespaces-smoke.test.js | 263 +++++++++++++++++++ 6 files changed, 806 insertions(+), 3 deletions(-) create mode 100644 docs/providers/github-codespaces.md create mode 100755 scripts/live-github-codespaces-smoke.sh create mode 100644 scripts/live-github-codespaces-smoke.test.js diff --git a/docs/providers/README.md b/docs/providers/README.md index 962128d4e..75a325477 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: 65 providers (38 SSH lease, 25 delegated run, 2 service control). +Current built-in surface: 66 providers (39 SSH lease, 25 delegated run, 2 service control). Access terms: @@ -96,6 +96,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..b9fbdeccb --- /dev/null +++ b/docs/providers/github-codespaces.md @@ -0,0 +1,261 @@ +# 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. +- 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 + +```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. 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` | 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-github-codespaces-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. + +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 b87ac63ce..b8e92df39 100644 --- a/docs/providers/provider-metadata.json +++ b/docs/providers/provider-metadata.json @@ -349,6 +349,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 e3558745a..589b5a476 100644 --- a/docs/source-map.md +++ b/docs/source-map.md @@ -76,6 +76,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` @@ -146,7 +148,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/blaxel.md` for delegated Blaxel sandbox execution, `docs/providers/apple-vz.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/lambda.md` for the direct Lambda GPU SSH lease 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/blaxel.md` for delegated Blaxel sandbox execution, `docs/providers/apple-vz.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/lambda.md` for the direct Lambda GPU SSH lease 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` - Tailscale contract: `docs/features/tailscale.md` @@ -234,5 +236,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`, 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`, and `scripts/live-superserve-smoke.sh` - Live coordinator auth smoke coverage: `scripts/live-auth-smoke.sh` diff --git a/scripts/live-github-codespaces-smoke.sh b/scripts/live-github-codespaces-smoke.sh new file mode 100755 index 000000000..1f00d15d7 --- /dev/null +++ b/scripts/live-github-codespaces-smoke.sh @@ -0,0 +1,262 @@ +#!/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_empty() { + local command="$1" + local output="$2" + local validation_output="" + local status=0 + set +e + validation_output="$(python3 -c ' +import json +import sys + +try: + payload = json.load(sys.stdin) +except Exception as exc: + print(f"invalid JSON: {exc}", file=sys.stderr) + sys.exit(1) + +if payload != []: + print("GitHub Codespaces Crabbox inventory is not empty", 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 +} + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$root" + +cleanup_armed=0 +slug="github-codespaces-smoke-$(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" -eq 0 ]]; then + cleanup_armed=0 + else + 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 + 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 + +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:-.devcontainer/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-devcontainer-path "$devcontainer" --github-codespaces-delete-on-release=true) +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 +cleanup_armed=0 + +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_empty "$crabbox_bin list --provider github-codespaces --json" "$final_list" + +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..77dfa3f25 --- /dev/null +++ b/scripts/live-github-codespaces-smoke.test.js @@ -0,0 +1,263 @@ +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 smokeScript = path.join(tempScripts, "live-github-codespaces-smoke.sh"); + fs.mkdirSync(tempScripts, { recursive: true }); + fs.copyFileSync(path.join(repoRoot, "scripts", "live-github-codespaces-smoke.sh"), smokeScript); + 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 +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 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 '[]\\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", + CRABBOX_GITHUB_CODESPACES_SMOKE_DEVCONTAINER_PATH: ".devcontainer/devcontainer.json", + 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"); + 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-devcontainer-path .devcontainer/devcontainer.json --github-codespaces-delete-on-release=true", + ); + 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-devcontainer-path \.devcontainer\/devcontainer\.json --github-codespaces-delete-on-release=true --slug github-codespaces-smoke-\d{14}-\d+ --keep=false --ttl 20m --idle-timeout 5m$/, + ); + assert.match(seen[2], /^status --provider github-codespaces --id github-codespaces-smoke-\d{14}-\d+ --wait --wait-timeout 600s$/); + assert.match(seen[3], /^run --provider github-codespaces --id github-codespaces-smoke-\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 github-codespaces-smoke-\d{14}-\d+$/); + assert.equal(seen[5], "list --provider github-codespaces --json"); + assert.match(seen[6], /^stop --provider github-codespaces github-codespaces-smoke-\d{14}-\d+$/); + assert.equal(seen[7], "cleanup --provider github-codespaces --dry-run"); + assert.equal(seen[8], "list --provider github-codespaces --json"); +}); + +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 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 + 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", + 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"), /^github-codespaces-smoke-\d{14}-\d+\n$/); + assert.doesNotMatch(result.stdout + result.stderr, /test-secret-token/); +}); From 73577b0e51270649843aeea3cc200274ebfcd5e7 Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Sat, 13 Jun 2026 21:36:00 -0700 Subject: [PATCH 04/17] fix(github-codespaces): harden lifecycle defaults Align the GitHub Codespaces backend with the documented default cleanup policy, GitHub CLI token precedence, bounded provisioning waits, explicit generic work root handling, and the real gh SSH config Host alias shape. --- internal/cli/config.go | 1 + internal/cli/config_test.go | 1 + .../providers/githubcodespaces/backend.go | 20 ++++-- .../githubcodespaces/backend_test.go | 71 +++++++++++++++++-- internal/providers/githubcodespaces/core.go | 4 ++ .../providers/githubcodespaces/ssh_config.go | 67 +++++++++++++---- .../githubcodespaces/ssh_config_test.go | 15 ++++ 7 files changed, 154 insertions(+), 25 deletions(-) diff --git a/internal/cli/config.go b/internal/cli/config.go index fb1b5ee01..d63fc4705 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -2410,6 +2410,7 @@ func baseConfig() Config { Machine: "basicLinux32gb", IdleTimeout: 30 * time.Minute, RetentionPeriod: 7 * 24 * time.Hour, + DeleteOnRelease: true, WorkRoot: "/workspaces/crabbox", }, Lambda: LambdaConfig{ diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go index 27900e4db..90912ed2b 100644 --- a/internal/cli/config_test.go +++ b/internal/cli/config_test.go @@ -599,6 +599,7 @@ func TestGitHubCodespacesConfigDefaultsFileEnvAndShow(t *testing.T) { 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) } diff --git a/internal/providers/githubcodespaces/backend.go b/internal/providers/githubcodespaces/backend.go index 6570e97b7..fcdf7d4d9 100644 --- a/internal/providers/githubcodespaces/backend.go +++ b/internal/providers/githubcodespaces/backend.go @@ -463,9 +463,9 @@ func (b *backend) controlPlane(ctx context.Context) (githubCLI, codespacesAPI, s if err != nil { return nil, nil, "", err } - token := strings.TrimSpace(os.Getenv("GITHUB_TOKEN")) + token := strings.TrimSpace(os.Getenv("GH_TOKEN")) if token == "" { - token = strings.TrimSpace(os.Getenv("GH_TOKEN")) + token = strings.TrimSpace(os.Getenv("GITHUB_TOKEN")) } if token == "" { token, err = gh.authToken(ctx) @@ -477,8 +477,15 @@ func (b *backend) controlPlane(ctx context.Context) (githubCLI, codespacesAPI, s } 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(ctx, name) + item, err := api.getCodespace(waitCtx, name) if err != nil { return codespace{}, err } @@ -489,8 +496,8 @@ func (b *backend) waitForAvailable(ctx context.Context, api codespacesAPI, name return codespace{}, exit(5, "github-codespaces codespace %s entered terminal state=%s", name, item.State) } select { - case <-ctx.Done(): - return codespace{}, ctx.Err() + case <-waitCtx.Done(): + return codespace{}, waitCtx.Err() case <-time.After(b.pollInterval): } } @@ -544,6 +551,9 @@ func (b *backend) githubIdleTimeout() time.Duration { 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 != "" { diff --git a/internal/providers/githubcodespaces/backend_test.go b/internal/providers/githubcodespaces/backend_test.go index 85508b4fb..9e1794771 100644 --- a/internal/providers/githubcodespaces/backend_test.go +++ b/internal/providers/githubcodespaces/backend_test.go @@ -2,12 +2,15 @@ package githubcodespaces import ( "context" + "errors" "fmt" "os" "path/filepath" "strings" "testing" "time" + + core "github.com/openclaw/crabbox/internal/cli" ) func TestAcquireCreatesClaimGeneratesSSHConfigAndWaitsReady(t *testing.T) { @@ -46,7 +49,7 @@ func TestAcquireCreatesClaimGeneratesSSHConfigAndWaitsReady(t *testing.T) { t.Fatalf("waits=%#v", b.waits) } wait := b.waits[0] - if wait.User != "vscode" || wait.Host != "cs-1" || wait.Key != "/tmp/codespaces/key" || !wait.SSHConfigProxy { + 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'") { @@ -56,7 +59,7 @@ func TestAcquireCreatesClaimGeneratesSSHConfigAndWaitsReady(t *testing.T) { if err != nil || !ok { t.Fatalf("claim ok=%t err=%v", ok, err) } - if claim.CloudID != "cs-1" || claim.SSHHost != "cs-1" || claim.Labels[labelEnvironmentID] != "env-cs-1" { + if claim.CloudID != "cs-1" || claim.SSHHost != "cs.cs-1.main" || claim.Labels[labelEnvironmentID] != "env-cs-1" { t.Fatalf("claim=%#v", claim) } if fg.configFor != "cs-1" { @@ -88,14 +91,14 @@ func TestResolveStartsStoppedCodespaceAndRefreshesTarget(t *testing.T) { 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-stopped" { + 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-stopped" { + if claim.Labels[labelState] != "ready" || claim.SSHHost != "cs.cs-stopped.main" { t.Fatalf("claim=%#v", claim) } } @@ -117,7 +120,7 @@ func TestResolveNoLocalStateMutationsDoesNotStoreSSHConfig(t *testing.T) { if err != nil { t.Fatal(err) } - if lease.SSH.Host != "cs-readonly" { + if lease.SSH.Host != "cs.cs-readonly.main" { t.Fatalf("lease=%#v", lease) } stored := filepath.Join(stateHome, "crabbox", "github-codespaces", leaseID+".ssh_config") @@ -287,6 +290,62 @@ func TestDoctorIsNonMutating(t *testing.T) { } } +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) + } +} + type testBackend struct { *backend waits []SSHTarget @@ -420,7 +479,7 @@ func (f *fakeGH) codespaceSSHConfig(_ context.Context, codespace string) (string return f.config(codespace), nil } func (f *fakeGH) config(codespace string) string { - return fmt.Sprintf(`Host %s + return fmt.Sprintf(`Host cs.%s.main User vscode IdentityFile "/tmp/codespaces/key" UserKnownHostsFile /dev/null diff --git a/internal/providers/githubcodespaces/core.go b/internal/providers/githubcodespaces/core.go index 07bc4a7af..b588841a8 100644 --- a/internal/providers/githubcodespaces/core.go +++ b/internal/providers/githubcodespaces/core.go @@ -63,6 +63,10 @@ func deleteOnReleaseExplicit(cfg Config) bool { return core.DeleteOnReleaseExplicit(cfg, providerName) } +func workRootExplicit(cfg *Config) bool { + return core.IsWorkRootExplicit(cfg) +} + func blank(value, fallback string) string { return core.Blank(value, fallback) } diff --git a/internal/providers/githubcodespaces/ssh_config.go b/internal/providers/githubcodespaces/ssh_config.go index 4d420936c..b27086298 100644 --- a/internal/providers/githubcodespaces/ssh_config.go +++ b/internal/providers/githubcodespaces/ssh_config.go @@ -67,34 +67,34 @@ func parseSSHConfig(data string) ([]sshConfigEntry, error) { } func selectSSHTarget(cfg Config, data, alias string) (SSHTarget, error) { - entry, err := selectSSHConfigEntry(data, alias) + 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", alias) + 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", alias, 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", alias) + 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", alias) + return SSHTarget{}, exit(2, "github-codespaces SSH config entry %q is missing HostName or ProxyCommand", selectedAlias) } if host == "" { - host = alias + 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", alias, port) + return SSHTarget{}, exit(2, "github-codespaces SSH config entry %q has invalid Port %q", selectedAlias, port) } target := SSHTarget{ User: user, @@ -113,31 +113,70 @@ func selectSSHTarget(cfg Config, data, alias string) (SSHTarget, error) { return target, nil } -func selectSSHConfigEntry(data, alias string) (sshConfigEntry, error) { +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") + return sshConfigEntry{}, "", exit(2, "github-codespaces SSH config host alias is required") } entries, err := parseSSHConfig(data) if err != nil { - return sshConfigEntry{}, err + return sshConfigEntry{}, "", err } - var matches []sshConfigEntry + 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 { - return sshConfigEntry{}, exit(4, "github-codespaces SSH config entry not found for host %q", alias) + 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 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 matches[0], nil + return false } func validatePrivateSSHConfigFile(path string) error { diff --git a/internal/providers/githubcodespaces/ssh_config_test.go b/internal/providers/githubcodespaces/ssh_config_test.go index 2e65bdb72..f8c610d7d 100644 --- a/internal/providers/githubcodespaces/ssh_config_test.go +++ b/internal/providers/githubcodespaces/ssh_config_test.go @@ -33,6 +33,21 @@ func TestSSHConfigParsesProxyTarget(t *testing.T) { } } +func TestSSHConfigSelectsGeneratedGitHubCLIAliasByProxyCodespace(t *testing.T) { + target, err := selectSSHTarget(Config{}, `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) + } +} + func TestSSHConfigParsesDirectTarget(t *testing.T) { target, err := selectSSHTarget(Config{}, `Host sturdy-space HostName 127.0.0.1 From c16df0500cc37d803abdae7200b6fc52eb27377c Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Sat, 13 Jun 2026 21:44:55 -0700 Subject: [PATCH 05/17] fix(github-codespaces): scope live smoke cleanup check Validate that the guarded GitHub Codespaces smoke lease is absent after cleanup without failing on unrelated retained claim-owned Codespaces leases. --- scripts/live-github-codespaces-smoke.sh | 24 ++++++++++++++++---- scripts/live-github-codespaces-smoke.test.js | 2 +- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/scripts/live-github-codespaces-smoke.sh b/scripts/live-github-codespaces-smoke.sh index 1f00d15d7..9fdb000d5 100755 --- a/scripts/live-github-codespaces-smoke.sh +++ b/scripts/live-github-codespaces-smoke.sh @@ -108,24 +108,38 @@ if not has_slug(payload): fi } -validate_list_json_empty() { +validate_list_json_missing_slug() { local command="$1" local output="$2" local validation_output="" local status=0 set +e - validation_output="$(python3 -c ' + 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) -if payload != []: - print("GitHub Codespaces Crabbox inventory is not empty", file=sys.stderr) +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=$? @@ -257,6 +271,6 @@ cleanup_armed=0 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_empty "$crabbox_bin list --provider github-codespaces --json" "$final_list" +validate_list_json_missing_slug "$crabbox_bin list --provider github-codespaces --json" "$final_list" 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 index 77dfa3f25..9f2851a0c 100644 --- a/scripts/live-github-codespaces-smoke.test.js +++ b/scripts/live-github-codespaces-smoke.test.js @@ -155,7 +155,7 @@ case "$1" in list) slug="$(cat ${JSON.stringify(slugFile)} 2>/dev/null || true)" if [[ -z "$slug" || -f ${JSON.stringify(`${slugFile}.stopped`)} ]]; then - printf '[]\\n' + printf '[{"labels":{"slug":"unrelated-retained-lease"},"name":"unrelated-retained-lease"}]\\n' else printf '[{"labels":{"slug":"%s"},"name":"%s"}]\\n' "$slug" "$slug" fi From e6ebb857c2848d39634016742db90d40fcbc2ac1 Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Sat, 13 Jun 2026 21:56:59 -0700 Subject: [PATCH 06/17] fix(github-codespaces): propagate runtime SSH config Persist the effective Codespaces work root into lease labels and claims, and rewrite generated gh SSH proxy commands to honor the configured GitHub CLI path. --- .../providers/githubcodespaces/backend.go | 29 ++++++++++++++----- .../githubcodespaces/backend_test.go | 13 ++++++++- .../providers/githubcodespaces/ssh_config.go | 14 ++++++++- .../githubcodespaces/ssh_config_test.go | 5 +++- 4 files changed, 51 insertions(+), 10 deletions(-) diff --git a/internal/providers/githubcodespaces/backend.go b/internal/providers/githubcodespaces/backend.go index fcdf7d4d9..fe7f1a9e6 100644 --- a/internal/providers/githubcodespaces/backend.go +++ b/internal/providers/githubcodespaces/backend.go @@ -87,6 +87,7 @@ func (b *backend) Acquire(ctx context.Context, req AcquireRequest) (LeaseTarget, 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 } @@ -107,7 +108,7 @@ func (b *backend) Acquire(ctx context.Context, req AcquireRequest) (LeaseTarget, return LeaseTarget{}, err } release := releaseDelete - if req.Keep || !githubCodespacesDeleteOnRelease(LeaseTarget{}, b.cfg) { + if req.Keep || !githubCodespacesDeleteOnRelease(LeaseTarget{}, cfg) { release = releaseStop } created, err := api.createCodespace(ctx, createCodespaceRequest{ @@ -133,7 +134,7 @@ func (b *backend) Acquire(ctx context.Context, req AcquireRequest) (LeaseTarget, } return LeaseTarget{}, err } - if err := claimLeaseTargetForRepoConfig(leaseID, slug, b.cfg, server, SSHTarget{}, repoRoot, b.cfg.IdleTimeout, req.Reclaim); err != nil { + if err := claimLeaseTargetForRepoConfig(leaseID, slug, cfg, server, SSHTarget{}, repoRoot, cfg.IdleTimeout, req.Reclaim); err != nil { if !req.Keep { _ = api.deleteCodespace(context.Background(), created.Name) _ = removeStoredSSHConfig(leaseID) @@ -238,12 +239,18 @@ func (b *backend) Resolve(ctx context.Context, req ResolveRequest) (LeaseTarget, return LeaseTarget{}, err } server = b.mergeLiveServer(server, item) - server.Labels = touchDirectLeaseLabels(server.Labels, b.cfg, "ready", b.now().UTC()) } if codespaceTerminal(item.State) { return LeaseTarget{}, exit(5, "github-codespaces codespace %s entered terminal state=%s", item.Name, item.State) } - target, err := b.sshTarget(ctx, gh, leaseID, item.Name, firstNonEmpty(server.Labels[labelRepository], item.Repository.FullName), !req.NoLocalStateMutations) + 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) } @@ -514,8 +521,7 @@ func (b *backend) sshTarget(ctx context.Context, gh githubCLI, leaseID, codespac } } cfg := b.cfg - cfg.GitHubCodespaces.WorkRoot = b.effectiveWorkRoot(repo) - cfg.WorkRoot = cfg.GitHubCodespaces.WorkRoot + cfg = b.repoConfig(repo) return selectSSHTarget(cfg, data, codespaceName) } @@ -567,8 +573,16 @@ func (b *backend) effectiveWorkRoot(repo string) string { 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 (b *backend) labelsFor(leaseID, slug, repo, login string, keep bool, release string, item codespace, state string) map[string]string { - labels := directLeaseLabels(b.cfg, leaseID, slug, providerName, "", keep, b.now().UTC()) + cfg := b.repoConfig(repo) + labels := directLeaseLabels(cfg, leaseID, slug, providerName, "", keep, b.now().UTC()) labels[labelState] = state labels[labelRelease] = release labels[labelCodespaceName] = item.Name @@ -577,6 +591,7 @@ func (b *backend) labelsFor(leaseID, slug, repo, login string, keep bool, releas 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 } diff --git a/internal/providers/githubcodespaces/backend_test.go b/internal/providers/githubcodespaces/backend_test.go index 9e1794771..f398d723c 100644 --- a/internal/providers/githubcodespaces/backend_test.go +++ b/internal/providers/githubcodespaces/backend_test.go @@ -59,7 +59,7 @@ func TestAcquireCreatesClaimGeneratesSSHConfigAndWaitsReady(t *testing.T) { 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" { + 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" { @@ -346,6 +346,17 @@ func TestEffectiveWorkRootHonorsExplicitGenericWorkRoot(t *testing.T) { } } +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"]) + } +} + type testBackend struct { *backend waits []SSHTarget diff --git a/internal/providers/githubcodespaces/ssh_config.go b/internal/providers/githubcodespaces/ssh_config.go index b27086298..db125d64b 100644 --- a/internal/providers/githubcodespaces/ssh_config.go +++ b/internal/providers/githubcodespaces/ssh_config.go @@ -108,7 +108,7 @@ func selectSSHTarget(cfg Config, data, alias string) (SSHTarget, error) { } if proxy != "" { target.SSHConfigProxy = true - target.ProxyCommand = proxy + target.ProxyCommand = rewriteProxyCommandGHPath(proxy, cfg.GitHubCodespaces.GHPath) } return target, nil } @@ -179,6 +179,18 @@ func proxyCommandReferencesCodespace(command, name string) bool { 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 { diff --git a/internal/providers/githubcodespaces/ssh_config_test.go b/internal/providers/githubcodespaces/ssh_config_test.go index f8c610d7d..9199d3c19 100644 --- a/internal/providers/githubcodespaces/ssh_config_test.go +++ b/internal/providers/githubcodespaces/ssh_config_test.go @@ -34,7 +34,7 @@ func TestSSHConfigParsesProxyTarget(t *testing.T) { } func TestSSHConfigSelectsGeneratedGitHubCLIAliasByProxyCodespace(t *testing.T) { - target, err := selectSSHTarget(Config{}, `Host cs.sturdy-space.main + 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 @@ -46,6 +46,9 @@ func TestSSHConfigSelectsGeneratedGitHubCLIAliasByProxyCodespace(t *testing.T) { 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) { From c645e6d41c3a1387d4b77986fed2c415be42f5a8 Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Sat, 13 Jun 2026 22:15:25 -0700 Subject: [PATCH 07/17] fix(github-codespaces): cap display names Keep GitHub Codespaces display names within the documented limit for long but valid Crabbox slugs while preserving the collision-resistant suffix. Also assert that create requests continue using the current geo field rather than the legacy location field. --- .../providers/githubcodespaces/backend.go | 27 ++++++++++++++++++- .../githubcodespaces/backend_test.go | 10 +++++++ .../providers/githubcodespaces/client_test.go | 3 +++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/internal/providers/githubcodespaces/backend.go b/internal/providers/githubcodespaces/backend.go index fe7f1a9e6..a94a8e15d 100644 --- a/internal/providers/githubcodespaces/backend.go +++ b/internal/providers/githubcodespaces/backend.go @@ -120,7 +120,7 @@ func (b *backend) Acquire(ctx context.Context, req AcquireRequest) (LeaseTarget, Geo: strings.TrimSpace(b.cfg.GitHubCodespaces.Geo), IdleTimeout: b.githubIdleTimeout(), RetentionPeriod: b.cfg.GitHubCodespaces.RetentionPeriod, - DisplayName: leaseProviderName(leaseID, slug), + DisplayName: githubCodespacesDisplayName(leaseID, slug), }) if err != nil { return LeaseTarget{}, err @@ -580,6 +580,31 @@ func (b *backend) repoConfig(repo string) Config { 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()) diff --git a/internal/providers/githubcodespaces/backend_test.go b/internal/providers/githubcodespaces/backend_test.go index f398d723c..810468858 100644 --- a/internal/providers/githubcodespaces/backend_test.go +++ b/internal/providers/githubcodespaces/backend_test.go @@ -357,6 +357,16 @@ func TestLabelsCarryEffectiveWorkRoot(t *testing.T) { } } +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 diff --git a/internal/providers/githubcodespaces/client_test.go b/internal/providers/githubcodespaces/client_test.go index c14ff372a..bb9e2add3 100644 --- a/internal/providers/githubcodespaces/client_test.go +++ b/internal/providers/githubcodespaces/client_test.go @@ -59,6 +59,9 @@ func TestClientCreateCodespaceRequestShape(t *testing.T) { 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) } From 8834d9c1d17252685a4f1e2b2b34a529134cc63e Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Sat, 13 Jun 2026 22:44:22 -0700 Subject: [PATCH 08/17] fix(github-codespaces): retain dirty leases on release Fall back to stopping and retaining a Codespace when default delete-on-release is unsafe because the remote worktree has uncommitted or unpushed changes. This avoids turning successful runs into failed cleanup while still clearing stale SSH endpoints. --- internal/providers/githubcodespaces/backend.go | 8 ++++++-- .../providers/githubcodespaces/backend_test.go | 18 ++++++++++++------ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/internal/providers/githubcodespaces/backend.go b/internal/providers/githubcodespaces/backend.go index a94a8e15d..56fa2c7a7 100644 --- a/internal/providers/githubcodespaces/backend.go +++ b/internal/providers/githubcodespaces/backend.go @@ -330,7 +330,7 @@ func (b *backend) ReleaseLease(ctx context.Context, req ReleaseLeaseRequest) err } if err == nil { if err := validateDeleteSafe(item); err != nil { - return err + return b.stopCodespaceAndRetain(ctx, api, leaseID, claim, server, name) } } err = api.deleteCodespace(ctx, name) @@ -342,6 +342,10 @@ func (b *backend) ReleaseLease(ctx context.Context, req ReleaseLeaseRequest) 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 @@ -353,7 +357,7 @@ func (b *backend) ReleaseLease(ctx context.Context, req ReleaseLeaseRequest) err server.Labels[labelState] = "stopped" server.Labels[labelRelease] = releaseStop server.Labels[labelCodespaceName] = name - _, err = updateLeaseClaimEndpointIfUnchangedAfter(leaseID, claim, server, SSHTarget{}, func() error { + _, err := updateLeaseClaimEndpointIfUnchangedAfter(leaseID, claim, server, SSHTarget{}, func() error { return api.stopCodespace(ctx, name) }) return err diff --git a/internal/providers/githubcodespaces/backend_test.go b/internal/providers/githubcodespaces/backend_test.go index 810468858..f5038c743 100644 --- a/internal/providers/githubcodespaces/backend_test.go +++ b/internal/providers/githubcodespaces/backend_test.go @@ -172,7 +172,7 @@ func TestReleaseDeleteRequiresLocalClaim(t *testing.T) { } } -func TestReleaseDeleteRefusesDirtyCodespace(t *testing.T) { +func TestReleaseDeleteFallsBackToStopForDirtyCodespace(t *testing.T) { t.Setenv("XDG_STATE_HOME", t.TempDir()) fc := newFakeCodespacesClient() item := fakeCodespace("cs-dirty", "Available") @@ -186,12 +186,18 @@ func TestReleaseDeleteRefusesDirtyCodespace(t *testing.T) { t.Fatal(err) } - err := b.ReleaseLease(context.Background(), ReleaseLeaseRequest{Lease: LeaseTarget{LeaseID: leaseID, Server: server}}) - if err == nil || !strings.Contains(err.Error(), "uncommitted or unpushed changes") { - t.Fatalf("err=%v", err) + if err := b.ReleaseLease(context.Background(), ReleaseLeaseRequest{Lease: LeaseTarget{LeaseID: leaseID, Server: server}}); err != nil { + t.Fatal(err) } - if len(fc.deletes) != 0 { - t.Fatalf("deleted dirty codespace: %#v", fc.deletes) + 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) } } From c1299bca1f6d81311aa6199781c1ef987d5f3534 Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Sat, 13 Jun 2026 22:56:43 -0700 Subject: [PATCH 09/17] fix(github-codespaces): retain fallback claims Make the release-claim retention hook read the post-release claim state so dirty Codespaces that fall back from delete to stop are not orphaned by higher-level release finalizers. --- internal/providers/githubcodespaces/backend.go | 17 +++++++++++++++++ .../providers/githubcodespaces/backend_test.go | 6 ++++++ 2 files changed, 23 insertions(+) diff --git a/internal/providers/githubcodespaces/backend.go b/internal/providers/githubcodespaces/backend.go index 56fa2c7a7..30596339a 100644 --- a/internal/providers/githubcodespaces/backend.go +++ b/internal/providers/githubcodespaces/backend.go @@ -364,6 +364,9 @@ func (b *backend) stopCodespaceAndRetain(ctx context.Context, api codespacesAPI, } 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)) } @@ -371,9 +374,23 @@ func (b *backend) ReleaseLeaseMessage(lease LeaseTarget) string { } 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 { diff --git a/internal/providers/githubcodespaces/backend_test.go b/internal/providers/githubcodespaces/backend_test.go index f5038c743..7a8cd98c2 100644 --- a/internal/providers/githubcodespaces/backend_test.go +++ b/internal/providers/githubcodespaces/backend_test.go @@ -199,6 +199,12 @@ func TestReleaseDeleteFallsBackToStopForDirtyCodespace(t *testing.T) { 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) { From 9e369dbc4705eab222fb6805cffe0a1fabc3388e Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Sat, 13 Jun 2026 23:05:51 -0700 Subject: [PATCH 10/17] fix(github-codespaces): accept start no-op Treat GitHub Codespaces 304 Not Modified start responses as successful no-ops so resolving retained Codespaces can continue polling the existing codespace. --- internal/providers/githubcodespaces/client.go | 9 ++++++++- internal/providers/githubcodespaces/client_test.go | 6 ++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/internal/providers/githubcodespaces/client.go b/internal/providers/githubcodespaces/client.go index 3996c00ad..6f3197ed8 100644 --- a/internal/providers/githubcodespaces/client.go +++ b/internal/providers/githubcodespaces/client.go @@ -176,7 +176,14 @@ func (c client) startCodespace(ctx context.Context, name string) (codespace, err if name == "" { return codespace{}, exit(2, "github-codespaces codespace name is required") } - return c.doJSON(ctx, http.MethodPost, "/user/codespaces/"+url.PathEscape(name)+"/start", nil) + 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 { diff --git a/internal/providers/githubcodespaces/client_test.go b/internal/providers/githubcodespaces/client_test.go index bb9e2add3..4bd8f7690 100644 --- a/internal/providers/githubcodespaces/client_test.go +++ b/internal/providers/githubcodespaces/client_test.go @@ -101,6 +101,8 @@ func TestClientLifecycleOperationsRequestShape(t *testing.T) { _, _ = 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": @@ -124,6 +126,9 @@ func TestClientLifecycleOperationsRequestShape(t *testing.T) { 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) } @@ -139,6 +144,7 @@ func TestClientLifecycleOperationsRequestShape(t *testing.T) { "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", "GET /api/v3/repos/example-org/my-app/codespaces/machines?ref=main", From a27500b5f6b6ffbbfd6c8cfab1bd0ff7b1cf2c3b Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Sat, 13 Jun 2026 23:15:13 -0700 Subject: [PATCH 11/17] fix(github-codespaces): honor type aliases Apply the generic --type machine override for the canonical provider and advertised Codespaces aliases so alias-based invocations do not silently provision the default machine size. --- internal/providers/githubcodespaces/flags.go | 13 ++++++-- .../githubcodespaces/provider_test.go | 32 ++++++++++--------- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/internal/providers/githubcodespaces/flags.go b/internal/providers/githubcodespaces/flags.go index 826d566db..42715199f 100644 --- a/internal/providers/githubcodespaces/flags.go +++ b/internal/providers/githubcodespaces/flags.go @@ -37,7 +37,7 @@ func RegisterGitHubCodespacesProviderFlags(fs *flag.FlagSet, defaults Config) an } func ApplyGitHubCodespacesProviderFlags(cfg *Config, fs *flag.FlagSet, values any) error { - if cfg.Provider == providerName { + 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") } @@ -92,7 +92,7 @@ func ApplyGitHubCodespacesProviderFlags(cfg *Config, fs *flag.FlagSet, values an } func ValidateGitHubCodespacesConfig(cfg Config) error { - if cfg.Provider == providerName && strings.TrimSpace(cfg.TargetOS) != "" && strings.ToLower(strings.TrimSpace(cfg.TargetOS)) != targetLinux { + 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 @@ -121,6 +121,15 @@ func validRepo(repo string) bool { 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, ".") { diff --git a/internal/providers/githubcodespaces/provider_test.go b/internal/providers/githubcodespaces/provider_test.go index c94f0b6f0..46dcdd2be 100644 --- a/internal/providers/githubcodespaces/provider_test.go +++ b/internal/providers/githubcodespaces/provider_test.go @@ -103,21 +103,23 @@ func TestApplyFlagsSetsCodespacesConfigAndRejectsClass(t *testing.T) { WorkRoot: defaultWorkRoot, }, } - typeAlias := typeAliasDefaults - typeAlias.Provider = providerName - 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("--type machine=%q", typeAlias.GitHubCodespaces.Machine) + 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) From c4ec025365bcabf7c433239ba6407b2a5c93df19 Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Sat, 13 Jun 2026 23:23:55 -0700 Subject: [PATCH 12/17] fix(github-codespaces): accept delete no-op Treat GitHub Codespaces 304 Not Modified delete responses as successful no-ops so release and cleanup remain idempotent when GitHub reports no remote state change is needed. --- internal/providers/githubcodespaces/client.go | 2 +- internal/providers/githubcodespaces/client_test.go | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/providers/githubcodespaces/client.go b/internal/providers/githubcodespaces/client.go index 6f3197ed8..41bfde922 100644 --- a/internal/providers/githubcodespaces/client.go +++ b/internal/providers/githubcodespaces/client.go @@ -199,7 +199,7 @@ func (c client) deleteCodespace(ctx context.Context, name string) error { if name == "" { return exit(2, "github-codespaces codespace name is required") } - return c.do(ctx, http.MethodDelete, "/user/codespaces/"+url.PathEscape(name), nil, nil, nil) + 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) { diff --git a/internal/providers/githubcodespaces/client_test.go b/internal/providers/githubcodespaces/client_test.go index 4bd8f7690..b7929cb0e 100644 --- a/internal/providers/githubcodespaces/client_test.go +++ b/internal/providers/githubcodespaces/client_test.go @@ -107,6 +107,8 @@ func TestClientLifecycleOperationsRequestShape(t *testing.T) { 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: @@ -135,6 +137,9 @@ func TestClientLifecycleOperationsRequestShape(t *testing.T) { 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) @@ -147,6 +152,7 @@ func TestClientLifecycleOperationsRequestShape(t *testing.T) { "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 { From fcf2d58290312dd18a76942f0154697550f38c56 Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Sat, 13 Jun 2026 23:35:14 -0700 Subject: [PATCH 13/17] fix(github-codespaces): probe status waits Allow StatusOnly resolves with ReadyProbe to refresh and probe the SSH target so status --wait can observe readiness for healthy Codespaces leases. --- .../providers/githubcodespaces/backend.go | 2 +- .../githubcodespaces/backend_test.go | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/internal/providers/githubcodespaces/backend.go b/internal/providers/githubcodespaces/backend.go index 30596339a..0c8ff0221 100644 --- a/internal/providers/githubcodespaces/backend.go +++ b/internal/providers/githubcodespaces/backend.go @@ -226,7 +226,7 @@ func (b *backend) Resolve(ctx context.Context, req ResolveRequest) (LeaseTarget, return LeaseTarget{}, err } server = b.mergeLiveServer(server, item) - if req.ReleaseOnly || req.StatusOnly { + if req.ReleaseOnly || (req.StatusOnly && !req.ReadyProbe) { return LeaseTarget{Server: server, LeaseID: leaseID}, nil } if codespaceStopped(item.State) { diff --git a/internal/providers/githubcodespaces/backend_test.go b/internal/providers/githubcodespaces/backend_test.go index 7a8cd98c2..e5cf268fe 100644 --- a/internal/providers/githubcodespaces/backend_test.go +++ b/internal/providers/githubcodespaces/backend_test.go @@ -129,6 +129,27 @@ func TestResolveNoLocalStateMutationsDoesNotStoreSSHConfig(t *testing.T) { } } +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() From 62be449400253c400edeb382cc3d50ff6d2eb701 Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Sat, 13 Jun 2026 23:53:12 -0700 Subject: [PATCH 14/17] fix(github-codespaces): preserve delete release policy Warmup keep semantics should keep a lease available after provisioning, not rewrite the later provider release action. Preserve the delete-on-release policy in stored Codespaces claims so default stop and cleanup paths delete claim-owned Codespaces unless configuration explicitly retains them. --- .../providers/githubcodespaces/backend.go | 2 +- .../githubcodespaces/backend_test.go | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/internal/providers/githubcodespaces/backend.go b/internal/providers/githubcodespaces/backend.go index 0c8ff0221..47e9a3f0f 100644 --- a/internal/providers/githubcodespaces/backend.go +++ b/internal/providers/githubcodespaces/backend.go @@ -108,7 +108,7 @@ func (b *backend) Acquire(ctx context.Context, req AcquireRequest) (LeaseTarget, return LeaseTarget{}, err } release := releaseDelete - if req.Keep || !githubCodespacesDeleteOnRelease(LeaseTarget{}, cfg) { + if !githubCodespacesDeleteOnRelease(LeaseTarget{}, cfg) { release = releaseStop } created, err := api.createCodespace(ctx, createCodespaceRequest{ diff --git a/internal/providers/githubcodespaces/backend_test.go b/internal/providers/githubcodespaces/backend_test.go index e5cf268fe..e1dad9f95 100644 --- a/internal/providers/githubcodespaces/backend_test.go +++ b/internal/providers/githubcodespaces/backend_test.go @@ -67,6 +67,36 @@ func TestAcquireCreatesClaimGeneratesSSHConfigAndWaitsReady(t *testing.T) { } } +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 TestResolveStartsStoppedCodespaceAndRefreshesTarget(t *testing.T) { t.Setenv("XDG_STATE_HOME", t.TempDir()) fc := newFakeCodespacesClient() From 11b005e0d997cadc16894e7cfc247b8cb9418966 Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Sun, 14 Jun 2026 00:02:36 -0700 Subject: [PATCH 15/17] fix(github-codespaces): block repo config redirects Treat githubCodespaces.repo like the other Codespaces connection selectors when loading untrusted repository config. Repo-local config can no longer redirect creation to an arbitrary repository; operators can still select a repo through trusted config, environment, or explicit CLI flags. --- docs/providers/github-codespaces.md | 2 +- internal/cli/config.go | 2 +- internal/cli/config_test.go | 9 +++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/providers/github-codespaces.md b/docs/providers/github-codespaces.md index b9fbdeccb..6ce16500e 100644 --- a/docs/providers/github-codespaces.md +++ b/docs/providers/github-codespaces.md @@ -89,7 +89,7 @@ Config keys under `githubCodespaces:`: | --- | --- | --- | | `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. Required when no GitHub remote can be inferred. | +| `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. | diff --git a/internal/cli/config.go b/internal/cli/config.go index d63fc4705..1b8ac9b57 100644 --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -4395,7 +4395,7 @@ func applyFileConfigWithTrust(cfg *Config, file fileConfig, trusted bool) error if trusted && file.GitHubCodespaces.GHPath != "" { cfg.GitHubCodespaces.GHPath = expandUserPath(file.GitHubCodespaces.GHPath) } - if file.GitHubCodespaces.Repo != "" { + if trusted && file.GitHubCodespaces.Repo != "" { cfg.GitHubCodespaces.Repo = file.GitHubCodespaces.Repo } if file.GitHubCodespaces.Ref != "" { diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go index 90912ed2b..21dc42616 100644 --- a/internal/cli/config_test.go +++ b/internal/cli/config_test.go @@ -685,22 +685,23 @@ func TestGitHubCodespacesConfigDefaultsFileEnvAndShow(t *testing.T) { } } -func TestGitHubCodespacesUntrustedConfigCannotRedirectEndpointOrCLI(t *testing.T) { +func TestGitHubCodespacesUntrustedConfigCannotRedirectEndpointCLIOrRepo(t *testing.T) { cfg := baseConfig() cfg.GitHubCodespaces.APIURL = "https://api.trusted.example" cfg.GitHubCodespaces.GHPath = "/trusted/gh" + cfg.GitHubCodespaces.Repo = "trusted-org/trusted-app" if err := applyFileConfigWithTrust(&cfg, fileConfig{GitHubCodespaces: &fileGitHubCodespacesConfig{ APIURL: "https://api.untrusted.example", GHPath: "./payload", - Repo: "example-org/my-app", + Repo: "attacker-org/payload", }}, 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 != "example-org/my-app" { - t.Fatalf("safe untrusted repo not applied: %#v", cfg.GitHubCodespaces) + if cfg.GitHubCodespaces.Repo != "trusted-org/trusted-app" { + t.Fatalf("untrusted repo redirect applied: %#v", cfg.GitHubCodespaces) } } From f2f7c2b639a80d06a56ffcacb6cb9cab8c20d3c8 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 24 Jun 2026 13:23:59 +0800 Subject: [PATCH 16/17] fix(github-codespaces): remove unused helper wrappers --- internal/providers/githubcodespaces/core.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/internal/providers/githubcodespaces/core.go b/internal/providers/githubcodespaces/core.go index b588841a8..8dffe979a 100644 --- a/internal/providers/githubcodespaces/core.go +++ b/internal/providers/githubcodespaces/core.go @@ -67,10 +67,6 @@ func workRootExplicit(cfg *Config) bool { return core.IsWorkRootExplicit(cfg) } -func blank(value, fallback string) string { - return core.Blank(value, fallback) -} - func newLeaseID() string { return core.NewLeaseID() } @@ -123,10 +119,6 @@ func shouldCleanupServer(server Server, now time.Time) (bool, string) { return core.ShouldCleanupServer(server, now) } -func serverSlug(server Server) string { - return core.ServerSlug(server) -} - func findServerByAlias(servers []Server, id string) (Server, string, error) { return core.FindServerByAlias(servers, id) } From 2afc239e021e093fd021e2b09657d7831e7cb0ba Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 24 Jun 2026 13:31:33 +0800 Subject: [PATCH 17/17] docs(github-codespaces): refresh provider category matrix --- internal/cli/provider_categories_generated.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/cli/provider_categories_generated.go b/internal/cli/provider_categories_generated.go index b358b8a07..e0a858b62 100644 --- a/internal/cli/provider_categories_generated.go +++ b/internal/cli/provider_categories_generated.go @@ -28,6 +28,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",