From c3a95c36116a5220e72bd2bbbbc7c9a401f523b9 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Wed, 25 Feb 2026 02:18:16 -0800 Subject: [PATCH] chore: add boxlite --- docs/deploy/boxlite.mdx | 67 +++++++++++ docs/docs.json | 3 +- examples/boxlite-python/.gitignore | 4 + examples/boxlite-python/Dockerfile | 5 + examples/boxlite-python/client.py | 145 +++++++++++++++++++++++ examples/boxlite-python/credentials.py | 32 +++++ examples/boxlite-python/main.py | 110 +++++++++++++++++ examples/boxlite-python/requirements.txt | 2 + examples/boxlite-python/setup_image.py | 29 +++++ examples/boxlite/.gitignore | 1 + examples/boxlite/Dockerfile | 5 + examples/boxlite/package.json | 19 +++ examples/boxlite/src/index.ts | 46 +++++++ examples/boxlite/src/setup-image.ts | 16 +++ examples/boxlite/tsconfig.json | 16 +++ examples/docker-python/.gitignore | 3 + examples/docker-python/client.py | 145 +++++++++++++++++++++++ examples/docker-python/credentials.py | 32 +++++ examples/docker-python/main.py | 143 ++++++++++++++++++++++ examples/docker-python/requirements.txt | 2 + 20 files changed, 824 insertions(+), 1 deletion(-) create mode 100644 docs/deploy/boxlite.mdx create mode 100644 examples/boxlite-python/.gitignore create mode 100644 examples/boxlite-python/Dockerfile create mode 100644 examples/boxlite-python/client.py create mode 100644 examples/boxlite-python/credentials.py create mode 100644 examples/boxlite-python/main.py create mode 100644 examples/boxlite-python/requirements.txt create mode 100644 examples/boxlite-python/setup_image.py create mode 100644 examples/boxlite/.gitignore create mode 100644 examples/boxlite/Dockerfile create mode 100644 examples/boxlite/package.json create mode 100644 examples/boxlite/src/index.ts create mode 100644 examples/boxlite/src/setup-image.ts create mode 100644 examples/boxlite/tsconfig.json create mode 100644 examples/docker-python/.gitignore create mode 100644 examples/docker-python/client.py create mode 100644 examples/docker-python/credentials.py create mode 100644 examples/docker-python/main.py create mode 100644 examples/docker-python/requirements.txt diff --git a/docs/deploy/boxlite.mdx b/docs/deploy/boxlite.mdx new file mode 100644 index 0000000..fb2f8d0 --- /dev/null +++ b/docs/deploy/boxlite.mdx @@ -0,0 +1,67 @@ +--- +title: "BoxLite" +description: "Run Sandbox Agent inside a BoxLite micro-VM." +--- + +BoxLite is a local-first micro-VM sandbox — no cloud account needed. +See [BoxLite docs](https://docs.boxlite.ai) for platform requirements (KVM on Linux, Apple Silicon on macOS). + +## Prerequisites + +- `@boxlite-ai/boxlite` installed (requires KVM or Apple Hypervisor) +- Docker (to build the base image) +- `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` + +## Base image + +Build a Docker image with Sandbox Agent pre-installed, then export it as an OCI layout +that BoxLite can load directly (BoxLite has its own image store separate from Docker): + +```dockerfile +FROM node:22-bookworm-slim +RUN apt-get update && apt-get install -y curl ca-certificates && rm -rf /var/lib/apt/lists/* +RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh +RUN sandbox-agent install-agent claude +RUN sandbox-agent install-agent codex +``` + +```bash +docker build -t sandbox-agent-boxlite . +mkdir -p oci-image +docker save sandbox-agent-boxlite | tar -xf - -C oci-image +``` + +## TypeScript example + +```typescript +import { SimpleBox } from "@boxlite-ai/boxlite"; +import { SandboxAgent } from "sandbox-agent"; + +const env: Record = {}; +if (process.env.ANTHROPIC_API_KEY) env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; +if (process.env.OPENAI_API_KEY) env.OPENAI_API_KEY = process.env.OPENAI_API_KEY; + +const box = new SimpleBox({ + rootfsPath: "./oci-image", + env, + ports: [{ hostPort: 3000, guestPort: 3000 }], + diskSizeGb: 4, +}); + +await box.exec("sh", "-c", + "nohup sandbox-agent server --no-token --host 0.0.0.0 --port 3000 >/tmp/sandbox-agent.log 2>&1 &" +); + +const baseUrl = "http://localhost:3000"; +const sdk = await SandboxAgent.connect({ baseUrl }); + +const session = await sdk.createSession({ agent: "claude" }); +const off = session.onEvent((event) => { + console.log(event.sender, event.payload); +}); + +await session.prompt([{ type: "text", text: "Summarize this repository" }]); +off(); + +await box.stop(); +``` diff --git a/docs/docs.json b/docs/docs.json index e7c2fdf..b2b3a6a 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -61,7 +61,8 @@ "deploy/daytona", "deploy/vercel", "deploy/cloudflare", - "deploy/docker" + "deploy/docker", + "deploy/boxlite" ] } ] diff --git a/examples/boxlite-python/.gitignore b/examples/boxlite-python/.gitignore new file mode 100644 index 0000000..f878106 --- /dev/null +++ b/examples/boxlite-python/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.venv/ +oci-image/ diff --git a/examples/boxlite-python/Dockerfile b/examples/boxlite-python/Dockerfile new file mode 100644 index 0000000..4564417 --- /dev/null +++ b/examples/boxlite-python/Dockerfile @@ -0,0 +1,5 @@ +FROM node:22-bookworm-slim +RUN apt-get update && apt-get install -y curl ca-certificates && rm -rf /var/lib/apt/lists/* +RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh +RUN sandbox-agent install-agent claude +RUN sandbox-agent install-agent codex diff --git a/examples/boxlite-python/client.py b/examples/boxlite-python/client.py new file mode 100644 index 0000000..29e4609 --- /dev/null +++ b/examples/boxlite-python/client.py @@ -0,0 +1,145 @@ +"""Minimal JSON-RPC client for sandbox-agent's streamable HTTP transport.""" + +import json +import threading +import time +import uuid + +import httpx + + +class SandboxConnection: + """Connects to a sandbox-agent server via JSON-RPC over streamable HTTP. + + Endpoints used: + POST /v1/acp/{server_id}?agent=... (bootstrap + requests) + GET /v1/acp/{server_id} (SSE event stream) + DELETE /v1/acp/{server_id} (close) + """ + + def __init__(self, base_url: str, agent: str): + self.base_url = base_url.rstrip("/") + self.agent = agent + self.server_id = f"py-{uuid.uuid4().hex[:8]}" + self.url = f"{self.base_url}/v1/acp/{self.server_id}" + self._next_id = 0 + self._events: list[dict] = [] + self._stop = threading.Event() + self._sse_thread: threading.Thread | None = None + + def _alloc_id(self) -> int: + self._next_id += 1 + return self._next_id + + def _post(self, method: str, params: dict | None = None, *, bootstrap: bool = False) -> dict: + payload: dict = { + "jsonrpc": "2.0", + "id": self._alloc_id(), + "method": method, + } + if params is not None: + payload["params"] = params + + url = f"{self.url}?agent={self.agent}" if bootstrap else self.url + r = httpx.post(url, json=payload, timeout=120) + r.raise_for_status() + body = r.text.strip() + return json.loads(body) if body else {} + + # -- Lifecycle ----------------------------------------------------------- + + def initialize(self) -> dict: + result = self._post( + "initialize", + { + "protocolVersion": 1, + "clientInfo": {"name": "python-example", "version": "0.1.0"}, + }, + bootstrap=True, + ) + self._start_sse() + + # Auto-authenticate if the agent advertises env-var-based auth methods. + auth_methods = result.get("result", {}).get("authMethods", []) + env_ids = ("anthropic-api-key", "codex-api-key", "openai-api-key") + for method in auth_methods: + if method.get("id") not in env_ids: + continue + try: + resp = self._post("authenticate", {"methodId": method["id"]}) + if "error" not in resp: + break + except Exception: + continue + + return result + + def new_session(self, cwd: str = "/root") -> str: + result = self._post("session/new", {"cwd": cwd, "mcpServers": []}) + if "error" in result: + raise RuntimeError(f"session/new failed: {result['error'].get('message', result['error'])}") + return result["result"]["sessionId"] + + def prompt(self, session_id: str, text: str) -> dict: + result = self._post( + "session/prompt", + { + "sessionId": session_id, + "prompt": [{"type": "text", "text": text}], + }, + ) + return result + + def close(self) -> None: + self._stop.set() + try: + httpx.delete(self.url, timeout=2) + except Exception: + pass + + # -- SSE event stream (background thread) -------------------------------- + + @property + def events(self) -> list[dict]: + return list(self._events) + + def _start_sse(self) -> None: + self._sse_thread = threading.Thread(target=self._sse_loop, daemon=True) + self._sse_thread.start() + + def _sse_loop(self) -> None: + while not self._stop.is_set(): + try: + with httpx.stream( + "GET", + self.url, + headers={"Accept": "text/event-stream"}, + timeout=httpx.Timeout(connect=5, read=None, write=5, pool=5), + ) as resp: + buffer = "" + for chunk in resp.iter_text(): + if self._stop.is_set(): + break + buffer += chunk.replace("\r\n", "\n") + while "\n\n" in buffer: + event_chunk, buffer = buffer.split("\n\n", 1) + self._process_sse_event(event_chunk) + except Exception: + if self._stop.is_set(): + return + time.sleep(0.15) + + def _process_sse_event(self, chunk: str) -> None: + data_lines: list[str] = [] + for line in chunk.split("\n"): + if line.startswith("data:"): + data_lines.append(line[5:].lstrip()) + if not data_lines: + return + payload = "\n".join(data_lines).strip() + if not payload: + return + try: + self._events.append(json.loads(payload)) + except json.JSONDecodeError: + pass diff --git a/examples/boxlite-python/credentials.py b/examples/boxlite-python/credentials.py new file mode 100644 index 0000000..46114dc --- /dev/null +++ b/examples/boxlite-python/credentials.py @@ -0,0 +1,32 @@ +"""Agent detection and credential helpers for sandbox-agent examples.""" + +import os +import sys + + +def detect_agent() -> str: + """Pick an agent based on env vars. Exits if no credentials are found.""" + if os.environ.get("SANDBOX_AGENT"): + return os.environ["SANDBOX_AGENT"] + has_claude = bool( + os.environ.get("ANTHROPIC_API_KEY") + or os.environ.get("CLAUDE_API_KEY") + or os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") + ) + has_codex = (os.environ.get("OPENAI_API_KEY") or "").startswith("sk-") + if has_codex: + return "codex" + if has_claude: + return "claude" + print("No API keys found. Set ANTHROPIC_API_KEY or OPENAI_API_KEY.") + sys.exit(1) + + +def build_box_env() -> list[tuple[str, str]]: + """Collect credential env vars to forward into the BoxLite sandbox.""" + env: list[tuple[str, str]] = [] + for key in ("ANTHROPIC_API_KEY", "CLAUDE_API_KEY", "OPENAI_API_KEY", "CODEX_API_KEY"): + val = os.environ.get(key) + if val: + env.append((key, val)) + return env diff --git a/examples/boxlite-python/main.py b/examples/boxlite-python/main.py new file mode 100644 index 0000000..244985f --- /dev/null +++ b/examples/boxlite-python/main.py @@ -0,0 +1,110 @@ +""" +Sandbox Agent – Python + BoxLite example. + +Builds a Docker image, exports it to OCI layout, runs it inside a BoxLite +sandbox, connects to the sandbox-agent server, creates a session, and sends a prompt. + +Usage: + pip install -r requirements.txt + python main.py +""" + +import asyncio +import json +import signal +import time + +import boxlite +import httpx + +from client import SandboxConnection +from credentials import build_box_env, detect_agent +from setup_image import OCI_DIR, setup_image + +PORT = 3000 + + +def wait_for_health(base_url: str, timeout_s: float = 120) -> None: + deadline = time.monotonic() + timeout_s + last_err: str | None = None + while time.monotonic() < deadline: + try: + r = httpx.get(f"{base_url}/v1/health", timeout=5) + if r.status_code == 200 and r.json().get("status") == "ok": + return + last_err = f"health returned {r.status_code}" + except Exception as exc: + last_err = str(exc) + time.sleep(0.5) + raise RuntimeError(f"Timed out waiting for /v1/health: {last_err}") + + +async def main() -> None: + agent = detect_agent() + print(f"Agent: {agent}") + + setup_image() + + env = build_box_env() + + print("Creating BoxLite sandbox...") + box = boxlite.SimpleBox( + rootfs_path=OCI_DIR, + env=env, + ports=[(PORT, PORT, "tcp")], + ) + + async with box: + print("Starting server...") + result = await box.exec( + "sh", "-c", + f"nohup sandbox-agent server --no-token --host 0.0.0.0 --port {PORT} " + ">/tmp/sandbox-agent.log 2>&1 &", + ) + if result.exit_code != 0: + raise RuntimeError(f"Failed to start server: {result.stderr}") + + base_url = f"http://localhost:{PORT}" + print("Waiting for server...") + wait_for_health(base_url) + print("Server ready.") + print(f"Inspector: {base_url}/ui/") + + # -- Session flow ---------------------------------------------------- + conn = SandboxConnection(base_url, agent) + + print("Connecting...") + init_result = conn.initialize() + agent_info = init_result.get("result", {}).get("agentInfo", {}) + print(f"Connected to: {agent_info.get('title', agent)} {agent_info.get('version', '')}") + + session_id = conn.new_session() + print(f"Session: {session_id}") + + prompt_text = "Say hello and tell me what you are. Be brief (one sentence)." + print(f"\n> {prompt_text}") + response = conn.prompt(session_id, prompt_text) + + if "error" in response: + err = response["error"] + print(f"Error: {err.get('message', err)}") + else: + print(f"Stop reason: {response.get('result', {}).get('stopReason', 'unknown')}") + + # Give SSE events a moment to arrive. + time.sleep(1) + + if conn.events: + for ev in conn.events: + if ev.get("method") == "session/update": + content = ev.get("params", {}).get("update", {}).get("content", {}) + if content.get("text"): + print(content["text"], end="") + print() + + conn.close() + print("\nDone.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/boxlite-python/requirements.txt b/examples/boxlite-python/requirements.txt new file mode 100644 index 0000000..2977142 --- /dev/null +++ b/examples/boxlite-python/requirements.txt @@ -0,0 +1,2 @@ +boxlite>=0.5.0 +httpx>=0.27.0 diff --git a/examples/boxlite-python/setup_image.py b/examples/boxlite-python/setup_image.py new file mode 100644 index 0000000..f56b76f --- /dev/null +++ b/examples/boxlite-python/setup_image.py @@ -0,0 +1,29 @@ +"""Build the sandbox-agent Docker image and export it to OCI layout.""" + +import os +import subprocess + +DOCKER_IMAGE = "sandbox-agent-boxlite" +OCI_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "oci-image") + + +def setup_image() -> None: + dockerfile_dir = os.path.dirname(os.path.abspath(__file__)) + + print(f'Building image "{DOCKER_IMAGE}" (cached after first run)...') + subprocess.run( + ["docker", "build", "-t", DOCKER_IMAGE, dockerfile_dir], + check=True, + ) + + if not os.path.exists(os.path.join(OCI_DIR, "oci-layout")): + print("Exporting to OCI layout...") + os.makedirs(OCI_DIR, exist_ok=True) + subprocess.run( + [ + "skopeo", "copy", + f"docker-daemon:{DOCKER_IMAGE}:latest", + f"oci:{OCI_DIR}:latest", + ], + check=True, + ) diff --git a/examples/boxlite/.gitignore b/examples/boxlite/.gitignore new file mode 100644 index 0000000..329f592 --- /dev/null +++ b/examples/boxlite/.gitignore @@ -0,0 +1 @@ +oci-image/ diff --git a/examples/boxlite/Dockerfile b/examples/boxlite/Dockerfile new file mode 100644 index 0000000..4564417 --- /dev/null +++ b/examples/boxlite/Dockerfile @@ -0,0 +1,5 @@ +FROM node:22-bookworm-slim +RUN apt-get update && apt-get install -y curl ca-certificates && rm -rf /var/lib/apt/lists/* +RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh +RUN sandbox-agent install-agent claude +RUN sandbox-agent install-agent codex diff --git a/examples/boxlite/package.json b/examples/boxlite/package.json new file mode 100644 index 0000000..8e7a5d8 --- /dev/null +++ b/examples/boxlite/package.json @@ -0,0 +1,19 @@ +{ + "name": "@sandbox-agent/example-boxlite", + "private": true, + "type": "module", + "scripts": { + "start": "tsx src/index.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@boxlite-ai/boxlite": "latest", + "@sandbox-agent/example-shared": "workspace:*", + "sandbox-agent": "workspace:*" + }, + "devDependencies": { + "@types/node": "latest", + "tsx": "latest", + "typescript": "latest" + } +} diff --git a/examples/boxlite/src/index.ts b/examples/boxlite/src/index.ts new file mode 100644 index 0000000..e5ce412 --- /dev/null +++ b/examples/boxlite/src/index.ts @@ -0,0 +1,46 @@ +import { SimpleBox } from "@boxlite-ai/boxlite"; +import { SandboxAgent } from "sandbox-agent"; +import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; +import { setupImage, OCI_DIR } from "./setup-image.ts"; + +const env: Record = {}; +if (process.env.ANTHROPIC_API_KEY) env.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; +if (process.env.OPENAI_API_KEY) env.OPENAI_API_KEY = process.env.OPENAI_API_KEY; + +setupImage(); + +console.log("Creating BoxLite sandbox..."); +const box = new SimpleBox({ + rootfsPath: OCI_DIR, + env, + ports: [{ hostPort: 3000, guestPort: 3000 }], + diskSizeGb: 4, +}); + +console.log("Starting server..."); +const result = await box.exec( + "sh", "-c", + "nohup sandbox-agent server --no-token --host 0.0.0.0 --port 3000 >/tmp/sandbox-agent.log 2>&1 &", +); +if (result.exitCode !== 0) throw new Error(`Failed to start server: ${result.stderr}`); + +const baseUrl = "http://localhost:3000"; + +console.log("Waiting for server..."); +await waitForHealth({ baseUrl }); + +const client = await SandboxAgent.connect({ baseUrl }); +const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } }); +const sessionId = session.id; + +console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); +console.log(" Press Ctrl+C to stop."); + +const keepAlive = setInterval(() => {}, 60_000); +const cleanup = async () => { + clearInterval(keepAlive); + await box.stop(); + process.exit(0); +}; +process.once("SIGINT", cleanup); +process.once("SIGTERM", cleanup); diff --git a/examples/boxlite/src/setup-image.ts b/examples/boxlite/src/setup-image.ts new file mode 100644 index 0000000..25b157e --- /dev/null +++ b/examples/boxlite/src/setup-image.ts @@ -0,0 +1,16 @@ +import { execSync } from "node:child_process"; +import { existsSync, mkdirSync } from "node:fs"; + +export const DOCKER_IMAGE = "sandbox-agent-boxlite"; +export const OCI_DIR = new URL("../oci-image", import.meta.url).pathname; + +export function setupImage() { + console.log(`Building image "${DOCKER_IMAGE}" (cached after first run)...`); + execSync(`docker build -t ${DOCKER_IMAGE} ${new URL("..", import.meta.url).pathname}`, { stdio: "inherit" }); + + if (!existsSync(`${OCI_DIR}/oci-layout`)) { + console.log("Exporting to OCI layout..."); + mkdirSync(OCI_DIR, { recursive: true }); + execSync(`docker save ${DOCKER_IMAGE} | tar -xf - -C ${OCI_DIR}`, { stdio: "inherit" }); + } +} diff --git a/examples/boxlite/tsconfig.json b/examples/boxlite/tsconfig.json new file mode 100644 index 0000000..96ba2fd --- /dev/null +++ b/examples/boxlite/tsconfig.json @@ -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"] +} diff --git a/examples/docker-python/.gitignore b/examples/docker-python/.gitignore new file mode 100644 index 0000000..00f2d38 --- /dev/null +++ b/examples/docker-python/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.venv/ diff --git a/examples/docker-python/client.py b/examples/docker-python/client.py new file mode 100644 index 0000000..29e4609 --- /dev/null +++ b/examples/docker-python/client.py @@ -0,0 +1,145 @@ +"""Minimal JSON-RPC client for sandbox-agent's streamable HTTP transport.""" + +import json +import threading +import time +import uuid + +import httpx + + +class SandboxConnection: + """Connects to a sandbox-agent server via JSON-RPC over streamable HTTP. + + Endpoints used: + POST /v1/acp/{server_id}?agent=... (bootstrap + requests) + GET /v1/acp/{server_id} (SSE event stream) + DELETE /v1/acp/{server_id} (close) + """ + + def __init__(self, base_url: str, agent: str): + self.base_url = base_url.rstrip("/") + self.agent = agent + self.server_id = f"py-{uuid.uuid4().hex[:8]}" + self.url = f"{self.base_url}/v1/acp/{self.server_id}" + self._next_id = 0 + self._events: list[dict] = [] + self._stop = threading.Event() + self._sse_thread: threading.Thread | None = None + + def _alloc_id(self) -> int: + self._next_id += 1 + return self._next_id + + def _post(self, method: str, params: dict | None = None, *, bootstrap: bool = False) -> dict: + payload: dict = { + "jsonrpc": "2.0", + "id": self._alloc_id(), + "method": method, + } + if params is not None: + payload["params"] = params + + url = f"{self.url}?agent={self.agent}" if bootstrap else self.url + r = httpx.post(url, json=payload, timeout=120) + r.raise_for_status() + body = r.text.strip() + return json.loads(body) if body else {} + + # -- Lifecycle ----------------------------------------------------------- + + def initialize(self) -> dict: + result = self._post( + "initialize", + { + "protocolVersion": 1, + "clientInfo": {"name": "python-example", "version": "0.1.0"}, + }, + bootstrap=True, + ) + self._start_sse() + + # Auto-authenticate if the agent advertises env-var-based auth methods. + auth_methods = result.get("result", {}).get("authMethods", []) + env_ids = ("anthropic-api-key", "codex-api-key", "openai-api-key") + for method in auth_methods: + if method.get("id") not in env_ids: + continue + try: + resp = self._post("authenticate", {"methodId": method["id"]}) + if "error" not in resp: + break + except Exception: + continue + + return result + + def new_session(self, cwd: str = "/root") -> str: + result = self._post("session/new", {"cwd": cwd, "mcpServers": []}) + if "error" in result: + raise RuntimeError(f"session/new failed: {result['error'].get('message', result['error'])}") + return result["result"]["sessionId"] + + def prompt(self, session_id: str, text: str) -> dict: + result = self._post( + "session/prompt", + { + "sessionId": session_id, + "prompt": [{"type": "text", "text": text}], + }, + ) + return result + + def close(self) -> None: + self._stop.set() + try: + httpx.delete(self.url, timeout=2) + except Exception: + pass + + # -- SSE event stream (background thread) -------------------------------- + + @property + def events(self) -> list[dict]: + return list(self._events) + + def _start_sse(self) -> None: + self._sse_thread = threading.Thread(target=self._sse_loop, daemon=True) + self._sse_thread.start() + + def _sse_loop(self) -> None: + while not self._stop.is_set(): + try: + with httpx.stream( + "GET", + self.url, + headers={"Accept": "text/event-stream"}, + timeout=httpx.Timeout(connect=5, read=None, write=5, pool=5), + ) as resp: + buffer = "" + for chunk in resp.iter_text(): + if self._stop.is_set(): + break + buffer += chunk.replace("\r\n", "\n") + while "\n\n" in buffer: + event_chunk, buffer = buffer.split("\n\n", 1) + self._process_sse_event(event_chunk) + except Exception: + if self._stop.is_set(): + return + time.sleep(0.15) + + def _process_sse_event(self, chunk: str) -> None: + data_lines: list[str] = [] + for line in chunk.split("\n"): + if line.startswith("data:"): + data_lines.append(line[5:].lstrip()) + if not data_lines: + return + payload = "\n".join(data_lines).strip() + if not payload: + return + try: + self._events.append(json.loads(payload)) + except json.JSONDecodeError: + pass diff --git a/examples/docker-python/credentials.py b/examples/docker-python/credentials.py new file mode 100644 index 0000000..5d5e489 --- /dev/null +++ b/examples/docker-python/credentials.py @@ -0,0 +1,32 @@ +"""Agent detection and credential helpers for sandbox-agent examples.""" + +import os +import sys + + +def detect_agent() -> str: + """Pick an agent based on env vars. Exits if no credentials are found.""" + if os.environ.get("SANDBOX_AGENT"): + return os.environ["SANDBOX_AGENT"] + has_claude = bool( + os.environ.get("ANTHROPIC_API_KEY") + or os.environ.get("CLAUDE_API_KEY") + or os.environ.get("CLAUDE_CODE_OAUTH_TOKEN") + ) + has_codex = (os.environ.get("OPENAI_API_KEY") or "").startswith("sk-") + if has_codex: + return "codex" + if has_claude: + return "claude" + print("No API keys found. Set ANTHROPIC_API_KEY or OPENAI_API_KEY.") + sys.exit(1) + + +def build_container_env() -> dict[str, str]: + """Collect credential env vars to forward into the Docker container.""" + env: dict[str, str] = {} + for key in ("ANTHROPIC_API_KEY", "CLAUDE_API_KEY", "OPENAI_API_KEY", "CODEX_API_KEY"): + val = os.environ.get(key) + if val: + env[key] = val + return env diff --git a/examples/docker-python/main.py b/examples/docker-python/main.py new file mode 100644 index 0000000..b8f0f86 --- /dev/null +++ b/examples/docker-python/main.py @@ -0,0 +1,143 @@ +""" +Sandbox Agent – Python + Docker example. + +Starts a Docker container running sandbox-agent, connects to the sandbox-agent server, creates a session, sends a prompt, and +prints the streamed response. + +Usage: + pip install -r requirements.txt + python main.py +""" + +import json +import os +import signal +import subprocess +import sys +import time + +import docker +import httpx + +from client import SandboxConnection +from credentials import build_container_env, detect_agent + +PORT = 3000 +DOCKERFILE_DIR = os.path.join(os.path.dirname(__file__), "..", "shared") +IMAGE_NAME = "sandbox-agent-examples:latest" + + +def build_image(client: docker.DockerClient) -> str: + """Build the shared example Docker image if it doesn't exist.""" + try: + client.images.get(IMAGE_NAME) + return IMAGE_NAME + except docker.errors.ImageNotFound: + pass + + print(f"Building {IMAGE_NAME} (first run only)...") + subprocess.run( + ["docker", "build", "-t", IMAGE_NAME, DOCKERFILE_DIR], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + return IMAGE_NAME + + +def wait_for_health(base_url: str, timeout_s: float = 120) -> None: + deadline = time.monotonic() + timeout_s + last_err: str | None = None + while time.monotonic() < deadline: + try: + r = httpx.get(f"{base_url}/v1/health", timeout=5) + if r.status_code == 200 and r.json().get("status") == "ok": + return + last_err = f"health returned {r.status_code}" + except Exception as exc: + last_err = str(exc) + time.sleep(0.5) + raise RuntimeError(f"Timed out waiting for /v1/health: {last_err}") + + +def main() -> None: + agent = detect_agent() + print(f"Agent: {agent}") + + client = docker.from_env() + image = build_image(client) + + env = build_container_env() + + print("Starting container...") + container = client.containers.run( + image, + command=[ + "sh", "-c", + f"sandbox-agent install-agent {agent} && " + f"sandbox-agent server --no-token --host 0.0.0.0 --port {PORT}", + ], + environment=env, + ports={f"{PORT}/tcp": PORT}, + detach=True, + auto_remove=True, + ) + + def cleanup(*_args: object) -> None: + print("\nCleaning up...") + try: + container.stop(timeout=5) + except Exception: + pass + + signal.signal(signal.SIGINT, cleanup) + signal.signal(signal.SIGTERM, cleanup) + + try: + base_url = f"http://127.0.0.1:{PORT}" + print(f"Waiting for server at {base_url}...") + wait_for_health(base_url) + print("Server ready.") + print(f"Inspector: {base_url}/ui/") + + # -- Session flow ---------------------------------------------------- + conn = SandboxConnection(base_url, agent) + + print("Connecting...") + init_result = conn.initialize() + agent_info = init_result.get("result", {}).get("agentInfo", {}) + print(f"Connected to: {agent_info.get('title', agent)} {agent_info.get('version', '')}") + + session_id = conn.new_session() + print(f"Session: {session_id}") + + prompt_text = "Say hello and tell me what you are. Be brief (one sentence)." + print(f"\n> {prompt_text}") + response = conn.prompt(session_id, prompt_text) + + if "error" in response: + err = response["error"] + print(f"Error: {err.get('message', err)}") + else: + print(f"Stop reason: {response.get('result', {}).get('stopReason', 'unknown')}") + + # Give SSE events a moment to arrive. + time.sleep(1) + + if conn.events: + for ev in conn.events: + if ev.get("method") == "session/update": + content = ev.get("params", {}).get("update", {}).get("content", {}) + if content.get("text"): + print(content["text"], end="") + print() + + conn.close() + print("\nDone.") + + finally: + cleanup() + + +if __name__ == "__main__": + main() diff --git a/examples/docker-python/requirements.txt b/examples/docker-python/requirements.txt new file mode 100644 index 0000000..f7fd028 --- /dev/null +++ b/examples/docker-python/requirements.txt @@ -0,0 +1,2 @@ +docker>=7.0.0 +httpx>=0.27.0