Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 5 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,8 @@ spawn claude gcp --beta tarball --beta parallel
| `images` | Use pre-built cloud images/snapshots (faster boot) |
| `parallel` | Parallelize server boot with setup prompts |
| `recursive` | Install spawn CLI on VM so it can spawn child VMs |
| `sandbox` | Run local agents in a Docker container (sandboxed) |

`--fast` enables `tarball`, `images`, and `parallel` (not `recursive` or `sandbox`).
`--fast` enables `tarball`, `images`, and `parallel` (not `recursive`).

#### Recursive Spawn

Expand Down Expand Up @@ -188,12 +187,12 @@ Tear down an entire tree:
spawn delete --cascade <id> # Delete a VM and all its children
```

#### Sandboxed Local
#### Local Sandbox

Use `--beta sandbox` to run local agents inside a Docker container instead of directly on your machine:
The `sandbox` cloud runs an agent inside a throwaway Docker container on your own machine, instead of directly on your host:

```bash
spawn claude local --beta sandbox
spawn claude sandbox
```

What this does:
Expand All @@ -202,12 +201,7 @@ What this does:
- **Auto-installs Docker** if not present (OrbStack on macOS, docker.io on Linux)
- **Cleans up the container** automatically when the session ends

In the interactive picker, `--beta sandbox` adds a "Local Machine (Sandboxed)" option alongside the regular "Local Machine":

```bash
spawn --beta sandbox # Interactive picker shows both local options
spawn openclaw local --beta sandbox # Direct launch, sandboxed
```
It's the same setup as the `local` cloud, but the agent can't touch your host filesystem, shell, or SSH keys. (This was previously the `--beta sandbox` flag.)

### Without the CLI

Expand Down
22 changes: 22 additions & 0 deletions manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,18 @@
"interactive_method": "exec",
"notes": "No cloud provisioning needed. Installs agents and injects OpenRouter credentials locally. Useful for local development and testing."
},
"sandbox": {
"name": "Local Sandbox",
"price": "Free",
"description": "Your computer, in a throwaway Docker container — no account or payment needed",
"url": "https://github.com/OpenRouterTeam/spawn",
"type": "local",
"auth": "none",
"provision_method": "docker run (local container)",
"exec_method": "docker exec",
"interactive_method": "docker exec -it",
"notes": "Runs the agent inside an isolated Docker container on your machine. Docker is auto-installed if missing, and the container is removed on exit. Same setup as the local cloud, but the agent cannot touch your host filesystem."
},
"hetzner": {
"name": "Hetzner Cloud",
"price": "~€3/mo",
Expand Down Expand Up @@ -498,6 +510,16 @@
"local/codex": "implemented",
"local/opencode": "implemented",
"local/kilocode": "implemented",
"sandbox/claude": "implemented",
"sandbox/openclaw": "implemented",
"sandbox/codex": "implemented",
"sandbox/opencode": "implemented",
"sandbox/kilocode": "implemented",
"sandbox/hermes": "implemented",
"sandbox/junie": "implemented",
"sandbox/pi": "implemented",
"sandbox/cursor": "implemented",
"sandbox/t3code": "missing",
"hetzner/claude": "implemented",
"hetzner/openclaw": "implemented",
"hetzner/codex": "implemented",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "1.0.44",
"version": "1.1.0",
"type": "module",
"bin": {
"spawn": "cli.js"
Expand Down
1 change: 0 additions & 1 deletion packages/cli/src/__tests__/feature-flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,6 @@ describe("feature flags", () => {
expect(expandFastProvisionVariant("test")).toEqual([
"images",
"docker",
"sandbox",
]);
});

Expand Down
45 changes: 33 additions & 12 deletions packages/cli/src/__tests__/sandbox.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { mockBunSpawn, mockClackPrompts } from "./test-helpers";

mockClackPrompts();
Expand Down Expand Up @@ -394,20 +396,39 @@ describe("cleanupContainer", () => {
});
});

// ─── sandbox mode integration ───────────────────────────────────────────────

describe("sandbox mode", () => {
it("sandbox beta feature is detected from SPAWN_BETA", () => {
process.env.SPAWN_BETA = "sandbox";
const betaFeatures = (process.env.SPAWN_BETA ?? "").split(",");
expect(betaFeatures.includes("sandbox")).toBe(true);
// ─── sandbox cloud wiring ───────────────────────────────────────────────────

describe("sandbox cloud", () => {
const REPO_ROOT = resolve(import.meta.dir, "../../../..");
const manifest = JSON.parse(readFileSync(resolve(REPO_ROOT, "manifest.json"), "utf-8"));
// Docker-capable agents — `sandbox` runs each inside a container image.
const SANDBOX_AGENTS = [
"claude",
"codex",
"cursor",
"hermes",
"junie",
"kilocode",
"openclaw",
"opencode",
"pi",
];

it("is registered as a no-auth cloud in the manifest", () => {
expect(manifest.clouds.sandbox).toBeDefined();
expect(manifest.clouds.sandbox.auth).toBe("none");
});

it("is marked implemented in the matrix for every Docker-capable agent", () => {
for (const agent of SANDBOX_AGENTS) {
expect(manifest.matrix[`sandbox/${agent}`]).toBe("implemented");
}
});

it("sandbox can coexist with other beta features", () => {
process.env.SPAWN_BETA = "tarball,sandbox,parallel";
const betaFeatures = (process.env.SPAWN_BETA ?? "").split(",");
expect(betaFeatures.includes("sandbox")).toBe(true);
expect(betaFeatures.includes("tarball")).toBe(true);
it("ships a shim script for every implemented agent", () => {
for (const agent of SANDBOX_AGENTS) {
expect(existsSync(resolve(REPO_ROOT, "sh", "sandbox", `${agent}.sh`))).toBe(true);
}
});
});

Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/__tests__/update-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,9 +486,9 @@ describe("update-check", () => {
// - SPAWN_NO_AUTO_UPDATE=1 suppresses auto-install entirely
describe("update policy", () => {
it("auto-installs patch bumps even without SPAWN_AUTO_UPDATE=1", async () => {
// 1.0.20 -> 1.0.99 is a patch bump (same major.minor)
// current -> 1.1.99 is a patch bump (same major.minor as the 1.1.x line)
process.env.SPAWN_AUTO_UPDATE = undefined;
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.0.99\n")));
const fetchSpy = spyOn(global, "fetch").mockImplementation(() => Promise.resolve(new Response("1.1.99\n")));
const { executor } = await import("../update-check.js");
const execFileSyncSpy = spyOn(executor, "execFileSync").mockImplementation((file: string) =>
Buffer.from(file === "curl" ? FAKE_INSTALL_SCRIPT : ""),
Expand Down
6 changes: 1 addition & 5 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,6 @@ function checkUnknownFlags(args: string[]): void {
console.error(` ${pc.cyan("--beta images")} Use pre-built DO marketplace images (faster boot)`);
console.error(` ${pc.cyan("--beta parallel")} Parallelize server boot with setup prompts`);
console.error(` ${pc.cyan("--beta docker")} Use Docker CE app image on Hetzner/GCP (faster boot)`);
console.error(` ${pc.cyan("--beta sandbox")} Run local agents in a Docker container (sandboxed)`);
console.error(` ${pc.cyan("--beta recursive")} Install spawn CLI on VM for recursive spawning`);
console.error(` ${pc.cyan("--help, -h")} Show help information`);
console.error(` ${pc.cyan("--version, -v")} Show version`);
Expand Down Expand Up @@ -943,7 +942,6 @@ async function main(): Promise<void> {
"parallel",
"docker",
"recursive",
"sandbox",
"skills",
]);
const betaFeatures = extractAllFlagValues(filteredArgs, "--beta", "spawn <agent> <cloud> --beta parallel");
Expand All @@ -956,7 +954,6 @@ async function main(): Promise<void> {
console.error(` ${pc.cyan("images")} Use pre-built DO marketplace images (faster boot)`);
console.error(` ${pc.cyan("parallel")} Parallelize server boot with setup prompts`);
console.error(` ${pc.cyan("docker")} Use Docker CE app image on Hetzner/GCP (faster boot)`);
console.error(` ${pc.cyan("sandbox")} Run local agents in a Docker container (sandboxed)`);
console.error(` ${pc.cyan("skills")} Pre-install MCP servers and tools on the VM`);
console.error(` ${pc.cyan("recursive")} Install spawn CLI on VM for recursive spawning`);
process.exit(1);
Expand All @@ -969,10 +966,9 @@ async function main(): Promise<void> {

// fast_provision experiment: if the user did NOT pass --beta or --fast,
// bucket them on the PostHog `fast_provision` flag. The `test` variant
// turns on images + docker + sandbox by default; control behaves as before.
// turns on images + docker by default; control behaves as before.
// - images: pre-built DO marketplace images (cloud-side faster boot)
// - docker: Docker CE host image on Hetzner/GCP (cloud-side faster boot)
// - sandbox: local agents run in a Docker container (local-side faster boot)
// Exposure is captured for both variants so PostHog can compute conversion.
// Bundle composition lives in expandFastProvisionVariant() for unit testing.
if (!userOptedIntoBeta) {
Expand Down
111 changes: 7 additions & 104 deletions packages/cli/src/local/main.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,15 @@
#!/usr/bin/env bun

// local/main.ts — Orchestrator: deploys an agent on the local machine
// local/main.ts — Orchestrator: deploys an agent on the local machine.
//
// For the isolated Docker-container variant, see sandbox/main.ts — both share
// the orchestration in local/run.ts.

import type { CloudOrchestrator } from "../shared/orchestrate.js";

import * as p from "@clack/prompts";
import { getErrorMessage } from "@openrouter/spawn-shared";
import pkg from "../../package.json" with { type: "json" };
import { createCloudAgents } from "../shared/agent-setup.js";
import { makeDockerRunner, runOrchestration } from "../shared/orchestrate.js";
import { initTelemetry } from "../shared/telemetry.js";
import { logWarn } from "../shared/ui.js";
import { agents, resolveAgent } from "./agents.js";
import {
cleanupContainer,
dockerInteractiveSession,
downloadFile,
ensureDocker,
interactiveSession,
pullAndStartContainer,
runLocal,
uploadFile,
} from "./local.js";
import { agents } from "./agents.js";
import { runLocalAgent } from "./run.js";

async function main() {
const agentName = process.argv[2];
Expand All @@ -31,92 +19,7 @@ async function main() {
process.exit(1);
}

// Check if --beta sandbox is active
const betaFeatures = (process.env.SPAWN_BETA ?? "").split(",");
const useSandbox = betaFeatures.includes("sandbox");

const baseRunner = {
runServer: runLocal,
uploadFile: async (l: string, r: string) => uploadFile(l, r),
downloadFile: async (r: string, l: string) => downloadFile(r, l),
};

// When sandboxed, recreate agents with the Docker-wrapped runner so that
// agent.configure() / agent.install() closures execute inside the container
// instead of writing config files directly to the host filesystem.
const agent = useSandbox
? createCloudAgents(makeDockerRunner(baseRunner)).resolveAgent(agentName)
: resolveAgent(agentName);

// If sandboxed, ensure Docker is installed (auto-install if missing)
if (useSandbox) {
await ensureDocker();
}

// Warn about security implications of installing OpenClaw locally
// (skip warning in sandbox mode — the container provides isolation)
if (agentName === "openclaw" && !useSandbox && process.env.SPAWN_NON_INTERACTIVE !== "1") {
process.stderr.write("\n");
logWarn("⚠ Local installation warning");
logWarn(` This will install ${agent.name} directly on your machine.`);
logWarn(" The agent will have full access to your filesystem, shell, and network.");
logWarn(" For isolation, consider running on a cloud VM instead.\n");

const confirmed = await p.confirm({
message: "Continue with local installation?",
initialValue: true,
});

if (p.isCancel(confirmed) || !confirmed) {
p.log.info("Installation cancelled.");
process.exit(0);
}
}

const cloud: CloudOrchestrator = {
cloudName: "local",
cloudLabel: useSandbox ? "local (sandboxed)" : "local",
skipAgentInstall: false,
runner: useSandbox ? makeDockerRunner(baseRunner) : baseRunner,
async authenticate() {},
async promptSize() {},
async createServer(_name: string) {
return {
ip: "localhost",
user: process.env.USER || "local",
cloud: "local",
};
},
async getServerName() {
const result = Bun.spawnSync(
[
"hostname",
],
{
stdio: [
"ignore",
"pipe",
"ignore",
],
},
);
return new TextDecoder().decode(result.stdout).trim() || "local";
},
async waitForReady() {
if (useSandbox) {
await pullAndStartContainer(agentName);
cloud.skipAgentInstall = true;
}
},
interactiveSession: useSandbox ? dockerInteractiveSession : interactiveSession,
};

// Clean up sandbox container on exit
if (useSandbox) {
process.on("exit", cleanupContainer);
}

await runOrchestration(cloud, agent, agentName);
await runLocalAgent(agentName, false);
}

initTelemetry(pkg.version);
Expand Down
Loading
Loading