From 0e3515023b38e671aff3de0977cc842ac00dffc0 Mon Sep 17 00:00:00 2001 From: Yossi Eliaz Date: Thu, 7 May 2026 01:17:45 +0300 Subject: [PATCH] fix: harden daytona auth and resource flags Use the authenticated Daytona CLI profile as a Daytona auth fallback, reject snapshot-incompatible resource flags, and document the auth path. Verified locally with Go/docs gates and live Daytona CLI-auth run. --- CHANGELOG.md | 2 + docs/cli.md | 4 +- docs/features/daytona.md | 12 +- docs/providers/daytona.md | 12 +- internal/cli/provider_backend_test.go | 23 +++ internal/cli/providers_builtin_test.go | 8 ++ .../providers/daytona/backend_run_test.go | 113 +++++++++++++++ internal/providers/daytona/backend_ssh.go | 8 ++ internal/providers/daytona/client.go | 132 +++++++++++++++++- 9 files changed, 308 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4d1e3a..5af7781 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - Documented the prebaked runner image boundary: provider-owned AMIs/snapshots hold machine capabilities while repo/runtime caches stay in QA workflows or warm leases. - Added a provider backend registry and authoring guide so delegated and SSH-backed providers can live in provider-owned packages while core keeps command parsing, rendering, and capability validation. - Added `provider: daytona` for Daytona sandbox leases using Daytona's SDK/toolbox for sync and command execution, with short-lived SSH access available through `crabbox ssh`. +- Added Daytona CLI profile auth fallback so `daytona login --api-key ...` can satisfy Crabbox Daytona auth without duplicating `DAYTONA_API_KEY`. - Added `provider: islo` for delegated Islo sandbox runs using the Islo Go SDK. - Added best-effort GitHub Actions run and workflow links for external Blacksmith Testbox rows in the portal. - Added GitHub Actions status badges, stuck filters, and copyable local stop commands for external Blacksmith Testbox rows in the portal. @@ -53,6 +54,7 @@ - Fixed managed Linux browser cloud-init setup so Chrome/Chromium policy and wrapper generation cannot break YAML parsing. - Fixed Islo delegated runs so shell-mode commands preserve raw shell strings and truncated exec streams fail instead of silently reporting success. - Fixed Daytona SDK sync so tar creation and Daytona toolbox upload stream from disk instead of buffering large archives in memory. +- Fixed Daytona resource override handling so snapshot-only sandboxes reject generic `--class` and `--type` flags instead of accepting no-op compute settings. - Fixed WebVNC portal passwords with escaped special characters and kept the bridge alive across viewer resets and transient coordinator EOFs. - Fixed managed AWS Windows WSL2 bootstrap by using the current Ubuntu WSL rootfs URL, downloading large rootfs files through `curl.exe`, and retrying empty or partial rootfs downloads instead of reusing a poisoned tarball. Thanks @vincentkoc. - Fixed AWS Windows WSL2 mode overrides so they refresh the default instance type to a nested-virtualization-capable family. Thanks @steipete. diff --git a/docs/cli.md b/docs/cli.md index 74245bf..64222f2 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -295,7 +295,9 @@ With `provider: blacksmith-testbox`, Crabbox delegates machine setup, sync, and With `provider: daytona`, Crabbox creates Daytona sandboxes from `daytona.snapshot`, uploads workspaces through Daytona toolbox file APIs, and runs commands through Daytona toolbox process APIs. `crabbox ssh` mints -short-lived Daytona SSH tokens and redacts those tokens from output. With +short-lived Daytona SSH tokens and redacts those tokens from output. Daytona +auth can come from `DAYTONA_API_KEY` / `DAYTONA_JWT_TOKEN` env or an +authenticated Daytona CLI profile created by `daytona login --api-key`. With `provider: islo`, Crabbox delegates sandbox setup and command execution to the Islo Go SDK; sync is delegated and `--sync-only`, `--checksum`, and `--force-sync-large` are unsupported. diff --git a/docs/features/daytona.md b/docs/features/daytona.md index 8013745..893d2ca 100644 --- a/docs/features/daytona.md +++ b/docs/features/daytona.md @@ -13,7 +13,16 @@ requested. ## Auth -Set one of: +Run Daytona's CLI login: + +```sh +daytona login --api-key ... +``` + +Crabbox uses the active Daytona CLI profile when no explicit Daytona auth +environment variables are set. + +Alternatively, set one of: ```sh export DAYTONA_API_KEY=... @@ -28,6 +37,7 @@ export DAYTONA_ORGANIZATION_ID=... `DAYTONA_ORGANIZATION_ID` is required when JWT auth is used. `DAYTONA_API_URL` or `daytona.apiUrl` can override the default `https://app.daytona.io/api`. +Explicit environment or Crabbox config values override the Daytona CLI profile. ## Config diff --git a/docs/providers/daytona.md b/docs/providers/daytona.md index fc1cd5c..ab540c3 100644 --- a/docs/providers/daytona.md +++ b/docs/providers/daytona.md @@ -30,7 +30,16 @@ crabbox stop --provider daytona blue-lobster ## Auth -Use an API key: +Use the Daytona CLI login: + +```sh +daytona login --api-key ... +``` + +Crabbox reads the active Daytona CLI profile when no Daytona auth environment +variables are set. + +You can also use explicit environment auth with an API key: ```sh export DAYTONA_API_KEY=... @@ -44,6 +53,7 @@ export DAYTONA_ORGANIZATION_ID=... ``` `DAYTONA_ORGANIZATION_ID` is required with JWT auth. +Explicit environment or Crabbox config values override the Daytona CLI profile. ## Config diff --git a/internal/cli/provider_backend_test.go b/internal/cli/provider_backend_test.go index 09bcfa9..dfa215a 100644 --- a/internal/cli/provider_backend_test.go +++ b/internal/cli/provider_backend_test.go @@ -85,6 +85,29 @@ func TestLeaseCreateFlagsApplySelectedProviderFlags(t *testing.T) { } } +func TestLeaseCreateFlagsRejectDaytonaResourceNoops(t *testing.T) { + defaults := baseConfig() + for _, tc := range []struct { + name string + args []string + }{ + {name: "class", args: []string{"--provider", "daytona", "--class", "standard"}}, + {name: "type", args: []string{"--provider", "daytona", "--type", "large"}}, + } { + t.Run(tc.name, func(t *testing.T) { + fs := newFlagSet("test", io.Discard) + values := registerLeaseCreateFlags(fs, defaults) + if err := parseFlags(fs, tc.args); err != nil { + t.Fatal(err) + } + cfg := defaults + if err := applyLeaseCreateFlags(&cfg, fs, values); err == nil { + t.Fatalf("expected %v to be rejected", tc.args) + } + }) + } +} + func TestValidateRequestedCapabilitiesUsesProviderSpec(t *testing.T) { cfg := baseConfig() cfg.Provider = "blacksmith-testbox" diff --git a/internal/cli/providers_builtin_test.go b/internal/cli/providers_builtin_test.go index 948334b..5bffc67 100644 --- a/internal/cli/providers_builtin_test.go +++ b/internal/cli/providers_builtin_test.go @@ -171,6 +171,14 @@ func (testDaytonaProvider) RegisterFlags(fs *flag.FlagSet, defaults Config) any } } func (testDaytonaProvider) ApplyFlags(cfg *Config, fs *flag.FlagSet, values any) error { + if cfg.Provider == "daytona" { + if flagWasSet(fs, "class") { + return exit(2, "--class is not supported for provider=daytona") + } + if flagWasSet(fs, "type") { + return exit(2, "--type is not supported for provider=daytona") + } + } v, ok := values.(testDaytonaFlagValues) if !ok { return nil diff --git a/internal/providers/daytona/backend_run_test.go b/internal/providers/daytona/backend_run_test.go index f02f6a5..3f0327f 100644 --- a/internal/providers/daytona/backend_run_test.go +++ b/internal/providers/daytona/backend_run_test.go @@ -3,6 +3,7 @@ package daytona import ( "archive/tar" "compress/gzip" + "flag" "io" "net/http" "net/http/httptest" @@ -161,6 +162,118 @@ func TestDaytonaAuthRequiresOrganizationForJWT(t *testing.T) { } } +func TestDaytonaAuthFallsBackToCLIConfig(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + configDir, err := os.UserConfigDir() + if err != nil { + t.Fatal(err) + } + path := filepath.Join(configDir, "daytona", "config.json") + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(`{ + "activeProfile": "prod", + "profiles": [ + { + "id": "dev", + "name": "dev", + "api": {"url": "https://dev.example/api", "key": "wrong"} + }, + { + "id": "prod", + "name": "prod", + "api": {"url": "https://daytona.example/api/", "key": "cli-api-key"}, + "activeOrganizationId": "org-123" + } + ] +}`), 0o600); err != nil { + t.Fatal(err) + } + + cfg := baseConfig() + cfg.Provider = daytonaProvider + cfg.Daytona.APIKey = "" + cfg.Daytona.JWTToken = "" + cfg.Daytona.OrganizationID = "" + cfg.Daytona.APIURL = "https://app.daytona.io/api" + auth, err := daytonaAuthConfig(cfg) + if err != nil { + t.Fatal(err) + } + if auth.APIKey != "cli-api-key" || auth.OrganizationID != "org-123" { + t.Fatalf("auth=%#v", auth) + } + if got := daytonaAPIURL(cfg, auth); got != "https://daytona.example/api" { + t.Fatalf("api url=%q", got) + } +} + +func TestDaytonaEnvAuthOverridesCLIConfig(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + configDir, err := os.UserConfigDir() + if err != nil { + t.Fatal(err) + } + path := filepath.Join(configDir, "daytona", "config.json") + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(`{ + "activeProfile": "initial", + "profiles": [ + {"id": "initial", "name": "initial", "api": {"url": "https://cli.example/api", "key": "cli-api-key"}} + ] +}`), 0o600); err != nil { + t.Fatal(err) + } + + cfg := baseConfig() + cfg.Provider = daytonaProvider + cfg.Daytona.APIKey = "env-api-key" + cfg.Daytona.APIURL = "https://env.example/api" + auth, err := daytonaAuthConfig(cfg) + if err != nil { + t.Fatal(err) + } + if auth.APIKey != "env-api-key" { + t.Fatalf("api key=%q", auth.APIKey) + } + if got := daytonaAPIURL(cfg, auth); got != "https://env.example/api" { + t.Fatalf("api url=%q", got) + } +} + +func TestApplyDaytonaProviderFlagsRejectsResourceNoops(t *testing.T) { + cfg := baseConfig() + cfg.Provider = daytonaProvider + for _, tc := range []struct { + name string + args []string + }{ + {name: "class", args: []string{"--class", "standard"}}, + {name: "type", args: []string{"--type", "large"}}, + } { + t.Run(tc.name, func(t *testing.T) { + fs := flag.NewFlagSet("test", flag.ContinueOnError) + fs.String("class", "", "") + fs.String("type", "", "") + values := RegisterDaytonaProviderFlags(fs, cfg) + if err := fs.Parse(tc.args); err != nil { + t.Fatal(err) + } + err := ApplyDaytonaProviderFlags(&cfg, fs, values) + if err == nil || !strings.Contains(err.Error(), "provider=daytona") { + t.Fatalf("err=%v, want daytona resource flag rejection", err) + } + }) + } +} + func TestDaytonaSSHTargetUsesReturnedSSHCommand(t *testing.T) { cfg := baseConfig() cfg.Daytona.SSHGatewayHost = "fallback.example" diff --git a/internal/providers/daytona/backend_ssh.go b/internal/providers/daytona/backend_ssh.go index e2c3a9c..8ef5666 100644 --- a/internal/providers/daytona/backend_ssh.go +++ b/internal/providers/daytona/backend_ssh.go @@ -38,6 +38,14 @@ func RegisterDaytonaProviderFlags(fs *flag.FlagSet, defaults Config) any { } func ApplyDaytonaProviderFlags(cfg *Config, fs *flag.FlagSet, values any) error { + if cfg.Provider == daytonaProvider { + if flagWasSet(fs, "class") { + return exit(2, "--class is not supported for provider=daytona; choose CPU, memory, and disk in the Daytona snapshot") + } + if flagWasSet(fs, "type") { + return exit(2, "--type is not supported for provider=daytona; choose CPU, memory, and disk in the Daytona snapshot") + } + } v, ok := values.(daytonaFlagValues) if !ok { return nil diff --git a/internal/providers/daytona/client.go b/internal/providers/daytona/client.go index 65e847a..69dc405 100644 --- a/internal/providers/daytona/client.go +++ b/internal/providers/daytona/client.go @@ -5,6 +5,8 @@ import ( "encoding/json" "errors" "fmt" + "os" + "path/filepath" "strings" "time" @@ -35,12 +37,14 @@ type daytonaSDKClient struct { orgID string } +const defaultDaytonaAPIURL = "https://app.daytona.io/api" + func newDaytonaClient(cfg Config, rt Runtime) (daytonaAPI, error) { auth, err := daytonaAuthConfig(cfg) if err != nil { return nil, err } - apiURL := strings.TrimRight(blank(cfg.Daytona.APIURL, "https://app.daytona.io/api"), "/") + apiURL := daytonaAPIURL(cfg, auth) apiCfg := daytona.NewConfiguration() apiCfg.Servers = daytona.ServerConfigurations{{URL: apiURL}} if rt.HTTP != nil { @@ -53,6 +57,7 @@ type daytonaAuth struct { APIKey string JWTToken string OrganizationID string + APIURL string } func (a daytonaAuth) token() string { @@ -67,9 +72,17 @@ func daytonaAuthConfig(cfg Config) (daytonaAuth, error) { APIKey: strings.TrimSpace(cfg.Daytona.APIKey), JWTToken: strings.TrimSpace(cfg.Daytona.JWTToken), OrganizationID: strings.TrimSpace(cfg.Daytona.OrganizationID), + APIURL: strings.TrimSpace(cfg.Daytona.APIURL), } if auth.APIKey == "" && auth.JWTToken == "" { - return daytonaAuth{}, exit(3, "provider=daytona requires DAYTONA_API_KEY or DAYTONA_JWT_TOKEN") + if cliAuth, err := daytonaCLIAuthConfig(); err == nil { + auth = mergeDaytonaCLIAuth(auth, cliAuth) + } else if !errors.Is(err, os.ErrNotExist) { + return daytonaAuth{}, err + } + } + if auth.APIKey == "" && auth.JWTToken == "" { + return daytonaAuth{}, exit(3, "provider=daytona requires DAYTONA_API_KEY, DAYTONA_JWT_TOKEN, or an authenticated Daytona CLI profile") } if auth.APIKey == "" && auth.JWTToken != "" && auth.OrganizationID == "" { return daytonaAuth{}, exit(3, "provider=daytona with DAYTONA_JWT_TOKEN requires DAYTONA_ORGANIZATION_ID") @@ -77,6 +90,17 @@ func daytonaAuthConfig(cfg Config) (daytonaAuth, error) { return auth, nil } +func daytonaAPIURL(cfg Config, auth daytonaAuth) string { + configured := strings.TrimSpace(cfg.Daytona.APIURL) + if configured != "" && configured != defaultDaytonaAPIURL { + return strings.TrimRight(configured, "/") + } + if auth.APIURL != "" { + return strings.TrimRight(auth.APIURL, "/") + } + return strings.TrimRight(blank(configured, defaultDaytonaAPIURL), "/") +} + func newDaytonaToolboxClient(cfg Config) (*sdkdaytona.Client, error) { auth, err := daytonaAuthConfig(cfg) if err != nil { @@ -86,11 +110,113 @@ func newDaytonaToolboxClient(cfg Config) (*sdkdaytona.Client, error) { APIKey: auth.APIKey, JWTToken: auth.JWTToken, OrganizationID: auth.OrganizationID, - APIUrl: strings.TrimRight(blank(cfg.Daytona.APIURL, "https://app.daytona.io/api"), "/"), + APIUrl: daytonaAPIURL(cfg, auth), Target: strings.TrimSpace(cfg.Daytona.Target), }) } +type daytonaCLIConfig struct { + ActiveProfile string `json:"activeProfile"` + Profiles []daytonaCLIProfile `json:"profiles"` +} + +type daytonaCLIProfile struct { + ID string `json:"id"` + Name string `json:"name"` + ActiveOrganizationID string `json:"activeOrganizationId"` + API struct { + URL string `json:"url"` + Key string `json:"key"` + } `json:"api"` +} + +func daytonaCLIAuthConfig() (daytonaAuth, error) { + paths := daytonaCLIConfigPaths() + for _, path := range paths { + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + continue + } + return daytonaAuth{}, fmt.Errorf("read Daytona CLI config %s: %w", path, err) + } + auth, err := parseDaytonaCLIAuthConfig(data) + if err != nil { + return daytonaAuth{}, fmt.Errorf("read Daytona CLI config %s: %w", path, err) + } + if auth.APIKey != "" || auth.JWTToken != "" { + return auth, nil + } + } + return daytonaAuth{}, os.ErrNotExist +} + +func daytonaCLIConfigPaths() []string { + var candidates []string + if dir, err := os.UserConfigDir(); err == nil && dir != "" { + candidates = append(candidates, + filepath.Join(dir, "daytona", "config.json"), + filepath.Join(dir, "Daytona", "config.json"), + ) + } + if home, err := os.UserHomeDir(); err == nil && home != "" { + candidates = append(candidates, + filepath.Join(home, ".config", "daytona", "config.json"), + filepath.Join(home, ".daytona", "config.json"), + ) + } + seen := map[string]bool{} + out := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + if candidate == "" || seen[candidate] { + continue + } + seen[candidate] = true + out = append(out, candidate) + } + return out +} + +func parseDaytonaCLIAuthConfig(data []byte) (daytonaAuth, error) { + var config daytonaCLIConfig + if err := json.Unmarshal(data, &config); err != nil { + return daytonaAuth{}, err + } + var selected *daytonaCLIProfile + for i := range config.Profiles { + profile := &config.Profiles[i] + if config.ActiveProfile == "" || profile.ID == config.ActiveProfile || profile.Name == config.ActiveProfile { + selected = profile + break + } + } + if selected == nil && len(config.Profiles) > 0 { + selected = &config.Profiles[0] + } + if selected == nil { + return daytonaAuth{}, nil + } + return daytonaAuth{ + APIKey: strings.TrimSpace(selected.API.Key), + OrganizationID: strings.TrimSpace(selected.ActiveOrganizationID), + APIURL: strings.TrimSpace(selected.API.URL), + }, nil +} + +func mergeDaytonaCLIAuth(auth, cliAuth daytonaAuth) daytonaAuth { + if auth.APIKey == "" && auth.JWTToken == "" { + auth.APIKey = cliAuth.APIKey + auth.JWTToken = cliAuth.JWTToken + } + if auth.OrganizationID == "" { + auth.OrganizationID = cliAuth.OrganizationID + } + if auth.APIURL == "" || auth.APIURL == defaultDaytonaAPIURL { + auth.APIURL = cliAuth.APIURL + } + return auth +} + func (c *daytonaSDKClient) ctx(ctx context.Context) context.Context { return context.WithValue(ctx, daytona.ContextAccessToken, c.token) }