Skip to content

Commit 6228ea5

Browse files
committed
chore: simplify cloudflare compatibility
1 parent 783ea10 commit 6228ea5

File tree

10 files changed

+422
-253
lines changed

10 files changed

+422
-253
lines changed

docs/deploy/cloudflare.mdx

Lines changed: 39 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,23 @@ RUN sandbox-agent install-agent claude && sandbox-agent install-agent codex
3131
EXPOSE 8000
3232
```
3333

34-
## TypeScript proxy example
34+
## TypeScript example
3535

3636
```typescript
3737
import { getSandbox, type Sandbox } from "@cloudflare/sandbox";
38+
import { Hono } from "hono";
39+
import { SandboxAgent } from "sandbox-agent";
40+
3841
export { Sandbox } from "@cloudflare/sandbox";
3942

40-
type Env = {
43+
type Bindings = {
4144
Sandbox: DurableObjectNamespace<Sandbox>;
45+
ASSETS: Fetcher;
4246
ANTHROPIC_API_KEY?: string;
4347
OPENAI_API_KEY?: string;
4448
};
4549

50+
const app = new Hono<{ Bindings: Bindings }>();
4651
const PORT = 8000;
4752

4853
async function isServerRunning(sandbox: Sandbox): Promise<boolean> {
@@ -54,64 +59,50 @@ async function isServerRunning(sandbox: Sandbox): Promise<boolean> {
5459
}
5560
}
5661

57-
async function ensureRunning(sandbox: Sandbox, env: Env): Promise<void> {
58-
if (await isServerRunning(sandbox)) return;
59-
60-
const envVars: Record<string, string> = {};
61-
if (env.ANTHROPIC_API_KEY) envVars.ANTHROPIC_API_KEY = env.ANTHROPIC_API_KEY;
62-
if (env.OPENAI_API_KEY) envVars.OPENAI_API_KEY = env.OPENAI_API_KEY;
63-
await sandbox.setEnvVars(envVars);
64-
65-
await sandbox.startProcess(`sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`);
66-
67-
for (let i = 0; i < 30; i++) {
68-
if (await isServerRunning(sandbox)) return;
69-
await new Promise((r) => setTimeout(r, 200));
62+
async function getReadySandbox(name: string, env: Bindings): Promise<Sandbox> {
63+
const sandbox = getSandbox(env.Sandbox, name);
64+
if (!(await isServerRunning(sandbox))) {
65+
const envVars: Record<string, string> = {};
66+
if (env.ANTHROPIC_API_KEY) envVars.ANTHROPIC_API_KEY = env.ANTHROPIC_API_KEY;
67+
if (env.OPENAI_API_KEY) envVars.OPENAI_API_KEY = env.OPENAI_API_KEY;
68+
await sandbox.setEnvVars(envVars);
69+
await sandbox.startProcess(`sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`);
7070
}
71-
72-
throw new Error("sandbox-agent failed to start");
71+
return sandbox;
7372
}
7473

75-
export default {
76-
async fetch(request: Request, env: Env): Promise<Response> {
77-
const url = new URL(request.url);
78-
const match = url.pathname.match(/^\/sandbox\/([^/]+)(\/.*)?$/);
74+
app.post("/sandbox/:name/prompt", async (c) => {
75+
const sandbox = await getReadySandbox(c.req.param("name"), c.env);
7976

80-
if (!match) {
81-
return new Response("Not found", { status: 404 });
82-
}
77+
const sdk = await SandboxAgent.connect({
78+
fetch: (input, init) => sandbox.containerFetch(input as Request | string | URL, init, PORT),
79+
});
8380

84-
const [, name, path = "/"] = match;
85-
const sandbox = getSandbox(env.Sandbox, name);
86-
await ensureRunning(sandbox, env);
81+
const session = await sdk.createSession({ agent: "codex" });
82+
const response = await session.prompt([{ type: "text", text: "Summarize this repository" }]);
83+
await sdk.destroySession(session.id);
84+
await sdk.dispose();
8785

88-
return sandbox.containerFetch(
89-
new Request(`http://localhost${path}${url.search}`, request),
90-
PORT,
91-
);
92-
},
93-
};
94-
```
95-
96-
## Connect from a client
97-
98-
```typescript
99-
import { SandboxAgent } from "sandbox-agent";
100-
101-
const sdk = await SandboxAgent.connect({
102-
baseUrl: "http://localhost:8787/sandbox/my-sandbox",
86+
return c.json(response);
10387
});
10488

105-
const session = await sdk.createSession({ agent: "claude" });
89+
app.all("/sandbox/:name/proxy/*", async (c) => {
90+
const sandbox = await getReadySandbox(c.req.param("name"), c.env);
91+
const wildcard = c.req.param("*");
92+
const path = wildcard ? `/${wildcard}` : "/";
93+
const query = new URL(c.req.raw.url).search;
10694

107-
const off = session.onEvent((event) => {
108-
console.log(event.sender, event.payload);
95+
return sandbox.containerFetch(new Request(`http://localhost${path}${query}`, c.req.raw), PORT);
10996
});
11097

111-
await session.prompt([{ type: "text", text: "Summarize this repository" }]);
112-
off();
98+
app.all("*", (c) => c.env.ASSETS.fetch(c.req.raw));
99+
100+
export default app;
113101
```
114102

103+
Create the SDK client inside the Worker using custom `fetch` backed by `sandbox.containerFetch(...)`.
104+
This keeps all Sandbox Agent calls inside the Cloudflare sandbox routing path and does not require a `baseUrl`.
105+
115106
## Local development
116107

117108
```bash
@@ -121,7 +112,7 @@ npm run dev
121112
Test health:
122113

123114
```bash
124-
curl http://localhost:8787/sandbox/demo/v1/health
115+
curl http://localhost:8787/sandbox/demo/proxy/v1/health
125116
```
126117

127118
## Production deployment

docs/sdk-overview.mdx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ const sdk = await SandboxAgent.connect({
3939
});
4040
```
4141

42+
With a custom fetch handler (for example, proxying requests inside Workers):
43+
44+
```ts
45+
const sdk = await SandboxAgent.connect({
46+
fetch: (input, init) => customFetch(input, init),
47+
});
48+
```
49+
4250
With persistence:
4351

4452
```ts
@@ -158,9 +166,10 @@ console.log(url);
158166

159167
Parameters:
160168

161-
- `baseUrl` (required): Sandbox Agent server URL
169+
- `baseUrl` (required unless `fetch` is provided): Sandbox Agent server URL
162170
- `token` (optional): Bearer token for authenticated servers
163171
- `headers` (optional): Additional request headers
172+
- `fetch` (optional): Custom fetch implementation used by SDK HTTP and ACP calls
164173

165174
## Types
166175

examples/cloudflare/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ Test the endpoint:
3636
curl http://localhost:8787
3737
```
3838

39+
Test prompt routing through the SDK with a custom sandbox fetch handler:
40+
41+
```bash
42+
curl -X POST "http://localhost:8787/sandbox/demo/prompt" \
43+
-H "Content-Type: application/json" \
44+
-d '{"agent":"codex","prompt":"Reply with one short sentence."}'
45+
```
46+
47+
The response includes `events`, an array of all recorded session events for that prompt.
48+
3949
## Deploy
4050

4151
```bash

examples/cloudflare/frontend/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export function App() {
2525

2626
try {
2727
// Connect via proxy endpoint (need full URL for SDK)
28-
const baseUrl = `${window.location.origin}/sandbox/${encodeURIComponent(sandboxName)}`;
28+
const baseUrl = `${window.location.origin}/sandbox/${encodeURIComponent(sandboxName)}/proxy`;
2929
log(`Connecting to sandbox: ${sandboxName}`);
3030

3131
const client = await SandboxAgent.connect({ baseUrl });

examples/cloudflare/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
},
1111
"dependencies": {
1212
"@cloudflare/sandbox": "latest",
13+
"hono": "^4.12.2",
1314
"react": "^19.1.0",
1415
"react-dom": "^19.1.0",
1516
"sandbox-agent": "workspace:*"

examples/cloudflare/src/index.ts

Lines changed: 58 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
import { getSandbox, type Sandbox } from "@cloudflare/sandbox";
2+
import { Hono } from "hono";
3+
import { HTTPException } from "hono/http-exception";
4+
import { runPromptTest, type PromptTestRequest } from "./prompt-test";
25

36
export { Sandbox } from "@cloudflare/sandbox";
47

5-
type Env = {
6-
Bindings: {
7-
Sandbox: DurableObjectNamespace<Sandbox>;
8-
ASSETS: Fetcher;
9-
ANTHROPIC_API_KEY?: string;
10-
OPENAI_API_KEY?: string;
11-
};
8+
type Bindings = {
9+
Sandbox: DurableObjectNamespace<Sandbox>;
10+
ASSETS: Fetcher;
11+
ANTHROPIC_API_KEY?: string;
12+
OPENAI_API_KEY?: string;
13+
CODEX_API_KEY?: string;
1214
};
1315

16+
type AppEnv = { Bindings: Bindings };
17+
1418
const PORT = 8000;
1519

1620
/** Check if sandbox-agent is already running by probing its health endpoint */
@@ -23,54 +27,60 @@ async function isServerRunning(sandbox: Sandbox): Promise<boolean> {
2327
}
2428
}
2529

26-
/** Ensure sandbox-agent is running in the container */
27-
async function ensureRunning(sandbox: Sandbox, env: Env["Bindings"]): Promise<void> {
28-
if (await isServerRunning(sandbox)) return;
29-
30-
// Set environment variables for agents
30+
async function getReadySandbox(name: string, env: Bindings): Promise<Sandbox> {
31+
const sandbox = getSandbox(env.Sandbox, name);
3132
const envVars: Record<string, string> = {};
3233
if (env.ANTHROPIC_API_KEY) envVars.ANTHROPIC_API_KEY = env.ANTHROPIC_API_KEY;
3334
if (env.OPENAI_API_KEY) envVars.OPENAI_API_KEY = env.OPENAI_API_KEY;
35+
if (env.CODEX_API_KEY) envVars.CODEX_API_KEY = env.CODEX_API_KEY;
36+
if (!envVars.CODEX_API_KEY && envVars.OPENAI_API_KEY) envVars.CODEX_API_KEY = envVars.OPENAI_API_KEY;
3437
await sandbox.setEnvVars(envVars);
3538

36-
// Start sandbox-agent server as background process
37-
await sandbox.startProcess(`sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`);
39+
if (!(await isServerRunning(sandbox))) {
40+
await sandbox.startProcess(`sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`);
3841

39-
// Poll health endpoint until server is ready (max ~6 seconds)
40-
for (let i = 0; i < 30; i++) {
41-
if (await isServerRunning(sandbox)) return;
42-
await new Promise((r) => setTimeout(r, 200));
42+
for (let i = 0; i < 30; i++) {
43+
if (await isServerRunning(sandbox)) break;
44+
await new Promise((r) => setTimeout(r, 200));
45+
}
4346
}
47+
return sandbox;
4448
}
4549

46-
export default {
47-
async fetch(request: Request, env: Env["Bindings"]): Promise<Response> {
48-
const url = new URL(request.url);
49-
50-
// Proxy requests to sandbox-agent: /sandbox/:name/v1/...
51-
const match = url.pathname.match(/^\/sandbox\/([^/]+)(\/.*)?$/);
52-
if (match) {
53-
if (!env.ANTHROPIC_API_KEY && !env.OPENAI_API_KEY) {
54-
return Response.json(
55-
{ error: "ANTHROPIC_API_KEY or OPENAI_API_KEY must be set" },
56-
{ status: 500 }
57-
);
58-
}
59-
60-
const name = match[1];
61-
const path = match[2] || "/";
62-
const sandbox = getSandbox(env.Sandbox, name);
63-
64-
await ensureRunning(sandbox, env);
65-
66-
// Proxy request to container
67-
return sandbox.containerFetch(
68-
new Request(`http://localhost${path}${url.search}`, request),
69-
PORT
70-
);
71-
}
50+
async function proxyToSandbox(sandbox: Sandbox, request: Request, path: string): Promise<Response> {
51+
const query = new URL(request.url).search;
52+
return sandbox.containerFetch(new Request(`http://localhost${path}${query}`, request), PORT);
53+
}
54+
55+
const app = new Hono<AppEnv>();
56+
57+
app.onError((error) => {
58+
return new Response(String(error), { status: 500 });
59+
});
60+
61+
app.post("/sandbox/:name/prompt", async (c) => {
62+
if (!(c.req.header("content-type") ?? "").includes("application/json")) {
63+
throw new HTTPException(400, { message: "Content-Type must be application/json" });
64+
}
65+
66+
let payload: PromptTestRequest;
67+
try {
68+
payload = await c.req.json<PromptTestRequest>();
69+
} catch {
70+
throw new HTTPException(400, { message: "Invalid JSON body" });
71+
}
72+
73+
const sandbox = await getReadySandbox(c.req.param("name"), c.env);
74+
return c.json(await runPromptTest(sandbox, payload, PORT));
75+
});
76+
77+
app.all("/sandbox/:name/proxy/*", async (c) => {
78+
const sandbox = await getReadySandbox(c.req.param("name"), c.env);
79+
const wildcard = c.req.param("*");
80+
const path = wildcard ? `/${wildcard}` : "/";
81+
return proxyToSandbox(sandbox, c.req.raw, path);
82+
});
83+
84+
app.all("*", (c) => c.env.ASSETS.fetch(c.req.raw));
7285

73-
// Serve frontend assets
74-
return env.ASSETS.fetch(request);
75-
},
76-
} satisfies ExportedHandler<Env["Bindings"]>;
86+
export default app;
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { Sandbox } from "@cloudflare/sandbox";
2+
import { SandboxAgent } from "sandbox-agent";
3+
4+
export type PromptTestRequest = {
5+
agent?: string;
6+
prompt?: string;
7+
};
8+
9+
export type PromptTestResponse = {
10+
sessionId: string;
11+
agent: string;
12+
prompt: string;
13+
events: unknown[];
14+
};
15+
16+
export async function runPromptTest(
17+
sandbox: Sandbox,
18+
request: PromptTestRequest,
19+
port: number,
20+
): Promise<PromptTestResponse> {
21+
const client = await SandboxAgent.connect({
22+
fetch: (req, init) =>
23+
sandbox.containerFetch(req, init, port),
24+
});
25+
26+
let sessionId: string | null = null;
27+
try {
28+
const session = await client.createSession({
29+
agent: request.agent ?? "codex",
30+
});
31+
sessionId = session.id;
32+
33+
const promptText =
34+
request.prompt?.trim() || "Reply with a short confirmation.";
35+
await session.prompt([{ type: "text", text: promptText }]);
36+
37+
const events: unknown[] = [];
38+
let cursor: string | undefined;
39+
while (true) {
40+
const page = await client.getEvents({
41+
sessionId: session.id,
42+
cursor,
43+
limit: 200,
44+
});
45+
events.push(...page.items);
46+
if (!page.nextCursor) break;
47+
cursor = page.nextCursor;
48+
}
49+
50+
return {
51+
sessionId: session.id,
52+
agent: session.agent,
53+
prompt: promptText,
54+
events,
55+
};
56+
} finally {
57+
if (sessionId) {
58+
try {
59+
await client.destroySession(sessionId);
60+
} catch {
61+
// Ignore cleanup failures; session teardown is best-effort.
62+
}
63+
}
64+
await client.dispose();
65+
}
66+
}

0 commit comments

Comments
 (0)