Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 38 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>` to use your own checkout instead. The
OpenCode setup also adds the plugin to your OpenCode config, backing the file
up to `<config>.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:**

Expand Down
12 changes: 0 additions & 12 deletions cmd/sandbox/orca.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
193 changes: 193 additions & 0 deletions cmd/sandbox/setup.go
Original file line number Diff line number Diff line change
@@ -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.")
}
Loading