mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-21 02:04:37 +00:00
docs: add Cloudflare Sandbox SDK deployment guide (#57)
* docs: add Cloudflare Sandbox SDK deployment guide - Add docs/deploy/cloudflare.mdx with full deployment guide - Add examples/cloudflare/ with working Worker code - Update docs navigation to include Cloudflare option - Update deploy index page with Cloudflare card The example shows how to run sandbox-agent inside a Cloudflare Sandbox with exposed ports for API access. Co-authored-by: Shelley <shelley@exe.dev> * fix: guard server startup to avoid port conflicts Add health check before starting sandbox-agent to prevent 'address already in use' errors on subsequent requests. The isServerRunning() function probes the health endpoint to determine if setup should be skipped. Co-authored-by: Shelley <shelley@exe.dev> * fix: default cloudflare/sandbox:0.7.0 (latest does not exist) * feat(cloudflare): add React frontend and improve deployment docs - Add React + Vite frontend for Cloudflare example with sandbox-agent SDK - Update ensureRunning to poll health endpoint instead of fixed wait - Fix SDK fetch binding issue (globalThis.fetch.bind) - Update docs with .dev.vars format warning and container caching tip - Use containerFetch proxy pattern for reliable local dev --------- Co-authored-by: Shelley <shelley@exe.dev> Co-authored-by: Nathan Flurry <git@nathanflurry.com> Co-authored-by: Nathan Flurry <developer@nathanflurry.com>
This commit is contained in:
parent
64582ef299
commit
cc5a9e0d73
16 changed files with 1459 additions and 220 deletions
251
docs/deploy/cloudflare.mdx
Normal file
251
docs/deploy/cloudflare.mdx
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
---
|
||||||
|
title: "Cloudflare"
|
||||||
|
description: "Deploy the daemon inside a Cloudflare Sandbox."
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Cloudflare account with Workers Paid plan
|
||||||
|
- Docker running locally for `wrangler dev`
|
||||||
|
- `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` for the coding agents
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
Cloudflare Sandbox SDK is in beta. See [Sandbox SDK docs](https://developers.cloudflare.com/sandbox/) for details.
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
Create a new Sandbox SDK project:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm create cloudflare@latest -- my-sandbox --template=cloudflare/sandbox-sdk/examples/minimal
|
||||||
|
cd my-sandbox
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dockerfile
|
||||||
|
|
||||||
|
Create a `Dockerfile` with sandbox-agent and agents pre-installed:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM cloudflare/sandbox:0.7.0
|
||||||
|
|
||||||
|
# Install sandbox-agent
|
||||||
|
RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh
|
||||||
|
|
||||||
|
# Pre-install agents
|
||||||
|
RUN sandbox-agent install-agent claude && \
|
||||||
|
sandbox-agent install-agent codex
|
||||||
|
|
||||||
|
# Required for local development with wrangler dev
|
||||||
|
EXPOSE 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
The `EXPOSE 8000` directive is required for `wrangler dev` to proxy requests to the container. Port 3000 is reserved for the Cloudflare control plane.
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
## Wrangler Configuration
|
||||||
|
|
||||||
|
Update `wrangler.jsonc` to use your Dockerfile:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"name": "my-sandbox-agent",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"compatibility_date": "2025-01-01",
|
||||||
|
"compatibility_flags": ["nodejs_compat"],
|
||||||
|
"containers": [
|
||||||
|
{
|
||||||
|
"class_name": "Sandbox",
|
||||||
|
"image": "./Dockerfile",
|
||||||
|
"instance_type": "lite",
|
||||||
|
"max_instances": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"durable_objects": {
|
||||||
|
"bindings": [
|
||||||
|
{
|
||||||
|
"class_name": "Sandbox",
|
||||||
|
"name": "Sandbox"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"migrations": [
|
||||||
|
{
|
||||||
|
"new_sqlite_classes": ["Sandbox"],
|
||||||
|
"tag": "v1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## TypeScript Example
|
||||||
|
|
||||||
|
This example proxies requests to sandbox-agent via `containerFetch`, which works reliably in both local development and production:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getSandbox, type Sandbox } from "@cloudflare/sandbox";
|
||||||
|
export { Sandbox } from "@cloudflare/sandbox";
|
||||||
|
|
||||||
|
type Env = {
|
||||||
|
Sandbox: DurableObjectNamespace<Sandbox>;
|
||||||
|
ANTHROPIC_API_KEY?: string;
|
||||||
|
OPENAI_API_KEY?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PORT = 8000;
|
||||||
|
|
||||||
|
/** Check if sandbox-agent is already running */
|
||||||
|
async function isServerRunning(sandbox: Sandbox): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const result = await sandbox.exec(`curl -sf http://localhost:${PORT}/v1/health`);
|
||||||
|
return result.success;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ensure sandbox-agent is running in the container */
|
||||||
|
async function ensureRunning(sandbox: Sandbox, env: Env): Promise<void> {
|
||||||
|
if (await isServerRunning(sandbox)) return;
|
||||||
|
|
||||||
|
// Set environment variables for agents
|
||||||
|
const envVars: Record<string, string> = {};
|
||||||
|
if (env.ANTHROPIC_API_KEY) envVars.ANTHROPIC_API_KEY = env.ANTHROPIC_API_KEY;
|
||||||
|
if (env.OPENAI_API_KEY) envVars.OPENAI_API_KEY = env.OPENAI_API_KEY;
|
||||||
|
await sandbox.setEnvVars(envVars);
|
||||||
|
|
||||||
|
// Start sandbox-agent server
|
||||||
|
await sandbox.startProcess(
|
||||||
|
`sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Poll health endpoint until server is ready
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
if (await isServerRunning(sandbox)) return;
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env): Promise<Response> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Proxy requests: /sandbox/:name/v1/...
|
||||||
|
const match = url.pathname.match(/^\/sandbox\/([^/]+)(\/.*)?$/);
|
||||||
|
if (match) {
|
||||||
|
const [, name, path = "/"] = match;
|
||||||
|
const sandbox = getSandbox(env.Sandbox, name);
|
||||||
|
|
||||||
|
await ensureRunning(sandbox, env);
|
||||||
|
|
||||||
|
// Proxy request to container
|
||||||
|
return sandbox.containerFetch(
|
||||||
|
new Request(`http://localhost${path}${url.search}`, request),
|
||||||
|
PORT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("Not found", { status: 404 });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Connect from Client
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { SandboxAgent } from "sandbox-agent";
|
||||||
|
|
||||||
|
// Connect via the proxy endpoint
|
||||||
|
const client = await SandboxAgent.connect({
|
||||||
|
baseUrl: "http://localhost:8787/sandbox/my-sandbox",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for server to be ready
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
try {
|
||||||
|
await client.getHealth();
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a session and start coding
|
||||||
|
await client.createSession("my-session", { agent: "claude" });
|
||||||
|
|
||||||
|
await client.postMessage("my-session", {
|
||||||
|
message: "Summarize this repository",
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const event of client.streamEvents("my-session")) {
|
||||||
|
// Auto-approve permissions
|
||||||
|
if (event.type === "permission.requested") {
|
||||||
|
await client.replyPermission("my-session", event.data.permission_id, {
|
||||||
|
reply: "once",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle text output
|
||||||
|
if (event.type === "item.delta" && event.data?.delta) {
|
||||||
|
process.stdout.write(event.data.delta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
Use `.dev.vars` for local development:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "ANTHROPIC_API_KEY=your-api-key" > .dev.vars
|
||||||
|
```
|
||||||
|
|
||||||
|
<Warning>
|
||||||
|
Use plain `KEY=value` format in `.dev.vars`. Do not use `export KEY=value` - wrangler won't parse the bash syntax.
|
||||||
|
</Warning>
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
The `.dev.vars` file is automatically gitignored and only used during local development with `npm run dev`.
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
For production, set secrets via wrangler:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wrangler secret put ANTHROPIC_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
Start the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
First run builds the Docker container (2-3 minutes). Subsequent runs are much faster.
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
Test with curl:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8787/sandbox/demo/v1/health
|
||||||
|
```
|
||||||
|
|
||||||
|
<Tip>
|
||||||
|
Containers cache environment variables. If you change `.dev.vars`, either use a new sandbox name or clear existing containers:
|
||||||
|
```bash
|
||||||
|
docker ps -a | grep sandbox | awk '{print $1}' | xargs -r docker rm -f
|
||||||
|
```
|
||||||
|
</Tip>
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
Deploy to Cloudflare:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wrangler deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
For production with preview URLs (direct container access), you'll need a custom domain with wildcard DNS routing. See [Cloudflare Production Deployment](https://developers.cloudflare.com/sandbox/guides/production-deployment/) for setup instructions.
|
||||||
|
|
@ -15,6 +15,9 @@ icon: "server"
|
||||||
<Card title="Vercel" icon="triangle" href="/deploy/vercel">
|
<Card title="Vercel" icon="triangle" href="/deploy/vercel">
|
||||||
Deploy inside a Vercel Sandbox with port forwarding.
|
Deploy inside a Vercel Sandbox with port forwarding.
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card title="Cloudflare" icon="cloud" href="/deploy/cloudflare">
|
||||||
|
Deploy inside a Cloudflare Sandbox with port exposure.
|
||||||
|
</Card>
|
||||||
<Card title="Daytona" icon="cloud" href="/deploy/daytona">
|
<Card title="Daytona" icon="cloud" href="/deploy/daytona">
|
||||||
Run in a Daytona workspace with port forwarding.
|
Run in a Daytona workspace with port forwarding.
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -49,8 +49,9 @@
|
||||||
"deploy/index",
|
"deploy/index",
|
||||||
"deploy/local",
|
"deploy/local",
|
||||||
"deploy/e2b",
|
"deploy/e2b",
|
||||||
"deploy/vercel",
|
|
||||||
"deploy/daytona",
|
"deploy/daytona",
|
||||||
|
"deploy/vercel",
|
||||||
|
"deploy/cloudflare",
|
||||||
"deploy/docker"
|
"deploy/docker"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
3
examples/cloudflare/.gitignore
vendored
Normal file
3
examples/cloudflare/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules/
|
||||||
|
.wrangler/
|
||||||
|
.dev.vars
|
||||||
11
examples/cloudflare/Dockerfile
Normal file
11
examples/cloudflare/Dockerfile
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
FROM cloudflare/sandbox:0.7.0
|
||||||
|
|
||||||
|
# Install sandbox-agent
|
||||||
|
RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh
|
||||||
|
|
||||||
|
# Pre-install agents
|
||||||
|
RUN sandbox-agent install-agent claude && \
|
||||||
|
sandbox-agent install-agent codex
|
||||||
|
|
||||||
|
# Expose port for local dev (wrangler dev requires EXPOSE directives)
|
||||||
|
EXPOSE 8000
|
||||||
46
examples/cloudflare/README.md
Normal file
46
examples/cloudflare/README.md
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# Cloudflare Sandbox Agent Example
|
||||||
|
|
||||||
|
Deploy sandbox-agent inside a Cloudflare Sandbox.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Cloudflare account with Workers Paid plan
|
||||||
|
- Docker running locally for `wrangler dev`
|
||||||
|
- `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` for the coding agents
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create `.dev.vars` with your API keys:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "ANTHROPIC_API_KEY=your-api-key" > .dev.vars
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Start the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Test the endpoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8787
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Production preview URLs require a custom domain with wildcard DNS routing.
|
||||||
|
See [Cloudflare Production Deployment](https://developers.cloudflare.com/sandbox/guides/production-deployment/) for details.
|
||||||
272
examples/cloudflare/frontend/App.tsx
Normal file
272
examples/cloudflare/frontend/App.tsx
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
|
import { SandboxAgent } from "sandbox-agent";
|
||||||
|
import type { PermissionEventData, QuestionEventData } from "sandbox-agent";
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [sandboxName, setSandboxName] = useState("demo");
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
const [output, setOutput] = useState("");
|
||||||
|
const [status, setStatus] = useState<"idle" | "connecting" | "ready" | "thinking">("idle");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const clientRef = useRef<SandboxAgent | null>(null);
|
||||||
|
const sessionIdRef = useRef<string>(`session-${Date.now()}`);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const isThinkingRef = useRef(false);
|
||||||
|
|
||||||
|
const log = useCallback((msg: string) => {
|
||||||
|
setOutput((prev) => prev + msg + "\n");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const connect = useCallback(async () => {
|
||||||
|
setStatus("connecting");
|
||||||
|
setError(null);
|
||||||
|
setOutput("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Connect via proxy endpoint (need full URL for SDK)
|
||||||
|
const baseUrl = `${window.location.origin}/sandbox/${encodeURIComponent(sandboxName)}`;
|
||||||
|
log(`Connecting to sandbox: ${sandboxName}`);
|
||||||
|
|
||||||
|
const client = await SandboxAgent.connect({ baseUrl });
|
||||||
|
clientRef.current = client;
|
||||||
|
|
||||||
|
// Wait for health (this also ensures the container is started)
|
||||||
|
log("Waiting for sandbox-agent to be ready...");
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
try {
|
||||||
|
await client.getHealth();
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
if (i === 29) throw new Error("Timeout waiting for sandbox-agent");
|
||||||
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
await client.createSession(sessionIdRef.current, { agent: "claude" });
|
||||||
|
log("Session created. Ready to chat.\n");
|
||||||
|
|
||||||
|
setStatus("ready");
|
||||||
|
|
||||||
|
// Start listening for events
|
||||||
|
startEventStream(client);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
setStatus("idle");
|
||||||
|
}
|
||||||
|
}, [sandboxName, log]);
|
||||||
|
|
||||||
|
const startEventStream = useCallback(
|
||||||
|
async (client: SandboxAgent) => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const event of client.streamEvents(sessionIdRef.current, undefined, controller.signal)) {
|
||||||
|
console.log("Event:", event.type, event.data);
|
||||||
|
|
||||||
|
// Auto-approve permissions
|
||||||
|
if (event.type === "permission.requested") {
|
||||||
|
const data = event.data as PermissionEventData;
|
||||||
|
log(`[Auto-approved] ${data.action}`);
|
||||||
|
await client.replyPermission(sessionIdRef.current, data.permission_id, { reply: "once" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject questions (don't support interactive input)
|
||||||
|
if (event.type === "question.requested") {
|
||||||
|
const data = event.data as QuestionEventData;
|
||||||
|
log(`[Question rejected] ${data.prompt}`);
|
||||||
|
await client.rejectQuestion(sessionIdRef.current, data.question_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track when assistant starts thinking
|
||||||
|
if (event.type === "item.started") {
|
||||||
|
const item = (event.data as any)?.item;
|
||||||
|
if (item?.role === "assistant") {
|
||||||
|
isThinkingRef.current = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show deltas while assistant is thinking
|
||||||
|
if (event.type === "item.delta" && isThinkingRef.current) {
|
||||||
|
const delta = (event.data as any)?.delta;
|
||||||
|
if (delta) {
|
||||||
|
const text = typeof delta === "string" ? delta : delta.type === "text" ? delta.text || "" : "";
|
||||||
|
if (text) {
|
||||||
|
setOutput((prev) => prev + text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track assistant turn completion
|
||||||
|
if (event.type === "item.completed") {
|
||||||
|
const item = (event.data as any)?.item;
|
||||||
|
if (item?.role === "assistant") {
|
||||||
|
isThinkingRef.current = false;
|
||||||
|
setOutput((prev) => prev + "\n\n");
|
||||||
|
setStatus("ready");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle errors
|
||||||
|
if (event.type === "error") {
|
||||||
|
const data = event.data as any;
|
||||||
|
log(`Error: ${data?.message || JSON.stringify(data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle session end
|
||||||
|
if (event.type === "session.ended") {
|
||||||
|
const data = event.data as any;
|
||||||
|
log(`Session ended: ${data?.reason || "unknown"}`);
|
||||||
|
setStatus("idle");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
console.error("Event stream error:", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[log]
|
||||||
|
);
|
||||||
|
|
||||||
|
const send = useCallback(async () => {
|
||||||
|
if (!clientRef.current || !prompt.trim() || status !== "ready") return;
|
||||||
|
|
||||||
|
const message = prompt.trim();
|
||||||
|
setPrompt("");
|
||||||
|
setOutput((prev) => prev + `user: ${message}\n\nassistant: `);
|
||||||
|
setStatus("thinking");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await clientRef.current.postMessage(sessionIdRef.current, { message });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
setStatus("ready");
|
||||||
|
}
|
||||||
|
}, [prompt, status]);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
<h1 style={styles.title}>Sandbox Agent</h1>
|
||||||
|
|
||||||
|
{status === "idle" && (
|
||||||
|
<div style={styles.connectForm}>
|
||||||
|
<label style={styles.label}>
|
||||||
|
Sandbox name:
|
||||||
|
<input
|
||||||
|
style={styles.input}
|
||||||
|
value={sandboxName}
|
||||||
|
onChange={(e) => setSandboxName(e.target.value)}
|
||||||
|
placeholder="demo"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button style={styles.button} onClick={connect}>
|
||||||
|
Connect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === "connecting" && <div style={styles.status}>Connecting to sandbox...</div>}
|
||||||
|
|
||||||
|
{error && <div style={styles.error}>{error}</div>}
|
||||||
|
|
||||||
|
{(status === "ready" || status === "thinking") && (
|
||||||
|
<>
|
||||||
|
<div style={styles.output}>{output}</div>
|
||||||
|
<div style={styles.inputRow}>
|
||||||
|
<input
|
||||||
|
style={styles.promptInput}
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && send()}
|
||||||
|
placeholder="Enter prompt..."
|
||||||
|
disabled={status === "thinking"}
|
||||||
|
/>
|
||||||
|
<button style={styles.button} onClick={send} disabled={status === "thinking"}>
|
||||||
|
{status === "thinking" ? "..." : "Send"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles: Record<string, React.CSSProperties> = {
|
||||||
|
container: {
|
||||||
|
fontFamily: "system-ui, sans-serif",
|
||||||
|
maxWidth: 800,
|
||||||
|
margin: "2rem auto",
|
||||||
|
padding: "1rem",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
marginBottom: "1rem",
|
||||||
|
},
|
||||||
|
connectForm: {
|
||||||
|
display: "flex",
|
||||||
|
gap: "1rem",
|
||||||
|
alignItems: "flex-end",
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "0.25rem",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
color: "#666",
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
padding: "0.5rem",
|
||||||
|
fontSize: "1rem",
|
||||||
|
width: 200,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
fontSize: "1rem",
|
||||||
|
cursor: "pointer",
|
||||||
|
backgroundColor: "#0066cc",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
color: "#666",
|
||||||
|
fontStyle: "italic",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
color: "#cc0000",
|
||||||
|
padding: "0.5rem",
|
||||||
|
backgroundColor: "#fff0f0",
|
||||||
|
borderRadius: 4,
|
||||||
|
marginBottom: "1rem",
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
background: "#1e1e1e",
|
||||||
|
color: "#d4d4d4",
|
||||||
|
padding: "1rem",
|
||||||
|
minHeight: 300,
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: 14,
|
||||||
|
overflow: "auto",
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
inputRow: {
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.5rem",
|
||||||
|
marginTop: "1rem",
|
||||||
|
},
|
||||||
|
promptInput: {
|
||||||
|
flex: 1,
|
||||||
|
padding: "0.5rem",
|
||||||
|
fontSize: "1rem",
|
||||||
|
},
|
||||||
|
};
|
||||||
12
examples/cloudflare/frontend/index.html
Normal file
12
examples/cloudflare/frontend/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Sandbox Agent</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
9
examples/cloudflare/frontend/main.tsx
Normal file
9
examples/cloudflare/frontend/main.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { App } from "./App";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
28
examples/cloudflare/package.json
Normal file
28
examples/cloudflare/package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
"name": "@sandbox-agent/example-cloudflare",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite build --watch & wrangler dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"deploy": "vite build && wrangler deploy",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@cloudflare/sandbox": "latest",
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"sandbox-agent": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "latest",
|
||||||
|
"@types/node": "latest",
|
||||||
|
"@types/react": "^19.1.0",
|
||||||
|
"@types/react-dom": "^19.1.0",
|
||||||
|
"@vitejs/plugin-react": "^4.5.0",
|
||||||
|
"typescript": "latest",
|
||||||
|
"vite": "^6.2.0",
|
||||||
|
"vitest": "^3.0.0",
|
||||||
|
"wrangler": "latest"
|
||||||
|
}
|
||||||
|
}
|
||||||
76
examples/cloudflare/src/cloudflare.ts
Normal file
76
examples/cloudflare/src/cloudflare.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { getSandbox, type Sandbox } from "@cloudflare/sandbox";
|
||||||
|
|
||||||
|
export { Sandbox } from "@cloudflare/sandbox";
|
||||||
|
|
||||||
|
type Env = {
|
||||||
|
Bindings: {
|
||||||
|
Sandbox: DurableObjectNamespace<Sandbox>;
|
||||||
|
ASSETS: Fetcher;
|
||||||
|
ANTHROPIC_API_KEY?: string;
|
||||||
|
OPENAI_API_KEY?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const PORT = 8000;
|
||||||
|
|
||||||
|
/** Check if sandbox-agent is already running by probing its health endpoint */
|
||||||
|
async function isServerRunning(sandbox: Sandbox): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const result = await sandbox.exec(`curl -sf http://localhost:${PORT}/v1/health`);
|
||||||
|
return result.success;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ensure sandbox-agent is running in the container */
|
||||||
|
async function ensureRunning(sandbox: Sandbox, env: Env["Bindings"]): Promise<void> {
|
||||||
|
if (await isServerRunning(sandbox)) return;
|
||||||
|
|
||||||
|
// Set environment variables for agents
|
||||||
|
const envVars: Record<string, string> = {};
|
||||||
|
if (env.ANTHROPIC_API_KEY) envVars.ANTHROPIC_API_KEY = env.ANTHROPIC_API_KEY;
|
||||||
|
if (env.OPENAI_API_KEY) envVars.OPENAI_API_KEY = env.OPENAI_API_KEY;
|
||||||
|
await sandbox.setEnvVars(envVars);
|
||||||
|
|
||||||
|
// Start sandbox-agent server as background process
|
||||||
|
await sandbox.startProcess(`sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`);
|
||||||
|
|
||||||
|
// Poll health endpoint until server is ready (max ~6 seconds)
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
if (await isServerRunning(sandbox)) return;
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, env: Env["Bindings"]): Promise<Response> {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Proxy requests to sandbox-agent: /sandbox/:name/v1/...
|
||||||
|
const match = url.pathname.match(/^\/sandbox\/([^/]+)(\/.*)?$/);
|
||||||
|
if (match) {
|
||||||
|
if (!env.ANTHROPIC_API_KEY && !env.OPENAI_API_KEY) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: "ANTHROPIC_API_KEY or OPENAI_API_KEY must be set" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = match[1];
|
||||||
|
const path = match[2] || "/";
|
||||||
|
const sandbox = getSandbox(env.Sandbox, name);
|
||||||
|
|
||||||
|
await ensureRunning(sandbox, env);
|
||||||
|
|
||||||
|
// Proxy request to container
|
||||||
|
return sandbox.containerFetch(
|
||||||
|
new Request(`http://localhost${path}${url.search}`, request),
|
||||||
|
PORT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve frontend assets
|
||||||
|
return env.ASSETS.fetch(request);
|
||||||
|
},
|
||||||
|
} satisfies ExportedHandler<Env["Bindings"]>;
|
||||||
15
examples/cloudflare/tsconfig.json
Normal file
15
examples/cloudflare/tsconfig.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "esnext",
|
||||||
|
"lib": ["esnext"],
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"types": ["@cloudflare/workers-types"],
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
11
examples/cloudflare/vite.config.ts
Normal file
11
examples/cloudflare/vite.config.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
root: "frontend",
|
||||||
|
build: {
|
||||||
|
outDir: "../dist",
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
33
examples/cloudflare/wrangler.jsonc
Normal file
33
examples/cloudflare/wrangler.jsonc
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"$schema": "node_modules/wrangler/config-schema.json",
|
||||||
|
"name": "sandbox-agent-cloudflare",
|
||||||
|
"main": "src/cloudflare.ts",
|
||||||
|
"compatibility_date": "2025-01-01",
|
||||||
|
"compatibility_flags": ["nodejs_compat"],
|
||||||
|
"assets": {
|
||||||
|
"directory": "./dist",
|
||||||
|
"binding": "ASSETS"
|
||||||
|
},
|
||||||
|
"containers": [
|
||||||
|
{
|
||||||
|
"class_name": "Sandbox",
|
||||||
|
"image": "./Dockerfile",
|
||||||
|
"instance_type": "lite",
|
||||||
|
"max_instances": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"durable_objects": {
|
||||||
|
"bindings": [
|
||||||
|
{
|
||||||
|
"class_name": "Sandbox",
|
||||||
|
"name": "Sandbox"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"migrations": [
|
||||||
|
{
|
||||||
|
"new_sqlite_classes": ["Sandbox"],
|
||||||
|
"tag": "v1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
904
pnpm-lock.yaml
generated
904
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -66,7 +66,7 @@ export class SandboxAgent {
|
||||||
private constructor(options: SandboxAgentConnectOptions) {
|
private constructor(options: SandboxAgentConnectOptions) {
|
||||||
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
||||||
this.token = options.token;
|
this.token = options.token;
|
||||||
this.fetcher = options.fetch ?? globalThis.fetch;
|
this.fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
|
||||||
this.defaultHeaders = options.headers;
|
this.defaultHeaders = options.headers;
|
||||||
|
|
||||||
if (!this.fetcher) {
|
if (!this.fetcher) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue