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..362fc5a84e52 --- /dev/null +++ b/sprites/README.md @@ -0,0 +1,44 @@ +# 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`. + +Credential-bearing requests require HTTPS, including local development. Use a +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 +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..3d2e9ba10c73 --- /dev/null +++ b/sprites/cmd/cmux-sprites/main.go @@ -0,0 +1,915 @@ +package main + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "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 + baseErr error + tokens *tokens + 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)) +} + +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) { + 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", + }, &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 := clock.Now().Add(expires) + for { + remaining := deadline.Sub(clock.Now()) + if remaining <= 0 { + return tokens{}, errors.New("login expired before approval") + } + 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) + } + 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 { + 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 { + 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 { + 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 { + remaining := deadline.Sub(clock.Now()) + if remaining <= 0 { + return enrollmentTimeoutError(latestError) + } + commandContext, cancelCommand := context.WithTimeout(ctx, remaining) + pending, err := client.execWithTimeout( + commandContext, + vmID, + "/usr/local/bin/cmux enroll pending --session sprite --state-dir "+ + shellQuote("/home/sprite/.local/share/cmux-sprite/remote")+" --json", + 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"` + } + if json.Unmarshal([]byte(pending.Stdout), &requests) == nil { + for _, request := range requests { + if request.InvitationID == invitationID { + 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"), + enrollmentCommandTimeoutMs(approvalRemaining), + ) + cancelApproval() + if err != nil { + return err + } + if approved.ExitCode != 0 { + return fmt.Errorf("enrollment approval command exited %d", approved.ExitCode) + } + return nil + } + } + } + } + remaining = deadline.Sub(clock.Now()) + if remaining <= 0 { + return enrollmentTimeoutError(latestError) + } + delay := min(time.Second<= 300 { + return apiResponseError(response.StatusCode, data) + } + if out != nil && len(data) > 0 { + return json.Unmarshal(data, out) + } + return nil +} + +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: 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 the custom certificate authority file; verify its path and permissions", + ) + } + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(data) { + return nil, errors.New( + "the custom certificate authority file contains no valid PEM certificates", + ) + } + 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) { + 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") + } + if credentialed { + return "", errors.New("credentialed cmux API requests require 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) { + 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..e15d9dcb6c06 --- /dev/null +++ b/sprites/cmd/cmux-sprites/main_test.go @@ -0,0 +1,375 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +type fakeClock struct { + now time.Time + sleeps []time.Duration +} + +func (clock *fakeClock) Now() time.Time { + return clock.now +} + +func (clock *fakeClock) Sleep(ctx context.Context, delay time.Duration) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + clock.sleeps = append(clock.sleeps, delay) + clock.now = clock.now.Add(delay) + return nil + } +} + +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 + clock := &fakeClock{now: time.Unix(0, 0)} + got, err := loginWithClock( + context.Background(), + newClient(server.URL, nil), + false, + &output, + clock, + ) + 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) + } + if len(clock.sleeps) != 1 || clock.sleeps[0] != time.Second { + t.Fatalf("login sleeps = %v", clock.sleeps) + } +} + +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 := loginWithClock( + context.Background(), + newClient(server.URL, nil), + false, + io.Discard, + &fakeClock{now: time.Unix(0, 0)}, + ) + 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.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + targetCalled = true + response.WriteHeader(http.StatusNoContent) + })) + defer target.Close() + source := httptest.NewTLSServer(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", + }) + redirecting.http.Transport = source.Client().Transport + 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 TestCredentialedClientRequiresHTTPSEvenOnLoopback(t *testing.T) { + t.Setenv("CMUX_SPRITES_ALLOW_INSECURE_LOCALHOST", "1") + client := newClient("http://127.0.0.1:4733", &tokens{ + AccessToken: "access", + RefreshToken: "refresh", + }) + err := client.request(context.Background(), http.MethodGet, "/api/vm", nil, nil) + if err == nil || !strings.Contains(err.Error(), "credentialed cmux API requests require HTTPS") { + t.Fatalf("credentialed loopback HTTP error = %v", err) + } +} + +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) + _, _ = 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) + } +} + +type fakeEnrollmentExecutor struct { + results []execResult + errors []error + timeouts []int + onCall func(int) +} + +func (executor *fakeEnrollmentExecutor) execWithTimeout( + _ context.Context, + _ string, + _ string, + timeout int, +) (execResult, error) { + executor.timeouts = append(executor.timeouts, timeout) + index := len(executor.timeouts) - 1 + if executor.onCall != nil { + executor.onCall(index) + } + var result execResult + if index < len(executor.results) { + result = executor.results[index] + } + var err error + if index < len(executor.errors) { + err = executor.errors[index] + } + return result, err +} + +func TestApproveEnrollmentDoesNotApproveAfterDeadline(t *testing.T) { + clock := &fakeClock{now: time.Unix(0, 0)} + executor := &fakeEnrollmentExecutor{ + results: []execResult{ + {ExitCode: 0, Stdout: `[{"invitation_id":"invite-1"}]`}, + }, + onCall: func(index int) { + if index == 0 { + clock.now = clock.now.Add(3 * time.Second) + } + }, + } + + err := approveEnrollmentWithClock( + context.Background(), + executor, + "sprite-1", + "invite-1", + clock, + 3*time.Second, + ) + if err == nil || !strings.Contains(err.Error(), "timed out") { + t.Fatalf("deadline error = %v", err) + } + if len(executor.timeouts) != 1 { + t.Fatalf("approval ran after deadline; calls = %d", len(executor.timeouts)) + } + if executor.timeouts[0] != 3_000 { + t.Fatalf("pending timeout = %d", executor.timeouts[0]) + } +} + +func TestApproveEnrollmentUsesInjectedCancellationAwareRetry(t *testing.T) { + executor := &fakeEnrollmentExecutor{ + results: []execResult{ + {ExitCode: 0, Stdout: "[]"}, + {ExitCode: 0, Stdout: `[{"invitation_id":"invite-1"}]`}, + {ExitCode: 0}, + }, + } + clock := &fakeClock{now: time.Unix(0, 0)} + + err := approveEnrollmentWithClock( + context.Background(), + executor, + "sprite-1", + "invite-1", + clock, + 10*time.Second, + ) + if err != nil { + t.Fatal(err) + } + if len(clock.sleeps) != 1 || clock.sleeps[0] != time.Second { + t.Fatalf("retry sleeps = %v", clock.sleeps) + } + for _, timeout := range executor.timeouts { + if timeout != 8_000 { + t.Fatalf("exec timeout = %d", timeout) + } + } +} 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..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") 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 57cdc9b3cde3..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,11 +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 - : 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 682c5aebffd0..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,7 +248,10 @@ async function parseBaseRequest( } } const provider = typeof candidate.provider === "string" ? candidate.provider.trim() : undefined; - if (provider && provider !== "e2b" && provider !== "freestyle" && provider !== "daytona") { + // 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 2cbc582ec605..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,7 +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") 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 118716ea6275..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"; @@ -92,8 +93,9 @@ export async function GET(request: Request): Promise { image: entry.image, imageVersion: entry.imageVersion, createdAt: entry.createdAt, + ...(entry.connectionUrl ? { connectionUrl: entry.connectionUrl } : {}), })); - return jsonResponse({ vms }); + return jsonResponse({ vms }, 200, { "cache-control": "no-store" }); }, ); } @@ -166,7 +168,7 @@ export async function POST(request: Request): Promise { details: { field: "provider" }, }); } - if (candidate.provider !== "e2b" && candidate.provider !== "freestyle" && candidate.provider !== "daytona") { + if (!isProviderId(candidate.provider)) { return vmErrorResponse({ error: "vm_invalid_provider", status: 400, @@ -358,10 +360,12 @@ 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 } : {}), + }, 200, { "cache-control": "no-store" }); } }, ); 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..7e4407453e1d --- /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')) NOT VALID; 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/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/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/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/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/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/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..4e4c6206fd8f 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; @@ -183,6 +183,8 @@ export function drizzleCliAuthRepository(): CliAuthRepository { }; } -function cliAuthClient(value: unknown): CliAuthClient | null { - return value === "cmux-vault" || value === "subrouter" ? value : 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/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..aa5f1a5c69b4 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 type { ProviderId, VMProvider } from "./types"; +import { SpritesProvider } from "./sprites"; +import { isProviderId, type ProviderId, type 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; } @@ -24,8 +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") 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 new file mode 100644 index 000000000000..6843e0fc1090 --- /dev/null +++ b/web/services/vms/drivers/sprites.ts @@ -0,0 +1,268 @@ +import { randomBytes } from "node:crypto"; +import { + ExecError, + SpritesClient, + type Checkpoint, + type ServiceLogEvent, + 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("'", `'\\''`)}'`; +} + +export 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": + 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: + throw new ProviderError("sprites", "unsupported lifecycle status"); + } +} + +async function drain(stream: AsyncIterable): Promise { + for await (const event of stream) { + 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", + `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, + }, + ); + await waitForServiceStarted(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 checkpointComment = `cmux-${randomBytes(16).toString("hex")}`; + const stream = await sprite.createCheckpoint(checkpointComment); + await drain(stream); + const checkpoints = await sprite.listCheckpoints(); + const checkpoint = checkpointMatchingComment(checkpoints, checkpointComment); + 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 { + void snapshotId; + throw new NotImplementedError( + "sprites", + "cross-Sprite checkpoint restore requires a provider fork API", + ); + } + + 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..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"; +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/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/routeHelpers.ts b/web/services/vms/routeHelpers.ts index 80ab7bd53649..89561e777055 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: "provider_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/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..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"; + 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" }>; + calls: Array<{ userId: string; client: CliAuthClient }>; } { const calls: Array<{ userId: string; - client: "cmux-vault" | "subrouter"; + client: CliAuthClient; }> = []; return { minter: async (userId, client) => { @@ -95,6 +96,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-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 new file mode 100644 index 000000000000..763e1279cd94 --- /dev/null +++ b/web/tests/vm-sprites-provider.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test"; +import { + checkpointMatchingComment, + mapStatus, + SpritesProvider, + waitForServiceStarted, +} 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("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"); + expect(resolveVmImage("sprites", undefined, {})).toMatchObject({ + provider: "sprites", + image: "cmux@0.9.11", + 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"); + }); +});