diff --git a/README.md b/README.md index eca909f9..092551df 100644 --- a/README.md +++ b/README.md @@ -156,8 +156,9 @@ 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`). +`--fast` enables `tarball`, `images`, and `parallel` (not `recursive` or `sandbox`). #### Recursive Spawn @@ -187,6 +188,27 @@ Tear down an entire tree: spawn delete --cascade # Delete a VM and all its children ``` +#### Sandboxed Local + +Use `--beta sandbox` to run local agents inside a Docker container instead of directly on your machine: + +```bash +spawn claude local --beta sandbox +``` + +What this does: +- **Pulls the agent's Docker image** from `ghcr.io/openrouterteam/spawn-` +- **Runs the agent in a container** with filesystem, network, and process isolation +- **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 +``` + ### Without the CLI Every combination works as a one-liner — no install required: diff --git a/packages/cli/package.json b/packages/cli/package.json index 0442c1eb..69da665c 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.29.3", + "version": "0.29.4", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/__tests__/sandbox.test.ts b/packages/cli/src/__tests__/sandbox.test.ts new file mode 100644 index 00000000..5f2668d7 --- /dev/null +++ b/packages/cli/src/__tests__/sandbox.test.ts @@ -0,0 +1,197 @@ +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { mockBunSpawn, mockClackPrompts } from "./test-helpers"; + +mockClackPrompts(); + +import { cleanupContainer, ensureDocker, isDockerAvailable, pullAndStartContainer } from "../local/local"; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function mockSpawnSync(exitCode: number, stdout = "", stderr = "") { + return spyOn(Bun, "spawnSync").mockReturnValue({ + exitCode, + stdout: new TextEncoder().encode(stdout), + stderr: new TextEncoder().encode(stderr), + success: exitCode === 0, + signalCode: null, + resourceUsage: undefined, + pid: 1234, + } satisfies ReturnType); +} + +let origEnv: NodeJS.ProcessEnv; +let stderrSpy: ReturnType; + +beforeEach(() => { + origEnv = { + ...process.env, + }; + stderrSpy = spyOn(process.stderr, "write").mockReturnValue(true); +}); + +afterEach(() => { + process.env = origEnv; + stderrSpy.mockRestore(); + mock.restore(); +}); + +// ─── isDockerAvailable ────────────────────────────────────────────────────── + +describe("isDockerAvailable", () => { + it("returns true when docker info exits 0", () => { + const spy = mockSpawnSync(0); + expect(isDockerAvailable()).toBe(true); + expect(spy).toHaveBeenCalledWith( + [ + "docker", + "info", + ], + expect.anything(), + ); + spy.mockRestore(); + }); + + it("returns false when docker info exits non-zero", () => { + const spy = mockSpawnSync(1); + expect(isDockerAvailable()).toBe(false); + spy.mockRestore(); + }); +}); + +// ─── ensureDocker ─────────────────────────────────────────────────────────── + +describe("ensureDocker", () => { + it("returns immediately if docker is available", async () => { + const spy = mockSpawnSync(0); + await ensureDocker(); + // Should have called spawnSync for docker info check only + expect(spy.mock.calls[0][0]).toEqual([ + "docker", + "info", + ]); + spy.mockRestore(); + }); + + it("attempts brew install on macOS when docker unavailable", async () => { + const origPlatform = Object.getOwnPropertyDescriptor(process, "platform"); + Object.defineProperty(process, "platform", { + value: "darwin", + configurable: true, + }); + + let callCount = 0; + const spy = spyOn(Bun, "spawnSync").mockImplementation((..._args: unknown[]) => { + callCount++; + // First call: docker info → fail, second: brew install → succeed, third: docker info → succeed + if (callCount === 1) { + return { + exitCode: 1, + stdout: new Uint8Array(), + stderr: new Uint8Array(), + success: false, + signalCode: null, + resourceUsage: undefined, + pid: 1234, + } satisfies ReturnType; + } + return { + exitCode: 0, + stdout: new Uint8Array(), + stderr: new Uint8Array(), + success: true, + signalCode: null, + resourceUsage: undefined, + pid: 1234, + } satisfies ReturnType; + }); + + await ensureDocker(); + + // Second call should be brew install orbstack + expect(spy.mock.calls[1][0]).toEqual([ + "brew", + "install", + "orbstack", + ]); + + spy.mockRestore(); + if (origPlatform) { + Object.defineProperty(process, "platform", origPlatform); + } + }); +}); + +// ─── pullAndStartContainer ────────────────────────────────────────────────── + +describe("pullAndStartContainer", () => { + it("cleans up stale container, pulls image, and starts new container", async () => { + // Mock spawnSync for cleanup call + const syncSpy = mockSpawnSync(0); + // Mock Bun.spawn for runLocal calls + const spawnSpy = mockBunSpawn(0); + + await pullAndStartContainer("claude"); + + // First spawnSync call: docker rm -f spawn-agent (cleanup) + expect(syncSpy.mock.calls[0][0]).toEqual([ + "docker", + "rm", + "-f", + "spawn-agent", + ]); + + // Bun.spawn calls: docker pull, docker run + const spawnCalls = spawnSpy.mock.calls; + expect(spawnCalls.length).toBe(2); + + // Pull command + const pullCmd = spawnCalls[0][0][2]; + expect(pullCmd).toContain("docker pull"); + expect(pullCmd).toContain("ghcr.io/openrouterteam/spawn-claude:latest"); + + // Run command + const runCmd = spawnCalls[1][0][2]; + expect(runCmd).toContain("docker run -d"); + expect(runCmd).toContain("--name spawn-agent"); + expect(runCmd).toContain("ghcr.io/openrouterteam/spawn-claude:latest"); + + syncSpy.mockRestore(); + spawnSpy.mockRestore(); + }); +}); + +// ─── cleanupContainer ─────────────────────────────────────────────────────── + +describe("cleanupContainer", () => { + it("runs docker rm -f spawn-agent", () => { + const spy = mockSpawnSync(0); + cleanupContainer(); + expect(spy).toHaveBeenCalledWith( + [ + "docker", + "rm", + "-f", + "spawn-agent", + ], + expect.anything(), + ); + spy.mockRestore(); + }); +}); + +// ─── 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); + }); + + 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); + }); +}); diff --git a/packages/cli/src/commands/interactive.ts b/packages/cli/src/commands/interactive.ts index 6af82085..c7e0826a 100644 --- a/packages/cli/src/commands/interactive.ts +++ b/packages/cli/src/commands/interactive.ts @@ -78,20 +78,50 @@ function getAndValidateCloudChoices( }; } -// Prompt user to select a cloud with arrow-key navigation +// Prompt user to select a cloud with arrow-key navigation. +// When --beta sandbox is active and "local" is in the list, injects a +// "Local Machine (Sandboxed)" option right after "Local Machine". async function selectCloud( manifest: Manifest, cloudList: string[], hintOverrides: Record, ): Promise { + const betaFeatures = (process.env.SPAWN_BETA ?? "").split(","); + const sandboxEnabled = betaFeatures.includes("sandbox"); + + const options = mapToSelectOptions(cloudList, manifest.clouds, hintOverrides); + + // Inject sandbox option next to "local" when --beta sandbox is set + if (sandboxEnabled && cloudList.includes("local")) { + const localIdx = options.findIndex((o) => o.value === "local"); + if (localIdx !== -1) { + options[localIdx].hint = "No isolation — runs on your machine"; + options.splice(localIdx + 1, 0, { + value: "local-sandbox", + label: "Local Machine (Sandboxed)", + hint: "Runs in a Docker container", + }); + } + } + const cloudChoice = await p.select({ message: "Select a cloud", - options: mapToSelectOptions(cloudList, manifest.clouds, hintOverrides), + options, initialValue: cloudList[0], }); if (p.isCancel(cloudChoice)) { handleCancel(); } + + // Map synthetic "local-sandbox" back to "local" and ensure sandbox beta is set + if (cloudChoice === "local-sandbox") { + const existing = process.env.SPAWN_BETA ?? ""; + if (!existing.split(",").includes("sandbox")) { + process.env.SPAWN_BETA = existing ? `${existing},sandbox` : "sandbox"; + } + return "local"; + } + return cloudChoice; } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index c4c2f63a..2b96a308 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -132,6 +132,7 @@ 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`); @@ -893,6 +894,7 @@ async function main(): Promise { "parallel", "docker", "recursive", + "sandbox", ]); const betaFeatures = extractAllFlagValues(filteredArgs, "--beta", "spawn --beta parallel"); for (const flag of betaFeatures) { @@ -903,6 +905,7 @@ async function main(): Promise { 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("recursive")} Install spawn CLI on VM for recursive spawning`); process.exit(1); } diff --git a/packages/cli/src/local/local.ts b/packages/cli/src/local/local.ts index a51412bf..79cded44 100644 --- a/packages/cli/src/local/local.ts +++ b/packages/cli/src/local/local.ts @@ -2,9 +2,11 @@ import { copyFileSync, mkdirSync } from "node:fs"; import { dirname } from "node:path"; +import { DOCKER_CONTAINER_NAME, DOCKER_REGISTRY } from "../shared/orchestrate.js"; import { getUserHome } from "../shared/paths.js"; import { getLocalShell } from "../shared/shell.js"; import { spawnInteractive } from "../shared/ssh.js"; +import { logInfo, logStep } from "../shared/ui.js"; // ─── Execution ─────────────────────────────────────────────────────────────── @@ -63,3 +65,157 @@ export async function interactiveSession(cmd: string): Promise { cmd, ]); } + +// ─── Docker Sandbox ───────────────────────────────────────────────────────── + +/** Check whether Docker (or OrbStack) is available on the host. */ +export function isDockerAvailable(): boolean { + const result = Bun.spawnSync( + [ + "docker", + "info", + ], + { + stdio: [ + "ignore", + "ignore", + "ignore", + ], + }, + ); + return result.exitCode === 0; +} + +/** Install Docker if not present, or exit with guidance if install fails. */ +export async function ensureDocker(): Promise { + if (isDockerAvailable()) { + return; + } + + const isMac = process.platform === "darwin"; + if (isMac) { + logStep("Docker not found — installing OrbStack..."); + const result = Bun.spawnSync( + [ + "brew", + "install", + "orbstack", + ], + { + stdio: [ + "ignore", + "inherit", + "inherit", + ], + }, + ); + if (result.exitCode !== 0) { + logInfo("Auto-install failed. Install OrbStack manually: brew install orbstack"); + process.exit(1); + } + } else { + logStep("Docker not found — installing docker.io..."); + const hasSudo = + Bun.spawnSync( + [ + "which", + "sudo", + ], + { + stdio: [ + "ignore", + "ignore", + "ignore", + ], + }, + ).exitCode === 0; + const prefix = hasSudo ? "sudo " : ""; + const result = Bun.spawnSync( + [ + "bash", + "-c", + `${prefix}apt-get update -qq && ${prefix}apt-get install -y -qq docker.io`, + ], + { + stdio: [ + "ignore", + "inherit", + "inherit", + ], + }, + ); + if (result.exitCode !== 0) { + logInfo("Auto-install failed. Install Docker manually: sudo apt-get install docker.io"); + process.exit(1); + } + } + + // Verify Docker works after install + if (!isDockerAvailable()) { + logInfo("Docker installed but not responding. You may need to start the Docker daemon."); + process.exit(1); + } +} + +/** Pull the agent Docker image and start a container. */ +export async function pullAndStartContainer(agentName: string): Promise { + // Clean up any stale container (ignore errors) + Bun.spawnSync( + [ + "docker", + "rm", + "-f", + DOCKER_CONTAINER_NAME, + ], + { + stdio: [ + "ignore", + "ignore", + "ignore", + ], + }, + ); + + const image = `${DOCKER_REGISTRY}/spawn-${agentName}:latest`; + logStep(`Pulling Docker image ${image}...`); + await runLocal(`docker pull ${image}`); + + logStep("Starting agent container..."); + await runLocal(`docker run -d --name ${DOCKER_CONTAINER_NAME} ${image}`); + logInfo("Agent container running"); +} + +/** Launch an interactive session inside the Docker container. */ +export function dockerInteractiveSession(cmd: string): Promise { + return Promise.resolve( + spawnInteractive([ + "docker", + "exec", + "-it", + DOCKER_CONTAINER_NAME, + "bash", + "-l", + "-c", + cmd, + ]), + ); +} + +/** Remove the sandbox container (best-effort, for cleanup). */ +export function cleanupContainer(): void { + Bun.spawnSync( + [ + "docker", + "rm", + "-f", + DOCKER_CONTAINER_NAME, + ], + { + stdio: [ + "ignore", + "ignore", + "ignore", + ], + }, + ); +} diff --git a/packages/cli/src/local/main.ts b/packages/cli/src/local/main.ts index 3b2bcc2c..dcdc71c7 100644 --- a/packages/cli/src/local/main.ts +++ b/packages/cli/src/local/main.ts @@ -6,10 +6,19 @@ import type { CloudOrchestrator } from "../shared/orchestrate.js"; import * as p from "@clack/prompts"; import { getErrorMessage } from "@openrouter/spawn-shared"; -import { runOrchestration } from "../shared/orchestrate.js"; +import { makeDockerRunner, runOrchestration } from "../shared/orchestrate.js"; import { logWarn } from "../shared/ui.js"; import { agents, resolveAgent } from "./agents.js"; -import { downloadFile, interactiveSession, runLocal, uploadFile } from "./local.js"; +import { + cleanupContainer, + dockerInteractiveSession, + downloadFile, + ensureDocker, + interactiveSession, + pullAndStartContainer, + runLocal, + uploadFile, +} from "./local.js"; async function main() { const agentName = process.argv[2]; @@ -21,9 +30,18 @@ async function main() { const agent = resolveAgent(agentName); + // Check if --beta sandbox is active + const betaFeatures = (process.env.SPAWN_BETA ?? "").split(","); + const useSandbox = betaFeatures.includes("sandbox"); + + // If sandboxed, ensure Docker is installed (auto-install if missing) + if (useSandbox) { + await ensureDocker(); + } + // Warn about security implications of installing OpenClaw locally - // (OpenClaw has browser access and broader system control than other agents) - if (agentName === "openclaw" && process.env.SPAWN_NON_INTERACTIVE !== "1") { + // (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.`); @@ -41,14 +59,17 @@ async function main() { } } + const baseRunner = { + runServer: runLocal, + uploadFile: async (l: string, r: string) => uploadFile(l, r), + downloadFile: async (r: string, l: string) => downloadFile(r, l), + }; + const cloud: CloudOrchestrator = { cloudName: "local", - cloudLabel: "local machine", - runner: { - runServer: runLocal, - uploadFile: async (l: string, r: string) => uploadFile(l, r), - downloadFile: async (r: string, l: string) => downloadFile(r, l), - }, + cloudLabel: useSandbox ? "local (sandboxed)" : "local", + skipAgentInstall: false, + runner: useSandbox ? makeDockerRunner(baseRunner) : baseRunner, async authenticate() {}, async promptSize() {}, async createServer(_name: string) { @@ -73,10 +94,20 @@ async function main() { ); return new TextDecoder().decode(result.stdout).trim() || "local"; }, - async waitForReady() {}, - interactiveSession, + 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); }