Merge remote-tracking branch 'origin/main' into crabbox/aws-auto-region-routing

* origin/main:
  fix: harden daytona auth and resource flags
This commit is contained in:
Vincent Koc 2026-05-06 16:08:24 -07:00
commit a8ccdfefe8
No known key found for this signature in database
9 changed files with 308 additions and 6 deletions

View File

@ -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.
@ -57,6 +58,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.

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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)
}