Files
rhema-cloudflare-code/specs/rhema-cloudflare-code.md
T
vhaudiquet c1059c23e0 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.
2026-11-13 12:05:30 +01:00

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-agnostic SseDecoder in src/stream.rs, which consumes byte slices)
  • rhema-transcript
  • rhema-rsd, rhema-autonomous-agent, rhema-memory, rhema-config with --no-default-features (excludes the sqlite feature)

Does not compile to wasm, with exact reasons:

  • rhema-agent-tools (and transitively rhema-subagents): crates/rhema-agent-tools/src/shell.rs:137 hardcodes tokio::process::Command::new("bash"), and the crate enables tokio's process/time features; mio does not build on wasm.
  • rhema-provider-anthropic, two reqwest-on-wasm issues: (a) connect_timeout does 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] requires Send futures.

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 to ProviderError::Other("network error: ...") (that prefix is what makes them retryable).
  • rhema-provider/src/retry.rs — Retrying decorator: 3 attempts, 1 s base / 2 s cap, never retries once an event was emitted, retries an empty success. Sleeps via tokio::time (tests use start_paused; see retry.rs:320).
  • rhema-agent-tools/src/shell.rs — ShellTools: the ONLY tool is bash ("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 from pending: 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 one ShellTools for the agent lifetime, installs the per-turn approval hook, runs revive_dangling_calls on 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 via std::fs at agent.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> }; ReplaySafety per 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). ExecRequest carries command, cwd, timeout (echo the 120 s default), cancellation. ShellError maps transport failure so the bash tool result stays an in-band is_error result, never an abort.
  • shell — ShellSandbox: the bash tool resolver (or the sandbox-side ShellExecutor impl for Phase 1's seam) exposing the SAME spec as ShellTools::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 Sandbox impl for offline tests (house pattern: rhema-core's mock feature), so the full turn loop tests host-side with rhema-core's MockProvider.
  • 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.rs into 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> } where Executed = stdout tail, stderr tail, exit code. ShellTools keeps: the spec, the approval gate, caps, timeout, error-shaping; it delegates execution.
  • Local impl = today's tokio::process code verbatim, behind a default-on feature (e.g. local-shell). The crate's tokio process/time features 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-features is clean; native tests green (cargo test -p rhema-agent-tools); existing ShellTools behavior unchanged (caps, timeout-kill, line-streaming, approval).

Phase 2 — session/skills/prompt inputs in rhema-coding-agent (small)

  • Sessions: the cloud crate serializes Session itself; if the fs helpers at agent.rs:233/238 are the only consumers, no seam is needed — confirm and note it.
  • Skills/system prompt: stamped_system discovers skills from the filesystem internally. Add an input path that accepts an already-collected skill list (keep the fs discovery as the default used by rhema-code), so the cloud crate can feed skills from DO storage or discovered inside the sandbox. SYSTEM.md base is 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 Transport trait to rhema-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 via spawn_local + channel (or async-compat).
  • rhema-provider-anthropic: put reqwest behind a default-on native feature; take Arc<dyn Transport>; move connect_timeout and the "network error: " mapping into the native impl; keep the (HTTP <status>): error shape; the bytes_stream while-loop becomes transport-agnostic over the shared SseDecoder.
  • rhema-provider/src/retry.rs: make Retrying's sleep injectable (additive builder, e.g. with_sleeper), default = today's tokio behavior so existing tests stay green. The cloud crate passes a worker::Delay sleeper.
  • Accept: cargo check --workspace --all-features green natively; wasm check green for rhema-provider and 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: sandbox trait + 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 worker module: DO class CodingAgent — WS chat surface (stream Events 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_id per turn, ToolContext.cancelled wired to a disconnect/stop message; alarm reserved 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 use rhema-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_classes migration, DO + sandbox namespace bindings, secrets docs), the optional JS shim, README notes — sibling of docker/ and helm/.
  • Add the wasm gate to CI (scope ci): cargo check over the cloud closure with --target wasm32-unknown-unknown (the full crate with the worker feature 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 scope cloudflare-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-features green. Conventional commits with component scopes, linear history (rebase, no merge commits).
  • Do not weaken #![forbid(unsafe_code)] / #![deny(missing_docs)] anywhere except the sanctioned worker module exception (documented in §3).
  • No wasm-bindgen, fetch, or worker dependencies in rhema-core, rhema-coding-agent, rhema-agent-tools; none in the platform-neutral modules of rhema-cloudflare-code. The platform lives at the edges.
  • rhema-core keeps 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 in rhema-rsd, never in the engine.
  • Keep the single bash spec 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)

  1. 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).
  2. Sandbox idle-sleep model and workspace persistence guarantees — map onto ReplaySafety explicitly: a command killed by container sleep/eviction is the committed-unanswered-call case; bash is Unsafe to replay, so resume must report it interrupted (existing machinery — verify the wiring reaches it).
  3. Current workers-rs DO support level: DurableObject trait shape, WS hibernation, SQLite (SqlStorage) bindings — pin the version accordingly.
  4. 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 dev e2e: 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).