From db684b6e8ea166a71f7718556d6d1778a2599e47 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:27:42 -0700 Subject: [PATCH 1/6] Add authenticated cmux Sprites workflow --- sprites/.gitignore | 1 + sprites/README.md | 39 + sprites/cmd/cmux-sprites/main.go | 702 ++++++++++++++++++ sprites/cmd/cmux-sprites/main_test.go | 109 +++ sprites/go.mod | 3 + .../dashboard/vault/cli-auth/page.tsx | 14 +- web/app/api/vault/cli/auth/poll/route.ts | 2 +- web/app/api/vault/cli/auth/start/route.ts | 4 +- web/app/api/vm/base/routeShared.ts | 8 +- web/app/api/vm/restore/route.ts | 7 +- web/app/api/vm/route.ts | 10 +- web/bun.lock | 3 + .../migration.sql | 1 + .../migration.sql | 6 + web/db/schema.ts | 4 +- web/messages/en.json | 3 + web/messages/ja.json | 3 + web/package.json | 1 + web/services/vault/cliAuth.ts | 6 +- web/services/vms/README.md | 34 +- web/services/vms/config.ts | 2 + web/services/vms/drivers/index.ts | 11 +- web/services/vms/drivers/sprites.ts | 261 +++++++ web/services/vms/drivers/types.ts | 2 +- web/services/vms/images/manifest.json | 12 + web/services/vms/images/resolver.ts | 2 + web/services/vms/workflows.ts | 5 + web/tests/vault-cli-auth.test.ts | 19 +- web/tests/vm-sprites-provider.test.ts | 35 + 29 files changed, 1278 insertions(+), 31 deletions(-) create mode 100644 sprites/.gitignore create mode 100644 sprites/README.md create mode 100644 sprites/cmd/cmux-sprites/main.go create mode 100644 sprites/cmd/cmux-sprites/main_test.go create mode 100644 sprites/go.mod create mode 100644 web/db/migrations/20260805043000_cloud_vm_sprites_provider/migration.sql create mode 100644 web/db/migrations/20260805043100_cmux_sprites_cli_auth/migration.sql create mode 100644 web/services/vms/drivers/sprites.ts create mode 100644 web/tests/vm-sprites-provider.test.ts diff --git a/sprites/.gitignore b/sprites/.gitignore new file mode 100644 index 000000000000..dfab136a27bc --- /dev/null +++ b/sprites/.gitignore @@ -0,0 +1 @@ +/cmux-sprites diff --git a/sprites/README.md b/sprites/README.md new file mode 100644 index 000000000000..f59c241a374a --- /dev/null +++ b/sprites/README.md @@ -0,0 +1,39 @@ +# cmux Sprites + +`cmux-sprites` creates and controls Fly Sprites through the authenticated +cmux.com backend. The CLI receives Stack access and refresh tokens, but never +receives a Fly token. Sprite ownership, billing-team checks, provider calls, +and lifecycle state remain in the existing Cloud VM control plane. + +```sh +cd sprites +go build ./cmd/cmux-sprites +./cmux-sprites login +./cmux-sprites create +./cmux-sprites list +./cmux-sprites connect +``` + +Login prints a URL and an eight-character code. Browser opening is opt-in with +`login --open`; no localhost callback is started. Credentials are written to +`~/.config/cmux-sprites/auth.json` with mode `0600`. + +`connect` asks the owned Sprite daemon for a five-minute, one-device +invitation, writes it to an owner-only temporary file, launches `cmux +connect`, and approves only that invitation after its device claim appears. +The public Sprite URL carries the end-to-end Noise session. It is not the +authorization boundary. + +## Templates + +The public Sprites API does not currently expose a reusable template or +cross-Sprite checkpoint restore operation. Fly has an admin-console fork +workflow that copies a Sprite drive at a timestamp, but its public REST and SDK +contract is not documented. + +Until Fly publishes that contract, cmux creates a normal Sprite and installs a +pinned `cmux` npm package before registering the persistent service. The +provider image manifest records that package as the reproducible bootstrap +input. A future fork implementation should replace only the provider create +step; Stack ownership, database idempotency, enrollment, and client auth stay +unchanged. diff --git a/sprites/cmd/cmux-sprites/main.go b/sprites/cmd/cmux-sprites/main.go new file mode 100644 index 000000000000..a5c7e51c9f09 --- /dev/null +++ b/sprites/cmd/cmux-sprites/main.go @@ -0,0 +1,702 @@ +package main + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" +) + +const defaultAPIBase = "https://cmux.com" + +type tokens struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` +} + +type authStart struct { + DeviceCode string `json:"deviceCode"` + UserCode string `json:"userCode"` + VerificationURL string `json:"verificationUrl"` + ExpiresInSeconds int `json:"expiresInSeconds"` + IntervalSeconds int `json:"intervalSeconds"` +} + +type authPoll struct { + Status string `json:"status"` + Client string `json:"client"` + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` +} + +type vm struct { + ID string `json:"id"` + Provider string `json:"provider"` + Status string `json:"status"` + Image string `json:"image"` + ConnectionURL string `json:"connectionUrl"` +} + +type vmList struct { + VMs []vm `json:"vms"` +} + +type execResult struct { + ExitCode int `json:"exitCode"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` +} + +type apiClient struct { + baseURL string + tokens *tokens + http *http.Client +} + +func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +func run(args []string, stdout, stderr io.Writer) int { + global := flag.NewFlagSet("cmux-sprites", flag.ContinueOnError) + global.SetOutput(stderr) + apiBase := global.String("api-base", envOr("CMUX_SPRITES_API_BASE", defaultAPIBase), "cmux web API base URL") + jsonOutput := global.Bool("json", false, "write JSON output") + if err := global.Parse(args); err != nil { + return 2 + } + remaining := global.Args() + if len(remaining) == 0 { + usage(stderr) + return 2 + } + configDir, err := configDir() + if err != nil { + fmt.Fprintf(stderr, "cmux-sprites: %v\n", err) + return 1 + } + ctx := context.Background() + + switch remaining[0] { + case "login": + fs := flag.NewFlagSet("login", flag.ContinueOnError) + fs.SetOutput(stderr) + openBrowser := fs.Bool("open", false, "open the verification URL after printing its code") + if err := fs.Parse(remaining[1:]); err != nil { + return 2 + } + created, err := login(ctx, newClient(*apiBase, nil), *openBrowser, stdout) + if err != nil { + fmt.Fprintf(stderr, "login failed: %v\n", err) + return 1 + } + if err := saveTokens(configDir, created); err != nil { + fmt.Fprintf(stderr, "saving login failed: %v\n", err) + return 1 + } + fmt.Fprintln(stdout, "Logged in to cmux Sprites.") + return 0 + case "logout": + if err := os.Remove(filepath.Join(configDir, "auth.json")); err != nil && !errors.Is(err, os.ErrNotExist) { + fmt.Fprintf(stderr, "logout failed: %v\n", err) + return 1 + } + fmt.Fprintln(stdout, "Logged out.") + return 0 + case "status": + loaded, err := loadTokens(configDir) + if err != nil { + fmt.Fprintf(stderr, "status failed: %v\n", err) + return 1 + } + return write(stdout, *jsonOutput, map[string]any{ + "loggedIn": loaded != nil, + "apiBase": strings.TrimRight(*apiBase, "/"), + }) + case "list", "ls": + client, ok := authedClient(*apiBase, configDir, stderr) + if !ok { + return 1 + } + var result vmList + if err := client.request(ctx, http.MethodGet, "/api/vm", nil, &result); err != nil { + fmt.Fprintf(stderr, "list failed: %v\n", err) + return 1 + } + filtered := make([]vm, 0, len(result.VMs)) + for _, entry := range result.VMs { + if entry.Provider == "sprites" { + filtered = append(filtered, entry) + } + } + if *jsonOutput { + return writeJSON(stdout, map[string]any{"sprites": filtered}) + } + for _, entry := range filtered { + fmt.Fprintf(stdout, "%s\t%s\t%s\n", entry.ID, entry.Status, entry.ConnectionURL) + } + return 0 + case "create": + fs := flag.NewFlagSet("create", flag.ContinueOnError) + fs.SetOutput(stderr) + teamID := fs.String("team", "", "Stack team id for ownership and billing") + if err := fs.Parse(remaining[1:]); err != nil { + return 2 + } + client, ok := authedClient(*apiBase, configDir, stderr) + if !ok { + return 1 + } + body := map[string]any{"provider": "sprites"} + if strings.TrimSpace(*teamID) != "" { + body["teamId"] = strings.TrimSpace(*teamID) + } + var created vm + if err := client.request(ctx, http.MethodPost, "/api/vm", body, &created); err != nil { + fmt.Fprintf(stderr, "create failed: %v\n", err) + return 1 + } + return write(stdout, *jsonOutput, created) + case "destroy", "rm": + if len(remaining) != 2 { + fmt.Fprintln(stderr, "destroy requires a Sprite id") + return 2 + } + client, ok := authedClient(*apiBase, configDir, stderr) + if !ok { + return 1 + } + if err := client.request(ctx, http.MethodDelete, "/api/vm/"+url.PathEscape(remaining[1]), nil, nil); err != nil { + fmt.Fprintf(stderr, "destroy failed: %v\n", err) + return 1 + } + fmt.Fprintf(stdout, "Destroyed %s.\n", remaining[1]) + return 0 + case "exec": + if len(remaining) < 3 { + fmt.Fprintln(stderr, "exec requires a Sprite id and command") + return 2 + } + commandArgs := remaining[2:] + if commandArgs[0] == "--" { + commandArgs = commandArgs[1:] + } + if len(commandArgs) == 0 { + fmt.Fprintln(stderr, "exec requires a command after --") + return 2 + } + client, ok := authedClient(*apiBase, configDir, stderr) + if !ok { + return 1 + } + result, err := client.exec(ctx, remaining[1], shellJoin(commandArgs)) + if err != nil { + fmt.Fprintf(stderr, "exec failed: %v\n", err) + return 1 + } + if *jsonOutput { + return writeJSON(stdout, result) + } + fmt.Fprint(stdout, result.Stdout) + fmt.Fprint(stderr, result.Stderr) + return result.ExitCode + case "connect": + return connect(ctx, remaining[1:], *apiBase, configDir, stdout, stderr) + case "help", "-h", "--help": + usage(stdout) + return 0 + default: + fmt.Fprintf(stderr, "cmux-sprites: unknown command %q\n", remaining[0]) + usage(stderr) + return 2 + } +} + +func login(ctx context.Context, client *apiClient, openBrowser bool, out io.Writer) (tokens, error) { + var started authStart + if err := client.request(ctx, http.MethodPost, "/api/vault/cli/auth/start", map[string]string{ + "client": "cmux-sprites", + }, &started); err != nil { + return tokens{}, err + } + fmt.Fprintf(out, "Sign in at:\n %s\n\nCopy and paste this code:\n %s\n\n", started.VerificationURL, started.UserCode) + if openBrowser { + switch runtime.GOOS { + case "darwin": + _ = exec.Command("open", started.VerificationURL).Start() + case "linux": + _ = exec.Command("xdg-open", started.VerificationURL).Start() + } + } + interval := time.Duration(max(started.IntervalSeconds, 1)) * time.Second + expires := time.Duration(max(started.ExpiresInSeconds, 60)) * time.Second + deadline := time.NewTimer(expires) + defer deadline.Stop() + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return tokens{}, ctx.Err() + case <-deadline.C: + return tokens{}, errors.New("login expired before approval") + case <-ticker.C: + var polled authPoll + if err := client.request(ctx, http.MethodPost, "/api/vault/cli/auth/poll", map[string]string{ + "deviceCode": started.DeviceCode, + }, &polled); err != nil { + return tokens{}, err + } + switch polled.Status { + case "pending": + continue + case "approved": + if polled.Client != "cmux-sprites" { + return tokens{}, fmt.Errorf("server returned credentials for %q", polled.Client) + } + if polled.AccessToken == "" || polled.RefreshToken == "" { + return tokens{}, errors.New("server approved login without credentials") + } + return tokens{AccessToken: polled.AccessToken, RefreshToken: polled.RefreshToken}, nil + default: + return tokens{}, fmt.Errorf("login %s", polled.Status) + } + } + } +} + +func connect(ctx context.Context, args []string, apiBase, configDir string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("connect", flag.ContinueOnError) + fs.SetOutput(stderr) + cmuxBin := fs.String("cmux-bin", "cmux", "cmux TUI binary") + deviceName := fs.String("device-name", hostname(), "enrolled device name") + if err := fs.Parse(args); err != nil { + return 2 + } + if fs.NArg() != 1 { + fmt.Fprintln(stderr, "connect requires a Sprite id") + return 2 + } + vmID := fs.Arg(0) + client, ok := authedClient(apiBase, configDir, stderr) + if !ok { + return 1 + } + var listed vmList + if err := client.request(ctx, http.MethodGet, "/api/vm", nil, &listed); err != nil { + fmt.Fprintf(stderr, "connect failed: %v\n", err) + return 1 + } + var selected *vm + for i := range listed.VMs { + if listed.VMs[i].ID == vmID && listed.VMs[i].Provider == "sprites" { + selected = &listed.VMs[i] + break + } + } + if selected == nil || selected.ConnectionURL == "" { + fmt.Fprintf(stderr, "connect failed: Sprite %s was not found or has no connection URL\n", vmID) + return 1 + } + advertise, err := enrollmentURL(selected.ConnectionURL) + if err != nil { + fmt.Fprintf(stderr, "connect failed: %v\n", err) + return 1 + } + createCommand := fmt.Sprintf( + "/usr/local/bin/cmux enroll create --session sprite --state-dir %s --advertise %s --ttl 300 --json", + shellQuote("/home/sprite/.local/share/cmux-sprite/remote"), + shellQuote(advertise), + ) + created, err := client.exec(ctx, vmID, createCommand) + if err != nil || created.ExitCode != 0 { + fmt.Fprintf(stderr, "connect failed to create enrollment: %v %s\n", err, created.Stderr) + return 1 + } + var invitation struct { + URI string `json:"uri"` + } + if err := json.Unmarshal([]byte(created.Stdout), &invitation); err != nil || invitation.URI == "" { + fmt.Fprintln(stderr, "connect failed: Sprite returned an invalid enrollment invitation") + return 1 + } + invitationID, err := invitationID(invitation.URI) + if err != nil { + fmt.Fprintf(stderr, "connect failed: %v\n", err) + return 1 + } + stateDir := filepath.Join(configDir, "devices", safePath(vmID)) + if err := os.MkdirAll(stateDir, 0o700); err != nil { + fmt.Fprintf(stderr, "connect failed: %v\n", err) + return 1 + } + inviteFile, err := os.CreateTemp(stateDir, ".invite-*.txt") + if err != nil { + fmt.Fprintf(stderr, "connect failed: %v\n", err) + return 1 + } + invitePath := inviteFile.Name() + defer os.Remove(invitePath) + if err := inviteFile.Chmod(0o600); err != nil { + inviteFile.Close() + fmt.Fprintf(stderr, "connect failed: %v\n", err) + return 1 + } + if _, err := fmt.Fprintln(inviteFile, invitation.URI); err != nil { + inviteFile.Close() + fmt.Fprintf(stderr, "connect failed: %v\n", err) + return 1 + } + if err := inviteFile.Close(); err != nil { + fmt.Fprintf(stderr, "connect failed: %v\n", err) + return 1 + } + + connectContext, cancelConnect := context.WithCancel(ctx) + defer cancelConnect() + command := exec.CommandContext( + connectContext, + *cmuxBin, + "connect", + "--invite-file", + invitePath, + "--device-name", + *deviceName, + "--state-dir", + stateDir, + ) + command.Stdin, command.Stdout, command.Stderr = os.Stdin, stdout, stderr + if err := command.Start(); err != nil { + fmt.Fprintf(stderr, "connect failed to start cmux: %v\n", err) + return 1 + } + approval := make(chan error, 1) + go func() { + approval <- approveEnrollment(connectContext, client, vmID, invitationID) + }() + processDone := make(chan error, 1) + go func() { + processDone <- command.Wait() + }() + select { + case err := <-approval: + if err != nil { + cancelConnect() + <-processDone + fmt.Fprintf(stderr, "connect approval failed: %v\n", err) + return 1 + } + return processExitCode(<-processDone, stderr) + case err := <-processDone: + cancelConnect() + return processExitCode(err, stderr) + } +} + +func processExitCode(err error, stderr io.Writer) int { + if err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return exitErr.ExitCode() + } + fmt.Fprintf(stderr, "connect failed: %v\n", err) + return 1 + } + return 0 +} + +func approveEnrollment(ctx context.Context, client *apiClient, vmID, invitationID string) error { + deadline := time.NewTimer(45 * time.Second) + defer deadline.Stop() + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + pending, err := client.exec( + ctx, + vmID, + "/usr/local/bin/cmux enroll pending --session sprite --state-dir "+ + shellQuote("/home/sprite/.local/share/cmux-sprite/remote")+" --json", + ) + if err == nil && pending.ExitCode == 0 { + var requests []struct { + InvitationID string `json:"invitation_id"` + } + if json.Unmarshal([]byte(pending.Stdout), &requests) == nil { + for _, request := range requests { + if request.InvitationID == invitationID { + approved, err := client.exec( + ctx, + vmID, + "/usr/local/bin/cmux enroll approve "+shellQuote(invitationID)+ + " --session sprite --state-dir "+ + shellQuote("/home/sprite/.local/share/cmux-sprite/remote"), + ) + if err != nil { + return err + } + if approved.ExitCode != 0 { + return errors.New(strings.TrimSpace(approved.Stderr)) + } + return nil + } + } + } + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-deadline.C: + return errors.New("timed out waiting for the device enrollment claim") + case <-ticker.C: + } + } +} + +func (c *apiClient) exec(ctx context.Context, vmID, command string) (execResult, error) { + var result execResult + err := c.request(ctx, http.MethodPost, "/api/vm/"+url.PathEscape(vmID)+"/exec", map[string]any{ + "command": command, + "timeoutMs": 60_000, + }, &result) + return result, err +} + +func (c *apiClient) request(ctx context.Context, method, path string, body any, out any) error { + var payload []byte + var err error + if body != nil { + payload, err = json.Marshal(body) + if err != nil { + return err + } + } + request, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(payload)) + if err != nil { + return err + } + request.Header.Set("Accept", "application/json") + if body != nil { + request.Header.Set("Content-Type", "application/json") + } + if c.tokens != nil { + request.Header.Set("Authorization", "Bearer "+c.tokens.AccessToken) + request.Header.Set("X-Stack-Refresh-Token", c.tokens.RefreshToken) + } + response, err := c.http.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + data, err := io.ReadAll(io.LimitReader(response.Body, 12*1024*1024)) + if err != nil { + return err + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("cmux.com returned %d: %s", response.StatusCode, strings.TrimSpace(string(data))) + } + if out != nil && len(data) > 0 { + return json.Unmarshal(data, out) + } + return nil +} + +func newClient(baseURL string, value *tokens) *apiClient { + return &apiClient{ + baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"), + tokens: value, + http: &http.Client{Timeout: 90 * time.Second}, + } +} + +func authedClient(apiBase, dir string, stderr io.Writer) (*apiClient, bool) { + loaded, err := loadTokens(dir) + if err != nil { + fmt.Fprintf(stderr, "loading login failed: %v\n", err) + return nil, false + } + if loaded == nil { + fmt.Fprintln(stderr, "not logged in; run cmux-sprites login") + return nil, false + } + return newClient(apiBase, loaded), true +} + +func configDir() (string, error) { + if configured := strings.TrimSpace(os.Getenv("CMUX_SPRITES_CONFIG_DIR")); configured != "" { + return configured, nil + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return "", errors.New("could not resolve the home directory") + } + return filepath.Join(home, ".config", "cmux-sprites"), nil +} + +func loadTokens(dir string) (*tokens, error) { + data, err := os.ReadFile(filepath.Join(dir, "auth.json")) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + var loaded tokens + if err := json.Unmarshal(data, &loaded); err != nil { + return nil, err + } + if loaded.AccessToken == "" || loaded.RefreshToken == "" { + return nil, nil + } + return &loaded, nil +} + +func saveTokens(dir string, value tokens) error { + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + temp, err := os.CreateTemp(dir, ".auth-*.tmp") + if err != nil { + return err + } + defer os.Remove(temp.Name()) + if err := temp.Chmod(0o600); err != nil { + temp.Close() + return err + } + if _, err := temp.Write(data); err != nil { + temp.Close() + return err + } + if err := temp.Close(); err != nil { + return err + } + return os.Rename(temp.Name(), filepath.Join(dir, "auth.json")) +} + +func enrollmentURL(raw string) (string, error) { + parsed, err := url.Parse(raw) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return "", errors.New("Sprite returned an invalid HTTPS URL") + } + parsed.Scheme = "wss" + parsed.Path = "/v1/link" + parsed.RawQuery = "" + parsed.Fragment = "" + return parsed.String(), nil +} + +func invitationID(uri string) (string, error) { + const prefix = "cmux://enroll/" + if !strings.HasPrefix(uri, prefix) || len(uri) > len(prefix)+16*1024 { + return "", errors.New("invalid enrollment invitation URI") + } + decoded, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(uri, prefix)) + if err != nil { + return "", errors.New("invalid enrollment invitation encoding") + } + var invitation struct { + ID string `json:"id"` + } + if err := json.Unmarshal(decoded, &invitation); err != nil { + return "", errors.New("invalid enrollment invitation payload") + } + if invitation.ID == "" || strings.ContainsAny(invitation.ID, " \t\r\n'\"") { + return "", errors.New("invalid enrollment invitation id") + } + return invitation.ID, nil +} + +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} + +func shellJoin(args []string) string { + quoted := make([]string, len(args)) + for i, value := range args { + quoted[i] = shellQuote(value) + } + return strings.Join(quoted, " ") +} + +func safePath(value string) string { + var builder strings.Builder + for _, char := range value { + if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || + (char >= '0' && char <= '9') || char == '-' || char == '_' { + builder.WriteRune(char) + } + } + if builder.Len() == 0 { + return "sprite" + } + return builder.String() +} + +func hostname() string { + value, err := os.Hostname() + if err != nil || strings.TrimSpace(value) == "" { + return "cmux-sprites" + } + return value +} + +func envOr(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} + +func write(out io.Writer, asJSON bool, value any) int { + if asJSON { + return writeJSON(out, value) + } + switch typed := value.(type) { + case vm: + fmt.Fprintf(out, "%s\t%s\t%s\n", typed.ID, typed.Status, typed.ConnectionURL) + default: + data, _ := json.Marshal(value) + fmt.Fprintln(out, string(data)) + } + return 0 +} + +func writeJSON(out io.Writer, value any) int { + encoder := json.NewEncoder(out) + encoder.SetIndent("", " ") + if err := encoder.Encode(value); err != nil { + return 1 + } + return 0 +} + +func usage(out io.Writer) { + fmt.Fprintln(out, `usage: cmux-sprites [--api-base URL] [--json] + +commands: + login [--open] sign in with a copy-paste device code + logout delete locally stored Stack credentials + status show local authentication state + create [--team ID] create a cmux Sprite through cmux.com + list list your cmux Sprites + exec -- execute a command through cmux.com + connect enroll this device and open the cmux TUI + destroy destroy a Sprite`) +} diff --git a/sprites/cmd/cmux-sprites/main_test.go b/sprites/cmd/cmux-sprites/main_test.go new file mode 100644 index 000000000000..cfdd0f4d878a --- /dev/null +++ b/sprites/cmd/cmux-sprites/main_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestInvitationID(t *testing.T) { + payload, err := json.Marshal(map[string]any{ + "id": "invitation_123", + "secret": "not-inspected-by-cli", + }) + if err != nil { + t.Fatal(err) + } + uri := "cmux://enroll/" + base64.RawURLEncoding.EncodeToString(payload) + + got, err := invitationID(uri) + if err != nil { + t.Fatal(err) + } + if got != "invitation_123" { + t.Fatalf("invitationID() = %q", got) + } +} + +func TestInvitationIDRejectsMalformedValues(t *testing.T) { + for _, value := range []string{ + "https://example.com", + "cmux://enroll/not-base64!", + "cmux://enroll/" + base64.RawURLEncoding.EncodeToString([]byte(`{"id":"bad id"}`)), + } { + if _, err := invitationID(value); err == nil { + t.Fatalf("invitationID(%q) succeeded", value) + } + } +} + +func TestEnrollmentURL(t *testing.T) { + got, err := enrollmentURL("https://example.sprites.app/old?secret=no") + if err != nil { + t.Fatal(err) + } + if got != "wss://example.sprites.app/v1/link" { + t.Fatalf("enrollmentURL() = %q", got) + } + if _, err := enrollmentURL("http://example.sprites.app"); err == nil { + t.Fatal("insecure enrollment URL succeeded") + } +} + +func TestShellJoinQuotesArguments(t *testing.T) { + got := shellJoin([]string{"printf", "%s", "hello; rm -rf /"}) + want := "'printf' '%s' 'hello; rm -rf /'" + if got != want { + t.Fatalf("shellJoin() = %q, want %q", got, want) + } +} + +func TestLoginPrintsCopyPasteCodeAndBindsClient(t *testing.T) { + var startedClient string + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/api/vault/cli/auth/start": + body, _ := io.ReadAll(request.Body) + var value map[string]string + _ = json.Unmarshal(body, &value) + startedClient = value["client"] + _ = json.NewEncoder(response).Encode(authStart{ + DeviceCode: strings.Repeat("a", 64), + UserCode: "ABCD2345", + VerificationURL: "https://cmux.test/approve", + ExpiresInSeconds: 60, + IntervalSeconds: 1, + }) + case "/api/vault/cli/auth/poll": + _ = json.NewEncoder(response).Encode(authPoll{ + Status: "approved", + Client: "cmux-sprites", + AccessToken: "access", + RefreshToken: "refresh", + }) + default: + http.NotFound(response, request) + } + })) + defer server.Close() + + var output strings.Builder + got, err := login(context.Background(), newClient(server.URL, nil), false, &output) + if err != nil { + t.Fatal(err) + } + if startedClient != "cmux-sprites" { + t.Fatalf("start client = %q", startedClient) + } + if !strings.Contains(output.String(), "Copy and paste this code:\n ABCD2345") { + t.Fatalf("login output did not contain copy-paste code: %q", output.String()) + } + if got.AccessToken != "access" || got.RefreshToken != "refresh" { + t.Fatalf("login tokens = %#v", got) + } +} diff --git a/sprites/go.mod b/sprites/go.mod new file mode 100644 index 000000000000..14da42be1549 --- /dev/null +++ b/sprites/go.mod @@ -0,0 +1,3 @@ +module github.com/manaflow-ai/cmux/sprites + +go 1.24 diff --git a/web/app/[locale]/dashboard/vault/cli-auth/page.tsx b/web/app/[locale]/dashboard/vault/cli-auth/page.tsx index 1b98019a00db..2df29c0ff090 100644 --- a/web/app/[locale]/dashboard/vault/cli-auth/page.tsx +++ b/web/app/[locale]/dashboard/vault/cli-auth/page.tsx @@ -41,18 +41,26 @@ export default async function VaultCliAuthPage({ ? await pendingCliAuthClientForUserCode(initialCode, new Date()) : null; const subrouter = client === "subrouter"; + const sprites = client === "cmux-sprites"; + const eyebrowKey = sprites ? "spritesEyebrow" : subrouter ? "subrouterEyebrow" : "eyebrow"; + const titleKey = sprites ? "spritesTitle" : subrouter ? "subrouterTitle" : "title"; + const descriptionKey = sprites + ? "spritesDescription" + : subrouter + ? "subrouterDescription" + : "description"; return (

