diff --git a/README.md b/README.md index 6643e80..0baea63 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,7 @@ Setup, configuration, limits, and troubleshooting live in the | Command | What | | ------------------------------------------------ | ----------------------------------------------------------------------- | | `/createos-sandbox:offload ` | one-shot: stage → run → pull artifacts → destroy | +| `/createos-sandbox:exec [args]` | run one untrusted/ad-hoc source file in a throwaway box | | `/createos-sandbox:fanout [cmd2 …]` | run each command in its own throwaway box, in parallel | | `/createos-sandbox:shell` | instant throwaway interactive Linux (destroyed on exit) | | `/createos-sandbox:up` · `run` · `sync` · `down` | reusable per-repo box + file sync for live dev loops | diff --git a/packages/claude-code-plugin/README.md b/packages/claude-code-plugin/README.md index 48a236d..ae5130f 100644 --- a/packages/claude-code-plugin/README.md +++ b/packages/claude-code-plugin/README.md @@ -70,7 +70,7 @@ The plugin is a **thin Claude-facing surface** over the `createos` CLI. It ships | Piece | Path | Role | | ------------------ | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -| **Slash commands** | `commands/*.md` | 20 commands (`offload`, `fanout`, `shell`, …), each a thin wrapper that calls `scripts/cos` | +| **Slash commands** | `commands/*.md` | 22 commands (`offload`, `exec`, `fanout`, `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 | @@ -125,6 +125,7 @@ claude --plugin-dir /path/to/createos-plugin/packages/claude-code-plugin | Command | Summary | | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | | [`offload`](#offload--one-shot) `[flags] ` | one-shot: stage → run (keepalive) → pull → destroy | +| [`exec`](#exec--remote-code-execution) `[-l lang] [-i stdin] [-t secs] [-N] [args]` | run one source file (untrusted/ad-hoc) in a throwaway box | | [`fanout`](#fanout--parallel-boxes) `[-j N] [flags] [cmd2] …` | run each command in its own throwaway box, in parallel | | [`agent`](#coding-agents) `[flags] ` | run claude/codex/opencode/pi/cursor on your code in a box | | [`shell`](#shell--throwaway-linux) `[-s] [-r] [-e\|-p\|-E]` | instant throwaway interactive Linux (destroyed on exit) | @@ -176,6 +177,16 @@ The core command. Stages a directory into a fresh box, runs a command, optionall /createos-sandbox:offload -p python-uv -o dist . "uv sync --frozen && uv run python -m build" ``` +### Exec — remote code execution + +Run untrusted code or any ad-hoc script — anything you would rather not run locally — as one source file in a throwaway box. No directory to stage. + +``` +/createos-sandbox:exec [-l lang] [-i stdin-file] [-t secs] [-N] [-p preset] [-e dom] [args...] +``` + +Languages `py js mjs cjs ts go sh rb c cpp rs`, picked from the extension or `-l`. `-i` feeds stdin, `-t` caps wall-clock time (default 120 s, exit 124), `-N` denies all egress (default is unrestricted, like `offload`). stdout/stderr and the exit code are the program's own. + **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. ### Fanout — parallel boxes diff --git a/packages/claude-code-plugin/commands/exec.md b/packages/claude-code-plugin/commands/exec.md new file mode 100644 index 0000000..88b0126 --- /dev/null +++ b/packages/claude-code-plugin/commands/exec.md @@ -0,0 +1,11 @@ +--- +description: Remote code execution — run untrusted code or any ad-hoc script/snippet (py, js, mjs, cjs, ts, go, sh, rb, c, cpp, rs) in a throwaway CreateOS box instead of on this machine. Timeout, optional stdin, real exit code, auto-destroys. +argument-hint: "[-l lang] [-i stdin-file] [-t secs] [-N] [-p preset] [-e dom] [args...]" +allowed-tools: Bash +--- + +Run one source file in a disposable CreateOS Sandbox. Flags precede ``; everything after `` goes to the program untouched. Language comes from the extension (`-l` overrides). Egress is unrestricted by default; `-p`/`-e` allow just those hosts, `-N` blocks outbound connections. `-i` feeds a local file to stdin; `-t` is a wall-clock limit (default 120 s, exit 124 when hit). + +!`if test -n "$ARGUMENTS"; then "${CLAUDE_PLUGIN_ROOT}/scripts/cos" exec $ARGUMENTS; else "${CLAUDE_PLUGIN_ROOT}/scripts/cos" exec; fi` + +Report the program's stdout, stderr and the `cos: exit=N time=Ns` line above. Exit 124 means the timeout killed it. diff --git a/packages/claude-code-plugin/scripts/cos b/packages/claude-code-plugin/scripts/cos index d9d6a9a..d23c64e 100755 --- a/packages/claude-code-plugin/scripts/cos +++ b/packages/claude-code-plugin/scripts/cos @@ -462,6 +462,87 @@ example: EOF } +# ─────────────────────────────────────────────────── run one untrusted source file +# Egress stays open by default (snippets often call APIs). -N denies all: any rule flips +# CreateOS to deny-by-default and an IP rule is enforced at once (hostname rules are not), +# so one unroutable TEST-NET-1 address allows nothing. +DENY_ALL_EGRESS=192.0.2.1/32 +cmd_exec(){ # no _norm: it would rewrite the program's own --flags after ; getopts stops at + local shape=s-1vcpu-1gb rootfs=devbox:1 lang="" stdin="" to=120 deny=0 + COS_EGRESS=(); local -a _d; local OPTIND=1 o doms d + while getopts "s:r:l:i:t:e:p:v:Nh" o; do case $o in + s) shape=$OPTARG;; r) rootfs=$OPTARG;; l) lang=$OPTARG;; i) stdin=$OPTARG;; t) to=$OPTARG;; + v) add_env "$OPTARG";; + e) COS_EGRESS+=(--egress "$OPTARG");; + p) doms=$(egress_preset "$OPTARG") || die "unknown egress preset '$OPTARG' (have: $EGRESS_PRESETS)" + read -ra _d <<<"$doms"; for d in "${_d[@]}"; do COS_EGRESS+=(--egress "$d"); done;; + N) deny=1;; + h) exec_usage; exit 0;; + *) exec_usage >&2; exit 2;; esac; done + shift $((OPTIND-1)) + if [ $# -lt 1 ]; then exec_usage; exit 0; fi + local file=$1; shift + numeric "$to" || die "-t wants whole seconds, got '$to'" + [ -z "$stdin" ] || [ -f "$stdin" ] || die "no such stdin file: $stdin" + local src=$file + if [ "$file" = - ]; then src=$(mktemp); cat >"$src"; else [ -f "$file" ] || die "no such file: $file"; fi + [ -n "$lang" ] || lang=${file##*.} + [ "$lang" != - ] && [ "$lang" != "$file" ] || die "cannot tell the language — pass -l py|js|mjs|cjs|ts|go|sh|rb|c|cpp|rs" + + local main run + case "$lang" in + py|python) main=main.py; run="python3 main.py";; + js|node) main=main.js; run="node main.js";; + mjs) main=main.mjs; run="node main.mjs";; + cjs) main=main.cjs; run="node main.cjs";; + ts|typescript) main=main.ts; run="bun main.ts";; + go) main=main.go; run="go run main.go";; + sh|bash) main=main.sh; run="bash main.sh";; + rb|ruby) main=main.rb; run="ruby main.rb";; + c) main=main.c; run="gcc -O2 -o main main.c && ./main";; + cpp|cc|cxx) main=main.cpp; run="g++ -O2 -o main main.cpp && ./main";; + rs|rust) main=main.rs; run="rustc -O -o main main.rs && ./main";; + *) die "unsupported language '$lang' — py|js|mjs|cjs|ts|go|sh|rb|c|cpp|rs (anything else: cos offload)";; + esac + local a; for a in "$@"; do run="$run $(printf '%q' "$a")"; done + + [ "$deny" = 1 ] && { COS_EGRESS=(--egress "$DENY_ALL_EGRESS"); echo "cos: egress DENIED (-N)" >&2; } + + OFFLOAD_ID=$(create_box "cos-x-$$-${RANDOM}" "$shape" "$rootfs") + KEEP=0; trap on_offload_exit EXIT + wait_running "$OFFLOAD_ID" 30 || die "box $OFFLOAD_ID not running after 30s" + "$CLI" sandbox push "$OFFLOAD_ID" "$src" "/work/$main" >/dev/null 2>&1 || die "push failed" + [ "$file" = - ] && rm -f "$src" + local in=/dev/null + if [ -n "$stdin" ]; then + "$CLI" sandbox push "$OFFLOAD_ID" "$stdin" /work/.stdin >/dev/null 2>&1 || die "stdin push failed"; in=.stdin + fi + + # ponytail: buffered exec, fine for snippets; a job that outlives the stream belongs in offload (keepalive) + local t0 rc=0; t0=$(date +%s) + "$CLI" sandbox exec "$OFFLOAD_ID" -- bash -lc "cd /work && timeout -k 5 $to bash -c $(printf '%q' "$run") <$in" || rc=$? + echo "cos: exit=$rc time=$(( $(date +%s) - t0 ))s$([ "$rc" = 124 ] && echo " (killed: -t ${to}s timeout)")" >&2 + return "$rc" +} +exec_usage(){ cat <<'EOF' +cos exec — run one source file (untrusted/ad-hoc code) in a throwaway box (auto-destroyed). + cos exec [flags] [args...] ('-' reads the code from stdin; needs -l) +flags: + -l lang py | js | mjs | cjs | ts | go | sh | rb | c | cpp | rs (default: from the file extension) + -i file feed this local file to the program's stdin + -t secs wall-clock limit, default 120 (exit 124 on timeout) + -s shape -r rootfs -v KEY[=VAL] + -N deny ALL egress (box reaches nothing) — for code you suspect of exfiltration + -e / -p allow just these hosts (default: unrestricted egress) +stdout/stderr pass through; exit code is the program's; 'cos: exit=N time=Ns' goes to stderr. +example: + cos exec -i input.txt solution.py + cos exec -l py - <<'PY' + print(sum(range(10))) + PY +EOF +} + # ───────────────────────────────────────────────────────────── reusable project box cmd_up(){ _norm "$@"; set -- ${NORMA[@]+"${NORMA[@]}"} @@ -1339,6 +1420,7 @@ 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 exec [flags] [args] run one untrusted source file (py|js|ts|go|sh|rb|c|cpp|rs); -N denies egress (cos exec -h) cos agent [flags] run claude|codex|opencode|pi|cursor on your code in a box against OpenRouter / any OpenAI- or Anthropic-compatible provider (cos agent -h) cos fanout [-j N][flags] ... run each in its own throwaway box, in parallel (cos fanout -h) @@ -1377,6 +1459,7 @@ case "$sub" in install) cmd_install "$@";; auth) cmd_auth "$@";; offload) cmd_offload "$@";; + exec) cmd_exec "$@";; agent) cmd_agent "$@";; fanout) cmd_fanout "$@";; shell) cmd_shell "$@";; 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 bda825a..1996d82 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 you edit 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, mounting an S3 bucket of data, handing a coding task to another agent (Claude Code, Codex, OpenCode, Pi, Cursor) running on OpenRouter or any OpenAI-/Anthropic-compatible provider, or work that needs a real screen — a graphical Linux desktop with a browser that you drive by screenshot/click/type and the user can watch over noVNC. 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, desktop/computer-use, cluster, disk, vpn, pause/resume, custom images, and snapshot/fork. Also use to answer any question about CreateOS Sandbox itself — its REST API, SDKs (TypeScript, Go, Python, Rust, C#, Java), CLI commands, limits, lifecycle, egress, networks, disks, templates, webhooks, or integrations — by fetching the relevant live docs page listed in references/docs.md. +description: Use when you need to run code OFF the user's machine — ALWAYS for untrusted or unknown code, and for any ad-hoc script or snippet you would otherwise run locally (remote code execution: `cos exec `), heavy/long builds or test suites, a parallel test/config matrix across many boxes, an instant clean Linux to try a tool, a live dev-server/watcher you edit 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, mounting an S3 bucket of data, handing a coding task to another agent (Claude Code, Codex, OpenCode, Pi, Cursor) running on OpenRouter or any OpenAI-/Anthropic-compatible provider, or work that needs a real screen — a graphical Linux desktop with a browser that you drive by screenshot/click/type and the user can watch over noVNC. 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, desktop/computer-use, cluster, disk, vpn, pause/resume, custom images, and snapshot/fork. Also use to answer any question about CreateOS Sandbox itself — its REST API, SDKs (TypeScript, Go, Python, Rust, C#, Java), CLI commands, limits, lifecycle, egress, networks, disks, templates, webhooks, or integrations — by fetching the relevant live docs page listed in references/docs.md. --- # Using CreateOS Sandbox as remote compute @@ -38,7 +38,8 @@ Every `cos` command except `install` and `auth` runs this check first, so an una | Situation | Why offload | | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -| **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. | +| **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. One file → `exec`. | +| **Any ad-hoc script** — a one-off Python/JS/shell/Go snippet to compute, parse, probe or try something | Keep the laptop clean; `exec` runs it remotely and returns stdout, stderr and the exit code. | | **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. | | **Quick scratch Linux** — try a CLI/tool/snippet on a clean box | `shell` — instant keyless box, destroyed on exit (interactive; the user runs it). | @@ -60,6 +61,7 @@ Do NOT offload trivial commands, anything needing the user's local secrets/SSH/c Almost every task is one of two shapes, and picking the wrong one wastes a lot of motion: - **"Run this and tell me the result"** — a test suite, a build, a script, anything with an end. → **`cos offload `.** One command. It creates the box, ships the directory, runs, and destroys the box. Nothing to clean up. +- **"Run this one piece of code"** — a snippet you wrote or were handed, untrusted code, a solution to test against an input. → **`cos exec `** (or `cos exec -l py -` with the code on stdin). No directory to stage. - **"Keep a box around while I work"** — a dev server you'll hit repeatedly, a watcher reacting to edits, a session spanning many commands. → **`cos up`**, then `run`/`sync`, then `pause` or `down`. If you find yourself doing any of the following, you have picked the wrong shape and should stop and use `offload` instead: @@ -97,6 +99,22 @@ Long, quiet builds survive a dropped connection: the command runs detached with For the full flag table, the egress presets, the enforcement caveats, fanout, and the OOM/disk/bandwidth traps on heavy builds → **`references/offload-and-egress.md`**. +### Exec — remote code execution for one file + +Code you would rather not run on the user's machine — untrusted, generated, or just not yours to run locally — goes to `exec`: it writes the file into a fresh box, runs it with the right toolchain, and destroys the box. + +```bash +cos exec -i input.txt solution.py arg1 # stdin from a file, args after the file +cos exec -t 10 -N suspect.js # 10 s limit, no network at all +cos exec -l go - <<'GO' +package main +import "fmt" +func main() { fmt.Println("hi") } +GO +``` + +Languages: `py js mjs cjs ts go sh rb c cpp rs` (from the extension, or `-l`). Everything after `` reaches the program unchanged. stdout/stderr pass through, the exit code is the program's, exit 124 means the `-t` limit (default 120 s) killed it, and `cos: exit=N time=Ns` goes to stderr. Egress is unrestricted by default like `offload`; `-N` denies all of it (enforced by an IP rule, so it applies immediately). More than one file, or dependencies to install → `offload`. + ### Fanout — same input, many boxes, in parallel ```bash diff --git a/packages/codex-plugin/scripts/cos b/packages/codex-plugin/scripts/cos index d9d6a9a..d23c64e 100755 --- a/packages/codex-plugin/scripts/cos +++ b/packages/codex-plugin/scripts/cos @@ -462,6 +462,87 @@ example: EOF } +# ─────────────────────────────────────────────────── run one untrusted source file +# Egress stays open by default (snippets often call APIs). -N denies all: any rule flips +# CreateOS to deny-by-default and an IP rule is enforced at once (hostname rules are not), +# so one unroutable TEST-NET-1 address allows nothing. +DENY_ALL_EGRESS=192.0.2.1/32 +cmd_exec(){ # no _norm: it would rewrite the program's own --flags after ; getopts stops at + local shape=s-1vcpu-1gb rootfs=devbox:1 lang="" stdin="" to=120 deny=0 + COS_EGRESS=(); local -a _d; local OPTIND=1 o doms d + while getopts "s:r:l:i:t:e:p:v:Nh" o; do case $o in + s) shape=$OPTARG;; r) rootfs=$OPTARG;; l) lang=$OPTARG;; i) stdin=$OPTARG;; t) to=$OPTARG;; + v) add_env "$OPTARG";; + e) COS_EGRESS+=(--egress "$OPTARG");; + p) doms=$(egress_preset "$OPTARG") || die "unknown egress preset '$OPTARG' (have: $EGRESS_PRESETS)" + read -ra _d <<<"$doms"; for d in "${_d[@]}"; do COS_EGRESS+=(--egress "$d"); done;; + N) deny=1;; + h) exec_usage; exit 0;; + *) exec_usage >&2; exit 2;; esac; done + shift $((OPTIND-1)) + if [ $# -lt 1 ]; then exec_usage; exit 0; fi + local file=$1; shift + numeric "$to" || die "-t wants whole seconds, got '$to'" + [ -z "$stdin" ] || [ -f "$stdin" ] || die "no such stdin file: $stdin" + local src=$file + if [ "$file" = - ]; then src=$(mktemp); cat >"$src"; else [ -f "$file" ] || die "no such file: $file"; fi + [ -n "$lang" ] || lang=${file##*.} + [ "$lang" != - ] && [ "$lang" != "$file" ] || die "cannot tell the language — pass -l py|js|mjs|cjs|ts|go|sh|rb|c|cpp|rs" + + local main run + case "$lang" in + py|python) main=main.py; run="python3 main.py";; + js|node) main=main.js; run="node main.js";; + mjs) main=main.mjs; run="node main.mjs";; + cjs) main=main.cjs; run="node main.cjs";; + ts|typescript) main=main.ts; run="bun main.ts";; + go) main=main.go; run="go run main.go";; + sh|bash) main=main.sh; run="bash main.sh";; + rb|ruby) main=main.rb; run="ruby main.rb";; + c) main=main.c; run="gcc -O2 -o main main.c && ./main";; + cpp|cc|cxx) main=main.cpp; run="g++ -O2 -o main main.cpp && ./main";; + rs|rust) main=main.rs; run="rustc -O -o main main.rs && ./main";; + *) die "unsupported language '$lang' — py|js|mjs|cjs|ts|go|sh|rb|c|cpp|rs (anything else: cos offload)";; + esac + local a; for a in "$@"; do run="$run $(printf '%q' "$a")"; done + + [ "$deny" = 1 ] && { COS_EGRESS=(--egress "$DENY_ALL_EGRESS"); echo "cos: egress DENIED (-N)" >&2; } + + OFFLOAD_ID=$(create_box "cos-x-$$-${RANDOM}" "$shape" "$rootfs") + KEEP=0; trap on_offload_exit EXIT + wait_running "$OFFLOAD_ID" 30 || die "box $OFFLOAD_ID not running after 30s" + "$CLI" sandbox push "$OFFLOAD_ID" "$src" "/work/$main" >/dev/null 2>&1 || die "push failed" + [ "$file" = - ] && rm -f "$src" + local in=/dev/null + if [ -n "$stdin" ]; then + "$CLI" sandbox push "$OFFLOAD_ID" "$stdin" /work/.stdin >/dev/null 2>&1 || die "stdin push failed"; in=.stdin + fi + + # ponytail: buffered exec, fine for snippets; a job that outlives the stream belongs in offload (keepalive) + local t0 rc=0; t0=$(date +%s) + "$CLI" sandbox exec "$OFFLOAD_ID" -- bash -lc "cd /work && timeout -k 5 $to bash -c $(printf '%q' "$run") <$in" || rc=$? + echo "cos: exit=$rc time=$(( $(date +%s) - t0 ))s$([ "$rc" = 124 ] && echo " (killed: -t ${to}s timeout)")" >&2 + return "$rc" +} +exec_usage(){ cat <<'EOF' +cos exec — run one source file (untrusted/ad-hoc code) in a throwaway box (auto-destroyed). + cos exec [flags] [args...] ('-' reads the code from stdin; needs -l) +flags: + -l lang py | js | mjs | cjs | ts | go | sh | rb | c | cpp | rs (default: from the file extension) + -i file feed this local file to the program's stdin + -t secs wall-clock limit, default 120 (exit 124 on timeout) + -s shape -r rootfs -v KEY[=VAL] + -N deny ALL egress (box reaches nothing) — for code you suspect of exfiltration + -e / -p allow just these hosts (default: unrestricted egress) +stdout/stderr pass through; exit code is the program's; 'cos: exit=N time=Ns' goes to stderr. +example: + cos exec -i input.txt solution.py + cos exec -l py - <<'PY' + print(sum(range(10))) + PY +EOF +} + # ───────────────────────────────────────────────────────────── reusable project box cmd_up(){ _norm "$@"; set -- ${NORMA[@]+"${NORMA[@]}"} @@ -1339,6 +1420,7 @@ 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 exec [flags] [args] run one untrusted source file (py|js|ts|go|sh|rb|c|cpp|rs); -N denies egress (cos exec -h) cos agent [flags] run claude|codex|opencode|pi|cursor on your code in a box against OpenRouter / any OpenAI- or Anthropic-compatible provider (cos agent -h) cos fanout [-j N][flags] ... run each in its own throwaway box, in parallel (cos fanout -h) @@ -1377,6 +1459,7 @@ case "$sub" in install) cmd_install "$@";; auth) cmd_auth "$@";; offload) cmd_offload "$@";; + exec) cmd_exec "$@";; agent) cmd_agent "$@";; fanout) cmd_fanout "$@";; shell) cmd_shell "$@";; diff --git a/packages/codex-plugin/skills/using-createos-sandbox/SKILL.md b/packages/codex-plugin/skills/using-createos-sandbox/SKILL.md index 3969c3d..1996d82 100644 --- a/packages/codex-plugin/skills/using-createos-sandbox/SKILL.md +++ b/packages/codex-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 you edit 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, mounting an S3 bucket of data, handing a coding task to another agent (Claude Code, Codex, OpenCode, Pi, Cursor) running on OpenRouter or any OpenAI-/Anthropic-compatible provider, or work that needs a real screen — a graphical Linux desktop with a browser that you drive by screenshot/click/type and the user can watch over noVNC. 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, desktop/computer-use, cluster, disk, vpn, pause/resume, custom images, and snapshot/fork. +description: Use when you need to run code OFF the user's machine — ALWAYS for untrusted or unknown code, and for any ad-hoc script or snippet you would otherwise run locally (remote code execution: `cos exec `), heavy/long builds or test suites, a parallel test/config matrix across many boxes, an instant clean Linux to try a tool, a live dev-server/watcher you edit 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, mounting an S3 bucket of data, handing a coding task to another agent (Claude Code, Codex, OpenCode, Pi, Cursor) running on OpenRouter or any OpenAI-/Anthropic-compatible provider, or work that needs a real screen — a graphical Linux desktop with a browser that you drive by screenshot/click/type and the user can watch over noVNC. 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, desktop/computer-use, cluster, disk, vpn, pause/resume, custom images, and snapshot/fork. Also use to answer any question about CreateOS Sandbox itself — its REST API, SDKs (TypeScript, Go, Python, Rust, C#, Java), CLI commands, limits, lifecycle, egress, networks, disks, templates, webhooks, or integrations — by fetching the relevant live docs page listed in references/docs.md. --- # Using CreateOS Sandbox as remote compute @@ -38,7 +38,8 @@ Every `cos` command except `install` and `auth` runs this check first, so an una | Situation | Why offload | | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -| **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. | +| **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. One file → `exec`. | +| **Any ad-hoc script** — a one-off Python/JS/shell/Go snippet to compute, parse, probe or try something | Keep the laptop clean; `exec` runs it remotely and returns stdout, stderr and the exit code. | | **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. | | **Quick scratch Linux** — try a CLI/tool/snippet on a clean box | `shell` — instant keyless box, destroyed on exit (interactive; the user runs it). | @@ -60,6 +61,7 @@ Do NOT offload trivial commands, anything needing the user's local secrets/SSH/c Almost every task is one of two shapes, and picking the wrong one wastes a lot of motion: - **"Run this and tell me the result"** — a test suite, a build, a script, anything with an end. → **`cos offload `.** One command. It creates the box, ships the directory, runs, and destroys the box. Nothing to clean up. +- **"Run this one piece of code"** — a snippet you wrote or were handed, untrusted code, a solution to test against an input. → **`cos exec `** (or `cos exec -l py -` with the code on stdin). No directory to stage. - **"Keep a box around while I work"** — a dev server you'll hit repeatedly, a watcher reacting to edits, a session spanning many commands. → **`cos up`**, then `run`/`sync`, then `pause` or `down`. If you find yourself doing any of the following, you have picked the wrong shape and should stop and use `offload` instead: @@ -97,6 +99,22 @@ Long, quiet builds survive a dropped connection: the command runs detached with For the full flag table, the egress presets, the enforcement caveats, fanout, and the OOM/disk/bandwidth traps on heavy builds → **`references/offload-and-egress.md`**. +### Exec — remote code execution for one file + +Code you would rather not run on the user's machine — untrusted, generated, or just not yours to run locally — goes to `exec`: it writes the file into a fresh box, runs it with the right toolchain, and destroys the box. + +```bash +cos exec -i input.txt solution.py arg1 # stdin from a file, args after the file +cos exec -t 10 -N suspect.js # 10 s limit, no network at all +cos exec -l go - <<'GO' +package main +import "fmt" +func main() { fmt.Println("hi") } +GO +``` + +Languages: `py js mjs cjs ts go sh rb c cpp rs` (from the extension, or `-l`). Everything after `` reaches the program unchanged. stdout/stderr pass through, the exit code is the program's, exit 124 means the `-t` limit (default 120 s) killed it, and `cos: exit=N time=Ns` goes to stderr. Egress is unrestricted by default like `offload`; `-N` denies all of it (enforced by an IP rule, so it applies immediately). More than one file, or dependencies to install → `offload`. + ### Fanout — same input, many boxes, in parallel ```bash @@ -224,7 +242,7 @@ Disk data lives in the user's own S3 account and region. `--path-style` is neede - **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. - 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. +- 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 — `createos sandbox shapes`, or the [Limits](https://createos.sh/docs/Sandbox/Limits.md) page. ## References @@ -236,3 +254,4 @@ Load these when the task actually needs the depth — the summaries above are en | `references/networking.md` | choosing between tunnel/expose/cluster/vpn, cluster DNS names, expose gotchas, WireGuard setup | | `references/coding-agents.md` | the five agent CLIs in `devbox:1`, per-agent provider wiring (OpenRouter / OpenAI-compatible / Anthropic-compatible), which agents can't be repointed, egress around an agent box | | `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 | +| `references/docs.md` | every page of the live CreateOS Sandbox docs as a fetchable `.md` URL — REST API, SDKs, CLI reference, limits, concepts, integrations. Fetch only the page you need | diff --git a/packages/codex-plugin/skills/using-createos-sandbox/references/docs.md b/packages/codex-plugin/skills/using-createos-sandbox/references/docs.md new file mode 100644 index 0000000..fad9d2a --- /dev/null +++ b/packages/codex-plugin/skills/using-createos-sandbox/references/docs.md @@ -0,0 +1,114 @@ +# CreateOS Sandbox docs — live index + +Every CreateOS Sandbox docs page, as raw markdown. Use this when the question is +about the **product** — REST endpoints, SDK methods, CLI flags, limits, lifecycle, +egress semantics, integrations — rather than about driving `cos`. + +**How to use:** pick the one or two pages that answer the question and fetch them +(`WebFetch`, or `curl -sL `). Every URL below is `.md` and returns +`text/markdown` — no HTML scraping needed. Do not fetch the whole list. + +The docs are the source of truth and newer than this plugin. When a page +disagrees with `SKILL.md` or another reference file on a number or an API shape, +trust the page. Behaviour this plugin measured itself (egress enforcement, the +concurrency cap, OOM traps) stays in the other reference files. + +If a URL 404s, the page moved: re-derive the list from + (lines under `/Sandbox/`) and append `.md`. + +## Start here + +- [Sandbox](https://createos.sh/docs/Sandbox.md) — landing page: what the product is for. +- [Overview](https://createos.sh/docs/Sandbox/Overview.md) — disposable Linux microVMs for untrusted code, AI agents, CI jobs, previews, networking, persistence, forking. +- [Quickstart](https://createos.sh/docs/Sandbox/Quickstart.md) — first sandbox with CLI, TypeScript SDK and REST side by side. +- [Concepts](https://createos.sh/docs/Sandbox/Concepts.md) — vocabulary: shapes, root filesystems, lifecycle state machine, pause and fork, private networks, egress rules, disks, templates. +- [Limits & defaults](https://createos.sh/docs/Sandbox/Limits.md) — shapes, disks, networks, timeouts, quotas. +- [Bring your own storage](https://createos.sh/docs/Sandbox/Bring-Your-Own-Storage.md) — snapshots and disks in your own S3 / R2 / MinIO / Tigris. +- [Run on your own infrastructure](https://createos.sh/docs/Sandbox/Self-Hosting.md) — self-hosted / on-prem data plane (alpha, enterprise). +- [Claude Managed Agents](https://createos.sh/docs/Sandbox/Claude-Managed-Agents.md) — run Claude Managed Agents inside sandboxes: setup, credentials, egress allowlist, cleanup. + +## CLI + +- [CLI](https://createos.sh/docs/Sandbox/CLI.md) — install, sign in, manage sandboxes from a terminal or CI. +- [Overview](https://createos.sh/docs/Sandbox/CLI/Overview.md) — create, exec, sync, tunnel, destroy. +- [Command Reference](https://createos.sh/docs/Sandbox/CLI/Commands.md) — every `createos sandbox` command and flag. +- [Sandboxes (CLI section)](https://createos.sh/docs/CLI/Sandbox.md) — `createos sandbox` / `sb` from the main CLI docs, including shell and tunnel. + +## REST API + +- [REST API](https://createos.sh/docs/Sandbox/REST-API.md) — index of resource groups. +- [Overview](https://createos.sh/docs/Sandbox/REST-API/Overview.md) — base URL, `X-Api-Key` auth, JSend envelope, rate behaviour. +- [Sandboxes](https://createos.sh/docs/Sandbox/REST-API/Sandboxes.md) — create / list / inspect / destroy: shapes, rootfs, create-time egress, auto-pause, envs, ingress, regions, status lifecycle. +- [Execution & Files](https://createos.sh/docs/Sandbox/REST-API/Execution-And-Files.md) — run commands (buffered or streamed), upload / download files and directories. +- [Managed Processes](https://createos.sh/docs/Sandbox/REST-API/Managed-Processes.md) — start, supervise, stop long-running processes. +- [Pause, Resume & Fork](https://createos.sh/docs/Sandbox/REST-API/Pause-Resume-Fork.md) — snapshot, restore, clone. +- [Egress](https://createos.sh/docs/Sandbox/REST-API/Egress.md) — open with no rules, deny-by-default with any rule, in-kernel enforcement, rule formats. +- [Networks](https://createos.sh/docs/Sandbox/REST-API/Networks.md) — private overlay networks, DNS names, limits. +- [Devices & VPN](https://createos.sh/docs/Sandbox/REST-API/Devices.md) — WireGuard devices into private networks. +- [Shell & Tunnels](https://createos.sh/docs/Sandbox/REST-API/Connections.md) — interactive shells and port tunnels. +- [Disks](https://createos.sh/docs/Sandbox/REST-API/Disks.md) — register S3-compatible disks, mount / detach on running sandboxes. +- [Templates](https://createos.sh/docs/Sandbox/REST-API/Templates.md) — custom rootfs from a Dockerfile: build status, limits. +- [Bandwidth & Resize](https://createos.sh/docs/Sandbox/REST-API/Bandwidth-And-Resize.md) — bandwidth budget check / recharge, shape resize. +- [Catalog & Identity](https://createos.sh/docs/Sandbox/REST-API/Catalog-And-Identity.md) — list shapes and root filesystems, whoami. +- [Sandbox access tokens](https://createos.sh/docs/Sandbox/REST-API/Access-Tokens.md) — delegated credential scoped to one sandbox: create, rotate, disable. +- [Self-Signal (In-Sandbox)](https://createos.sh/docs/Sandbox/REST-API/Self-Signal.md) — endpoints a sandbox calls on itself. +- [Metrics](https://createos.sh/docs/Sandbox/REST-API/Metrics.md) — per-sandbox CPU, memory, disk, network. +- [Webhooks](https://createos.sh/docs/Sandbox/REST-API/Webhooks.md) — lifecycle events, payload, retries, signature verification. +- [Computer](https://createos.sh/docs/Sandbox/REST-API/Computer.md) — screenshots, input events, desktop control. + +## SDK — getting started + +- [SDK](https://createos.sh/docs/Sandbox/SDK.md) — SDKs for TypeScript, Go, Python, Rust, C#, Java. +- [SDKs overview](https://createos.sh/docs/Sandbox/SDK/Overview.md) — install / create / run / files / cleanup in each language. +- [Quickstart](https://createos.sh/docs/Sandbox/SDK/Quickstart.md) — `@nodeops-createos/sandbox` in five minutes. +- [Tutorial](https://createos.sh/docs/Sandbox/SDK/Tutorial.md) — build a code-execution service: restrict egress, run untrusted code, collect output. +- [Examples](https://createos.sh/docs/Sandbox/SDK/Examples.md) — agent code execution, fork-based parallel rollouts, multi-sandbox networks. + +## SDK — how-to + +- [How-To Guides](https://createos.sh/docs/Sandbox/SDK/How-To.md) — index. +- [Upload & Download Files](https://createos.sh/docs/Sandbox/SDK/How-To/Files.md) +- [Pause, Fork & Auto-Pause](https://createos.sh/docs/Sandbox/SDK/How-To/Lifecycle.md) +- [Expose a Service](https://createos.sh/docs/Sandbox/SDK/How-To/Expose-A-Service.md) +- [Disks, Networks & Templates](https://createos.sh/docs/Sandbox/SDK/How-To/Disks-Networks-Templates.md) +- [Stream Command Output](https://createos.sh/docs/Sandbox/SDK/How-To/Streaming.md) +- [Error Handling](https://createos.sh/docs/Sandbox/SDK/How-To/Error-Handling.md) +- [Observability](https://createos.sh/docs/Sandbox/SDK/How-To/Observability.md) +- [Delegate access to one sandbox](https://createos.sh/docs/Sandbox/SDK/How-To/Sandbox-Access-Tokens.md) — sandbox access token for a worker. + +## SDK — API reference (TypeScript) + +- [API Reference](https://createos.sh/docs/Sandbox/SDK/Reference.md) — index. +- [Overview](https://createos.sh/docs/Sandbox/SDK/Reference/Overview.md) +- [Client](https://createos.sh/docs/Sandbox/SDK/Reference/Client.md) +- [Sandbox](https://createos.sh/docs/Sandbox/SDK/Reference/Sandbox.md) +- [Sandbox Files](https://createos.sh/docs/Sandbox/SDK/Reference/Sandbox-Files.md) +- [Sub-APIs](https://createos.sh/docs/Sandbox/SDK/Reference/Sub-APIs.md) — disks, networks, templates. +- [Managed Processes](https://createos.sh/docs/Sandbox/SDK/Reference/Managed-Processes.md) +- [Computer](https://createos.sh/docs/Sandbox/SDK/Reference/Computer.md) +- [Errors](https://createos.sh/docs/Sandbox/SDK/Reference/Errors.md) +- [Helpers](https://createos.sh/docs/Sandbox/SDK/Reference/Helpers.md) +- [Types](https://createos.sh/docs/Sandbox/SDK/Reference/Types.md) + +## SDK — explanation + +- [Concepts](https://createos.sh/docs/Sandbox/SDK/Explanation.md) — index. +- [VM Sandboxes](https://createos.sh/docs/Sandbox/SDK/Explanation/VM-Sandboxes.md) — why a Firecracker microVM, not a container. +- [The Handle Model](https://createos.sh/docs/Sandbox/SDK/Explanation/Handle-Model.md) — what the `Sandbox` handle caches and when it refreshes. +- [Sandbox Lifecycle](https://createos.sh/docs/Sandbox/SDK/Explanation/Lifecycle.md) — creating → running → pausing → paused → resuming → forking → destroying. +- [Reliability](https://createos.sh/docs/Sandbox/SDK/Explanation/Reliability.md) — retries, timeouts, partial state. + +## Integrations + +- [Integrations](https://createos.sh/docs/Sandbox/Integrations.md) — index. +- [Overview](https://createos.sh/docs/Sandbox/Integrations/Overview.md) +- [Claude Code](https://createos.sh/docs/Sandbox/Integrations/Claude-Code.md) — this plugin. +- [Codex](https://createos.sh/docs/Sandbox/Integrations/Codex.md) +- [OpenCode](https://createos.sh/docs/Sandbox/Integrations/OpenCode.md) +- [Pi](https://createos.sh/docs/Sandbox/Integrations/Pi.md) +- [Herdr](https://createos.sh/docs/Sandbox/Integrations/Herdr.md) +- [DeepSeek Harness](https://createos.sh/docs/Sandbox/Integrations/DeepSeek-Harness.md) +- [Orca](https://createos.sh/docs/Sandbox/Integrations/Orca.md) +- [n8n](https://createos.sh/docs/Sandbox/Integrations/n8n.md) +- [Langflow](https://createos.sh/docs/Sandbox/Integrations/Langflow.md) +- [AgentBox](https://createos.sh/docs/Sandbox/Integrations/AgentBox.md) diff --git a/packages/opencode-plugin/README.md b/packages/opencode-plugin/README.md index daa60d8..dbc1827 100644 --- a/packages/opencode-plugin/README.md +++ b/packages/opencode-plugin/README.md @@ -74,6 +74,7 @@ Two shapes of work, two tools. Getting this wrong is the most common mistake: | Work | Tool | | ------------------------------------------------------------ | --------------------------------------------------------- | +| Untrusted code or any ad-hoc script — one program's source | `sandbox_run_code` — stdout, stderr, exit code; box destroyed | | Has a finish line — a build, a test suite, a script | `sandbox_offload` — one call, box destroyed afterwards | | Several variants of that at once — shards, a config matrix | `sandbox_fanout` — one throwaway box per command | | Outlives one command — a dev server, a watcher, a session | `sandbox_create` + `sandbox_exec`, then `sandbox_destroy` | @@ -84,7 +85,11 @@ build actually needs, a keepalive so a dropped stream does not kill a long build guaranteed destruction even when the command throws, and staging excludes that keep `.git`, `node_modules`, `target` and large media off the wire. -## Tool inventory (38 tools) +For questions about CreateOS Sandbox itself, the agent is told to fetch the live docs: +every page listed under `/Sandbox/` in is raw +markdown at `https://createos.sh/docs.md`. + +## Tool inventory (39 tools) ### Offload engine @@ -92,6 +97,7 @@ keep `.git`, `node_modules`, `target` and large media off the wire. | -------------------- | ---------------------------------------------------------------------------- | | `sandbox_offload` | Stage a directory, run a command, pull artifacts, destroy the box | | `sandbox_fanout` | Run each of several commands in its own throwaway box, in parallel | +| `sandbox_run_code` | Remote code execution: run one program (py/js/ts/go/sh/rb/c/cpp/rs) with stdin, args and a timeout; egress open unless `egress_deny_all`/presets | ### Desktop / computer use diff --git a/packages/opencode-plugin/index.ts b/packages/opencode-plugin/index.ts index d731a7b..8b011ef 100644 --- a/packages/opencode-plugin/index.ts +++ b/packages/opencode-plugin/index.ts @@ -125,9 +125,12 @@ export const CreateOSPlugin: Plugin = async ({ project, client, $, directory }) `ONE call: it creates the box, stages the dir, runs, and destroys the box. Do not hand-roll that out of ` + `sandbox_create + sandbox_exec — that drops egress restriction, the keepalive, and the guaranteed destroy.\n` + `- Several variants of that at once (shards, a config matrix) → sandbox_fanout\n` + + `- Untrusted code or any ad-hoc script/snippet you would otherwise run locally → sandbox_run_code (code + lang)\n` + `- "mount/sync this dir" → sandbox_sync local_dir="${hostCwd}" remote_dir="/root/project"\n` + `- Port access → sandbox_preview_url (public URL) > sandbox_tunnel (localhost) > device VPN (last resort)\n` + - `- Multi-node → sandbox_network_create + sandbox_create with network + sandbox_exec on other sandboxes`, + `- Multi-node → sandbox_network_create + sandbox_create with network + sandbox_exec on other sandboxes\n` + + `- Questions about CreateOS Sandbox itself (REST API, SDKs, CLI, limits) → fetch the matching page listed in ` + + `https://createos.sh/docs/llms.txt (under /Sandbox/); every page is raw markdown at https://createos.sh/docs.md`, ); }, diff --git a/packages/opencode-plugin/src/sandbox-engine.ts b/packages/opencode-plugin/src/sandbox-engine.ts index b8dcd9d..6a6a81c 100644 --- a/packages/opencode-plugin/src/sandbox-engine.ts +++ b/packages/opencode-plugin/src/sandbox-engine.ts @@ -15,7 +15,7 @@ * file drops into any TypeScript plugin. */ -import { execSync } from "node:child_process"; +import { execSync, spawnSync } from "node:child_process"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; @@ -191,9 +191,16 @@ export interface EgressOptions { egressPresets?: string[]; /** Unrestricted egress — only for a trusted offload. */ egressAll?: boolean; + /** Deny ALL egress: any rule flips CreateOS to deny-by-default, and an IP rule is + * enforced at once (hostname rules are not), so one unroutable TEST-NET-1 address + * allows nothing. Mirrors `cos exec -N`. */ + egressDenyAll?: boolean; } +export const DENY_ALL_EGRESS = "192.0.2.1/32"; + export function egressArgs(opts: EgressOptions): { args: string[]; warning?: string } { + if (opts.egressDenyAll) return { args: ["--egress", DENY_ALL_EGRESS] }; if (opts.egressAll) return { args: [], warning: undefined }; const domains = [ ...(opts.egress ?? []), @@ -518,6 +525,111 @@ export function cleanupFailureNote(sandboxId: string, error?: string): string { ); } +// --------------------------------------------------------------------------- +// Remote code execution — one source file in a throwaway box (`cos exec`) +// --------------------------------------------------------------------------- + +/** Language → file name in /work and the command that runs it. `.js` stays CommonJS-capable. */ +export const RUN_CODE_LANGS: Record = { + py: { file: "main.py", run: "python3 main.py" }, + js: { file: "main.js", run: "node main.js" }, + mjs: { file: "main.mjs", run: "node main.mjs" }, + cjs: { file: "main.cjs", run: "node main.cjs" }, + ts: { file: "main.ts", run: "bun main.ts" }, + go: { file: "main.go", run: "go run main.go" }, + sh: { file: "main.sh", run: "bash main.sh" }, + rb: { file: "main.rb", run: "ruby main.rb" }, + c: { file: "main.c", run: "gcc -O2 -o main main.c && ./main" }, + cpp: { file: "main.cpp", run: "g++ -O2 -o main main.cpp && ./main" }, + rs: { file: "main.rs", run: "rustc -O -o main main.rs && ./main" }, +}; + +export interface RunCodeOptions extends EgressOptions { + code: string; + lang: string; + /** Passed to the program untouched. */ + args?: string[]; + stdin?: string; + /** Wall-clock limit; the program is killed and exits 124 when hit. Default 120. */ + timeoutSec?: number; + shape?: string; + rootfs?: string; +} + +export interface RunCodeResult extends ExecResult { + timedOut: boolean; + durationMs: number; + warnings: string[]; +} + +/** The in-box command: timeout-wrapped, stdin from a pushed file or /dev/null. */ +export function runCodeCommand( + lang: string, + args: string[], + timeoutSec: number, + hasStdin: boolean, +): string { + const spec = RUN_CODE_LANGS[lang]; + if (!spec) { + throw new Error( + `Unsupported language '${lang}' — have: ${Object.keys(RUN_CODE_LANGS).join(", ")}`, + ); + } + const run = [spec.run, ...args.map(shq)].join(" "); + return `cd /work && timeout -k 5 ${timeoutSec} bash -c ${shq(run)} <${hasStdin ? ".stdin" : "/dev/null"}`; +} + +/** + * Run one piece of code off the user's machine: create → push → run → destroy. + * Buffered exec on purpose — a job that outlives one exec stream belongs in offload(). + */ +export async function runCode(opts: RunCodeOptions): Promise { + assertAuth(); + const timeoutSec = opts.timeoutSec ?? 120; + const cmd = runCodeCommand(opts.lang, opts.args ?? [], timeoutSec, opts.stdin !== undefined); + const { id, warning } = createBox({ + ...opts, + name: `cos-x-${process.pid}-${Math.floor(Math.random() * 1e6)}`, + }); + const warnings = warning ? [warning] : []; + try { + if (!(await waitRunning(id))) throw new Error(`Sandbox ${id} did not reach running within 30s`); + const push = (content: string, remote: string) => { + const res = spawnSync("createos", ["sandbox", "push", id, "-", remote], { + input: content, + encoding: "utf-8", + timeout: 120_000, + }); + if (res.status !== 0) + throw new Error( + `Push of ${remote} failed: ${(res.stderr || res.stdout || String(res.error)).trim()}`, + ); + }; + push(opts.code, `/work/${RUN_CODE_LANGS[opts.lang].file}`); + if (opts.stdin !== undefined) push(opts.stdin, "/work/.stdin"); + const t0 = Date.now(); + // spawnSync, not execShell: execShell drops stderr when the exit code is 0, + // and a program's stderr is part of its answer. + const r = spawnSync("createos", ["sandbox", "exec", id, "--", "bash", "-lc", cmd], { + encoding: "utf-8", + timeout: (timeoutSec + 60) * 1000, + maxBuffer: 64 * 1024 * 1024, + }); + const code = r.status ?? 1; + return { + code, + stdout: r.stdout ?? "", + stderr: r.stderr || (r.error ? String(r.error) : ""), + timedOut: code === 124, + durationMs: Date.now() - t0, + warnings, + }; + } finally { + const d = destroyBox(id); + if (!d.ok) warnings.push(cleanupFailureNote(id, d.error)); + } +} + /** * Stage → run (keepalive) → pull → destroy, in one call. * diff --git a/packages/opencode-plugin/src/tools.ts b/packages/opencode-plugin/src/tools.ts index a4643a9..8de4070 100644 --- a/packages/opencode-plugin/src/tools.ts +++ b/packages/opencode-plugin/src/tools.ts @@ -701,6 +701,61 @@ export function createTools($: any, getActive: () => ToolSandbox | null) { }, }), + sandbox_run_code: tool({ + description: + "Remote code execution: run untrusted code or ANY ad-hoc script/snippet in a THROWAWAY " + + "sandbox instead of on this machine, then destroy the box. Pass the source as `code`. " + + "Returns stdout, stderr and the program's exit code (124 = timeout). Use it whenever you " + + "would otherwise run a one-off script locally. Several files or dependencies to install → sandbox_offload.", + args: { + code: tool.schema.string().describe("Full source of the program"), + lang: tool.schema + .string() + .describe(`Language: ${Object.keys(engine.RUN_CODE_LANGS).join(" | ")}`), + args: tool.schema + .array(tool.schema.string()) + .optional() + .describe("Program arguments, passed through untouched"), + stdin: tool.schema.string().optional().describe("Text fed to the program's stdin"), + timeout_sec: tool.schema + .number() + .optional() + .describe("Wall-clock limit in seconds, default 120"), + egress_deny_all: tool.schema + .boolean() + .optional() + .describe("Block all outbound connections. Egress is unrestricted by default"), + egress_presets: tool.schema + .array(tool.schema.string()) + .optional() + .describe("Allow only what these ecosystems need: python-uv | rust-cargo | npm | github"), + egress: tool.schema + .array(tool.schema.string()) + .optional() + .describe("Allow only these hosts; composes with egress_presets"), + shape: tool.schema.string().optional().describe("VM size. Defaults to 's-1vcpu-1gb'"), + }, + async execute(args) { + const res = await engine.runCode({ + code: args.code, + lang: args.lang, + args: args.args, + stdin: args.stdin, + timeoutSec: args.timeout_sec, + egressDenyAll: args.egress_deny_all, + egressPresets: args.egress_presets, + egress: args.egress, + shape: args.shape, + }); + const lines = [ + `exit code ${res.code}${res.timedOut ? " (killed by timeout)" : ""} in ${(res.durationMs / 1000).toFixed(1)}s`, + ]; + for (const w of res.warnings) lines.push(`warning: ${w}`); + lines.push("", "stdout:", res.stdout || "(empty)", "", "stderr:", res.stderr || "(empty)"); + return lines.join("\n"); + }, + }), + sandbox_fanout: tool({ description: "Run each command in its OWN throwaway sandbox, in parallel, from the same staged directory. " + diff --git a/packages/pi-extension/README.md b/packages/pi-extension/README.md index b9cecd2..71280cb 100644 --- a/packages/pi-extension/README.md +++ b/packages/pi-extension/README.md @@ -81,6 +81,15 @@ it actually needs), the keepalive, and the guaranteed destroy, so a "successful" leave an unrestricted box billing. The upload already skips `.git`, `node_modules`, `target`, virtualenvs and large media. +### Run untrusted code or an ad-hoc script + +`sandbox_run_code` is remote code execution for one program: pass the source as `code` with a +`lang` (py, js, mjs, cjs, ts, go, sh, rb, c, cpp, rs), plus optional `args`, `stdin` and +`timeout_sec` (default 120, exit 124 when hit). It returns stdout, stderr and the exit code and +destroys the box. Egress is open unless `egress_deny_all` or presets restrict it. Its prompt +guidelines also point the agent at the live CreateOS Sandbox docs +(`https://createos.sh/docs/llms.txt`, each page raw markdown at `.md`). + ### Drive a graphical desktop On a sandbox created with `rootfs: desktop:1`, `sandbox_desktop` mints a live noVNC link the diff --git a/packages/pi-extension/src/sandbox-engine.ts b/packages/pi-extension/src/sandbox-engine.ts index b8dcd9d..6a6a81c 100644 --- a/packages/pi-extension/src/sandbox-engine.ts +++ b/packages/pi-extension/src/sandbox-engine.ts @@ -15,7 +15,7 @@ * file drops into any TypeScript plugin. */ -import { execSync } from "node:child_process"; +import { execSync, spawnSync } from "node:child_process"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; @@ -191,9 +191,16 @@ export interface EgressOptions { egressPresets?: string[]; /** Unrestricted egress — only for a trusted offload. */ egressAll?: boolean; + /** Deny ALL egress: any rule flips CreateOS to deny-by-default, and an IP rule is + * enforced at once (hostname rules are not), so one unroutable TEST-NET-1 address + * allows nothing. Mirrors `cos exec -N`. */ + egressDenyAll?: boolean; } +export const DENY_ALL_EGRESS = "192.0.2.1/32"; + export function egressArgs(opts: EgressOptions): { args: string[]; warning?: string } { + if (opts.egressDenyAll) return { args: ["--egress", DENY_ALL_EGRESS] }; if (opts.egressAll) return { args: [], warning: undefined }; const domains = [ ...(opts.egress ?? []), @@ -518,6 +525,111 @@ export function cleanupFailureNote(sandboxId: string, error?: string): string { ); } +// --------------------------------------------------------------------------- +// Remote code execution — one source file in a throwaway box (`cos exec`) +// --------------------------------------------------------------------------- + +/** Language → file name in /work and the command that runs it. `.js` stays CommonJS-capable. */ +export const RUN_CODE_LANGS: Record = { + py: { file: "main.py", run: "python3 main.py" }, + js: { file: "main.js", run: "node main.js" }, + mjs: { file: "main.mjs", run: "node main.mjs" }, + cjs: { file: "main.cjs", run: "node main.cjs" }, + ts: { file: "main.ts", run: "bun main.ts" }, + go: { file: "main.go", run: "go run main.go" }, + sh: { file: "main.sh", run: "bash main.sh" }, + rb: { file: "main.rb", run: "ruby main.rb" }, + c: { file: "main.c", run: "gcc -O2 -o main main.c && ./main" }, + cpp: { file: "main.cpp", run: "g++ -O2 -o main main.cpp && ./main" }, + rs: { file: "main.rs", run: "rustc -O -o main main.rs && ./main" }, +}; + +export interface RunCodeOptions extends EgressOptions { + code: string; + lang: string; + /** Passed to the program untouched. */ + args?: string[]; + stdin?: string; + /** Wall-clock limit; the program is killed and exits 124 when hit. Default 120. */ + timeoutSec?: number; + shape?: string; + rootfs?: string; +} + +export interface RunCodeResult extends ExecResult { + timedOut: boolean; + durationMs: number; + warnings: string[]; +} + +/** The in-box command: timeout-wrapped, stdin from a pushed file or /dev/null. */ +export function runCodeCommand( + lang: string, + args: string[], + timeoutSec: number, + hasStdin: boolean, +): string { + const spec = RUN_CODE_LANGS[lang]; + if (!spec) { + throw new Error( + `Unsupported language '${lang}' — have: ${Object.keys(RUN_CODE_LANGS).join(", ")}`, + ); + } + const run = [spec.run, ...args.map(shq)].join(" "); + return `cd /work && timeout -k 5 ${timeoutSec} bash -c ${shq(run)} <${hasStdin ? ".stdin" : "/dev/null"}`; +} + +/** + * Run one piece of code off the user's machine: create → push → run → destroy. + * Buffered exec on purpose — a job that outlives one exec stream belongs in offload(). + */ +export async function runCode(opts: RunCodeOptions): Promise { + assertAuth(); + const timeoutSec = opts.timeoutSec ?? 120; + const cmd = runCodeCommand(opts.lang, opts.args ?? [], timeoutSec, opts.stdin !== undefined); + const { id, warning } = createBox({ + ...opts, + name: `cos-x-${process.pid}-${Math.floor(Math.random() * 1e6)}`, + }); + const warnings = warning ? [warning] : []; + try { + if (!(await waitRunning(id))) throw new Error(`Sandbox ${id} did not reach running within 30s`); + const push = (content: string, remote: string) => { + const res = spawnSync("createos", ["sandbox", "push", id, "-", remote], { + input: content, + encoding: "utf-8", + timeout: 120_000, + }); + if (res.status !== 0) + throw new Error( + `Push of ${remote} failed: ${(res.stderr || res.stdout || String(res.error)).trim()}`, + ); + }; + push(opts.code, `/work/${RUN_CODE_LANGS[opts.lang].file}`); + if (opts.stdin !== undefined) push(opts.stdin, "/work/.stdin"); + const t0 = Date.now(); + // spawnSync, not execShell: execShell drops stderr when the exit code is 0, + // and a program's stderr is part of its answer. + const r = spawnSync("createos", ["sandbox", "exec", id, "--", "bash", "-lc", cmd], { + encoding: "utf-8", + timeout: (timeoutSec + 60) * 1000, + maxBuffer: 64 * 1024 * 1024, + }); + const code = r.status ?? 1; + return { + code, + stdout: r.stdout ?? "", + stderr: r.stderr || (r.error ? String(r.error) : ""), + timedOut: code === 124, + durationMs: Date.now() - t0, + warnings, + }; + } finally { + const d = destroyBox(id); + if (!d.ok) warnings.push(cleanupFailureNote(id, d.error)); + } +} + /** * Stage → run (keepalive) → pull → destroy, in one call. * diff --git a/packages/pi-extension/src/tools.ts b/packages/pi-extension/src/tools.ts index 8ace9c5..b0efb38 100644 --- a/packages/pi-extension/src/tools.ts +++ b/packages/pi-extension/src/tools.ts @@ -332,6 +332,77 @@ export function registerTools(pi: ExtensionAPI, getActive: () => ToolSandbox | n }, }); + // --- Remote code execution --- + + pi.registerTool({ + name: "sandbox_run_code", + label: "Run Code In Throwaway Sandbox", + description: + "Remote code execution: run one program's source in a throwaway sandbox and destroy it. Returns stdout, " + + "stderr and the program's exit code (124 = killed by the timeout). Egress is unrestricted unless restricted.", + promptSnippet: "Run untrusted code or an ad-hoc script off this machine", + promptGuidelines: [ + "Use sandbox_run_code for untrusted code and for ANY ad-hoc script or snippet you would otherwise run locally; pass the full source as code. Several files or dependencies to install → sandbox_offload.", + "For questions about CreateOS Sandbox itself (REST API, SDKs, CLI, limits), fetch the matching page listed in https://createos.sh/docs/llms.txt under /Sandbox/ — every page is raw markdown at https://createos.sh/docs.md.", + ], + parameters: Type.Object({ + code: Type.String({ description: "Full source of the program" }), + lang: Type.String({ + description: `Language: ${Object.keys(engine.RUN_CODE_LANGS).join(" | ")}`, + }), + args: Type.Optional( + Type.Array(Type.String(), { description: "Program arguments, passed through untouched" }), + ), + stdin: Type.Optional(Type.String({ description: "Text fed to the program's stdin" })), + timeout_sec: Type.Optional( + Type.Integer({ minimum: 1, description: "Wall-clock limit in seconds (default: 120)" }), + ), + egress_deny_all: Type.Optional( + Type.Boolean({ description: "Block all outbound connections" }), + ), + egress_presets: Type.Optional( + Type.Array(Type.String(), { + description: + "Allow only what these ecosystems need: python-uv | rust-cargo | npm | github", + }), + ), + egress: Type.Optional( + Type.Array(Type.String(), { + description: "Allow only these hosts; composes with egress_presets", + }), + ), + shape: Type.Optional(Type.String({ description: "Sandbox size (default: s-1vcpu-1gb)" })), + }), + async execute(_id, params) { + const result = await engine.runCode({ + code: params.code, + lang: params.lang, + args: params.args, + stdin: params.stdin, + timeoutSec: params.timeout_sec, + egressDenyAll: params.egress_deny_all, + egressPresets: params.egress_presets, + egress: params.egress, + shape: params.shape, + }); + const header = `exit code ${result.code}${result.timedOut ? " (killed by timeout)" : ""} in ${( + result.durationMs / 1000 + ).toFixed(1)}s`; + const warnings = result.warnings.map((warning) => `warning: ${warning}`); + const text = [ + header, + ...warnings, + "", + "stdout:", + result.stdout || "(empty)", + "", + "stderr:", + result.stderr || "(empty)", + ].join("\n"); + return { content: [{ type: "text", text }], details: { result } }; + }, + }); + // --- Desktop / computer use --- function desktopTarget(params: { sandbox_id?: string; screen?: string }): { diff --git a/packages/shared/sandbox-engine.test.ts b/packages/shared/sandbox-engine.test.ts index 435b742..b90d008 100644 --- a/packages/shared/sandbox-engine.test.ts +++ b/packages/shared/sandbox-engine.test.ts @@ -14,6 +14,7 @@ import { cleanupFailureNote, egressArgs, retentionReasons, + runCodeCommand, } from "./sandbox-engine.ts"; test("a preset expands to its domains as repeated --egress flags", () => { @@ -171,3 +172,23 @@ test("an artifact path may not escape /work", () => { expect(() => assertSafeOutPath("/etc/passwd")).toThrow(/inside \/work/); expect(() => assertSafeOutPath("../../etc")).toThrow(/inside \/work/); }); + +test("egressDenyAll allows only an unroutable IP, so nothing is reachable", () => { + expect(egressArgs({ egressDenyAll: true, egressPresets: ["npm"] }).args).toEqual([ + "--egress", + "192.0.2.1/32", + ]); +}); + +test("runCode passes program args through untouched and bounds the run", () => { + const cmd = runCodeCommand("py", ["--name=alice", "a b", "it's"], 30, false); + expect(cmd).toBe( + `cd /work && timeout -k 5 30 bash -c 'python3 main.py '\\''--name=alice'\\'' '\\''a b'\\'' '\\''it'\\''\\'\\'''\\''s'\\''' { + expect(() => runCodeCommand("cobol", [], 5, false)).toThrow(/Unsupported language/); +}); diff --git a/packages/shared/sandbox-engine.ts b/packages/shared/sandbox-engine.ts index b8dcd9d..6a6a81c 100644 --- a/packages/shared/sandbox-engine.ts +++ b/packages/shared/sandbox-engine.ts @@ -15,7 +15,7 @@ * file drops into any TypeScript plugin. */ -import { execSync } from "node:child_process"; +import { execSync, spawnSync } from "node:child_process"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; @@ -191,9 +191,16 @@ export interface EgressOptions { egressPresets?: string[]; /** Unrestricted egress — only for a trusted offload. */ egressAll?: boolean; + /** Deny ALL egress: any rule flips CreateOS to deny-by-default, and an IP rule is + * enforced at once (hostname rules are not), so one unroutable TEST-NET-1 address + * allows nothing. Mirrors `cos exec -N`. */ + egressDenyAll?: boolean; } +export const DENY_ALL_EGRESS = "192.0.2.1/32"; + export function egressArgs(opts: EgressOptions): { args: string[]; warning?: string } { + if (opts.egressDenyAll) return { args: ["--egress", DENY_ALL_EGRESS] }; if (opts.egressAll) return { args: [], warning: undefined }; const domains = [ ...(opts.egress ?? []), @@ -518,6 +525,111 @@ export function cleanupFailureNote(sandboxId: string, error?: string): string { ); } +// --------------------------------------------------------------------------- +// Remote code execution — one source file in a throwaway box (`cos exec`) +// --------------------------------------------------------------------------- + +/** Language → file name in /work and the command that runs it. `.js` stays CommonJS-capable. */ +export const RUN_CODE_LANGS: Record = { + py: { file: "main.py", run: "python3 main.py" }, + js: { file: "main.js", run: "node main.js" }, + mjs: { file: "main.mjs", run: "node main.mjs" }, + cjs: { file: "main.cjs", run: "node main.cjs" }, + ts: { file: "main.ts", run: "bun main.ts" }, + go: { file: "main.go", run: "go run main.go" }, + sh: { file: "main.sh", run: "bash main.sh" }, + rb: { file: "main.rb", run: "ruby main.rb" }, + c: { file: "main.c", run: "gcc -O2 -o main main.c && ./main" }, + cpp: { file: "main.cpp", run: "g++ -O2 -o main main.cpp && ./main" }, + rs: { file: "main.rs", run: "rustc -O -o main main.rs && ./main" }, +}; + +export interface RunCodeOptions extends EgressOptions { + code: string; + lang: string; + /** Passed to the program untouched. */ + args?: string[]; + stdin?: string; + /** Wall-clock limit; the program is killed and exits 124 when hit. Default 120. */ + timeoutSec?: number; + shape?: string; + rootfs?: string; +} + +export interface RunCodeResult extends ExecResult { + timedOut: boolean; + durationMs: number; + warnings: string[]; +} + +/** The in-box command: timeout-wrapped, stdin from a pushed file or /dev/null. */ +export function runCodeCommand( + lang: string, + args: string[], + timeoutSec: number, + hasStdin: boolean, +): string { + const spec = RUN_CODE_LANGS[lang]; + if (!spec) { + throw new Error( + `Unsupported language '${lang}' — have: ${Object.keys(RUN_CODE_LANGS).join(", ")}`, + ); + } + const run = [spec.run, ...args.map(shq)].join(" "); + return `cd /work && timeout -k 5 ${timeoutSec} bash -c ${shq(run)} <${hasStdin ? ".stdin" : "/dev/null"}`; +} + +/** + * Run one piece of code off the user's machine: create → push → run → destroy. + * Buffered exec on purpose — a job that outlives one exec stream belongs in offload(). + */ +export async function runCode(opts: RunCodeOptions): Promise { + assertAuth(); + const timeoutSec = opts.timeoutSec ?? 120; + const cmd = runCodeCommand(opts.lang, opts.args ?? [], timeoutSec, opts.stdin !== undefined); + const { id, warning } = createBox({ + ...opts, + name: `cos-x-${process.pid}-${Math.floor(Math.random() * 1e6)}`, + }); + const warnings = warning ? [warning] : []; + try { + if (!(await waitRunning(id))) throw new Error(`Sandbox ${id} did not reach running within 30s`); + const push = (content: string, remote: string) => { + const res = spawnSync("createos", ["sandbox", "push", id, "-", remote], { + input: content, + encoding: "utf-8", + timeout: 120_000, + }); + if (res.status !== 0) + throw new Error( + `Push of ${remote} failed: ${(res.stderr || res.stdout || String(res.error)).trim()}`, + ); + }; + push(opts.code, `/work/${RUN_CODE_LANGS[opts.lang].file}`); + if (opts.stdin !== undefined) push(opts.stdin, "/work/.stdin"); + const t0 = Date.now(); + // spawnSync, not execShell: execShell drops stderr when the exit code is 0, + // and a program's stderr is part of its answer. + const r = spawnSync("createos", ["sandbox", "exec", id, "--", "bash", "-lc", cmd], { + encoding: "utf-8", + timeout: (timeoutSec + 60) * 1000, + maxBuffer: 64 * 1024 * 1024, + }); + const code = r.status ?? 1; + return { + code, + stdout: r.stdout ?? "", + stderr: r.stderr || (r.error ? String(r.error) : ""), + timedOut: code === 124, + durationMs: Date.now() - t0, + warnings, + }; + } finally { + const d = destroyBox(id); + if (!d.ok) warnings.push(cleanupFailureNote(id, d.error)); + } +} + /** * Stage → run (keepalive) → pull → destroy, in one call. * diff --git a/scripts/sync-shared.sh b/scripts/sync-shared.sh index c4bf305..df57a2a 100755 --- a/scripts/sync-shared.sh +++ b/scripts/sync-shared.sh @@ -21,6 +21,7 @@ PAIRS=( "packages/claude-code-plugin/scripts/cos:packages/codex-plugin/scripts/cos" "packages/claude-code-plugin/scripts/offload-hint.sh:packages/codex-plugin/scripts/offload-hint.sh" "packages/claude-code-plugin/skills/using-createos-sandbox/SKILL.md:packages/codex-plugin/skills/using-createos-sandbox/SKILL.md" + "packages/claude-code-plugin/skills/using-createos-sandbox/references/docs.md:packages/codex-plugin/skills/using-createos-sandbox/references/docs.md" "packages/claude-code-plugin/skills/using-createos-sandbox/references/coding-agents.md:packages/codex-plugin/skills/using-createos-sandbox/references/coding-agents.md" "packages/claude-code-plugin/skills/using-createos-sandbox/references/lifecycle-and-images.md:packages/codex-plugin/skills/using-createos-sandbox/references/lifecycle-and-images.md" "packages/claude-code-plugin/skills/using-createos-sandbox/references/networking.md:packages/codex-plugin/skills/using-createos-sandbox/references/networking.md"