mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 02:01:35 +00:00
feat: add Docker Sandbox deployment support
Add example and documentation for deploying sandbox-agent inside Docker Sandbox microVMs for enhanced isolation on macOS/Windows. - Add examples/docker-sandbox/ with TypeScript example - Add docs/deploy/docker-sandbox.mdx with setup guide using custom templates - Update docs navigation to include Docker Sandbox
This commit is contained in:
parent
cc5a9e0d73
commit
1b2e65ec7f
14 changed files with 952 additions and 2 deletions
10
examples/docker-sandbox/Dockerfile
Normal file
10
examples/docker-sandbox/Dockerfile
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
FROM --platform=linux/amd64 node:22-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y curl git && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install sandbox-agent
|
||||
RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh
|
||||
|
||||
# Pre-install claude agent and add to PATH
|
||||
RUN sandbox-agent install-agent claude
|
||||
ENV PATH="/root/.local/share/sandbox-agent/bin:$PATH"
|
||||
37
examples/docker-sandbox/README.md
Normal file
37
examples/docker-sandbox/README.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Docker Sandbox Example
|
||||
|
||||
Runs sandbox-agent inside a Docker Sandbox microVM for enhanced isolation.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Docker Desktop 4.58+ (macOS or Windows)
|
||||
- `ANTHROPIC_API_KEY` environment variable
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
pnpm start
|
||||
```
|
||||
|
||||
First run builds the image and creates the VM (slow). Subsequent runs reuse the VM (fast).
|
||||
|
||||
To clean up resources:
|
||||
```bash
|
||||
pnpm cleanup
|
||||
```
|
||||
|
||||
## What it does
|
||||
|
||||
1. Checks if VM exists, creates one if not (via sandboxd daemon API)
|
||||
2. Builds and loads the template image into the VM (one-time)
|
||||
3. Starts a container with sandbox-agent server (with proxy config for network access)
|
||||
4. Creates a Claude session and sends a test message
|
||||
5. Streams and displays Claude's response
|
||||
|
||||
## Notes
|
||||
|
||||
- Docker Sandbox VMs have network isolation - outbound HTTPS goes through a proxy at `host.docker.internal:3128`
|
||||
- The container is configured with `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` environment variables
|
||||
- `NODE_TLS_REJECT_UNAUTHORIZED=0` is set to bypass proxy SSL verification (for testing)
|
||||
- `ANTHROPIC_API_KEY` is baked into the container at creation time - run `pnpm cleanup` and restart if you change the key
|
||||
- Resources are kept hot between runs for faster iteration - use `pnpm cleanup` to remove
|
||||
17
examples/docker-sandbox/package.json
Normal file
17
examples/docker-sandbox/package.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "@sandbox-agent/example-docker-sandbox",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "tsx src/docker-sandbox.ts",
|
||||
"cleanup": "tsx src/cleanup.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"@types/node": "latest",
|
||||
"tsx": "latest",
|
||||
"typescript": "latest",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
5
examples/docker-sandbox/src/cleanup.ts
Normal file
5
examples/docker-sandbox/src/cleanup.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { cleanup } from "./utils.js";
|
||||
|
||||
console.log("Cleaning up Docker Sandbox resources...");
|
||||
cleanup();
|
||||
console.log("Done.");
|
||||
102
examples/docker-sandbox/src/docker-sandbox.ts
Normal file
102
examples/docker-sandbox/src/docker-sandbox.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import * as os from "node:os";
|
||||
import { exec, vmExec, sleep, runPrompt, SANDBOXD_SOCK, VM_NAME } from "./utils.js";
|
||||
|
||||
// Global error handlers
|
||||
process.on("uncaughtException", (err) => {
|
||||
console.error("Error:", err.message);
|
||||
if (err.message.includes("docker.sock")) {
|
||||
console.error("Try: pnpm cleanup && pnpm start");
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Check prerequisites
|
||||
try {
|
||||
exec("docker sandbox --help", { silent: true });
|
||||
} catch {
|
||||
console.error(
|
||||
"Docker Sandbox not available. Requires Docker Desktop 4.58+ on macOS/Windows.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!process.env.ANTHROPIC_API_KEY) {
|
||||
console.error("ANTHROPIC_API_KEY environment variable is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check if VM already exists
|
||||
const vms = JSON.parse(
|
||||
exec(`curl -s --unix-socket "${SANDBOXD_SOCK}" http://localhost/vm`, { silent: true }),
|
||||
);
|
||||
const existingVm = vms.find((v: { vm_name: string }) => v.vm_name === VM_NAME);
|
||||
|
||||
let vmSock: string;
|
||||
if (existingVm) {
|
||||
console.log(`Using existing VM: ${existingVm.vm_id}`);
|
||||
vmSock = `${os.homedir()}/.docker/sandboxes/vm/${VM_NAME}/docker.sock`;
|
||||
} else {
|
||||
// Create VM
|
||||
console.log("Creating VM (one-time setup)...");
|
||||
const payload = JSON.stringify({
|
||||
agent_name: "sandbox-agent",
|
||||
workspace_dir: process.cwd(),
|
||||
});
|
||||
const vm = JSON.parse(
|
||||
exec(
|
||||
`curl -s -X POST --unix-socket "${SANDBOXD_SOCK}" http://localhost/vm -H "Content-Type: application/json" -d '${payload}'`,
|
||||
{ silent: true },
|
||||
),
|
||||
);
|
||||
if (!vm.vm_id) throw new Error(`Failed to create VM: ${JSON.stringify(vm)}`);
|
||||
vmSock =
|
||||
vm.vm_config?.socketPath ??
|
||||
`${os.homedir()}/.docker/sandboxes/vm/${VM_NAME}/docker.sock`;
|
||||
console.log(`VM created: ${vm.vm_id}`);
|
||||
|
||||
// Build and load image (only needed once per VM)
|
||||
console.log("Building image (one-time setup)...");
|
||||
exec(`docker build -t sandbox-agent-template:latest .`);
|
||||
console.log("Loading image into VM (one-time setup)...");
|
||||
exec(`docker save sandbox-agent-template:latest | docker --host "unix://${vmSock}" load`);
|
||||
}
|
||||
|
||||
// Check if container already exists
|
||||
const containerExists = exec(
|
||||
`docker --host "unix://${vmSock}" ps -a --filter name=^sandbox$ --format "{{.Status}}"`,
|
||||
{ silent: true },
|
||||
);
|
||||
|
||||
if (containerExists.includes("Up")) {
|
||||
console.log("Container already running");
|
||||
} else if (containerExists) {
|
||||
console.log("Starting existing container...");
|
||||
exec(`docker --host "unix://${vmSock}" start sandbox`, { silent: true });
|
||||
} else {
|
||||
console.log("Creating container...");
|
||||
// Note: Docker Sandbox requires proxy for outbound HTTPS
|
||||
exec(
|
||||
`docker --host "unix://${vmSock}" run -d --name sandbox ` +
|
||||
`-e HTTP_PROXY=http://host.docker.internal:3128 ` +
|
||||
`-e HTTPS_PROXY=http://host.docker.internal:3128 ` +
|
||||
`-e NO_PROXY=localhost,127.0.0.1 ` +
|
||||
`-e NODE_TLS_REJECT_UNAUTHORIZED=0 ` +
|
||||
`-e ANTHROPIC_API_KEY="${process.env.ANTHROPIC_API_KEY}" ` +
|
||||
`-v "${process.cwd()}:${process.cwd()}" -w "${process.cwd()}" ` +
|
||||
`sandbox-agent-template:latest sandbox-agent server --no-token --host 0.0.0.0`,
|
||||
{ silent: true },
|
||||
);
|
||||
}
|
||||
|
||||
// Wait for server
|
||||
console.log("Waiting for healthy...");
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < 30000) {
|
||||
try {
|
||||
if (vmExec(vmSock, "sandbox-agent api sessions list").includes("sessions")) break;
|
||||
} catch {}
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
// Interactive prompt loop
|
||||
await runPrompt(vmSock);
|
||||
57
examples/docker-sandbox/src/utils.ts
Normal file
57
examples/docker-sandbox/src/utils.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { execSync, spawnSync } from "node:child_process";
|
||||
import * as os from "node:os";
|
||||
|
||||
export const SANDBOXD_SOCK = `${os.homedir()}/.docker/sandboxes/sandboxd.sock`;
|
||||
export const VM_NAME = "sandbox-agent-vm";
|
||||
|
||||
export const exec = (cmd: string, opts?: { silent?: boolean }) =>
|
||||
execSync(cmd, { encoding: "utf-8", stdio: opts?.silent ? "pipe" : "inherit" })?.trim() ?? "";
|
||||
|
||||
export const vmExec = (vmSock: string, cmd: string, env?: Record<string, string>) => {
|
||||
const envFlags = env ? Object.entries(env).flatMap(([k, v]) => ["-e", `${k}=${v}`]) : [];
|
||||
const r = spawnSync("docker", ["--host", `unix://${vmSock}`, "exec", ...envFlags, "sandbox", "sh", "-c", cmd], { encoding: "utf-8", stdio: "pipe" });
|
||||
if (r.error) throw r.error;
|
||||
return r.stdout?.trim() ?? "";
|
||||
};
|
||||
|
||||
export const cleanup = () => {
|
||||
try { exec(`curl -s -X DELETE --unix-socket "${SANDBOXD_SOCK}" http://localhost/vm/${VM_NAME}`); } catch {}
|
||||
};
|
||||
|
||||
export const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
export const runPrompt = async (vmSock: string): Promise<void> => {
|
||||
const { createInterface } = await import("node:readline/promises");
|
||||
const { spawn } = await import("node:child_process");
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
|
||||
const sessionId = `session-${Date.now()}`;
|
||||
vmExec(vmSock, `sandbox-agent api sessions create ${sessionId} --agent claude`);
|
||||
console.log(`Session: ${sessionId}\nPress Ctrl+C to quit.\n`);
|
||||
|
||||
const sendMessage = (input: string) => new Promise<void>((resolve) => {
|
||||
const proc = spawn("docker", [
|
||||
"--host", `unix://${vmSock}`, "exec", "sandbox", "sh", "-c",
|
||||
`sandbox-agent api sessions send-message-stream ${sessionId} --message "${input.replace(/"/g, '\\"')}"`,
|
||||
]);
|
||||
|
||||
proc.stdout.on("data", (chunk: Buffer) => {
|
||||
for (const line of chunk.toString().split("\n")) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const evt = JSON.parse(line.slice(6));
|
||||
if (evt.type === "item.delta" && evt.data?.delta) {
|
||||
const isUserEcho = evt.data.item_id?.includes("user") || evt.data.native_item_id?.includes("user");
|
||||
if (!isUserEcho) process.stdout.write(evt.data.delta);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
proc.on("close", () => { console.log(); resolve(); });
|
||||
});
|
||||
|
||||
for await (const input of rl) {
|
||||
if (input.trim()) await sendMessage(input);
|
||||
process.stdout.write("> ");
|
||||
}
|
||||
};
|
||||
64
examples/docker-sandbox/tests/docker-sandbox.test.ts
Normal file
64
examples/docker-sandbox/tests/docker-sandbox.test.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const shouldRun = process.env.RUN_DOCKER_SANDBOX_EXAMPLES === "1";
|
||||
const timeoutMs = Number.parseInt(process.env.SANDBOX_TEST_TIMEOUT_MS || "", 10) || 300_000;
|
||||
|
||||
const testFn = shouldRun ? it : it.skip;
|
||||
|
||||
function execCapture(cmd: string): string {
|
||||
return execSync(cmd, { encoding: "utf-8", stdio: "pipe" }).toString().trim();
|
||||
}
|
||||
|
||||
function isDockerSandboxAvailable(): boolean {
|
||||
try {
|
||||
execCapture("docker sandbox --help");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe("docker-sandbox example", () => {
|
||||
testFn(
|
||||
"docker sandbox CLI is available",
|
||||
async () => {
|
||||
expect(isDockerSandboxAvailable()).toBe(true);
|
||||
},
|
||||
timeoutMs
|
||||
);
|
||||
|
||||
testFn(
|
||||
"can create and remove a sandbox",
|
||||
async () => {
|
||||
if (!isDockerSandboxAvailable()) {
|
||||
console.log("Skipping: Docker Sandbox not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const sandboxName = `test-sandbox-${Date.now()}`;
|
||||
const workspaceDir = process.cwd();
|
||||
|
||||
try {
|
||||
// Create sandbox
|
||||
execCapture(`docker sandbox create --name ${sandboxName} ${workspaceDir}`);
|
||||
|
||||
// Verify it exists
|
||||
const list = execCapture(`docker sandbox ls --format "{{.Name}}"`);
|
||||
expect(list.split("\n")).toContain(sandboxName);
|
||||
|
||||
// Execute a command inside
|
||||
const result = execCapture(`docker sandbox exec ${sandboxName} echo "hello"`);
|
||||
expect(result).toBe("hello");
|
||||
} finally {
|
||||
// Cleanup
|
||||
try {
|
||||
execCapture(`docker sandbox rm -f ${sandboxName}`);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
},
|
||||
timeoutMs
|
||||
);
|
||||
});
|
||||
16
examples/docker-sandbox/tsconfig.json
Normal file
16
examples/docker-sandbox/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "**/*.test.ts"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue