diff --git a/README.md b/README.md index 8143010..8c18c7a 100644 --- a/README.md +++ b/README.md @@ -341,25 +341,54 @@ asks for a few more characters rather than guessing. | `--ingress` | Give the sandbox a public HTTPS URL | | `--auto-pause` | Auto-pause after inactivity (e.g. `10m`, `1h`). Omit to keep running. | -**`sandbox setup` — run an editor's workspaces on sandboxes:** - -`createos sandbox setup orca` connects [Orca](https://orca.dev) so that each of -its workspaces runs on its own disposable sandbox instead of your laptop. +**`sandbox setup` — run a coding harness on sandboxes:** + +One subcommand per host on the +[Integrations](https://createos.sh/docs/Sandbox/Integrations) page. Each one +installs the CreateOS plugin for that host, so its work runs in a disposable +sandbox instead of on your laptop. + +| Command | Host | Needs | +| -------------------------------------------- | ---------------- | --------------- | +| `createos sandbox setup claude-code` | Claude Code | `claude` | +| `createos sandbox setup codex` | Codex | `codex` | +| `createos sandbox setup deepseek` | DeepSeek Harness | `dsh`, `node` | +| `createos sandbox setup herdr` | Herdr | `herdr`, `bun` | +| `createos sandbox setup opencode` | OpenCode | `opencode`, `bun` | +| `createos sandbox setup orca` | Orca | `git`, `ssh` | +| `createos sandbox setup pi` | Pi | `pi` | + +Every subcommand takes `--doctor`, which checks the prerequisites and reports +without changing anything. Running one twice is safe — an install that is +already in place is left alone. ```bash -createos sandbox setup orca --doctor # check prerequisites, change nothing -createos sandbox setup orca # print the plugin install steps +createos sandbox setup claude-code --doctor # check prerequisites, change nothing +createos sandbox setup claude-code # add the marketplace + install the plugin ``` +`opencode` and `deepseek` have no installer of their own, so setup +clones the plugins into `~/.config/createos/plugins` and refreshes that clone +on each run. Pass `--local ` to use your own checkout instead. The +OpenCode setup also adds the plugin to your OpenCode config, backing the file +up to `.before-createos` first; `--mode remote` moves OpenCode's own +shell and file tools into the sandbox as well. + +The DeepSeek Harness plugin reads its CreateOS credentials from +`CREATEOS_SANDBOX_API_KEY` and `CREATEOS_SANDBOX_SHAPE`. Setup reports whether +they are set but never reads or prints a key — export them yourself. + +**Orca** + The workspace checkout is pushed into the sandbox rather than cloned, so no git token ever reaches the box and private repositories work with no extra setup. Set `CREATEOS_AGENTS` to install coding agents at create time, for example `CREATEOS_AGENTS=claude,codex`. Orca calls this command itself for each lifecycle phase once its plugin is -installed. The plugin lives in -[NodeOps-app/createos-plugins](https://github.com/NodeOps-app/createos-plugins) -under `packages/orca-plugin`. +installed. The plugins live in +[NodeOps-app/createos-plugin](https://github.com/NodeOps-app/createos-plugin) +under `packages/`. **When to use `exec`, `shell`, `process`, and PTY:** diff --git a/cmd/sandbox/orca.go b/cmd/sandbox/orca.go index 9d1f778..3efda42 100644 --- a/cmd/sandbox/orca.go +++ b/cmd/sandbox/orca.go @@ -121,18 +121,6 @@ type orcaLifecyclePayload struct { } `json:"recipeResult"` } -// newSetupCommand returns `createos sandbox setup`, the harness integration -// group. -func newSetupCommand() *cli.Command { - return &cli.Command{ - Name: "setup", - Usage: "Connect a coding harness to CreateOS Sandbox", - Description: "Each subcommand wires one harness to CreateOS Sandbox, so a\n" + - "workspace runs on a disposable microVM instead of your laptop.", - Subcommands: []*cli.Command{newSetupHerdrCommand(), newSetupOrcaCommand()}, - } -} - func newSetupOrcaCommand() *cli.Command { return &cli.Command{ Name: "orca", diff --git a/cmd/sandbox/setup.go b/cmd/sandbox/setup.go new file mode 100644 index 0000000..bfcaabc --- /dev/null +++ b/cmd/sandbox/setup.go @@ -0,0 +1,193 @@ +package sandbox + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/urfave/cli/v2" + + "github.com/NodeOps-app/createos-cli/internal/api" +) + +// The integrations monorepo. Every host below installs some part of it, and +// the two that have no installer of their own (OpenCode, DeepSeek Harness) +// need a checkout on disk, which setupPluginCheckout manages. +const ( + setupPluginRepo = "NodeOps-app/createos-plugin" + setupPluginRepoURL = "https://github.com/" + setupPluginRepo + // The Claude Code marketplace declares itself under this name, so + // `plugin@marketplace` ids resolve to it regardless of how the user + // named the source when adding it. + setupMarketplaceName = "createos" +) + +// newSetupCommand returns `createos sandbox setup`, the harness integration +// group. One subcommand per host on +// https://createos.sh/docs/Sandbox/Integrations. +func newSetupCommand() *cli.Command { + return &cli.Command{ + Name: "setup", + Usage: "Connect a coding harness to CreateOS Sandbox", + Description: "Each subcommand wires one harness to CreateOS Sandbox, so a\n" + + "workspace runs on a disposable microVM instead of your laptop.\n\n" + + "Every subcommand takes --doctor, which checks the prerequisites and\n" + + "reports without changing anything.", + Subcommands: []*cli.Command{ + newSetupClaudeCodeCommand(), + newSetupCodexCommand(), + newSetupDeepSeekCommand(), + newSetupHerdrCommand(), + newSetupOpenCodeCommand(), + newSetupOrcaCommand(), + newSetupPiCommand(), + }, + } +} + +// setupSignedIn confirms the session actually works before a host integration +// is wired to it. A plugin installed against a dead session fails later, in +// the host's UI, where the reason is much harder to see. +func setupSignedIn(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' first") + } + if _, _, err := client.ListSandboxes(c.Context, api.ListSandboxesOpts{}); err != nil { + return fmt.Errorf("your session is not usable — run 'createos login' again: %w", err) + } + fmt.Println("signed in to CreateOS") + return nil +} + +// setupRequireBin resolves a host binary, turning a bare exec.LookPath miss +// into the install hint the user actually needs. +func setupRequireBin(name, hint string) (string, error) { + bin, err := exec.LookPath(name) + if err != nil { + return "", fmt.Errorf("%s is not on PATH — %s", name, hint) + } + fmt.Printf("%s found at %s\n", name, bin) + return bin, nil +} + +// setupRun runs a host CLI and returns its combined output, which callers +// attach to any error: these tools explain their own failures far better than +// an exit status does. +func setupRun(ctx context.Context, bin string, args ...string) (string, error) { + // #nosec G204 -- bin is an exec.LookPath result and every arg is either a + // literal from this package or a path the user named on the command line; + // it is one argv element, never a shell string. + out, err := exec.CommandContext(ctx, bin, args...).CombinedOutput() + return string(out), err +} + +// setupAlreadyDone reports whether a host CLI refused because the thing was +// already installed. Those tools exit non-zero for it, so a plain error check +// would make a second `setup` run fail on a box that is correctly set up. +func setupAlreadyDone(out string) bool { + s := strings.ToLower(out) + for _, phrase := range []string{ + "already exists", + "already added", + "already installed", + "already registered", + "already configured", + } { + if strings.Contains(s, phrase) { + return true + } + } + return false +} + +// setupCheckoutDir is where setup keeps its own clone of the integrations +// monorepo. Beside the per-sandbox keys and ssh mux sockets the CLI already +// owns, so nothing of the user's is involved. +func setupCheckoutDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve $HOME: %w", err) + } + return filepath.Join(home, ".config", "createos", "plugins", "createos-plugin"), nil +} + +// setupPluginCheckout returns a path to the integrations monorepo. +// +// A --local path wins and is used as-is, so plugin developers can point the +// setup at their own working tree. Otherwise setup owns a clone under +// ~/.config/createos and refreshes it on every run, because the host reads +// these files directly — a stale checkout silently pins the user to whatever +// the plugin looked like the day they first ran setup. +func setupPluginCheckout(ctx context.Context, local string) (string, error) { + if local = strings.TrimSpace(local); local != "" { + dir, err := filepath.Abs(local) + if err != nil { + return "", fmt.Errorf("could not resolve %q: %w", local, err) + } + if _, err := os.Stat(filepath.Join(dir, "packages")); err != nil { + return "", fmt.Errorf("%s does not look like the integrations repo: no packages/ directory", dir) + } + fmt.Printf("using your checkout at %s\n", dir) + return dir, nil + } + + if _, err := setupRequireBin("git", "install it from https://git-scm.com"); err != nil { + return "", err + } + dir, err := setupCheckoutDir() + if err != nil { + return "", err + } + if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil { + fmt.Printf("updating %s\n", dir) + if out, pullErr := setupRun(ctx, "git", "-C", dir, "pull", "--ff-only", "--quiet"); pullErr != nil { + // A diverged or dirty checkout is the user's, not ours to reset. + // The stale copy still works, so warn and carry on. + fmt.Printf("could not update the checkout, using it as-is: %s\n", strings.TrimSpace(out)) + } + return dir, nil + } + if err := os.MkdirAll(filepath.Dir(dir), 0o750); err != nil { + return "", fmt.Errorf("could not create %s: %w", filepath.Dir(dir), err) + } + fmt.Printf("cloning %s into %s\n", setupPluginRepoURL, dir) + if out, err := setupRun(ctx, "git", "clone", "--depth", "1", setupPluginRepoURL, dir); err != nil { + return "", fmt.Errorf("could not clone the integrations repo: %w\n%s", err, out) + } + return dir, nil +} + +// setupPackageDir resolves one package inside the checkout and fails loudly +// when it is missing, which means the checkout is not what we think it is. +func setupPackageDir(checkout, pkg string) (string, error) { + dir := filepath.Join(checkout, "packages", pkg) + if _, err := os.Stat(dir); err != nil { + return "", fmt.Errorf("%s is missing from the checkout at %s", pkg, checkout) + } + return dir, nil +} + +// setupDoctorFlag is the flag every subcommand shares. +func setupDoctorFlag() cli.Flag { + return &cli.BoolFlag{ + Name: "doctor", + Usage: "Check the prerequisites and report, without changing anything", + } +} + +// setupLocalFlag is shared by the hosts that need a checkout on disk. +func setupLocalFlag() cli.Flag { + return &cli.StringFlag{ + Name: "local", + Usage: "Use this local checkout of " + setupPluginRepo + " instead of cloning it", + } +} + +// setupDoctorDone prints the line that ends a --doctor run. +func setupDoctorDone() { + fmt.Println("\nEverything the plugin needs is present. Run this again without --doctor to install it.") +} diff --git a/cmd/sandbox/setup_dsh.go b/cmd/sandbox/setup_dsh.go new file mode 100644 index 0000000..9cdee46 --- /dev/null +++ b/cmd/sandbox/setup_dsh.go @@ -0,0 +1,168 @@ +package sandbox + +import ( + "fmt" + "os" + "strconv" + "strings" + + "github.com/urfave/cli/v2" +) + +// DeepSeek Harness installs a bundle from a path on disk, so setup's job is to +// put the checkout there and register it against a DSH profile. +const ( + dshPkg = "dsh-createos" + dshDefaultProfile = "web" + // The plugin's own requirement: Node ^22.19 or >=24. + dshNodeMinMajor = 22 + dshNodeMinMinor = 19 +) + +// dshEnv lists the variables the plugin reads. The API key is deliberately +// never read or printed here — setup reports only whether one is present. +var dshEnv = []struct{ name, why string }{ + {"CREATEOS_SANDBOX_API_KEY", "your CreateOS API key"}, + {"CREATEOS_SANDBOX_SHAPE", "the sandbox size, e.g. s-2vcpu-2gb"}, +} + +func newSetupDeepSeekCommand() *cli.Command { + return &cli.Command{ + Name: "deepseek", + Aliases: []string{"deepseek-harness", "dsh"}, + Usage: "Set up DeepSeek Harness to run its tools in a CreateOS Sandbox", + Description: "Clones the plugin and registers it with a DeepSeek Harness profile,\n" + + "so the harness runs its files, subprocesses and terminals inside one\n" + + "sandbox instead of on your laptop.\n\n" + + "The harness reads its CreateOS credentials from environment\n" + + "variables, which only you can set — setup prints the ones you still\n" + + "need at the end.\n\n" + + "Run with --doctor first if you only want to check the prerequisites.", + Flags: []cli.Flag{ + setupDoctorFlag(), + setupLocalFlag(), + &cli.StringFlag{ + Name: "profile", + Value: dshDefaultProfile, + Usage: "DeepSeek Harness profile to install into", + }, + }, + Action: runDeepSeekSetup, + } +} + +func runDeepSeekSetup(c *cli.Context) error { + profile := strings.TrimSpace(c.String("profile")) + if profile == "" { + return fmt.Errorf("--profile cannot be empty") + } + + if err := setupSignedIn(c); err != nil { + return err + } + dshBin, err := setupRequireBin("dsh", "install it from https://github.com/deepseek-ai/harness") + if err != nil { + return err + } + if nodeErr := dshCheckNode(c); nodeErr != nil { + return nodeErr + } + if c.Bool("doctor") { + dshReportEnv() + setupDoctorDone() + return nil + } + + checkout, err := setupPluginCheckout(c.Context, c.String("local")) + if err != nil { + return err + } + pkgDir, err := setupPackageDir(checkout, dshPkg) + if err != nil { + return err + } + + fmt.Printf("registering the plugin with the %q profile\n", profile) + out, err := setupRun(c.Context, dshBin, "plugin", "--profile", profile, "add", pkgDir) + switch { + case err == nil: + case setupAlreadyDone(out): + fmt.Println(" already done, skipping") + default: + return fmt.Errorf("dsh plugin add failed: %w\n%s", err, strings.TrimSpace(out)) + } + + fmt.Println("\nDone.") + if missing := dshReportEnv(); len(missing) > 0 { + fmt.Println("\nSet those, then start the harness from the directory its tools") + fmt.Printf("should work in:\n dsh %s\n", profile) + fmt.Println("\nGet an API key from https://createos.nodeops.network/profile.") + return nil + } + fmt.Printf("\nStart the harness from the directory its tools should work in:\n dsh %s\n", profile) + return nil +} + +// dshReportEnv prints which of the plugin's variables are set, and returns the +// names of the ones that are not. It reports presence only: a key's value is +// never read into the CLI's output. +func dshReportEnv() []string { + var missing []string + for _, e := range dshEnv { + if strings.TrimSpace(os.Getenv(e.name)) != "" { + fmt.Printf("%s is set\n", e.name) + continue + } + missing = append(missing, e.name) + } + if len(missing) == 0 { + return nil + } + fmt.Println("\nThe harness still needs these in its environment:") + for _, e := range dshEnv { + if strings.TrimSpace(os.Getenv(e.name)) == "" { + fmt.Printf(" export %s='...' # %s\n", e.name, e.why) + } + } + return missing +} + +// dshCheckNode enforces the plugin's Node requirement up front, because DSH +// fails much later and much less clearly when it is not met. +func dshCheckNode(c *cli.Context) error { + nodeBin, err := setupRequireBin("node", "the plugin runs on it; install it from https://nodejs.org") + if err != nil { + return err + } + out, err := setupRun(c.Context, nodeBin, "--version") + if err != nil { + return fmt.Errorf("could not run 'node --version': %w\n%s", err, strings.TrimSpace(out)) + } + version := strings.TrimSpace(out) + if !dshNodeOK(version) { + return fmt.Errorf("node %s is too old for the plugin — it needs 22.19 or newer on 22.x, or 24 and above", version) + } + return nil +} + +// dshNodeOK reports whether a `node --version` string satisfies ^22.19 || >=24. +// 23.x is deliberately excluded: it is not a maintained line, which is why the +// plugin's own manifest skips it. +func dshNodeOK(version string) bool { + parts := strings.SplitN(strings.TrimPrefix(strings.TrimSpace(version), "v"), ".", 3) + if len(parts) < 2 { + return false + } + major, err := strconv.Atoi(parts[0]) + if err != nil { + return false + } + minor, err := strconv.Atoi(parts[1]) + if err != nil { + return false + } + if major >= 24 { + return true + } + return major == dshNodeMinMajor && minor >= dshNodeMinMinor +} diff --git a/cmd/sandbox/setup_hosts.go b/cmd/sandbox/setup_hosts.go new file mode 100644 index 0000000..6ac39ba --- /dev/null +++ b/cmd/sandbox/setup_hosts.go @@ -0,0 +1,148 @@ +package sandbox + +import ( + "fmt" + "strings" + + "github.com/urfave/cli/v2" +) + +// Claude Code, Codex and Pi each ship an installer of their own, so setup's +// whole job for them is: check the prerequisites, then drive that installer +// with the right arguments. setupHost describes one of them; setupHostAction +// is the shared runner. +// +// The hosts that have no installer — OpenCode and DeepSeek Harness — need a +// checkout and a config edit instead, and live in their own files. +type setupHost struct { + name string + aliases []string + usage string + // bin is the host's own CLI, and hint tells the user where to get it. + bin string + hint string + // steps run in order; each failure reports the host's own output, and a + // step whose output says the work was already done is treated as done. + steps []setupStep + // next is printed on success: the first thing to actually try. + next string +} + +type setupStep struct { + label string + args []string +} + +func newSetupHostCommand(h setupHost) *cli.Command { + return &cli.Command{ + Name: h.name, + Aliases: h.aliases, + Usage: h.usage, + Description: fmt.Sprintf( + "Installs the CreateOS Sandbox plugin into %s using its own\n"+ + "installer, so work can run in a disposable sandbox instead of on\n"+ + "your laptop.\n\n"+ + "Run with --doctor first if you only want to check the prerequisites.", + h.bin), + Flags: []cli.Flag{setupDoctorFlag()}, + Action: func(c *cli.Context) error { return setupHostAction(c, h) }, + } +} + +func setupHostAction(c *cli.Context, h setupHost) error { + if err := setupSignedIn(c); err != nil { + return err + } + bin, err := setupRequireBin(h.bin, h.hint) + if err != nil { + return err + } + if c.Bool("doctor") { + setupDoctorDone() + return nil + } + + for _, step := range h.steps { + fmt.Println(step.label) + out, runErr := setupRun(c.Context, bin, step.args...) + if runErr == nil { + continue + } + // These installers exit non-zero when the marketplace or plugin is + // already there. Re-running setup on a configured machine is a normal + // thing to do, so that is a success, not a failure. + if setupAlreadyDone(out) { + fmt.Println(" already done, skipping") + continue + } + return fmt.Errorf("%s %s failed: %w\n%s", + h.bin, strings.Join(step.args, " "), runErr, strings.TrimSpace(out)) + } + + fmt.Printf("\nDone. %s\n", h.next) + return nil +} + +func newSetupClaudeCodeCommand() *cli.Command { + return newSetupHostCommand(setupHost{ + name: "claude-code", + aliases: []string{"claude"}, + usage: "Set up Claude Code to offload work to CreateOS Sandboxes", + bin: "claude", + hint: "install it from https://docs.claude.com/en/docs/claude-code", + steps: []setupStep{ + { + label: "adding the CreateOS marketplace", + args: []string{"plugin", "marketplace", "add", setupPluginRepo}, + }, + { + label: "installing createos-sandbox", + // --yes so the install completes when stdout is not a terminal; + // without it Claude Code refuses to run unattended. + args: []string{"plugin", "install", "createos-sandbox@" + setupMarketplaceName, "--yes"}, + }, + }, + next: "Start a new Claude Code session, then offload a heavy command:\n" + + " /createos-sandbox:offload . \"npm ci && npm test\"\n" + + "Claude also reaches for a sandbox on its own once the skill loads.", + }) +} + +func newSetupCodexCommand() *cli.Command { + return newSetupHostCommand(setupHost{ + name: "codex", + usage: "Set up Codex to offload work to CreateOS Sandboxes", + bin: "codex", + hint: "install it from https://github.com/openai/codex", + steps: []setupStep{ + { + label: "adding the CreateOS marketplace", + args: []string{"plugin", "marketplace", "add", setupPluginRepo}, + }, + { + label: "installing createos-sandbox-codex", + args: []string{"plugin", "add", "createos-sandbox-codex", "--marketplace", setupMarketplaceName}, + }, + }, + next: "Run codex. Its session-start hook checks your sign-in and the skill\n" + + "teaches it when to move work off your machine.", + }) +} + +func newSetupPiCommand() *cli.Command { + return newSetupHostCommand(setupHost{ + name: "pi", + usage: "Set up Pi to run its tools in CreateOS Sandboxes", + bin: "pi", + hint: "install it from https://github.com/anthropics/pi", + steps: []setupStep{ + { + label: "installing the CreateOS extension", + args: []string{"install", "git:github.com/" + setupPluginRepo}, + }, + }, + next: "Run pi — the sandbox tools are available right away.\n" + + "Add --inside-createos-sandbox to run Pi's own tools in a sandbox,\n" + + "and --createos-sync-once to copy this project into it first.", + }) +} diff --git a/cmd/sandbox/setup_opencode.go b/cmd/sandbox/setup_opencode.go new file mode 100644 index 0000000..faf1c8e --- /dev/null +++ b/cmd/sandbox/setup_opencode.go @@ -0,0 +1,261 @@ +package sandbox + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/urfave/cli/v2" +) + +// OpenCode has no `plugin add` for an unpublished package, so setup does the +// two things its installer would: put the plugin on disk with its dependencies +// installed, and name it in the user's config. +const ( + openCodePkg = "opencode-plugin" + openCodeDefaultMode = "local" +) + +// openCodePluginsKey matches the `"plugins": [` key, and only as a key — a +// bare mention of the word in a comment has no colon and bracket after it. +var openCodePluginsKey = regexp.MustCompile(`"plugins"\s*:\s*\[`) + +func newSetupOpenCodeCommand() *cli.Command { + return &cli.Command{ + Name: "opencode", + Usage: "Set up OpenCode to run its tools in CreateOS Sandboxes", + Description: "Clones the plugin, installs its dependencies with bun, and adds it\n" + + "to your OpenCode config. Your config is backed up before it changes.\n\n" + + "In local mode OpenCode keeps running its own tools and gains the\n" + + "sandbox tools. In remote mode its shell and file tools run inside a\n" + + "sandbox instead.\n\n" + + "Run with --doctor first if you only want to check the prerequisites.", + Flags: []cli.Flag{ + setupDoctorFlag(), + setupLocalFlag(), + &cli.StringFlag{ + Name: "config", + Usage: "OpenCode config file to edit (default: ~/.config/opencode/opencode.json)", + }, + &cli.StringFlag{ + Name: "mode", + Value: openCodeDefaultMode, + Usage: "Where OpenCode's own shell and file tools run: `local` or `remote`", + }, + &cli.StringFlag{ + Name: "shape", + Usage: "Sandbox size for remote mode (default: the plugin's own)", + }, + &cli.StringFlag{ + Name: "rootfs", + Usage: "Sandbox image for remote mode (default: the plugin's own)", + }, + }, + Action: runOpenCodeSetup, + } +} + +func runOpenCodeSetup(c *cli.Context) error { + mode := strings.ToLower(strings.TrimSpace(c.String("mode"))) + if mode != "local" && mode != "remote" { + return fmt.Errorf("--mode must be 'local' or 'remote', got %q", c.String("mode")) + } + + if err := setupSignedIn(c); err != nil { + return err + } + // OpenCode ships as `opencode`, and as `opencode2` for the V2 preview. + // Either one reads the same config, so finding one is enough. + if _, err := setupRequireBin("opencode", "install it from https://opencode.ai"); err != nil { + if _, err2 := setupRequireBin("opencode2", "install it from https://opencode.ai"); err2 != nil { + return err + } + } + if _, err := setupRequireBin("bun", "the plugin runs on it; install it from https://bun.sh"); err != nil { + return err + } + configPath, err := openCodeConfigPath(c.String("config")) + if err != nil { + return err + } + fmt.Printf("config file: %s\n", configPath) + + if c.Bool("doctor") { + setupDoctorDone() + return nil + } + + checkout, err := setupPluginCheckout(c.Context, c.String("local")) + if err != nil { + return err + } + pkgDir, err := setupPackageDir(checkout, openCodePkg) + if err != nil { + return err + } + + fmt.Println("installing plugin dependencies with bun") + if out, bunErr := setupRun(c.Context, "bun", "install", "--cwd", pkgDir); bunErr != nil { + return fmt.Errorf("bun install failed: %w\n%s", bunErr, strings.TrimSpace(out)) + } + + entry, err := openCodeEntry(pkgDir, mode, c.String("shape"), c.String("rootfs")) + if err != nil { + return err + } + changed, err := openCodeWriteConfig(configPath, entry, pkgDir) + if err != nil { + return err + } + if changed { + fmt.Printf("added the plugin to %s\n", configPath) + } else { + fmt.Printf("%s already names this plugin — left it alone\n", configPath) + fmt.Println(" edit that entry by hand to change mode, shape, or image") + } + + fmt.Println("\nDone. Start OpenCode — the sandbox tools load with it.") + if mode == "remote" { + fmt.Println("Its shell and file tools now run inside a sandbox, created on the") + fmt.Println("first tool call of each session.") + } else { + fmt.Println("Re-run with --mode remote to move OpenCode's own shell and file") + fmt.Println("tools into a sandbox as well.") + } + return nil +} + +// openCodeConfigPath resolves which config file to edit. OpenCode reads both +// opencode.json and opencode.jsonc, so an existing file of either name wins +// over creating the other. +func openCodeConfigPath(explicit string) (string, error) { + if explicit = strings.TrimSpace(explicit); explicit != "" { + return filepath.Abs(explicit) + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve $HOME: %w", err) + } + dir := filepath.Join(home, ".config", "opencode") + for _, name := range []string{"opencode.json", "opencode.jsonc"} { + path := filepath.Join(dir, name) + if _, err := os.Stat(path); err == nil { + return path, nil + } + } + return filepath.Join(dir, "opencode.json"), nil +} + +// openCodeEntry renders the array element for the plugin. Local mode is a bare +// path; remote mode carries the options that move OpenCode's tools into the +// sandbox. Both are produced by the JSON encoder so a path with a quote or a +// backslash in it cannot break the file. +func openCodeEntry(pkgDir, mode, shape, rootfs string) (string, error) { + if mode == "local" { + b, err := json.Marshal(pkgDir) + if err != nil { + return "", fmt.Errorf("could not encode the plugin path: %w", err) + } + return string(b), nil + } + options := map[string]string{"mode": "remote"} + if s := strings.TrimSpace(shape); s != "" { + options["shape"] = s + } + if r := strings.TrimSpace(rootfs); r != "" { + options["rootfs"] = r + } + b, err := json.Marshal(struct { + Package string `json:"package"` + Options map[string]string `json:"options"` + }{Package: pkgDir, Options: options}) + if err != nil { + return "", fmt.Errorf("could not encode the plugin entry: %w", err) + } + return string(b), nil +} + +// openCodeWriteConfig inserts the entry, backing the file up first. Reports +// whether it changed anything. +func openCodeWriteConfig(path, entry, pkgDir string) (bool, error) { + existing, err := os.ReadFile(path) // #nosec G304 -- the user's own config, chosen by --config or the documented default + if err != nil && !os.IsNotExist(err) { + return false, fmt.Errorf("could not read %s: %w", path, err) + } + updated, changed, err := insertOpenCodePlugin(string(existing), entry, pkgDir) + if err != nil { + return false, err + } + if !changed { + return false, nil + } + if len(existing) > 0 { + backup := path + ".before-createos" + // #nosec G703 -- path is the user's own config, from --config or the + // documented default under $HOME; the suffix is a literal. + if err := os.WriteFile(backup, existing, 0o600); err != nil { + return false, fmt.Errorf("could not back up %s: %w", path, err) + } + fmt.Printf("backed up your config to %s\n", backup) + } + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return false, fmt.Errorf("could not create %s: %w", filepath.Dir(path), err) + } + // #nosec G703 -- path is the user's own config, from --config or the + // documented default under $HOME. + if err := os.WriteFile(path, []byte(updated), 0o600); err != nil { + return false, fmt.Errorf("could not write %s: %w", path, err) + } + return true, nil +} + +// insertOpenCodePlugin adds entry to the config's plugins array. +// +// The edit is textual rather than a parse-and-re-encode because the file is +// the user's: it may carry comments and their own formatting, and OpenCode +// documents JSONC support, which no standard-library decoder round-trips. +// Reports whether it changed anything — a config that already names pkgDir is +// left exactly as it is, so re-running setup is safe. +func insertOpenCodePlugin(existing, entry, pkgDir string) (string, bool, error) { + if strings.Contains(existing, pkgDir) { + return existing, false, nil + } + if strings.TrimSpace(existing) == "" { + return fmt.Sprintf("{\n \"$schema\": \"https://opencode.ai/config.json\",\n \"plugins\": [\n %s\n ]\n}\n", entry), true, nil + } + + if loc := openCodePluginsKey.FindStringIndex(existing); loc != nil { + open := loc[1] // just past the '[' + rest := strings.TrimLeft(existing[open:], " \t\r\n") + sep := "" + if !strings.HasPrefix(rest, "]") { + // A non-empty array needs a comma between our entry and the first + // one already there. + sep = "," + } + // Follow the layout already in the file. An array written on one line + // stays on one line: opening a new line inside it would leave whatever + // was there trailing off the end of ours. + if head, _, _ := strings.Cut(existing[open:], "\n"); strings.Contains(head, "]") { + if sep != "" { + sep = ", " + } + return existing[:open] + entry + sep + existing[open:], true, nil + } + return existing[:open] + "\n " + entry + sep + existing[open:], true, nil + } + + brace := strings.Index(existing, "{") + if brace < 0 { + return "", false, fmt.Errorf("could not find a JSON object in the config — add the plugin by hand:\n \"plugins\": [%s]", entry) + } + rest := strings.TrimLeft(existing[brace+1:], " \t\r\n") + sep := "" + if !strings.HasPrefix(rest, "}") { + sep = "," + } + return existing[:brace+1] + "\n \"plugins\": [\n " + entry + "\n ]" + sep + existing[brace+1:], true, nil +} diff --git a/cmd/sandbox/setup_test.go b/cmd/sandbox/setup_test.go new file mode 100644 index 0000000..49a60c4 --- /dev/null +++ b/cmd/sandbox/setup_test.go @@ -0,0 +1,231 @@ +package sandbox + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestInsertOpenCodePluginCreatesConfigFromNothing(t *testing.T) { + out, changed, err := insertOpenCodePlugin("", `"/p/opencode-plugin"`, "/p/opencode-plugin") + if err != nil || !changed { + t.Fatalf("insert = (%v, %v), want (true, nil)", changed, err) + } + if !strings.Contains(out, `"plugins"`) || !strings.Contains(out, "/p/opencode-plugin") { + t.Fatalf("fresh config missing the plugin:\n%s", out) + } + var parsed map[string]any + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("fresh config is not valid JSON: %v\n%s", err, out) + } +} + +func TestInsertOpenCodePluginIsIdempotent(t *testing.T) { + existing := "{\n \"plugins\": [\n \"/p/opencode-plugin\"\n ]\n}\n" + out, changed, err := insertOpenCodePlugin(existing, `"/p/opencode-plugin"`, "/p/opencode-plugin") + if err != nil { + t.Fatal(err) + } + if changed { + t.Fatal("a config that already names the plugin must not change") + } + if out != existing { + t.Fatalf("content changed:\n%s", out) + } +} + +func TestInsertOpenCodePluginKeepsExistingEntriesAndComments(t *testing.T) { + existing := "{\n // my notes\n \"plugins\": [\"./other\"],\n \"model\": \"x\"\n}\n" + out, changed, err := insertOpenCodePlugin(existing, `"/p/opencode-plugin"`, "/p/opencode-plugin") + if err != nil || !changed { + t.Fatalf("insert = (%v, %v), want (true, nil)", changed, err) + } + for _, want := range []string{"// my notes", `"./other"`, "/p/opencode-plugin", `"model"`} { + if !strings.Contains(out, want) { + t.Fatalf("lost %q:\n%s", want, out) + } + } + // The new entry must be separated from the one already there. + if strings.Contains(out, `"/p/opencode-plugin""./other"`) { + t.Fatalf("entries were not comma separated:\n%s", out) + } +} + +func TestInsertOpenCodePluginKeepsASingleLineArrayOnOneLine(t *testing.T) { + existing := "{\n \"plugins\": [\"./other\"],\n \"model\": \"x\"\n}\n" + out, changed, err := insertOpenCodePlugin(existing, `"/p/opencode-plugin"`, "/p/opencode-plugin") + if err != nil || !changed { + t.Fatalf("insert = (%v, %v), want (true, nil)", changed, err) + } + if want := " \"plugins\": [\"/p/opencode-plugin\", \"./other\"],"; !strings.Contains(out, want) { + t.Fatalf("a one-line array must stay on one line:\n%s", out) + } +} + +func TestInsertOpenCodePluginIndentsIntoAMultiLineArray(t *testing.T) { + existing := "{\n \"plugins\": [\n \"./other\"\n ]\n}\n" + out, changed, err := insertOpenCodePlugin(existing, `"/p/opencode-plugin"`, "/p/opencode-plugin") + if err != nil || !changed { + t.Fatalf("insert = (%v, %v), want (true, nil)", changed, err) + } + if want := "\n \"/p/opencode-plugin\",\n \"./other\"\n"; !strings.Contains(out, want) { + t.Fatalf("a multi-line array must keep one entry per line:\n%s", out) + } +} + +func TestInsertOpenCodePluginAddsKeyWhenAbsent(t *testing.T) { + out, changed, err := insertOpenCodePlugin("{\n \"model\": \"x\"\n}\n", `"/p/opencode-plugin"`, "/p/opencode-plugin") + if err != nil || !changed { + t.Fatalf("insert = (%v, %v), want (true, nil)", changed, err) + } + var parsed struct { + Model string `json:"model"` + Plugins []string `json:"plugins"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("result is not valid JSON: %v\n%s", err, out) + } + if parsed.Model != "x" { + t.Fatalf("existing key lost: %#v", parsed) + } + if len(parsed.Plugins) != 1 || parsed.Plugins[0] != "/p/opencode-plugin" { + t.Fatalf("plugins = %#v", parsed.Plugins) + } +} + +func TestInsertOpenCodePluginIntoEmptyArray(t *testing.T) { + out, changed, err := insertOpenCodePlugin("{\n \"plugins\": []\n}\n", `"/p/opencode-plugin"`, "/p/opencode-plugin") + if err != nil || !changed { + t.Fatalf("insert = (%v, %v), want (true, nil)", changed, err) + } + var parsed struct { + Plugins []string `json:"plugins"` + } + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("result is not valid JSON: %v\n%s", err, out) + } + if len(parsed.Plugins) != 1 { + t.Fatalf("plugins = %#v", parsed.Plugins) + } +} + +func TestInsertOpenCodePluginIgnoresThePluginsWordInProse(t *testing.T) { + // "plugins" appears in a comment but is not a key; the entry must still + // land in a real plugins array rather than be spliced into the comment. + existing := "{\n // plugins are configured below\n \"model\": \"x\"\n}\n" + out, changed, err := insertOpenCodePlugin(existing, `"/p/opencode-plugin"`, "/p/opencode-plugin") + if err != nil || !changed { + t.Fatalf("insert = (%v, %v), want (true, nil)", changed, err) + } + if !strings.Contains(out, "// plugins are configured below") { + t.Fatalf("comment was damaged:\n%s", out) + } + if !openCodePluginsKey.MatchString(out) { + t.Fatalf("no plugins key was added:\n%s", out) + } +} + +func TestOpenCodeEntryLocalIsAPlainPath(t *testing.T) { + entry, err := openCodeEntry("/p/opencode-plugin", "local", "", "") + if err != nil { + t.Fatal(err) + } + if entry != `"/p/opencode-plugin"` { + t.Fatalf("entry = %s", entry) + } +} + +func TestOpenCodeEntryRemoteCarriesOptions(t *testing.T) { + entry, err := openCodeEntry("/p/opencode-plugin", "remote", "s-2vcpu-2gb", "devbox:1") + if err != nil { + t.Fatal(err) + } + var parsed struct { + Package string `json:"package"` + Options map[string]string `json:"options"` + } + if err := json.Unmarshal([]byte(entry), &parsed); err != nil { + t.Fatalf("entry is not valid JSON: %v\n%s", err, entry) + } + if parsed.Package != "/p/opencode-plugin" { + t.Fatalf("package = %q", parsed.Package) + } + if parsed.Options["mode"] != "remote" || parsed.Options["shape"] != "s-2vcpu-2gb" || parsed.Options["rootfs"] != "devbox:1" { + t.Fatalf("options = %#v", parsed.Options) + } +} + +func TestOpenCodeEntryRemoteOmitsUnsetOptions(t *testing.T) { + entry, err := openCodeEntry("/p/opencode-plugin", "remote", "", "") + if err != nil { + t.Fatal(err) + } + if strings.Contains(entry, "shape") || strings.Contains(entry, "rootfs") { + t.Fatalf("unset options must not be written: %s", entry) + } +} + +func TestDSHNodeOK(t *testing.T) { + cases := map[string]bool{ + "v22.19.0": true, + "v22.20.1": true, + "v24.0.0": true, + "v25.1.0": true, + "22.19": true, + "v22.18.0": false, + "v23.5.0": false, + "v20.11.0": false, + "v22": false, + "": false, + "banana": false, + } + for version, want := range cases { + if got := dshNodeOK(version); got != want { + t.Errorf("dshNodeOK(%q) = %v, want %v", version, got, want) + } + } +} + +func TestSetupAlreadyDoneRecognisesHostPhrasing(t *testing.T) { + for _, out := range []string{ + "Error: marketplace 'createos' already exists", + "plugin already installed", + "ALREADY ADDED", + } { + if !setupAlreadyDone(out) { + t.Errorf("setupAlreadyDone(%q) = false, want true", out) + } + } + for _, out := range []string{"network unreachable", "permission denied", ""} { + if setupAlreadyDone(out) { + t.Errorf("setupAlreadyDone(%q) = true, want false", out) + } + } +} + +func TestSetupCommandCoversEveryDocumentedIntegration(t *testing.T) { + // The subcommand names are the contract with + // https://createos.sh/docs/Sandbox/Integrations — a host listed there + // without a setup is the gap this test exists to catch. + want := []string{ + "claude-code", + "codex", + "deepseek", + "herdr", + "opencode", + "orca", + "pi", + } + got := map[string]bool{} + for _, sub := range newSetupCommand().Subcommands { + got[sub.Name] = true + } + for _, name := range want { + if !got[name] { + t.Errorf("createos sandbox setup %s is missing", name) + } + } + if len(got) != len(want) { + t.Errorf("setup has %d subcommands, want %d", len(got), len(want)) + } +}