mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 05:02:11 +00:00
chore: add boxlite
This commit is contained in:
parent
a3fe0cc764
commit
c3a95c3611
20 changed files with 824 additions and 1 deletions
67
docs/deploy/boxlite.mdx
Normal file
67
docs/deploy/boxlite.mdx
Normal file
|
|
@ -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<string, string> = {};
|
||||||
|
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();
|
||||||
|
```
|
||||||
|
|
@ -61,7 +61,8 @@
|
||||||
"deploy/daytona",
|
"deploy/daytona",
|
||||||
"deploy/vercel",
|
"deploy/vercel",
|
||||||
"deploy/cloudflare",
|
"deploy/cloudflare",
|
||||||
"deploy/docker"
|
"deploy/docker",
|
||||||
|
"deploy/boxlite"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
4
examples/boxlite-python/.gitignore
vendored
Normal file
4
examples/boxlite-python/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
oci-image/
|
||||||
5
examples/boxlite-python/Dockerfile
Normal file
5
examples/boxlite-python/Dockerfile
Normal file
|
|
@ -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
|
||||||
145
examples/boxlite-python/client.py
Normal file
145
examples/boxlite-python/client.py
Normal file
|
|
@ -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
|
||||||
32
examples/boxlite-python/credentials.py
Normal file
32
examples/boxlite-python/credentials.py
Normal file
|
|
@ -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
|
||||||
110
examples/boxlite-python/main.py
Normal file
110
examples/boxlite-python/main.py
Normal file
|
|
@ -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())
|
||||||
2
examples/boxlite-python/requirements.txt
Normal file
2
examples/boxlite-python/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
boxlite>=0.5.0
|
||||||
|
httpx>=0.27.0
|
||||||
29
examples/boxlite-python/setup_image.py
Normal file
29
examples/boxlite-python/setup_image.py
Normal file
|
|
@ -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,
|
||||||
|
)
|
||||||
1
examples/boxlite/.gitignore
vendored
Normal file
1
examples/boxlite/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
oci-image/
|
||||||
5
examples/boxlite/Dockerfile
Normal file
5
examples/boxlite/Dockerfile
Normal file
|
|
@ -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
|
||||||
19
examples/boxlite/package.json
Normal file
19
examples/boxlite/package.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
46
examples/boxlite/src/index.ts
Normal file
46
examples/boxlite/src/index.ts
Normal file
|
|
@ -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<string, string> = {};
|
||||||
|
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);
|
||||||
16
examples/boxlite/src/setup-image.ts
Normal file
16
examples/boxlite/src/setup-image.ts
Normal file
|
|
@ -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" });
|
||||||
|
}
|
||||||
|
}
|
||||||
16
examples/boxlite/tsconfig.json
Normal file
16
examples/boxlite/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"]
|
||||||
|
}
|
||||||
3
examples/docker-python/.gitignore
vendored
Normal file
3
examples/docker-python/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
145
examples/docker-python/client.py
Normal file
145
examples/docker-python/client.py
Normal file
|
|
@ -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
|
||||||
32
examples/docker-python/credentials.py
Normal file
32
examples/docker-python/credentials.py
Normal file
|
|
@ -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
|
||||||
143
examples/docker-python/main.py
Normal file
143
examples/docker-python/main.py
Normal file
|
|
@ -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()
|
||||||
2
examples/docker-python/requirements.txt
Normal file
2
examples/docker-python/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
docker>=7.0.0
|
||||||
|
httpx>=0.27.0
|
||||||
Loading…
Add table
Add a link
Reference in a new issue