diff --git a/README.md b/README.md index 8143010..59a47bd 100644 --- a/README.md +++ b/README.md @@ -321,6 +321,8 @@ asks for a few more characters rather than guessing. | `createos sandbox push` | Copy a local file into a sandbox | | `createos sandbox pull` | Copy a file out of a sandbox | | `createos sandbox tunnel` | Forward a local port to a port inside a sandbox | +| `createos sandbox desktop` | Open a graphical sandbox in your browser | +| `createos sandbox computer` | Control the desktop inside a sandbox | | `createos sandbox shapes` | List available sandbox sizes (vCPU / RAM / disk) | | `createos sandbox rootfs` | List built-in OS images you can boot a sandbox from | | `createos sandbox setup` | Connect a coding harness so its workspaces run on a sandbox | diff --git a/cmd/sandbox/computer.go b/cmd/sandbox/computer.go new file mode 100644 index 0000000..e8271b9 --- /dev/null +++ b/cmd/sandbox/computer.go @@ -0,0 +1,468 @@ +package sandbox + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" + "github.com/NodeOps-app/createos-cli/internal/output" +) + +// `createos sandbox computer` drives the desktop inside a sandbox: look at it, +// point at it, type into it. Pair it with `createos sandbox desktop`, which +// brings the desktop up and hands a human the link to watch. +// +// Coordinates are raw X11 pixels of the target screen. Nothing scales them for +// DPI anywhere along this path, so read the bounds from `computer screen` +// rather than assuming a resolution. + +// defaultScreenshotPath is where a capture lands when no -o is given. +const defaultScreenshotPath = "screenshot.png" + +func newComputerCommand() *cli.Command { + return &cli.Command{ + Name: "computer", + Usage: "Control the desktop inside a sandbox", + Description: `Look at and control a sandbox's desktop — take a screenshot, move and +click the pointer, type, press keys, open a page. + +Start the desktop first: + + createos sandbox desktop + +Take a screenshot before and after anything you click. Nothing here +confirms that a click landed on what you meant, so a screenshot is the +only way to see what actually happened.`, + Subcommands: []*cli.Command{ + newComputerScreenshotCommand(), + newComputerInfoCommand("screen", "Show the screen's size in pixels"), + newComputerInfoCommand("cursor", "Show where the pointer is"), + newComputerInfoCommand("windows", "List the windows on screen"), + newComputerMoveCommand(), + newComputerClickCommand(), + newComputerTypeCommand(), + newComputerKeyCommand(), + newComputerOpenCommand(), + newComputerRawCommand(), + }, + } +} + +// screenFlag is repeated per subcommand rather than set on the group: urfave +// does not pass a parent group's flags down to its subcommands. +// +// The flag is declared so it shows up in help and parses in the position +// urfave expects; parseComputerArgs is what actually reads it, because urfave +// stops parsing flags at the first positional argument. +func screenFlag() cli.Flag { + return &cli.StringFlag{ + Name: "screen", + Aliases: []string{"S"}, + Usage: "Which screen to act on", + Value: api.DefaultComputerScreen, + } +} + +// outFlag names the file a screenshot is written to. It deliberately has no +// short alias: `-o` is already the global output-format flag. +func outFlag() cli.Flag { + return &cli.StringFlag{ + Name: "out", + Usage: "Where to save the picture", + Value: defaultScreenshotPath, + } +} + +// computerArgs is one subcommand's arguments after the flags have been pulled +// out of them, wherever the caller happened to put them. +type computerArgs struct { + ref string + screen string + out string + wait time.Duration + rest []string +} + +// parseComputerArgs re-scans the raw arguments for this package's flags. +// +// urfave/cli v2 stops parsing flags at the first positional argument, so +// `computer screenshot my-box --out shot.png` silently drops --out and writes +// to the default path. Nobody types the flags first, so the arguments are +// scanned by hand — the same workaround `sandbox edit` already makes for +// --ingress. +func parseComputerArgs(c *cli.Context) computerArgs { + parsed := computerArgs{screen: c.String("screen"), out: c.String("out"), wait: c.Duration("wait")} + args := c.Args().Slice() + + for i := 0; i < len(args); i++ { + a := args[i] + take := func() string { + if i+1 < len(args) { + i++ + return args[i] + } + return "" + } + switch { + case a == "--screen" || a == "-S": + if v := take(); v != "" { + parsed.screen = v + } + case strings.HasPrefix(a, "--screen="): + parsed.screen = strings.TrimPrefix(a, "--screen=") + case strings.HasPrefix(a, "-S="): + parsed.screen = strings.TrimPrefix(a, "-S=") + case a == "--out": + if v := take(); v != "" { + parsed.out = v + } + case strings.HasPrefix(a, "--out="): + parsed.out = strings.TrimPrefix(a, "--out=") + case a == "--wait": + if v := take(); v != "" { + if d, err := time.ParseDuration(v); err == nil { + parsed.wait = d + } + } + case strings.HasPrefix(a, "--wait="): + if d, err := time.ParseDuration(strings.TrimPrefix(a, "--wait=")); err == nil { + parsed.wait = d + } + default: + parsed.rest = append(parsed.rest, a) + } + } + + if len(parsed.rest) > 0 { + parsed.ref = strings.TrimSpace(parsed.rest[0]) + parsed.rest = parsed.rest[1:] + } + if strings.TrimSpace(parsed.screen) == "" { + parsed.screen = api.DefaultComputerScreen + } + if strings.TrimSpace(parsed.out) == "" { + parsed.out = defaultScreenshotPath + } + return parsed +} + +// computerTarget resolves the shared preamble of every op: the client, the +// sandbox the positional ref names, and the arguments left over for the op. +func computerTarget(c *cli.Context) (*api.SandboxClient, string, computerArgs, error) { + args := parseComputerArgs(c) + client, ok := c.App.Metadata[api.SandboxClientKey].(*api.SandboxClient) + if !ok { + return nil, "", args, fmt.Errorf("you're not signed in — run 'createos login' to get started") + } + if args.ref == "" { + return nil, "", args, fmt.Errorf("please provide a sandbox ID or name\n\n To see your sandboxes, run:\n createos sandbox list") + } + id, err := resolveSandboxRef(c.Context, client, args.ref) + if err != nil { + return nil, "", args, err + } + return client, id, args, nil +} + +func newComputerScreenshotCommand() *cli.Command { + return &cli.Command{ + Name: "screenshot", + Usage: "Save a picture of the screen", + ArgsUsage: "", + Flags: []cli.Flag{screenFlag(), outFlag()}, + Action: func(c *cli.Context) error { + client, id, args, err := computerTarget(c) + if err != nil { + return err + } + png, err := client.ComputerScreenshot(c.Context, id, args.screen) + if err != nil { + return err + } + path := args.out + if err := os.WriteFile(path, png, 0o600); err != nil { + return fmt.Errorf("couldn't save the picture to %s: %w", path, err) + } + output.Render(c, map[string]any{"path": path, "bytes": len(png)}, func() { + pterm.Success.Printfln("Saved a picture of the screen to %s (%d bytes)", path, len(png)) + }) + return nil + }, + } +} + +// newComputerInfoCommand builds the read-only ops, which differ only in which +// route they read and what they are called. +func newComputerInfoCommand(name, usage string) *cli.Command { + return &cli.Command{ + Name: name, + Usage: usage, + ArgsUsage: "", + Flags: []cli.Flag{screenFlag()}, + Action: func(c *cli.Context) error { + client, id, args, err := computerTarget(c) + if err != nil { + return err + } + switch name { + case "screen": + geom, err := client.ComputerScreen(c.Context, id, args.screen) + if err != nil { + return err + } + output.Render(c, geom, func() { + pterm.Printfln("%s %d × %d pixels", pterm.NewStyle(pterm.FgCyan).Sprint("Screen:"), geom.Width, geom.Height) + }) + case "cursor": + pos, err := client.ComputerCursor(c.Context, id, args.screen) + if err != nil { + return err + } + output.Render(c, pos, func() { + pterm.Printfln("%s %d, %d", pterm.NewStyle(pterm.FgCyan).Sprint("Pointer:"), pos.X, pos.Y) + }) + case "windows": + raw, err := client.ComputerWindows(c.Context, id, args.screen) + if err != nil { + return err + } + printRaw(c, raw) + } + return nil + }, + } +} + +func newComputerMoveCommand() *cli.Command { + return &cli.Command{ + Name: "move", + Usage: "Move the pointer somewhere", + ArgsUsage: " ", + Flags: []cli.Flag{screenFlag()}, + Action: func(c *cli.Context) error { + client, id, args, err := computerTarget(c) + if err != nil { + return err + } + if len(args.rest) < 2 { + return fmt.Errorf("a move needs two whole numbers — how far across and how far down\n\n For example:\n createos sandbox computer move %s 640 400", args.ref) + } + x, y, err := coords(args.rest[0], args.rest[1], "move") + if err != nil { + return err + } + if err := client.ComputerMouseMove(c.Context, id, args.screen, x, y); err != nil { + return err + } + pterm.Success.Printfln("Moved the pointer to %d, %d", x, y) + return nil + }, + } +} + +func newComputerClickCommand() *cli.Command { + return &cli.Command{ + Name: "click", + Usage: "Click, optionally somewhere specific", + ArgsUsage: " [ ]", + Description: `With no coordinates this clicks wherever the pointer already is. + +Take a screenshot first to see what you are about to click, and +another afterwards to confirm it did what you expected.`, + Flags: []cli.Flag{screenFlag()}, + Action: func(c *cli.Context) error { + client, id, args, err := computerTarget(c) + if err != nil { + return err + } + var at *api.ComputerCursorPos + switch len(args.rest) { + case 0: + case 1: + return fmt.Errorf("a click needs both an across and a down position\n\n For example:\n createos sandbox computer click %s 640 400", args.ref) + default: + x, y, err := coords(args.rest[0], args.rest[1], "click") + if err != nil { + return err + } + at = &api.ComputerCursorPos{X: x, Y: y} + } + if err := client.ComputerMouseClick(c.Context, id, args.screen, at); err != nil { + return err + } + if at != nil { + pterm.Success.Printfln("Clicked at %d, %d", at.X, at.Y) + } else { + pterm.Success.Println("Clicked where the pointer was") + } + return nil + }, + } +} + +func newComputerTypeCommand() *cli.Command { + return &cli.Command{ + Name: "type", + Usage: "Type text into whatever has focus", + ArgsUsage: " ", + Description: `Quote the text to keep it as one piece: + + createos sandbox computer type my-box "hello world" + +Unquoted words are joined with single spaces.`, + Flags: []cli.Flag{screenFlag()}, + Action: func(c *cli.Context) error { + client, id, args, err := computerTarget(c) + if err != nil { + return err + } + // Unquoted multi-word text arrives as separate arguments; join it + // back up so `type my-box hello world` types the space too. + text := strings.Join(args.rest, " ") + if text == "" { + return fmt.Errorf("please provide the text to type\n\n For example:\n createos sandbox computer type %s \"hello world\"", args.ref) + } + if err := client.ComputerType(c.Context, id, args.screen, text); err != nil { + return err + } + pterm.Success.Printfln("Typed %d characters", len([]rune(text))) + return nil + }, + } +} + +func newComputerKeyCommand() *cli.Command { + return &cli.Command{ + Name: "key", + Usage: "Press keys together", + ArgsUsage: " ...", + Description: `Every key is pressed at the same time, so this is how you send a +shortcut: + + createos sandbox computer key my-box ctrl l`, + Flags: []cli.Flag{screenFlag()}, + Action: func(c *cli.Context) error { + client, id, args, err := computerTarget(c) + if err != nil { + return err + } + keys := args.rest + if len(keys) == 0 { + return fmt.Errorf("please provide at least one key to press\n\n For example:\n createos sandbox computer key %s ctrl l", args.ref) + } + if err := client.ComputerPress(c.Context, id, args.screen, keys); err != nil { + return err + } + pterm.Success.Printfln("Pressed %s", strings.Join(keys, "+")) + return nil + }, + } +} + +func newComputerOpenCommand() *cli.Command { + return &cli.Command{ + Name: "open", + Usage: "Open a web page or file on the desktop", + ArgsUsage: " ", + Flags: []cli.Flag{screenFlag()}, + Action: func(c *cli.Context) error { + client, id, args, err := computerTarget(c) + if err != nil { + return err + } + target := "" + if len(args.rest) > 0 { + target = strings.TrimSpace(args.rest[0]) + } + if target == "" { + return fmt.Errorf("please provide something to open\n\n For example:\n createos sandbox computer open %s https://example.com", args.ref) + } + if err := client.ComputerOpen(c.Context, id, args.screen, target); err != nil { + return err + } + pterm.Success.Printfln("Opened %s", target) + return nil + }, + } +} + +func newComputerRawCommand() *cli.Command { + return &cli.Command{ + Name: "raw", + Usage: "Call a desktop endpoint this CLI doesn't wrap", + ArgsUsage: " [json-body]", + Description: `An escape hatch for the parts of the desktop API without their own +command. The path is relative to the sandbox's computer routes: + + createos sandbox computer raw my-box GET screen`, + Hidden: true, + Flags: []cli.Flag{screenFlag()}, + Action: func(c *cli.Context) error { + client, id, args, err := computerTarget(c) + if err != nil { + return err + } + method, path := "", "" + if len(args.rest) > 0 { + method = strings.TrimSpace(args.rest[0]) + } + if len(args.rest) > 1 { + path = strings.TrimSpace(args.rest[1]) + } + if method == "" || path == "" { + return fmt.Errorf("please provide a method and a path\n\n For example:\n createos sandbox computer raw %s GET screen", args.ref) + } + var body json.RawMessage + if raw := func() string { + if len(args.rest) > 2 { + return strings.TrimSpace(args.rest[2]) + } + return "" + }(); raw != "" { + if !json.Valid([]byte(raw)) { + return fmt.Errorf("the body isn't valid JSON") + } + body = json.RawMessage(raw) + } + out, err := client.ComputerRaw(c.Context, id, args.screen, method, path, body) + if err != nil { + return err + } + printRaw(c, out) + return nil + }, + } +} + +// coords parses an x/y pair, naming the op in the error so the fix is obvious. +func coords(xs, ys, op string) (int, int, error) { + x, errX := strconv.Atoi(strings.TrimSpace(xs)) + y, errY := strconv.Atoi(strings.TrimSpace(ys)) + if errX != nil || errY != nil { + return 0, 0, fmt.Errorf("a %s needs two whole numbers — how far across and how far down\n\n To see the screen's size, run:\n createos sandbox computer screen ", op) + } + return x, y, nil +} + +// printRaw emits a passthrough payload: as-is under -o json, pretty otherwise. +func printRaw(c *cli.Context, raw json.RawMessage) { + if output.IsJSON(c) { + fmt.Println(string(raw)) + return + } + var pretty any + if err := json.Unmarshal(raw, &pretty); err == nil { + if formatted, err := json.MarshalIndent(pretty, "", " "); err == nil { + fmt.Println(string(formatted)) + return + } + } + fmt.Println(string(raw)) +} diff --git a/cmd/sandbox/computer_args_test.go b/cmd/sandbox/computer_args_test.go new file mode 100644 index 0000000..b8c65b5 --- /dev/null +++ b/cmd/sandbox/computer_args_test.go @@ -0,0 +1,123 @@ +package sandbox + +import ( + "flag" + "testing" + "time" + + "github.com/urfave/cli/v2" +) + +// newComputerTestContext builds a context the way urfave hands one to an +// Action: flags declared, but parsing stopped at the first positional. Every +// argument after the sandbox reference therefore arrives unparsed. +func newComputerTestContext(args ...string) *cli.Context { + set := flag.NewFlagSet("test", flag.ContinueOnError) + set.String("screen", "screen-0", "") + set.String("out", defaultScreenshotPath, "") + set.Duration("wait", 2*time.Minute, "") + _ = set.Parse(args) + return cli.NewContext(cli.NewApp(), set, nil) +} + +// Nobody types flags before the positional argument, and urfave stops parsing +// at the first one. Without the hand re-scan, `screenshot my-box --out shot.png` +// writes to the default path and reports success, which is worse than an error. +func TestParseComputerArgsReadsFlagsAfterPositional(t *testing.T) { + cases := []struct { + name string + args []string + wantRef string + wantScreen string + wantOut string + wantRest []string + }{ + { + name: "out after the reference", + args: []string{"my-box", "--out", "shot.png"}, + wantRef: "my-box", + wantScreen: "screen-0", + wantOut: "shot.png", + }, + { + name: "equals form", + args: []string{"my-box", "--out=shot.png", "--screen=screen-2"}, + wantRef: "my-box", + wantScreen: "screen-2", + wantOut: "shot.png", + }, + { + name: "short screen alias after the reference", + args: []string{"my-box", "-S", "screen-1"}, + wantRef: "my-box", + wantScreen: "screen-1", + wantOut: defaultScreenshotPath, + }, + { + name: "flags before the reference still work", + args: []string{"--screen", "screen-3", "my-box"}, + wantRef: "my-box", + wantScreen: "screen-3", + wantOut: defaultScreenshotPath, + }, + { + name: "operands survive around a flag", + args: []string{"my-box", "640", "--screen", "screen-1", "400"}, + wantRef: "my-box", + wantScreen: "screen-1", + wantOut: defaultScreenshotPath, + wantRest: []string{"640", "400"}, + }, + { + name: "no arguments at all", + args: nil, + wantRef: "", + wantScreen: "screen-0", + wantOut: defaultScreenshotPath, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := parseComputerArgs(newComputerTestContext(tc.args...)) + if got.ref != tc.wantRef { + t.Errorf("ref = %q, want %q", got.ref, tc.wantRef) + } + if got.screen != tc.wantScreen { + t.Errorf("screen = %q, want %q", got.screen, tc.wantScreen) + } + if got.out != tc.wantOut { + t.Errorf("out = %q, want %q", got.out, tc.wantOut) + } + if len(got.rest) != len(tc.wantRest) { + t.Fatalf("rest = %v, want %v", got.rest, tc.wantRest) + } + for i := range got.rest { + if got.rest[i] != tc.wantRest[i] { + t.Errorf("rest[%d] = %q, want %q", i, got.rest[i], tc.wantRest[i]) + } + } + }) + } +} + +// `sandbox desktop` reads --wait through the same parser, so a timeout written +// after the sandbox reference has to survive. +func TestParseComputerArgsReadsWait(t *testing.T) { + got := parseComputerArgs(newComputerTestContext("my-box", "--wait", "30s")) + if got.wait != 30*time.Second { + t.Errorf("wait = %s, want 30s", got.wait) + } + + got = parseComputerArgs(newComputerTestContext("my-box", "--wait=90s")) + if got.wait != 90*time.Second { + t.Errorf("wait = %s, want 90s", got.wait) + } + + // An unparseable duration keeps the declared default rather than zeroing + // the timeout, which would turn the readiness wait into a single attempt. + got = parseComputerArgs(newComputerTestContext("my-box", "--wait", "soon")) + if got.wait != 2*time.Minute { + t.Errorf("wait = %s, want the 2m default", got.wait) + } +} diff --git a/cmd/sandbox/desktop.go b/cmd/sandbox/desktop.go new file mode 100644 index 0000000..fabccd2 --- /dev/null +++ b/cmd/sandbox/desktop.go @@ -0,0 +1,203 @@ +package sandbox + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" + "github.com/NodeOps-app/createos-cli/internal/output" + "github.com/NodeOps-app/createos-cli/internal/terminal" +) + +// The desktop stack (Xvfb → XFCE → x11vnc → websockify) starts *after* the +// sandbox reports `running`, so every computer call fails for the first while. +// Nothing upstream polls for this, which means every caller ends up writing +// this wait — so the CLI does it once, here. +const ( + desktopReadyTimeout = 2 * time.Minute + desktopPollInterval = 2 * time.Second +) + +func newDesktopCommand() *cli.Command { + return &cli.Command{ + Name: "desktop", + Usage: "Open a graphical sandbox in your browser", + ArgsUsage: "[]", + Description: `Turns on the public URL for a sandbox running a desktop image, waits +for its desktop to finish starting, and prints a link you can open in +a browser to watch and control it. + +The sandbox must already be running a desktop image. To create one: + + createos sandbox create --rootfs desktop:1 + +Anyone with the link can control the desktop, so treat it like a +password. It expires, and running this again issues a fresh link.`, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "screen", + Aliases: []string{"S"}, + Usage: "Which screen to open", + Value: api.DefaultComputerScreen, + }, + &cli.DurationFlag{ + Name: "wait", + Usage: "How long to wait for the desktop to start", + Value: desktopReadyTimeout, + }, + }, + Action: runDesktop, + } +} + +func runDesktop(c *cli.Context) error { + client, ok := c.App.Metadata[api.SandboxClientKey].(*api.SandboxClient) + if !ok { + return fmt.Errorf("you're not signed in — run 'createos login' to get started") + } + + args := parseComputerArgs(c) + ref := args.ref + var id string + switch { + case ref != "": + resolved, err := resolveSandboxRef(c.Context, client, ref) + if err != nil { + return err + } + id = resolved + case terminal.IsInteractive(): + picked, label, err := pickByStatus(c, client, "Pick a sandbox to open", api.SandboxStatusRunning) + if err != nil { + return err + } + if picked == "" { + fmt.Println("Cancelled. Nothing changed.") + return nil + } + id, ref = picked, label + default: + return fmt.Errorf("please provide a sandbox ID or name\n\n To see your sandboxes, run:\n createos sandbox list") + } + + sb, err := client.GetSandbox(c.Context, id) + if err != nil { + return err + } + if err = ensureDesktopReady(c, client, sb); err != nil { + return err + } + + screen := args.screen + if !sb.IngressEnabled { + if _, err = client.SetSandboxIngress(c.Context, id, true); err != nil { + return fmt.Errorf("couldn't turn on the public URL for %s: %w", refLabel(ref, id), err) + } + } + + if err = waitForDesktop(c.Context, client, id, screen, args.wait); err != nil { + return err + } + + conn, err := client.ComputerConnect(c.Context, id, screen) + if err != nil { + return err + } + if conn.URL == "" { + return fmt.Errorf("no link came back for %s\n\n The public URL has to be on before a link can be issued. Turn it on with:\n createos sandbox edit %s --ingress on", refLabel(ref, id), id) + } + + output.Render(c, conn, func() { + pterm.Success.Printfln("Desktop ready on %s (%s)", refLabel(ref, id), screen) + fmt.Printf(" %s\n", conn.URL) + pterm.Println(pterm.Gray(" Anyone with this link can control the desktop.")) + if conn.ExpiresAt != "" { + pterm.Println(pterm.Gray(fmt.Sprintf(" It expires at %s. Running this again issues a new one.", conn.ExpiresAt))) + } + }) + return nil +} + +// ensureDesktopReady refuses early on a sandbox that cannot serve a desktop. +// Without this the first computer call fails with a far less obvious message +// than saying so up front. +func ensureDesktopReady(c *cli.Context, client *api.SandboxClient, sb *api.SandboxView) error { + if sb.Status == api.SandboxStatusPaused { + if _, err := client.ResumeSandbox(c.Context, sb.ID); err != nil { + return err + } + spinner, _ := pterm.DefaultSpinner.Start("Waking the sandbox up…") //nolint:errcheck + resumed, err := waitForStatus(c.Context, client, sb.ID, api.SandboxStatusRunning) + if err != nil { + spinner.Fail("It didn't wake up") + return err + } + spinner.Success("Sandbox is awake") + sb = resumed + } + if sb.Status != api.SandboxStatusRunning { + return fmt.Errorf("that sandbox is %s, so it has no desktop to show yet\n\n To check on it, run:\n createos sandbox get %s", sb.Status, sb.ID) + } + rootfs := "" + if sb.Rootfs != nil { + rootfs = *sb.Rootfs + } + if !strings.Contains(strings.ToLower(rootfs), "desktop") { + named := rootfs + if named == "" { + named = "an image without a desktop" + } + return fmt.Errorf("that sandbox runs %s, which has no desktop\n\n Create one that does with:\n createos sandbox create --rootfs desktop:1", named) + } + return nil +} + +// waitForDesktop polls the screen route until the desktop answers. It stops +// early on an error that more waiting cannot fix — a missing desktop image +// answers 501 forever, and spinning on that just delays the real message. +func waitForDesktop(ctx context.Context, client *api.SandboxClient, id, screen string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + var spinner *pterm.SpinnerPrinter + + for { + _, err := client.ComputerScreen(ctx, id, screen) + if err == nil { + if spinner != nil { + spinner.Success("Desktop is up") + } + return nil + } + + var computerErr *api.ComputerError + if errors.As(err, &computerErr) && !computerErr.Retryable() { + if spinner != nil { + spinner.Fail("The desktop didn't start") + } + return err + } + if time.Now().After(deadline) { + if spinner != nil { + spinner.Fail("The desktop didn't start in time") + } + return fmt.Errorf("the desktop on %s didn't start within %s\n\n To see whether it's still coming up, run:\n createos sandbox exec %s 'pgrep -a Xvfb; pgrep -a websockify'", id, timeout, id) + } + if spinner == nil { + spinner, _ = pterm.DefaultSpinner.Start("Waiting for the desktop to start…") //nolint:errcheck + } + + select { + case <-ctx.Done(): + if spinner != nil { + spinner.Fail("Cancelled") + } + return ctx.Err() + case <-time.After(desktopPollInterval): + } + } +} diff --git a/cmd/sandbox/sandbox.go b/cmd/sandbox/sandbox.go index 5353359..8fa0abb 100644 --- a/cmd/sandbox/sandbox.go +++ b/cmd/sandbox/sandbox.go @@ -28,6 +28,8 @@ func NewSandboxCommand() *cli.Command { newPushCommand(), newPullCommand(), newShellCommand(), + newDesktopCommand(), + newComputerCommand(), newEditorCommand(), newSyncCommand(), newTunnelCommand(), diff --git a/internal/api/sandbox_computer.go b/internal/api/sandbox_computer.go new file mode 100644 index 0000000..84ee5a8 --- /dev/null +++ b/internal/api/sandbox_computer.go @@ -0,0 +1,330 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// Computer-use routes: GET/POST /v1/sandboxes/:id/computer/*. +// +// These drive the X session inside a sandbox booted on a desktop rootfs — +// screenshot, pointer, keyboard, window list — plus the noVNC connect URL a +// human opens in a browser. Every call is scoped to one screen, selected by +// the screen_id query parameter. +// +// Screenshot is the only route that answers with bytes (image/png) instead of +// the JSend envelope, so it bypasses SetResult and reads resp.Body() directly. + +// DefaultComputerScreen is the screen every computer call targets unless the +// caller names another one. +const DefaultComputerScreen = "screen-0" + +// ComputerScreenGeometry is GET /computer/screen — the coordinate space every +// other call's x/y is measured in. Raw X11 pixels: no DPI scaling is applied +// anywhere along this path, so callers must read the bounds rather than assume +// a resolution. +type ComputerScreenGeometry struct { + Width int `json:"width"` + Height int `json:"height"` +} + +// ComputerCursorPos is GET /computer/cursor. +type ComputerCursorPos struct { + X int `json:"x"` + Y int `json:"y"` +} + +// ComputerConnection is GET /computer/screens/:screen/connect — a live noVNC +// URL with a bearer token embedded in it, plus that token's expiry. +// +// The URL *is* the credential: anyone holding it can drive the desktop until +// it expires. fc only mints one when ingress is enabled on the sandbox. +type ComputerConnection struct { + URL string `json:"url"` + ExpiresAt string `json:"expires_at"` +} + +// ComputerScreen returns the screen's pixel geometry. It doubles as the +// readiness probe: it is the cheapest route that only answers 2xx once the +// desktop stack (Xvfb → XFCE → x11vnc → websockify) is actually up. +func (c *SandboxClient) ComputerScreen(ctx context.Context, id, screen string) (*ComputerScreenGeometry, error) { + var envelope Response[ComputerScreenGeometry] + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", id). + SetQueryParam("screen_id", computerScreen(screen)). + SetResult(&envelope). + Get("/v1/sandboxes/{id}/computer/screen") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseComputerError(resp.StatusCode(), resp.Body()) + } + return &envelope.Data, nil +} + +// ComputerCursor returns the pointer's current position. +func (c *SandboxClient) ComputerCursor(ctx context.Context, id, screen string) (*ComputerCursorPos, error) { + var envelope Response[ComputerCursorPos] + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", id). + SetQueryParam("screen_id", computerScreen(screen)). + SetResult(&envelope). + Get("/v1/sandboxes/{id}/computer/cursor") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseComputerError(resp.StatusCode(), resp.Body()) + } + return &envelope.Data, nil +} + +// ComputerWindows lists the windows on the screen. The window shape is fc's to +// define and has no stable schema here yet, so the payload is passed through +// unparsed rather than pinned to a struct this repo would have to guess at. +func (c *SandboxClient) ComputerWindows(ctx context.Context, id, screen string) (json.RawMessage, error) { + return c.computerGetRaw(ctx, id, screen, "/v1/sandboxes/{id}/computer/windows") +} + +// ComputerScreenshot captures the screen and returns the PNG bytes verbatim. +// +// This route answers image/png, not the JSend envelope, so an error body has +// to be read off the same response — hence the manual IsError branch before +// the bytes are handed back. +func (c *SandboxClient) ComputerScreenshot(ctx context.Context, id, screen string) ([]byte, error) { + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", id). + SetQueryParam("screen_id", computerScreen(screen)). + SetHeader("Accept", "image/png"). + Get("/v1/sandboxes/{id}/computer/screenshot") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseComputerError(resp.StatusCode(), resp.Body()) + } + return resp.Body(), nil +} + +// ComputerMouseMove moves the pointer to an absolute position on the screen. +func (c *SandboxClient) ComputerMouseMove(ctx context.Context, id, screen string, x, y int) error { + return c.computerPost(ctx, id, screen, "/v1/sandboxes/{id}/computer/mouse/move", + map[string]int{"x": x, "y": y}) +} + +// ComputerMouseClick clicks. With at == nil it clicks wherever the pointer +// already is; otherwise it moves there first. +func (c *SandboxClient) ComputerMouseClick(ctx context.Context, id, screen string, at *ComputerCursorPos) error { + body := map[string]int{} + if at != nil { + body["x"] = at.X + body["y"] = at.Y + } + return c.computerPost(ctx, id, screen, "/v1/sandboxes/{id}/computer/mouse/click", body) +} + +// ComputerType types a string into the focused window. +func (c *SandboxClient) ComputerType(ctx context.Context, id, screen, text string) error { + return c.computerPost(ctx, id, screen, "/v1/sandboxes/{id}/computer/keyboard/type", + map[string]string{"text": text}) +} + +// ComputerPress presses one key chord — each element is a key name, and they +// are pressed together (e.g. ["ctrl","l"]). +func (c *SandboxClient) ComputerPress(ctx context.Context, id, screen string, keys []string) error { + return c.computerPost(ctx, id, screen, "/v1/sandboxes/{id}/computer/keyboard/press", + map[string][]string{"keys": keys}) +} + +// ComputerOpen opens a URL or a local path in the desktop's browser. +func (c *SandboxClient) ComputerOpen(ctx context.Context, id, screen, target string) error { + return c.computerPost(ctx, id, screen, "/v1/sandboxes/{id}/computer/open", + map[string]string{"target": target}) +} + +// ComputerConnect mints a noVNC URL for the screen. fc only returns one when +// ingress is enabled on the sandbox; a fresh call invalidates the previous +// link for new connections. +func (c *SandboxClient) ComputerConnect(ctx context.Context, id, screen string) (*ComputerConnection, error) { + var envelope Response[ComputerConnection] + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", id). + SetPathParam("screen", computerScreen(screen)). + SetResult(&envelope). + Get("/v1/sandboxes/{id}/computer/screens/{screen}/connect") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseComputerError(resp.StatusCode(), resp.Body()) + } + return &envelope.Data, nil +} + +// ComputerRaw is the escape hatch for the computer routes this client does not +// wrap. path is relative to /v1/sandboxes/:id/computer (a leading slash makes +// it absolute instead). body may be nil. +func (c *SandboxClient) ComputerRaw(ctx context.Context, id, screen, method, path string, body json.RawMessage) (json.RawMessage, error) { + full := path + if !strings.HasPrefix(path, "/") { + full = fmt.Sprintf("/v1/sandboxes/%s/computer/%s", id, path) + } + req := c.Client.R(). + SetContext(ctx). + SetQueryParam("screen_id", computerScreen(screen)) + if len(body) > 0 { + req = req.SetBody(body) + } + resp, err := req.Execute(strings.ToUpper(method), full) + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseComputerError(resp.StatusCode(), resp.Body()) + } + return unwrapEnvelope(resp.Body()), nil +} + +// computerPost is the shared shape of every action route: POST a small JSON +// body, care only about success or failure. +func (c *SandboxClient) computerPost(ctx context.Context, id, screen, path string, body any) error { + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", id). + SetQueryParam("screen_id", computerScreen(screen)). + SetBody(body). + Post(path) + if err != nil { + return err + } + if resp.IsError() { + return ParseComputerError(resp.StatusCode(), resp.Body()) + } + return nil +} + +// computerGetRaw fetches a route whose payload this client deliberately does +// not model, and returns it unwrapped from the JSend envelope. +func (c *SandboxClient) computerGetRaw(ctx context.Context, id, screen, path string) (json.RawMessage, error) { + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", id). + SetQueryParam("screen_id", computerScreen(screen)). + Get(path) + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseComputerError(resp.StatusCode(), resp.Body()) + } + return unwrapEnvelope(resp.Body()), nil +} + +// computerScreen applies the default so callers can pass "". +func computerScreen(screen string) string { + if strings.TrimSpace(screen) == "" { + return DefaultComputerScreen + } + return screen +} + +// unwrapEnvelope pulls `data` out of a JSend body, falling back to the whole +// body when the response is not enveloped. +func unwrapEnvelope(body []byte) json.RawMessage { + var envelope struct { + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(body, &envelope); err == nil && len(envelope.Data) > 0 { + return envelope.Data + } + return body +} + +// ComputerError is a failed computer-use call. It keeps the status code so +// callers that poll (readiness waits) can tell a "not up yet" apart from a +// "this will never work", instead of retrying until the timeout either way. +type ComputerError struct { + StatusCode int + Message string + advice string +} + +func (e *ComputerError) Error() string { + if e.advice == "" { + return e.Message + } + return e.Message + "\n\n" + e.advice +} + +// Retryable reports whether polling the same route again could plausibly +// succeed. A 409 is the interesting case: fc uses it both for "the desktop is +// still coming up" and "the action failed on a live desktop", so a waiter has +// to keep trying while an action caller should surface it. +func (e *ComputerError) Retryable() bool { + switch e.StatusCode { + case http.StatusNotFound, http.StatusConflict, http.StatusTooManyRequests: + return true + default: + return false + } +} + +// ParseComputerError maps the computer API's status codes onto something a +// caller can act on. +// +// This is worth doing by hand rather than leaning on ParseAPIError, because +// fc returns `desktop_unavailable` (409) for *every* X-side failure — the raw +// message alone never distinguishes "the desktop is still booting" from "the +// action failed on a perfectly healthy desktop", and those want opposite +// responses from whoever is reading the error. +func ParseComputerError(statusCode int, body []byte) error { + msg := "" + if base := ParseAPIError(statusCode, body); base != nil { + msg = base.Message + } + e := &ComputerError{StatusCode: statusCode, Message: msg} + + switch statusCode { + case http.StatusBadRequest: + e.Message = "the desktop couldn't use that" + suffix(msg) + e.advice = " If you named a screen, check it exists. Most sandboxes have only\n screen-0, which is the default.\n To see the screen you have, run:\n createos sandbox computer screen " + case http.StatusNotFound: + e.Message = "that sandbox or screen doesn't exist" + suffix(msg) + e.advice = " Computer-use needs a sandbox booted on a desktop image.\n To start one, run:\n createos sandbox create --rootfs desktop:1" + case http.StatusConflict: + if strings.Contains(strings.ToLower(msg), "ingress") { + e.Message = "the public URL is off for this sandbox" + suffix(msg) + e.advice = " To turn it on, run:\n createos sandbox desktop " + break + } + e.Message = "the desktop didn't answer" + suffix(msg) + e.advice = " Either the desktop is still starting up, or the action failed on a\n running desktop — the API reports both the same way.\n If the sandbox just started, wait for it with:\n createos sandbox desktop " + case http.StatusTooManyRequests: + e.Message = "too many requests in a row" + suffix(msg) + e.advice = " Screenshots are rate limited. Wait a second and try again." + case http.StatusNotImplemented: + e.Message = "this sandbox has no desktop installed" + suffix(msg) + e.advice = " Its image doesn't include the desktop tools. Create a new one with:\n createos sandbox create --rootfs desktop:1" + default: + if e.Message == "" { + e.Message = fmt.Sprintf("the desktop request failed (HTTP %d)", statusCode) + } + } + return e +} + +// suffix formats an optional server message as a trailing clause. +func suffix(msg string) string { + if msg == "" { + return "" + } + return ": " + msg +} diff --git a/internal/api/sandbox_computer_test.go b/internal/api/sandbox_computer_test.go new file mode 100644 index 0000000..68798bf --- /dev/null +++ b/internal/api/sandbox_computer_test.go @@ -0,0 +1,87 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + "testing" +) + +// The readiness wait in `sandbox desktop` keeps polling while an error is +// retryable and gives up immediately when it is not. Misclassify one and the +// command either spins for the whole timeout on a failure that will never +// clear, or abandons a desktop that was still starting. +func TestComputerErrorRetryable(t *testing.T) { + cases := []struct { + status int + want bool + why string + }{ + {http.StatusNotFound, true, "the screen appears only once the desktop is up"}, + {http.StatusConflict, true, "fc uses 409 for 'still booting' as well as 'action failed'"}, + {http.StatusTooManyRequests, true, "rate limiting clears on its own"}, + {http.StatusNotImplemented, false, "an image without desktop tools never grows them"}, + {http.StatusUnauthorized, false, "bad credentials do not fix themselves"}, + {http.StatusForbidden, false, "bad credentials do not fix themselves"}, + } + for _, tc := range cases { + var err *ComputerError + if !errors.As(ParseComputerError(tc.status, nil), &err) { + t.Fatalf("status %d: expected a *ComputerError", tc.status) + } + if got := err.Retryable(); got != tc.want { + t.Errorf("status %d: Retryable() = %v, want %v — %s", tc.status, got, tc.want, tc.why) + } + } +} + +// A 409 mentioning ingress is a different fix from a 409 about the desktop, +// so the two must not collapse into one message. +func TestComputerErrorConflictDistinguishesIngress(t *testing.T) { + body := []byte(`{"status":"fail","data":"ingress is not enabled"}`) + ingress := ParseComputerError(http.StatusConflict, body).Error() + if !strings.Contains(ingress, "public URL is off") { + t.Errorf("ingress conflict should name the public URL, got: %q", ingress) + } + + desktop := ParseComputerError(http.StatusConflict, []byte(`{"status":"fail","data":"desktop_unavailable"}`)).Error() + if strings.Contains(desktop, "public URL is off") { + t.Errorf("a desktop_unavailable conflict should not be reported as an ingress problem, got: %q", desktop) + } + if !strings.Contains(desktop, "still starting up") { + t.Errorf("a desktop conflict should say it may still be starting, got: %q", desktop) + } +} + +func TestComputerScreenDefaults(t *testing.T) { + if got := computerScreen(""); got != DefaultComputerScreen { + t.Errorf("computerScreen(%q) = %q, want %q", "", got, DefaultComputerScreen) + } + if got := computerScreen(" "); got != DefaultComputerScreen { + t.Errorf("computerScreen(whitespace) = %q, want %q", got, DefaultComputerScreen) + } + if got := computerScreen("screen-2"); got != "screen-2" { + t.Errorf("computerScreen(%q) = %q, want it unchanged", "screen-2", got) + } +} + +func TestUnwrapEnvelope(t *testing.T) { + wrapped := unwrapEnvelope([]byte(`{"status":"success","data":{"width":1280}}`)) + var got struct { + Width int `json:"width"` + } + if err := json.Unmarshal(wrapped, &got); err != nil { + t.Fatalf("unwrapping an enveloped body: %v", err) + } + if got.Width != 1280 { + t.Errorf("width = %d, want 1280", got.Width) + } + + // Not every computer route envelopes its payload, so a bare body has to + // survive untouched rather than come back empty. + bare := []byte(`[{"id":1}]`) + if string(unwrapEnvelope(bare)) != string(bare) { + t.Errorf("a bare body should pass through unchanged, got %q", unwrapEnvelope(bare)) + } +}