From 12036474907833de1d427b13f63c797ae50713cd Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Sat, 2 May 2026 16:23:08 -0700 Subject: [PATCH 1/7] runtime/claude_code: materialize images to tmpfile so the CLI's Read tool can view them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds image materialization in the claude_code driver: inbound image ContentBlocks are written to $HOME/.openfang/tmp/images/ so the Claude CLI can view them via its Read tool (it cannot fetch URLs or read in-memory bytes). Also introduces `ContentBlock::Image::source_url: Option` in openfang-types and threads it through every consumer: - openfang-channels/bridge.rs (sets source_url when downloading from URL) - openfang-api/openai_compat.rs + routes.rs (serde round-trip) - openfang-runtime drivers: anthropic, gemini, openai, vertex (pattern match exhaustiveness) - openfang-runtime: agent_loop, compactor (pattern match exhaustiveness) The `source_url` field is the load-bearing schema change for downstream work: later commits in this series consume it for cache lookup, and outbound Discord (separate PR) uses it to round-trip image URLs back to Discord without re-uploading bytes. No driver reads it on this branch yet — it is wired through serde and pattern matches only. --- crates/openfang-api/src/openai_compat.rs | 6 +- crates/openfang-api/src/routes.rs | 2 + crates/openfang-channels/src/bridge.rs | 72 ++++- crates/openfang-runtime/src/agent_loop.rs | 1 + crates/openfang-runtime/src/compactor.rs | 1 + .../openfang-runtime/src/drivers/anthropic.rs | 2 +- .../src/drivers/claude_code.rs | 259 +++++++++++++++++- crates/openfang-runtime/src/drivers/gemini.rs | 2 +- crates/openfang-runtime/src/drivers/openai.rs | 2 +- crates/openfang-runtime/src/drivers/vertex.rs | 2 +- crates/openfang-types/src/message.rs | 8 + 11 files changed, 347 insertions(+), 10 deletions(-) diff --git a/crates/openfang-api/src/openai_compat.rs b/crates/openfang-api/src/openai_compat.rs index d5d1cebd85..b66a05b229 100644 --- a/crates/openfang-api/src/openai_compat.rs +++ b/crates/openfang-api/src/openai_compat.rs @@ -216,7 +216,11 @@ fn convert_messages(oai_messages: &[OaiMessage]) -> Vec { .unwrap_or(parts[0]) .to_string(); let data = parts[1].to_string(); - Some(ContentBlock::Image { media_type, data }) + Some(ContentBlock::Image { + media_type, + data, + source_url: None, + }) } else { None } diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index 2336777775..7b07686c8c 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -280,6 +280,7 @@ pub fn resolve_attachments( blocks.push(openfang_types::message::ContentBlock::Image { media_type: content_type, data: b64, + source_url: None, }); } Err(e) => { @@ -512,6 +513,7 @@ pub async fn get_agent_session( openfang_types::message::ContentBlock::Image { media_type, data, + .. } => { texts.push("[Image]".to_string()); // Persist image to upload dir so it can be diff --git a/crates/openfang-channels/src/bridge.rs b/crates/openfang-channels/src/bridge.rs index a1aecae623..efcc917fb5 100644 --- a/crates/openfang-channels/src/bridge.rs +++ b/crates/openfang-channels/src/bridge.rs @@ -1453,7 +1453,14 @@ async fn download_image_to_blocks(url: &str, caption: Option<&str>) -> Vec assert_eq!(text, "hello"), + other => panic!("expected text caption block, got {other:?}"), + } + match &blocks[1] { + ContentBlock::Image { + source_url, + media_type, + .. + } => { + assert_eq!( + source_url.as_deref(), + Some(url.as_str()), + "source_url must round-trip the fetched URL" + ); + assert_eq!(media_type, "image/png"); + } + other => panic!("expected image block, got {other:?}"), + } + } } diff --git a/crates/openfang-runtime/src/agent_loop.rs b/crates/openfang-runtime/src/agent_loop.rs index 7f5f05edb5..b62118dc73 100644 --- a/crates/openfang-runtime/src/agent_loop.rs +++ b/crates/openfang-runtime/src/agent_loop.rs @@ -3327,6 +3327,7 @@ mod tests { ContentBlock::Image { media_type: "image/png".to_string(), data: "aGVsbG8=".to_string(), + source_url: None, } } diff --git a/crates/openfang-runtime/src/compactor.rs b/crates/openfang-runtime/src/compactor.rs index fef90c8154..c85bde85e8 100644 --- a/crates/openfang-runtime/src/compactor.rs +++ b/crates/openfang-runtime/src/compactor.rs @@ -1266,6 +1266,7 @@ mod tests { content: MessageContent::Blocks(vec![ContentBlock::Image { media_type: "image/png".to_string(), data: "base64data".to_string(), + source_url: None, }]), }, ]; diff --git a/crates/openfang-runtime/src/drivers/anthropic.rs b/crates/openfang-runtime/src/drivers/anthropic.rs index e6f10fe58e..9ca8a135fb 100644 --- a/crates/openfang-runtime/src/drivers/anthropic.rs +++ b/crates/openfang-runtime/src/drivers/anthropic.rs @@ -664,7 +664,7 @@ fn convert_message(msg: &Message) -> ApiMessage { ContentBlock::Text { text, .. } => { Some(ApiContentBlock::Text { text: text.clone() }) } - ContentBlock::Image { media_type, data } => Some(ApiContentBlock::Image { + ContentBlock::Image { media_type, data, .. } => Some(ApiContentBlock::Image { source: ApiImageSource { source_type: "base64".to_string(), media_type: media_type.clone(), diff --git a/crates/openfang-runtime/src/drivers/claude_code.rs b/crates/openfang-runtime/src/drivers/claude_code.rs index 21e0fb6d1b..9d4cd1c5fa 100644 --- a/crates/openfang-runtime/src/drivers/claude_code.rs +++ b/crates/openfang-runtime/src/drivers/claude_code.rs @@ -10,10 +10,14 @@ use crate::llm_driver::{CompletionRequest, CompletionResponse, LlmDriver, LlmError, StreamEvent}; use async_trait::async_trait; +use base64::Engine; use dashmap::DashMap; -use openfang_types::message::{ContentBlock, Role, StopReason, TokenUsage}; +use openfang_types::message::{ContentBlock, MessageContent, Role, StopReason, TokenUsage}; use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::sync::Once; use tokio::io::{AsyncBufReadExt, AsyncReadExt}; use tracing::{debug, info, warn}; @@ -52,6 +56,110 @@ const SENSITIVE_SUFFIXES: &[&str] = &["_SECRET", "_TOKEN", "_PASSWORD"]; /// Default subprocess timeout in seconds (5 minutes). const DEFAULT_MESSAGE_TIMEOUT_SECS: u64 = 300; +/// TTL for materialized image tmpfiles (24 hours). Files older than this +/// are swept on driver init. +const IMAGE_TMP_TTL_SECS: u64 = 24 * 60 * 60; + +/// One-shot guard so the TTL sweep only fires once per process. +static IMAGE_TMP_SWEEP_ONCE: Once = Once::new(); + +/// Resolve the directory used for materializing image attachments. +/// +/// Lives under `$HOME/.openfang/tmp/images` so it travels with the OpenFang +/// install. Falls back to the OS temp dir when `$HOME` isn't set (which +/// shouldn't happen in our deployed daemon but is handled defensively). +fn image_tmp_dir() -> PathBuf { + if let Ok(home) = std::env::var("HOME") { + let mut p = PathBuf::from(home); + p.push(".openfang"); + p.push("tmp"); + p.push("images"); + p + } else { + let mut p = std::env::temp_dir(); + p.push("openfang-images"); + p + } +} + +/// Map a MIME type to a sensible filename extension. +fn ext_for_mime(media_type: &str) -> &'static str { + match media_type.to_ascii_lowercase().as_str() { + "image/png" => "png", + "image/jpeg" | "image/jpg" => "jpg", + "image/gif" => "gif", + "image/webp" => "webp", + "image/heic" => "heic", + "image/heif" => "heif", + "image/bmp" => "bmp", + "image/svg+xml" => "svg", + _ => "bin", + } +} + +/// Decode the base64 image and write it to a content-addressed file under +/// `dir`. Idempotent: if a file with the same content hash already exists, +/// the existing path is returned without rewriting. Returns `None` on +/// decode or I/O failure (caller falls back to the textual placeholder). +fn materialize_image(media_type: &str, data: &str, dir: &Path) -> Option { + let bytes = base64::engine::general_purpose::STANDARD + .decode(data.as_bytes()) + .ok()?; + let mut hasher = Sha256::new(); + hasher.update(&bytes); + let hash = hasher.finalize(); + let hex: String = hash.iter().take(16).map(|b| format!("{:02x}", b)).collect(); + let filename = format!("{hex}.{ext}", ext = ext_for_mime(media_type)); + let path = dir.join(filename); + if path.exists() { + return Some(path); + } + if let Err(e) = std::fs::create_dir_all(dir) { + warn!(dir = ?dir, error = %e, "failed to create claude_code image tmp dir"); + return None; + } + if let Err(e) = std::fs::write(&path, &bytes) { + warn!(path = ?path, error = %e, "failed to write claude_code image tmpfile"); + return None; + } + Some(path) +} + +/// Delete image tmpfiles older than [`IMAGE_TMP_TTL_SECS`]. Best-effort: +/// any error is logged at debug and the sweep moves on. +fn sweep_old_image_tmpfiles(dir: &Path) { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(e) => { + debug!(dir = ?dir, error = %e, "image tmp sweep: read_dir failed (likely missing dir, fine)"); + return; + } + }; + let now = std::time::SystemTime::now(); + let ttl = std::time::Duration::from_secs(IMAGE_TMP_TTL_SECS); + let mut removed = 0u32; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(meta) = entry.metadata() else { continue }; + if !meta.is_file() { + continue; + } + let Ok(modified) = meta.modified() else { continue }; + if let Ok(age) = now.duration_since(modified) { + if age > ttl { + if let Err(e) = std::fs::remove_file(&path) { + debug!(path = ?path, error = %e, "image tmp sweep: remove failed"); + } else { + removed += 1; + } + } + } + } + if removed > 0 { + info!(removed, "swept stale claude_code image tmpfiles"); + } +} + /// LLM driver that delegates to the Claude Code CLI. pub struct ClaudeCodeDriver { cli_path: String, @@ -78,6 +186,12 @@ impl ClaudeCodeDriver { ); } + // Best-effort sweep of stale image tmpfiles, once per process. + IMAGE_TMP_SWEEP_ONCE.call_once(|| { + let dir = image_tmp_dir(); + std::thread::spawn(move || sweep_old_image_tmpfiles(&dir)); + }); + Self { cli_path: cli_path .filter(|s| !s.is_empty()) @@ -131,6 +245,7 @@ impl ClaudeCodeDriver { /// Build a text prompt from the completion request messages. fn build_prompt(request: &CompletionRequest) -> String { + let tmp_dir = image_tmp_dir(); let mut parts = Vec::new(); for msg in &request.messages { @@ -139,15 +254,74 @@ impl ClaudeCodeDriver { Role::Assistant => "Assistant", Role::System => "System", }; - let text = msg.content.text_content(); - if !text.is_empty() { - parts.push(format!("[{role_label}]\n{text}")); + let rendered = Self::render_content(&msg.content, Some(&tmp_dir)); + if !rendered.is_empty() { + parts.push(format!("[{role_label}]\n{rendered}")); } } parts.join("\n\n") } + /// Render message content for the text-only CLI prompt. + /// + /// Text blocks pass through verbatim. Image blocks are materialized to + /// an on-disk tmpfile (when `image_dir` is provided) so the model can + /// view them via the CLI's `Read` tool — Claude Code is multimodal and + /// will load the file as native image content. We render a directive + /// telling the model exactly which path to read, plus the original + /// `source_url` (e.g. Discord CDN) when known. If materialization + /// fails or `image_dir` is `None` (test path), we fall back to the + /// legacy textual placeholder so the model at least knows an + /// attachment arrived. ToolUse/ToolResult/Thinking are omitted — + /// the CLI manages its own tool loop. + fn render_content(content: &MessageContent, image_dir: Option<&Path>) -> String { + match content { + MessageContent::Text(s) => s.clone(), + MessageContent::Blocks(blocks) => blocks + .iter() + .filter_map(|b| match b { + ContentBlock::Text { text, .. } => { + if text.is_empty() { + None + } else { + Some(text.clone()) + } + } + ContentBlock::Image { + media_type, + data, + source_url, + } => { + // base64 → ~3/4 the length in decoded bytes. + let approx_kb = (data.len().saturating_mul(3) / 4) / 1024; + let url_suffix = match source_url { + Some(u) => format!(" (original: {u})"), + None => String::new(), + }; + if let Some(dir) = image_dir { + if let Some(path) = materialize_image(&media_type, &data, dir) { + return Some(format!( + "[attachment: {media_type} image, ~{approx_kb} KB — view with the Read tool at {path}{url_suffix}]", + path = path.display() + )); + } + } + // Fallback: at least surface the URL if we have one. + Some(format!( + "[attachment: {media_type} image, ~{approx_kb} KB — not viewable on this provider{url_suffix}]" + )) + } + ContentBlock::ToolUse { .. } + | ContentBlock::ToolResult { .. } + | ContentBlock::Thinking { .. } + | ContentBlock::Unknown => None, + }) + .collect::>() + .join("\n"), + } + } + /// Map a model ID like "claude-code/opus" to CLI --model flag value. fn model_flag(model: &str) -> Option { let stripped = model.strip_prefix("claude-code/").unwrap_or(model); @@ -726,6 +900,83 @@ mod tests { assert!(prompt.contains("Hello")); } + #[test] + fn test_build_prompt_renders_image_attachment_marker() { + use openfang_types::message::{ContentBlock, Message, MessageContent}; + + // ~12 KB of base64 — decoded ~9 KB. + let fake_b64 = "A".repeat(12 * 1024); + let request = CompletionRequest { + model: "claude-code/sonnet".to_string(), + messages: vec![Message { + role: Role::User, + content: MessageContent::Blocks(vec![ + ContentBlock::Text { + text: "what's in this?".to_string(), + provider_metadata: None, + }, + ContentBlock::Image { + media_type: "image/png".to_string(), + data: fake_b64, + source_url: None, + }, + ]), + }], + tools: vec![], + max_tokens: 1024, + temperature: 0.7, + system: None, + thinking: None, + }; + + let prompt = ClaudeCodeDriver::build_prompt(&request); + assert!(prompt.contains("what's in this?"), "text preserved"); + assert!( + prompt.contains("[attachment: image/png image"), + "image rendered as synthetic attachment marker, got: {prompt}" + ); + // Either materialized to a tmpfile (preferred) or fell back to + // the legacy "not viewable" placeholder. Both are acceptable + // outcomes for this test; we just need the marker to be emitted. + assert!( + prompt.contains("view with the Read tool at") + || prompt.contains("not viewable on this provider"), + "marker either points at a tmpfile or explains the limitation, got: {prompt}" + ); + } + + #[test] + fn test_build_prompt_image_only_still_emits_marker() { + use openfang_types::message::{ContentBlock, Message, MessageContent}; + + let request = CompletionRequest { + model: "claude-code/sonnet".to_string(), + messages: vec![Message { + role: Role::User, + content: MessageContent::Blocks(vec![ContentBlock::Image { + media_type: "image/jpeg".to_string(), + data: "Zm9v".to_string(), + source_url: Some("https://cdn.example/foo.jpg".to_string()), + }]), + }], + tools: vec![], + max_tokens: 1024, + temperature: 0.7, + system: None, + thinking: None, + }; + + let prompt = ClaudeCodeDriver::build_prompt(&request); + assert!( + prompt.contains("[User]"), + "user role label emitted even with image-only content, got: {prompt}" + ); + assert!( + prompt.contains("[attachment: image/jpeg image"), + "bare image renders marker, got: {prompt}" + ); + } + #[test] fn test_model_flag_mapping() { assert_eq!( diff --git a/crates/openfang-runtime/src/drivers/gemini.rs b/crates/openfang-runtime/src/drivers/gemini.rs index 9efc617b98..f03aeb3540 100644 --- a/crates/openfang-runtime/src/drivers/gemini.rs +++ b/crates/openfang-runtime/src/drivers/gemini.rs @@ -298,7 +298,7 @@ fn convert_messages( thought_signature, }); } - ContentBlock::Image { media_type, data } => { + ContentBlock::Image { media_type, data, .. } => { parts.push(GeminiPart::InlineData { inline_data: GeminiInlineData { mime_type: media_type.clone(), diff --git a/crates/openfang-runtime/src/drivers/openai.rs b/crates/openfang-runtime/src/drivers/openai.rs index 554e14b537..dac3c24b76 100644 --- a/crates/openfang-runtime/src/drivers/openai.rs +++ b/crates/openfang-runtime/src/drivers/openai.rs @@ -491,7 +491,7 @@ impl LlmDriver for OpenAIDriver { ContentBlock::Text { text, .. } => { parts.push(OaiContentPart::Text { text: text.clone() }); } - ContentBlock::Image { media_type, data } => { + ContentBlock::Image { media_type, data, .. } => { parts.push(OaiContentPart::ImageUrl { image_url: OaiImageUrl { url: format!("data:{media_type};base64,{data}"), diff --git a/crates/openfang-runtime/src/drivers/vertex.rs b/crates/openfang-runtime/src/drivers/vertex.rs index 9f04841634..58d3705c6c 100644 --- a/crates/openfang-runtime/src/drivers/vertex.rs +++ b/crates/openfang-runtime/src/drivers/vertex.rs @@ -356,7 +356,7 @@ fn convert_messages( }, }); } - ContentBlock::Image { media_type, data } => { + ContentBlock::Image { media_type, data, .. } => { parts.push(VertexPart::InlineData { inline_data: VertexInlineData { mime_type: media_type.clone(), diff --git a/crates/openfang-types/src/message.rs b/crates/openfang-types/src/message.rs index 67955a445d..be8afbb208 100644 --- a/crates/openfang-types/src/message.rs +++ b/crates/openfang-types/src/message.rs @@ -55,6 +55,12 @@ pub enum ContentBlock { media_type: String, /// Base64-encoded image data. data: String, + /// Original source URL (e.g. Discord CDN), if the image was + /// materialized from a remote attachment. Preserved alongside + /// `data` so text-only drivers can reference the URL and + /// vision-capable drivers retain it for diagnostics. + #[serde(default, skip_serializing_if = "Option::is_none")] + source_url: Option, }, /// A tool use request from the assistant. #[serde(rename = "tool_use")] @@ -325,6 +331,7 @@ mod tests { let block = ContentBlock::Image { media_type: "image/png".to_string(), data: "base64data".to_string(), + source_url: None, }; let json = serde_json::to_value(&block).unwrap(); assert_eq!(json["type"], "image"); @@ -461,6 +468,7 @@ mod tests { ContentBlock::Image { media_type: "image/jpeg".to_string(), data: "base64data".to_string(), + source_url: None, }, ]; let msg = Message::user_with_blocks(blocks); From 021c6df284533c766cd275c9403d1a4589bf91b7 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Sat, 2 May 2026 17:09:20 -0700 Subject: [PATCH 2/7] fix(runtime/claude_code): atomically publish materialized image tmpfiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `materialize_image` previously wrote bytes directly to the content-addressed destination via `fs::write`, which truncates-then-writes non-atomically. Two concurrent renders of the same image (or a re-render racing a Read tool invocation) could produce a torn, partially-written file readable by the CLI's Read tool — a real risk under load now that file-sharing is a first-class feature. Switch to write-tmp + rename(2): write the decoded bytes to a unique sibling tmpfile (suffixed with pid + nanos), then atomically rename into the content-addressed destination. rename(2) is atomic on the same filesystem, so readers either see the full file or nothing. Loser of a race still rename-replaces with byte-identical content. Orphan .tmp files from crashed processes are reaped by the existing 24h TTL sweep (mtime-based). --- .../src/drivers/claude_code.rs | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/openfang-runtime/src/drivers/claude_code.rs b/crates/openfang-runtime/src/drivers/claude_code.rs index 9d4cd1c5fa..1609c7b366 100644 --- a/crates/openfang-runtime/src/drivers/claude_code.rs +++ b/crates/openfang-runtime/src/drivers/claude_code.rs @@ -118,8 +118,28 @@ fn materialize_image(media_type: &str, data: &str, dir: &Path) -> Option Date: Sat, 2 May 2026 17:09:51 -0700 Subject: [PATCH 3/7] fix(runtime/claude_code): pass --add-dir for materialized image tmpfiles The CLI's Read tool refuses paths outside the agent's working directory unless explicitly granted via --add-dir (or unless --dangerously-skip-permissions is set, which we don't want to rely on as the only escape hatch). Materialized images live under $HOME/.openfang/tmp/images/, which is outside the agent workspace, so without --add-dir the materialization is a dead-end whenever skip_permissions is false. Append --add-dir to both the non-streaming and streaming Command builders. The directory is per-user and content-addressed, so the grant is narrow and idempotent. --- crates/openfang-runtime/src/drivers/claude_code.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/crates/openfang-runtime/src/drivers/claude_code.rs b/crates/openfang-runtime/src/drivers/claude_code.rs index 1609c7b366..26be9abdd5 100644 --- a/crates/openfang-runtime/src/drivers/claude_code.rs +++ b/crates/openfang-runtime/src/drivers/claude_code.rs @@ -474,6 +474,14 @@ impl LlmDriver for ClaudeCodeDriver { cmd.arg("--model").arg(model); } + // Grant the CLI's Read tool access to our image tmp dir, which lives + // outside the agent's workspace cwd. Without --add-dir the CLI would + // refuse Read on `$HOME/.openfang/tmp/images/*` (unless + // --dangerously-skip-permissions is set) and the materialization would + // be a dead-end. Cheap and idempotent — the dir is per-user and + // content-addressed. + cmd.arg("--add-dir").arg(image_tmp_dir()); + Self::apply_env_filter(&mut cmd); // Inject HOME so the CLI can find its credentials (~/.claude/) when @@ -670,6 +678,9 @@ impl LlmDriver for ClaudeCodeDriver { cmd.arg("--model").arg(model); } + // Same image-tmp-dir grant as the non-streaming path; see complete(). + cmd.arg("--add-dir").arg(image_tmp_dir()); + Self::apply_env_filter(&mut cmd); // Same HOME and stdin hygiene as the non-streaming path. From c93318f937df457e7bdb0bbb1cdc28410ebc1c9f Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Sat, 2 May 2026 17:12:17 -0700 Subject: [PATCH 4/7] refactor(runtime): extract image_cache module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lift the content-addressed image tmpfile cache out of the Claude Code driver and into a sibling module so the upcoming outbound Discord file-sharing path can reuse the same cache. No behavior change — the extracted helpers (image_tmp_dir, ext_for_mime, materialize_image, sweep_old_image_tmpfiles, IMAGE_TMP_TTL_SECS, sweep guard) are byte-identical to the previous private impl. The driver now imports image_tmp_dir / materialize_image / spawn_sweep_once from crate::image_cache. The new module is publicly exported so producers outside the runtime crate (channel adapters) can resolve materialized paths from base64 image blocks before forwarding them to outbound transports. --- .../src/drivers/claude_code.rs | 138 +-------------- crates/openfang-runtime/src/image_cache.rs | 160 ++++++++++++++++++ crates/openfang-runtime/src/lib.rs | 1 + 3 files changed, 168 insertions(+), 131 deletions(-) create mode 100644 crates/openfang-runtime/src/image_cache.rs diff --git a/crates/openfang-runtime/src/drivers/claude_code.rs b/crates/openfang-runtime/src/drivers/claude_code.rs index 26be9abdd5..7241dfee69 100644 --- a/crates/openfang-runtime/src/drivers/claude_code.rs +++ b/crates/openfang-runtime/src/drivers/claude_code.rs @@ -8,16 +8,14 @@ //! Tracks active subprocess PIDs and enforces message timeouts to prevent //! hung CLI processes from blocking agents indefinitely. +use crate::image_cache::{image_tmp_dir, materialize_image, spawn_sweep_once}; use crate::llm_driver::{CompletionRequest, CompletionResponse, LlmDriver, LlmError, StreamEvent}; use async_trait::async_trait; -use base64::Engine; use dashmap::DashMap; use openfang_types::message::{ContentBlock, MessageContent, Role, StopReason, TokenUsage}; use serde::Deserialize; -use sha2::{Digest, Sha256}; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::Arc; -use std::sync::Once; use tokio::io::{AsyncBufReadExt, AsyncReadExt}; use tracing::{debug, info, warn}; @@ -56,129 +54,10 @@ const SENSITIVE_SUFFIXES: &[&str] = &["_SECRET", "_TOKEN", "_PASSWORD"]; /// Default subprocess timeout in seconds (5 minutes). const DEFAULT_MESSAGE_TIMEOUT_SECS: u64 = 300; -/// TTL for materialized image tmpfiles (24 hours). Files older than this -/// are swept on driver init. -const IMAGE_TMP_TTL_SECS: u64 = 24 * 60 * 60; - -/// One-shot guard so the TTL sweep only fires once per process. -static IMAGE_TMP_SWEEP_ONCE: Once = Once::new(); - -/// Resolve the directory used for materializing image attachments. -/// -/// Lives under `$HOME/.openfang/tmp/images` so it travels with the OpenFang -/// install. Falls back to the OS temp dir when `$HOME` isn't set (which -/// shouldn't happen in our deployed daemon but is handled defensively). -fn image_tmp_dir() -> PathBuf { - if let Ok(home) = std::env::var("HOME") { - let mut p = PathBuf::from(home); - p.push(".openfang"); - p.push("tmp"); - p.push("images"); - p - } else { - let mut p = std::env::temp_dir(); - p.push("openfang-images"); - p - } -} - -/// Map a MIME type to a sensible filename extension. -fn ext_for_mime(media_type: &str) -> &'static str { - match media_type.to_ascii_lowercase().as_str() { - "image/png" => "png", - "image/jpeg" | "image/jpg" => "jpg", - "image/gif" => "gif", - "image/webp" => "webp", - "image/heic" => "heic", - "image/heif" => "heif", - "image/bmp" => "bmp", - "image/svg+xml" => "svg", - _ => "bin", - } -} - -/// Decode the base64 image and write it to a content-addressed file under -/// `dir`. Idempotent: if a file with the same content hash already exists, -/// the existing path is returned without rewriting. Returns `None` on -/// decode or I/O failure (caller falls back to the textual placeholder). -fn materialize_image(media_type: &str, data: &str, dir: &Path) -> Option { - let bytes = base64::engine::general_purpose::STANDARD - .decode(data.as_bytes()) - .ok()?; - let mut hasher = Sha256::new(); - hasher.update(&bytes); - let hash = hasher.finalize(); - let hex: String = hash.iter().take(16).map(|b| format!("{:02x}", b)).collect(); - let filename = format!("{hex}.{ext}", ext = ext_for_mime(media_type)); - let path = dir.join(filename); - if path.exists() { - return Some(path); - } - if let Err(e) = std::fs::create_dir_all(dir) { - warn!(dir = ?dir, error = %e, "failed to create claude_code image tmp dir"); - return None; - } - // Atomic publish: write to a unique tmp sibling, then rename into place. - // Two concurrent renders of the same image each write their own tmpfile; - // the rename(2) is atomic on the same filesystem, so the Read tool never - // sees a torn or partially-written file. If the destination already exists - // by the time we rename (loser of a race), the rename still succeeds - // (POSIX replaces) — and the contents are identical anyway by construction. - let tmp_path = dir.join(format!( - "{hex}.{pid}.{nanos}.tmp", - pid = std::process::id(), - nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0), - )); - if let Err(e) = std::fs::write(&tmp_path, &bytes) { - warn!(path = ?tmp_path, error = %e, "failed to write claude_code image tmpfile"); - return None; - } - if let Err(e) = std::fs::rename(&tmp_path, &path) { - warn!(from = ?tmp_path, to = ?path, error = %e, "failed to rename claude_code image tmpfile into place"); - // Best-effort cleanup of the orphan tmpfile. - let _ = std::fs::remove_file(&tmp_path); - return None; - } - Some(path) -} - -/// Delete image tmpfiles older than [`IMAGE_TMP_TTL_SECS`]. Best-effort: -/// any error is logged at debug and the sweep moves on. -fn sweep_old_image_tmpfiles(dir: &Path) { - let entries = match std::fs::read_dir(dir) { - Ok(e) => e, - Err(e) => { - debug!(dir = ?dir, error = %e, "image tmp sweep: read_dir failed (likely missing dir, fine)"); - return; - } - }; - let now = std::time::SystemTime::now(); - let ttl = std::time::Duration::from_secs(IMAGE_TMP_TTL_SECS); - let mut removed = 0u32; - for entry in entries.flatten() { - let path = entry.path(); - let Ok(meta) = entry.metadata() else { continue }; - if !meta.is_file() { - continue; - } - let Ok(modified) = meta.modified() else { continue }; - if let Ok(age) = now.duration_since(modified) { - if age > ttl { - if let Err(e) = std::fs::remove_file(&path) { - debug!(path = ?path, error = %e, "image tmp sweep: remove failed"); - } else { - removed += 1; - } - } - } - } - if removed > 0 { - info!(removed, "swept stale claude_code image tmpfiles"); - } -} +// Image materialization helpers (image_tmp_dir, ext_for_mime, +// materialize_image, sweep_old_image_tmpfiles, TTL constants, sweep guard) +// live in crate::image_cache so the outbound file-sharing path can reuse +// the same content-addressed cache without a circular dep on this driver. /// LLM driver that delegates to the Claude Code CLI. pub struct ClaudeCodeDriver { @@ -207,10 +86,7 @@ impl ClaudeCodeDriver { } // Best-effort sweep of stale image tmpfiles, once per process. - IMAGE_TMP_SWEEP_ONCE.call_once(|| { - let dir = image_tmp_dir(); - std::thread::spawn(move || sweep_old_image_tmpfiles(&dir)); - }); + spawn_sweep_once(); Self { cli_path: cli_path diff --git a/crates/openfang-runtime/src/image_cache.rs b/crates/openfang-runtime/src/image_cache.rs new file mode 100644 index 0000000000..f6e998449d --- /dev/null +++ b/crates/openfang-runtime/src/image_cache.rs @@ -0,0 +1,160 @@ +//! Content-addressed image tmpfile cache. +//! +//! Decodes base64 image payloads (the `ContentBlock::Image` shape used by +//! all LLM drivers) and writes them to a content-addressed file under +//! `$HOME/.openfang/tmp/images/` so out-of-process consumers — initially +//! the Claude Code CLI's Read tool, soon the outbound Discord bridge — +//! can reach the bytes by path. +//! +//! Originally lived inside `drivers/claude_code.rs`; lifted here so the +//! outbound file-sharing path can reuse the same cache without a circular +//! dep on the driver crate. Behavior is byte-identical to the previous +//! private implementation. +//! +//! Properties: +//! - **Idempotent.** Filename is the first 64 bits of SHA-256(bytes), so +//! re-rendering the same image hits the cache. +//! - **Atomic publish.** Bytes are written to a unique sibling tmpfile +//! then `rename(2)`-d into place; readers never see a torn file. +//! - **Time-bounded.** A best-effort sweep on first call (per process) +//! removes files older than [`IMAGE_TMP_TTL_SECS`]. + +use base64::Engine; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; +use std::sync::Once; +use tracing::{debug, info, warn}; + +/// TTL for materialized image tmpfiles (24 hours). Files older than this +/// are swept on first use. +pub const IMAGE_TMP_TTL_SECS: u64 = 24 * 60 * 60; + +/// One-shot guard so the TTL sweep only fires once per process. +static IMAGE_TMP_SWEEP_ONCE: Once = Once::new(); + +/// Resolve the directory used for materializing image attachments. +/// +/// Lives under `$HOME/.openfang/tmp/images` so it travels with the OpenFang +/// install. Falls back to the OS temp dir when `$HOME` isn't set (which +/// shouldn't happen in our deployed daemon but is handled defensively). +pub fn image_tmp_dir() -> PathBuf { + if let Ok(home) = std::env::var("HOME") { + let mut p = PathBuf::from(home); + p.push(".openfang"); + p.push("tmp"); + p.push("images"); + p + } else { + let mut p = std::env::temp_dir(); + p.push("openfang-images"); + p + } +} + +/// Map a MIME type to a sensible filename extension. +pub fn ext_for_mime(media_type: &str) -> &'static str { + match media_type.to_ascii_lowercase().as_str() { + "image/png" => "png", + "image/jpeg" | "image/jpg" => "jpg", + "image/gif" => "gif", + "image/webp" => "webp", + "image/heic" => "heic", + "image/heif" => "heif", + "image/bmp" => "bmp", + "image/svg+xml" => "svg", + _ => "bin", + } +} + +/// Decode the base64 image and write it to a content-addressed file under +/// `dir`. Idempotent: if a file with the same content hash already exists, +/// the existing path is returned without rewriting. Returns `None` on +/// decode or I/O failure (caller falls back to a textual placeholder). +pub fn materialize_image(media_type: &str, data: &str, dir: &Path) -> Option { + let bytes = base64::engine::general_purpose::STANDARD + .decode(data.as_bytes()) + .ok()?; + let mut hasher = Sha256::new(); + hasher.update(&bytes); + let hash = hasher.finalize(); + let hex: String = hash.iter().take(16).map(|b| format!("{:02x}", b)).collect(); + let filename = format!("{hex}.{ext}", ext = ext_for_mime(media_type)); + let path = dir.join(filename); + if path.exists() { + return Some(path); + } + if let Err(e) = std::fs::create_dir_all(dir) { + warn!(dir = ?dir, error = %e, "failed to create openfang image tmp dir"); + return None; + } + // Atomic publish: write to a unique tmp sibling, then rename into place. + // Two concurrent renders of the same image each write their own tmpfile; + // the rename(2) is atomic on the same filesystem, so consumers never see + // a torn or partially-written file. If the destination already exists by + // the time we rename (loser of a race), the rename still succeeds (POSIX + // replaces) — and the contents are identical anyway by construction. + let tmp_path = dir.join(format!( + "{hex}.{pid}.{nanos}.tmp", + pid = std::process::id(), + nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0), + )); + if let Err(e) = std::fs::write(&tmp_path, &bytes) { + warn!(path = ?tmp_path, error = %e, "failed to write openfang image tmpfile"); + return None; + } + if let Err(e) = std::fs::rename(&tmp_path, &path) { + warn!(from = ?tmp_path, to = ?path, error = %e, "failed to rename openfang image tmpfile into place"); + // Best-effort cleanup of the orphan tmpfile. + let _ = std::fs::remove_file(&tmp_path); + return None; + } + Some(path) +} + +/// Delete image tmpfiles older than [`IMAGE_TMP_TTL_SECS`]. Best-effort: +/// any error is logged at debug and the sweep moves on. +pub fn sweep_old_image_tmpfiles(dir: &Path) { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(e) => { + debug!(dir = ?dir, error = %e, "image tmp sweep: read_dir failed (likely missing dir, fine)"); + return; + } + }; + let now = std::time::SystemTime::now(); + let ttl = std::time::Duration::from_secs(IMAGE_TMP_TTL_SECS); + let mut removed = 0u32; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(meta) = entry.metadata() else { continue }; + if !meta.is_file() { + continue; + } + let Ok(modified) = meta.modified() else { continue }; + if let Ok(age) = now.duration_since(modified) { + if age > ttl { + if let Err(e) = std::fs::remove_file(&path) { + debug!(path = ?path, error = %e, "image tmp sweep: remove failed"); + } else { + removed += 1; + } + } + } + } + if removed > 0 { + info!(removed, "swept stale openfang image tmpfiles"); + } +} + +/// Spawn the once-per-process TTL sweep in a background thread. Safe to +/// call from any number of driver inits — the [`Once`] guard ensures only +/// the first call does work. +pub fn spawn_sweep_once() { + IMAGE_TMP_SWEEP_ONCE.call_once(|| { + let dir = image_tmp_dir(); + std::thread::spawn(move || sweep_old_image_tmpfiles(&dir)); + }); +} diff --git a/crates/openfang-runtime/src/lib.rs b/crates/openfang-runtime/src/lib.rs index bde54ab199..8793601b11 100644 --- a/crates/openfang-runtime/src/lib.rs +++ b/crates/openfang-runtime/src/lib.rs @@ -25,6 +25,7 @@ pub mod embedding; pub mod graceful_shutdown; pub mod hooks; pub mod host_functions; +pub mod image_cache; pub mod image_gen; pub mod kernel_handle; pub mod link_understanding; From 3313acfbb4b2727cbfabf8a7d7414a9051e6a628 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Sun, 3 May 2026 00:26:52 -0700 Subject: [PATCH 5/7] fix(runtime/image_cache): refresh mtime on cache hit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `materialize_image` is content-addressed and idempotent: a re-render of the same image returns the existing path without rewriting. As a side effect, the tmpfile's mtime never advanced past its original write — so the 24h TTL sweep, which gates on `meta.modified()`, could GC a tmpfile still actively referenced by an in-scope ContentBlock::Image in a long-running conversation. Refresh mtime via `File::set_modified(SystemTime::now())` (futimens on Unix) on every cache hit. Read-only fd is sufficient: futimens only requires file ownership, not write access. Best-effort: any failure is debug-logged and the cached path is returned anyway — worst case is the prior 24h-GC behavior. Tests: cache-hit refreshes mtime and survives a sweep that would otherwise GC the file; companion test confirms the sweep does remove genuinely stale files. --- crates/openfang-runtime/src/image_cache.rs | 105 +++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/crates/openfang-runtime/src/image_cache.rs b/crates/openfang-runtime/src/image_cache.rs index f6e998449d..1cda8bc719 100644 --- a/crates/openfang-runtime/src/image_cache.rs +++ b/crates/openfang-runtime/src/image_cache.rs @@ -81,6 +81,17 @@ pub fn materialize_image(media_type: &str, data: &str, dir: &Path) -> Option Option std::io::Result<()> { + let f = std::fs::File::open(path)?; + f.set_modified(std::time::SystemTime::now()) +} + /// Delete image tmpfiles older than [`IMAGE_TMP_TTL_SECS`]. Best-effort: /// any error is logged at debug and the sweep moves on. pub fn sweep_old_image_tmpfiles(dir: &Path) { @@ -158,3 +179,87 @@ pub fn spawn_sweep_once() { std::thread::spawn(move || sweep_old_image_tmpfiles(&dir)); }); } + +#[cfg(test)] +mod tests { + use super::*; + use base64::Engine; + use std::time::{Duration, SystemTime}; + + /// A 1×1 transparent PNG, base64-encoded. Tiny enough to keep tests fast. + const TINY_PNG_B64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; + + #[test] + fn materialize_image_refreshes_mtime_on_cache_hit() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + + // First call materializes. + let path = materialize_image("image/png", TINY_PNG_B64, dir) + .expect("first materialization should succeed"); + assert!(path.exists()); + + // Backdate mtime to ~25 hours ago — past IMAGE_TMP_TTL_SECS. + let stale = SystemTime::now() - Duration::from_secs(IMAGE_TMP_TTL_SECS + 3600); + let f = std::fs::File::open(&path).unwrap(); + f.set_modified(stale).unwrap(); + drop(f); + let mtime_before = std::fs::metadata(&path).unwrap().modified().unwrap(); + + // Second call should hit cache AND refresh mtime. + let path2 = materialize_image("image/png", TINY_PNG_B64, dir) + .expect("cache hit should return Some"); + assert_eq!(path, path2); + let mtime_after = std::fs::metadata(&path).unwrap().modified().unwrap(); + assert!( + mtime_after > mtime_before, + "mtime should be refreshed on cache hit (before={mtime_before:?}, after={mtime_after:?})" + ); + + // And the now-touched file must NOT be GC'd by a sweep that + // would have caught the stale mtime. + sweep_old_image_tmpfiles(dir); + assert!( + path.exists(), + "refreshed tmpfile should survive the TTL sweep" + ); + } + + #[test] + fn sweep_removes_stale_tmpfiles() { + // Sanity check that the sweep actually GCs old files — pairs with + // the test above to prove the refresh is what saves the file. + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path(); + let path = materialize_image("image/png", TINY_PNG_B64, dir).unwrap(); + + let stale = SystemTime::now() - Duration::from_secs(IMAGE_TMP_TTL_SECS + 3600); + let f = std::fs::File::open(&path).unwrap(); + f.set_modified(stale).unwrap(); + drop(f); + + sweep_old_image_tmpfiles(dir); + assert!(!path.exists(), "stale tmpfile should have been swept"); + } + + #[test] + fn ext_for_mime_known_and_unknown() { + assert_eq!(ext_for_mime("image/png"), "png"); + assert_eq!(ext_for_mime("IMAGE/JPEG"), "jpg"); + assert_eq!(ext_for_mime("image/webp"), "webp"); + assert_eq!(ext_for_mime("application/octet-stream"), "bin"); + } + + #[test] + fn materialize_image_rejects_invalid_base64() { + let tmp = tempfile::tempdir().unwrap(); + assert!(materialize_image("image/png", "!!!not-base64!!!", tmp.path()).is_none()); + } + + // Force-reference base64 engine to keep imports tidy in case someone + // refactors and the const is the only consumer. + #[allow(dead_code)] + fn _b64_compile_check() { + let _ = base64::engine::general_purpose::STANDARD.decode(TINY_PNG_B64); + } +} From e8de324de202ecc0cac52fee70b243b2ee26ed09 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Sun, 3 May 2026 00:25:30 -0700 Subject: [PATCH 6/7] fix(runtime/compactor): preserve http(s) source_url across compaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ContentBlock::Image was being stringified to `[Image: {mime}]`, silently dropping the `source_url` populated by the inbound Discord path. That field exists so the outbound path (PR-C) can re-fetch the original CDN-hosted image and re-attach it post-compaction — without it, every compaction event quietly severed an image from its CDN origin. Emit `[Image: {mime} @ {url}]` when `source_url` is `http://` or `https://`. `file://` (local tmpfile materialization) and any other schemes fall back to the legacy mime-only form: those are internal and must not leak into compacted summaries that may be persisted, logged, or shipped across processes. Tests cover all four arms (https, http, file://, None). --- crates/openfang-runtime/src/compactor.rs | 96 +++++++++++++++++++++++- 1 file changed, 94 insertions(+), 2 deletions(-) diff --git a/crates/openfang-runtime/src/compactor.rs b/crates/openfang-runtime/src/compactor.rs index c85bde85e8..c502c0db4f 100644 --- a/crates/openfang-runtime/src/compactor.rs +++ b/crates/openfang-runtime/src/compactor.rs @@ -400,8 +400,31 @@ fn build_conversation_text(messages: &[Message], config: &CompactionConfig) -> S conversation_text .push_str(&format!("[Tool result ({status}): {preview}]\n\n")); } - ContentBlock::Image { media_type, .. } => { - conversation_text.push_str(&format!("[Image: {media_type}]\n\n")); + ContentBlock::Image { + media_type, + source_url, + .. + } => { + // Preserve the original CDN URL across compaction so the + // outbound Discord path (PR-C) can re-attach the image by + // re-fetching it. Only http(s) URLs are exposed: local + // `file://` tmpfile paths are an internal materialization + // detail and shouldn't leak into compacted summaries that + // may be persisted, logged, or sent across processes. + match source_url.as_deref() { + Some(url) + if url.starts_with("http://") + || url.starts_with("https://") => + { + conversation_text.push_str(&format!( + "[Image: {media_type} @ {url}]\n\n" + )); + } + _ => { + conversation_text + .push_str(&format!("[Image: {media_type}]\n\n")); + } + } } ContentBlock::Thinking { .. } => {} ContentBlock::Unknown => {} @@ -1279,6 +1302,75 @@ mod tests { assert!(text.contains("[Image: image/png]")); } + #[test] + fn test_build_conversation_text_image_source_url_https() { + // https:// CDN URL is exposed post-compaction so the outbound path + // can re-fetch the image. + let config = CompactionConfig::default(); + let messages = vec![Message { + role: Role::User, + content: MessageContent::Blocks(vec![ContentBlock::Image { + media_type: "image/png".to_string(), + data: "base64data".to_string(), + source_url: Some("https://cdn.discordapp.com/attachments/x/y.png".to_string()), + }]), + }]; + let text = build_conversation_text(&messages, &config); + assert!( + text.contains("[Image: image/png @ https://cdn.discordapp.com/attachments/x/y.png]"), + "https source_url should be preserved, got: {text}" + ); + } + + #[test] + fn test_build_conversation_text_image_source_url_http() { + // Plain http (rare but valid) is also exposed. + let config = CompactionConfig::default(); + let messages = vec![Message { + role: Role::User, + content: MessageContent::Blocks(vec![ContentBlock::Image { + media_type: "image/jpeg".to_string(), + data: "base64data".to_string(), + source_url: Some("http://example.com/foo.jpg".to_string()), + }]), + }]; + let text = build_conversation_text(&messages, &config); + assert!( + text.contains("[Image: image/jpeg @ http://example.com/foo.jpg]"), + "http source_url should be preserved, got: {text}" + ); + } + + #[test] + fn test_build_conversation_text_image_source_url_file_falls_back() { + // file:// URLs (local tmpfile materialization) MUST NOT leak into + // compacted summaries — fall back to the legacy mime-only form. + let config = CompactionConfig::default(); + let messages = vec![Message { + role: Role::User, + content: MessageContent::Blocks(vec![ContentBlock::Image { + media_type: "image/png".to_string(), + data: "base64data".to_string(), + source_url: Some( + "file:///Users/x/.openfang/tmp/images/abc.png".to_string(), + ), + }]), + }]; + let text = build_conversation_text(&messages, &config); + assert!( + text.contains("[Image: image/png]"), + "file:// source_url should fall back to legacy form, got: {text}" + ); + assert!( + !text.contains("file://"), + "file:// path must not leak post-compaction, got: {text}" + ); + assert!( + !text.contains(".openfang"), + "local tmpfile path must not leak post-compaction, got: {text}" + ); + } + #[test] fn test_build_conversation_text_truncates_oversized() { let config = CompactionConfig { From a13171d6b10d361b2004a38793055504a8849b63 Mon Sep 17 00:00:00 2001 From: Ben Hoverter <32376575+benhoverter@users.noreply.github.com> Date: Sun, 3 May 2026 23:21:34 -0700 Subject: [PATCH 7/7] fix(runtime/claude_code): drop needless borrows on materialize_image args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rust 1.94 / clippy 1.94 (CI runner image 20260413.86.1) flags the `&media_type` and `&data` borrows at claude_code.rs:199 as `needless_borrow` — both are already `&String` from destructuring `ContentBlock::Image` by reference, and `materialize_image` takes `&str` / `&[u8]`, so the compiler was re-dereferencing immediately. Pure toolchain-drift fix; no behavior change. cargo test -p openfang-runtime --lib → 958/958 green. --- crates/openfang-runtime/src/drivers/claude_code.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/openfang-runtime/src/drivers/claude_code.rs b/crates/openfang-runtime/src/drivers/claude_code.rs index 7241dfee69..91b160d5b4 100644 --- a/crates/openfang-runtime/src/drivers/claude_code.rs +++ b/crates/openfang-runtime/src/drivers/claude_code.rs @@ -196,7 +196,7 @@ impl ClaudeCodeDriver { None => String::new(), }; if let Some(dir) = image_dir { - if let Some(path) = materialize_image(&media_type, &data, dir) { + if let Some(path) = materialize_image(media_type, data, dir) { return Some(format!( "[attachment: {media_type} image, ~{approx_kb} KB — view with the Read tool at {path}{url_suffix}]", path = path.display()