build: the cloud frontend stands alone

The Durable Object, the sandbox daemon, and their deploy assets
leave the rhema workspace for a repository of their own. The DO
was already a deployment artifact (publish = false, worker feature
wasm-only), and rhema-sandbox-server's only consumer was the image
beside it — both are product, not framework. The harness and the
seam stay behind: rhema-cloud-agent and the sandbox family join
the rhema repository's publish list, and this tree consumes them
from crates.io at 0.2.6, the same arrangement the claw keeps its
framework crates under.

The sandboxd image build carries the fix its extraction needed:
the old Dockerfile still staged the claw's workspace (claw and
tools manifests, integrations globs, the vendored crossterm) and
missed rhema-provider-registry's manifest entirely. The manifest
stage is now this workspace's two members, fetched from the
registry.

Not yet buildable: the rhema 0.2.6 batch (rhema-sandbox,
-local, -http, rhema-cloud-agent) must publish first — then one
cargo check generates Cargo.lock and CI goes green.
This commit is contained in:
vhaudiquet committed 2026-11-13 12:05:30 +01:00
commit c1059c23e0
26 files changed
+3304

No files matched your search

+96
View File
@@ -0,0 +1,96 @@
# Running the coding agent on Cloudflare
One Durable Object per session: WebSocket chat, streaming model
events, `bash` in a Cloudflare Sandbox container, checkpoints in the
DO's SQLite storage. A restart — of the DO, the container, or the
worker — resumes the session; a command that died with the container
comes back as an "interrupted" tool result, never silently re-run.
## Prerequisites
- A Cloudflare account with **Workers Paid**: Containers require it,
and the bill includes container runtime (see the pricing caveat
below).
- Node (for wrangler and worker-build), Rust with
`wasm32-unknown-unknown` (`rustup target add wasm32-unknown-unknown`).
## Deploy
```sh
# from the repository root
npx worker-build --release # builds crates/rhema-cloudflare-code into build/worker/
npx wrangler deploy -c deploy/cloudflare/wrangler.toml
```
First deploy creates the `CodingAgent` and `SandboxShim` Durable
Object classes and the container binding.
## Secrets
Two wrangler secrets, never config files, never DO storage:
```sh
npx wrangler secret put ANTHROPIC_API_KEY -c deploy/cloudflare/wrangler.toml
npx wrangler secret put SANDBOX_TOKEN -c deploy/cloudflare/wrangler.toml
```
`ANTHROPIC_API_KEY` authenticates the model calls. `SANDBOX_TOKEN` is
any long random string; the agent DO sends it to the sandbox shim on
every call, and the shim refuses everything else. Generate one with
`openssl rand -hex 32`.
## Driving a session
Every session lives at `/sessions/<sessionId>/...`:
```sh
# Set up the session once: model, approval policy, seed, skills.
curl -X POST https://<worker>/sessions/demo/config \
-H 'content-type: application/json' \
-d '{
"model": "claude-sonnet-4-5",
"approval": "ask_client",
"seed_git": "https://github.com/acme/widgets",
"skills": [{ "name": "deploy",
"description": "Triggers on deploy tasks.",
"body": "---\ndescription: Triggers on deploy tasks.\n---\n..." }]
}'
# Chat over a WebSocket: send {"type":"chat","text":"fix the build"},
# receive streaming events; answer approval requests with
# {"type":"approve","id":"...","approve":true}. Stop with {"type":"stop"}.
curl https://<worker>/sessions/demo/session # the checkpointed record
```
Approval policies: `deny_all` (default), `allow_list` with
`{"allow_list": ["bash"]}`-style tool names, `allow_all`, and
`ask_client` — requests go over the socket, an unanswered ask denies
after 120 s.
## Limits and pricing
- **Subrequests**: one model fetch plus one or two sandbox calls per
step. A step costs 2–3 subrequests against the plan's per-invocation
budget (1000 on paid plans), so a 16-step turn spends well under
a tenth of it. If turns ever grow past that, the alarm-driven step
queue (one invocation per step) is the v2 answer, not limit-raising.
- **CPU**: the DO's work is orchestration; the model and the container
do the heavy lifting off-thread. Wall-clock, not CPU, is the binding
constraint, and workerd allows long-lived WebSocket requests.
- **Container pricing**: Containers bill for **runtime, not
invocations** — a container that idles for its inactivity window
still costs minutes. The shim keeps one warm for 15 minutes after
the last command (`INACTIVITY_TIMEOUT_MS`); turn that down for
cost, up for latency.
- **Workspace persistence**: files survive idle sleeps. A full
container eviction loses them; the next seeded session re-clones.
Treat the sandbox as a checkout, not a store — push early.
## What is where
| File | What it is |
|---|---|
| `wrangler.toml` | the Worker: DO bindings, SQLite migration, container, vars |
| `entry.mjs` | the deployable entry: re-exports the classes, routes `/sessions/<id>/...` |
| `sandbox-shim.mjs` | the JS DO wrapping `ctx.container` in the JSON surface the agent DO speaks |
| `../../crates/rhema-cloudflare-code/` | the Rust agent: WS protocol, turn loop, checkpoints, fetch transport |
+25
View File
@@ -0,0 +1,25 @@
// The deployable entry: the Rust agent DO (built by `worker-build` into
// build/worker/) re-exported beside the JS sandbox shim, plus the
// route that turns /sessions/<id>/... into a DO fetch addressed by the
// session name.
import { CodingAgent } from "../../build/worker/shim.js";
import { SandboxShim } from "./sandbox-shim.mjs";
export { CodingAgent, SandboxShim };
export default {
async fetch(request, env) {
const url = new URL(request.url);
const match = url.pathname.match(/^\/sessions\/([^/]+)(\/.*)?$/);
if (!match) {
return new Response("route a session as /sessions/<sessionId>/...", {
status: 404,
});
}
const stub = env.CODING_AGENT.get(
env.CODING_AGENT.idFromName(decodeURIComponent(match[1])),
);
url.pathname = match[2] ?? "/";
return stub.fetch(new Request(url, request));
},
};
+188
View File
@@ -0,0 +1,188 @@
// The sandbox shim: a small JS Durable Object that turns plain JSON
// over HTTP into Durable Object Container calls. The container runtime
// (`ctx.container`) is a JS-only API — workers-rs cannot reach it — so
// the Rust agent DO speaks this surface instead of fighting JS RPC.
//
// One shim instance per session, named after the session like the
// agent DO, so the container's lifetime hangs off the same name the
// checkpoints do. `SANDBOX_TOKEN` guards the HTTP surface: the agent
// DO sends it in `x-rhema-token`, and nothing else should answer here.
//
// Contract (all routes JSON):
// POST /exec { command, cwd?, timeout_secs }
// -> { exit_code, stdout, stderr, timed_out }
// POST /put { path, content_base64 } -> { error: null | message }
// POST /get { path } -> { content_base64 } | { error: message }
// POST /seed { git } | { tarball_base64 } -> { error: null | message }
//
// Output streams are tail-capped before they cross the wire, so a
// runaway command cannot flood the DO; the tool applies its display
// caps again on top.
const MAX_STREAM_BYTES = 50 * 1024;
// The put/get/seed routes ride command arguments, which are bounded;
// keep the payloads comfortably inside that.
const MAX_FILE_BYTES = 512 * 1024;
const IMAGE = "cloudflare/debian-trixie";
// Keep the container warm for a quarter hour after the last command —
// the idle sleep the pricing caveat is about (see README.md).
const INACTIVITY_TIMEOUT_MS = 15 * 60 * 1000;
/// Shell-quotes one argument, single-quote style.
function shquote(value) {
return `'${String(value).replaceAll(`'`, `'\\''`)}'`;
}
function cap(text) {
return text.length <= MAX_STREAM_BYTES ? text : text.slice(text.length - MAX_STREAM_BYTES);
}
function clampTimeout(secs) {
const n = Number(secs);
if (!Number.isFinite(n)) return 120;
return Math.min(Math.max(Math.round(n), 1), 600);
}
export class SandboxShim {
constructor(ctx, env) {
this.ctx = ctx;
this.env = env;
}
async ensureRunning() {
const container = this.ctx.container;
if (!container) {
throw new Error("the container binding is not configured");
}
if (!container.running) {
container.start({
image: IMAGE,
entrypoint: ["sleep", "infinity"],
enableInternet: true,
});
await container.setInactivityTimeout(INACTIVITY_TIMEOUT_MS);
}
}
/// Runs one command with a wall-clock budget; `timeout` kills the
/// command and every process it started.
async exec(command, timeoutSecs) {
await this.ensureRunning();
const argv = [
"timeout",
"--kill-after=5",
String(timeoutSecs),
"bash",
"-c",
command,
];
const process = await this.ctx.container.exec(argv);
const output = await process.output();
const decoder = new TextDecoder();
return {
exit_code: output.exitCode,
stdout: cap(decoder.decode(output.stdout)),
stderr: cap(decoder.decode(output.stderr)),
// Only GNU timeout's own 124 means a budget kill. 137 is an
// external SIGKILL (OOM most often): reporting it as a timeout
// teaches the model to widen the budget and re-run the same
// command, which dies the same way — so it surfaces as a plain
// failure instead.
timed_out: output.exitCode === 124,
};
}
async fetch(request) {
const token = this.env.SANDBOX_TOKEN;
if (token && request.headers.get("x-rhema-token") !== token) {
return Response.json({ error: "forbidden" }, { status: 403 });
}
const route = new URL(request.url).pathname;
let body = {};
try {
body = await request.json();
} catch {
// Routes that need a body fail on their own below.
}
try {
switch (route) {
case "/healthz":
return Response.json({ ok: true });
case "/exec": {
const cwd =
typeof body.cwd === "string" && body.cwd
? `cd ${shquote(body.cwd)} && `
: "";
const result = await this.exec(
`${cwd}{ ${body.command ?? ":"} ; }`,
clampTimeout(body.timeout_secs),
);
return Response.json(result);
}
case "/put": {
const path = String(body.path ?? "");
const b64 = String(body.content_base64 ?? "");
if (b64.length > (MAX_FILE_BYTES * 4) / 3 + 4) {
return Response.json({ error: "file too large" }, { status: 413 });
}
const result = await this.exec(
`mkdir -p "$(dirname ${shquote(path)})" && ` +
`printf %s ${shquote(b64)} | base64 -d > ${shquote(path)}`,
60,
);
if (result.exit_code !== 0) {
return Response.json(
{ error: result.stderr || "write failed" },
{ status: 500 },
);
}
return Response.json({ error: null });
}
case "/get": {
const path = String(body.path ?? "");
const result = await this.exec(
`base64 ${shquote(path)} 2>/dev/null | head -c ${MAX_FILE_BYTES * 2}`,
60,
);
if (result.exit_code !== 0) {
return Response.json({ error: "no such file" }, { status: 404 });
}
return Response.json({
content_base64: result.stdout.replace(/\s+/g, ""),
error: null,
});
}
case "/seed": {
if (body.git) {
const result = await this.exec(
`git clone ${shquote(String(body.git))} .`,
300,
);
if (result.exit_code !== 0) {
return Response.json(
{ error: result.stderr || "clone failed" },
{ status: 500 },
);
}
} else if (body.tarball_base64) {
const result = await this.exec(
`printf %s ${shquote(String(body.tarball_base64))} | base64 -d | tar -xzf -`,
300,
);
if (result.exit_code !== 0) {
return Response.json(
{ error: result.stderr || "unpack failed" },
{ status: 500 },
);
}
}
return Response.json({ error: null });
}
default:
return Response.json({ error: "not found" }, { status: 404 });
}
} catch (err) {
return Response.json({ error: String(err) }, { status: 500 });
}
}
}
+43
View File
@@ -0,0 +1,43 @@
# The rhema cloud agent: one Worker exporting the Rust `CodingAgent`
# Durable Object (built by `worker-build` into build/worker/), the JS
# `SandboxShim` container DO, and the /sessions/<id>/ route.
#
# Build and deploy:
# npx worker-build --release # or: cargo install worker-build
# wrangler deploy
# See README.md beside this file for the full walk-through.
name = "rhema-cloudflare-code"
main = "entry.mjs"
compatibility_date = "2026-01-15"
compatibility_flags = ["nodejs_compat"]
# The agent DO and the sandbox shim of a session share a name, so the
# container's lifetime hangs off the same name the checkpoints do.
[[durable_objects.bindings]]
name = "CODING_AGENT"
class_name = "CodingAgent"
[[durable_objects.bindings]]
name = "SANDBOX"
class_name = "SandboxShim"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["CodingAgent", "SandboxShim"]
# The container: `scheduling_policy = "durable_object"` lets the shim
# choose the image in `container.start()`.
[[containers]]
class_name = "SandboxShim"
scheduling_policy = "durable_object"
[vars]
# The default model, until a session's POST /config carries its own.
RHEMA_MODEL = "claude-sonnet-4-5"
# Secrets — set with `wrangler secret put`, never in this file, never
# in DO storage:
# ANTHROPIC_API_KEY the model key the agent DO authenticates with
# SANDBOX_TOKEN the shared secret between the agent DO and the
# sandbox shim's HTTP surface