Skip to content
Merged
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
26 changes: 26 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,32 @@ When adding a new command:
2. Register it in the group's `NewXxxCommand()` subcommands slice
3. Add it to the manual list in `root.go` Action (the home screen) in alphabetical order

## Harness integrations (`sandbox setup`)

`createos sandbox setup <harness>` wires an editor so its workspaces run on a
sandbox instead of the user's machine. `cmd/sandbox/orca.go` is the reference
implementation.

These commands have two halves in one binary, and both must keep working:

- the **human** half (`--doctor`, plain invocation) checks prerequisites and
prints install steps;
- the **machine** half (`--recipe`, hidden) is what the editor itself runs, once
per lifecycle phase, selected by an env var such as `ORCA_VM_MODE`.

The machine half prints one JSON object on stdout and everything else on
stderr. Anything written to stdout that is not that object breaks the editor's
parse, so use the package's `orcaLog` style helper rather than `fmt.Println`.

Two constraints worth knowing before changing one:

- The command string is duplicated in the editor's plugin, in a separate repo
(`createos-plugins`). Renaming or moving the command breaks installed plugins
and both sides must change together.
- Provisioning done through the exec API is **not** visible over SSH. The two do
not share a mount namespace outside `/workspace`, so anything an integration
installs for the editor to use must go over SSH.

## API Client

### Response shapes
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ asks for a few more characters rather than guessing.
| `createos sandbox tunnel` | Forward a local port to a port 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 |

**`sandbox create` flags:**

Expand All @@ -340,6 +341,26 @@ 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.

```bash
createos sandbox setup orca --doctor # check prerequisites, change nothing
createos sandbox setup orca # print the plugin install steps
```

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`.

**When to use `exec`, `shell`, `process`, and PTY:**

Use `sandbox exec` for quick non-interactive one-shot commands. Use `sandbox shell` when you want an immediate interactive terminal and do not need to reconnect later. Use `sandbox process` when the command should be manageable after it starts — list it, reconnect to output, send input, wait for it, signal it, or stop it. Add `--pty`/`--tty`/`-t` to `process run` or `process start` when the managed command needs terminal behavior.
Expand Down
46 changes: 42 additions & 4 deletions cmd/sandbox/editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,8 +281,8 @@ func runEditor(c *cli.Context) error {
sp, _ = pterm.DefaultSpinner.WithText("Waiting for sshd to accept connections…").Start() //nolint:errcheck // spinner init failure is benign UI-only
probeCtx, cancel := context.WithTimeout(c.Context, 15*time.Second)
defer cancel()
if probeErr := probeSSH(probeCtx, alias); probeErr != nil {
sp.Warning("sshd didn't answer in 15 s — connection may still work; try `ssh " + alias + "` yourself.")
if probeErr := probeSSH(probeCtx, alias, 30*time.Second); probeErr != nil {
sp.Warning("sshd didn't answer in 30 s — connection may still work; try `ssh " + alias + "` yourself.")
} else {
sp.Success("sshd is answering")
}
Expand Down Expand Up @@ -388,6 +388,29 @@ func keysDir() (string, error) {
return filepath.Join(home, ".config", "createos", "keys"), nil
}

// ensureMuxDir makes the directory ControlPath's %n-keyed sockets live in.
//
// Every tunnel-mode sandbox shares HostName 127.0.0.1 + User root, and VPN
// mode reuses an overlay IP once its old owner is destroyed — so a
// ControlPath built from %h/%r/%p (a common personal ~/.ssh/config default)
// collides across sandboxes. `ssh <alias>` then reuses another sandbox's
// stale multiplexed connection instead of opening one to the box actually
// asked for, and hangs or times out against a box that no longer exists.
// %n is the alias itself — the one token guaranteed unique per sandbox,
// which is why renderSSHBlock pins ControlPath here instead of trusting
// whatever the user's own ssh config already has for `Host *`.
func ensureMuxDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("resolve $HOME: %w", err)
}
dir := filepath.Join(home, ".config", "createos", "mux")
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", fmt.Errorf("mkdir mux dir: %w", err)
}
return dir, nil
}

func dedicatedKeyPath(alias string) (string, string, error) {
dir, err := keysDir()
if err != nil {
Expand Down Expand Up @@ -482,6 +505,11 @@ func hostLine(alias, name string) string {
// overlay IP, which is also recycled between sandboxes, so it needs the same
// pin.
func renderSSHBlock(alias, mode, sandboxID, sbIP, gwHost string, gwPort int, user, identity, name string) (string, error) {
// The directory the %n-keyed ControlPath below lands sockets in — ssh
// creates the socket file itself but not its parent directory.
if _, err := ensureMuxDir(); err != nil {
return "", err
}
begin := fmt.Sprintf(sshConfigBlockBegin, alias)
end := fmt.Sprintf(sshConfigBlockEnd, alias)
host := hostLine(alias, name)
Expand All @@ -496,13 +524,22 @@ Host %s
IdentityFile %s
StrictHostKeyChecking accept-new
UserKnownHostsFile ~/.ssh/known_hosts_createos
ControlPath ~/.config/createos/mux/%%n
%s
`, begin, host, sbIP, sandboxID, user, identity, end), nil
case "tunnel":
// The inner `ssh -W` for the gateway needs its own
// StrictHostKeyChecking + UserKnownHostsFile — it doesn't inherit
// the outer Host block's options. Without these, the first
// connect fails on unknown gateway host key.
//
// ControlPath must be pinned the same way HostKeyAlias is: every
// tunnel-mode sandbox shares HostName 127.0.0.1 + User root, so a
// ControlPath built from %h/%r/%p — including a common personal
// `Host *` default — collides across every sandbox. ssh then reuses
// another sandbox's stale multiplexed connection instead of opening
// one to the box actually asked for. %n (the alias) is the one
// token guaranteed unique per sandbox.
return fmt.Sprintf(`%s
Host %s
HostName 127.0.0.1
Expand All @@ -513,6 +550,7 @@ Host %s
ProxyCommand ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=~/.ssh/known_hosts_createos -W %%h:%%p %s@%s -p %d -i %s
StrictHostKeyChecking accept-new
UserKnownHostsFile ~/.ssh/known_hosts_createos
ControlPath ~/.config/createos/mux/%%n
%s
`, begin, host, sandboxID, user, identity, sandboxID, gwHost, gwPort, identity, end), nil
default:
Expand Down Expand Up @@ -748,8 +786,8 @@ fi
// verify the config is parseable, then attempts a 1-second TCP probe by
// running `ssh -o BatchMode=yes -o ConnectTimeout=3 <alias> true`. Any
// non-nil error signals the caller to warn but not fail.
func probeSSH(ctx context.Context, alias string) error {
deadline, cancel := context.WithTimeout(ctx, 15*time.Second)
func probeSSH(ctx context.Context, alias string, wait time.Duration) error {
deadline, cancel := context.WithTimeout(ctx, wait)
defer cancel()
last := fmt.Errorf("no attempt")
for deadline.Err() == nil {
Expand Down
Loading