- {t(subrouter ? "subrouterEyebrow" : "eyebrow")} + {t(eyebrowKey)}

- {t(subrouter ? "subrouterTitle" : "title")} + {t(titleKey)}

- {t(subrouter ? "subrouterDescription" : "description")} + {t(descriptionKey)}

diff --git a/web/app/api/vault/cli/auth/poll/route.ts b/web/app/api/vault/cli/auth/poll/route.ts index bc5a024f05f4..00df7dfe2fb0 100644 --- a/web/app/api/vault/cli/auth/poll/route.ts +++ b/web/app/api/vault/cli/auth/poll/route.ts @@ -23,7 +23,7 @@ async function mintStackTokens( userId: string, client: CliAuthClient, ): Promise { - if (client !== "cmux-vault" && client !== "subrouter") return null; + if (client !== "cmux-vault" && client !== "subrouter" && client !== "cmux-sprites") return null; const user = await getStackServerApp().getUser(userId); if (!user) return null; // Stack sessions establish identity only. Every Subrouter route separately diff --git a/web/app/api/vault/cli/auth/start/route.ts b/web/app/api/vault/cli/auth/start/route.ts index 57cdc9b3cde3..d5b9c7a58918 100644 --- a/web/app/api/vault/cli/auth/start/route.ts +++ b/web/app/api/vault/cli/auth/start/route.ts @@ -49,7 +49,9 @@ export async function POST(request: Request): Promise { const requestedClient = body.value.client; const client = requestedClient === undefined ? "cmux-vault" - : requestedClient === "cmux-vault" || requestedClient === "subrouter" + : requestedClient === "cmux-vault" || + requestedClient === "subrouter" || + requestedClient === "cmux-sprites" ? requestedClient : null; if (!client) { diff --git a/web/app/api/vm/base/routeShared.ts b/web/app/api/vm/base/routeShared.ts index 682c5aebffd0..fa196617d049 100644 --- a/web/app/api/vm/base/routeShared.ts +++ b/web/app/api/vm/base/routeShared.ts @@ -248,7 +248,13 @@ async function parseBaseRequest( } } const provider = typeof candidate.provider === "string" ? candidate.provider.trim() : undefined; - if (provider && provider !== "e2b" && provider !== "freestyle" && provider !== "daytona") { + if ( + provider && + provider !== "e2b" && + provider !== "freestyle" && + provider !== "daytona" && + provider !== "sprites" + ) { return { ok: false, response: vmErrorResponse({ diff --git a/web/app/api/vm/restore/route.ts b/web/app/api/vm/restore/route.ts index 2cbc582ec605..5b172744be21 100644 --- a/web/app/api/vm/restore/route.ts +++ b/web/app/api/vm/restore/route.ts @@ -157,7 +157,12 @@ type ProviderFieldResult = { ok: true; provider?: ProviderId } | { ok: false; re function providerField(body: Record): ProviderFieldResult { const value = stringField(body, "provider"); if (!value) return { ok: true }; - if (value === "e2b" || value === "freestyle" || value === "daytona") return { ok: true, provider: value }; + if ( + value === "e2b" || + value === "freestyle" || + value === "daytona" || + value === "sprites" + ) return { ok: true, provider: value }; return { ok: false, response: vmErrorResponse({ diff --git a/web/app/api/vm/route.ts b/web/app/api/vm/route.ts index 118716ea6275..077794e76646 100644 --- a/web/app/api/vm/route.ts +++ b/web/app/api/vm/route.ts @@ -92,6 +92,7 @@ export async function GET(request: Request): Promise { image: entry.image, imageVersion: entry.imageVersion, createdAt: entry.createdAt, + ...(entry.connectionUrl ? { connectionUrl: entry.connectionUrl } : {}), })); return jsonResponse({ vms }); }, @@ -166,7 +167,12 @@ export async function POST(request: Request): Promise { details: { field: "provider" }, }); } - if (candidate.provider !== "e2b" && candidate.provider !== "freestyle" && candidate.provider !== "daytona") { + if ( + candidate.provider !== "e2b" && + candidate.provider !== "freestyle" && + candidate.provider !== "daytona" && + candidate.provider !== "sprites" + ) { return vmErrorResponse({ error: "vm_invalid_provider", status: 400, @@ -358,9 +364,11 @@ export async function POST(request: Request): Promise { return jsonResponse({ id: created.providerVmId, provider: created.provider, + status: created.status, image: created.image, imageVersion: created.imageVersion, createdAt: created.createdAt, + ...(created.connectionUrl ? { connectionUrl: created.connectionUrl } : {}), }); } }, diff --git a/web/bun.lock b/web/bun.lock index 01bb6a82e814..f175499d5cd4 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -14,6 +14,7 @@ "@effect/platform": "0.93.6", "@effect/sql": "0.48.6", "@effect/sql-pg": "0.49.7", + "@fly/sprites": "0.1.0", "@opentelemetry/api": "^1.9.1", "@orpc/client": "^1.14.7", "@orpc/openapi": "^1.14.7", @@ -294,6 +295,8 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + "@fly/sprites": ["@fly/sprites@0.1.0", "", {}, "sha512-08jnuPEFmImqy3/iwL+jQJ6IwrHuZlmR3w9dRSSUiZ3N4uMSqWMi5FLyL1UKiL/vtcAb9Cayj8gHsEtckDDfkQ=="], + "@formatjs/fast-memoize": ["@formatjs/fast-memoize@3.1.6", "", {}, "sha512-H5aexk1Le7T9TPmscacZ+1pR6CTa2n1wq+HDVGXhH8TzUlQQpeXzZs91dRtmFHrbeNbjPFPfQujUqm7MHgVoXQ=="], "@formatjs/icu-messageformat-parser": ["@formatjs/icu-messageformat-parser@3.5.12", "", { "dependencies": { "@formatjs/icu-skeleton-parser": "2.1.10" } }, "sha512-YyzzxVgYJ8DELmmkhn0Yr0rUj0dTJFf9Jp628K3S0ysInBWxLVDOS8i3RP91cCp4DMK4WYb4cVMhWA9i4knSJg=="], diff --git a/web/db/migrations/20260805043000_cloud_vm_sprites_provider/migration.sql b/web/db/migrations/20260805043000_cloud_vm_sprites_provider/migration.sql new file mode 100644 index 000000000000..9704df2825a7 --- /dev/null +++ b/web/db/migrations/20260805043000_cloud_vm_sprites_provider/migration.sql @@ -0,0 +1 @@ +ALTER TYPE "public"."vm_provider" ADD VALUE IF NOT EXISTS 'sprites'; diff --git a/web/db/migrations/20260805043100_cmux_sprites_cli_auth/migration.sql b/web/db/migrations/20260805043100_cmux_sprites_cli_auth/migration.sql new file mode 100644 index 000000000000..addde2bc76e3 --- /dev/null +++ b/web/db/migrations/20260805043100_cmux_sprites_cli_auth/migration.sql @@ -0,0 +1,6 @@ +ALTER TABLE "vault_cli_auth_requests" + DROP CONSTRAINT IF EXISTS "vault_cli_auth_requests_client_check"; +--> statement-breakpoint +ALTER TABLE "vault_cli_auth_requests" + ADD CONSTRAINT "vault_cli_auth_requests_client_check" + CHECK ("client" IN ('cmux-vault', 'subrouter', 'cmux-sprites')); diff --git a/web/db/schema.ts b/web/db/schema.ts index 64e3291f872d..09b000a4877d 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -14,7 +14,7 @@ import { uuid, } from "drizzle-orm/pg-core"; -export const vmProvider = pgEnum("vm_provider", ["e2b", "freestyle", "daytona"]); +export const vmProvider = pgEnum("vm_provider", ["e2b", "freestyle", "daytona", "sprites"]); export const vmStatus = pgEnum("vm_status", [ "provisioning", @@ -622,7 +622,7 @@ export const vaultCliAuthRequests = pgTable( index("vault_cli_auth_requests_user_code_idx").on(table.userCode), check( "vault_cli_auth_requests_client_check", - sql`${table.client} in ('cmux-vault', 'subrouter')`, + sql`${table.client} in ('cmux-vault', 'subrouter', 'cmux-sprites')`, ), ], ); diff --git a/web/messages/en.json b/web/messages/en.json index 594c8fd6de40..f1c797eb5a14 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -300,6 +300,9 @@ "subrouterEyebrow": "Subrouter", "subrouterTitle": "Approve Subrouter access", "subrouterDescription": "Confirm the code shown by sr to connect this device to your signed-in cmux team.", + "spritesEyebrow": "cmux Sprites", + "spritesTitle": "Approve cmux Sprites access", + "spritesDescription": "Enter the code shown by cmux-sprites. Fly credentials stay on cmux.com and are never sent to this device.", "codeLabel": "User code", "approveButton": "Approve access", "approving": "Approving...", diff --git a/web/messages/ja.json b/web/messages/ja.json index 2bb17e5ee93e..3544ef555fa8 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -300,6 +300,9 @@ "subrouterEyebrow": "Subrouter", "subrouterTitle": "Subrouterアクセスを承認", "subrouterDescription": "srに表示されたコードを確認し、このデバイスをサインイン中のcmuxチームに接続します。", + "spritesEyebrow": "cmux Sprites", + "spritesTitle": "cmux Spritesアクセスを承認", + "spritesDescription": "cmux-spritesに表示されたコードを入力します。Flyの認証情報はcmux.comに保持され、このデバイスには送信されません。", "codeLabel": "ユーザーコード", "approveButton": "アクセスを承認", "approving": "承認中...", diff --git a/web/package.json b/web/package.json index a49f32d890b6..70d26f39ae8c 100644 --- a/web/package.json +++ b/web/package.json @@ -43,6 +43,7 @@ "@effect/platform": "0.93.6", "@effect/sql": "0.48.6", "@effect/sql-pg": "0.49.7", + "@fly/sprites": "0.1.0", "@opentelemetry/api": "^1.9.1", "@orpc/client": "^1.14.7", "@orpc/openapi": "^1.14.7", diff --git a/web/services/vault/cliAuth.ts b/web/services/vault/cliAuth.ts index 0533cec7d2a7..fff7219193e0 100644 --- a/web/services/vault/cliAuth.ts +++ b/web/services/vault/cliAuth.ts @@ -7,7 +7,7 @@ export type CliAuthTokens = { readonly refreshToken: string; }; -export type CliAuthClient = "cmux-vault" | "subrouter"; +export type CliAuthClient = "cmux-vault" | "subrouter" | "cmux-sprites"; type ApprovedClaimRow = { readonly id: string; @@ -184,5 +184,7 @@ export function drizzleCliAuthRepository(): CliAuthRepository { } function cliAuthClient(value: unknown): CliAuthClient | null { - return value === "cmux-vault" || value === "subrouter" ? value : null; + return value === "cmux-vault" || value === "subrouter" || value === "cmux-sprites" + ? value + : null; } diff --git a/web/services/vms/README.md b/web/services/vms/README.md index 47d2060c02b5..917e1621f947 100644 --- a/web/services/vms/README.md +++ b/web/services/vms/README.md @@ -1,6 +1,6 @@ # Cloud VMs service -Backend for `cmux vm new/ls/rm/exec/attach` and the sidebar Cloud VM surface. Stack Auth gates every public route. Provider API keys stay server-side. Freestyle and E2B prefer `cmuxd-remote` WebSocket PTY with short-lived leases; older Freestyle VMs can fall back to its SSH gateway. +Backend for `cmux vm new/ls/rm/exec/attach`, `cmux-sprites`, and the sidebar Cloud VM surface. Stack Auth gates every public route. Provider API keys stay server-side. Freestyle and E2B prefer `cmuxd-remote` WebSocket PTY with short-lived leases; Sprites use native cmux Noise enrollment through the dedicated CLI. ## Layout @@ -9,7 +9,7 @@ services/vms/ auth.ts Stack Auth request verification helpers billingGateway.ts Stack Auth VM create-credit reservations entitlements.ts Team plan and active VM limit resolution - drivers/ Provider SDK adapters for E2B, Freestyle, and Daytona + drivers/ Provider SDK adapters for E2B, Freestyle, Daytona, and Sprites images/ Checked-in known-good provider image manifest errors.ts Typed Effect errors for VM workflows config.ts Runtime kill switches and deployment guards @@ -191,14 +191,17 @@ Set these Vercel environment variables per production/staging environment: - `CMUX_VM_E2B_ENABLED`, per-provider E2B create kill switch. - `CMUX_VM_FREESTYLE_ENABLED`, per-provider Freestyle create kill switch. - `CMUX_VM_DAYTONA_ENABLED`, per-provider Daytona create kill switch. +- `CMUX_VM_SPRITES_ENABLED`, per-provider Sprites create kill switch. - `CMUX_VM_ALLOWED_ORIGINS`, optional comma-separated extra origins allowed for cookie mutations. - `E2B_API_KEY`, E2B provider key. - `FREESTYLE_API_KEY`, Freestyle provider key. - `DAYTONA_API_KEY`, Daytona provider key. +- `SPRITE_TOKEN`, server-only Fly Sprites provider token. - `E2B_CMUXD_WS_TEMPLATE`, E2B template alias/name for WebSocket PTY sandboxes. - `FREESTYLE_SANDBOX_SNAPSHOT`, Freestyle snapshot id. - `DAYTONA_SANDBOX_SNAPSHOT`, Daytona snapshot name for WebSocket PTY sandboxes. -- `CMUX_VM_DEFAULT_PROVIDER`, `freestyle`, `e2b`, or `daytona`. +- `CMUX_SPRITES_NPM_SPEC`, exact `cmux@x.y.z` package installed when public template forking is unavailable. +- `CMUX_VM_DEFAULT_PROVIDER`, `freestyle`, `e2b`, `daytona`, or `sprites`. - `CMUX_VM_PLAN_FREE_CREATE_CREDIT_ITEM_ID`, optional Stack Auth team item used as the free-plan create-credit bucket. Leave unset to skip free-plan create-credit accounting; set to `none`, `disabled`, `off`, or `false` to explicitly opt out. - `CMUX_VM_PLAN_FREE_CREATE_CREDIT_COST`, optional free-plan per-create cost. Defaults to `1`. - `CMUX_VM_PLAN_FREE_INITIAL_CREATE_CREDITS`, optional first-use seed for the free-plan Stack Auth create-credit item. Defaults to `20`. @@ -300,16 +303,21 @@ The dev Postgres port is `CMUX_PORT + 10000`, so `CMUX_PORT=10180` maps to `loca ## Provider matrix -| Verb | Freestyle | E2B | Daytona | -|-----------------------------|-----------|-----|---------| -| `cmux vm new` | yes | yes | yes | -| `cmux vm new --workspace` | yes | yes | yes | -| `cmux vm new --detach` | yes | yes | yes | -| `cmux vm attach ` | yes | yes | yes | -| `cmux vm ssh ` | yes | yes | yes | -| `cmux vm ssh-info ` | legacy SSH info only | legacy SSH info only | no (WebSocket only) | -| `cmux vm exec -- ...` | yes | yes | yes | -| `cmux vm ls / rm` | yes | yes | yes | +| Verb | Freestyle | E2B | Daytona | Sprites | +|-----------------------------|-----------|-----|---------|---------| +| `cmux vm new` | yes | yes | yes | yes | +| `cmux vm new --workspace` | yes | yes | yes | use `cmux-sprites connect` | +| `cmux vm new --detach` | yes | yes | yes | yes | +| `cmux vm attach ` | yes | yes | yes | use `cmux-sprites connect` | +| `cmux vm ssh ` | yes | yes | yes | no | +| `cmux vm ssh-info ` | legacy SSH info only | legacy SSH info only | no (WebSocket only) | no | +| `cmux vm exec -- ...` | yes | yes | yes | yes | +| `cmux vm ls / rm` | yes | yes | yes | yes | + +`cmux-sprites` uses the same authenticated rows and workflows, but exposes the +Sprites-specific create, list, exec, connect, and destroy UX. Its Stack device +flow prints an eight-character code and does not start a localhost callback. +The Fly token remains in the Next.js backend. `cmux vm ssh ` is the user-facing interactive alias and opens the same managed workspace path as `cmux vm attach `. `cmux vm ssh-info ` is print-only for provider SSH debugging. diff --git a/web/services/vms/config.ts b/web/services/vms/config.ts index 4243bbeeabb5..2e740dc7d46a 100644 --- a/web/services/vms/config.ts +++ b/web/services/vms/config.ts @@ -31,6 +31,8 @@ export function providerEnabledEnvKey(provider: ProviderId): string { return "CMUX_VM_FREESTYLE_ENABLED"; case "daytona": return "CMUX_VM_DAYTONA_ENABLED"; + case "sprites": + return "CMUX_VM_SPRITES_ENABLED"; default: return assertNever(provider); } diff --git a/web/services/vms/drivers/index.ts b/web/services/vms/drivers/index.ts index 8e461364f6bb..bd40932dfa7e 100644 --- a/web/services/vms/drivers/index.ts +++ b/web/services/vms/drivers/index.ts @@ -1,10 +1,11 @@ import { DaytonaProvider } from "./daytona"; import { E2BProvider } from "./e2b"; import { FreestyleProvider } from "./freestyle"; +import { SpritesProvider } from "./sprites"; import type { ProviderId, VMProvider } from "./types"; export * from "./types"; -export { DaytonaProvider, E2BProvider, FreestyleProvider }; +export { DaytonaProvider, E2BProvider, FreestyleProvider, SpritesProvider }; let registry: Map | null = null; @@ -13,6 +14,7 @@ function buildRegistry(): Map { map.set("e2b", new E2BProvider()); map.set("freestyle", new FreestyleProvider()); map.set("daytona", new DaytonaProvider()); + map.set("sprites", new SpritesProvider()); return map; } @@ -25,7 +27,12 @@ export function getProvider(id: ProviderId): VMProvider { export function defaultProviderId(): ProviderId { const configured = process.env.CMUX_VM_DEFAULT_PROVIDER as ProviderId | undefined; - if (configured === "e2b" || configured === "freestyle" || configured === "daytona") return configured; + if ( + configured === "e2b" || + configured === "freestyle" || + configured === "daytona" || + configured === "sprites" + ) return configured; // Freestyle is the default for interactive work. The route layer still resolves // the provider image from the manifest/env before any paid create. return "freestyle"; diff --git a/web/services/vms/drivers/sprites.ts b/web/services/vms/drivers/sprites.ts new file mode 100644 index 000000000000..6899ecc10aff --- /dev/null +++ b/web/services/vms/drivers/sprites.ts @@ -0,0 +1,261 @@ +import { randomBytes } from "node:crypto"; +import { ExecError, SpritesClient, type Sprite } from "@fly/sprites"; +import { + NotImplementedError, + ProviderError, + type AttachEndpoint, + type CreateOptions, + type ExecResult, + type SnapshotRef, + type SSHEndpoint, + type VMHandle, + type VMProvider, + type VMStatus, +} from "./types"; +import { withVmSpan } from "../telemetry"; + +const EXEC_MAX_BUFFER_BYTES = 10 * 1024 * 1024; +const REMOTE_STATE_DIR = "/home/sprite/.local/share/cmux-sprite/remote"; +const SERVICE_NAME = "cmux-tui"; + +function client(): SpritesClient { + const token = process.env.SPRITE_TOKEN?.trim() || process.env.SPRITES_TOKEN?.trim(); + if (!token) { + throw new ProviderError("sprites", "SPRITE_TOKEN is required"); + } + return new SpritesClient(token, { + baseURL: process.env.SPRITES_API_URL?.trim() || undefined, + timeout: 30_000, + }); +} + +function spriteName(): string { + return `cmux-${randomBytes(9).toString("hex")}`; +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +function mapStatus(status: string | undefined): VMStatus { + switch (status) { + case "creating": + case "warming": + return "creating"; + case "destroyed": + case "deleted": + return "destroyed"; + case "paused": + case "cold": + case "warm": + // A paused Sprite remains immediately addressable and wakes on the next + // exec or URL request, so it is an active Cloud VM from cmux's perspective. + return "running"; + default: + return "running"; + } +} + +async function drain(stream: AsyncIterable): Promise { + for await (const event of stream) { + // Reading the full stream is the operation completion acknowledgement. + void event; + } +} + +function bootstrapCommand(image: string): string { + return [ + "set -euo pipefail", + `npm install -g --omit=dev --no-audit --fund=false ${shellQuote(image)} >/tmp/cmux-install.log 2>&1`, + "sudo ln -sf \"$(npm prefix -g)/bin/cmux\" /usr/local/bin/cmux", + "/usr/local/bin/cmux --version", + `install -d -m 700 ${shellQuote(REMOTE_STATE_DIR)}`, + ].join("; "); +} + +export class SpritesProvider implements VMProvider { + readonly id = "sprites" as const; + + async create(options: CreateOptions): Promise { + const image = options.image.trim(); + if (!/^cmux@[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/.test(image)) { + throw new ProviderError("sprites", "create requires a pinned cmux npm package"); + } + return withVmSpan( + "cmux.vm.provider.create", + { + "cmux.vm.provider": "sprites", + "cmux.vm.operation": "create", + "cmux.vm.image": image, + }, + async (span) => { + const sprites = client(); + const name = spriteName(); + let sprite: Sprite | null = null; + try { + sprite = await sprites.createSprite(name, { + labels: ["cmux"], + runtime: "dev", + urlSettings: { auth: "public" }, + waitForCapacity: true, + }); + await sprite.execFile("bash", ["-lc", bootstrapCommand(image)], { + maxBuffer: EXEC_MAX_BUFFER_BYTES, + }); + const service = await sprite.createService( + SERVICE_NAME, + { + cmd: "/usr/local/bin/cmux", + args: [ + "daemon", + "--session", + "sprite", + "--remote-ws", + "0.0.0.0:8080", + "--remote-ws-insecure-bind", + "--remote-state-dir", + REMOTE_STATE_DIR, + ], + dir: "/home/sprite", + httpPort: 8080, + }, + "5s", + ); + await drain(service); + const refreshed = await sprites.getSprite(name); + const url = refreshed.url; + if (!url) throw new Error("Sprite create response omitted its URL"); + span.setAttribute("cmux.vm.id", name); + return { + provider: "sprites", + providerVmId: name, + status: "running", + image, + createdAt: Date.now(), + providerMetadata: { url }, + }; + } catch (error) { + if (sprite) await sprite.delete().catch(() => undefined); + throw new ProviderError("sprites", `create(${name})`, error); + } + }, + ); + } + + async destroy(vmId: string): Promise { + try { + await client().deleteSprite(vmId); + } catch (error) { + throw new ProviderError("sprites", `destroy(${vmId})`, error); + } + } + + async getStatus(vmId: string): Promise { + try { + return mapStatus((await client().getSprite(vmId)).status); + } catch (error) { + throw new ProviderError("sprites", `getStatus(${vmId})`, error); + } + } + + async pause(): Promise { + // Sprites pause themselves when no command, session, or URL request is active. + } + + async resume(vmId: string): Promise { + try { + const sprite = await client().getSprite(vmId); + return { + provider: "sprites", + providerVmId: vmId, + status: mapStatus(sprite.status), + image: "cmux:sprite", + createdAt: sprite.createdAt?.getTime() ?? Date.now(), + providerMetadata: sprite.url ? { url: sprite.url } : undefined, + }; + } catch (error) { + throw new ProviderError("sprites", `resume(${vmId})`, error); + } + } + + async exec(vmId: string, command: string): Promise { + try { + const result = await client().sprite(vmId).execFile("bash", ["-lc", command], { + maxBuffer: EXEC_MAX_BUFFER_BYTES, + }); + return { + exitCode: result.exitCode, + stdout: String(result.stdout), + stderr: String(result.stderr), + }; + } catch (error) { + if (error instanceof ExecError) { + return { + exitCode: error.exitCode, + stdout: String(error.stdout), + stderr: String(error.stderr), + }; + } + throw new ProviderError("sprites", `exec(${vmId})`, error); + } + } + + async snapshot(vmId: string, name?: string): Promise { + try { + const sprite = client().sprite(vmId); + const stream = await sprite.createCheckpoint(name); + await drain(stream); + const checkpoints = await sprite.listCheckpoints(); + const checkpoint = checkpoints.toSorted((a, b) => + b.createTime.getTime() - a.createTime.getTime() + )[0]; + if (!checkpoint) throw new Error("Sprite checkpoint completed without a checkpoint id"); + return { + id: `${vmId}:${checkpoint.id}`, + createdAt: checkpoint.createTime.getTime(), + name, + }; + } catch (error) { + throw new ProviderError("sprites", `snapshot(${vmId})`, error); + } + } + + async restore(snapshotId: string): Promise { + const separator = snapshotId.lastIndexOf(":"); + if (separator <= 0 || separator === snapshotId.length - 1) { + throw new ProviderError("sprites", "restore requires :"); + } + const vmId = snapshotId.slice(0, separator); + const checkpoint = snapshotId.slice(separator + 1); + try { + const sprite = client().sprite(vmId); + await drain(await sprite.restoreCheckpoint(checkpoint)); + const refreshed = await client().getSprite(vmId); + return { + provider: "sprites", + providerVmId: vmId, + status: mapStatus(refreshed.status), + image: `checkpoint:${checkpoint}`, + createdAt: Date.now(), + providerMetadata: refreshed.url ? { url: refreshed.url } : undefined, + }; + } catch (error) { + throw new ProviderError("sprites", `restore(${snapshotId})`, error); + } + } + + async openAttach(): Promise { + throw new NotImplementedError( + "sprites", + "use cmux-sprites connect for Noise enrollment", + ); + } + + async openSSH(): Promise { + throw new NotImplementedError("sprites", "SSH is not exposed by cmux-sprites"); + } + + async revokeSSHIdentity(): Promise { + // Sprites never mint SSH identities. + } +} diff --git a/web/services/vms/drivers/types.ts b/web/services/vms/drivers/types.ts index ca703d77d88e..730df18da2da 100644 --- a/web/services/vms/drivers/types.ts +++ b/web/services/vms/drivers/types.ts @@ -2,7 +2,7 @@ // per-provider implementations behind an interface. Callers hold a `VMProvider` and never reach // into specifics. -export type ProviderId = "e2b" | "freestyle" | "daytona"; +export type ProviderId = "e2b" | "freestyle" | "daytona" | "sprites"; export type VMStatus = "creating" | "running" | "paused" | "destroyed"; diff --git a/web/services/vms/images/manifest.json b/web/services/vms/images/manifest.json index 196f8c09cf96..a83f035afaf0 100644 --- a/web/services/vms/images/manifest.json +++ b/web/services/vms/images/manifest.json @@ -147,6 +147,18 @@ }, "validationStatus": "passed", "notes": "Validated Daytona preview gate (healthz 401 without x-daytona-preview-token), WS PTY lease auth + single-use replay rejection, shell round trip, RPC hello + reusable lease, and RPC HTTP proxy to healthz. Region: shared us (runner in Corona, CA). binarySha256=d7d2a6b1ef9080ae0deadca3ced6fefbb4aeb88292d136b31bd0ea0784c01461 nodeMajor=22 agentTools=@anthropic-ai/claude-code@2.1.137,opencode-ai@1.14.41,@openai/codex@0.130.0,@earendil-works/pi-coding-agent@0.74.0" + }, + { + "provider": "sprites", + "version": "sprites-bootstrap-20260805a", + "imageId": "cmux@0.9.11", + "envVar": "CMUX_SPRITES_NPM_SPEC", + "defaultForLocalDev": true, + "cmuxdRemoteCommit": "a2b3c10f119324c9dbfecc29881d30ff54404a84", + "builtAt": "2026-08-05T04:30:00.000Z", + "builderScriptVersion": "bootstrap-v1", + "validationStatus": "passed", + "notes": "Validated direct Noise-enrolled cmux TUI over a public Sprite WSS carrier. Public Sprite fork templates are not yet documented, so create bootstraps this exact npm package." } ] } diff --git a/web/services/vms/images/resolver.ts b/web/services/vms/images/resolver.ts index e43201714919..14bb806c21c1 100644 --- a/web/services/vms/images/resolver.ts +++ b/web/services/vms/images/resolver.ts @@ -40,6 +40,8 @@ export function providerImageEnvKey(provider: ProviderId): string { return "FREESTYLE_SANDBOX_SNAPSHOT"; case "daytona": return "DAYTONA_SANDBOX_SNAPSHOT"; + case "sprites": + return "CMUX_SPRITES_NPM_SPEC"; default: return assertNever(provider); } diff --git a/web/services/vms/workflows.ts b/web/services/vms/workflows.ts index bda69e355e2e..1518403e58e5 100644 --- a/web/services/vms/workflows.ts +++ b/web/services/vms/workflows.ts @@ -56,6 +56,7 @@ export type VmEntry = { readonly imageVersion: string | null; readonly status: CloudVmStatus; readonly createdAt: number; + readonly connectionUrl?: string; }; export type BaseVmEntry = VmEntry & { @@ -2039,6 +2040,9 @@ function vmEntryFromRow(row: CloudVmRow): VmEntry { if (!row.providerVmId) { throw new Error(`VM row has no provider VM id: ${row.id}`); } + const connectionUrl = row.provider === "sprites" && typeof row.providerMetadata.url === "string" + ? row.providerMetadata.url + : undefined; return { providerVmId: row.providerVmId, provider: row.provider, @@ -2046,6 +2050,7 @@ function vmEntryFromRow(row: CloudVmRow): VmEntry { imageVersion: row.imageVersion, status: row.status, createdAt: row.createdAt.getTime(), + ...(connectionUrl ? { connectionUrl } : {}), }; } diff --git a/web/tests/vault-cli-auth.test.ts b/web/tests/vault-cli-auth.test.ts index 6ed6c7fda6dc..d01338ea94f8 100644 --- a/web/tests/vault-cli-auth.test.ts +++ b/web/tests/vault-cli-auth.test.ts @@ -9,7 +9,7 @@ import { type FakeRow = { id: string; deviceCodeHash: string; - client: "cmux-vault" | "subrouter"; + client: "cmux-vault" | "subrouter" | "cmux-sprites"; status: string; userId: string | null; expiresAt: Date; @@ -51,11 +51,11 @@ function countingMinter( tokens: CliAuthTokens | null, ): { minter: CliAuthTokenMinter; - calls: Array<{ userId: string; client: "cmux-vault" | "subrouter" }>; + calls: Array<{ userId: string; client: "cmux-vault" | "subrouter" | "cmux-sprites" }>; } { const calls: Array<{ userId: string; - client: "cmux-vault" | "subrouter"; + client: "cmux-vault" | "subrouter" | "cmux-sprites"; }> = []; return { minter: async (userId, client) => { @@ -95,6 +95,19 @@ describe("vault CLI auth claim", () => { expect(row.status).toBe("claimed"); }); + test("binds a cmux-sprites approval to the cmux-sprites client", async () => { + const row = approvedRow(); + row.client = "cmux-sprites"; + const { minter } = countingMinter({ accessToken: "access-1", refreshToken: "refresh-1" }); + + await expect(claimCliAuthTokens(fakeRepository(row), minter, "hash-1", NOW)).resolves.toEqual({ + status: "approved", + client: "cmux-sprites", + accessToken: "access-1", + refreshToken: "refresh-1", + }); + }); + test("second claim is terminal and does not mint again", async () => { const row = approvedRow(); const repository = fakeRepository(row); diff --git a/web/tests/vm-sprites-provider.test.ts b/web/tests/vm-sprites-provider.test.ts new file mode 100644 index 000000000000..0c85c673b980 --- /dev/null +++ b/web/tests/vm-sprites-provider.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import { SpritesProvider } from "../services/vms/drivers/sprites"; +import { providerEnabledEnvKey } from "../services/vms/config"; +import { providerImageEnvKey, resolveVmImage } from "../services/vms/images/resolver"; + +describe("SpritesProvider", () => { + test("rejects an unpinned bootstrap package before calling Fly", async () => { + const provider = new SpritesProvider(); + + await expect(provider.create({ image: "cmux@latest" })).rejects.toThrow( + "pinned cmux npm package", + ); + }); + + test("routes interactive attach through Noise enrollment", async () => { + const provider = new SpritesProvider(); + + await expect(provider.openAttach()).rejects.toThrow( + "cmux-sprites connect", + ); + await expect(provider.openSSH()).rejects.toThrow( + "SSH is not exposed", + ); + }); + + test("has explicit create and image configuration", () => { + expect(providerEnabledEnvKey("sprites")).toBe("CMUX_VM_SPRITES_ENABLED"); + expect(providerImageEnvKey("sprites")).toBe("CMUX_SPRITES_NPM_SPEC"); + expect(resolveVmImage("sprites", undefined, {})).toMatchObject({ + provider: "sprites", + image: "cmux@0.9.11", + imageVersion: "sprites-bootstrap-20260805a", + }); + }); +}); From 65d95d99f78ad2d6bc15fe0f67a2a5400e4d8b03 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:02:43 -0700 Subject: [PATCH 2/6] Harden cmux Sprites provisioning and auth --- sprites/cmd/cmux-sprites/main.go | 113 ++++++++++++++++-- sprites/cmd/cmux-sprites/main_test.go | 92 ++++++++++++++ web/app/api/vault/cli/auth/poll/route.ts | 4 +- web/app/api/vault/cli/auth/start/route.ts | 9 +- web/app/api/vm/base/routeShared.ts | 13 +- web/app/api/vm/restore/route.ts | 12 +- web/app/api/vm/route.ts | 12 +- .../migration.sql | 2 +- web/messages/ar.json | 1 + web/messages/bs.json | 1 + web/messages/da.json | 1 + web/messages/de.json | 1 + web/messages/es.json | 1 + web/messages/fr.json | 1 + web/messages/it.json | 1 + web/messages/km.json | 1 + web/messages/ko.json | 1 + web/messages/no.json | 1 + web/messages/pl.json | 1 + web/messages/pt-BR.json | 1 + web/messages/ru.json | 1 + web/messages/th.json | 1 + web/messages/tr.json | 1 + web/messages/uk.json | 1 + web/messages/zh-CN.json | 1 + web/messages/zh-TW.json | 1 + web/services/vault/cliAuth.ts | 2 +- web/services/vms/drivers/index.ts | 11 +- web/services/vms/drivers/sprites.ts | 69 ++++++----- web/services/vms/drivers/types.ts | 7 +- web/services/vms/routeHelpers.ts | 39 ++---- web/tests/vault-cli-auth.test.ts | 7 +- web/tests/vm-route-auth.test.ts | 18 ++- web/tests/vm-sprites-provider.test.ts | 43 ++++++- 34 files changed, 357 insertions(+), 114 deletions(-) diff --git a/sprites/cmd/cmux-sprites/main.go b/sprites/cmd/cmux-sprites/main.go index a5c7e51c9f09..7957c1be8094 100644 --- a/sprites/cmd/cmux-sprites/main.go +++ b/sprites/cmd/cmux-sprites/main.go @@ -61,6 +61,7 @@ type execResult struct { type apiClient struct { baseURL string + baseErr error tokens *tokens http *http.Client } @@ -321,8 +322,12 @@ func connect(ctx context.Context, args []string, apiBase, configDir string, stdo shellQuote(advertise), ) created, err := client.exec(ctx, vmID, createCommand) - if err != nil || created.ExitCode != 0 { - fmt.Fprintf(stderr, "connect failed to create enrollment: %v %s\n", err, created.Stderr) + if err != nil { + fmt.Fprintf(stderr, "connect failed to create enrollment: %v\n", err) + return 1 + } + if created.ExitCode != 0 { + fmt.Fprintf(stderr, "connect failed to create enrollment: remote command exited %d\n", created.ExitCode) return 1 } var invitation struct { @@ -422,13 +427,20 @@ func approveEnrollment(ctx context.Context, client *apiClient, vmID, invitationI defer deadline.Stop() ticker := time.NewTicker(time.Second) defer ticker.Stop() + var latestError error for { - pending, err := client.exec( + pending, err := client.execWithTimeout( ctx, vmID, "/usr/local/bin/cmux enroll pending --session sprite --state-dir "+ shellQuote("/home/sprite/.local/share/cmux-sprite/remote")+" --json", + 8_000, ) + if err != nil { + latestError = err + } else if pending.ExitCode != 0 { + latestError = fmt.Errorf("pending-enrollment command exited %d", pending.ExitCode) + } if err == nil && pending.ExitCode == 0 { var requests []struct { InvitationID string `json:"invitation_id"` @@ -436,18 +448,19 @@ func approveEnrollment(ctx context.Context, client *apiClient, vmID, invitationI if json.Unmarshal([]byte(pending.Stdout), &requests) == nil { for _, request := range requests { if request.InvitationID == invitationID { - approved, err := client.exec( + approved, err := client.execWithTimeout( ctx, vmID, "/usr/local/bin/cmux enroll approve "+shellQuote(invitationID)+ " --session sprite --state-dir "+ shellQuote("/home/sprite/.local/share/cmux-sprite/remote"), + 8_000, ) if err != nil { return err } if approved.ExitCode != 0 { - return errors.New(strings.TrimSpace(approved.Stderr)) + return fmt.Errorf("enrollment approval command exited %d", approved.ExitCode) } return nil } @@ -458,6 +471,12 @@ func approveEnrollment(ctx context.Context, client *apiClient, vmID, invitationI case <-ctx.Done(): return ctx.Err() case <-deadline.C: + if latestError != nil { + return fmt.Errorf( + "timed out waiting for the device enrollment claim; last check: %w", + latestError, + ) + } return errors.New("timed out waiting for the device enrollment claim") case <-ticker.C: } @@ -465,15 +484,27 @@ func approveEnrollment(ctx context.Context, client *apiClient, vmID, invitationI } func (c *apiClient) exec(ctx context.Context, vmID, command string) (execResult, error) { + return c.execWithTimeout(ctx, vmID, command, 60_000) +} + +func (c *apiClient) execWithTimeout( + ctx context.Context, + vmID string, + command string, + timeoutMs int, +) (execResult, error) { var result execResult err := c.request(ctx, http.MethodPost, "/api/vm/"+url.PathEscape(vmID)+"/exec", map[string]any{ "command": command, - "timeoutMs": 60_000, + "timeoutMs": timeoutMs, }, &result) return result, err } func (c *apiClient) request(ctx context.Context, method, path string, body any, out any) error { + if c.baseErr != nil { + return c.baseErr + } var payload []byte var err error if body != nil { @@ -504,7 +535,7 @@ func (c *apiClient) request(ctx context.Context, method, path string, body any, return err } if response.StatusCode < 200 || response.StatusCode >= 300 { - return fmt.Errorf("cmux.com returned %d: %s", response.StatusCode, strings.TrimSpace(string(data))) + return apiResponseError(response.StatusCode, data) } if out != nil && len(data) > 0 { return json.Unmarshal(data, out) @@ -513,11 +544,75 @@ func (c *apiClient) request(ctx context.Context, method, path string, body any, } func newClient(baseURL string, value *tokens) *apiClient { + normalized, baseErr := normalizedAPIBase(baseURL) return &apiClient{ - baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"), + baseURL: normalized, + baseErr: baseErr, tokens: value, - http: &http.Client{Timeout: 90 * time.Second}, + http: &http.Client{ + Timeout: 90 * time.Second, + CheckRedirect: func(request *http.Request, via []*http.Request) error { + if len(via) == 0 { + return nil + } + original := via[0].URL + if request.URL.Scheme != original.Scheme || request.URL.Host != original.Host { + return errors.New("refusing cross-origin API redirect") + } + return nil + }, + }, + } +} + +func normalizedAPIBase(raw string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Host == "" || parsed.User != nil || + parsed.RawQuery != "" || parsed.Fragment != "" { + return "", errors.New("invalid cmux API base URL") + } + if parsed.Scheme != "https" { + host := parsed.Hostname() + if parsed.Scheme != "http" || + (host != "localhost" && host != "127.0.0.1" && host != "::1") { + return "", errors.New("cmux API base URL must use HTTPS") + } + } + parsed.Path = strings.TrimRight(parsed.Path, "/") + return parsed.String(), nil +} + +func apiResponseError(status int, data []byte) error { + code := "" + var envelope struct { + Error json.RawMessage `json:"error"` + } + if json.Unmarshal(data, &envelope) == nil && len(envelope.Error) > 0 { + var direct string + if json.Unmarshal(envelope.Error, &direct) == nil { + code = direct + } else { + var nested struct { + Code string `json:"code"` + } + if json.Unmarshal(envelope.Error, &nested) == nil { + code = nested.Code + } + } + } + if code != "" { + for _, char := range code { + if !((char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || + char == '_' || char == '-' || char == '.') { + code = "" + break + } + } + } + if code != "" && len(code) <= 80 { + return fmt.Errorf("cmux API request failed (status %d, code %s)", status, code) } + return fmt.Errorf("cmux API request failed (status %d)", status) } func authedClient(apiBase, dir string, stderr io.Writer) (*apiClient, bool) { diff --git a/sprites/cmd/cmux-sprites/main_test.go b/sprites/cmd/cmux-sprites/main_test.go index cfdd0f4d878a..1919cf02ebc2 100644 --- a/sprites/cmd/cmux-sprites/main_test.go +++ b/sprites/cmd/cmux-sprites/main_test.go @@ -107,3 +107,95 @@ func TestLoginPrintsCopyPasteCodeAndBindsClient(t *testing.T) { t.Fatalf("login tokens = %#v", got) } } + +func TestLoginRejectsCredentialsMintedForAnotherClient(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + switch request.URL.Path { + case "/api/vault/cli/auth/start": + _ = json.NewEncoder(response).Encode(authStart{ + DeviceCode: strings.Repeat("a", 64), + UserCode: "ABCD2345", + VerificationURL: "https://cmux.test/approve", + ExpiresInSeconds: 60, + IntervalSeconds: 1, + }) + case "/api/vault/cli/auth/poll": + _ = json.NewEncoder(response).Encode(authPoll{ + Status: "approved", + Client: "cmux-vault", + AccessToken: "must-not-return", + RefreshToken: "must-not-return", + }) + default: + http.NotFound(response, request) + } + })) + defer server.Close() + + got, err := login(context.Background(), newClient(server.URL, nil), false, io.Discard) + if err == nil { + t.Fatal("login accepted credentials for cmux-vault") + } + if got.AccessToken != "" || got.RefreshToken != "" { + t.Fatalf("login returned mismatched tokens: %#v", got) + } +} + +func TestAPIClientRejectsInsecureRemoteBaseAndCrossOriginRedirects(t *testing.T) { + insecure := newClient("http://example.com", &tokens{ + AccessToken: "access", + RefreshToken: "refresh", + }) + if err := insecure.request(context.Background(), http.MethodGet, "/api/vm", nil, nil); err == nil { + t.Fatal("insecure remote API base succeeded") + } + + targetCalled := false + target := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + targetCalled = true + response.WriteHeader(http.StatusNoContent) + })) + defer target.Close() + source := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + http.Redirect(response, request, target.URL, http.StatusTemporaryRedirect) + })) + defer source.Close() + + redirecting := newClient(source.URL, &tokens{ + AccessToken: "access", + RefreshToken: "refresh", + }) + err := redirecting.request(context.Background(), http.MethodGet, "/", nil, nil) + if err == nil || !strings.Contains(err.Error(), "refusing cross-origin API redirect") { + t.Fatalf("cross-origin redirect error = %v", err) + } + if targetCalled { + t.Fatal("credential-bearing redirect reached the second origin") + } +} + +func TestAPIClientDoesNotExposeRawErrorBodies(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + response.WriteHeader(http.StatusBadGateway) + _, _ = response.Write([]byte(`{"error":"vm_cloud_service_unavailable","details":{"providerMessage":"SPRITE_TOKEN secret-provider-detail"}}`)) + })) + defer server.Close() + + err := newClient(server.URL, nil).request( + context.Background(), + http.MethodGet, + "/api/vm", + nil, + nil, + ) + if err == nil { + t.Fatal("error response succeeded") + } + if strings.Contains(err.Error(), "SPRITE_TOKEN") || + strings.Contains(err.Error(), "secret-provider-detail") { + t.Fatalf("raw API body leaked: %v", err) + } + if !strings.Contains(err.Error(), "code vm_cloud_service_unavailable") { + t.Fatalf("safe error code missing: %v", err) + } +} diff --git a/web/app/api/vault/cli/auth/poll/route.ts b/web/app/api/vault/cli/auth/poll/route.ts index 00df7dfe2fb0..b9b2730db935 100644 --- a/web/app/api/vault/cli/auth/poll/route.ts +++ b/web/app/api/vault/cli/auth/poll/route.ts @@ -23,7 +23,9 @@ async function mintStackTokens( userId: string, client: CliAuthClient, ): Promise { - if (client !== "cmux-vault" && client !== "subrouter" && client !== "cmux-sprites") return null; + // The client binding is returned with the minted session so the caller can + // reject credentials approved for another CLI. + void client; const user = await getStackServerApp().getUser(userId); if (!user) return null; // Stack sessions establish identity only. Every Subrouter route separately diff --git a/web/app/api/vault/cli/auth/start/route.ts b/web/app/api/vault/cli/auth/start/route.ts index d5b9c7a58918..8fe104e5cb93 100644 --- a/web/app/api/vault/cli/auth/start/route.ts +++ b/web/app/api/vault/cli/auth/start/route.ts @@ -8,6 +8,7 @@ import { withCliAuthApiRoute } from "../../../../../../services/vault/routeHelpe import { readVaultJsonObject } from "../../../../../../services/vault/validation"; import { setSpanAttributes } from "../../../../../../services/telemetry"; import { jsonResponse } from "../../../../../../services/vms/routeHelpers"; +import { cliAuthClient } from "../../../../../../services/vault/cliAuth"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -47,13 +48,7 @@ export async function POST(request: Request): Promise { return jsonResponse({ error: body.error }, body.error === "request_too_large" ? 413 : 400); } const requestedClient = body.value.client; - const client = requestedClient === undefined - ? "cmux-vault" - : requestedClient === "cmux-vault" || - requestedClient === "subrouter" || - requestedClient === "cmux-sprites" - ? requestedClient - : null; + const client = cliAuthClient(requestedClient ?? "cmux-vault"); if (!client) { return jsonResponse({ error: "invalid_client" }, 400); } diff --git a/web/app/api/vm/base/routeShared.ts b/web/app/api/vm/base/routeShared.ts index fa196617d049..1fa103bdd04c 100644 --- a/web/app/api/vm/base/routeShared.ts +++ b/web/app/api/vm/base/routeShared.ts @@ -1,6 +1,6 @@ import type { AuthedUser } from "../../../../services/vms/auth"; import { assertVmCreateEnabled } from "../../../../services/vms/config"; -import { defaultProviderId, type ProviderId } from "../../../../services/vms/drivers"; +import { defaultProviderId, isProviderId, type ProviderId } from "../../../../services/vms/drivers"; import { isVmBillingTeamResolutionError, isVmProGateBlocked, @@ -248,13 +248,10 @@ async function parseBaseRequest( } } const provider = typeof candidate.provider === "string" ? candidate.provider.trim() : undefined; - if ( - provider && - provider !== "e2b" && - provider !== "freestyle" && - provider !== "daytona" && - provider !== "sprites" - ) { + // Fly's public API can restore a checkpoint only into its source Sprite. + // This base route creates a distinct VM identity, so Sprites must fail closed + // until Fly publishes a cross-Sprite fork API. + if (provider && (!isProviderId(provider) || provider === "sprites")) { return { ok: false, response: vmErrorResponse({ diff --git a/web/app/api/vm/restore/route.ts b/web/app/api/vm/restore/route.ts index 5b172744be21..8dc090af1c26 100644 --- a/web/app/api/vm/restore/route.ts +++ b/web/app/api/vm/restore/route.ts @@ -1,5 +1,5 @@ import { unauthorized, verifyRequest, type AuthedUser } from "../../../../services/vms/auth"; -import { defaultProviderId, type ProviderId } from "../../../../services/vms/drivers"; +import { defaultProviderId, isProviderId, type ProviderId } from "../../../../services/vms/drivers"; import { jsonResponse, requestedVmTeamIdFromRequest, @@ -157,12 +157,10 @@ type ProviderFieldResult = { ok: true; provider?: ProviderId } | { ok: false; re function providerField(body: Record): ProviderFieldResult { const value = stringField(body, "provider"); if (!value) return { ok: true }; - if ( - value === "e2b" || - value === "freestyle" || - value === "daytona" || - value === "sprites" - ) return { ok: true, provider: value }; + // restoreVm creates a new database VM. A Sprite checkpoint can currently be + // restored only in place, so accepting it here would mutate the source while + // recording a second identity for the same provider VM. + if (isProviderId(value) && value !== "sprites") return { ok: true, provider: value }; return { ok: false, response: vmErrorResponse({ diff --git a/web/app/api/vm/route.ts b/web/app/api/vm/route.ts index 077794e76646..e1746e5e9313 100644 --- a/web/app/api/vm/route.ts +++ b/web/app/api/vm/route.ts @@ -8,6 +8,7 @@ import { } from "../../../services/vms/auth"; import { defaultProviderId, + isProviderId, type ProviderId, } from "../../../services/vms/drivers"; import { assertVmCreateEnabled } from "../../../services/vms/config"; @@ -94,7 +95,7 @@ export async function GET(request: Request): Promise { createdAt: entry.createdAt, ...(entry.connectionUrl ? { connectionUrl: entry.connectionUrl } : {}), })); - return jsonResponse({ vms }); + return jsonResponse({ vms }, 200, { "cache-control": "no-store" }); }, ); } @@ -167,12 +168,7 @@ export async function POST(request: Request): Promise { details: { field: "provider" }, }); } - if ( - candidate.provider !== "e2b" && - candidate.provider !== "freestyle" && - candidate.provider !== "daytona" && - candidate.provider !== "sprites" - ) { + if (!isProviderId(candidate.provider)) { return vmErrorResponse({ error: "vm_invalid_provider", status: 400, @@ -369,7 +365,7 @@ export async function POST(request: Request): Promise { imageVersion: created.imageVersion, createdAt: created.createdAt, ...(created.connectionUrl ? { connectionUrl: created.connectionUrl } : {}), - }); + }, 200, { "cache-control": "no-store" }); } }, ); diff --git a/web/db/migrations/20260805043100_cmux_sprites_cli_auth/migration.sql b/web/db/migrations/20260805043100_cmux_sprites_cli_auth/migration.sql index addde2bc76e3..7e4407453e1d 100644 --- a/web/db/migrations/20260805043100_cmux_sprites_cli_auth/migration.sql +++ b/web/db/migrations/20260805043100_cmux_sprites_cli_auth/migration.sql @@ -3,4 +3,4 @@ ALTER TABLE "vault_cli_auth_requests" --> statement-breakpoint ALTER TABLE "vault_cli_auth_requests" ADD CONSTRAINT "vault_cli_auth_requests_client_check" - CHECK ("client" IN ('cmux-vault', 'subrouter', 'cmux-sprites')); + CHECK ("client" IN ('cmux-vault', 'subrouter', 'cmux-sprites')) NOT VALID; diff --git a/web/messages/ar.json b/web/messages/ar.json index 82b6c24d0b49..047216d236e5 100644 --- a/web/messages/ar.json +++ b/web/messages/ar.json @@ -1,4 +1,5 @@ { + "vault": {"cliAuth": {"spritesEyebrow": "cmux Sprites", "spritesTitle": "الموافقة على وصول cmux Sprites", "spritesDescription": "أدخل الرمز المعروض في cmux-sprites. تبقى بيانات اعتماد Fly على cmux.com ولا يتم إرسالها مطلقًا إلى هذا الجهاز."}}, "cloudBillingReturn": { "eyebrow": "cmux Cloud", "returnTitle": "تابع في الطرفية", diff --git a/web/messages/bs.json b/web/messages/bs.json index fa38e73cf605..0a93d1e32efb 100644 --- a/web/messages/bs.json +++ b/web/messages/bs.json @@ -1,4 +1,5 @@ { + "vault": {"cliAuth": {"spritesEyebrow": "cmux Sprites", "spritesTitle": "Odobri pristup za cmux Sprites", "spritesDescription": "Unesite kod prikazan u cmux-sprites. Fly vjerodajnice ostaju na cmux.com i nikada se ne šalju na ovaj uređaj."}}, "cloudBillingReturn": { "eyebrow": "cmux Cloud", "returnTitle": "Nastavite u terminalu", diff --git a/web/messages/da.json b/web/messages/da.json index 6ed9d63ff1f6..8a7007ad0a19 100644 --- a/web/messages/da.json +++ b/web/messages/da.json @@ -1,4 +1,5 @@ { + "vault": {"cliAuth": {"spritesEyebrow": "cmux Sprites", "spritesTitle": "Godkend adgang til cmux Sprites", "spritesDescription": "Indtast koden, der vises af cmux-sprites. Fly-legitimationsoplysninger forbliver på cmux.com og sendes aldrig til denne enhed."}}, "cloudBillingReturn": { "eyebrow": "cmux Cloud", "returnTitle": "Fortsæt i terminalen", diff --git a/web/messages/de.json b/web/messages/de.json index 00a842109b82..91b2cad57628 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -1,4 +1,5 @@ { + "vault": {"cliAuth": {"spritesEyebrow": "cmux Sprites", "spritesTitle": "Zugriff auf cmux Sprites genehmigen", "spritesDescription": "Geben Sie den von cmux-sprites angezeigten Code ein. Fly-Zugangsdaten bleiben auf cmux.com und werden niemals an dieses Gerät gesendet."}}, "cloudBillingReturn": { "eyebrow": "cmux Cloud", "returnTitle": "Im Terminal fortfahren", diff --git a/web/messages/es.json b/web/messages/es.json index f079d25cf5b8..4dded3db23b2 100644 --- a/web/messages/es.json +++ b/web/messages/es.json @@ -1,4 +1,5 @@ { + "vault": {"cliAuth": {"spritesEyebrow": "cmux Sprites", "spritesTitle": "Aprobar el acceso a cmux Sprites", "spritesDescription": "Introduce el código que muestra cmux-sprites. Las credenciales de Fly permanecen en cmux.com y nunca se envían a este dispositivo."}}, "cloudBillingReturn": { "eyebrow": "cmux Cloud", "returnTitle": "Continúa en la terminal", diff --git a/web/messages/fr.json b/web/messages/fr.json index 765aaa87b86b..e25df887eca8 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -1,4 +1,5 @@ { + "vault": {"cliAuth": {"spritesEyebrow": "cmux Sprites", "spritesTitle": "Autoriser l’accès à cmux Sprites", "spritesDescription": "Saisissez le code affiché par cmux-sprites. Les identifiants Fly restent sur cmux.com et ne sont jamais envoyés à cet appareil."}}, "cloudBillingReturn": { "eyebrow": "cmux Cloud", "returnTitle": "Continuer dans le terminal", diff --git a/web/messages/it.json b/web/messages/it.json index a12ec0a08ec4..8235241a99c4 100644 --- a/web/messages/it.json +++ b/web/messages/it.json @@ -1,4 +1,5 @@ { + "vault": {"cliAuth": {"spritesEyebrow": "cmux Sprites", "spritesTitle": "Approva l’accesso a cmux Sprites", "spritesDescription": "Inserisci il codice mostrato da cmux-sprites. Le credenziali Fly restano su cmux.com e non vengono mai inviate a questo dispositivo."}}, "cloudBillingReturn": { "eyebrow": "cmux Cloud", "returnTitle": "Continua nel terminale", diff --git a/web/messages/km.json b/web/messages/km.json index d7458cb7b972..05c5e39f9771 100644 --- a/web/messages/km.json +++ b/web/messages/km.json @@ -1,4 +1,5 @@ { + "vault": {"cliAuth": {"spritesEyebrow": "cmux Sprites", "spritesTitle": "អនុម័តការចូលប្រើ cmux Sprites", "spritesDescription": "បញ្ចូលកូដដែលបង្ហាញដោយ cmux-sprites។ ព័ត៌មានសម្ងាត់ Fly រក្សាទុកនៅលើ cmux.com ហើយមិនត្រូវបានផ្ញើទៅឧបករណ៍នេះទេ។"}}, "cloudBillingReturn": { "eyebrow": "cmux Cloud", "returnTitle": "បន្តនៅក្នុងស្ថានីយ", diff --git a/web/messages/ko.json b/web/messages/ko.json index a6de93c132d7..a36a1db27f22 100644 --- a/web/messages/ko.json +++ b/web/messages/ko.json @@ -1,4 +1,5 @@ { + "vault": {"cliAuth": {"spritesEyebrow": "cmux Sprites", "spritesTitle": "cmux Sprites 접근 승인", "spritesDescription": "cmux-sprites에 표시된 코드를 입력하세요. Fly 자격 증명은 cmux.com에만 유지되며 이 기기로 전송되지 않습니다."}}, "cloudBillingReturn": { "eyebrow": "cmux Cloud", "returnTitle": "터미널에서 계속하기", diff --git a/web/messages/no.json b/web/messages/no.json index e451e6e492cd..cdfe824483e0 100644 --- a/web/messages/no.json +++ b/web/messages/no.json @@ -1,4 +1,5 @@ { + "vault": {"cliAuth": {"spritesEyebrow": "cmux Sprites", "spritesTitle": "Godkjenn tilgang til cmux Sprites", "spritesDescription": "Skriv inn koden som vises av cmux-sprites. Fly-legitimasjonen forblir på cmux.com og sendes aldri til denne enheten."}}, "cloudBillingReturn": { "eyebrow": "cmux Cloud", "returnTitle": "Fortsett i terminalen", diff --git a/web/messages/pl.json b/web/messages/pl.json index 85ad4096b0eb..08114dc48121 100644 --- a/web/messages/pl.json +++ b/web/messages/pl.json @@ -1,4 +1,5 @@ { + "vault": {"cliAuth": {"spritesEyebrow": "cmux Sprites", "spritesTitle": "Zatwierdź dostęp do cmux Sprites", "spritesDescription": "Wprowadź kod wyświetlony przez cmux-sprites. Dane logowania Fly pozostają w cmux.com i nigdy nie są wysyłane na to urządzenie."}}, "cloudBillingReturn": { "eyebrow": "cmux Cloud", "returnTitle": "Kontynuuj w terminalu", diff --git a/web/messages/pt-BR.json b/web/messages/pt-BR.json index cd8c93afe378..c6d140b080b8 100644 --- a/web/messages/pt-BR.json +++ b/web/messages/pt-BR.json @@ -1,4 +1,5 @@ { + "vault": {"cliAuth": {"spritesEyebrow": "cmux Sprites", "spritesTitle": "Aprovar acesso ao cmux Sprites", "spritesDescription": "Digite o código exibido pelo cmux-sprites. As credenciais da Fly permanecem no cmux.com e nunca são enviadas para este dispositivo."}}, "cloudBillingReturn": { "eyebrow": "cmux Cloud", "returnTitle": "Continue no terminal", diff --git a/web/messages/ru.json b/web/messages/ru.json index f18d81d3ce15..a53bad0d3433 100644 --- a/web/messages/ru.json +++ b/web/messages/ru.json @@ -1,4 +1,5 @@ { + "vault": {"cliAuth": {"spritesEyebrow": "cmux Sprites", "spritesTitle": "Разрешить доступ к cmux Sprites", "spritesDescription": "Введите код, показанный в cmux-sprites. Учетные данные Fly остаются на cmux.com и никогда не отправляются на это устройство."}}, "cloudBillingReturn": { "eyebrow": "cmux Cloud", "returnTitle": "Продолжите в терминале", diff --git a/web/messages/th.json b/web/messages/th.json index 16b47c845398..aa0ac3843c31 100644 --- a/web/messages/th.json +++ b/web/messages/th.json @@ -1,4 +1,5 @@ { + "vault": {"cliAuth": {"spritesEyebrow": "cmux Sprites", "spritesTitle": "อนุมัติการเข้าถึง cmux Sprites", "spritesDescription": "ป้อนรหัสที่แสดงโดย cmux-sprites ข้อมูลรับรอง Fly จะอยู่บน cmux.com และจะไม่ถูกส่งไปยังอุปกรณ์นี้"}}, "cloudBillingReturn": { "eyebrow": "cmux Cloud", "returnTitle": "ดำเนินการต่อในเทอร์มินัล", diff --git a/web/messages/tr.json b/web/messages/tr.json index 1e20cb73213c..313c0f321e66 100644 --- a/web/messages/tr.json +++ b/web/messages/tr.json @@ -1,4 +1,5 @@ { + "vault": {"cliAuth": {"spritesEyebrow": "cmux Sprites", "spritesTitle": "cmux Sprites erişimini onayla", "spritesDescription": "cmux-sprites tarafından gösterilen kodu girin. Fly kimlik bilgileri cmux.com üzerinde kalır ve bu cihaza asla gönderilmez."}}, "cloudBillingReturn": { "eyebrow": "cmux Cloud", "returnTitle": "Terminalde devam edin", diff --git a/web/messages/uk.json b/web/messages/uk.json index 4945ce409cdf..2387acdea0a2 100644 --- a/web/messages/uk.json +++ b/web/messages/uk.json @@ -1,4 +1,5 @@ { + "vault": {"cliAuth": {"spritesEyebrow": "cmux Sprites", "spritesTitle": "Схвалити доступ до cmux Sprites", "spritesDescription": "Введіть код, показаний у cmux-sprites. Облікові дані Fly залишаються на cmux.com і ніколи не надсилаються на цей пристрій."}}, "cloudBillingReturn": { "eyebrow": "cmux Cloud", "returnTitle": "Продовжте в терміналі", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 5e02b2b18dce..dda58a1a5d91 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -1,4 +1,5 @@ { + "vault": {"cliAuth": {"spritesEyebrow": "cmux Sprites", "spritesTitle": "批准访问 cmux Sprites", "spritesDescription": "输入 cmux-sprites 显示的代码。Fly 凭据仅保留在 cmux.com,绝不会发送到此设备。"}}, "cloudBillingReturn": { "eyebrow": "cmux Cloud", "returnTitle": "在终端中继续", diff --git a/web/messages/zh-TW.json b/web/messages/zh-TW.json index e2f9c7cb26e2..21974ff79fb3 100644 --- a/web/messages/zh-TW.json +++ b/web/messages/zh-TW.json @@ -1,4 +1,5 @@ { + "vault": {"cliAuth": {"spritesEyebrow": "cmux Sprites", "spritesTitle": "核准存取 cmux Sprites", "spritesDescription": "輸入 cmux-sprites 顯示的代碼。Fly 憑證只會保留在 cmux.com,絕不會傳送到此裝置。"}}, "cloudBillingReturn": { "eyebrow": "cmux Cloud", "returnTitle": "在終端機中繼續", diff --git a/web/services/vault/cliAuth.ts b/web/services/vault/cliAuth.ts index fff7219193e0..4e4c6206fd8f 100644 --- a/web/services/vault/cliAuth.ts +++ b/web/services/vault/cliAuth.ts @@ -183,7 +183,7 @@ export function drizzleCliAuthRepository(): CliAuthRepository { }; } -function cliAuthClient(value: unknown): CliAuthClient | null { +export function cliAuthClient(value: unknown): CliAuthClient | null { return value === "cmux-vault" || value === "subrouter" || value === "cmux-sprites" ? value : null; diff --git a/web/services/vms/drivers/index.ts b/web/services/vms/drivers/index.ts index bd40932dfa7e..aa5f1a5c69b4 100644 --- a/web/services/vms/drivers/index.ts +++ b/web/services/vms/drivers/index.ts @@ -2,7 +2,7 @@ import { DaytonaProvider } from "./daytona"; import { E2BProvider } from "./e2b"; import { FreestyleProvider } from "./freestyle"; import { SpritesProvider } from "./sprites"; -import type { ProviderId, VMProvider } from "./types"; +import { isProviderId, type ProviderId, type VMProvider } from "./types"; export * from "./types"; export { DaytonaProvider, E2BProvider, FreestyleProvider, SpritesProvider }; @@ -26,13 +26,8 @@ export function getProvider(id: ProviderId): VMProvider { } export function defaultProviderId(): ProviderId { - const configured = process.env.CMUX_VM_DEFAULT_PROVIDER as ProviderId | undefined; - if ( - configured === "e2b" || - configured === "freestyle" || - configured === "daytona" || - configured === "sprites" - ) return configured; + const configured = process.env.CMUX_VM_DEFAULT_PROVIDER; + if (isProviderId(configured)) return configured; // Freestyle is the default for interactive work. The route layer still resolves // the provider image from the manifest/env before any paid create. return "freestyle"; diff --git a/web/services/vms/drivers/sprites.ts b/web/services/vms/drivers/sprites.ts index 6899ecc10aff..6843e0fc1090 100644 --- a/web/services/vms/drivers/sprites.ts +++ b/web/services/vms/drivers/sprites.ts @@ -1,5 +1,11 @@ import { randomBytes } from "node:crypto"; -import { ExecError, SpritesClient, type Sprite } from "@fly/sprites"; +import { + ExecError, + SpritesClient, + type Checkpoint, + type ServiceLogEvent, + type Sprite, +} from "@fly/sprites"; import { NotImplementedError, ProviderError, @@ -37,7 +43,7 @@ function shellQuote(value: string): string { return `'${value.replaceAll("'", `'\\''`)}'`; } -function mapStatus(status: string | undefined): VMStatus { +export function mapStatus(status: string | undefined): VMStatus { switch (status) { case "creating": case "warming": @@ -48,21 +54,40 @@ function mapStatus(status: string | undefined): VMStatus { case "paused": case "cold": case "warm": + case "running": // A paused Sprite remains immediately addressable and wakes on the next // exec or URL request, so it is an active Cloud VM from cmux's perspective. return "running"; default: - return "running"; + throw new ProviderError("sprites", "unsupported lifecycle status"); } } async function drain(stream: AsyncIterable): Promise { for await (const event of stream) { - // Reading the full stream is the operation completion acknowledgement. void event; } } +export async function waitForServiceStarted( + stream: AsyncIterable, +): Promise { + for await (const event of stream) { + if (event.type === "started") return; + if (event.type === "error" || event.type === "exit" || event.type === "stopped") { + throw new ProviderError("sprites", "cmux service failed during startup"); + } + } + throw new ProviderError("sprites", "cmux service ended before readiness"); +} + +export function checkpointMatchingComment( + checkpoints: readonly Checkpoint[], + comment: string, +): Checkpoint | undefined { + return checkpoints.find((checkpoint) => checkpoint.comment === comment); +} + function bootstrapCommand(image: string): string { return [ "set -euo pipefail", @@ -119,9 +144,8 @@ export class SpritesProvider implements VMProvider { dir: "/home/sprite", httpPort: 8080, }, - "5s", ); - await drain(service); + await waitForServiceStarted(service); const refreshed = await sprites.getSprite(name); const url = refreshed.url; if (!url) throw new Error("Sprite create response omitted its URL"); @@ -203,12 +227,11 @@ export class SpritesProvider implements VMProvider { async snapshot(vmId: string, name?: string): Promise { try { const sprite = client().sprite(vmId); - const stream = await sprite.createCheckpoint(name); + const checkpointComment = `cmux-${randomBytes(16).toString("hex")}`; + const stream = await sprite.createCheckpoint(checkpointComment); await drain(stream); const checkpoints = await sprite.listCheckpoints(); - const checkpoint = checkpoints.toSorted((a, b) => - b.createTime.getTime() - a.createTime.getTime() - )[0]; + const checkpoint = checkpointMatchingComment(checkpoints, checkpointComment); if (!checkpoint) throw new Error("Sprite checkpoint completed without a checkpoint id"); return { id: `${vmId}:${checkpoint.id}`, @@ -221,27 +244,11 @@ export class SpritesProvider implements VMProvider { } async restore(snapshotId: string): Promise { - const separator = snapshotId.lastIndexOf(":"); - if (separator <= 0 || separator === snapshotId.length - 1) { - throw new ProviderError("sprites", "restore requires :"); - } - const vmId = snapshotId.slice(0, separator); - const checkpoint = snapshotId.slice(separator + 1); - try { - const sprite = client().sprite(vmId); - await drain(await sprite.restoreCheckpoint(checkpoint)); - const refreshed = await client().getSprite(vmId); - return { - provider: "sprites", - providerVmId: vmId, - status: mapStatus(refreshed.status), - image: `checkpoint:${checkpoint}`, - createdAt: Date.now(), - providerMetadata: refreshed.url ? { url: refreshed.url } : undefined, - }; - } catch (error) { - throw new ProviderError("sprites", `restore(${snapshotId})`, error); - } + void snapshotId; + throw new NotImplementedError( + "sprites", + "cross-Sprite checkpoint restore requires a provider fork API", + ); } async openAttach(): Promise { diff --git a/web/services/vms/drivers/types.ts b/web/services/vms/drivers/types.ts index 730df18da2da..ad706497994e 100644 --- a/web/services/vms/drivers/types.ts +++ b/web/services/vms/drivers/types.ts @@ -2,7 +2,12 @@ // per-provider implementations behind an interface. Callers hold a `VMProvider` and never reach // into specifics. -export type ProviderId = "e2b" | "freestyle" | "daytona" | "sprites"; +export const PROVIDER_IDS = ["e2b", "freestyle", "daytona", "sprites"] as const; +export type ProviderId = (typeof PROVIDER_IDS)[number]; + +export function isProviderId(value: unknown): value is ProviderId { + return typeof value === "string" && PROVIDER_IDS.some((provider) => provider === value); +} export type VMStatus = "creating" | "running" | "paused" | "destroyed"; diff --git a/web/services/vms/routeHelpers.ts b/web/services/vms/routeHelpers.ts index 80ab7bd53649..416a63709137 100644 --- a/web/services/vms/routeHelpers.ts +++ b/web/services/vms/routeHelpers.ts @@ -96,10 +96,14 @@ export async function withAuthedVmApiRoute( * promise settles but turbopack reports "No response is returned from route handler"). * Use `new Response(JSON.stringify(...), { ... })` explicitly instead. */ -export function jsonResponse(data: unknown, status = 200): Response { +export function jsonResponse( + data: unknown, + status = 200, + headers?: HeadersInit, +): Response { return new Response(JSON.stringify(data), { status, - headers: { "content-type": "application/json" }, + headers: { "content-type": "application/json", ...Object.fromEntries(new Headers(headers)) }, }); } @@ -284,19 +288,14 @@ export function vmWorkflowErrorResponse(err: unknown): Response | null { const providerCause = providerCauseSummary(workflowError.cause); const phase = vmPhaseForOperation(workflowError.operation); const retryAfterSeconds = retryAfterForOperation(workflowError.operation); - const providerMessage = providerCause?.message - ? sanitizedProviderMessage(providerCause.message) - : null; const providerCode = providerCause?.code ? sanitizedProviderCode(providerCause.code) - : inferredProviderCode(providerMessage); + : inferredProviderCode(providerCause?.message ?? null); return vmErrorResponse({ error: "vm_cloud_service_unavailable", status: 502, message: vmUnavailableMessage(phase), - reason: providerMessage - ? `Cloud VM service is temporarily unavailable: ${providerMessage}` - : "Cloud VM service is temporarily unavailable.", + reason: "Cloud VM service is temporarily unavailable.", action: cloudServiceAction(workflowError.operation, retryAfterSeconds), phase, retryable: true, @@ -307,7 +306,6 @@ export function vmWorkflowErrorResponse(err: unknown): Response | null { operation: workflowError.operation, retryable: true, ...(providerCode ? { providerCode } : {}), - ...(providerMessage ? { providerMessage } : {}), }, }); } @@ -479,19 +477,6 @@ function vmUnavailableDisplayMessage(phase: VmLifecyclePhase, retryAfterSeconds: return `${vmUnavailableMessage(phase)}${suffix}`; } -function sanitizedProviderMessage(message: string): string { - const normalized = message.trim(); - if (!normalized) return ""; - if (/internal/i.test(normalized) && /error/i.test(normalized)) return "internal service error"; - if (/timeout|timed out|aborted/i.test(normalized)) return "request timed out"; - if (/rate[_\s-]*limit|too many requests/i.test(normalized)) return "rate limited"; - if (/not found|deleted/i.test(normalized)) return "VM not found"; - return normalized - .replace(/freestyle/gi, "Cloud VM") - .replace(/e2b/gi, "Cloud VM") - .slice(0, 240); -} - function sanitizedProviderCode(code: string): string { const normalized = code.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, ""); if (!normalized) return ""; @@ -504,10 +489,10 @@ function sanitizedProviderCode(code: string): string { function inferredProviderCode(message: string | null): string | null { if (!message) return null; - if (message === "internal service error") return "provider_internal"; - if (message === "request timed out") return "provider_timeout"; - if (message === "rate limited") return "provider_rate_limited"; - if (message === "VM not found") return "provider_not_found"; + if (/internal/i.test(message) && /error/i.test(message)) return "provider_internal"; + if (/timeout|timed out|aborted/i.test(message)) return "provider_timeout"; + if (/rate[_\s-]*limit|too many requests/i.test(message)) return "provider_rate_limited"; + if (/not found|deleted/i.test(message)) return "provider_not_found"; return null; } diff --git a/web/tests/vault-cli-auth.test.ts b/web/tests/vault-cli-auth.test.ts index d01338ea94f8..e624cde27c4c 100644 --- a/web/tests/vault-cli-auth.test.ts +++ b/web/tests/vault-cli-auth.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { claimCliAuthTokens, + type CliAuthClient, type CliAuthRepository, type CliAuthTokenMinter, type CliAuthTokens, @@ -9,7 +10,7 @@ import { type FakeRow = { id: string; deviceCodeHash: string; - client: "cmux-vault" | "subrouter" | "cmux-sprites"; + client: CliAuthClient; status: string; userId: string | null; expiresAt: Date; @@ -51,11 +52,11 @@ function countingMinter( tokens: CliAuthTokens | null, ): { minter: CliAuthTokenMinter; - calls: Array<{ userId: string; client: "cmux-vault" | "subrouter" | "cmux-sprites" }>; + calls: Array<{ userId: string; client: CliAuthClient }>; } { const calls: Array<{ userId: string; - client: "cmux-vault" | "subrouter" | "cmux-sprites"; + client: CliAuthClient; }> = []; return { minter: async (userId, client) => { diff --git a/web/tests/vm-route-auth.test.ts b/web/tests/vm-route-auth.test.ts index 953de5342395..05240e310603 100644 --- a/web/tests/vm-route-auth.test.ts +++ b/web/tests/vm-route-auth.test.ts @@ -206,6 +206,7 @@ describe("VM REST auth", () => { expect(await response.json()).toEqual({ error: "unauthorized" }); expect(getUser).toHaveBeenCalled(); expect(runVmWorkflow).not.toHaveBeenCalled(); + }); test("rejects unauthenticated VM listing before reaching Postgres", async () => { @@ -419,6 +420,7 @@ describe("VM REST auth", () => { ); expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); expect(createVm).toHaveBeenCalled(); }); @@ -429,6 +431,7 @@ describe("VM REST auth", () => { const response = await GET(new Request("https://cmux.test/api/vm")); expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); expect(createVm).not.toHaveBeenCalled(); }); @@ -1079,11 +1082,11 @@ describe("VM REST auth", () => { details: { operation: "openAttach", providerCode: "provider_internal", - providerMessage: "internal service error", retryable: true, }, }); expect(JSON.stringify(payload)).not.toContain("INTERNAL_ERROR"); + expect(JSON.stringify(payload)).not.toContain("internal service error"); expect(JSON.stringify(payload)).not.toContain("Freestyle"); } finally { console.error = originalError; @@ -1213,6 +1216,19 @@ describe("VM REST auth", () => { details: { field: "provider" }, }); expect(runVmWorkflow).not.toHaveBeenCalled(); + + const unsupportedSpriteRestore = await restoreRoute.POST( + new Request("https://cmux.test/api/vm/restore", { + method: "POST", + headers: { origin: "https://cmux.test" }, + body: JSON.stringify({ snapshotId: "sprite-1:v1", provider: "sprites" }), + }), + ); + expect(unsupportedSpriteRestore.status).toBe(400); + expect(await unsupportedSpriteRestore.json()).toMatchObject({ + error: "vm_invalid_provider", + }); + expect(runVmWorkflow).not.toHaveBeenCalled(); }); test("allows native bearer mutations without browser CSRF headers", async () => { diff --git a/web/tests/vm-sprites-provider.test.ts b/web/tests/vm-sprites-provider.test.ts index 0c85c673b980..763e1279cd94 100644 --- a/web/tests/vm-sprites-provider.test.ts +++ b/web/tests/vm-sprites-provider.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { SpritesProvider } from "../services/vms/drivers/sprites"; +import { + checkpointMatchingComment, + mapStatus, + SpritesProvider, + waitForServiceStarted, +} from "../services/vms/drivers/sprites"; import { providerEnabledEnvKey } from "../services/vms/config"; import { providerImageEnvKey, resolveVmImage } from "../services/vms/images/resolver"; @@ -23,6 +28,12 @@ describe("SpritesProvider", () => { ); }); + test("fails closed for unknown Sprite lifecycle states", () => { + expect(mapStatus("running")).toBe("running"); + expect(() => mapStatus(undefined)).toThrow("unsupported lifecycle status"); + expect(() => mapStatus("future-state")).toThrow("unsupported lifecycle status"); + }); + test("has explicit create and image configuration", () => { expect(providerEnabledEnvKey("sprites")).toBe("CMUX_VM_SPRITES_ENABLED"); expect(providerImageEnvKey("sprites")).toBe("CMUX_SPRITES_NPM_SPEC"); @@ -32,4 +43,34 @@ describe("SpritesProvider", () => { imageVersion: "sprites-bootstrap-20260805a", }); }); + + test("waits for the service-owned started event and fails closed", async () => { + async function* delayedStart() { + yield { type: "stdout" as const, timestamp: 1, data: "booting" }; + yield { type: "started" as const, timestamp: 2 }; + } + async function* failedStart() { + yield { type: "error" as const, timestamp: 1, data: "private provider detail" }; + } + + await expect(waitForServiceStarted(delayedStart())).resolves.toBeUndefined(); + await expect(waitForServiceStarted(failedStart())).rejects.toThrow( + "cmux service failed during startup", + ); + }); + + test("binds concurrent snapshots to their unique checkpoint comments", async () => { + const checkpoints = [ + { id: "v1", comment: "cmux-request-a", createTime: new Date(1) }, + { id: "v2", comment: "cmux-request-b", createTime: new Date(2) }, + ]; + + const [first, second] = await Promise.all([ + Promise.resolve(checkpointMatchingComment(checkpoints, "cmux-request-a")), + Promise.resolve(checkpointMatchingComment(checkpoints, "cmux-request-b")), + ]); + + expect(first?.id).toBe("v1"); + expect(second?.id).toBe("v2"); + }); }); From 7b4b885bdfeb8e564d3e5280ab2be6266d3bac86 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 4 Aug 2026 22:14:21 -0700 Subject: [PATCH 3/6] Make Sprite retries cancellable and testable --- sprites/README.md | 4 + sprites/cmd/cmux-sprites/main.go | 134 ++++++++++++++++++-------- sprites/cmd/cmux-sprites/main_test.go | 113 +++++++++++++++++++++- web/services/vms/routeHelpers.ts | 2 +- 4 files changed, 210 insertions(+), 43 deletions(-) diff --git a/sprites/README.md b/sprites/README.md index f59c241a374a..f35b342f2a35 100644 --- a/sprites/README.md +++ b/sprites/README.md @@ -18,6 +18,10 @@ Login prints a URL and an eight-character code. Browser opening is opt-in with `login --open`; no localhost callback is started. Credentials are written to `~/.config/cmux-sprites/auth.json` with mode `0600`. +Credential-bearing requests require HTTPS. Local Next.js development may opt +into loopback HTTP with `CMUX_SPRITES_ALLOW_INSECURE_LOCALHOST=1`; non-loopback +HTTP and cross-origin redirects always fail closed. + `connect` asks the owned Sprite daemon for a five-minute, one-device invitation, writes it to an owner-only temporary file, launches `cmux connect`, and approves only that invitation after its device claim appears. diff --git a/sprites/cmd/cmux-sprites/main.go b/sprites/cmd/cmux-sprites/main.go index 7957c1be8094..f58233b3d20a 100644 --- a/sprites/cmd/cmux-sprites/main.go +++ b/sprites/cmd/cmux-sprites/main.go @@ -66,6 +66,32 @@ type apiClient struct { http *http.Client } +type retryClock interface { + Now() time.Time + Sleep(context.Context, time.Duration) error +} + +type systemClock struct{} + +func (systemClock) Now() time.Time { + return time.Now() +} + +func (systemClock) Sleep(ctx context.Context, delay time.Duration) error { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +type enrollmentExecutor interface { + execWithTimeout(context.Context, string, string, int) (execResult, error) +} + func main() { os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) } @@ -226,6 +252,16 @@ func run(args []string, stdout, stderr io.Writer) int { } func login(ctx context.Context, client *apiClient, openBrowser bool, out io.Writer) (tokens, error) { + return loginWithClock(ctx, client, openBrowser, out, systemClock{}) +} + +func loginWithClock( + ctx context.Context, + client *apiClient, + openBrowser bool, + out io.Writer, + clock retryClock, +) (tokens, error) { var started authStart if err := client.request(ctx, http.MethodPost, "/api/vault/cli/auth/start", map[string]string{ "client": "cmux-sprites", @@ -243,37 +279,35 @@ func login(ctx context.Context, client *apiClient, openBrowser bool, out io.Writ } interval := time.Duration(max(started.IntervalSeconds, 1)) * time.Second expires := time.Duration(max(started.ExpiresInSeconds, 60)) * time.Second - deadline := time.NewTimer(expires) - defer deadline.Stop() - ticker := time.NewTicker(interval) - defer ticker.Stop() + deadline := clock.Now().Add(expires) for { - select { - case <-ctx.Done(): - return tokens{}, ctx.Err() - case <-deadline.C: + remaining := deadline.Sub(clock.Now()) + if remaining <= 0 { return tokens{}, errors.New("login expired before approval") - case <-ticker.C: - var polled authPoll - if err := client.request(ctx, http.MethodPost, "/api/vault/cli/auth/poll", map[string]string{ - "deviceCode": started.DeviceCode, - }, &polled); err != nil { - return tokens{}, err + } + delay := min(interval, remaining) + if err := clock.Sleep(ctx, delay); err != nil { + return tokens{}, err + } + var polled authPoll + if err := client.request(ctx, http.MethodPost, "/api/vault/cli/auth/poll", map[string]string{ + "deviceCode": started.DeviceCode, + }, &polled); err != nil { + return tokens{}, err + } + switch polled.Status { + case "pending": + continue + case "approved": + if polled.Client != "cmux-sprites" { + return tokens{}, fmt.Errorf("server returned credentials for %q", polled.Client) } - switch polled.Status { - case "pending": - continue - case "approved": - if polled.Client != "cmux-sprites" { - return tokens{}, fmt.Errorf("server returned credentials for %q", polled.Client) - } - if polled.AccessToken == "" || polled.RefreshToken == "" { - return tokens{}, errors.New("server approved login without credentials") - } - return tokens{AccessToken: polled.AccessToken, RefreshToken: polled.RefreshToken}, nil - default: - return tokens{}, fmt.Errorf("login %s", polled.Status) + if polled.AccessToken == "" || polled.RefreshToken == "" { + return tokens{}, errors.New("server approved login without credentials") } + return tokens{AccessToken: polled.AccessToken, RefreshToken: polled.RefreshToken}, nil + default: + return tokens{}, fmt.Errorf("login %s", polled.Status) } } } @@ -423,11 +457,27 @@ func processExitCode(err error, stderr io.Writer) int { } func approveEnrollment(ctx context.Context, client *apiClient, vmID, invitationID string) error { - deadline := time.NewTimer(45 * time.Second) - defer deadline.Stop() - ticker := time.NewTicker(time.Second) - defer ticker.Stop() + return approveEnrollmentWithClock( + ctx, + client, + vmID, + invitationID, + systemClock{}, + 45*time.Second, + ) +} + +func approveEnrollmentWithClock( + ctx context.Context, + client enrollmentExecutor, + vmID string, + invitationID string, + clock retryClock, + timeout time.Duration, +) error { + deadline := clock.Now().Add(timeout) var latestError error + attempt := 0 for { pending, err := client.execWithTimeout( ctx, @@ -467,10 +517,8 @@ func approveEnrollment(ctx context.Context, client *apiClient, vmID, invitationI } } } - select { - case <-ctx.Done(): - return ctx.Err() - case <-deadline.C: + remaining := deadline.Sub(clock.Now()) + if remaining <= 0 { if latestError != nil { return fmt.Errorf( "timed out waiting for the device enrollment claim; last check: %w", @@ -478,8 +526,12 @@ func approveEnrollment(ctx context.Context, client *apiClient, vmID, invitationI ) } return errors.New("timed out waiting for the device enrollment claim") - case <-ticker.C: } + delay := min(time.Second< Date: Tue, 4 Aug 2026 22:22:20 -0700 Subject: [PATCH 4/6] Enforce Sprite enrollment deadlines --- sprites/README.md | 6 +-- sprites/cmd/cmux-sprites/main.go | 62 +++++++++++++++++++-------- sprites/cmd/cmux-sprites/main_test.go | 37 ++++++++++++++++ 3 files changed, 85 insertions(+), 20 deletions(-) diff --git a/sprites/README.md b/sprites/README.md index f35b342f2a35..5787ddd19cbe 100644 --- a/sprites/README.md +++ b/sprites/README.md @@ -18,9 +18,9 @@ Login prints a URL and an eight-character code. Browser opening is opt-in with `login --open`; no localhost callback is started. Credentials are written to `~/.config/cmux-sprites/auth.json` with mode `0600`. -Credential-bearing requests require HTTPS. Local Next.js development may opt -into loopback HTTP with `CMUX_SPRITES_ALLOW_INSECURE_LOCALHOST=1`; non-loopback -HTTP and cross-origin redirects always fail closed. +Credential-bearing requests require HTTPS, including local development. Use a +locally trusted TLS proxy for `next dev`; HTTP is accepted only before the CLI +has credentials. Cross-origin redirects always fail closed. `connect` asks the owned Sprite daemon for a five-minute, one-device invitation, writes it to an owner-only temporary file, launches `cmux diff --git a/sprites/cmd/cmux-sprites/main.go b/sprites/cmd/cmux-sprites/main.go index f58233b3d20a..310e292bf452 100644 --- a/sprites/cmd/cmux-sprites/main.go +++ b/sprites/cmd/cmux-sprites/main.go @@ -479,18 +479,27 @@ func approveEnrollmentWithClock( var latestError error attempt := 0 for { + remaining := deadline.Sub(clock.Now()) + if remaining <= 0 { + return enrollmentTimeoutError(latestError) + } + commandContext, cancelCommand := context.WithTimeout(ctx, remaining) pending, err := client.execWithTimeout( - ctx, + commandContext, vmID, "/usr/local/bin/cmux enroll pending --session sprite --state-dir "+ shellQuote("/home/sprite/.local/share/cmux-sprite/remote")+" --json", - 8_000, + enrollmentCommandTimeoutMs(remaining), ) + cancelCommand() if err != nil { latestError = err } else if pending.ExitCode != 0 { latestError = fmt.Errorf("pending-enrollment command exited %d", pending.ExitCode) } + if deadline.Sub(clock.Now()) <= 0 { + return enrollmentTimeoutError(latestError) + } if err == nil && pending.ExitCode == 0 { var requests []struct { InvitationID string `json:"invitation_id"` @@ -498,14 +507,23 @@ func approveEnrollmentWithClock( if json.Unmarshal([]byte(pending.Stdout), &requests) == nil { for _, request := range requests { if request.InvitationID == invitationID { - approved, err := client.execWithTimeout( + approvalRemaining := deadline.Sub(clock.Now()) + if approvalRemaining <= 0 { + return enrollmentTimeoutError(latestError) + } + approvalContext, cancelApproval := context.WithTimeout( ctx, + approvalRemaining, + ) + approved, err := client.execWithTimeout( + approvalContext, vmID, "/usr/local/bin/cmux enroll approve "+shellQuote(invitationID)+ " --session sprite --state-dir "+ shellQuote("/home/sprite/.local/share/cmux-sprite/remote"), - 8_000, + enrollmentCommandTimeoutMs(approvalRemaining), ) + cancelApproval() if err != nil { return err } @@ -517,15 +535,9 @@ func approveEnrollmentWithClock( } } } - remaining := deadline.Sub(clock.Now()) + remaining = deadline.Sub(clock.Now()) if remaining <= 0 { - if latestError != nil { - return fmt.Errorf( - "timed out waiting for the device enrollment claim; last check: %w", - latestError, - ) - } - return errors.New("timed out waiting for the device enrollment claim") + return enrollmentTimeoutError(latestError) } delay := min(time.Second< Date: Tue, 4 Aug 2026 22:27:59 -0700 Subject: [PATCH 5/6] Support secure local Sprite API dogfood --- sprites/README.md | 5 +-- sprites/cmd/cmux-sprites/main.go | 54 ++++++++++++++++++++++++-------- 2 files changed, 44 insertions(+), 15 deletions(-) diff --git a/sprites/README.md b/sprites/README.md index 5787ddd19cbe..362fc5a84e52 100644 --- a/sprites/README.md +++ b/sprites/README.md @@ -19,8 +19,9 @@ Login prints a URL and an eight-character code. Browser opening is opt-in with `~/.config/cmux-sprites/auth.json` with mode `0600`. Credential-bearing requests require HTTPS, including local development. Use a -locally trusted TLS proxy for `next dev`; HTTP is accepted only before the CLI -has credentials. Cross-origin redirects always fail closed. +locally trusted TLS proxy for `next dev` and set `CMUX_SPRITES_CA_FILE` when it +uses a private certificate authority; HTTP is accepted only before the CLI has +credentials. Cross-origin redirects always fail closed. `connect` asks the owned Sprite daemon for a five-minute, one-device invitation, writes it to an owner-only temporary file, launches `cmux diff --git a/sprites/cmd/cmux-sprites/main.go b/sprites/cmd/cmux-sprites/main.go index 310e292bf452..ef71caa6c7f8 100644 --- a/sprites/cmd/cmux-sprites/main.go +++ b/sprites/cmd/cmux-sprites/main.go @@ -3,6 +3,8 @@ package main import ( "bytes" "context" + "crypto/tls" + "crypto/x509" "encoding/base64" "encoding/json" "errors" @@ -628,24 +630,50 @@ func (c *apiClient) request(ctx context.Context, method, path string, body any, func newClient(baseURL string, value *tokens) *apiClient { normalized, baseErr := normalizedAPIBase(baseURL, value != nil) + httpClient := &http.Client{ + Timeout: 90 * time.Second, + CheckRedirect: func(request *http.Request, via []*http.Request) error { + if len(via) == 0 { + return nil + } + original := via[0].URL + if request.URL.Scheme != original.Scheme || request.URL.Host != original.Host { + return errors.New("refusing cross-origin API redirect") + } + return nil + }, + } + if baseErr == nil { + httpClient.Transport, baseErr = customCARootTransport( + strings.TrimSpace(os.Getenv("CMUX_SPRITES_CA_FILE")), + ) + } return &apiClient{ baseURL: normalized, baseErr: baseErr, tokens: value, - http: &http.Client{ - Timeout: 90 * time.Second, - CheckRedirect: func(request *http.Request, via []*http.Request) error { - if len(via) == 0 { - return nil - } - original := via[0].URL - if request.URL.Scheme != original.Scheme || request.URL.Host != original.Host { - return errors.New("refusing cross-origin API redirect") - } - return nil - }, - }, + http: httpClient, + } +} + +func customCARootTransport(path string) (http.RoundTripper, error) { + if path == "" { + return nil, nil } + data, err := os.ReadFile(path) + if err != nil { + return nil, errors.New("could not read CMUX_SPRITES_CA_FILE") + } + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(data) { + return nil, errors.New("CMUX_SPRITES_CA_FILE contains no certificates") + } + return &http.Transport{ + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + RootCAs: roots, + }, + }, nil } func normalizedAPIBase(raw string, credentialed bool) (string, error) { From 591a512ab8bb35390eeea43f97d5c6d864bb1f45 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:02:20 -0700 Subject: [PATCH 6/6] Preserve default transport for custom Sprite CAs --- sprites/cmd/cmux-sprites/main.go | 20 ++++++++++------- sprites/cmd/cmux-sprites/main_test.go | 32 +++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/sprites/cmd/cmux-sprites/main.go b/sprites/cmd/cmux-sprites/main.go index ef71caa6c7f8..3d2e9ba10c73 100644 --- a/sprites/cmd/cmux-sprites/main.go +++ b/sprites/cmd/cmux-sprites/main.go @@ -662,18 +662,22 @@ func customCARootTransport(path string) (http.RoundTripper, error) { } data, err := os.ReadFile(path) if err != nil { - return nil, errors.New("could not read CMUX_SPRITES_CA_FILE") + return nil, errors.New( + "could not read the custom certificate authority file; verify its path and permissions", + ) } roots := x509.NewCertPool() if !roots.AppendCertsFromPEM(data) { - return nil, errors.New("CMUX_SPRITES_CA_FILE contains no certificates") + return nil, errors.New( + "the custom certificate authority file contains no valid PEM certificates", + ) } - return &http.Transport{ - TLSClientConfig: &tls.Config{ - MinVersion: tls.VersionTLS12, - RootCAs: roots, - }, - }, nil + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.TLSClientConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + RootCAs: roots, + } + return transport, nil } func normalizedAPIBase(raw string, credentialed bool) (string, error) { diff --git a/sprites/cmd/cmux-sprites/main_test.go b/sprites/cmd/cmux-sprites/main_test.go index 6688324f82ad..e15d9dcb6c06 100644 --- a/sprites/cmd/cmux-sprites/main_test.go +++ b/sprites/cmd/cmux-sprites/main_test.go @@ -7,6 +7,8 @@ import ( "io" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" "time" @@ -224,6 +226,36 @@ func TestCredentialedClientRequiresHTTPSEvenOnLoopback(t *testing.T) { } } +func TestCustomCertificateErrorsAreActionableAndProductSafe(t *testing.T) { + t.Run("unreadable file", func(t *testing.T) { + t.Setenv("CMUX_SPRITES_CA_FILE", filepath.Join(t.TempDir(), "missing.pem")) + client := newClient("https://cmux.example", nil) + err := client.request(context.Background(), http.MethodGet, "/api/vm", nil, nil) + if err == nil || !strings.Contains(err.Error(), "verify its path and permissions") { + t.Fatalf("certificate read error = %v", err) + } + if strings.Contains(err.Error(), "CMUX_SPRITES_CA_FILE") { + t.Fatalf("certificate read error exposed environment internals: %v", err) + } + }) + + t.Run("invalid file", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "invalid.pem") + if err := os.WriteFile(path, []byte("not a certificate"), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("CMUX_SPRITES_CA_FILE", path) + client := newClient("https://cmux.example", nil) + err := client.request(context.Background(), http.MethodGet, "/api/vm", nil, nil) + if err == nil || !strings.Contains(err.Error(), "no valid PEM certificates") { + t.Fatalf("invalid certificate error = %v", err) + } + if strings.Contains(err.Error(), "CMUX_SPRITES_CA_FILE") { + t.Fatalf("invalid certificate error exposed environment internals: %v", err) + } + }) +} + func TestAPIClientDoesNotExposeRawErrorBodies(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { response.WriteHeader(http.StatusBadGateway)