From 23b9e02ed8fb1a1c5db26dbad305703eedf91485 Mon Sep 17 00:00:00 2001 From: Justin Davis Date: Sat, 23 May 2026 09:23:57 +1000 Subject: [PATCH 1/2] feat(setup): R2-optional deploys + headless service key generation (CLA-107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two first-run convenience gaps bundled because both live in setup-time ergonomics: * Image storage (R2) is now optional. The wrangler.toml.example ships [[r2_buckets]] commented out, setup.sh prompts the operator, and the worker detects the missing IMAGES binding at runtime — remember_with_image and recall_image are hidden from the MCP tools listing when R2 is absent, and return a clean error if a stale client calls them anyway. This unblocks deploys on truly free CF accounts (R2 is the one stack component that needs billing enabled, even within its 10GB free tier). * setup.sh prompts whether to mint a starter rover service key. If yes, runs the existing keygen subcommand (with new --quiet flag for scripting), captures the raw key + env entry from stdout, pushes the hash to ONEIRO_API_KEYS, and surfaces the raw key in the final summary with a "store now, unrecoverable" warning. The orient hook (CLA-105), rover, and any non-interactive caller now work out of the box from a fresh deploy. Incidental cleanup: removed ONEIRO_ADMIN_KEY generation, display, and secret push from setup.sh + wrangler.toml.example. CLA-103 retired the /restore admin endpoint that was its only consumer; the secret has been dead weight since. CLA-112 (backlog) tracks the deeper fix for multi-key UX: moving the hash list out of the wrangler secret and into a D1 table so add / list / revoke are first-class DB operations. --- scripts/setup.sh | 132 +++++++++++++++++++++++++++++++++++----- src/api_key.rs | 9 +++ src/main.rs | 16 ++++- src/worker_mcp.rs | 137 +++++++++++++++++++++++++----------------- wrangler.toml.example | 20 ++++-- 5 files changed, 236 insertions(+), 78 deletions(-) diff --git a/scripts/setup.sh b/scripts/setup.sh index f0d7e62..30bfbe8 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -258,6 +258,25 @@ fi header "[2/8] Creating Cloudflare resources" +# Image storage (R2) — optional. R2 is the only stack component that +# requires Cloudflare billing to be enabled on the account, even within +# the 10GB free tier. Free-tier deployers can decline this and still +# get a fully working memory system; only image features (the rover +# camera heartbeat, photo memories) need R2. +say "" +say " ${BOLD}Image storage (R2) — optional${RESET}" +dim " Image features (remember_with_image, recall_image, rover camera" +dim " heartbeats) need a Cloudflare R2 bucket. R2 requires CF billing" +dim " enabled on your account, even within its 10GB free tier." +dim " Decline if you're on a billing-free CF account — every non-image" +dim " feature still works." +say "" +prompt ENABLE_R2 "Enable image storage? [y/N]" "n" +case "$ENABLE_R2" in + y|Y|yes|YES) ENABLE_R2="y" ;; + *) ENABLE_R2="n" ;; +esac + # D1 say " Creating D1 database 'oneiro-db'..." if [ "$DRY_RUN" = true ]; then @@ -336,19 +355,36 @@ else fi ok "KV namespace: ONEIRO_VERSION_CACHE (id: ${VERSION_KV_ID})" -# R2 -say " Creating R2 bucket 'oneiro-images'..." -if [ "$DRY_RUN" = true ]; then - dim "[dry-run] wrangler r2 bucket create oneiro-images" -else - if R2_OUT=$(wrangler r2 bucket create oneiro-images 2>&1); then - printf '%s\n' "$R2_OUT" | tail -3 +# R2 — only when the operator opted in above. Skipped builds get no +# IMAGES binding and the worker hides remember_with_image / recall_image +# from the MCP tools listing at runtime. +if [ "$ENABLE_R2" = "y" ]; then + say " Creating R2 bucket 'oneiro-images'..." + if [ "$DRY_RUN" = true ]; then + dim "[dry-run] wrangler r2 bucket create oneiro-images" else - warn "R2 bucket create returned non-zero (likely already exists):" - printf '%s\n' "$R2_OUT" | tail -3 | sed 's/^/ /' + if R2_OUT=$(wrangler r2 bucket create oneiro-images 2>&1); then + printf '%s\n' "$R2_OUT" | tail -3 + else + warn "R2 bucket create returned non-zero (likely already exists):" + printf '%s\n' "$R2_OUT" | tail -3 | sed 's/^/ /' + fi + # The example ships the [[r2_buckets]] block commented out — uncomment + # it now so wrangler deploy picks up the binding. Idempotent: re-running + # setup.sh with R2 already enabled is a no-op for this awk. + awk ' + /^# \[\[r2_buckets\]\]$/ { print "[[r2_buckets]]"; next } + /^# binding = "IMAGES"$/ { print "binding = \"IMAGES\""; next } + /^# bucket_name = "oneiro-images"$/ { print "bucket_name = \"oneiro-images\""; next } + { print } + ' wrangler.toml > wrangler.toml.r2tmp && mv wrangler.toml.r2tmp wrangler.toml fi + ok "R2 bucket: oneiro-images (binding enabled in wrangler.toml)" +else + dim " Skipping R2 — image features will be disabled in this deploy." + dim " To enable later: uncomment [[r2_buckets]] in wrangler.toml," + dim " run 'wrangler r2 bucket create oneiro-images', then redeploy." fi -ok "R2 bucket: oneiro-images" # Patch wrangler.toml with the new IDs. if [ "$DRY_RUN" = true ]; then @@ -454,7 +490,6 @@ header "[4/8] Generating credentials" CLIENT_ID="oneiro-$(openssl rand -hex 4)" CLIENT_SECRET=$(openssl rand -hex 32) -ADMIN_KEY=$(openssl rand -hex 32) cat <`; this just folds the +# first-key path into setup so the orient hook works out of the box. +say "" +say " ${BOLD}Starter service API key — optional${RESET}" +dim " A service key lets the orient hook, the rover, or any other" +dim " non-interactive client authenticate against Oneiro without" +dim " OAuth. You can mint one now or skip and generate later via" +dim " 'cargo run --bin oneiro -- keygen --role rover'." +say "" +prompt MAKE_API_KEY "Generate a starter rover service key now? [y/N]" "n" +case "$MAKE_API_KEY" in + y|Y|yes|YES) MAKE_API_KEY="y" ;; + *) MAKE_API_KEY="n" ;; +esac + +RAW_API_KEY="" +API_KEY_ENTRY="" +if [ "$MAKE_API_KEY" = "y" ]; then + if [ "$DRY_RUN" = true ]; then + dim "[dry-run] cargo run --bin oneiro -- keygen --role rover --quiet" + RAW_API_KEY="mk_rover_dryrunplaceholderrawkeyvalue00" + API_KEY_ENTRY="rover:\$argon2id\$v=19\$m=19456,t=2,p=1\$dryrunsaltvalue\$dryrunhashvalue" + ok "Service key (dry-run synthetic)" + else + say " Building keygen binary (cargo run, first invocation may compile)..." + if KEYGEN_OUT=$(cargo run --quiet --bin oneiro -- keygen --role rover --quiet 2>/dev/null); then + RAW_API_KEY=$(printf '%s\n' "$KEYGEN_OUT" | sed -n '1p') + API_KEY_ENTRY=$(printf '%s\n' "$KEYGEN_OUT" | sed -n '2p') + fi + if [ -z "$RAW_API_KEY" ] || [ -z "$API_KEY_ENTRY" ]; then + err "keygen produced no output — check 'cargo run --bin oneiro -- keygen --role rover'" + exit 1 + fi + ok "Service key generated (role: rover) — raw key shown in final summary" + fi +fi + # ──── Step 5: Claude Code OAuth token ──────────────────────────────── header "[5/8] Claude Code long-lived OAuth token" @@ -516,11 +590,14 @@ push_secret "ONEIRO_OAUTH_CLIENT_ID" "$CLIENT_ID" ok "ONEIRO_OAUTH_CLIENT_ID" push_secret "ONEIRO_OAUTH_CLIENT_SECRET" "$CLIENT_SECRET" ok "ONEIRO_OAUTH_CLIENT_SECRET" -push_secret "ONEIRO_ADMIN_KEY" "$ADMIN_KEY" -ok "ONEIRO_ADMIN_KEY" push_secret "CLAUDE_CODE_OAUTH_TOKEN" "$OAUTH_TOKEN" ok "CLAUDE_CODE_OAUTH_TOKEN" +if [ -n "$API_KEY_ENTRY" ]; then + push_secret "ONEIRO_API_KEYS" "$API_KEY_ENTRY" + ok "ONEIRO_API_KEYS (rover service key hash)" +fi + # Stage 3 dispatcher mode. The worker defaults to dry_run when this is # missing — that's a fail-safe for in-place operator deploys (burn-in # observation period before flipping live). Fresh consumer deployments @@ -569,6 +646,33 @@ ok "Deployed: ${WORKER_URL}" # ──── Final summary ────────────────────────────────────────────────── +# Service key — surfaced AFTER deploy succeeds so the hash on the worker +# matches the raw value we're about to print. Shown first because it's +# unrecoverable from the stored hash; the operator must capture it now. +if [ -n "$RAW_API_KEY" ]; then + cat <'${RESET} + Rover .env / any non-interactive client: + ${DIM}ONEIRO_MCP_TOKEN=${RESET} + +EOF +fi + cat < Result<(), Box> { fn run_keygen(args: &[String]) -> Result<(), Box> { let mut role: Option = None; + let mut quiet = false; let mut i = 0; while i < args.len() { match args[i].as_str() { @@ -1196,10 +1197,17 @@ fn run_keygen(args: &[String]) -> Result<(), Box> { })?); i += 2; } + "--quiet" => { + quiet = true; + i += 1; + } "--help" | "-h" => { - eprintln!("Usage: oneiro keygen --role "); + eprintln!("Usage: oneiro keygen --role [--quiet]"); eprintln!(); eprintln!("Roles: rover"); + eprintln!(); + eprintln!("--quiet Emit only the raw key and env entry to stdout,"); + eprintln!(" one per line. For scripting (e.g. setup.sh)."); return Ok(()); } other => { @@ -1214,7 +1222,11 @@ fn run_keygen(args: &[String]) -> Result<(), Box> { let role = role.ok_or("--role is required (e.g. --role rover)")?; let key = api_key::generate_api_key(role)?; - api_key::print_generated_key(&key); + if quiet { + api_key::print_generated_key_quiet(&key); + } else { + api_key::print_generated_key(&key); + } Ok(()) } diff --git a/src/worker_mcp.rs b/src/worker_mcp.rs index a3c71ef..035a036 100644 --- a/src/worker_mcp.rs +++ b/src/worker_mcp.rs @@ -99,7 +99,7 @@ async fn dispatch(env: &Env, req: &JsonRpcRequest) -> std::result::Result Ok(handle_initialize()), "notifications/initialized" => Ok(Value::Null), - "tools/list" => Ok(handle_tools_list()), + "tools/list" => Ok(handle_tools_list(env)), "tools/call" => handle_tools_call(env, req).await.map_err(|e| { rpc_err(id, INTERNAL_ERROR, format!("Tool dispatch failed: {}", e)) }), @@ -124,8 +124,18 @@ fn handle_initialize() -> Value { }) } -fn handle_tools_list() -> Value { - json!({ +/// Image tools (remember_with_image, recall_image) need an R2 bucket +/// binding. R2 is the one stack component that requires CF billing, so +/// deploys without it skip the binding — and we hide the tools from the +/// MCP listing rather than advertising features that can't work. A model +/// that somehow still calls them gets a clean error from the handlers +/// below. +fn images_available(env: &Env) -> bool { + env.bucket("IMAGES").is_ok() +} + +fn handle_tools_list(env: &Env) -> Value { + let mut listing = json!({ "tools": [ { "name": "recall_orient", @@ -187,52 +197,6 @@ fn handle_tools_list() -> Value { "required": ["content", "summary", "memory_type"] } }, - { - "name": "remember_with_image", - "description": "Store a memory with an attached image. The image bytes (base64) \ - are content-addressed into R2 — duplicate uploads of the same \ - image are deduplicated automatically. Used primarily by the \ - rover heartbeat to record observations with the current camera \ - frame.", - "inputSchema": { - "type": "object", - "properties": { - "content": { "type": "string" }, - "summary": { "type": "string" }, - "memory_type": { - "type": "string", - "enum": ["episodic", "semantic", "orientation"] - }, - "entity": { "type": "string" }, - "tags": { "type": "array", "items": { "type": "string" } }, - "image_base64": { - "type": "string", - "description": "Base64-encoded image bytes (no data: URI prefix)." - }, - "image_mime": { - "type": "string", - "description": "MIME type — image/jpeg, image/png, or image/webp." - } - }, - "required": ["content", "summary", "memory_type", "image_base64", "image_mime"] - } - }, - { - "name": "recall_image", - "description": "Retrieve an image attached to a memory. Takes the memory's id \ - (full UUID or the 8-char prefix shown in recall output). Returns \ - MCP content with the memory's summary text and the image bytes.", - "inputSchema": { - "type": "object", - "properties": { - "memory_id": { - "type": "string", - "description": "Full UUID or 8-char prefix from a prior recall." - } - }, - "required": ["memory_id"] - } - }, { "name": "recall_check", "description": "Lightweight mid-conversation memory lookup. Stricter similarity \ @@ -335,7 +299,63 @@ fn handle_tools_list() -> Value { } } ] - }) + }); + + if images_available(env) { + if let Some(arr) = listing + .get_mut("tools") + .and_then(Value::as_array_mut) + { + arr.push(json!({ + "name": "remember_with_image", + "description": "Store a memory with an attached image. The image bytes (base64) \ + are content-addressed into R2 — duplicate uploads of the same \ + image are deduplicated automatically. Used primarily by the \ + rover heartbeat to record observations with the current camera \ + frame.", + "inputSchema": { + "type": "object", + "properties": { + "content": { "type": "string" }, + "summary": { "type": "string" }, + "memory_type": { + "type": "string", + "enum": ["episodic", "semantic", "orientation"] + }, + "entity": { "type": "string" }, + "tags": { "type": "array", "items": { "type": "string" } }, + "image_base64": { + "type": "string", + "description": "Base64-encoded image bytes (no data: URI prefix)." + }, + "image_mime": { + "type": "string", + "description": "MIME type — image/jpeg, image/png, or image/webp." + } + }, + "required": ["content", "summary", "memory_type", "image_base64", "image_mime"] + } + })); + arr.push(json!({ + "name": "recall_image", + "description": "Retrieve an image attached to a memory. Takes the memory's id \ + (full UUID or the 8-char prefix shown in recall output). Returns \ + MCP content with the memory's summary text and the image bytes.", + "inputSchema": { + "type": "object", + "properties": { + "memory_id": { + "type": "string", + "description": "Full UUID or 8-char prefix from a prior recall." + } + }, + "required": ["memory_id"] + } + })); + } + } + + listing } async fn handle_tools_call( @@ -585,9 +605,12 @@ async fn tool_remember_with_image( .decode(args.image_base64.as_bytes()) .map_err(|e| format!("invalid base64 image: {}", e))?; - let bucket = env - .bucket("IMAGES") - .map_err(|e| format!("IMAGES bucket binding: {:?}", e))?; + let bucket = env.bucket("IMAGES").map_err(|_| { + "Image storage is not configured on this deployment. The IMAGES \ + R2 binding is absent — remember_with_image and recall_image are \ + disabled. Use `remember` (without an image) instead, or enable \ + R2 in wrangler.toml and redeploy.".to_string() + })?; let recorded_by = worker_auth_ctx::current_recorded_by(); @@ -652,9 +675,11 @@ async fn tool_recall_image( }; let mime = memory.image_mime.as_deref().unwrap_or("image/jpeg"); - let bucket = env - .bucket("IMAGES") - .map_err(|e| format!("IMAGES bucket binding: {:?}", e))?; + let bucket = env.bucket("IMAGES").map_err(|_| { + "Image storage is not configured on this deployment. The IMAGES \ + R2 binding is absent — recall_image cannot retrieve attached \ + images. Enable R2 in wrangler.toml and redeploy to access them.".to_string() + })?; let bytes = worker_store::read_image_from_r2(&bucket, hash, mime) .await .map_err(|e| format!("read_image: {:?}", e))? diff --git a/wrangler.toml.example b/wrangler.toml.example index 50190ae..03f85ee 100644 --- a/wrangler.toml.example +++ b/wrangler.toml.example @@ -54,10 +54,20 @@ binding = "AI" # ────────────────────────────────────────────────────────────────────── # R2 — content-addressed image storage. Keys are `{hash}.{ext}`. -# ────────────────────────────────────────────────────────────────────── -[[r2_buckets]] -binding = "IMAGES" -bucket_name = "oneiro-images" +# +# OPTIONAL: image features (remember_with_image, recall_image, rover +# camera heartbeats) need R2. The worker detects the IMAGES binding at +# runtime — if absent, those tools are hidden from the MCP listing and +# every other feature works normally. +# +# R2 is the only stack component that requires CF billing to be enabled +# on the account (even within the 10GB free tier). Comment this block +# in if you want image storage; leave it out for a truly billing-free +# deploy. The setup script will ask which path you want. +# ────────────────────────────────────────────────────────────────────── +# [[r2_buckets]] +# binding = "IMAGES" +# bucket_name = "oneiro-images" # ────────────────────────────────────────────────────────────────────── # KV — OAuth access tokens with TTL. @@ -105,8 +115,6 @@ crons = ["0 14 * * *", "0 8 * * *"] # the subscription credit pool. # ONEIRO_OAUTH_CLIENT_ID — generated by setup script. # ONEIRO_OAUTH_CLIENT_SECRET — generated by setup script. -# ONEIRO_ADMIN_KEY — generated by setup script. -# Used for /admin/import endpoint. # ONEIRO_OAUTH_REDIRECT_URIS — optional; semicolon-separated # allowlist of OAuth redirect URIs # (CLA-91 Fix 2). Default covers From c1753852141cfe2c720dc53e14e01762a051270b Mon Sep 17 00:00:00 2001 From: Justin Davis Date: Sat, 23 May 2026 09:42:24 +1000 Subject: [PATCH 2/2] fix(setup): fail-fast on real R2 errors, only proceed on success or already-exists (CLA-107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #37 review (CodeRabbit): the previous code treated every non-zero exit from `wrangler r2 bucket create` as "likely already exists" and fell through to uncomment the IMAGES binding. That silently masked the failure mode CLA-107 is specifically designed to handle — billing not enabled on the Cloudflare account — by enabling the binding anyway and surfacing the failure two steps later as a confusing wrangler deploy error. Now three outcomes: success → proceed, already-exists (detected by explicit grep on R2_OUT) → proceed with a warning, anything else → print the full R2_OUT, point at the common causes (billing, R2 permissions), exit 1. The binding-uncomment + ok message only fire when create actually succeeded or the bucket was already there. --- scripts/setup.sh | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/scripts/setup.sh b/scripts/setup.sh index 30bfbe8..686b223 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -363,15 +363,29 @@ if [ "$ENABLE_R2" = "y" ]; then if [ "$DRY_RUN" = true ]; then dim "[dry-run] wrangler r2 bucket create oneiro-images" else + # Three outcomes worth distinguishing: success (proceed), bucket + # already exists (proceed, harmless re-run), real failure (stop). + # Treating every non-zero as "already exists" was the bug — billing + # not enabled would silently uncomment the binding and surface as a + # confusing wrangler deploy error two steps later. if R2_OUT=$(wrangler r2 bucket create oneiro-images 2>&1); then printf '%s\n' "$R2_OUT" | tail -3 - else - warn "R2 bucket create returned non-zero (likely already exists):" + elif printf '%s' "$R2_OUT" | grep -qiE 'already exists|bucketalreadyexists'; then + warn "R2 bucket 'oneiro-images' already exists; reusing it." printf '%s\n' "$R2_OUT" | tail -3 | sed 's/^/ /' + else + err "R2 bucket create failed:" + printf '%s\n' "$R2_OUT" | sed 's/^/ /' + say "" + err "Common causes: billing not enabled on the Cloudflare account," + err "or insufficient R2 permissions on the API token. Re-run setup.sh" + err "and decline R2 if you want a billing-free deploy." + exit 1 fi - # The example ships the [[r2_buckets]] block commented out — uncomment - # it now so wrangler deploy picks up the binding. Idempotent: re-running - # setup.sh with R2 already enabled is a no-op for this awk. + # Either created or already-exists: enable the binding. The example + # ships [[r2_buckets]] commented out so wrangler deploy ignores it on + # a no-R2 deploy. Idempotent: a second run with R2 already enabled + # is a no-op (the `# ` prefix lines won't match). awk ' /^# \[\[r2_buckets\]\]$/ { print "[[r2_buckets]]"; next } /^# binding = "IMAGES"$/ { print "binding = \"IMAGES\""; next }