//! Claude Code bridge for sandboxed execution. //! //! Spawns the `claude` CLI inside a Docker container and streams its NDJSON //! output back to the orchestrator via HTTP. Supports follow-up prompts via //! `--resume`. //! //! Security model: the Docker container is the primary security boundary //! (cap-drop ALL, non-root user, memory limits, network isolation). //! As defense-in-depth, a project-level `.claude/settings.json` is written //! before spawning with an explicit tool allowlist. Only listed tools are //! auto-approved; unknown/future tools would require interactive approval, //! which times out harmlessly in the non-interactive container. //! //! ```text //! ┌──────────────────────────────────────────────┐ //! │ Docker Container │ //! │ │ //! │ ironclaw claude-bridge --job-id │ //! │ └─ writes /workspace/.claude/settings.json │ //! │ └─ claude -p "task" --output-format │ //! │ stream-json │ //! │ └─ reads stdout line-by-line │ //! │ └─ POSTs events to orchestrator │ //! │ └─ polls for follow-up prompts │ //! │ └─ on follow-up: claude --resume │ //! └──────────────────────────────────────────────┘ //! ``` use std::sync::Arc; use std::time::Duration; use serde::{Deserialize, Serialize}; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command; use uuid::Uuid; use crate::error::WorkerError; use crate::worker::api::{CompletionReport, JobEventPayload, PromptResponse, WorkerHttpClient}; /// Configuration for the Claude bridge runtime. pub struct ClaudeBridgeConfig { pub job_id: Uuid, pub orchestrator_url: String, pub max_turns: u32, pub model: String, pub timeout: Duration, /// Tool patterns to auto-approve via project-level settings.json. pub allowed_tools: Vec, } /// A Claude Code streaming event (NDJSON line from `--output-format stream-json`). /// /// Claude Code emits one JSON object per line with these top-level types: /// /// system -> session init (session_id, tools, model) /// assistant -> LLM response, nested under message.content[] as text/tool_use blocks /// user -> tool results, nested under message.content[] as tool_result blocks /// result -> final summary (is_error, duration_ms, num_turns, result text) /// /// Content blocks live under `message.content`, NOT at the top level. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ClaudeStreamEvent { #[serde(rename = "type")] pub event_type: String, #[serde(default)] pub session_id: Option, #[serde(default)] pub subtype: Option, /// For `assistant` and `user` events: the message wrapper containing content blocks. #[serde(default)] pub message: Option, /// For `result` events: the final text output. #[serde(default)] pub result: Option, /// For `result` events: whether the session ended in error. #[serde(default)] pub is_error: Option, /// For `result` events: total wall-clock duration. #[serde(default)] pub duration_ms: Option, /// For `result` events: number of agentic turns used. #[serde(default)] pub num_turns: Option, } /// Wrapper around the `message` field in assistant/user events. /// /// ```text /// { "type": "assistant", "message": { "content": [ { "type": "text", ... } ] } } /// ``` #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MessageWrapper { #[serde(default)] pub role: Option, #[serde(default)] pub content: Option>, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ContentBlock { #[serde(rename = "type")] pub block_type: String, /// Text block content. #[serde(default)] pub text: Option, /// Tool name (for tool_use blocks). #[serde(default)] pub name: Option, /// Tool use ID (for tool_use and tool_result blocks). #[serde(default)] pub id: Option, /// Tool input params (for tool_use blocks). #[serde(default)] pub input: Option, /// Tool result content (for tool_result blocks), or general content. #[serde(default)] pub content: Option, /// Tool use ID reference (for tool_result blocks). #[serde(default)] pub tool_use_id: Option, } /// The Claude Code bridge runtime. pub struct ClaudeBridgeRuntime { config: ClaudeBridgeConfig, client: Arc, } impl ClaudeBridgeRuntime { /// Create a new bridge runtime. /// /// Reads `IRONCLAW_WORKER_TOKEN` from the environment for auth. pub fn new(config: ClaudeBridgeConfig) -> Result { let client = Arc::new(WorkerHttpClient::from_env( config.orchestrator_url.clone(), config.job_id, )?); Ok(Self { config, client }) } /// Write project-level `.claude/settings.json` with the tool allowlist. /// /// This replaces `--dangerously-skip-permissions` with an explicit set of /// auto-approved tools. The Docker container is still the primary security /// boundary; this is defense-in-depth. fn write_permission_settings(&self) -> Result<(), WorkerError> { let settings_json = build_permission_settings(&self.config.allowed_tools); let settings_dir = std::path::Path::new("/workspace/.claude"); std::fs::create_dir_all(settings_dir).map_err(|e| WorkerError::ExecutionFailed { reason: format!("failed to create /workspace/.claude/: {e}"), })?; std::fs::write(settings_dir.join("settings.json"), &settings_json).map_err(|e| { WorkerError::ExecutionFailed { reason: format!("failed to write settings.json: {e}"), } })?; tracing::info!( job_id = %self.config.job_id, tools = ?self.config.allowed_tools, "Wrote Claude Code permission settings" ); Ok(()) } /// Copy auth files from a read-only source into the writable home dir. /// /// If the orchestrator bind-mounts the host's `~/.claude` at /// `/home/sandbox/.claude-host:ro`, this copies everything into the /// container's own `/home/sandbox/.claude` so Claude Code can read auth /// credentials AND write its state (todos, debug files, etc.) without /// touching the host filesystem. /// /// When no host mount is present (the default orchestrator injects /// credentials via environment variables), this is a no-op. fn copy_auth_from_mount(&self) -> Result<(), WorkerError> { let mount = std::path::Path::new("/home/sandbox/.claude-host"); if !mount.exists() { return Ok(()); } let target = std::path::Path::new("/home/sandbox/.claude"); std::fs::create_dir_all(target).map_err(|e| WorkerError::ExecutionFailed { reason: format!("failed to create ~/.claude: {e}"), })?; let copied = copy_dir_recursive(mount, target).map_err(|e| WorkerError::ExecutionFailed { reason: format!("failed to copy auth from host mount: {e}"), })?; tracing::info!( job_id = %self.config.job_id, files_copied = copied, "Copied auth config from host mount into container" ); Ok(()) } /// Run the bridge: fetch job, spawn claude, stream events, handle follow-ups. pub async fn run(&self) -> Result<(), WorkerError> { // Copy auth files from read-only host mount (if present) into the // writable home directory before Claude Code needs them. self.copy_auth_from_mount()?; // Write project-level settings with explicit tool allowlist. // This replaces --dangerously-skip-permissions with defense-in-depth: // only the listed tools are auto-approved, unknown tools fail safely. self.write_permission_settings()?; // Fetch the job description from the orchestrator let job = self.client.get_job().await?; tracing::info!( job_id = %self.config.job_id, "Starting Claude Code bridge for: {}", truncate(&job.description, 100) ); // Fetch credentials for injection into the spawned Command via .envs() // (avoids unsafe std::env::set_var in multi-threaded runtime). let credentials = self.client.fetch_credentials().await?; let mut extra_env = std::collections::HashMap::new(); for cred in &credentials { extra_env.insert(cred.env_var.clone(), cred.value.clone()); } if !extra_env.is_empty() { tracing::info!( job_id = %self.config.job_id, "Fetched {} credential(s) for child process injection", extra_env.len() ); } // Warn if no auth method is available (check both process env and fetched credentials). let has_api_key = extra_env.contains_key("ANTHROPIC_API_KEY") || std::env::var("ANTHROPIC_API_KEY").is_ok(); let has_oauth = extra_env.contains_key("CLAUDE_CODE_OAUTH_TOKEN") || std::env::var("CLAUDE_CODE_OAUTH_TOKEN").is_ok(); if !has_api_key && !has_oauth { tracing::warn!( job_id = %self.config.job_id, "No Claude Code auth available. Set ANTHROPIC_API_KEY or run \ `claude login` on the host to authenticate." ); } // Report that we're running self.client .report_status(&crate::worker::api::StatusUpdate { state: "running".to_string(), message: Some("Spawning Claude Code".to_string()), iteration: 0, }) .await?; // Run the initial Claude session let session_id = match self .run_claude_session(&job.description, None, &extra_env) .await { Ok(sid) => sid, Err(e) => { tracing::error!(job_id = %self.config.job_id, "Claude session failed: {}", e); self.client .report_complete(&CompletionReport { success: false, message: Some(format!("Claude Code failed: {}", e)), iterations: 1, }) .await?; return Ok(()); } }; // Follow-up loop: poll for prompts, resume Claude sessions let mut iteration = 1u32; loop { // Poll for a follow-up prompt (2 second intervals) match self.poll_for_prompt().await { Ok(Some(prompt)) => { if prompt.done { tracing::info!(job_id = %self.config.job_id, "Orchestrator signaled done"); break; } iteration += 1; tracing::info!( job_id = %self.config.job_id, "Got follow-up prompt, resuming session" ); if let Err(e) = self .run_claude_session(&prompt.content, session_id.as_deref(), &extra_env) .await { tracing::error!( job_id = %self.config.job_id, "Follow-up Claude session failed: {}", e ); // Don't fail the whole job on a follow-up error, just report it self.report_event( "status", &serde_json::json!({ "message": format!("Follow-up session failed: {}", e), }), ) .await; } } Ok(None) => { // No prompt available, wait and poll again tokio::time::sleep(Duration::from_secs(2)).await; } Err(e) => { tracing::warn!( job_id = %self.config.job_id, "Prompt polling error: {}", e ); tokio::time::sleep(Duration::from_secs(5)).await; } } } self.client .report_complete(&CompletionReport { success: true, message: Some("Claude Code session completed".to_string()), iterations: iteration, }) .await?; Ok(()) } /// Spawn a `claude` CLI process and stream its output. /// /// Returns the session_id if captured from the `system` init message. async fn run_claude_session( &self, prompt: &str, resume_session_id: Option<&str>, extra_env: &std::collections::HashMap, ) -> Result, WorkerError> { let mut cmd = Command::new("claude"); cmd.arg("-p") .arg(prompt) .arg("--output-format") .arg("stream-json") .arg("--verbose") .arg("--max-turns") .arg(self.config.max_turns.to_string()) .arg("--model") .arg(&self.config.model); if let Some(sid) = resume_session_id { cmd.arg("--resume").arg(sid); } // Inject credentials into the child process environment without // mutating the global process env (which is unsafe in multi-threaded programs). cmd.envs(extra_env); cmd.current_dir("/workspace") .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()); let mut child = cmd.spawn().map_err(|e| WorkerError::ExecutionFailed { reason: format!("failed to spawn claude: {}", e), })?; let stdout = child .stdout .take() .ok_or_else(|| WorkerError::ExecutionFailed { reason: "failed to capture claude stdout".to_string(), })?; let stderr = child .stderr .take() .ok_or_else(|| WorkerError::ExecutionFailed { reason: "failed to capture claude stderr".to_string(), })?; // Spawn stderr reader that forwards lines as log events let client_for_stderr = Arc::clone(&self.client); let job_id = self.config.job_id; let stderr_handle = tokio::spawn(async move { let reader = BufReader::new(stderr); let mut lines = reader.lines(); while let Ok(Some(line)) = lines.next_line().await { tracing::debug!(job_id = %job_id, "claude stderr: {}", line); let payload = JobEventPayload { event_type: "status".to_string(), data: serde_json::json!({ "message": line }), }; client_for_stderr.post_event(&payload).await; } }); // Read stdout NDJSON line by line let reader = BufReader::new(stdout); let mut lines = reader.lines(); let mut session_id: Option = None; while let Ok(Some(line)) = lines.next_line().await { let line = line.trim().to_string(); if line.is_empty() { continue; } match serde_json::from_str::(&line) { Ok(event) => { // Capture session_id from system init if event.event_type == "system" && let Some(ref sid) = event.session_id { session_id = Some(sid.clone()); tracing::info!( job_id = %self.config.job_id, session_id = %sid, "Captured Claude session ID" ); } // Convert to our event payload and forward let payloads = stream_event_to_payloads(&event); for payload in payloads { self.report_event(&payload.event_type, &payload.data).await; } } Err(e) => { // Not valid JSON, forward as a status message tracing::debug!( job_id = %self.config.job_id, "Non-JSON claude output: {} (parse error: {})", line, e ); self.report_event("status", &serde_json::json!({ "message": line })) .await; } } } // Wait for the process to exit let status = child .wait() .await .map_err(|e| WorkerError::ExecutionFailed { reason: format!("failed waiting for claude: {}", e), })?; // Wait for stderr reader to finish let _ = stderr_handle.await; if !status.success() { let code = status.code().unwrap_or(-1); tracing::warn!( job_id = %self.config.job_id, exit_code = code, "Claude process exited with non-zero status" ); // Report result event self.report_event( "result", &serde_json::json!({ "status": "error", "exit_code": code, "session_id": session_id, }), ) .await; return Err(WorkerError::ExecutionFailed { reason: format!("claude exited with code {}", code), }); } // Report successful result self.report_event( "result", &serde_json::json!({ "status": "completed", "session_id": session_id, }), ) .await; Ok(session_id) } /// Post a job event to the orchestrator. async fn report_event(&self, event_type: &str, data: &serde_json::Value) { let payload = JobEventPayload { event_type: event_type.to_string(), data: data.clone(), }; self.client.post_event(&payload).await; } /// Poll the orchestrator for a follow-up prompt. async fn poll_for_prompt(&self) -> Result, WorkerError> { self.client.poll_prompt().await } } /// Build the JSON content for `.claude/settings.json` with the given tool allowlist. /// /// Produces a Claude Code project settings file that auto-approves the listed /// tools while leaving any unknown/future tools unapproved (defense-in-depth). fn build_permission_settings(allowed_tools: &[String]) -> String { let settings = serde_json::json!({ "permissions": { "allow": allowed_tools, } }); serde_json::to_string_pretty(&settings).expect("static JSON structure is always valid") } /// Convert a Claude stream event into one or more event payloads for the orchestrator. fn stream_event_to_payloads(event: &ClaudeStreamEvent) -> Vec { let mut payloads = Vec::new(); // Helper: extract content blocks from message wrapper. let blocks = event.message.as_ref().and_then(|m| m.content.as_ref()); match event.event_type.as_str() { "system" => { payloads.push(JobEventPayload { event_type: "status".to_string(), data: serde_json::json!({ "message": "Claude Code session started", "session_id": event.session_id, }), }); } "assistant" => { // Content blocks are nested under message.content[]. if let Some(blocks) = blocks { for block in blocks { match block.block_type.as_str() { "text" => { if let Some(ref text) = block.text.as_deref().filter(|t| !t.is_empty()) { payloads.push(JobEventPayload { event_type: "message".to_string(), data: serde_json::json!({ "role": "assistant", "content": text, }), }); } } "tool_use" => { payloads.push(JobEventPayload { event_type: "tool_use".to_string(), data: serde_json::json!({ "tool_name": block.name, "tool_use_id": block.id, "input": block.input, }), }); } _ => {} } } } } "user" => { // User events carry tool_result blocks under message.content[]. if let Some(blocks) = blocks { for block in blocks { if block.block_type == "tool_result" { payloads.push(JobEventPayload { event_type: "tool_result".to_string(), data: serde_json::json!({ "tool_use_id": block.tool_use_id, "output": block.content, }), }); } } } } "result" => { let is_error = event.is_error.unwrap_or(false); // Emit the final review text as a message so it appears in activity. if let Some(text) = event .result .as_ref() .and_then(|v| v.as_str()) .filter(|t| !t.is_empty()) { payloads.push(JobEventPayload { event_type: "message".to_string(), data: serde_json::json!({ "role": "assistant", "content": text, }), }); } payloads.push(JobEventPayload { event_type: "result".to_string(), data: serde_json::json!({ "status": if is_error { "error" } else { "completed" }, "session_id": event.session_id, "duration_ms": event.duration_ms, "num_turns": event.num_turns, }), }); } _ => { // Forward unknown event types as status payloads.push(JobEventPayload { event_type: "status".to_string(), data: serde_json::json!({ "message": format!("Claude event: {}", event.event_type), "raw_type": event.event_type, }), }); } } payloads } /// Recursively copy files and directories from `src` to `dst`, skipping /// entries that can't be read (e.g. permission-restricted files owned by a /// different uid on a read-only bind mount). Returns the number of files /// successfully copied. fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result { let entries = match std::fs::read_dir(src) { Ok(e) => e, Err(e) => { tracing::debug!("Skipping unreadable directory {}: {}", src.display(), e); return Ok(0); } }; let mut copied = 0; for entry in entries { let entry = match entry { Ok(e) => e, Err(e) => { tracing::debug!("Skipping unreadable entry in {}: {}", src.display(), e); continue; } }; let src_path = entry.path(); let dst_path = dst.join(entry.file_name()); let file_type = match entry.file_type() { Ok(ft) => ft, Err(e) => { tracing::debug!( "Skipping entry with unreadable type {}: {}", src_path.display(), e ); continue; } }; // Skip symlinks to avoid following links outside the mount. if file_type.is_symlink() { tracing::debug!("Skipping symlink {}", src_path.display()); continue; } if file_type.is_dir() { if std::fs::create_dir_all(&dst_path).is_ok() { copied += copy_dir_recursive(&src_path, &dst_path)?; } } else { match std::fs::copy(&src_path, &dst_path) { Ok(_) => copied += 1, Err(e) => { tracing::debug!("Skipping unreadable file {}: {}", src_path.display(), e); } } } } Ok(copied) } fn truncate(s: &str, max_len: usize) -> &str { if s.len() <= max_len { s } else { // Walk back from max_len to find a valid UTF-8 char boundary. let mut end = max_len; while end > 0 && !s.is_char_boundary(end) { end -= 1; } &s[..end] } } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_system_event() { let json = r#"{"type":"system","session_id":"abc-123","subtype":"init"}"#; let event: ClaudeStreamEvent = serde_json::from_str(json).unwrap(); assert_eq!(event.event_type, "system"); assert_eq!(event.session_id.as_deref(), Some("abc-123")); } #[test] fn test_parse_assistant_text_event() { // Real Claude Code format: content blocks are under message.content let json = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello world"}]}}"#; let event: ClaudeStreamEvent = serde_json::from_str(json).unwrap(); assert_eq!(event.event_type, "assistant"); let blocks = event.message.unwrap().content.unwrap(); assert_eq!(blocks.len(), 1); assert_eq!(blocks[0].block_type, "text"); assert_eq!(blocks[0].text.as_deref(), Some("Hello world")); } #[test] fn test_parse_assistant_tool_use_event() { let json = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_01abc","name":"Bash","input":{"command":"ls"}}]}}"#; let event: ClaudeStreamEvent = serde_json::from_str(json).unwrap(); let blocks = event.message.unwrap().content.unwrap(); assert_eq!(blocks[0].block_type, "tool_use"); assert_eq!(blocks[0].name.as_deref(), Some("Bash")); assert_eq!(blocks[0].id.as_deref(), Some("toolu_01abc")); assert!(blocks[0].input.is_some()); } #[test] fn test_parse_user_tool_result_event() { let json = r#"{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_01abc","content":"/workspace"}]}}"#; let event: ClaudeStreamEvent = serde_json::from_str(json).unwrap(); assert_eq!(event.event_type, "user"); let blocks = event.message.unwrap().content.unwrap(); assert_eq!(blocks[0].block_type, "tool_result"); assert_eq!(blocks[0].tool_use_id.as_deref(), Some("toolu_01abc")); } #[test] fn test_parse_result_event() { let json = r#"{"type":"result","subtype":"success","is_error":false,"duration_ms":5000,"num_turns":3,"result":"Done.","session_id":"sid-1"}"#; let event: ClaudeStreamEvent = serde_json::from_str(json).unwrap(); assert_eq!(event.event_type, "result"); assert_eq!(event.is_error, Some(false)); assert_eq!(event.duration_ms, Some(5000)); assert_eq!(event.num_turns, Some(3)); assert_eq!(event.result.unwrap().as_str().unwrap(), "Done."); } #[test] fn test_parse_result_error_event() { let json = r#"{"type":"result","subtype":"error_max_turns","is_error":true,"duration_ms":60000,"num_turns":50}"#; let event: ClaudeStreamEvent = serde_json::from_str(json).unwrap(); assert_eq!(event.is_error, Some(true)); assert_eq!(event.subtype.as_deref(), Some("error_max_turns")); } #[test] fn test_stream_event_to_payloads_system() { let event = ClaudeStreamEvent { event_type: "system".to_string(), session_id: Some("sid-123".to_string()), subtype: Some("init".to_string()), message: None, result: None, is_error: None, duration_ms: None, num_turns: None, }; let payloads = stream_event_to_payloads(&event); assert_eq!(payloads.len(), 1); assert_eq!(payloads[0].event_type, "status"); assert_eq!(payloads[0].data["session_id"], "sid-123"); } #[test] fn test_stream_event_to_payloads_assistant_text() { let event = ClaudeStreamEvent { event_type: "assistant".to_string(), session_id: None, subtype: None, message: Some(MessageWrapper { role: Some("assistant".to_string()), content: Some(vec![ContentBlock { block_type: "text".to_string(), text: Some("Here's the answer".to_string()), name: None, id: None, input: None, content: None, tool_use_id: None, }]), }), result: None, is_error: None, duration_ms: None, num_turns: None, }; let payloads = stream_event_to_payloads(&event); assert_eq!(payloads.len(), 1); assert_eq!(payloads[0].event_type, "message"); assert_eq!(payloads[0].data["role"], "assistant"); assert_eq!(payloads[0].data["content"], "Here's the answer"); } #[test] fn test_stream_event_to_payloads_assistant_tool_use() { let event = ClaudeStreamEvent { event_type: "assistant".to_string(), session_id: None, subtype: None, message: Some(MessageWrapper { role: Some("assistant".to_string()), content: Some(vec![ContentBlock { block_type: "tool_use".to_string(), text: None, name: Some("Bash".to_string()), id: Some("toolu_01abc".to_string()), input: Some(serde_json::json!({"command": "ls"})), content: None, tool_use_id: None, }]), }), result: None, is_error: None, duration_ms: None, num_turns: None, }; let payloads = stream_event_to_payloads(&event); assert_eq!(payloads.len(), 1); assert_eq!(payloads[0].event_type, "tool_use"); assert_eq!(payloads[0].data["tool_name"], "Bash"); assert_eq!(payloads[0].data["tool_use_id"], "toolu_01abc"); } #[test] fn test_stream_event_to_payloads_user_tool_result() { let event = ClaudeStreamEvent { event_type: "user".to_string(), session_id: None, subtype: None, message: Some(MessageWrapper { role: Some("user".to_string()), content: Some(vec![ContentBlock { block_type: "tool_result".to_string(), text: None, name: None, id: None, input: None, content: Some(serde_json::json!("/workspace")), tool_use_id: Some("toolu_01abc".to_string()), }]), }), result: None, is_error: None, duration_ms: None, num_turns: None, }; let payloads = stream_event_to_payloads(&event); assert_eq!(payloads.len(), 1); assert_eq!(payloads[0].event_type, "tool_result"); assert_eq!(payloads[0].data["tool_use_id"], "toolu_01abc"); assert_eq!(payloads[0].data["output"], "/workspace"); } #[test] fn test_stream_event_to_payloads_result_success() { let event = ClaudeStreamEvent { event_type: "result".to_string(), session_id: Some("s1".to_string()), subtype: Some("success".to_string()), message: None, result: Some(serde_json::json!("The review is complete.")), is_error: Some(false), duration_ms: Some(12000), num_turns: Some(5), }; let payloads = stream_event_to_payloads(&event); // Should emit a message (the result text) + a result event assert_eq!(payloads.len(), 2); assert_eq!(payloads[0].event_type, "message"); assert_eq!(payloads[0].data["content"], "The review is complete."); assert_eq!(payloads[1].event_type, "result"); assert_eq!(payloads[1].data["status"], "completed"); } #[test] fn test_stream_event_to_payloads_result_error() { let event = ClaudeStreamEvent { event_type: "result".to_string(), session_id: None, subtype: Some("error_max_turns".to_string()), message: None, result: None, is_error: Some(true), duration_ms: None, num_turns: None, }; let payloads = stream_event_to_payloads(&event); assert_eq!(payloads.len(), 1); assert_eq!(payloads[0].data["status"], "error"); } #[test] fn test_stream_event_to_payloads_unknown_type() { let event = ClaudeStreamEvent { event_type: "fancy_new_thing".to_string(), session_id: None, subtype: None, message: None, result: None, is_error: None, duration_ms: None, num_turns: None, }; let payloads = stream_event_to_payloads(&event); assert_eq!(payloads.len(), 1); assert_eq!(payloads[0].event_type, "status"); } #[test] fn test_claude_event_payload_serde() { let payload = JobEventPayload { event_type: "message".to_string(), data: serde_json::json!({ "role": "assistant", "content": "hi" }), }; let json = serde_json::to_string(&payload).unwrap(); let parsed: JobEventPayload = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.event_type, "message"); assert_eq!(parsed.data["content"], "hi"); } #[test] fn test_truncate() { assert_eq!(truncate("hello", 10), "hello"); assert_eq!(truncate("hello world", 5), "hello"); assert_eq!(truncate("", 5), ""); } #[test] fn test_build_permission_settings_default_tools() { let tools: Vec = ["Bash(*)", "Read", "Edit(*)", "Glob", "Grep"] .into_iter() .map(String::from) .collect(); let json_str = build_permission_settings(&tools); let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); let allow = parsed["permissions"]["allow"].as_array().unwrap(); assert_eq!(allow.len(), 5); assert_eq!(allow[0], "Bash(*)"); assert_eq!(allow[1], "Read"); assert_eq!(allow[2], "Edit(*)"); } #[test] fn test_build_permission_settings_empty_tools() { let json_str = build_permission_settings(&[]); let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); let allow = parsed["permissions"]["allow"].as_array().unwrap(); assert!(allow.is_empty()); } #[test] fn test_build_permission_settings_is_valid_json() { let tools = vec!["Bash(npm run *)".to_string(), "Read".to_string()]; let json_str = build_permission_settings(&tools); // Must be valid JSON let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); // Must have the expected structure assert!(parsed["permissions"].is_object()); assert!(parsed["permissions"]["allow"].is_array()); } #[test] fn test_copy_dir_recursive() { let src = tempfile::tempdir().unwrap(); let dst = tempfile::tempdir().unwrap(); // Create a nested structure in src std::fs::write(src.path().join("auth.json"), r#"{"token":"abc"}"#).unwrap(); std::fs::create_dir_all(src.path().join("subdir")).unwrap(); std::fs::write(src.path().join("subdir").join("nested.txt"), "nested").unwrap(); let copied = copy_dir_recursive(src.path(), dst.path()).unwrap(); assert_eq!(copied, 2); // Verify files were copied assert_eq!( std::fs::read_to_string(dst.path().join("auth.json")).unwrap(), r#"{"token":"abc"}"# ); assert_eq!( std::fs::read_to_string(dst.path().join("subdir").join("nested.txt")).unwrap(), "nested" ); } #[test] fn test_copy_dir_recursive_empty_source() { let src = tempfile::tempdir().unwrap(); let dst = tempfile::tempdir().unwrap(); let copied = copy_dir_recursive(src.path(), dst.path()).unwrap(); assert_eq!(copied, 0); } #[test] fn test_copy_dir_recursive_skips_nonexistent_source() { let dst = tempfile::tempdir().unwrap(); let nonexistent = std::path::Path::new("/no/such/path"); // Should gracefully return 0 instead of failing let copied = copy_dir_recursive(nonexistent, dst.path()).unwrap(); assert_eq!(copied, 0); } }