Skip to content

feat(sandbox): add offload, matrix and self commands with safety guards - #84

Open
pratikbin wants to merge 3 commits into
mainfrom
feat/sandbox-compositions
Open

feat(sandbox): add offload, matrix and self commands with safety guards#84
pratikbin wants to merge 3 commits into
mainfrom
feat/sandbox-compositions

Conversation

@pratikbin

Copy link
Copy Markdown
Contributor

Summary

Three new capabilities for driving a fleet of sandboxes from the CLI:

  • sandbox offload <dir> -- <cmd> — stage a directory, run one command, fetch results, destroy the sandbox. .gitignore-aware staging, --egress-preset, --fetch, --keep-on-fail.
  • sandbox matrix <dir> — build one golden sandbox, run --prepare once, fork it N times, run one job per clone in parallel, report per-job exit codes. --from reuses an existing paused sandbox instead of building one.
  • sandbox self pause|delete — a workload ends its own sandbox from the inside, over the loopback-only guest agent (127.0.0.1:1029). No credential needed or present.
  • sandbox fork --count N — direct N-way cloning of one snapshot.

Fixes found and verified along the way

Each of these was confirmed against a live account, not just unit-tested:

  • pause/resume failing with "Content-Length is required"lifecyclePOST sent a body-less POST; control's forwarder strips the client's Content-Length header, and a nil body makes Go emit none at all. fork always had a body and never hit this. Now sends {}.
  • No retry on connection-setup failures — a single dial timeout killed a whole fan-out. Added a retry that fires on connection-setup errors for any method, and on 429/5xx only for read-only methods (a retried POST could create a second billable sandbox).
  • Fork raced pausefork issued right after pause hit "sandbox is running, expected paused or error" because pause is async. Now waits for paused first.
  • Fork does not inherit shape or auto-pause — measured live: a s-1vcpu-1gb / auto_pause=900 golden box produced a s-4vcpu-4gb / auto_pause=None clone. matrix now re-applies the auto-pause backstop to every clone.
  • Path-traversal in fetched-file extraction — the original lexical prefix check validated the constructed pathname but not symlinks already present on the caller's disk (coverage -> /etc + entry coverage/passwd escapes it). Replaced with os.Root, which resolves through an open directory descriptor and cannot be defeated by a symlink.
  • Billable sandbox leaked on readiness-poll failureCreateSandbox/ForkSandbox succeeding then waitForStatus failing left an untracked, running sandbox. Both paths now destroy the sandbox and name its id in the error.
  • offload exited 0 after a failed teardown — a CI job reading only the exit code would call this a clean run while a sandbox kept billing. Teardown failure now fails the command.
  • fork silently paused a running sourcecreateos sandbox fork my-live-server would pause whatever it was serving with no prompt. Now refused; only matrix's own golden box (which it created) is paused automatically.
  • Writing into an S3 disk mount via the file API crashes the mount and loses the object (issue chore(deps): bump golang.org/x/mod from 0.38.0 to 0.39.0 #71). push/pull now refuse this, and fail closed (refuse, not allow) if the disk list itself cannot be read.
  • Fork drops the source's disk attachments (issue chore(deps): bump golang.org/x/crypto from 0.53.0 to 0.54.0 #63) — now warned before forking.
  • -o json after a subcommand silently failing ("flag provided but not defined") — OnUsageError is not inherited from the app in urfave/cli v2, so it has to be set on every command node. Fixed, and the error now shows the corrected command line. Unknown (sub)commands now suggest the nearest real one via edit distance.

Test plan

  • gofmt -l clean, golangci-lint run ./... 0 issues, go test ./... passes
  • Live: offload end to end (staged 4 files / 5.0 kB, excluded a 200 kB node_modules via .gitignore, 2.1s total)
  • Live: matrix — 3 forked clones, correct per-job exit codes (0, 0, 3), --prepare output identical (same nanosecond timestamp + PID) across all three clones, auto-pause backstop confirmed on every clone
  • Live: sandbox self pause from inside a real sandbox with ls ~/.createos → no such file (zero credential on disk), sandbox verified paused afterward
  • Live: sandbox fork on a running sandbox refuses and leaves it running
  • Regression tests for every fix above, each confirmed to fail against the pre-fix code (named-return leak, symlink escape, fork-pauses-running-source, disk-guard-fails-open)
  • No sandboxes leaked across any of the above — verified against account sandbox count before/after

Introduce `sandbox offload` and `sandbox matrix` subcommands for one‑shot offload and parallel fork workflows. Add `sandbox self` for in‑sandbox signaling. Implement guards, retry logic, and improved error handling for lifecycle, connection setup, and disk operations.
fork --count was checked after resolving the source ref, so an invalid
count still paid for an API lookup before failing. Move the check to
the top of runFork.

Unknown command names (root or nested) printed a suggestion but
exited 0: urfave's CommandNotFoundFunc has no return value, so
ShowCommandHelp always returns nil after calling it. Root's own
custom Action also never reached that path at all, since urfave only
falls back to CommandNotFoundFunc through its default help Action.
Detect the unresolved name directly in each command's own Action
instead, and return a real error so app.Run (and main.go's exit code)
see the failure.

Claude-Session: https://claude.ai/code/session_019J9PwFePTzAhpPs1TY9ZGq
Go's stdlib flag parsing stops at the first positional argument, so a command like `fork <sandbox> --count 2` left `--count` unparsed and defaulted to 1. Added `forkCountFlag` that falls back to scanning raw os.Args, restoring the intended count.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant