mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-07-11 10:08:31 +00:00
feat(sandbox): promote sandbox from --beta flag to first-class cloud (#3432)
Running an agent in a local Docker container is now a cloud target: `spawn <agent> sandbox`, instead of `spawn <agent> local --beta sandbox`. - Extract local orchestration into local/run.ts (runLocalAgent), shared by the local and sandbox clouds; add sandbox/main.ts entry point. - Add 9 sh/sandbox/*.sh shims + README for the Docker-capable agents. - Register the sandbox cloud and matrix entries in manifest.json. - Remove sandbox from the --beta gate and the fast_provision experiment. - Bump CLI to 1.1.0; update tests for the new wiring. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8661ab26ec
commit
18bbe3e9a5
21 changed files with 541 additions and 136 deletions
16
README.md
16
README.md
|
|
@ -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
|
||||
|
||||
|
|
@ -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:
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "1.0.44",
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
|
|
@ -236,7 +236,6 @@ describe("feature flags", () => {
|
|||
expect(expandFastProvisionVariant("test")).toEqual([
|
||||
"images",
|
||||
"docker",
|
||||
"sandbox",
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -394,20 +396,39 @@ describe("cleanupContainer", () => {
|
|||
});
|
||||
});
|
||||
|
||||
// ─── sandbox mode integration ───────────────────────────────────────────────
|
||||
// ─── sandbox cloud wiring ───────────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
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("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("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("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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 : ""),
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
@ -943,7 +942,6 @@ async function main(): Promise<void> {
|
|||
"parallel",
|
||||
"docker",
|
||||
"recursive",
|
||||
"sandbox",
|
||||
"skills",
|
||||
]);
|
||||
const betaFeatures = extractAllFlagValues(filteredArgs, "--beta", "spawn <agent> <cloud> --beta parallel");
|
||||
|
|
@ -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);
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
122
packages/cli/src/local/run.ts
Normal file
122
packages/cli/src/local/run.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// local/run.ts — Shared orchestration for the `local` and `sandbox` clouds.
|
||||
//
|
||||
// `local` runs the agent directly on the host machine.
|
||||
// `sandbox` runs the agent inside a throwaway Docker container on the host.
|
||||
//
|
||||
// Both share one code path; `useSandbox` swaps in the Docker-wrapped runner,
|
||||
// container lifecycle, and interactive session. The orchestrator's internal
|
||||
// `cloudName` stays "local" either way — orchestrate.ts has ~20 `!== "local"`
|
||||
// branches (tarball install, repo cloning, restart loops, reconnects, skills)
|
||||
// that must treat the sandbox as local execution. The user-facing `sandbox`
|
||||
// cloud name is tracked separately by the run/headless command layer.
|
||||
|
||||
import type { CloudOrchestrator } from "../shared/orchestrate.js";
|
||||
|
||||
import * as p from "@clack/prompts";
|
||||
import { createCloudAgents } from "../shared/agent-setup.js";
|
||||
import { makeDockerRunner, runOrchestration } from "../shared/orchestrate.js";
|
||||
import { logWarn } from "../shared/ui.js";
|
||||
import { resolveAgent } from "./agents.js";
|
||||
import {
|
||||
cleanupContainer,
|
||||
dockerInteractiveSession,
|
||||
downloadFile,
|
||||
ensureDocker,
|
||||
interactiveSession,
|
||||
pullAndStartContainer,
|
||||
runLocal,
|
||||
uploadFile,
|
||||
} from "./local.js";
|
||||
|
||||
/**
|
||||
* Deploy an agent on the local machine.
|
||||
*
|
||||
* @param agentName Agent key (e.g. "claude", "hermes").
|
||||
* @param useSandbox When true, the agent runs inside a Docker container
|
||||
* (the `sandbox` cloud); otherwise it runs directly on the
|
||||
* host (the `local` cloud).
|
||||
*/
|
||||
export async function runLocalAgent(agentName: string, useSandbox: boolean): Promise<void> {
|
||||
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);
|
||||
}
|
||||
31
packages/cli/src/sandbox/main.ts
Normal file
31
packages/cli/src/sandbox/main.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
#!/usr/bin/env bun
|
||||
|
||||
// sandbox/main.ts — Orchestrator: deploys an agent inside a local Docker sandbox.
|
||||
//
|
||||
// The `sandbox` cloud reuses the `local` orchestrator with the Docker-wrapped
|
||||
// runner enabled, so the agent runs in a throwaway container on the host
|
||||
// machine. Docker is auto-installed if missing; the container is removed on
|
||||
// exit. See local/run.ts for the shared implementation.
|
||||
|
||||
import { getErrorMessage } from "@openrouter/spawn-shared";
|
||||
import pkg from "../../package.json" with { type: "json" };
|
||||
import { agents } from "../local/agents.js";
|
||||
import { runLocalAgent } from "../local/run.js";
|
||||
import { initTelemetry } from "../shared/telemetry.js";
|
||||
|
||||
async function main() {
|
||||
const agentName = process.argv[2];
|
||||
if (!agentName) {
|
||||
console.error("Usage: bun run sandbox/main.ts <agent>");
|
||||
console.error(`Agents: ${Object.keys(agents).join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await runLocalAgent(agentName, true);
|
||||
}
|
||||
|
||||
initTelemetry(pkg.version);
|
||||
main().catch((err) => {
|
||||
process.stderr.write(`\x1b[0;31mFatal: ${getErrorMessage(err)}\x1b[0m\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -215,7 +215,6 @@ export function expandFastProvisionVariant(variant: string): readonly string[] {
|
|||
return [
|
||||
"images",
|
||||
"docker",
|
||||
"sandbox",
|
||||
];
|
||||
}
|
||||
return [];
|
||||
|
|
|
|||
57
sh/sandbox/README.md
Normal file
57
sh/sandbox/README.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# Local Sandbox
|
||||
|
||||
Run agents inside a throwaway Docker container on your own machine.
|
||||
|
||||
> Same setup as the `local` cloud, but the agent runs in an isolated Docker container instead of directly on your host. No account or payment needed. Docker is auto-installed if missing, and the container is removed when the session ends — so the agent can't touch your host filesystem, shell, or SSH keys.
|
||||
|
||||
This was previously the `--beta sandbox` flag on the `local` cloud. It is now a first-class cloud.
|
||||
|
||||
## Quick Start
|
||||
|
||||
If you have the [spawn CLI](https://github.com/OpenRouterTeam/spawn) installed:
|
||||
|
||||
```bash
|
||||
spawn claude sandbox
|
||||
spawn openclaw sandbox
|
||||
spawn codex sandbox
|
||||
spawn opencode sandbox
|
||||
spawn kilocode sandbox
|
||||
spawn hermes sandbox
|
||||
spawn junie sandbox
|
||||
spawn cursor sandbox
|
||||
spawn pi sandbox
|
||||
```
|
||||
|
||||
Or run directly without the CLI:
|
||||
|
||||
```bash
|
||||
bash <(curl -fsSL https://openrouter.ai/labs/spawn/sandbox/claude.sh)
|
||||
bash <(curl -fsSL https://openrouter.ai/labs/spawn/sandbox/openclaw.sh)
|
||||
bash <(curl -fsSL https://openrouter.ai/labs/spawn/sandbox/codex.sh)
|
||||
bash <(curl -fsSL https://openrouter.ai/labs/spawn/sandbox/opencode.sh)
|
||||
bash <(curl -fsSL https://openrouter.ai/labs/spawn/sandbox/kilocode.sh)
|
||||
bash <(curl -fsSL https://openrouter.ai/labs/spawn/sandbox/hermes.sh)
|
||||
bash <(curl -fsSL https://openrouter.ai/labs/spawn/sandbox/junie.sh)
|
||||
bash <(curl -fsSL https://openrouter.ai/labs/spawn/sandbox/cursor.sh)
|
||||
bash <(curl -fsSL https://openrouter.ai/labs/spawn/sandbox/pi.sh)
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Docker** — auto-installed if missing (OrbStack on macOS, `docker.io` via apt on Linux).
|
||||
- **`OPENROUTER_API_KEY`** — prompted interactively, or set in the environment.
|
||||
|
||||
## How it works
|
||||
|
||||
The `sandbox` cloud reuses the `local` orchestrator with a Docker-wrapped runner:
|
||||
|
||||
1. Ensure Docker is installed and running.
|
||||
2. Pull the agent image `ghcr.io/openrouterteam/spawn-<agent>:latest` and start a container.
|
||||
3. Inject OpenRouter credentials and write agent config files **inside the container**.
|
||||
4. Drop into an interactive session via `docker exec -it`.
|
||||
5. Remove the container on exit.
|
||||
|
||||
## Notes
|
||||
|
||||
- Agents that need a Docker image: `claude`, `codex`, `cursor`, `hermes`, `junie`, `kilocode`, `openclaw`, `opencode`, `pi`. The container images are built from `sh/docker/<agent>.Dockerfile`.
|
||||
- For host-native execution (no container), use the [`local`](../local/README.md) cloud instead.
|
||||
29
sh/sandbox/claude.sh
Executable file
29
sh/sandbox/claude.sh
Executable file
|
|
@ -0,0 +1,29 @@
|
|||
#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
# Thin shim for the `sandbox` cloud: runs the agent inside a throwaway Docker
|
||||
# container on this machine. Ensures bun is available, then runs bundled
|
||||
# sandbox.js (local source or from the GitHub release).
|
||||
|
||||
_ensure_bun() {
|
||||
if command -v bun &>/dev/null; then return 0; fi
|
||||
printf '\033[0;36mInstalling bun...\033[0m\n' >&2
|
||||
curl -fsSL --proto '=https' --show-error https://bun.sh/install?version=1.3.9 | bash >/dev/null || { printf '\033[0;31mFailed to install bun\033[0m\n' >&2; exit 1; }
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
command -v bun &>/dev/null || { printf '\033[0;31mbun not found after install\033[0m\n' >&2; exit 1; }
|
||||
}
|
||||
|
||||
_ensure_bun
|
||||
|
||||
# SPAWN_CLI_DIR override — force local source (used by e2e tests)
|
||||
if [[ -n "${SPAWN_CLI_DIR:-}" && -f "$SPAWN_CLI_DIR/packages/cli/src/sandbox/main.ts" ]]; then
|
||||
exec bun run "$SPAWN_CLI_DIR/packages/cli/src/sandbox/main.ts" claude "$@"
|
||||
fi
|
||||
|
||||
# Remote — download bundled sandbox.js from GitHub release
|
||||
SANDBOX_JS=$(mktemp)
|
||||
trap 'rm -f "$SANDBOX_JS"' EXIT
|
||||
curl -fsSL --proto '=https' "https://github.com/OpenRouterTeam/spawn/releases/download/sandbox-latest/sandbox.js" -o "$SANDBOX_JS" \
|
||||
|| { printf '\033[0;31mFailed to download sandbox.js\033[0m\n' >&2; exit 1; }
|
||||
|
||||
exec bun run "$SANDBOX_JS" claude "$@"
|
||||
29
sh/sandbox/codex.sh
Executable file
29
sh/sandbox/codex.sh
Executable file
|
|
@ -0,0 +1,29 @@
|
|||
#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
# Thin shim for the `sandbox` cloud: runs the agent inside a throwaway Docker
|
||||
# container on this machine. Ensures bun is available, then runs bundled
|
||||
# sandbox.js (local source or from the GitHub release).
|
||||
|
||||
_ensure_bun() {
|
||||
if command -v bun &>/dev/null; then return 0; fi
|
||||
printf '\033[0;36mInstalling bun...\033[0m\n' >&2
|
||||
curl -fsSL --proto '=https' --show-error https://bun.sh/install?version=1.3.9 | bash >/dev/null || { printf '\033[0;31mFailed to install bun\033[0m\n' >&2; exit 1; }
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
command -v bun &>/dev/null || { printf '\033[0;31mbun not found after install\033[0m\n' >&2; exit 1; }
|
||||
}
|
||||
|
||||
_ensure_bun
|
||||
|
||||
# SPAWN_CLI_DIR override — force local source (used by e2e tests)
|
||||
if [[ -n "${SPAWN_CLI_DIR:-}" && -f "$SPAWN_CLI_DIR/packages/cli/src/sandbox/main.ts" ]]; then
|
||||
exec bun run "$SPAWN_CLI_DIR/packages/cli/src/sandbox/main.ts" codex "$@"
|
||||
fi
|
||||
|
||||
# Remote — download bundled sandbox.js from GitHub release
|
||||
SANDBOX_JS=$(mktemp)
|
||||
trap 'rm -f "$SANDBOX_JS"' EXIT
|
||||
curl -fsSL --proto '=https' "https://github.com/OpenRouterTeam/spawn/releases/download/sandbox-latest/sandbox.js" -o "$SANDBOX_JS" \
|
||||
|| { printf '\033[0;31mFailed to download sandbox.js\033[0m\n' >&2; exit 1; }
|
||||
|
||||
exec bun run "$SANDBOX_JS" codex "$@"
|
||||
29
sh/sandbox/cursor.sh
Executable file
29
sh/sandbox/cursor.sh
Executable file
|
|
@ -0,0 +1,29 @@
|
|||
#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
# Thin shim for the `sandbox` cloud: runs the agent inside a throwaway Docker
|
||||
# container on this machine. Ensures bun is available, then runs bundled
|
||||
# sandbox.js (local source or from the GitHub release).
|
||||
|
||||
_ensure_bun() {
|
||||
if command -v bun &>/dev/null; then return 0; fi
|
||||
printf '\033[0;36mInstalling bun...\033[0m\n' >&2
|
||||
curl -fsSL --proto '=https' --show-error https://bun.sh/install?version=1.3.9 | bash >/dev/null || { printf '\033[0;31mFailed to install bun\033[0m\n' >&2; exit 1; }
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
command -v bun &>/dev/null || { printf '\033[0;31mbun not found after install\033[0m\n' >&2; exit 1; }
|
||||
}
|
||||
|
||||
_ensure_bun
|
||||
|
||||
# SPAWN_CLI_DIR override — force local source (used by e2e tests)
|
||||
if [[ -n "${SPAWN_CLI_DIR:-}" && -f "$SPAWN_CLI_DIR/packages/cli/src/sandbox/main.ts" ]]; then
|
||||
exec bun run "$SPAWN_CLI_DIR/packages/cli/src/sandbox/main.ts" cursor "$@"
|
||||
fi
|
||||
|
||||
# Remote — download bundled sandbox.js from GitHub release
|
||||
SANDBOX_JS=$(mktemp)
|
||||
trap 'rm -f "$SANDBOX_JS"' EXIT
|
||||
curl -fsSL --proto '=https' "https://github.com/OpenRouterTeam/spawn/releases/download/sandbox-latest/sandbox.js" -o "$SANDBOX_JS" \
|
||||
|| { printf '\033[0;31mFailed to download sandbox.js\033[0m\n' >&2; exit 1; }
|
||||
|
||||
exec bun run "$SANDBOX_JS" cursor "$@"
|
||||
29
sh/sandbox/hermes.sh
Executable file
29
sh/sandbox/hermes.sh
Executable file
|
|
@ -0,0 +1,29 @@
|
|||
#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
# Thin shim for the `sandbox` cloud: runs the agent inside a throwaway Docker
|
||||
# container on this machine. Ensures bun is available, then runs bundled
|
||||
# sandbox.js (local source or from the GitHub release).
|
||||
|
||||
_ensure_bun() {
|
||||
if command -v bun &>/dev/null; then return 0; fi
|
||||
printf '\033[0;36mInstalling bun...\033[0m\n' >&2
|
||||
curl -fsSL --proto '=https' --show-error https://bun.sh/install?version=1.3.9 | bash >/dev/null || { printf '\033[0;31mFailed to install bun\033[0m\n' >&2; exit 1; }
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
command -v bun &>/dev/null || { printf '\033[0;31mbun not found after install\033[0m\n' >&2; exit 1; }
|
||||
}
|
||||
|
||||
_ensure_bun
|
||||
|
||||
# SPAWN_CLI_DIR override — force local source (used by e2e tests)
|
||||
if [[ -n "${SPAWN_CLI_DIR:-}" && -f "$SPAWN_CLI_DIR/packages/cli/src/sandbox/main.ts" ]]; then
|
||||
exec bun run "$SPAWN_CLI_DIR/packages/cli/src/sandbox/main.ts" hermes "$@"
|
||||
fi
|
||||
|
||||
# Remote — download bundled sandbox.js from GitHub release
|
||||
SANDBOX_JS=$(mktemp)
|
||||
trap 'rm -f "$SANDBOX_JS"' EXIT
|
||||
curl -fsSL --proto '=https' "https://github.com/OpenRouterTeam/spawn/releases/download/sandbox-latest/sandbox.js" -o "$SANDBOX_JS" \
|
||||
|| { printf '\033[0;31mFailed to download sandbox.js\033[0m\n' >&2; exit 1; }
|
||||
|
||||
exec bun run "$SANDBOX_JS" hermes "$@"
|
||||
29
sh/sandbox/junie.sh
Executable file
29
sh/sandbox/junie.sh
Executable file
|
|
@ -0,0 +1,29 @@
|
|||
#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
# Thin shim for the `sandbox` cloud: runs the agent inside a throwaway Docker
|
||||
# container on this machine. Ensures bun is available, then runs bundled
|
||||
# sandbox.js (local source or from the GitHub release).
|
||||
|
||||
_ensure_bun() {
|
||||
if command -v bun &>/dev/null; then return 0; fi
|
||||
printf '\033[0;36mInstalling bun...\033[0m\n' >&2
|
||||
curl -fsSL --proto '=https' --show-error https://bun.sh/install?version=1.3.9 | bash >/dev/null || { printf '\033[0;31mFailed to install bun\033[0m\n' >&2; exit 1; }
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
command -v bun &>/dev/null || { printf '\033[0;31mbun not found after install\033[0m\n' >&2; exit 1; }
|
||||
}
|
||||
|
||||
_ensure_bun
|
||||
|
||||
# SPAWN_CLI_DIR override — force local source (used by e2e tests)
|
||||
if [[ -n "${SPAWN_CLI_DIR:-}" && -f "$SPAWN_CLI_DIR/packages/cli/src/sandbox/main.ts" ]]; then
|
||||
exec bun run "$SPAWN_CLI_DIR/packages/cli/src/sandbox/main.ts" junie "$@"
|
||||
fi
|
||||
|
||||
# Remote — download bundled sandbox.js from GitHub release
|
||||
SANDBOX_JS=$(mktemp)
|
||||
trap 'rm -f "$SANDBOX_JS"' EXIT
|
||||
curl -fsSL --proto '=https' "https://github.com/OpenRouterTeam/spawn/releases/download/sandbox-latest/sandbox.js" -o "$SANDBOX_JS" \
|
||||
|| { printf '\033[0;31mFailed to download sandbox.js\033[0m\n' >&2; exit 1; }
|
||||
|
||||
exec bun run "$SANDBOX_JS" junie "$@"
|
||||
29
sh/sandbox/kilocode.sh
Executable file
29
sh/sandbox/kilocode.sh
Executable file
|
|
@ -0,0 +1,29 @@
|
|||
#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
# Thin shim for the `sandbox` cloud: runs the agent inside a throwaway Docker
|
||||
# container on this machine. Ensures bun is available, then runs bundled
|
||||
# sandbox.js (local source or from the GitHub release).
|
||||
|
||||
_ensure_bun() {
|
||||
if command -v bun &>/dev/null; then return 0; fi
|
||||
printf '\033[0;36mInstalling bun...\033[0m\n' >&2
|
||||
curl -fsSL --proto '=https' --show-error https://bun.sh/install?version=1.3.9 | bash >/dev/null || { printf '\033[0;31mFailed to install bun\033[0m\n' >&2; exit 1; }
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
command -v bun &>/dev/null || { printf '\033[0;31mbun not found after install\033[0m\n' >&2; exit 1; }
|
||||
}
|
||||
|
||||
_ensure_bun
|
||||
|
||||
# SPAWN_CLI_DIR override — force local source (used by e2e tests)
|
||||
if [[ -n "${SPAWN_CLI_DIR:-}" && -f "$SPAWN_CLI_DIR/packages/cli/src/sandbox/main.ts" ]]; then
|
||||
exec bun run "$SPAWN_CLI_DIR/packages/cli/src/sandbox/main.ts" kilocode "$@"
|
||||
fi
|
||||
|
||||
# Remote — download bundled sandbox.js from GitHub release
|
||||
SANDBOX_JS=$(mktemp)
|
||||
trap 'rm -f "$SANDBOX_JS"' EXIT
|
||||
curl -fsSL --proto '=https' "https://github.com/OpenRouterTeam/spawn/releases/download/sandbox-latest/sandbox.js" -o "$SANDBOX_JS" \
|
||||
|| { printf '\033[0;31mFailed to download sandbox.js\033[0m\n' >&2; exit 1; }
|
||||
|
||||
exec bun run "$SANDBOX_JS" kilocode "$@"
|
||||
29
sh/sandbox/openclaw.sh
Executable file
29
sh/sandbox/openclaw.sh
Executable file
|
|
@ -0,0 +1,29 @@
|
|||
#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
# Thin shim for the `sandbox` cloud: runs the agent inside a throwaway Docker
|
||||
# container on this machine. Ensures bun is available, then runs bundled
|
||||
# sandbox.js (local source or from the GitHub release).
|
||||
|
||||
_ensure_bun() {
|
||||
if command -v bun &>/dev/null; then return 0; fi
|
||||
printf '\033[0;36mInstalling bun...\033[0m\n' >&2
|
||||
curl -fsSL --proto '=https' --show-error https://bun.sh/install?version=1.3.9 | bash >/dev/null || { printf '\033[0;31mFailed to install bun\033[0m\n' >&2; exit 1; }
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
command -v bun &>/dev/null || { printf '\033[0;31mbun not found after install\033[0m\n' >&2; exit 1; }
|
||||
}
|
||||
|
||||
_ensure_bun
|
||||
|
||||
# SPAWN_CLI_DIR override — force local source (used by e2e tests)
|
||||
if [[ -n "${SPAWN_CLI_DIR:-}" && -f "$SPAWN_CLI_DIR/packages/cli/src/sandbox/main.ts" ]]; then
|
||||
exec bun run "$SPAWN_CLI_DIR/packages/cli/src/sandbox/main.ts" openclaw "$@"
|
||||
fi
|
||||
|
||||
# Remote — download bundled sandbox.js from GitHub release
|
||||
SANDBOX_JS=$(mktemp)
|
||||
trap 'rm -f "$SANDBOX_JS"' EXIT
|
||||
curl -fsSL --proto '=https' "https://github.com/OpenRouterTeam/spawn/releases/download/sandbox-latest/sandbox.js" -o "$SANDBOX_JS" \
|
||||
|| { printf '\033[0;31mFailed to download sandbox.js\033[0m\n' >&2; exit 1; }
|
||||
|
||||
exec bun run "$SANDBOX_JS" openclaw "$@"
|
||||
29
sh/sandbox/opencode.sh
Executable file
29
sh/sandbox/opencode.sh
Executable file
|
|
@ -0,0 +1,29 @@
|
|||
#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
# Thin shim for the `sandbox` cloud: runs the agent inside a throwaway Docker
|
||||
# container on this machine. Ensures bun is available, then runs bundled
|
||||
# sandbox.js (local source or from the GitHub release).
|
||||
|
||||
_ensure_bun() {
|
||||
if command -v bun &>/dev/null; then return 0; fi
|
||||
printf '\033[0;36mInstalling bun...\033[0m\n' >&2
|
||||
curl -fsSL --proto '=https' --show-error https://bun.sh/install?version=1.3.9 | bash >/dev/null || { printf '\033[0;31mFailed to install bun\033[0m\n' >&2; exit 1; }
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
command -v bun &>/dev/null || { printf '\033[0;31mbun not found after install\033[0m\n' >&2; exit 1; }
|
||||
}
|
||||
|
||||
_ensure_bun
|
||||
|
||||
# SPAWN_CLI_DIR override — force local source (used by e2e tests)
|
||||
if [[ -n "${SPAWN_CLI_DIR:-}" && -f "$SPAWN_CLI_DIR/packages/cli/src/sandbox/main.ts" ]]; then
|
||||
exec bun run "$SPAWN_CLI_DIR/packages/cli/src/sandbox/main.ts" opencode "$@"
|
||||
fi
|
||||
|
||||
# Remote — download bundled sandbox.js from GitHub release
|
||||
SANDBOX_JS=$(mktemp)
|
||||
trap 'rm -f "$SANDBOX_JS"' EXIT
|
||||
curl -fsSL --proto '=https' "https://github.com/OpenRouterTeam/spawn/releases/download/sandbox-latest/sandbox.js" -o "$SANDBOX_JS" \
|
||||
|| { printf '\033[0;31mFailed to download sandbox.js\033[0m\n' >&2; exit 1; }
|
||||
|
||||
exec bun run "$SANDBOX_JS" opencode "$@"
|
||||
29
sh/sandbox/pi.sh
Executable file
29
sh/sandbox/pi.sh
Executable file
|
|
@ -0,0 +1,29 @@
|
|||
#!/bin/bash
|
||||
set -eo pipefail
|
||||
|
||||
# Thin shim for the `sandbox` cloud: runs the agent inside a throwaway Docker
|
||||
# container on this machine. Ensures bun is available, then runs bundled
|
||||
# sandbox.js (local source or from the GitHub release).
|
||||
|
||||
_ensure_bun() {
|
||||
if command -v bun &>/dev/null; then return 0; fi
|
||||
printf '\033[0;36mInstalling bun...\033[0m\n' >&2
|
||||
curl -fsSL --proto '=https' --show-error https://bun.sh/install?version=1.3.9 | bash >/dev/null || { printf '\033[0;31mFailed to install bun\033[0m\n' >&2; exit 1; }
|
||||
export PATH="$HOME/.bun/bin:$PATH"
|
||||
command -v bun &>/dev/null || { printf '\033[0;31mbun not found after install\033[0m\n' >&2; exit 1; }
|
||||
}
|
||||
|
||||
_ensure_bun
|
||||
|
||||
# SPAWN_CLI_DIR override — force local source (used by e2e tests)
|
||||
if [[ -n "${SPAWN_CLI_DIR:-}" && -f "$SPAWN_CLI_DIR/packages/cli/src/sandbox/main.ts" ]]; then
|
||||
exec bun run "$SPAWN_CLI_DIR/packages/cli/src/sandbox/main.ts" pi "$@"
|
||||
fi
|
||||
|
||||
# Remote — download bundled sandbox.js from GitHub release
|
||||
SANDBOX_JS=$(mktemp)
|
||||
trap 'rm -f "$SANDBOX_JS"' EXIT
|
||||
curl -fsSL --proto '=https' "https://github.com/OpenRouterTeam/spawn/releases/download/sandbox-latest/sandbox.js" -o "$SANDBOX_JS" \
|
||||
|| { printf '\033[0;31mFailed to download sandbox.js\033[0m\n' >&2; exit 1; }
|
||||
|
||||
exec bun run "$SANDBOX_JS" pi "$@"
|
||||
Loading…
Add table
Add a link
Reference in a new issue