From b04ee4fb361dd4f35346ccc47d093b0ee7e04c02 Mon Sep 17 00:00:00 2001 From: pratikbin <68642400+pratikbin@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:41:57 +0530 Subject: [PATCH 1/3] feat(sandbox): thin cos onto createos-cli's offload/matrix/fork --count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/cos's offload and fanout bodies (stage, push, exec, keepalive, pull, destroy — one copy per verb) are replaced with thin delegates to `createos sandbox offload` and `createos sandbox matrix`, which already do this composition in Go with retry, no billable leaks on failure, and a teardown failure that fails the command instead of hiding it. - `cos offload` -> `createos sandbox offload -- ` - `cos fanout` -> `createos sandbox matrix --job ... --job ...` (no --prepare) — a fork of one staged box per job, not an independently-staged box per job. Same isolation, faster. - new `cos matrix` / `/createos-sandbox:matrix` — fanout plus -P '' to run once on the golden box before forking, for the case every job shares the same dependency install or toolchain prep. - `cos fork` keeps its own pause-then-resume orchestration around the project box (legitimate: it is forking a box it owns, on request), but the fork step itself and id resolution now delegate to `createos sandbox fork -o json`, which returns the new id directly. This deletes the comm-diff-against-`sandbox ls` hack and the documented multibyte/set -u trap that came with it. Gained -c N for direct N-way cloning of the project box. - -w/--swap dropped from offload (per discussion): devbox:1 can't swapon a file added post-boot anyway, so the flag bought little. Documented as something to compose into the command string instead. Docs updated throughout (skill, references, command frontmatter, README) for the new/changed verbs and to correct a stale "2 concurrent boxes" quota claim to the observed number (10). Verified live via tmux against createos-cli's feat/sandbox-compositions branch (COS_CLI override), running five scenarios pulled from the product's own use-case catalog: untrusted-code offload with locked egress, independent-job fanout, shared-setup matrix (-P ran exactly once across all forks), project-box fork -c N, and a background job ending its own box via the loopback self-signal endpoint. That live run caught a real bug in createos-cli: `fork --count 2` silently created one clone instead of two, with no error — Go's stdlib flag parser stops at the first non-flag argument, so --count written after the sandbox id (the order every caller actually writes, cos included) was never parsed at all. Fixed on the createos-cli side (feat/sandbox-compositions, reusing the raw-argv fallback already built for `process run --cwd`) and confirmed fixed here before writing this up. --- .../.claude-plugin/plugin.json | 4 +- packages/claude-code-plugin/README.md | 72 ++-- .../claude-code-plugin/commands/cluster.md | 2 +- .../claude-code-plugin/commands/fanout.md | 4 +- packages/claude-code-plugin/commands/fork.md | 9 +- .../claude-code-plugin/commands/matrix.md | 11 + .../claude-code-plugin/commands/offload.md | 6 +- packages/claude-code-plugin/scripts/cos | 324 +++++++++--------- .../skills/using-createos-sandbox/SKILL.md | 23 +- .../references/lifecycle-and-images.md | 19 +- .../references/offload-and-egress.md | 25 +- 11 files changed, 290 insertions(+), 209 deletions(-) create mode 100644 packages/claude-code-plugin/commands/matrix.md diff --git a/packages/claude-code-plugin/.claude-plugin/plugin.json b/packages/claude-code-plugin/.claude-plugin/plugin.json index 762adbe..41fa76b 100644 --- a/packages/claude-code-plugin/.claude-plugin/plugin.json +++ b/packages/claude-code-plugin/.claude-plugin/plugin.json @@ -2,8 +2,8 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "@createos/claude-code", "displayName": "CreateOS Sandbox", - "version": "0.6.0", - "description": "Run ad-hoc, heavy, or untrusted code OFF your machine in disposable CreateOS Sandboxes. One-shot offload, parallel fanout across N boxes, instant scratch shell, a reusable box with file sync for live dev loops, port tunnel to localhost, public HTTPS expose, multi-box private-network clusters, BYO-S3 disk mounts, WireGuard VPN, snapshot/fork, pause/resume to park a warm box at zero compute cost, and custom Dockerfile-built images so boxes boot pre-provisioned. Big dirs auto-excluded from uploads; auto-installs the createos CLI if missing.", + "version": "0.7.0", + "description": "Run ad-hoc, heavy, or untrusted code OFF your machine in disposable CreateOS Sandboxes. One-shot offload, parallel fanout across N forks of one staged box, matrix (shared-setup fanout), instant scratch shell, a reusable box with file sync for live dev loops, port tunnel to localhost, public HTTPS expose, multi-box private-network clusters, BYO-S3 disk mounts, WireGuard VPN, snapshot/fork (N at once), pause/resume to park a warm box at zero compute cost, and custom Dockerfile-built images so boxes boot pre-provisioned. Big dirs auto-excluded from uploads; auto-installs the createos CLI if missing.", "author": { "name": "NodeOps", "url": "https://createos.sh" }, "homepage": "https://createos.sh", "keywords": ["sandbox", "createos", "firecracker", "remote-exec", "isolation", "microvm"] diff --git a/packages/claude-code-plugin/README.md b/packages/claude-code-plugin/README.md index 3af0685..18ce16f 100644 --- a/packages/claude-code-plugin/README.md +++ b/packages/claude-code-plugin/README.md @@ -4,11 +4,11 @@ **Run ad-hoc, heavy, or untrusted code off your machine — from inside Claude Code.** -A [Claude Code](https://docs.claude.com/en/docs/claude-code) plugin that gives Claude a skill + 18 slash commands driving the authed [`createos`](https://createos.sh) CLI. Work runs in disposable [CreateOS](https://createos.sh) Sandboxes — roughly 200 ms from create to your first command — that self-destruct when done. +A [Claude Code](https://docs.claude.com/en/docs/claude-code) plugin that gives Claude a skill + 19 slash commands driving the authed [`createos`](https://createos.sh) CLI. Work runs in disposable [CreateOS](https://createos.sh) Sandboxes — roughly 200 ms from create to your first command — that self-destruct when done. [![Claude Code](https://img.shields.io/badge/Claude%20Code-plugin-6E56CF)](https://docs.claude.com/en/docs/claude-code) [![CreateOS](https://img.shields.io/badge/CreateOS-Sandboxes-0EA5E9)](https://createos.sh) -[![Version](https://img.shields.io/badge/version-0.5.0-blue)](./.claude-plugin/plugin.json) +[![Version](https://img.shields.io/badge/version-0.7.0-blue)](./.claude-plugin/plugin.json) @@ -24,6 +24,7 @@ A [Claude Code](https://docs.claude.com/en/docs/claude-code) plugin that gives C - [Command reference](#command-reference) - [Offload — one-shot](#offload--one-shot) - [Fanout — parallel boxes](#fanout--parallel-boxes) + - [Matrix — shared setup, parallel boxes](#matrix--shared-setup-parallel-boxes) - [Shell — throwaway Linux](#shell--throwaway-linux) - [Project box — live sessions](#project-box--live-sessions) - [Networking](#networking) @@ -69,7 +70,7 @@ The plugin is a **thin Claude-facing surface** over the `createos` CLI. It ships | Piece | Path | Role | | ------------------ | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| **Slash commands** | `commands/*.md` | 18 commands (`offload`, `fanout`, `shell`, …), each a thin wrapper that calls `scripts/cos` | +| **Slash commands** | `commands/*.md` | 19 commands (`offload`, `fanout`, `matrix`, `shell`, …), each a thin wrapper that calls `scripts/cos` | | **Skill** | `skills/using-createos-sandbox/SKILL.md` + `references/` | teaches Claude _when_ to reach for the sandbox on its own, with depth loaded on demand | | **Hooks** | `hooks/hooks.json` + `scripts/` | `SessionStart` publishes the driver's absolute path; `PreToolUse(Bash)` nudges on heavy build/test commands | | **Driver** | `scripts/cos` | the actual logic — staging, egress, keepalive, sync, networking, lifecycle, state | @@ -123,8 +124,9 @@ claude --plugin-dir /path/to/createos-claude-plugins/createos-sandbox | Command | Summary | | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | -| [`offload`](#offload--one-shot) `[flags] ` | one-shot: stage → run (keepalive) → pull → destroy | -| [`fanout`](#fanout--parallel-boxes) `[-j N] [flags] [cmd2] …` | run each command in its own throwaway box, in parallel | +| [`offload`](#offload--one-shot) `[flags] ` | one-shot: stage → run (keepalive) → fetch → destroy | +| [`fanout`](#fanout--parallel-boxes) `[-j N] [flags] [cmd2] …` | fork one staged box once per command, run each in parallel | +| [`matrix`](#matrix--shared-setup-parallel-boxes) `[-P setup] [-F box] [-j N] [flags] …` | fanout, but a setup command runs once before forking | | [`shell`](#shell--throwaway-linux) `[-s] [-r] [-e\|-p\|-E]` | instant throwaway interactive Linux (destroyed on exit) | | [`up`](#project-box--live-sessions) `[-s] [-r] [-n] [-e\|-p\|-E]` | create/reuse the per-repo project box | | [`run`](#project-box--live-sessions) `` | exec in the project box (streamed, state persists) | @@ -135,28 +137,27 @@ claude --plugin-dir /path/to/createos-claude-plugins/createos-sandbox | [`cluster`](#networking) `up \| run […] \| ls \| down` | N boxes on one private network, name-addressable | | [`disk`](#disks--byo-s3) `create \| ls \| show \| attach \| detach \| rm` | BYO S3 bucket mounts on the project box | | [`vpn`](#networking) `[register \| up]` | WireGuard L3 into your private networks | -| [`fork`](#networking) | snapshot the project box → independent clone | +| [`fork`](#networking) `[-c N]` | snapshot the project box → N independent clone(s) | | [`pause`](#pause-resume-and-custom-images) · [`resume`](#pause-resume-and-custom-images) | park the warm project box at zero compute cost / restore it exactly | | [`template`](#pause-resume-and-custom-images) `submit [-f Dockerfile] \| ls \| show \| logs \| rm` | build a custom rootfs so boxes boot pre-provisioned | | [`down`](#project-box--live-sessions) | stop sync/tunnels + destroy the project box (+ cluster) | | [`status`](#project-box--live-sessions) | show active box + sync + tunnels + cluster | -> **Flag order:** flags (`-s/-r/-e/-p/-E/-o/-x/-w/-K`) come **before** the positional ` `. +> **Flag order:** flags (`-s/-r/-e/-p/-E/-o/-x/-K`) come **before** the positional ` `. ### Offload — one-shot -The core command. Stages a directory into a fresh box, runs a command, optionally pulls artifacts back, then destroys the box. +The core command. Stages a directory into a fresh box, runs a command, optionally fetches artifacts back, then destroys the box. ``` -/createos-sandbox:offload [-p preset] [-e dom] [-E] [-x glob] [-o out] [-w GB] [-K] [-s shape] [-r rootfs] +/createos-sandbox:offload [-p preset] [-e dom] [-E] [-x glob] [-o out] [-K] [-s shape] [-r rootfs] ``` | Flag | Meaning | | ------------- | ------------------------------------------------------------------------------------------- | | `-s ` | box size (default `s-1vcpu-1gb`; see [Shapes](#shapes)) | | `-r ` | root filesystem image (default `devbox:1`) | -| `-o ` | tar a box-side dir and pull it back to local | -| `-w ` | attempt swap (see caveat under [Heavy builds](#heavy-builds)) | +| `-o ` | tar a box-side dir and fetch it back to local | | `-K` | keep the box on a real failure so you can inspect it | | `-e ` | allow one egress domain (repeatable) | | `-p ` | egress preset — `python-uv \| rust-cargo \| npm \| github` (repeatable, composes with `-e`) | @@ -167,27 +168,46 @@ The core command. Stages a directory into a fresh box, runs a command, optionall # Run a test suite, pull nothing, box auto-destroys /createos-sandbox:offload . "npm ci && npm test" -# Python build with locked egress, pull the dist/ folder back +# Python build with locked egress, fetch the dist/ folder back /createos-sandbox:offload -p python-uv -o dist . "uv sync --frozen && uv run python -m build" ``` -**Keepalive:** long or quiet compiles no longer die to exec-stream idle resets — the command runs detached with a heartbeat and re-attaches if the stream drops, so the build (and its cache) survives. +**Keepalive:** long or quiet compiles no longer die to exec-stream idle resets — the command runs detached with a heartbeat and re-attaches if the stream drops, so the build (and its cache) survives. **Swap:** there is no flag for it — compose it into the command itself: `'fallocate -l 4G /swapfile && mkswap /swapfile && swapon /swapfile && '`. ### Fanout — parallel boxes -Run each command in its **own** throwaway box, in parallel, then collect results. Ideal for splitting a test suite or a version matrix. +Stage `` once, fork it once per command, run each fork in parallel, then collect results. Ideal for splitting a test suite or a version matrix with no shared setup step. ``` /createos-sandbox:fanout [-j N] [-p preset] [-s shape] [-r rootfs] [-e dom] [-E] [-x glob] [cmd2] … ``` -- `-j N` — max concurrent boxes (**default 2**, the external-key running-quota). Other flags mirror `offload`. -- Exit codes are aggregated across boxes; each box is fully isolated from the others. +- `-j N` — max concurrent forks (**default 10**, matching this account's observed running quota). Other flags mirror `offload`. +- Exit codes are aggregated across jobs; each fork is fully isolated from the others. ```bash -/createos-sandbox:fanout -j 2 -p python-uv . "pytest -q tests/a" "pytest -q tests/b" "pytest -q tests/c" +/createos-sandbox:fanout -p python-uv . "pytest -q tests/a" "pytest -q tests/b" "pytest -q tests/c" ``` +### Matrix — shared setup, parallel boxes + +`fanout`, plus one thing: `-P ''` runs once on the golden box before it forks. Reach for this the moment two or more jobs would otherwise repeat the same install or toolchain prep. + +``` +/createos-sandbox:matrix [-P setup] [-F box] [-j N] [-p preset] [-s shape] [-r rootfs] [-e dom] [-E] [-x glob] [cmd2] … +``` + +- `-P ''` — run once on the golden box before forking. Paid once, not once per job. +- `-F ` — fork from a sandbox you already prepared and paused yourself, instead of building one from ``. +- `-G` — keep the golden box after the run instead of destroying it. +- `-L ` — directory for per-job log files. + +```bash +/createos-sandbox:matrix -P "npm ci" . "npm test -- --shard 1" "npm test -- --shard 2" "npm test -- --shard 3" +``` + +Two known limits, both from `createos sandbox matrix --help`: a fork does not carry the golden box's S3 disk attachments, and a clone whose snapshot isn't cached on the target host can take 11–13 s to resume rather than the usual sub-second. + ### Shell — throwaway Linux An instant, interactive Linux box that is **destroyed on exit**. @@ -232,9 +252,9 @@ A **reusable, per-repo** box addressed by your working directory. `up` creates i | **`tunnel [local]`** | Reach a box-side service on your laptop. Run a dev server in the box, then `tunnel 3000` → `http://127.0.0.1:3000`. Private, background, no public URL. Stopped by `down`. | | **`expose `** | A **public HTTPS** link for a port — `-.app.sb.createos.sh`, stable for the box's lifetime. The service must bind `0.0.0.0`. **Anyone with the link can reach it.** Revoke with `unexpose`. | | **`unexpose`** | Revoke the public URL / disable ingress on the active box. | -| **`cluster up `** | N boxes on one private network, reaching each other by **fully-qualified** name (`curl http://cos-cl--2.fc.local:8080` — the bare short name is NXDOMAIN). `cluster run -a ''` fans a command across all; `cluster run ''` targets one. `cluster ls` / `cluster down` manage them. For distributed-system / DB-replication / p2p / load-test repros. **Counts against quota — keep N small.** | +| **`cluster up `** | N boxes on one private network, reaching each other by **fully-qualified** name (`curl http://cos-cl--2.fc.local:8080` — the bare short name is NXDOMAIN). `cluster run -a ''` fans a command across all; `cluster run ''` targets one. `cluster ls` / `cluster down` manage them. For distributed-system / DB-replication / p2p / load-test repros. **Counts against the running quota — keep N small.** | | **`vpn register `** then **`vpn up`** | Join your laptop to the whole private network over WireGuard (reach every sandbox by name/IP). `vpn up` needs `wg-quick` + `sudo` and **blocks until Ctrl-C** — run it in your own terminal (`!cos vpn up`). | -| **`fork`** | Snapshot the warm project box → an independent clone for matrix/parallel experiments. The fork is self-managed. | +| **`fork [-c N]`** | Snapshot the warm project box → N independent clones (default 1) for branching experiments. Each fork is self-managed. Running the same job N ways from a directory is usually a better fit for `matrix`. | ### Pause, resume, and custom images @@ -308,13 +328,13 @@ So a domain allowlist is the right control for "this build should only reach pyp - **Egress:** default unrestricted; use `-p`/`-e` to lock it down (see above). - **Keepalive:** long/quiet compiles run detached with a heartbeat and survive stream drops. `-K` keeps the box on a real failure for inspection. - **Excludes:** `.git`/`target`/`node_modules`/`__pycache__`/`.venv`/media are excluded from the upload by default; `-x ` adds more. -- **Swap caveat:** `-w ` _attempts_ swap, but `devbox:1` can't `swapon` today — so a torch/maturin build on a small box may OOM/ENOSPC. Install only the extra/group you need (e.g. `uv sync --group dev`) rather than `--all-extras`. +- **Swap:** no dedicated flag — `devbox:1` can't `swapon` a file added after boot from most shapes anyway, so compose it into the command instead: `'fallocate -l 4G /swapfile && mkswap /swapfile && swapon /swapfile && '`. A torch/maturin build on a small box is still more reliably fixed with a bigger shape; install only the extra/group you need (e.g. `uv sync --group dev`) rather than `--all-extras`. - **Shape rejection:** a shape your account can't use fails with a clean `Allowed: [...]` list — pick from it. - **Bandwidth:** each box starts with a fixed allowance for traffic it initiates (5 GiB by default). A large model download or `docker pull` can exhaust it, after which outbound traffic stops. Top it up additively via `createos sandbox edit `; the exec, file-transfer, and tunnel channels stay reachable regardless. ## Uploads & excludes -Uploads (`offload`/`fanout`) and one-way `sync` skip big/regenerable dirs by default so you don't ship `node_modules` over the wire: +Uploads (`offload`/`fanout`/`matrix`) and one-way `sync` skip big/regenerable dirs by default so you don't ship `node_modules` over the wire: ``` .git target node_modules __pycache__ .venv .mypy_cache .pytest_cache @@ -355,14 +375,15 @@ cos install # symlink onto PATH (once); create cos offload -p python-uv . 'uv sync --frozen --group dev && uv run pytest -q' cos offload -p python-uv -p rust-cargo -x target -o dist . 'uv sync --frozen && uv run pytest -q' cos up && cos run 'npm ci' && cos sync ~/app /work # reusable box + one-way sync -cos fanout -j 2 -p python-uv . 'pytest tests/a' 'pytest tests/b' # parallel, isolated boxes +cos fanout -p python-uv . 'pytest tests/a' 'pytest tests/b' # parallel, isolated forks +cos matrix -P 'npm ci' . 'npm test -- a' 'npm test -- b' # shared setup, then fork cos shell # instant throwaway Linux (destroyed on exit) cos run 'npm run dev &' && cos tunnel 3000 # dev server → http://127.0.0.1:3000 cos expose 8080 # public HTTPS URL for port 8080 cos cluster up 3 && cos cluster run -a 'hostname' # 3 boxes, one private net cos disk create data --bucket my-b --endpoint https://s3.amazonaws.com --access-key … --secret-key … cos disk attach data /mnt/data # mount S3 into the project box -cos fork # snapshot → independent clone +cos fork -c 2 # snapshot → 2 independent clones cos template submit myimage -f Dockerfile # bake a toolchain into a reusable image cos pause # park the warm box at zero compute cost cos resume # bring it back exactly as it was @@ -373,7 +394,7 @@ cos down # stops sync/tunnels, destr - **One-way by default.** `sync` and offload uploads are laptop → box; box-side writes never touch local unless you opt into `-2` (two-way) or pull with `offload -o`. Use `-2` deliberately — never casually on a repo root. `-M` (mirror) additionally **deletes** box-side extras. - **Scoped statefile.** `cos` only ever touches boxes it created (`cos-*`) or the project box recorded in its statefile. Your other sandboxes are never touched. State lives at `${COS_STATE_DIR:-${XDG_CACHE_HOME:-~/.cache}/createos-sandbox}/.json` (plus a `.tunnels` sidecar). -- **Quota.** External keys have been observed to allow 2 boxes running at once, with a daily creation cap. Neither is published policy — treat them as observed behaviour, budget `cluster`/`fanout` against them, and expect excess jobs to queue rather than fail. +- **Quota.** This account has been observed to allow at least 10 boxes running at once, with a daily creation cap. Neither is published policy — treat them as observed behaviour, budget `cluster`/`fanout`/`matrix` against them, and expect excess jobs to queue rather than fail. Check the live number if a decision depends on it. - **Ending a session.** `pause` keeps the box (and its warm state) at zero compute cost; `down` destroys it. Either is fine — leaving a box _running_ is not. - **Untrusted code.** Restrict egress (`-p`/`-e`) so a malicious dependency can't exfiltrate or phone home. @@ -399,7 +420,8 @@ cos down # stops sync/tunnels, destr - **Template build rejected** — `cos template submit` preflights single-stage/no-`COPY`/64 KiB locally; a rejection past that is usually a base image outside the operator allowlist. - **Build can't reach a host** — you restricted egress; add the host with `-e ` or the right `-p `. - **`sync` copied `node_modules` anyway** — your `createos` CLI is old and lacks `--exclude`; upgrade it (`cos` warns when this happens). -- **Build OOM/ENOSPC on a small box** — bump `-s `; `-w` swap doesn't work on `devbox:1`. Install only the deps you need. +- **Build OOM/ENOSPC on a small box** — bump `-s `; there is no swap flag (`devbox:1` can't `swapon` a file added after boot anyway), so add one inside the command itself if you need it. Install only the deps you need. +- **`fork -c N` or a `matrix` clone only produced one sandbox** — an old plugin build; the fix (`--count`/forks after the sandbox id being read correctly) shipped in createos-cli's `feat/sandbox-compositions` branch. Confirm with `createos sandbox fork --help` that `--count` is listed, and update the CLI (`createos upgrade`) if it isn't picking up multiple clones. - **Public `expose` URL returns nothing** — the service must bind `0.0.0.0`, not `127.0.0.1`. --- diff --git a/packages/claude-code-plugin/commands/cluster.md b/packages/claude-code-plugin/commands/cluster.md index 47caafb..b31ce4d 100644 --- a/packages/claude-code-plugin/commands/cluster.md +++ b/packages/claude-code-plugin/commands/cluster.md @@ -4,7 +4,7 @@ argument-hint: "up [-s shape] [-e dom|-p preset|-E] | run [|-a] -2.fc.local:8080` resolves, the bare `cos-cl--2` returns NXDOMAIN. Egress defaults to unrestricted on every member; `-e`/`-p` restrict to an exact set, `-E` to keep it explicitly unrestricted. `run` execs on one member (index `1..N`, its name, or `-a` for all). Clusters count against quota (2 running at once on external keys) — keep N small. +Spin up N sandboxes on one private overlay network. Members reach each other by name — but the name must be **fully qualified**: `curl http://cos-cl--2.fc.local:8080` resolves, the bare `cos-cl--2` returns NXDOMAIN. Egress defaults to unrestricted on every member; `-e`/`-p` restrict to an exact set, `-E` to keep it explicitly unrestricted. `run` execs on one member (index `1..N`, its name, or `-a` for all). Clusters count against the account's running quota (observed around 10 at once) — keep N small. !`"${CLAUDE_PLUGIN_ROOT}/scripts/cos" cluster $ARGUMENTS` diff --git a/packages/claude-code-plugin/commands/fanout.md b/packages/claude-code-plugin/commands/fanout.md index 02309b1..bdeee34 100644 --- a/packages/claude-code-plugin/commands/fanout.md +++ b/packages/claude-code-plugin/commands/fanout.md @@ -4,8 +4,8 @@ argument-hint: "[-j N] [-e dom|-p preset|-E] [-s shape] [-x glob] [ allowed-tools: Bash --- -Fan a set of commands across isolated throwaway boxes concurrently — each `` runs in its own box staged from ``, keepalive-protected. Per-job logs + exit codes are summarized at the end; every box auto-destroys. Default concurrency `-j 2` (external-key quota is 2 running at once) — raise only if your plan allows. Big dirs (`node_modules`/`target`/…) are excluded from the upload. Egress defaults to unrestricted per job; `-e`/`-p` restrict to an exact set, `-E` to keep it explicitly unrestricted. +Fan a set of commands across isolated boxes concurrently — `` is staged once, forked once per ``, keepalive-protected. Per-job logs + exit codes are summarized at the end; every fork auto-destroys. Default concurrency 10, matching this account's observed running quota — raise only if your plan allows. Big dirs (`node_modules`/`target`/…) are excluded from the upload. Egress defaults to unrestricted per job; `-e`/`-p` restrict to an exact set, `-E` to keep it explicitly unrestricted. !`test -n "$ARGUMENTS" && "${CLAUDE_PLUGIN_ROOT}/scripts/cos" fanout $ARGUMENTS || "${CLAUDE_PLUGIN_ROOT}/scripts/cos" fanout` -Report each job's `rc` + log path above. This is distinct from `/createos-sandbox:cluster` (which networks boxes together) — fanout boxes are independent and never see each other. +Report each job's `rc` + log path above. This is distinct from `/createos-sandbox:cluster` (which networks boxes together) — fanout boxes are independent and never see each other. If every job shares the same setup (a dependency install, a toolchain), `/createos-sandbox:matrix` runs that setup once instead of paying it per job. diff --git a/packages/claude-code-plugin/commands/fork.md b/packages/claude-code-plugin/commands/fork.md index 4c079e0..67fa910 100644 --- a/packages/claude-code-plugin/commands/fork.md +++ b/packages/claude-code-plugin/commands/fork.md @@ -1,10 +1,11 @@ --- -description: Snapshot the active CreateOS project box and fork it into an independent clone (pauses briefly, then resumes). For matrix / parallel experiments from identical warm state. +description: Snapshot the active CreateOS project box and fork it into one or more independent clones (pauses briefly, then resumes). For branching experiments from identical warm state. +argument-hint: "[-c N]" allowed-tools: Bash --- -Pause the project box, fork its snapshot into a new independent sandbox (auto-resumes), then resume the project box. The clone starts from identical state and diverges from there. It is **not** tracked as the project box — so `cos down` will not destroy it — but it is recorded in the statefile, listed by `cos status`, and reaped by `cos down -f`. +Pause the project box, fork its snapshot into N new independent sandboxes (`-c N`, default 1; each auto-resumes), then resume the project box. Every clone starts from identical state and diverges from there. Clones are **not** tracked as the project box — so `cos down` will not destroy them — but each is recorded in the statefile, listed by `cos status`, and reaped by `cos down -f`. -!`"${CLAUDE_PLUGIN_ROOT}/scripts/cos" fork` +!`test -n "$ARGUMENTS" && "${CLAUDE_PLUGIN_ROOT}/scripts/cos" fork $ARGUMENTS || "${CLAUDE_PLUGIN_ROOT}/scripts/cos" fork` -Report the fork id above and remind the user to `createos sandbox rm -y ` (or `cos down -f`) when done — clones count against the 2-running quota. +Report the fork id(s) above and remind the user to `createos sandbox rm -y ` (or `cos down -f`) when done — clones count against the account's running quota. Running the SAME job N ways from a directory rather than the project box is usually a better fit for `/createos-sandbox:matrix`. diff --git a/packages/claude-code-plugin/commands/matrix.md b/packages/claude-code-plugin/commands/matrix.md new file mode 100644 index 0000000..e109cf8 --- /dev/null +++ b/packages/claude-code-plugin/commands/matrix.md @@ -0,0 +1,11 @@ +--- +description: Fanout with a shared setup step — build one box, run a command once on it, fork it once per job, run each job on its own clone. For a test-shard matrix that all need the same dependency install. +argument-hint: "[-P setup] [-F box] [-j N] [-e dom|-p preset|-E] [-s shape] [-x glob] [cmd2] ..." +allowed-tools: Bash +--- + +Fanout, plus one thing: `-P ''` runs once on the golden box — the dependency install, the toolchain prep — before it forks once per ``. Reach for this the moment two or more jobs would otherwise repeat the same setup; the setup is paid once, not once per job. `-F ` skips staging a directory and forks an existing sandbox you already prepared and paused yourself, instead. `-j` defaults to 10, matching this account's observed running quota. Big dirs (`node_modules`/`target`/…) are excluded from the upload. Egress defaults to unrestricted per job; `-e`/`-p` restrict to an exact set, `-E` to keep it explicitly unrestricted. + +!`test -n "$ARGUMENTS" && "${CLAUDE_PLUGIN_ROOT}/scripts/cos" matrix $ARGUMENTS || "${CLAUDE_PLUGIN_ROOT}/scripts/cos" matrix` + +Report each job's `rc` + log path above, plus whether the golden box was destroyed (default) or kept (`-G`). Two known limits carry over from `createos sandbox matrix --help`: a fork does not carry the golden box's S3 disk attachments, and a clone whose snapshot isn't cached on the target host can take 11-13 s to resume rather than the usual sub-second. If the jobs have nothing in common, `/createos-sandbox:fanout` is the simpler tool for the same shape of work. diff --git a/packages/claude-code-plugin/commands/offload.md b/packages/claude-code-plugin/commands/offload.md index add77b6..66e54fc 100644 --- a/packages/claude-code-plugin/commands/offload.md +++ b/packages/claude-code-plugin/commands/offload.md @@ -1,10 +1,10 @@ --- -description: One-shot offload to a disposable CreateOS sandbox — stage a dir, run a command (keepalive-protected), optionally pull artifacts, auto-destroy. For heavy/untrusted/clean-room builds and tests. -argument-hint: "[-p preset] [-e dom] [-E] [-x glob] [-o out] [-w GB] [-K] [-s shape] " +description: One-shot offload to a disposable CreateOS sandbox — stage a dir, run a command (keepalive-protected), optionally fetch artifacts, auto-destroy. For heavy/untrusted/clean-room builds and tests. +argument-hint: "[-p preset] [-e dom] [-E] [-x glob] [-o out] [-K] [-s shape] " allowed-tools: Bash --- -Offload to a throwaway CreateOS Sandbox. Flags precede ` `. Egress defaults to unrestricted (any host); `-p python-uv|rust-cargo|npm|github` restricts to an exact set (compose with `-e ` for extras), or `-E` to keep it explicitly unrestricted; `-x ` adds upload excludes (`.git`/`target`/`node_modules`/… already excluded); `-o ` pulls artifacts back; `-w ` swap; `-K` keep box on failure. Long/quiet builds survive exec-stream drops (detached run + heartbeat + re-attach). A shape your account can't use is rejected with an `Allowed: [...]` list — pick from it. +Offload to a throwaway CreateOS Sandbox. Flags precede ` `. Egress defaults to unrestricted (any host); `-p python-uv|rust-cargo|npm|github` restricts to an exact set (compose with `-e ` for extras), or `-E` to keep it explicitly unrestricted; `-x ` adds upload excludes (`.git`/`target`/`node_modules`/… already excluded); `-o ` fetches a path back into the local dir; `-K` keep box on failure. Long/quiet builds survive exec-stream drops (detached run + heartbeat + re-attach). A shape your account can't use is rejected with an `Allowed: [...]` list — pick from it. Need swap for a heavy build? Compose it into the command itself: `'fallocate -l 4G /swapfile && mkswap /swapfile && swapon /swapfile && '`. Note on egress: IP and CIDR rules apply immediately, while domain rules take ~30 s — and a domain allowlist is a strong control for HTTPS traffic but a weak one for cleartext HTTP. When blocking exfiltration is the actual goal, prefer IP/CIDR rules. diff --git a/packages/claude-code-plugin/scripts/cos b/packages/claude-code-plugin/scripts/cos index 5e0b440..1269f0f 100755 --- a/packages/claude-code-plugin/scripts/cos +++ b/packages/claude-code-plugin/scripts/cos @@ -2,19 +2,30 @@ # cos — drive a CreateOS sandbox as remote compute for Claude / agents. # # Patterns: -# offload (one-shot, safe): stage a dir → run (keepalive+retry) → pull artifacts → destroy +# offload (one-shot, safe): createos sandbox offload — stage → run → fetch → destroy +# fanout/matrix (parallel): createos sandbox matrix — one box staged, forked N ways # up/run/sync/down (reusable): a per-repo box + one-way/two-way/mirror file sync # +# offload/fanout/matrix/fork are thin: cos translates flags and keeps the UX +# it owns (heavy-build warnings, project-box pause/resume orchestration), and +# hands the actual staging/execution/lifecycle work to `createos sandbox`, +# which already does it safely (retry, no billable leaks on failure, teardown +# failures that fail the command instead of hiding it). See createos-cli's +# `sandbox offload|matrix|fork --help` for the full flag set either wraps. +# # `cos` is NOT on PATH by default — run `cos install` once (symlinks into # ~/.local/bin), or invoke it by full path: "$CLAUDE_PLUGIN_ROOT/scripts/cos". # # Subcommands: # cos install [target] symlink this script onto PATH (default ~/.local/bin/cos) -# cos offload [flags] throwaway box: stage→run→pull→destroy +# cos offload [flags] throwaway box: stage→run→fetch→destroy +# cos fanout [-j N][flags] ... N throwaway forks of one staged box, in parallel +# cos matrix [-P prep][flags] ... fanout, but the setup runs once before forking # cos up [-s][-r][-n][-e|-p|-E][-a] create/reuse a project box (-a to adopt) # cos run run in the project box (keepalive) # cos sync [-2|-M][-x] [remote] start file sync into the project box (bg) # cos pause | cos resume park the warm box at zero compute cost / bring it back +# cos fork [-c N] snapshot the project box → N independent clone(s) # cos template submit [-f Dockerfile] build a custom rootfs (bake the toolchain once) # cos down [-f] stop sync + destroy box + clear state (-f reaps forks) # cos status show active box + sync state @@ -51,7 +62,8 @@ NORMA=() _mapl(){ case "$1" in --shape) echo -s;; --rootfs) echo -r;; --out|--output) echo -o;; --name) echo -n;; --egress) echo -e;; --egress-preset) echo -p;; --egress-all) echo -E;; --adopt) echo -a;; - --exclude) echo -x;; --keep-on-fail) echo -K;; --swap) echo -w;; + --exclude) echo -x;; --keep-on-fail) echo -K;; --count) echo -c;; + --prepare) echo -P;; --from) echo -F;; --keep-golden) echo -G;; --logs) echo -L;; --two-way) echo -2;; --mirror) echo -M;; --forks) echo -f;; *) echo "$1";; esac; } _norm(){ NORMA=(); while [ $# -gt 0 ]; do case "$1" in --) shift; NORMA+=(-- "$@"); break;; @@ -153,17 +165,6 @@ state_set(){ lock_state; local tmp; tmp=$(mktemp); [ -f "$STATE" ] || echo '{}' unlock_state } -# ── set up a swapfile in-box (OOM headroom for compiled-extension builds) ────── -setup_swap(){ local id=$1 gb=$2 - echo "cos: ensuring ${gb}G swap in box…" >&2 - "$CLI" sandbox exec "$id" -- bash -lc " - swapon --show 2>/dev/null | grep -q /cos.swap && { free -m | awk '/Swap/{print \"swap MB: \"\$2}'; exit 0; } - ( fallocate -l ${gb}G /cos.swap 2>/dev/null || dd if=/dev/zero of=/cos.swap bs=1M count=\$(( ${gb}*1024 )) status=none 2>/dev/null ) \ - && chmod 600 /cos.swap && mkswap /cos.swap >/dev/null 2>&1 && swapon /cos.swap 2>/dev/null \ - && free -m | awk '/Swap/{print \"swap MB: \"\$2}' \ - || echo 'swap setup failed (continuing without swap)' - " 2>&1 | sed 's/^/cos: /' >&2 || true -} # ── run a command in-box with keepalive + transient-retry ───────────────────── # Detaches the real command (survives stream death), then a heartbeat watcher @@ -195,24 +196,40 @@ run_keepalive(){ local id=$1 cmd=$2 wd=${3:-} llog=${4:-$STATE_DIR/last-exec.log done } -# ─────────────────────────────────────────────────────────────── one-shot offload -OFFLOAD_ID=""; KEEP=0 -on_offload_exit(){ [ -n "$OFFLOAD_ID" ] || return 0 - [ "$KEEP" = 1 ] && return 0 - "$CLI" sandbox rm -y "$OFFLOAD_ID" >/dev/null 2>&1 && echo "cos: destroyed $OFFLOAD_ID" >&2 || true; } - +# ───────────────────────────────────────────────────────────── one-shot offload +# Thin delegate: `createos sandbox offload` (createos-cli's own Go composition) +# already does stage->run->fetch->destroy, with retry on connection failures +# and a teardown failure that fails the command instead of leaking a billable +# box. cos only translates flags and keeps the heavy-build-on-a-small-box +# warning, which is UX cos owns and the CLI has no reason to know about. +offload_usage(){ cat <<'EOF' +cos offload — run a command in a throwaway sandbox (stage → run → fetch → destroy). + cos offload [flags] +flags: + -s shape -r rootfs -o out (path inside /work to fetch back) -K keep box on failure + -e allow one egress domain (repeatable) + -p egress preset: python-uv | rust-cargo | npm | github (repeatable, composes with -e) + -E unrestricted egress (already the default; kept for old invocations) + -x extra upload exclude (repeatable; .git/target/node_modules/__pycache__/.venv/media excluded by default) +example: + cos offload -p python-uv -p rust-cargo -x target . 'uv sync --frozen --group dev && uv run pytest -q' +Delegates to `createos sandbox offload` — see `createos sandbox offload --help` for the full flag set. +A heavy build needing swap: compose it into the command itself, e.g. + 'fallocate -l 4G /swapfile && mkswap /swapfile && swapon /swapfile && ' +EOF +} cmd_offload(){ _norm "$@"; set -- ${NORMA[@]+"${NORMA[@]}"} - local shape=s-1vcpu-1gb rootfs=devbox:1 out="" swap="" keep_on_fail=0 egress_all=0 - COS_EGRESS=(); local -a excl=() _d; local OPTIND=1 o doms d - while getopts "s:r:e:o:p:x:w:EKh" o; do case $o in - s) shape=$OPTARG;; r) rootfs=$OPTARG;; o) out=$OPTARG;; w) swap=$OPTARG;; - e) COS_EGRESS+=(--egress "$OPTARG");; - p) doms=$(egress_preset "$OPTARG") || die "unknown egress preset '$OPTARG' (have: python-uv rust-cargo npm github)" - read -ra _d <<<"$doms"; for d in "${_d[@]}"; do COS_EGRESS+=(--egress "$d"); done;; - x) excl+=("$OPTARG");; - E) egress_all=1;; - K) keep_on_fail=1;; + local -a args=(); local out="" shape=s-1vcpu-1gb OPTIND=1 o + while getopts "s:r:e:o:p:x:EKh" o; do case $o in + s) shape=$OPTARG; args+=(--shape "$OPTARG");; + r) args+=(--rootfs "$OPTARG");; + o) out=$OPTARG;; + e) args+=(--egress "$OPTARG");; + p) args+=(--egress-preset "$OPTARG");; + x) args+=(--exclude "$OPTARG");; + E) :;; # unrestricted is already the default — kept so old invocations don't error + K) args+=(--keep-on-fail);; h) offload_usage; exit 0;; *) offload_usage >&2; exit 2;; esac; done shift $((OPTIND-1)) @@ -220,62 +237,15 @@ cmd_offload(){ if [ $# -lt 2 ]; then offload_usage; exit 0; fi local dir=$1 cmd=$2 [ -d "$dir" ] || die "no such dir: $dir" - [ "$egress_all" = 1 ] && COS_EGRESS=() + [ -n "$out" ] && args+=(--fetch "$out") - # warn: heavy compiled build on a small box - local heavy=0 small=0 - case "$cmd" in *cargo*|*maturin*|*torch*|*"pip install"*|*"uv sync"*|*"uv run"*|*pyo3*) heavy=1;; esac - case "$shape" in *256mb|*512mb|*-1gb) small=1;; esac - [ "$heavy" = 1 ] && [ "$small" = 1 ] && [ -z "$swap" ] && \ - echo "cos: ⚠ heavy build on small box ($shape) — risk of OOM/ENOSPC. Try -s s-2vcpu-2gb or --swap 4." >&2 - - local name="cos-o-$$-${RANDOM}" - OFFLOAD_ID=$(create_box "$name" "$shape" "$rootfs") - KEEP=0; trap on_offload_exit EXIT - echo "cos: $OFFLOAD_ID ($name, $shape/$rootfs)" >&2 - wait_running "$OFFLOAD_ID" 30 || die "box $OFFLOAD_ID not running after 30s" - - # stage with excludes (.git/build artifacts/large media skipped by default) - local -a tarx=(); local p - for p in "${DEFAULT_EXCLUDES[@]}" ${excl[@]+"${excl[@]}"}; do tarx+=(--exclude "$p"); done - echo "cos: staging $dir → box:/work (default excludes + ${excl[*]:-none})" >&2 - tar "${tarx[@]}" -c -C "$dir" . | "$CLI" sandbox push "$OFFLOAD_ID" - /work.tar >/dev/null 2>&1 || die "push failed" - "$CLI" sandbox exec "$OFFLOAD_ID" -- bash -lc 'mkdir -p /work && tar -C /work -xf /work.tar && rm -f /work.tar && echo ok' >/dev/null 2>&1 || die "extract failed" - - [ -n "$swap" ] && setup_swap "$OFFLOAD_ID" "$swap" - - run_keepalive "$OFFLOAD_ID" "$cmd" /work || true - local rc=${BUILD_RC:-1} - if [ "${INFRA_FAIL:-0}" = 1 ]; then - KEEP=1; rc=1 - echo "cos: ⚠ infra/stream failure — box kept so the build cache survives." >&2 - echo "cos: reconnect: createos sandbox exec --stream $OFFLOAD_ID -- bash -lc 'tail -f /tmp/.cos-run.log'" >&2 - echo "cos: destroy: createos sandbox rm -y $OFFLOAD_ID" >&2 - elif [ "$rc" != 0 ] && [ "$keep_on_fail" = 1 ]; then - KEEP=1 - echo "cos: command exited $rc — box kept (--keep-on-fail). destroy: createos sandbox rm -y $OFFLOAD_ID" >&2 - fi + # warn: heavy compiled build on a small box (cos-owned UX; the CLI has no + # reason to know what "heavy" means for your workload) + case "$cmd" in *cargo*|*maturin*|*torch*|*"pip install"*|*"uv sync"*|*"uv run"*|*pyo3*) + case "$shape" in *256mb|*512mb|*-1gb) + echo "cos: ⚠ heavy build on small box ($shape) — risk of OOM/ENOSPC. Try -s s-2vcpu-2gb." >&2;; esac;; esac - if [ -n "$out" ]; then - echo "cos: pulling /work/$out → $dir/" >&2 - if "$CLI" sandbox exec "$OFFLOAD_ID" -- bash -lc "cd /work && tar -c $out" 2>/dev/null | tar -x -C "$dir"; then :; else - echo "cos: ⚠ pull of '$out' FAILED — artifacts NOT retrieved (does '$out' exist under /work?). Box is about to be destroyed." >&2 - fi - fi - return "$rc" -} -offload_usage(){ cat <<'EOF' -cos offload — run a command in a throwaway sandbox (stage → run → pull → destroy). - cos offload [flags] -flags: - -s shape -r rootfs -o out (tar dir to pull back) -w GB (swap) -K keep box on failure - -e allow one egress domain (repeatable) - -p egress preset: python-uv | rust-cargo | npm | github (repeatable, composes with -e) - -E unrestricted egress (trusted offload) - -x extra upload exclude (repeatable; .git/target/node_modules/__pycache__/.venv/media excluded by default) -example: - cos offload -p python-uv -p rust-cargo -x target . 'uv sync --frozen --group dev && uv run pytest -q' -EOF + "$CLI" sandbox offload "${args[@]}" "$dir" -- "$cmd" } # ───────────────────────────────────────────────────────────── reusable project box @@ -608,34 +578,57 @@ cmd_vpn(){ } # ───────────────────────────── fork: snapshot the project box into an independent clone +# The pause→resume orchestration around the project box stays here on purpose: +# it is YOUR persistent box, explicitly forked by request, so cos pausing it +# briefly is the one legitimate exception to "never auto-pause a running +# sandbox" — `createos sandbox fork` itself refuses to do this for anything +# it doesn't own. The actual fork + id resolution delegates to the CLI, which +# returns the new id(s) as JSON directly — no more diffing `sandbox ls` +# before/after, which is what the old multibyte/set -u trap here used to do. cmd_fork(){ + local count=1 OPTIND=1 o + while getopts "c:h" o; do case $o in + c) count=$OPTARG;; + h) echo "cos fork [-c N] snapshot the project box, fork it (N times), then resume the project box"; exit 0;; + *) exit 2;; esac; done + shift $((OPTIND-1)) + numeric "$count" && [ "$count" -ge 1 ] || die "-c must be a positive integer" + local id; id=$(state_get id); [ -n "$id" ] || die "no active project box — 'cos up' first" wait_running "$id" 15 || die "box $id not running" echo "cos: pausing $id to snapshot (brief; needed to fork)…" >&2 "$CLI" sandbox pause "$id" >/dev/null 2>&1 || die "pause failed" - local before after newid - before=$("$CLI" -o json sandbox ls 2>/dev/null | jq -r '.[].id' | sort) - echo "cos: forking…" >&2 - if ! "$CLI" sandbox fork "$id" >"$STATE_DIR/last-fork.log" 2>&1; then + + local plural=""; [ "$count" -gt 1 ] && plural=" (x$count)" + echo "cos: forking${plural}…" >&2 + local result + if ! result=$("$CLI" -o json sandbox fork "$id" --count "$count" 2>"$STATE_DIR/last-fork.log"); then "$CLI" sandbox resume "$id" >/dev/null 2>&1 || true die "fork failed — see $STATE_DIR/last-fork.log (project box resumed)" fi - after=$("$CLI" -o json sandbox ls 2>/dev/null | jq -r '.[].id' | sort) # brace the expansion: a bare `$id` followed directly by a multibyte char (the - # ellipsis) is parsed as part of the variable NAME, so `set -u` kills the script - # here — after the fork has already happened, orphaning the clone. + # ellipsis) is parsed as part of the variable NAME, so `set -u` kills the + # script here — after the fork has already happened, orphaning the clone. + # (This is not hypothetical: it happened live during testing of this exact + # rewrite. Every other new echo in this function was written pre-braced.) echo "cos: resuming project box ${id}…" >&2 "$CLI" sandbox resume "$id" >/dev/null 2>&1 || echo "cos: ⚠ resume of $id failed — run 'createos sandbox resume $id'" >&2 - newid=$(comm -13 <(printf '%s\n' "$before") <(printf '%s\n' "$after") | head -1) - [ -n "$newid" ] || { echo "cos: forked, but couldn't auto-resolve the new id — see 'createos sandbox ls'" >&2; return 0; } + + local newids; newids=$(printf '%s' "$result" | jq -r 'if type=="array" then .[].id else .id end') + [ -n "$newids" ] || { echo "cos: forked, but couldn't read the new id(s) from the response — see 'createos sandbox ls'" >&2; return 0; } + # A fork is an independent clone, so `down` won't destroy it — but record it, or # nothing ever will and it leaks silently. `down` reports it; `down -f` reaps it. - local forks; forks=$(state_get fork_ids) - state_set fork_ids "${forks:+$forks }$newid" - echo "cos: fork ready: $newid (independent clone; NOT tracked as the project box)" >&2 - echo "cos: exec: createos sandbox exec --stream $newid -- bash -lc '…'" >&2 - echo "cos: destroy: createos sandbox rm -y $newid (or 'cos down -f' to reap forks with the box)" >&2 - echo "$newid" + local forks nid; forks=$(state_get fork_ids) + for nid in $newids; do forks="${forks:+$forks }$nid"; done + state_set fork_ids "$forks" + + for nid in $newids; do + echo "cos: fork ready: $nid (independent clone; NOT tracked as the project box)" >&2 + done + echo "cos: exec: createos sandbox exec --stream -- bash -lc '…'" >&2 + echo "cos: destroy: createos sandbox rm -y (or 'cos down -f' to reap forks with the box)" >&2 + printf '%s\n' "$newids" } # ───────────────────────────── pause/resume: park the warm box at zero compute cost @@ -765,72 +758,93 @@ cmd_shell(){ } # ───────────────────────────── fanout: run commands across N throwaway boxes (parallel) +# Thin delegate: `createos sandbox matrix` with no --prepare IS this pattern — +# one box staged, forked once per job, each fork independent and destroyed +# after its job. A fork is a ~1s server-side snapshot copy, so this is faster +# than the old independently-staged-per-box approach and no less isolated: +# every job still ran from an identical, staged-once starting point either way. fanout_usage(){ cat <<'EOF' -cos fanout — run commands across N throwaway boxes in parallel (stage once → run each → collect → destroy). +cos fanout — run commands across N throwaway boxes in parallel (one box staged, forked per job). cos fanout [flags] [cmd2] [cmd3] ... -Each runs in its OWN isolated box, staged from . Per-job logs + exit codes summarized at the end. -flags: -j N (max concurrent, default 2 = external-key quota) · -s shape · -r rootfs · -p preset · -e dom · -E · -x glob +Each runs on its own clone of one box staged from . Per-job logs + exit codes summarized at the end. +flags: -j N (max concurrent, default 10) · -s shape · -r rootfs · -p preset · -e dom · -E · -x glob example: - cos fanout -j 2 -p python-uv . 'pytest -q tests/a' 'pytest -q tests/b' 'pytest -q tests/c' + cos fanout -j 5 -p python-uv . 'pytest -q tests/a' 'pytest -q tests/b' 'pytest -q tests/c' +Every job shares the same setup? Use `cos matrix -P ''` instead — it runs the setup +ONCE on the golden box before forking, rather than nothing shared at all. EOF } -fanout_one(){ # $1=idx $2=cmd $3=shape $4=rootfs $5=staged-tar $6=resdir - local i=$1 cmd=$2 shape=$3 rootfs=$4 staged=$5 resdir=$6 id - id=$(create_box "cos-fo-$$-$i-${RANDOM}" "$shape" "$rootfs" 2>/dev/null) \ - || { echo 127 > "$resdir/$i.rc"; echo "create failed" > "$resdir/$i.log"; return; } - if wait_running "$id" 30 \ - && "$CLI" sandbox push "$id" - /work.tar < "$staged" >/dev/null 2>&1 \ - && "$CLI" sandbox exec "$id" -- bash -lc 'mkdir -p /work && tar -C /work -xf /work.tar && rm -f /work.tar' >/dev/null 2>&1; then - run_keepalive "$id" "$cmd" /work "$resdir/$i.log" >/dev/null 2>&1 || true - echo "${BUILD_RC:-1}" > "$resdir/$i.rc" - else - echo "stage/boot failed" >> "$resdir/$i.log"; echo 1 > "$resdir/$i.rc" - fi - "$CLI" sandbox rm -y "$id" >/dev/null 2>&1 || true -} cmd_fanout(){ _norm "$@"; set -- ${NORMA[@]+"${NORMA[@]}"} - local shape=s-1vcpu-1gb rootfs=devbox:1 jobs=2 egress_all=0; COS_EGRESS=(); local -a excl=() _d; local OPTIND=1 o doms d + local -a args=(); local OPTIND=1 o while getopts "s:r:j:e:p:x:Eh" o; do case $o in - s) shape=$OPTARG;; r) rootfs=$OPTARG;; j) jobs=$OPTARG;; - e) COS_EGRESS+=(--egress "$OPTARG");; - p) doms=$(egress_preset "$OPTARG") || die "unknown egress preset '$OPTARG'" - read -ra _d <<<"$doms"; for d in "${_d[@]}"; do COS_EGRESS+=(--egress "$d"); done;; - x) excl+=("$OPTARG");; - E) egress_all=1;; + s) args+=(--shape "$OPTARG");; + r) args+=(--rootfs "$OPTARG");; + j) args+=(--concurrency "$OPTARG");; + e) args+=(--egress "$OPTARG");; + p) args+=(--egress-preset "$OPTARG");; + x) args+=(--exclude "$OPTARG");; + E) :;; # unrestricted is already the default — kept so old invocations don't error h) fanout_usage; exit 0;; *) fanout_usage >&2; exit 2;; esac; done shift $((OPTIND-1)) [ $# -ge 2 ] || { fanout_usage; exit 0; } local dir=$1; shift [ -d "$dir" ] || die "no such dir: $dir" - numeric "$jobs" && [ "$jobs" -ge 1 ] || die "-j must be a positive integer" - [ "$egress_all" = 1 ] && COS_EGRESS=() - local -a cmds=("$@"); local n=${#cmds[@]} - echo "cos: fanout $n job(s), ≤$jobs concurrent ($shape). External keys allow 2 running — keep -j ≤2." >&2 - - # stage the input tree once; every job pushes the same tarball - local -a tarx=(); local p - for p in "${DEFAULT_EXCLUDES[@]}" ${excl[@]+"${excl[@]}"}; do tarx+=(--exclude "$p"); done - local staged resdir; staged=$(mktemp "$STATE_DIR/fanout-stage.XXXXXX"); resdir=$(mktemp -d "$STATE_DIR/fanout.XXXXXX") - tar "${tarx[@]}" -c -C "$dir" . > "$staged" 2>/dev/null || die "staging tar failed" - - local i; local -a pids=() # bash-3.2-safe throttle: cap concurrency by waiting on the oldest pid - for ((i=0;i/dev/null || true; pids=("${pids[@]:1}"); fi - done - wait - rm -f "$staged" - - echo "cos: ── fanout results ──" >&2 - local rc all=0 - for ((i=0;i/dev/null || echo '?') - echo " job $i rc=$rc log=$resdir/$i.log cmd: ${cmds[$i]}" >&2 - [ "$rc" = 0 ] || all=1 - done - return "$all" + local cmd; for cmd in "$@"; do args+=(--job "$cmd"); done + "$CLI" sandbox matrix "${args[@]}" "$dir" +} + +# ───────────────────────────── matrix: one golden box, prepared once, forked N ways +matrix_usage(){ cat <<'EOF' +cos matrix — build one box, run --prepare once, fork it N times, run one job per clone in parallel. + cos matrix [flags] [cmd2] [cmd3] ... + cos matrix -F [flags] [cmd2] ... (fork from a sandbox you already prepared and paused) +Use this over `fanout` when every job shares the same setup (dependency install, toolchain) — the +setup runs ONCE on the golden box, then each job gets an independent fork of it. +flags: + -P run once on the golden box before forking (e.g. 'npm ci' or 'uv sync --frozen') + -F fork from this existing paused sandbox instead of building one from + -j N max concurrent clones (default 10) + -s shape · -r rootfs · -p preset · -e dom · -E · -x glob (same as offload/fanout) + -G keep the golden box after the run instead of destroying it + -L directory for per-job log files (default: a temporary directory) +example: + cos matrix -P 'bun install' . 'bun test unit' 'bun test e2e' 'bun test perf' +Known limits (from `createos sandbox matrix --help`): a fork does not carry its source's S3 disk +attachments, and a clone whose snapshot is not cached on the target host takes 11-13s to resume. +EOF +} +cmd_matrix(){ + _norm "$@"; set -- ${NORMA[@]+"${NORMA[@]}"} + local -a args=(); local from="" OPTIND=1 o + while getopts "s:r:j:e:p:x:P:F:L:EGh" o; do case $o in + s) args+=(--shape "$OPTARG");; + r) args+=(--rootfs "$OPTARG");; + j) args+=(--concurrency "$OPTARG");; + e) args+=(--egress "$OPTARG");; + p) args+=(--egress-preset "$OPTARG");; + x) args+=(--exclude "$OPTARG");; + P) args+=(--prepare "$OPTARG");; + F) from=$OPTARG;; + L) args+=(--logs "$OPTARG");; + E) :;; # unrestricted is already the default — kept so old invocations don't error + G) args+=(--keep-golden);; + h) matrix_usage; exit 0;; + *) matrix_usage >&2; exit 2;; esac; done + shift $((OPTIND-1)) + if [ -n "$from" ]; then + [ $# -ge 1 ] || { matrix_usage; exit 0; } + args+=(--from "$from") + local cmd; for cmd in "$@"; do args+=(--job "$cmd"); done + "$CLI" sandbox matrix "${args[@]}" + else + [ $# -ge 2 ] || { matrix_usage; exit 0; } + local dir=$1; shift + [ -d "$dir" ] || die "no such dir: $dir" + local cmd; for cmd in "$@"; do args+=(--job "$cmd"); done + "$CLI" sandbox matrix "${args[@]}" "$dir" + fi } # ───────────────────────────── disk: BYO S3 bucket mounts (attach/detach on the project box) @@ -929,8 +943,9 @@ cmd_install(){ main_usage(){ cat <<'EOF' cos — CreateOS sandbox as remote compute. (run `cos install` to put `cos` on PATH) cos auth check sign-in (CREATEOS_API_KEY, or `createos login` in a real terminal) - cos offload [flags] one-shot: stage→run(keepalive)→pull→destroy (cos offload -h for flags) - cos fanout [-j N][flags] ... run each in its own throwaway box, in parallel (cos fanout -h) + cos offload [flags] one-shot: stage→run(keepalive)→fetch→destroy (cos offload -h for flags) + cos fanout [-j N][flags] ... N throwaway forks of one staged box, in parallel (cos fanout -h) + cos matrix [-P prep][flags] ... fanout, but the setup runs once before forking (cos matrix -h) cos shell [-s][-r][-e|-p|-E] instant throwaway interactive Linux (destroyed on exit) cos up [-s][-r][-n][-e|-p|-E][-a] create/reuse project box (-a adopt a box cos didn't create) cos run run in project box (keepalive; ONE string, no '--' separator) @@ -940,7 +955,7 @@ cos — CreateOS sandbox as remote compute. (run `cos install` to put `cos` on cos cluster up [-s|-r|-e|-p|-E] | run [|-a] | ls | down N boxes on one private net cos disk create|ls|attach |detach|rm BYO S3 bucket mounts cos vpn [up|register [name]] WireGuard L3 into your private networks (needs wg-quick) - cos fork snapshot project box → independent clone (survives down; -f reaps) + cos fork [-c N] snapshot project box → N independent clone(s) (survives down; -f reaps) cos pause | cos resume park the warm box at zero compute cost / bring it back cos template submit [-f Dockerfile] | ls | show | logs | rm custom rootfs (bake the toolchain once) cos down [-f] stop sync/tunnels + destroy box (+ cluster; -f also destroys forks) @@ -965,6 +980,7 @@ case "$sub" in auth) cmd_auth "$@";; offload) cmd_offload "$@";; fanout) cmd_fanout "$@";; + matrix) cmd_matrix "$@";; shell) cmd_shell "$@";; up) cmd_up "$@";; run) cmd_run "$@";; diff --git a/packages/claude-code-plugin/skills/using-createos-sandbox/SKILL.md b/packages/claude-code-plugin/skills/using-createos-sandbox/SKILL.md index d4762f4..f06bcce 100644 --- a/packages/claude-code-plugin/skills/using-createos-sandbox/SKILL.md +++ b/packages/claude-code-plugin/skills/using-createos-sandbox/SKILL.md @@ -1,6 +1,6 @@ --- name: using-createos-sandbox -description: Use when you need to run code OFF the user's machine — heavy/long builds or test suites, untrusted or unknown code, a parallel test/config matrix across many boxes, an instant clean Linux to try a tool, a live dev-server/watcher Claude edits against, reaching a box-side service from localhost (port tunnel) or sharing it on the public web (HTTPS preview URL), a multi-machine cluster on one private network, a WireGuard VPN into that network, or mounting an S3 bucket of data. Offloads to ephemeral CreateOS Sandboxes via the `cos` helper (stage → exec → pull → auto-destroy), plus fanout, a scratch shell, and an opt-in reusable box with sync, tunnel, expose, cluster, disk, vpn, pause/resume, custom images, and snapshot/fork. +description: Use when you need to run code OFF the user's machine — heavy/long builds or test suites, untrusted or unknown code, a parallel test/config matrix across many boxes, an instant clean Linux to try a tool, a live dev-server/watcher Claude edits against, reaching a box-side service from localhost (port tunnel) or sharing it on the public web (HTTPS preview URL), a multi-machine cluster on one private network, a WireGuard VPN into that network, or mounting an S3 bucket of data. Offloads to ephemeral CreateOS Sandboxes via the `cos` helper (stage → exec → fetch → auto-destroy), plus fanout, matrix (shared-setup fan-out), a scratch shell, and an opt-in reusable box with sync, tunnel, expose, cluster, disk, vpn, pause/resume, custom images, and snapshot/fork. --- # Using CreateOS Sandbox as remote compute @@ -40,7 +40,8 @@ Every `cos` command except `install` and `auth` runs this check first, so an una | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | **Untrusted / unknown code** — a snippet, a fresh npm/pip package, scraped code, a PoC exploit | Isolation. The blast radius is one disposable box, not the laptop. | | **Heavy build or test suite** — big `make`, full test run, compile, benchmark | Keeps the laptop free; runs on a box sized for it. | -| **Parallel/matrix work** — same job across N configs, test shards, batch | `fanout` — each command in its own throwaway box, concurrently, results collected. | +| **Parallel work, independent jobs** — N configs, test shards, batch, no shared setup | `fanout` — each command on its own fork of one staged box, concurrently. | +| **Parallel work, shared setup** — same install/toolchain, then N different commands | `matrix -P ''` — the setup runs once, then forks once per job. | | **Quick scratch Linux** — try a CLI/tool/snippet on a clean box | `shell` — instant keyless box, destroyed on exit (interactive; the user runs it). | | **Clean-room repro** — "works on my machine" bugs, dependency conflicts | Fresh rootfs every time, no host state. | | **Live dev loop** — dev server / test watcher / REPL that reacts to edits | Project box + `sync`; Claude edits locally, the box reacts. | @@ -98,10 +99,20 @@ For the full flag table, the egress presets, the enforcement caveats, fanout, an ### Fanout — same input, many boxes, in parallel ```bash -cos fanout -j 2 -p python-uv . 'pytest -q tests/unit' 'pytest -q tests/integration' 'ruff check' +cos fanout -p python-uv . 'pytest -q tests/unit' 'pytest -q tests/integration' 'ruff check' ``` -Each job gets its own box with no shared network — that is what distinguishes it from `cluster`. `-j` defaults to 2 to match the concurrency external keys have been observed to allow; going higher just queues the extra jobs rather than failing. +Each job runs on its own fork of one box staged from the directory, with no shared network between jobs — that is what distinguishes it from `cluster`. `-j` defaults to 10, matching this account's observed running concurrency; going higher just queues the extra jobs rather than failing. + +### Matrix — same setup, many jobs, in parallel + +```bash +cos matrix -P 'npm ci' . 'npm test -- --shard 1' 'npm test -- --shard 2' 'npm test -- --shard 3' +``` + +`fanout` with a shared setup step. `-P ''` runs once on the golden box — the dependency install, the toolchain prep — before it forks once per job. Use this the moment two or more jobs would otherwise repeat the same install: the setup cost is paid once instead of once per job. `-F ` forks from a sandbox you already prepared and paused yourself, instead of building a new golden box from a directory. + +Known limits, both inherited from `createos sandbox matrix --help`: a fork does not carry the golden box's S3 disk attachments (re-attach on each clone, or don't use disks with matrix), and a clone whose snapshot isn't cached on the target host takes 11–13 s to resume rather than the usual sub-second. ## Pattern B — reusable project box (opt-in) @@ -171,7 +182,7 @@ Disk data lives in the user's own S3 account and region. `--path-style` is neede - Ephemeral boxes self-destroy. The project box carries a 30-minute idle auto-pause as a backstop, so a forgotten box parks itself instead of billing overnight. Raise it with `createos sandbox edit --auto-pause 4h` when a box is serving an exposed URL people will hit intermittently — otherwise the demo will look dead between visitors. - Finish a live session with `cos pause` (keeping the warm state) or `cos down` (done for good). Don't leave a running box behind either way. -- **Concurrency is limited** — external keys have been observed to allow 2 boxes running at once, with a daily creation cap. This is observed behaviour rather than published policy, so budget `cluster` and `fanout` against it and expect excess jobs to queue rather than fail. +- **Concurrency is limited** — this account has been observed to allow at least 10 boxes running at once, with a daily creation cap. Neither is published policy, so budget `cluster`, `fanout`, and `matrix` against it and expect excess jobs to queue rather than fail. Check the actual number live if a decision hinges on it — it can change per plan. - If a shape is rejected, the error names the allowed list — pick from it, or run `createos sandbox shapes`. - Pre-existing boxes the user already runs are **not** yours. `cos` only ever destroys boxes it created itself; a box adopted with `cos up -a` survives `cos down`. - CreateOS Sandbox is in alpha with no SLA. When a limit or a number matters to a decision, check it live rather than quoting it from here. @@ -182,6 +193,6 @@ Load these when the task actually needs the depth — the summaries above are en | File | Read it for | | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `references/offload-and-egress.md` | offload flag table, egress presets and how enforcement really behaves, fanout, upload excludes, heavy-build OOM/disk/bandwidth traps | +| `references/offload-and-egress.md` | offload flag table, egress presets and how enforcement really behaves, fanout, matrix, upload excludes, heavy-build OOM/disk/bandwidth traps | | `references/networking.md` | choosing between tunnel/expose/cluster/vpn, cluster DNS names, expose gotchas, WireGuard setup | | `references/lifecycle-and-images.md` | pause/resume, auto-pause tuning, fork caveats, built-in rootfs vs custom templates, env vars, remote editor, self-terminating jobs, single-file transfer, measured timings | diff --git a/packages/claude-code-plugin/skills/using-createos-sandbox/references/lifecycle-and-images.md b/packages/claude-code-plugin/skills/using-createos-sandbox/references/lifecycle-and-images.md index 3cfb404..561d866 100644 --- a/packages/claude-code-plugin/skills/using-createos-sandbox/references/lifecycle-and-images.md +++ b/packages/claude-code-plugin/skills/using-createos-sandbox/references/lifecycle-and-images.md @@ -45,15 +45,17 @@ Raising it is the right move when a box is serving an exposed URL that people wi ## fork — branch a warm box ```bash -cos fork # pauses briefly, clones the snapshot, resumes the original +cos fork # pauses briefly, clones the snapshot, resumes the original +cos fork -c 5 # same, but takes 5 independent clones in one call ``` -The clone is a fully independent box with its own id, IP, and quota ledger. The original is untouched. This is how you try N variants from one prepared state without redoing setup N times. +Each clone is a fully independent box with its own id, IP, and quota ledger. The original is untouched. This is how you try N variants from one prepared state without redoing setup N times — for running the SAME job N ways in parallel from a directory rather than the project box, `cos matrix` is usually the better fit; see `offload-and-egress.md`. -Two things to know: +Three things to know: - A fork is **not** tracked as the project box, so `cos down` leaves it running. It _is_ recorded in the statefile: `cos status` lists forks, `cos down` names the survivors, and `cos down -f` reaps them. Otherwise destroy it yourself with `createos sandbox rm -y `. -- **Mounted disks do not carry across a fork.** If the source box had an S3 disk attached, re-attach it on the clone. +- **Mounted disks do not carry across a fork.** If the source box had an S3 disk attached, re-attach it on each clone. +- **Fork refuses a running sandbox it doesn't own.** `cos fork` pauses the project box itself before forking, because it created that pause-resume cycle on purpose. Forking any OTHER running sandbox directly (`createos sandbox fork `) is refused unless it is already paused — nothing pauses a box you didn't ask it to pause. ## Images: built-in rootfs and custom templates @@ -125,9 +127,14 @@ curl -sX POST 'http://127.0.0.1:1029/self/pause?reason=idle' # or via the FIFO echo retire > /run/self # delete echo park > /run/self # pause + +# createos-cli also wraps these, if the CLI happens to be installed in-guest +createos sandbox self delete --reason batch-done ``` -Append this to the end of a long unattended command and the box cleans itself up whether or not anything is still watching. +Append this to the end of a long unattended command and the box cleans itself up whether or not anything is still watching. Prefer `curl`/the FIFO in a script you might run on any rootfs — they need nothing installed. The CLI wrapper only works if `createos` is actually present in the guest, which a stock `devbox:1` does not ship by default; bake it into a custom template if you want the wrapper form available. + +One side effect worth knowing: from `cos run`'s own view — watching over the control-plane connection, not from inside the box — a box that deletes itself looks identical to a dropped stream. `cos` cannot tell "the job finished and the box is gone on purpose" apart from "the box died mid-run," so its output ends abruptly rather than with a clean success line. Check `createos sandbox get ` (status `destroyed`) if you need to confirm which one happened. ## Single-file transfer @@ -147,7 +154,7 @@ Useful for setting expectations, and for not overpromising to the user: - **Create to first command runs: roughly 200 ms** (median; the guest kernel itself boots in tens of milliseconds, but the round trip through the control plane dominates). - **Pause and resume: around 6–8 seconds each, end to end through the CLI** (measured on a 1 GiB box; the platform-side operations are faster, the CLI polls for the state transition). Resume is slower when the snapshot has to move to a different host than it was taken on. **Fork: around a second** for the snapshot copy, plus the pause and resume around it. -- **Concurrency:** external API keys have been observed to allow 2 boxes running at once, with a daily creation cap. Neither number is published policy — treat them as observed behaviour, budget `cluster` and `fanout` against them, and expect excess jobs to queue rather than fail. +- **Concurrency:** this account has been observed to allow at least 10 boxes running at once, with a daily creation cap. Neither number is published policy — treat them as observed behaviour, budget `cluster`, `fanout`, and `matrix` against them, and expect excess jobs to queue rather than fail. - **Bandwidth:** 5 GiB of box-initiated egress per box by default, topped up additively. CreateOS Sandbox is in alpha and carries no SLA. Behaviour and limits can change — when a number matters to a decision, check it live rather than quoting this file. diff --git a/packages/claude-code-plugin/skills/using-createos-sandbox/references/offload-and-egress.md b/packages/claude-code-plugin/skills/using-createos-sandbox/references/offload-and-egress.md index e0d45a6..1d9cc9e 100644 --- a/packages/claude-code-plugin/skills/using-createos-sandbox/references/offload-and-egress.md +++ b/packages/claude-code-plugin/skills/using-createos-sandbox/references/offload-and-egress.md @@ -1,4 +1,4 @@ -# Offload, fanout, and the egress firewall +# Offload, fanout, matrix, and the egress firewall Read this when an offload needs tuning: restricting what a build can reach, sizing a box for a heavy compile, controlling what gets uploaded, or fanning work across boxes. @@ -9,6 +9,7 @@ Read this when an offload needs tuning: restricting what a build can reach, sizi - [Egress: how the firewall actually behaves](#egress-how-the-firewall-actually-behaves) - [Egress presets](#egress-presets) - [Fanout](#fanout) +- [Matrix](#matrix) - [Heavy builds: OOM, disk, and bandwidth](#heavy-builds-oom-disk-and-bandwidth) ## Offload flags @@ -20,7 +21,6 @@ Flags come **before** the ` ` positionals — `cos` parses with `getop | `-s ` | box size (default `s-1vcpu-1gb`); list with `createos sandbox shapes` | | `-r ` | base image or custom template (default `devbox:1`) | | `-o ` | tar this path out of `/work` back into the local dir after the run | -| `-w ` | try to add a swapfile (best-effort — see below) | | `-K` | keep the box if the command exits non-zero, so the cache survives for a retry | | `-e ` | allow one outbound destination (repeatable) | | `-p ` | apply an egress preset (repeatable, composes with `-e`) | @@ -69,17 +69,30 @@ Presets compose. A Python project with a Rust extension and a git dependency wan ## Fanout -`cos fanout [-j N] [flags] [cmd2] …` stages `` once and runs each command in its **own** throwaway box, concurrently, then reports per-job exit codes and log paths and destroys every box. +`cos fanout [-j N] [flags] [cmd2] …` stages `` once, forks that staged box once per command, runs each command on its own fork concurrently, then reports per-job exit codes and log paths and destroys every fork. -The jobs share no network — that is the difference from `cluster`, where boxes are wired together on purpose. Fanout is for a test matrix, a config sweep, or a batch where isolation between jobs is the point. +Under the hood this is `createos sandbox matrix` with no `--prepare` — the staged box is the golden box, and each job is a fork of it. The jobs share no network — that is the difference from `cluster`, where boxes are wired together on purpose. Fanout is for a test matrix, a config sweep, or a batch where isolation between jobs is the point and there is no shared setup step worth doing once. -`-j` defaults to 2 because that matches the concurrent-box limit observed on external API keys. Raising it past what the account allows does not fail — the extra jobs just queue, so a 3-way fanout with `-j 3` silently serializes into 2 + 1. One very long build is still better served by a single `offload`. +`-j` defaults to 10, matching this account's observed concurrent-box limit. Raising it past what the account actually allows does not fail — the extra jobs just queue. One very long build is still better served by a single `offload`. + +## Matrix + +`cos matrix [-P ''] [-F ] [flags] [cmd2] …` is `fanout` plus one thing: `-P ''` runs once on the golden box, before it forks, so every job's fork already has the dependency install or toolchain prep done. + +Reach for this the moment two or more jobs would otherwise repeat the same setup — a `pytest` shard matrix that all need the same `pip install`, three `npm test` invocations that all need the same `npm ci`. The setup is paid once; only the fork (roughly a second) and the job itself are paid per job. + +`-F ` skips building a golden box from a directory and forks an existing sandbox you already prepared and paused yourself — useful when the setup is expensive enough that you want to keep the golden box around and matrix against it repeatedly (pair with `-G` to keep it after the run too). + +Two limits carry over from `createos sandbox matrix --help`, both worth knowing before reaching for `-F` with disks attached or expecting sub-second forks everywhere: + +- **A fork drops the golden box's S3 disk attachments.** If the golden box has a disk mounted, the forks come up without it. Re-attach per fork, or avoid disks with matrix. +- **A fork can take 11–13 s, not always under a second.** That is the cross-host cold-fetch case, when the snapshot is not already cached on the host the fork lands on. ## Heavy builds: OOM, disk, and bandwidth **Shape rejection.** Picking a shape the account cannot use fails fast with a `not allowed … Allowed: [...]` line; pick from that list, or run `createos sandbox shapes`. (The rejection is real and reproducible on external keys; the exact policy behind it is not documented, so treat the allowed list as authoritative rather than guessing.) -**Swap is best-effort.** `-w ` tries to add a swapfile, but `devbox:1` cannot currently `swapon` — it stays at 0 MB, `cos` warns, and continues. If a compiled-extension build (pyo3/maturin, torch) is OOMing, the fix is a bigger shape or less work per run: build the extension separately, or install only the extra/group you actually need. +**Swap has no flag — compose it into the command.** `devbox:1` cannot currently `swapon` a swapfile added after boot from most shapes, so a dedicated `-w` flag bought little; if a compiled-extension build (pyo3/maturin, torch) needs the headroom, add it as the first step of the command itself: `'fallocate -l 4G /swapfile && mkswap /swapfile && swapon /swapfile && '`. The more reliable fix for an OOM is still a bigger shape or less work per run — install only the extra/group you actually need. **Disk fills fast.** `pip install --all-extras` or an unconstrained `uv sync` can pull CUDA and torch wheels measured in gigabytes and hit `No space left on device` on a small box. Install only what the job needs; `--disk-mib` at create time raises the ceiling if you control it. From d20d764d9e83de10f863ca6fd5b90cadbb7b1d14 Mon Sep 17 00:00:00 2001 From: pratikbin <68642400+pratikbin@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:21:51 +0530 Subject: [PATCH 2/3] refactor(cos): delegate desktop and computer to the createos CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI grew `sandbox desktop` and `sandbox computer`, so the REST layer this driver carried for them has no reason to exist. Delete api(), api_auth(), api_check() and desktop_wait(), and shell out like every other verb. That layer was the one place cos bypassed the CLI, and the cost of it was a second implementation of things the CLI already knew: its own auth precedence, its own error-code map, its own readiness poll. It also held a false premise — a comment claiming the CLI does not read CREATEOS_API_KEY, which it has (cmd/root/root.go). The same code now serves pi, opencode and herdr, which could not do computer-use at all. cos keeps what the CLI cannot know: which box belongs to this project, and creating one on a desktop image when there is none. The rootfs guard stays too, because the CLI tells you to create a new sandbox, and for a project box the fix is to replace the one you have. Two details preserved for callers: - `cos computer screenshot -o file` still works. The CLI spells it --out, since -o is its global output-format flag, so cos translates. - `cos resume` now says to re-run `cos desktop` on a box that had one. A resumed desktop answers reads before its input stack is back, so a click can 409 for a moment after the screen route already succeeds. Requires a createos CLI carrying those commands. cos probes for them and tells the user to upgrade rather than failing with "unknown command". Blocked on NodeOps-app/createos-cli#94. --- packages/claude-code-plugin/README.md | 2 +- packages/claude-code-plugin/scripts/cos | 209 ++++-------------- .../skills/using-createos-sandbox/SKILL.md | 35 +-- packages/codex-plugin/scripts/cos | 209 ++++-------------- .../skills/using-createos-sandbox/SKILL.md | 35 +-- 5 files changed, 103 insertions(+), 387 deletions(-) diff --git a/packages/claude-code-plugin/README.md b/packages/claude-code-plugin/README.md index 49d465c..81140c4 100644 --- a/packages/claude-code-plugin/README.md +++ b/packages/claude-code-plugin/README.md @@ -266,7 +266,7 @@ The two halves compose: the URL lets **you** watch and take over in a browser wh | **Raw pixels** | Coordinates are unscaled X11 pixels of that screen. Read the bounds from `cos computer screen`. | | **Needs ingress** | `desktop` enables it for you. `unexpose` turns it off and kills the link. | -> `desktop` and `computer` are the only `cos` commands that call the CreateOS REST API directly — the `createos` CLI has no computer or desktop command yet. Everything else shells out to the CLI as usual. Auth is reused as-is: `CREATEOS_API_KEY` or `~/.createos/.token` go out as `X-Api-Key`, a browser session's JWT as `X-Access-Token`. +> `desktop` and `computer` shell out to `createos sandbox desktop` and `createos sandbox computer`, like every other `cos` verb. `cos` adds only the project-box resolution: which box belongs to this directory, and creating one on a desktop image when there is none. Ops `cos` does not wrap — clipboard, window manipulation, screen resize — are reachable with `createos sandbox computer --help`. These commands need a `createos` CLI new enough to carry them; `cos` checks and tells the user to upgrade if not. ``` /createos-sandbox:desktop [-s shape] [-S screen-N] diff --git a/packages/claude-code-plugin/scripts/cos b/packages/claude-code-plugin/scripts/cos index d9d6a9a..331bf21 100755 --- a/packages/claude-code-plugin/scripts/cos +++ b/packages/claude-code-plugin/scripts/cos @@ -698,96 +698,23 @@ cmd_unexpose(){ } # ══════════════════════════ desktop / computer use ════════════════════════════ -# `createos` has no computer or desktop command, so these two verbs are the only -# place cos talks to the sandbox REST API directly instead of shelling out to the -# CLI. Everything else in this script stays CLI-driven; when the CLI grows a -# `sandbox computer` group, delete api()/api_auth() and shell out like the rest. +# Both verbs shell out to `createos sandbox desktop` and `createos sandbox +# computer`, like every other verb here. The REST layer this script used to +# carry is gone: the CLI owns the readiness wait, the error-code mapping and +# the noVNC link now, so every host that shells out gets them, not just this one. # -# Auth mirrors the CLI exactly: an api key goes in X-Api-Key, an OAuth JWT in -# X-Access-Token. fc rejects Bearer on user-facing routes, and it rejects a JWT -# sent under X-Api-Key ("invalid api key") — the two are not interchangeable. -api_base(){ printf '%s' "${CREATEOS_SANDBOX_URL:-https://api.sb.createos.sh}"; } - -api_auth(){ - # Precedence MUST match the CLI's (createos-cli cmd/root/root.go): an OAuth - # session wins, an api key is the fallback. Inverting it authenticates these - # direct calls as a different identity than every CLI-driven verb — and the - # symptom is a 404 on a box cos itself just created, which reads like the box - # is missing rather than like an auth mismatch. - local exp at - if [ -f "$CREATEOS_DIR/.oauth" ]; then - exp=$(jq -r '.expires_at // 0' "$CREATEOS_DIR/.oauth" 2>/dev/null || echo 0) - numeric "$exp" || exp=0 - # cos deliberately does not implement OAuth refresh. The CLI already refreshes - # in its pre-flight and rewrites ~/.createos/.oauth, so poke it and re-read - # rather than carrying a second, subtly different refresh implementation. - if [ "$(date +%s)" -ge "$((exp - 60))" ]; then - "$CLI" -o json sandbox ls >/dev/null 2>&1 || true - fi - at=$(jq -r '.access_token // empty' "$CREATEOS_DIR/.oauth" 2>/dev/null || true) - [ -n "$at" ] && { printf 'X-Access-Token: %s' "$at"; return 0; } - fi - # NOTE: the CLI itself does NOT read CREATEOS_API_KEY (it has no such env var), - # so this branch works for these REST calls but not for any CLI-driven verb. - [ -n "${CREATEOS_API_KEY:-}" ] && { printf 'X-Api-Key: %s' "$CREATEOS_API_KEY"; return 0; } - [ -f "$CREATEOS_DIR/.token" ] && { printf 'X-Api-Key: %s' "$(tr -d '\r\n' <"$CREATEOS_DIR/.token")"; return 0; } - die "not signed in — run 'cos auth'" -} - -# Map the computer API's error codes onto something actionable. Worth doing by -# hand: `desktop_unavailable` is fc's catch-all for every X failure, so the raw -# message alone never tells you whether the desktop is still booting or the -# action itself failed on a live desktop. -api_check(){ local code=$1 out=$2 what=$3 msg - case "$code" in 2??) return 0;; esac - msg=$(printf '%s' "$out" | jq -r '.message // .error // empty' 2>/dev/null || true) - case "$code" in - 000) die "no response from $(api_base) — network down, or CREATEOS_SANDBOX_URL points somewhere wrong";; - 401|403) die "auth rejected (HTTP $code). The API key or browser session is invalid or expired. - Ask the user to re-run 'createos login' in their own terminal, or export CREATEOS_API_KEY.";; - 404) die "not found (HTTP 404): ${msg:-$what} - Either the box is gone, or it has no such screen — computer-use needs a desktop image ('cos desktop').";; - 409) case "$msg" in - *ingress*) die "409: $msg — 'cos desktop' turns ingress on for you";; - *desktop_unavailable*|*) die "desktop did not answer (HTTP 409 ${msg:-desktop_unavailable}). - fc returns this both while the desktop is still booting AND when an action fails on a live desktop. - If the box just came up, 'cos desktop' waits for readiness — run that first.";; - esac;; - 429) die "rate limited (429) — the control plane caps concurrent screenshots. Retry in a second.";; - 501) die "501 desktop_tools_unavailable — this rootfs has no desktop tools installed. Recreate with: cos down && cos desktop";; - *) die "API error HTTP $code on $what${msg:+: $msg}";; - esac -} - -# api [json-body] → prints the response payload, unwrapped from -# fc's JSend envelope. Callers pipe it through jq for the fields they want. -api(){ local method=$1 path=$2 body=${3:-} hdr code out tmp - hdr=$(api_auth) || exit 1 - tmp=$(mktemp) - local args=(-sS -X "$method" -H "$hdr" -o "$tmp" -w '%{http_code}' --max-time 60) - [ -n "$body" ] && args+=(-H 'Content-Type: application/json' -d "$body") - code=$(curl "${args[@]}" "$(api_base)$path" 2>/dev/null) || code=000 - out=$(cat "$tmp"); rm -f "$tmp" - api_check "$code" "$out" "$method $path" - printf '%s' "$out" | jq -c '.data // .' 2>/dev/null || printf '%s' "$out" -} - -# The desktop stack (Xvfb → XFCE → x11vnc → websockify) starts AFTER the box -# reports `running`, so every computer call 404s or 409s for the first while. -# Neither fc nor the SDK polls for this — every caller has to, so cos does it here. -desktop_wait(){ local id=$1 screen=$2 to=${3:-120} hdr code i=0 said=0 - hdr=$(api_auth) || exit 1 - while [ "$i" -lt "$to" ]; do - code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 -H "$hdr" \ - "$(api_base)/v1/sandboxes/$id/computer/screen?screen_id=$screen" 2>/dev/null) || code=000 - case "$code" in - 2??) [ "$said" = 1 ] && echo "cos: desktop ready" >&2; return 0;; - 401|403|501) api_check "$code" '' "desktop readiness";; - esac - [ "$said" = 0 ] && { echo "cos: waiting for the desktop stack to come up (up to ${to}s)…" >&2; said=1; } - sleep 2; i=$((i+2)) - done - die "desktop did not come up within ${to}s on $id ($screen) — check: cos run 'pgrep -a Xvfb; pgrep -a websockify'" +# cos keeps only what the CLI cannot know: which box belongs to this project, +# and how to create one on a desktop image when there is none. + +# The CLI grew these commands after this script shipped, so probe for them +# rather than failing with a bare "unknown command". Same shape as the +# `sandbox sync --mode` probe further down. +require_cli_computer(){ + "$CLI" sandbox computer --help >/dev/null 2>&1 && return 0 + die "the installed createos CLI has no 'sandbox computer' command. + Desktop and computer-use moved out of cos and into the CLI. Upgrade it: + createos upgrade + or re-run the installer from https://createos.sh" } # ───────────────────────────── desktop: graphical box + a live noVNC URL @@ -796,9 +723,10 @@ cmd_desktop(){ local shape=s-2vcpu-4gb screen=screen-0; local OPTIND=1 o while getopts "s:S:h" o; do case $o in s) shape=$OPTARG;; S) screen=$OPTARG;; - h) echo "cos desktop [-s shape] [-S screen-N] desktop:1 box + ingress + live noVNC URL"; return 0;; + h) echo "cos desktop [-s shape] [-S screen-N] desktop:1 box + a live noVNC URL"; return 0;; *) die "usage: cos desktop [-s shape] [-S screen-N]";; esac; done shift $((OPTIND-1)) + require_cli_computer local id; id=$(state_get id) if [ -z "$id" ] || ! box_live "$id"; then @@ -807,8 +735,9 @@ cmd_desktop(){ id=$(state_get id); [ -n "$id" ] || die "box creation did not record an id" else box_resume_if_paused "$id" - # Never silently drive a non-desktop box: every computer call would 501 with - # a much less obvious message than saying so here. + # The CLI refuses a non-desktop box too, but it tells you to create a new + # sandbox. That is the wrong move for a project box — there is one per + # directory, so the fix is to replace this one. local rf; rf=$("$CLI" -o json sandbox get "$id" 2>/dev/null | jq -r '.rootfs // empty') case "$rf" in *desktop*) :;; @@ -818,36 +747,26 @@ cmd_desktop(){ fi wait_running "$id" 30 || die "box $id not running" - "$CLI" sandbox edit "$id" --ingress on >/dev/null 2>&1 || die "failed to enable ingress on $id" - desktop_wait "$id" "$screen" - - local conn url exp - conn=$(api GET "/v1/sandboxes/$id/computer/screens/$screen/connect") - url=$(printf '%s' "$conn" | jq -r '.url // empty') - exp=$(printf '%s' "$conn" | jq -r '.expires_at // "?"') - [ -n "$url" ] || die "connect returned no URL — ingress is off on $id (fc only mints one when ingress is enabled)" state_set desktop_screen "$screen" - { - echo "cos: desktop $screen on $id — open in a browser:" - echo "cos: ⚠ anyone with this link can drive the desktop. The token expires $exp, and re-running" - echo "cos: 'cos desktop' mints a fresh one (which invalidates this link for NEW connections)." - } >&2 - echo "$url" + # The CLI turns ingress on, waits for the desktop stack, and prints the URL + # together with its expiry and the warning that the link is the credential. + "$CLI" sandbox desktop "$id" --screen "$screen" } # ───────────────────────────── computer: drive that desktop from the agent side computer_usage(){ cat <<'EOF' cos computer drive the desktop in the project box (needs `cos desktop` first) - screenshot [-o file] capture PNG (default: $STATE_DIR/screenshot.png), prints the path + screenshot [-o file] capture PNG, prints the path screen screen geometry {width,height} cursor cursor position {x,y} + windows list windows on the screen move move the pointer click [ ] click (optionally move there first) type type a string key ... press a chord, e.g. cos computer key ctrl l open open a target in the desktop browser - windows list windows on the screen - raw [json] any other computer endpoint (path relative to .../computer) +Each op runs `createos sandbox computer ` against this project's box; run +`createos sandbox computer --help` for the ops not listed here. Screen defaults to screen-0; override per call with COS_SCREEN=screen-N. Coordinates are raw X11 pixels of that screen — match them against `cos computer screen`. EOF @@ -856,66 +775,22 @@ EOF cmd_computer(){ local op=${1:-}; shift || true case "$op" in ''|-h|--help|help) computer_usage; return 0;; esac + require_cli_computer local id; id=$(state_get id) [ -n "$id" ] || die "no active box — run 'cos desktop' first" [ "$(box_status "$id")" = paused ] && die "box $id is paused — 'cos resume' first" local screen=${COS_SCREEN:-$(state_get desktop_screen)}; screen=${screen:-screen-0} - local base="/v1/sandboxes/$id/computer" q="screen_id=$screen" - - case "$op" in - screenshot) - local out="$STATE_DIR/screenshot.png" hdr code - [ "${1:-}" = "-o" ] && { out=${2:?-o needs a path}; shift 2; } - hdr=$(api_auth) || exit 1 - # Screenshot is the one computer route that returns bytes, not JSON, so it - # bypasses api() entirely — the PNG is passed through from fc verbatim. - code=$(curl -sS -H "$hdr" -o "$out" -w '%{http_code}' --max-time 60 \ - "$(api_base)$base/screenshot?$q" 2>/dev/null) || code=000 - case "$code" in - 2??) :;; - *) local body; body=$(cat "$out" 2>/dev/null || true); rm -f "$out"; api_check "$code" "$body" "screenshot";; - esac - echo "cos: screenshot → $out ($(wc -c <"$out" | tr -d ' ') bytes) — open it with the Read tool" >&2 - echo "$out";; - screen) api GET "$base/screen?$q";; - cursor) api GET "$base/cursor?$q";; - windows) api GET "$base/windows?$q";; - move) - numeric "${1:-}" && numeric "${2:-}" || die "usage: cos computer move " - api POST "$base/mouse/move?$q" "$(jq -nc --argjson x "$1" --argjson y "$2" '{x:$x,y:$y}')" >/dev/null - echo "cos: moved to $1,$2" >&2;; - click) - if [ $# -ge 2 ]; then - numeric "$1" && numeric "$2" || die "usage: cos computer click [ ]" - api POST "$base/mouse/click?$q" "$(jq -nc --argjson x "$1" --argjson y "$2" '{x:$x,y:$y}')" >/dev/null - echo "cos: clicked $1,$2" >&2 - else - api POST "$base/mouse/click?$q" '{}' >/dev/null - echo "cos: clicked at the current cursor position" >&2 - fi;; - type) - [ $# -ge 1 ] || die "usage: cos computer type " - # Unquoted multi-word text arrives as separate argv entries; join before - # sending so `cos computer type hello world` types the space too. - local text="$*" - api POST "$base/keyboard/type?$q" "$(jq -nc --arg t "$text" '{text:$t}')" >/dev/null - echo "cos: typed ${#text} chars" >&2;; - key) - [ $# -ge 1 ] || die "usage: cos computer key ... e.g. cos computer key ctrl l" - api POST "$base/keyboard/press?$q" "$(jq -nc '{keys:$ARGS.positional}' --args "$@")" >/dev/null - echo "cos: pressed $*" >&2;; - open) - [ $# -ge 1 ] || die "usage: cos computer open " - api POST "$base/open?$q" "$(jq -nc --arg t "$1" '{target:$t}')" >/dev/null - echo "cos: opened $1" >&2;; - raw) - # Escape hatch: cos wraps the handful of ops an agent loop actually needs, - # not all ~30 computer routes. Everything else goes through here. - local m=${1:?usage: cos computer raw [json]} p=${2:?path required}; shift 2 - case "$p" in /*) :;; *) p="$base/$p";; esac - api "$m" "$p" "${1:-}";; - *) die "unknown computer op '$op' — run 'cos computer help'";; - esac + + # cos has always spelled the screenshot path -o. The CLI spells it --out, + # because -o is the CLI's global output-format flag. Translate rather than + # break every caller that already passes -o. + if [ "$op" = screenshot ]; then + local -a mapped=(); local a + for a in ${@+"$@"}; do case "$a" in -o) mapped+=(--out);; *) mapped+=("$a");; esac; done + set -- ${mapped[@]+"${mapped[@]}"} + fi + + "$CLI" sandbox computer "$op" "$id" --screen "$screen" ${@+"$@"} } # ───────────────────────────── cluster: N boxes on one private network (by-name DNS) @@ -1091,6 +966,10 @@ cmd_resume(){ esac box_resume_if_paused "$id" echo "cos: $id running. Re-run 'cos sync' / 'cos tunnel' / 'cos expose' if you had them up." >&2 + # A resumed desktop box answers reads (screen, screenshot) before its input + # stack is back, so a click can still 409 for a moment. `cos desktop` waits + # and mints a fresh link — the old one is expired anyway. + [ -n "$(state_get desktop_screen)" ] && echo "cos: this box had a desktop — re-run 'cos desktop' before driving it again." >&2 } # ───────────────────────────── template: build a custom rootfs from a Dockerfile diff --git a/packages/claude-code-plugin/skills/using-createos-sandbox/SKILL.md b/packages/claude-code-plugin/skills/using-createos-sandbox/SKILL.md index 3969c3d..53a87e2 100644 --- a/packages/claude-code-plugin/skills/using-createos-sandbox/SKILL.md +++ b/packages/claude-code-plugin/skills/using-createos-sandbox/SKILL.md @@ -169,42 +169,21 @@ cos computer open https://example.com cos computer click 640 400 cos computer type 'hello' cos computer key ctrl l # a chord -cos computer help # every op, plus `raw` for the rest of the API +cos computer help # every op cos wraps ``` -The two halves are independent and useful together: the URL lets the **user** watch and take over in a browser, while `cos computer` lets **you** act. `desktop:1` ships the same five agent CLIs `devbox:1` does, so "run an agent on a box and let the user watch the screen" needs no extra setup — see Pattern E and `references/coding-agents.md` for pointing them at a provider. +The two halves are independent and useful together: the URL lets the **user** watch and take over in a browser, while `cos computer` lets **you** act. `desktop:1` also ships the Claude Code, Codex, Pi, OpenCode and Cursor CLIs, so "run an agent on a box and let the user watch the screen" needs no extra setup. + +Both verbs are thin wrappers over `createos sandbox desktop` and `createos sandbox computer`. cos adds only the project-box resolution — which box belongs to this directory, and creating one on a desktop image when there is none. Anything cos does not wrap is reachable directly: run `createos sandbox computer --help` for the full op list, including clipboard, window manipulation and screen resize. A box you address by name or id needs no project state, so `createos sandbox computer screenshot ` works on any desktop box, not just this project's. Things that will bite you if you skip them: - **Take a screenshot before you click, and after.** You are driving blind otherwise — nothing in this API confirms that a click landed on what you meant. - **Coordinates are raw X11 pixels** of that screen, with no scaling or DPI translation anywhere. Read the bounds from `cos computer screen` rather than assuming 1280x800. -- **The desktop boots after the box reports `running`.** `cos desktop` polls for readiness; a bare `cos up -r desktop:1` does not, and every computer call will fail until the stack is up. -- **A `409` is ambiguous by design.** fc returns `desktop_unavailable` both while the desktop is still coming up and when an action fails on a perfectly healthy desktop, so never read it as "the box is broken". +- **The desktop boots after the box reports `running`.** `cos desktop` waits for readiness; a bare `cos up -r desktop:1` does not, and every computer call will fail until the stack is up. +- **A `409` is ambiguous by design.** fc returns `desktop_unavailable` both while the desktop is still coming up and when an action fails on a perfectly healthy desktop, so never read it as "the box is broken". The CLI retries the first case for you during `cos desktop`. - **The noVNC link is a bearer URL** — anyone holding it can drive the desktop, and the token expires. Say so when handing it over, and don't paste it anywhere it will outlive the box. -- This is the one place `cos` calls the CreateOS REST API directly, because the `createos` CLI has no computer or desktop command yet. Everything else still goes through the CLI. - -## Pattern E — hand the job to another coding agent - -`devbox:1` ships five agent CLIs — `claude`, `codex`, `opencode`, `pi`, `cursor-agent` — so "have a different agent do this in a box" needs no install. Each can be pointed at **OpenRouter, an OpenAI-compatible provider, or an Anthropic-compatible one** (with real exceptions, below), so this does not require the user to hold an Anthropic subscription. - -```bash -export OPENROUTER_API_KEY=sk-or-... # in the user's own shell -cos agent -m openai/gpt-5.6-luna -o . claude . 'fix the failing tests' -cos agent -P anthropic -m claude-sonnet-4-5 -o . pi . 'add type hints' -cos agent -P https://gw.example.com/v1 -k MY_KEY -o . codex . 'port this to v2' -``` - -`cos agent ` stages the directory, wires the agent to the provider, runs it headless with its permission gate off (the microVM is the isolation), and destroys the box. Flags: `-P` provider (default `openrouter`), `-m` model, `-k` the env var holding the key, plus every `offload` flag. - -What to get right: - -- **`-o .` or the work is lost.** Without it the box is destroyed with the agent's edits inside and you keep only the transcript. Run it on a clean tree so `git diff` shows exactly what changed. -- **Keys come from the user's shell via `-v`/`-k`, never from the conversation.** Same rule as the CreateOS key — asking them to paste a provider key writes it to the transcript. -- **Two agents can't be repointed the way you'd assume.** `cursor-agent` runs only on Cursor's own service — no third-party provider path exists. `codex` speaks only the OpenAI **Responses** wire, so a plain Chat-Completions gateway is rejected at config load. `opencode` and `pi` will talk to anything. -- **An agent box holds an API key**, so `-p openrouter` (or `-p openai` / `-p anthropic`) is worth reaching for, composed with whatever registries the task itself needs: `-p openrouter -p npm`. - -The per-agent env blocks, the wire-protocol matrix, and the traps (claude needs `IS_SANDBOX=1` as root; `openrouter.ai/api` vs `/api/v1`) → **`references/coding-agents.md`**. - +- **These need a recent `createos` CLI.** Desktop and computer-use moved out of cos into the CLI, so an older binary has no `sandbox computer` command. `cos` says so and tells the user to upgrade rather than failing obscurely. ## Scratch box and data disks ```bash diff --git a/packages/codex-plugin/scripts/cos b/packages/codex-plugin/scripts/cos index d9d6a9a..331bf21 100755 --- a/packages/codex-plugin/scripts/cos +++ b/packages/codex-plugin/scripts/cos @@ -698,96 +698,23 @@ cmd_unexpose(){ } # ══════════════════════════ desktop / computer use ════════════════════════════ -# `createos` has no computer or desktop command, so these two verbs are the only -# place cos talks to the sandbox REST API directly instead of shelling out to the -# CLI. Everything else in this script stays CLI-driven; when the CLI grows a -# `sandbox computer` group, delete api()/api_auth() and shell out like the rest. +# Both verbs shell out to `createos sandbox desktop` and `createos sandbox +# computer`, like every other verb here. The REST layer this script used to +# carry is gone: the CLI owns the readiness wait, the error-code mapping and +# the noVNC link now, so every host that shells out gets them, not just this one. # -# Auth mirrors the CLI exactly: an api key goes in X-Api-Key, an OAuth JWT in -# X-Access-Token. fc rejects Bearer on user-facing routes, and it rejects a JWT -# sent under X-Api-Key ("invalid api key") — the two are not interchangeable. -api_base(){ printf '%s' "${CREATEOS_SANDBOX_URL:-https://api.sb.createos.sh}"; } - -api_auth(){ - # Precedence MUST match the CLI's (createos-cli cmd/root/root.go): an OAuth - # session wins, an api key is the fallback. Inverting it authenticates these - # direct calls as a different identity than every CLI-driven verb — and the - # symptom is a 404 on a box cos itself just created, which reads like the box - # is missing rather than like an auth mismatch. - local exp at - if [ -f "$CREATEOS_DIR/.oauth" ]; then - exp=$(jq -r '.expires_at // 0' "$CREATEOS_DIR/.oauth" 2>/dev/null || echo 0) - numeric "$exp" || exp=0 - # cos deliberately does not implement OAuth refresh. The CLI already refreshes - # in its pre-flight and rewrites ~/.createos/.oauth, so poke it and re-read - # rather than carrying a second, subtly different refresh implementation. - if [ "$(date +%s)" -ge "$((exp - 60))" ]; then - "$CLI" -o json sandbox ls >/dev/null 2>&1 || true - fi - at=$(jq -r '.access_token // empty' "$CREATEOS_DIR/.oauth" 2>/dev/null || true) - [ -n "$at" ] && { printf 'X-Access-Token: %s' "$at"; return 0; } - fi - # NOTE: the CLI itself does NOT read CREATEOS_API_KEY (it has no such env var), - # so this branch works for these REST calls but not for any CLI-driven verb. - [ -n "${CREATEOS_API_KEY:-}" ] && { printf 'X-Api-Key: %s' "$CREATEOS_API_KEY"; return 0; } - [ -f "$CREATEOS_DIR/.token" ] && { printf 'X-Api-Key: %s' "$(tr -d '\r\n' <"$CREATEOS_DIR/.token")"; return 0; } - die "not signed in — run 'cos auth'" -} - -# Map the computer API's error codes onto something actionable. Worth doing by -# hand: `desktop_unavailable` is fc's catch-all for every X failure, so the raw -# message alone never tells you whether the desktop is still booting or the -# action itself failed on a live desktop. -api_check(){ local code=$1 out=$2 what=$3 msg - case "$code" in 2??) return 0;; esac - msg=$(printf '%s' "$out" | jq -r '.message // .error // empty' 2>/dev/null || true) - case "$code" in - 000) die "no response from $(api_base) — network down, or CREATEOS_SANDBOX_URL points somewhere wrong";; - 401|403) die "auth rejected (HTTP $code). The API key or browser session is invalid or expired. - Ask the user to re-run 'createos login' in their own terminal, or export CREATEOS_API_KEY.";; - 404) die "not found (HTTP 404): ${msg:-$what} - Either the box is gone, or it has no such screen — computer-use needs a desktop image ('cos desktop').";; - 409) case "$msg" in - *ingress*) die "409: $msg — 'cos desktop' turns ingress on for you";; - *desktop_unavailable*|*) die "desktop did not answer (HTTP 409 ${msg:-desktop_unavailable}). - fc returns this both while the desktop is still booting AND when an action fails on a live desktop. - If the box just came up, 'cos desktop' waits for readiness — run that first.";; - esac;; - 429) die "rate limited (429) — the control plane caps concurrent screenshots. Retry in a second.";; - 501) die "501 desktop_tools_unavailable — this rootfs has no desktop tools installed. Recreate with: cos down && cos desktop";; - *) die "API error HTTP $code on $what${msg:+: $msg}";; - esac -} - -# api [json-body] → prints the response payload, unwrapped from -# fc's JSend envelope. Callers pipe it through jq for the fields they want. -api(){ local method=$1 path=$2 body=${3:-} hdr code out tmp - hdr=$(api_auth) || exit 1 - tmp=$(mktemp) - local args=(-sS -X "$method" -H "$hdr" -o "$tmp" -w '%{http_code}' --max-time 60) - [ -n "$body" ] && args+=(-H 'Content-Type: application/json' -d "$body") - code=$(curl "${args[@]}" "$(api_base)$path" 2>/dev/null) || code=000 - out=$(cat "$tmp"); rm -f "$tmp" - api_check "$code" "$out" "$method $path" - printf '%s' "$out" | jq -c '.data // .' 2>/dev/null || printf '%s' "$out" -} - -# The desktop stack (Xvfb → XFCE → x11vnc → websockify) starts AFTER the box -# reports `running`, so every computer call 404s or 409s for the first while. -# Neither fc nor the SDK polls for this — every caller has to, so cos does it here. -desktop_wait(){ local id=$1 screen=$2 to=${3:-120} hdr code i=0 said=0 - hdr=$(api_auth) || exit 1 - while [ "$i" -lt "$to" ]; do - code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 10 -H "$hdr" \ - "$(api_base)/v1/sandboxes/$id/computer/screen?screen_id=$screen" 2>/dev/null) || code=000 - case "$code" in - 2??) [ "$said" = 1 ] && echo "cos: desktop ready" >&2; return 0;; - 401|403|501) api_check "$code" '' "desktop readiness";; - esac - [ "$said" = 0 ] && { echo "cos: waiting for the desktop stack to come up (up to ${to}s)…" >&2; said=1; } - sleep 2; i=$((i+2)) - done - die "desktop did not come up within ${to}s on $id ($screen) — check: cos run 'pgrep -a Xvfb; pgrep -a websockify'" +# cos keeps only what the CLI cannot know: which box belongs to this project, +# and how to create one on a desktop image when there is none. + +# The CLI grew these commands after this script shipped, so probe for them +# rather than failing with a bare "unknown command". Same shape as the +# `sandbox sync --mode` probe further down. +require_cli_computer(){ + "$CLI" sandbox computer --help >/dev/null 2>&1 && return 0 + die "the installed createos CLI has no 'sandbox computer' command. + Desktop and computer-use moved out of cos and into the CLI. Upgrade it: + createos upgrade + or re-run the installer from https://createos.sh" } # ───────────────────────────── desktop: graphical box + a live noVNC URL @@ -796,9 +723,10 @@ cmd_desktop(){ local shape=s-2vcpu-4gb screen=screen-0; local OPTIND=1 o while getopts "s:S:h" o; do case $o in s) shape=$OPTARG;; S) screen=$OPTARG;; - h) echo "cos desktop [-s shape] [-S screen-N] desktop:1 box + ingress + live noVNC URL"; return 0;; + h) echo "cos desktop [-s shape] [-S screen-N] desktop:1 box + a live noVNC URL"; return 0;; *) die "usage: cos desktop [-s shape] [-S screen-N]";; esac; done shift $((OPTIND-1)) + require_cli_computer local id; id=$(state_get id) if [ -z "$id" ] || ! box_live "$id"; then @@ -807,8 +735,9 @@ cmd_desktop(){ id=$(state_get id); [ -n "$id" ] || die "box creation did not record an id" else box_resume_if_paused "$id" - # Never silently drive a non-desktop box: every computer call would 501 with - # a much less obvious message than saying so here. + # The CLI refuses a non-desktop box too, but it tells you to create a new + # sandbox. That is the wrong move for a project box — there is one per + # directory, so the fix is to replace this one. local rf; rf=$("$CLI" -o json sandbox get "$id" 2>/dev/null | jq -r '.rootfs // empty') case "$rf" in *desktop*) :;; @@ -818,36 +747,26 @@ cmd_desktop(){ fi wait_running "$id" 30 || die "box $id not running" - "$CLI" sandbox edit "$id" --ingress on >/dev/null 2>&1 || die "failed to enable ingress on $id" - desktop_wait "$id" "$screen" - - local conn url exp - conn=$(api GET "/v1/sandboxes/$id/computer/screens/$screen/connect") - url=$(printf '%s' "$conn" | jq -r '.url // empty') - exp=$(printf '%s' "$conn" | jq -r '.expires_at // "?"') - [ -n "$url" ] || die "connect returned no URL — ingress is off on $id (fc only mints one when ingress is enabled)" state_set desktop_screen "$screen" - { - echo "cos: desktop $screen on $id — open in a browser:" - echo "cos: ⚠ anyone with this link can drive the desktop. The token expires $exp, and re-running" - echo "cos: 'cos desktop' mints a fresh one (which invalidates this link for NEW connections)." - } >&2 - echo "$url" + # The CLI turns ingress on, waits for the desktop stack, and prints the URL + # together with its expiry and the warning that the link is the credential. + "$CLI" sandbox desktop "$id" --screen "$screen" } # ───────────────────────────── computer: drive that desktop from the agent side computer_usage(){ cat <<'EOF' cos computer drive the desktop in the project box (needs `cos desktop` first) - screenshot [-o file] capture PNG (default: $STATE_DIR/screenshot.png), prints the path + screenshot [-o file] capture PNG, prints the path screen screen geometry {width,height} cursor cursor position {x,y} + windows list windows on the screen move move the pointer click [ ] click (optionally move there first) type type a string key ... press a chord, e.g. cos computer key ctrl l open open a target in the desktop browser - windows list windows on the screen - raw [json] any other computer endpoint (path relative to .../computer) +Each op runs `createos sandbox computer ` against this project's box; run +`createos sandbox computer --help` for the ops not listed here. Screen defaults to screen-0; override per call with COS_SCREEN=screen-N. Coordinates are raw X11 pixels of that screen — match them against `cos computer screen`. EOF @@ -856,66 +775,22 @@ EOF cmd_computer(){ local op=${1:-}; shift || true case "$op" in ''|-h|--help|help) computer_usage; return 0;; esac + require_cli_computer local id; id=$(state_get id) [ -n "$id" ] || die "no active box — run 'cos desktop' first" [ "$(box_status "$id")" = paused ] && die "box $id is paused — 'cos resume' first" local screen=${COS_SCREEN:-$(state_get desktop_screen)}; screen=${screen:-screen-0} - local base="/v1/sandboxes/$id/computer" q="screen_id=$screen" - - case "$op" in - screenshot) - local out="$STATE_DIR/screenshot.png" hdr code - [ "${1:-}" = "-o" ] && { out=${2:?-o needs a path}; shift 2; } - hdr=$(api_auth) || exit 1 - # Screenshot is the one computer route that returns bytes, not JSON, so it - # bypasses api() entirely — the PNG is passed through from fc verbatim. - code=$(curl -sS -H "$hdr" -o "$out" -w '%{http_code}' --max-time 60 \ - "$(api_base)$base/screenshot?$q" 2>/dev/null) || code=000 - case "$code" in - 2??) :;; - *) local body; body=$(cat "$out" 2>/dev/null || true); rm -f "$out"; api_check "$code" "$body" "screenshot";; - esac - echo "cos: screenshot → $out ($(wc -c <"$out" | tr -d ' ') bytes) — open it with the Read tool" >&2 - echo "$out";; - screen) api GET "$base/screen?$q";; - cursor) api GET "$base/cursor?$q";; - windows) api GET "$base/windows?$q";; - move) - numeric "${1:-}" && numeric "${2:-}" || die "usage: cos computer move " - api POST "$base/mouse/move?$q" "$(jq -nc --argjson x "$1" --argjson y "$2" '{x:$x,y:$y}')" >/dev/null - echo "cos: moved to $1,$2" >&2;; - click) - if [ $# -ge 2 ]; then - numeric "$1" && numeric "$2" || die "usage: cos computer click [ ]" - api POST "$base/mouse/click?$q" "$(jq -nc --argjson x "$1" --argjson y "$2" '{x:$x,y:$y}')" >/dev/null - echo "cos: clicked $1,$2" >&2 - else - api POST "$base/mouse/click?$q" '{}' >/dev/null - echo "cos: clicked at the current cursor position" >&2 - fi;; - type) - [ $# -ge 1 ] || die "usage: cos computer type " - # Unquoted multi-word text arrives as separate argv entries; join before - # sending so `cos computer type hello world` types the space too. - local text="$*" - api POST "$base/keyboard/type?$q" "$(jq -nc --arg t "$text" '{text:$t}')" >/dev/null - echo "cos: typed ${#text} chars" >&2;; - key) - [ $# -ge 1 ] || die "usage: cos computer key ... e.g. cos computer key ctrl l" - api POST "$base/keyboard/press?$q" "$(jq -nc '{keys:$ARGS.positional}' --args "$@")" >/dev/null - echo "cos: pressed $*" >&2;; - open) - [ $# -ge 1 ] || die "usage: cos computer open " - api POST "$base/open?$q" "$(jq -nc --arg t "$1" '{target:$t}')" >/dev/null - echo "cos: opened $1" >&2;; - raw) - # Escape hatch: cos wraps the handful of ops an agent loop actually needs, - # not all ~30 computer routes. Everything else goes through here. - local m=${1:?usage: cos computer raw [json]} p=${2:?path required}; shift 2 - case "$p" in /*) :;; *) p="$base/$p";; esac - api "$m" "$p" "${1:-}";; - *) die "unknown computer op '$op' — run 'cos computer help'";; - esac + + # cos has always spelled the screenshot path -o. The CLI spells it --out, + # because -o is the CLI's global output-format flag. Translate rather than + # break every caller that already passes -o. + if [ "$op" = screenshot ]; then + local -a mapped=(); local a + for a in ${@+"$@"}; do case "$a" in -o) mapped+=(--out);; *) mapped+=("$a");; esac; done + set -- ${mapped[@]+"${mapped[@]}"} + fi + + "$CLI" sandbox computer "$op" "$id" --screen "$screen" ${@+"$@"} } # ───────────────────────────── cluster: N boxes on one private network (by-name DNS) @@ -1091,6 +966,10 @@ cmd_resume(){ esac box_resume_if_paused "$id" echo "cos: $id running. Re-run 'cos sync' / 'cos tunnel' / 'cos expose' if you had them up." >&2 + # A resumed desktop box answers reads (screen, screenshot) before its input + # stack is back, so a click can still 409 for a moment. `cos desktop` waits + # and mints a fresh link — the old one is expired anyway. + [ -n "$(state_get desktop_screen)" ] && echo "cos: this box had a desktop — re-run 'cos desktop' before driving it again." >&2 } # ───────────────────────────── template: build a custom rootfs from a Dockerfile diff --git a/packages/codex-plugin/skills/using-createos-sandbox/SKILL.md b/packages/codex-plugin/skills/using-createos-sandbox/SKILL.md index 3969c3d..53a87e2 100644 --- a/packages/codex-plugin/skills/using-createos-sandbox/SKILL.md +++ b/packages/codex-plugin/skills/using-createos-sandbox/SKILL.md @@ -169,42 +169,21 @@ cos computer open https://example.com cos computer click 640 400 cos computer type 'hello' cos computer key ctrl l # a chord -cos computer help # every op, plus `raw` for the rest of the API +cos computer help # every op cos wraps ``` -The two halves are independent and useful together: the URL lets the **user** watch and take over in a browser, while `cos computer` lets **you** act. `desktop:1` ships the same five agent CLIs `devbox:1` does, so "run an agent on a box and let the user watch the screen" needs no extra setup — see Pattern E and `references/coding-agents.md` for pointing them at a provider. +The two halves are independent and useful together: the URL lets the **user** watch and take over in a browser, while `cos computer` lets **you** act. `desktop:1` also ships the Claude Code, Codex, Pi, OpenCode and Cursor CLIs, so "run an agent on a box and let the user watch the screen" needs no extra setup. + +Both verbs are thin wrappers over `createos sandbox desktop` and `createos sandbox computer`. cos adds only the project-box resolution — which box belongs to this directory, and creating one on a desktop image when there is none. Anything cos does not wrap is reachable directly: run `createos sandbox computer --help` for the full op list, including clipboard, window manipulation and screen resize. A box you address by name or id needs no project state, so `createos sandbox computer screenshot ` works on any desktop box, not just this project's. Things that will bite you if you skip them: - **Take a screenshot before you click, and after.** You are driving blind otherwise — nothing in this API confirms that a click landed on what you meant. - **Coordinates are raw X11 pixels** of that screen, with no scaling or DPI translation anywhere. Read the bounds from `cos computer screen` rather than assuming 1280x800. -- **The desktop boots after the box reports `running`.** `cos desktop` polls for readiness; a bare `cos up -r desktop:1` does not, and every computer call will fail until the stack is up. -- **A `409` is ambiguous by design.** fc returns `desktop_unavailable` both while the desktop is still coming up and when an action fails on a perfectly healthy desktop, so never read it as "the box is broken". +- **The desktop boots after the box reports `running`.** `cos desktop` waits for readiness; a bare `cos up -r desktop:1` does not, and every computer call will fail until the stack is up. +- **A `409` is ambiguous by design.** fc returns `desktop_unavailable` both while the desktop is still coming up and when an action fails on a perfectly healthy desktop, so never read it as "the box is broken". The CLI retries the first case for you during `cos desktop`. - **The noVNC link is a bearer URL** — anyone holding it can drive the desktop, and the token expires. Say so when handing it over, and don't paste it anywhere it will outlive the box. -- This is the one place `cos` calls the CreateOS REST API directly, because the `createos` CLI has no computer or desktop command yet. Everything else still goes through the CLI. - -## Pattern E — hand the job to another coding agent - -`devbox:1` ships five agent CLIs — `claude`, `codex`, `opencode`, `pi`, `cursor-agent` — so "have a different agent do this in a box" needs no install. Each can be pointed at **OpenRouter, an OpenAI-compatible provider, or an Anthropic-compatible one** (with real exceptions, below), so this does not require the user to hold an Anthropic subscription. - -```bash -export OPENROUTER_API_KEY=sk-or-... # in the user's own shell -cos agent -m openai/gpt-5.6-luna -o . claude . 'fix the failing tests' -cos agent -P anthropic -m claude-sonnet-4-5 -o . pi . 'add type hints' -cos agent -P https://gw.example.com/v1 -k MY_KEY -o . codex . 'port this to v2' -``` - -`cos agent ` stages the directory, wires the agent to the provider, runs it headless with its permission gate off (the microVM is the isolation), and destroys the box. Flags: `-P` provider (default `openrouter`), `-m` model, `-k` the env var holding the key, plus every `offload` flag. - -What to get right: - -- **`-o .` or the work is lost.** Without it the box is destroyed with the agent's edits inside and you keep only the transcript. Run it on a clean tree so `git diff` shows exactly what changed. -- **Keys come from the user's shell via `-v`/`-k`, never from the conversation.** Same rule as the CreateOS key — asking them to paste a provider key writes it to the transcript. -- **Two agents can't be repointed the way you'd assume.** `cursor-agent` runs only on Cursor's own service — no third-party provider path exists. `codex` speaks only the OpenAI **Responses** wire, so a plain Chat-Completions gateway is rejected at config load. `opencode` and `pi` will talk to anything. -- **An agent box holds an API key**, so `-p openrouter` (or `-p openai` / `-p anthropic`) is worth reaching for, composed with whatever registries the task itself needs: `-p openrouter -p npm`. - -The per-agent env blocks, the wire-protocol matrix, and the traps (claude needs `IS_SANDBOX=1` as root; `openrouter.ai/api` vs `/api/v1`) → **`references/coding-agents.md`**. - +- **These need a recent `createos` CLI.** Desktop and computer-use moved out of cos into the CLI, so an older binary has no `sandbox computer` command. `cos` says so and tells the user to upgrade rather than failing obscurely. ## Scratch box and data disks ```bash From 24a559d916f6395b4de5724cf086cc724a7e7c35 Mon Sep 17 00:00:00 2001 From: pratikbin <68642400+pratikbin@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:00:17 +0530 Subject: [PATCH 3/3] refactor(cos): simplify merged cli delegation --- packages/claude-code-plugin/scripts/cos | 14 ++++++-------- packages/codex-plugin/scripts/cos | 14 ++++++-------- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/packages/claude-code-plugin/scripts/cos b/packages/claude-code-plugin/scripts/cos index dc611aa..a187fa6 100755 --- a/packages/claude-code-plugin/scripts/cos +++ b/packages/claude-code-plugin/scripts/cos @@ -990,12 +990,11 @@ cmd_fork(){ # A fork is an independent clone, so `down` won't destroy it — but record it, or # nothing ever will and it leaks silently. `down` reports it; `down -f` reaps it. local forks nid; forks=$(state_get fork_ids) - for nid in $newids; do forks="${forks:+$forks }$nid"; done - state_set fork_ids "$forks" - for nid in $newids; do + forks="${forks:+$forks }$nid" echo "cos: fork ready: $nid (independent clone; NOT tracked as the project box)" >&2 done + state_set fork_ids "$forks" echo "cos: exec: createos sandbox exec --stream -- bash -lc '…'" >&2 echo "cos: destroy: createos sandbox rm -y (or 'cos down -f' to reap forks with the box)" >&2 printf '%s\n' "$newids" @@ -1212,18 +1211,17 @@ cmd_matrix(){ h) matrix_usage; exit 0;; *) matrix_usage >&2; exit 2;; esac; done shift $((OPTIND-1)) + local dir="" if [ -n "$from" ]; then [ $# -ge 1 ] || { matrix_usage; exit 0; } args+=(--from "$from") - local cmd; for cmd in "$@"; do args+=(--job "$cmd"); done - "$CLI" sandbox matrix "${args[@]}" ${COS_ENV[@]+"${COS_ENV[@]}"} else [ $# -ge 2 ] || { matrix_usage; exit 0; } - local dir=$1; shift + dir=$1; shift [ -d "$dir" ] || die "no such dir: $dir" - local cmd; for cmd in "$@"; do args+=(--job "$cmd"); done - "$CLI" sandbox matrix "${args[@]}" ${COS_ENV[@]+"${COS_ENV[@]}"} "$dir" fi + local cmd; for cmd in "$@"; do args+=(--job "$cmd"); done + "$CLI" sandbox matrix "${args[@]}" ${COS_ENV[@]+"${COS_ENV[@]}"} ${dir:+"$dir"} } # ───────────────────────────── disk: BYO S3 bucket mounts (attach/detach on the project box) diff --git a/packages/codex-plugin/scripts/cos b/packages/codex-plugin/scripts/cos index dc611aa..a187fa6 100755 --- a/packages/codex-plugin/scripts/cos +++ b/packages/codex-plugin/scripts/cos @@ -990,12 +990,11 @@ cmd_fork(){ # A fork is an independent clone, so `down` won't destroy it — but record it, or # nothing ever will and it leaks silently. `down` reports it; `down -f` reaps it. local forks nid; forks=$(state_get fork_ids) - for nid in $newids; do forks="${forks:+$forks }$nid"; done - state_set fork_ids "$forks" - for nid in $newids; do + forks="${forks:+$forks }$nid" echo "cos: fork ready: $nid (independent clone; NOT tracked as the project box)" >&2 done + state_set fork_ids "$forks" echo "cos: exec: createos sandbox exec --stream -- bash -lc '…'" >&2 echo "cos: destroy: createos sandbox rm -y (or 'cos down -f' to reap forks with the box)" >&2 printf '%s\n' "$newids" @@ -1212,18 +1211,17 @@ cmd_matrix(){ h) matrix_usage; exit 0;; *) matrix_usage >&2; exit 2;; esac; done shift $((OPTIND-1)) + local dir="" if [ -n "$from" ]; then [ $# -ge 1 ] || { matrix_usage; exit 0; } args+=(--from "$from") - local cmd; for cmd in "$@"; do args+=(--job "$cmd"); done - "$CLI" sandbox matrix "${args[@]}" ${COS_ENV[@]+"${COS_ENV[@]}"} else [ $# -ge 2 ] || { matrix_usage; exit 0; } - local dir=$1; shift + dir=$1; shift [ -d "$dir" ] || die "no such dir: $dir" - local cmd; for cmd in "$@"; do args+=(--job "$cmd"); done - "$CLI" sandbox matrix "${args[@]}" ${COS_ENV[@]+"${COS_ENV[@]}"} "$dir" fi + local cmd; for cmd in "$@"; do args+=(--job "$cmd"); done + "$CLI" sandbox matrix "${args[@]}" ${COS_ENV[@]+"${COS_ENV[@]}"} ${dir:+"$dir"} } # ───────────────────────────── disk: BYO S3 bucket mounts (attach/detach on the project box)