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.
17 KiB
Handoff: implement rhema-cloudflare-code — the coding agent as a Cloudflare Durable Object
Handoff prompt for an implementation session. Everything needed is in this file; a fresh session will not have the conversation that produced it. Verified facts in §1 were established against this tree by compiling — re-verify only if the tree has moved since (git log, then the §1 checks).
0. Mission
Add a new frontend consumer of rhema-coding-agent (alongside rhema-code and
rhema-acp) named rhema-cloudflare-code, under crates/. It runs the
coding agent inside a Cloudflare Durable Object, with the agent's bash
tool executing inside Cloudflare's Sandbox
(https://developers.cloudflare.com/agents/tools/sandbox/ — a Durable
Object-on-Container product: exec, file ops, idle sleep, persisted workspace).
Result: a WebSocket/HTTP-addressable coding agent whose sessions checkpoint into DO storage, whose workspace lives in the sandbox container, and which survives DO/container eviction via the engine's existing crash-resume machinery.
Non-goal: porting the terminal UI, the claw daemon, or the local-only provider stack to wasm. Non-goal v1: any provider adapter other than Anthropic.
1. Verified ground truth (already established; do not re-derive)
Verified in this tree with cargo check --target wasm32-unknown-unknown
(add the target first: rustup target add wasm32-unknown-unknown; toolchain
pinned at 1.98.1 in rust-toolchain.toml):
Compiles to wasm today, unchanged:
rhema-core(deps: serde, serde_json, thiserror, async-trait only — no tokio, fs, threads, or time)rhema-provider(including the transport-agnosticSseDecoderinsrc/stream.rs, which consumes byte slices)rhema-transcriptrhema-rsd,rhema-autonomous-agent,rhema-memory,rhema-configwith--no-default-features(excludes thesqlitefeature)
Does not compile to wasm, with exact reasons:
rhema-agent-tools(and transitivelyrhema-subagents):crates/rhema-agent-tools/src/shell.rs:137hardcodestokio::process::Command::new("bash"), and the crate enables tokio'sprocess/timefeatures;miodoes not build on wasm.rhema-provider-anthropic, two reqwest-on-wasm issues: (a)connect_timeoutdoes not exist on wasm (provider.rs:70) — fetch has no socket knobs; (b)response.bytes_stream()(provider.rs:357) is backed by JS objects (Rc<RefCell<js_sys::futures::Inner>>) and therefore!Send, while#[async_trait]requiresSendfutures.
Why Send is non-negotiable: EventSink: Send
(rhema-core/src/event.rs:80, reason documented at :78-79) — the sink
reference crosses await points inside provider implementations. All
JS-backed wasm-bindgen values are !Send by soundness (raw handles into the
JS heap); this cannot be "fixed upstream". The fix is always bridging: run the
!Send stream in wasm_bindgen_futures::spawn_local and forward plain
Vec<u8> chunks over a channel whose receiver is Send on single-threaded
wasm (plain data is trivially Send there). The async-compat crate packages
exactly this pattern.
Runtime facts (workers): workerd gives wasm no owned event loop, no
sockets, no filesystem, no Rust-side timers. All I/O goes through host
bindings (fetch, DO storage, sandbox bindings). tokio::time sleeps do not
fire inside workerd; use worker::Delay. reqwest-on-wasm has no streaming
bodies worth using — do not attempt to keep it. rusqlite technically
compiles on wasm but cannot open files in workerd — compile ≠ works.
Relevant existing seams (read these files first):
rhema-core/src/provider.rs—ModelProvider::complete(&self, ModelRequest, &mut dyn EventSink) -> Result<ModelOutput, ProviderError>. IMPORTANT:ProviderError::is_retryable()parses the"... (HTTP <status>): ..."message shape — any new transport must keep adapters formatting errors that way, and must map transport failures toProviderError::Other("network error: ...")(that prefix is what makes them retryable).rhema-provider/src/retry.rs—Retryingdecorator: 3 attempts, 1 s base / 2 s cap, never retries once an event was emitted, retries an empty success. Sleeps viatokio::time(tests usestart_paused; seeretry.rs:320).rhema-agent-tools/src/shell.rs—ShellTools: the ONLY tool isbash("reading, listing, and editing are shell work, not separate tools"). Output caps: 50 KiB / 2000 lines per stream (BASH_OUTPUT_LIMIT,BASH_OUTPUT_LINES);BASH_TIMEOUT= 120 s; approval consulted per call frompending: Mutex<Option<Arc<dyn Approval>>>; no hook installed = reject.ShellTools::builtin_specs()is the single source of truth for the tool spec, and the system-prompt renderer renders the same specs so the prompt and the request's tools array cannot drift — preserve this invariant.rhema-agent-tools/src/approval.rs—trait Approval: Send + Sync(+AllowAll,DenyAll).rhema-coding-agent/src/agent.rs— the driver: owns oneShellToolsfor the agent lifetime, installs the per-turn approval hook, runsrevive_dangling_callson resume (committed-but-unanswered calls: replay-safe tools re-run, others reported interrupted),stamped_system(base, workspace, extra)assembles the system prompt (discovers skills from the global directory internally; sessions save/load viastd::fsatagent.rs:233/238).rhema-core/src/session.rs—Session { history, driver_state: Value, cost_usd }: plain serde, checkpoint anywhere.rhema-core/src/tool.rs—ToolContext { cwd: PathBuf, run_id, cancelled: Arc<AtomicBool> };ReplaySafetyper tool.rhema-rsd/src/store.rs:117—trait Store { load(key); save(record, expected_revision); keys() }— revision-guarded persistence over any store. A DO is the single writer per key, so the revision check can be maintained in-memory + persisted counter.AGENTS.md— conventions; summary in §5 below.
2. Target architecture
browser/editor client
| WebSocket (chat, streaming deltas, approval prompts) + HTTPS
v
CodingAgent DO (workers-rs, wasm32-unknown-unknown; one instance per session id)
|- turn loop ........... rhema-coding-agent over rhema-core::run_turn
|- step cost control ... rhema-transcript (on by default)
|- provider ............ Anthropic Messages over host fetch (new transport)
|- session checkpoints . DO SQLite storage (rev-guarded, rhema-rsd Store impl)
|- approvals ........... DenyAll default; WS round-trip or per-session policy
|- memory/subagents .... rhema-memory tools; subagents inherit the sandbox
`- bash tool ........... -> Sandbox DO (same session name) over HTTP
`- container: bash -c in the workspace
One DO instance per session (idFromName(sessionId)), binding to the sandbox
instance of the same name. Turn driving v1: the turn runs inside the client's
live request while its WebSocket is connected (streaming deltas live);
checkpoint the session after every step so any eviction resumes via
revive_dangling_calls. Alarm-driven step queues are a v2 refinement.
3. Layout: one crate, strict module hygiene
The crate is crates/rhema-cloudflare-code/ (auto-added by the workspace's
members = ["crates/*"]). Two families of modules, never mixed:
Platform-neutral modules (no wasm-bindgen, no worker, no fetch deps —
host-side cargo test must compile and run them):
sandbox—trait Sandbox(object-safe, async):exec(ExecRequest) -> streamed stdout/stderr lines + exit code(+ v1: workspace seed — git clone URL or tarball upload; file get/put if the skills flow needs it).ExecRequestcarries command, cwd, timeout (echo the 120 s default), cancellation.ShellErrormaps transport failure so the bash tool result stays an in-bandis_errorresult, never an abort.shell—ShellSandbox: thebashtool resolver (or the sandbox-sideShellExecutorimpl for Phase 1's seam) exposing the SAME spec asShellTools::builtin_specs()— reuse it verbatim (its description says "Requires user approval", which must stay true).assembly/config types — model id, api key source, approval policy, agent construction.- Scripted
Sandboximpl for offline tests (house pattern:rhema-core'smockfeature), so the full turn loop tests host-side withrhema-core'sMockProvider. - Every platform-neutral module carries a module-level
#![forbid(unsafe_code)]; the crate carries#![deny(missing_docs)].
The worker module (and ONLY it): the workers-rs glue — the
#[durable_object] class CodingAgent, the host-fetch provider transport, the
!Send bridge, the worker::Delay sleeper, the DO-storage impls
(rhema-rsd::Store, rhema-memory::MemoryStore; prefer a SQLite-backed DO
class — KV values cap at 128 KiB and sessions grow; record the workers-rs
version you pin and why, it is 0.x and APIs move).
This module is why the crate cannot carry a crate-level
#![forbid(unsafe_code)]: the #[durable_object] macro emits unsafe
internally and forbid cannot be overridden by allow in inner scopes.
Document that exception in src/lib.rs (precedent for sanctioned exceptions:
vendor/crossterm is excluded for toolchain reasons).
4. Phased work plan
Phase 1 — shell seam in rhema-agent-tools (unblocks wasm + is the core abstraction)
- Extract the execution half of
shell.rsinto a seam, e.g.trait ShellExecutor: Send + Sync { async fn run(&self, command: &str, cwd: &Path, timeout: Duration, cancelled: &AtomicBool, progress: callback) -> Result<Executed, ShellError> }whereExecuted= stdout tail, stderr tail, exit code.ShellToolskeeps: the spec, the approval gate, caps, timeout, error-shaping; it delegates execution. - Local impl = today's
tokio::processcode verbatim, behind a default-on feature (e.g.local-shell). The crate's tokioprocess/timefeatures become feature-dependent so the wasm build excludes them. - Accept:
cargo check -p rhema-agent-tools -p rhema-subagents --target wasm32-unknown-unknown --no-default-featuresis clean; native tests green (cargo test -p rhema-agent-tools); existingShellToolsbehavior unchanged (caps, timeout-kill, line-streaming, approval).
Phase 2 — session/skills/prompt inputs in rhema-coding-agent (small)
- Sessions: the cloud crate serializes
Sessionitself; if the fs helpers atagent.rs:233/238are the only consumers, no seam is needed — confirm and note it. - Skills/system prompt:
stamped_systemdiscovers skills from the filesystem internally. Add an input path that accepts an already-collected skill list (keep the fs discovery as the default used byrhema-code), so the cloud crate can feed skills from DO storage or discovered inside the sandbox. SYSTEM.mdbaseis already a parameter — the cloud crate supplies it from DO storage with the built-in default as fallback. - Accept: native behavior unchanged; the cloud crate can assemble a full agent with zero filesystem access.
Phase 3 — transport seam in the provider family
- Add a
Transporttrait torhema-provider(it is the adapter-family base): request in (method/url/headers/json body) → status + a Send stream of byte chunks out. Contract: JS types must never leak into the trait; on wasm, bridge viaspawn_local+ channel (orasync-compat). rhema-provider-anthropic: put reqwest behind a default-onnativefeature; takeArc<dyn Transport>; moveconnect_timeoutand the "network error: " mapping into the native impl; keep the(HTTP <status>):error shape; thebytes_streamwhile-loop becomes transport-agnostic over the sharedSseDecoder.rhema-provider/src/retry.rs: makeRetrying's sleep injectable (additive builder, e.g.with_sleeper), default = today's tokio behavior so existing tests stay green. The cloud crate passes aworker::Delaysleeper.- Accept:
cargo check --workspace --all-featuresgreen natively; wasm check green forrhema-providerand the anthropic wire module with--no-default-features; no wire-type duplication between native and fetch paths.
Phase 4 — rhema-cloudflare-code itself
- Neutral half first:
sandboxtrait + scripted mock,ShellSandbox, assembly; host-side e2e (scripted sandbox +MockProvider): full turn with a bash call, approval round-trip, session checkpoint, and a simulated crash mid-command whose resume reports the call interrupted (not re-run). - Then the
workermodule: DO classCodingAgent— WS chat surface (streamEvents as they arrive; approval requests as WS round-trips with deny-on-timeout default and per-session policy: deny-all / read-only allowlist / allow-all flag), session checkpointing after every step,run_idper turn,ToolContext.cancelledwired to a disconnect/stop message;alarmreserved for v2. - Approval plumbing note: approval fires inside tool resolution inside the turn task; the WS handler and turn task share workerd's single thread — connect them with channels, never shared-blocking waits.
- Secrets via wrangler secrets (
env.secret(...)), never DO storage. Profiles in DO storage; do not userhema-config's XDG store here. - Sandbox HTTP client: call the Sandbox DO over its binding (
stub.fetch()with JSON commands). If the current Sandbox surface is JS-RPC-only, write a ~30-line JS shim DO/worker that converts plain JSON HTTP → sandbox calls (prefer this over fighting JS RPC from Rust).
Phase 5 — deploy assets + CI
deploy/cloudflare/: wrangler config (main = build/worker/shim.mjs,new_sqlite_classesmigration, DO + sandbox namespace bindings, secrets docs), the optional JS shim, README notes — sibling ofdocker/andhelm/.- Add the wasm gate to CI (scope
ci):cargo checkover the cloud closure with--target wasm32-unknown-unknown(the full crate with theworkerfeature is the real build; keep it green). - Accept: a fresh clone with
wrangler dev+ one secret works.
Phase 6 — docs & conventions debt
- README: a component mention for
rhema-cloudflare-code. AGENTS.md: add commit scopecloudflare-code. SPEC.md: the design-decision section (why DO + sandbox; the replay-safety implications of container eviction; the approval model; the turn-driving model).
5. Guardrails (from AGENTS.md and this design — non-negotiable)
- Per commit:
cargo fmt --all,cargo clippy --workspace --all-targets --all-features -- -D warnings,cargo test --workspace --all-featuresgreen. Conventional commits with component scopes, linear history (rebase, no merge commits). - Do not weaken
#![forbid(unsafe_code)]/#![deny(missing_docs)]anywhere except the sanctionedworkermodule exception (documented in §3). - No wasm-bindgen, fetch, or
workerdependencies inrhema-core,rhema-coding-agent,rhema-agent-tools; none in the platform-neutral modules ofrhema-cloudflare-code. The platform lives at the edges. rhema-corekeeps exactly its two engine consumers (coding agent, RSD agent) — the cloud crate is a new frontend, not a new engine consumer. Dispatch/lease/persistence belongs inrhema-rsd, never in the engine.- Keep the single
bashspec as the source of truth shared by prompt and tools array; never fork the spec text. - Do not attempt to make reqwest,
tokio::time, or rusqlite work inside workerd. Host bindings or nothing.
6. Open questions to resolve from current Cloudflare docs (verify, don't assume)
- The Sandbox's exact callable surface today (REST-over-DO-fetch vs JS RPC only) and its auth between your DO and the sandbox binding. If RPC-only, build the JS shim (Phase 4).
- Sandbox idle-sleep model and workspace persistence guarantees — map onto
ReplaySafetyexplicitly: a command killed by container sleep/eviction is the committed-unanswered-call case;bashisUnsafeto replay, so resume must report it interrupted (existing machinery — verify the wiring reaches it). - Current workers-rs DO support level:
DurableObjecttrait shape, WS hibernation, SQLite (SqlStorage) bindings — pin the version accordingly. - Current CPU-limit and subrequest-limit numbers (per invocation), and whether the target plan's limits fit a live-request turn (~1 model fetch + 1-2 sandbox calls + approval round-trips per step; a 16-step turn ≈ 30-50 subrequests). If not, implement the alarm-driven step queue (v2) instead of fighting limits.
7. Definition of done
- All three gate commands green: fmt / clippy (-D warnings) / test
(
--workspace --all-features), plus the wasm check over the cloud closure. - Offline e2e test (host-side): scripted sandbox +
MockProvider→ full turn with a bash call, approval round-trip, session checkpoint, and a simulated crash mid-command whose resume reports the call interrupted (not re-run). wrangler deve2e: real Anthropic key, chat drives real bash in the sandbox; DO restart resumes the session; workspace files persist.- README row, AGENTS.md scope, SPEC.md section landed.
- A short "running it" section in
deploy/cloudflare/(secrets, deploy, limits, pricing caveat for containers).