Files
optimclaw/src/worker/claude_bridge.rs
T
de5a1c7b0d fix(worker): replace script -qfc with pty-process for injection-safe PTY (#1678)
- Add pty-process crate (MIT, tokio async support) for PTY allocation
- Spawn claude CLI with pty-process::Command::arg() chaining instead of
  building a shell string for script -qfc
- Eliminates all shell injection surfaces: prompt, model, session_id
  are passed via execve, never interpreted by a shell
- Keep stderr on separate pipe to prevent NDJSON parse breakage
  (pty-process attaches PTY to all fds by default)
- Gate PTY behind #[cfg(unix)] with direct-spawn fallback for Windows CI
- Read stdout from PTY master (implements tokio::io::AsyncRead)
- Add regression tests: arg vector construction + PTY allocation

Addresses review feedback from zmanian and gemini-code-assist.

Co-authored-by: j-bloggs <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-03-28 16:31:49 +01:00

1139 lines
42 KiB
Rust

//! 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 <uuid> │
//! │ └─ 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};
#[cfg(not(unix))]
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<String>,
}
/// 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<String>,
#[serde(default)]
pub subtype: Option<String>,
/// For `assistant` and `user` events: the message wrapper containing content blocks.
#[serde(default)]
pub message: Option<MessageWrapper>,
/// For `result` events: the final text output.
#[serde(default)]
pub result: Option<serde_json::Value>,
/// For `result` events: whether the session ended in error.
#[serde(default)]
pub is_error: Option<bool>,
/// For `result` events: total wall-clock duration.
#[serde(default)]
pub duration_ms: Option<u64>,
/// For `result` events: number of agentic turns used.
#[serde(default)]
pub num_turns: Option<u32>,
}
/// 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<String>,
#[serde(default)]
pub content: Option<Vec<ContentBlock>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentBlock {
#[serde(rename = "type")]
pub block_type: String,
/// Text block content.
#[serde(default)]
pub text: Option<String>,
/// Tool name (for tool_use blocks).
#[serde(default)]
pub name: Option<String>,
/// Tool use ID (for tool_use and tool_result blocks).
#[serde(default)]
pub id: Option<String>,
/// Tool input params (for tool_use blocks).
#[serde(default)]
pub input: Option<serde_json::Value>,
/// Tool result content (for tool_result blocks), or general content.
#[serde(default)]
pub content: Option<serde_json::Value>,
/// Tool use ID reference (for tool_result blocks).
#[serde(default)]
pub tool_use_id: Option<String>,
}
/// The Claude Code bridge runtime.
pub struct ClaudeBridgeRuntime {
config: ClaudeBridgeConfig,
client: Arc<WorkerHttpClient>,
}
impl ClaudeBridgeRuntime {
/// Create a new bridge runtime.
///
/// Reads `IRONCLAW_WORKER_TOKEN` from the environment for auth.
pub fn new(config: ClaudeBridgeConfig) -> Result<Self, WorkerError> {
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.
///
/// Uses a PTY on Unix so Node.js line-buffers stdout instead of
/// full-buffering (which causes the bridge to hang on non-TTY pipes).
/// Arguments are passed via `execve` (no shell) — injection-safe by
/// construction.
///
/// 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<String, String>,
) -> Result<Option<String>, WorkerError> {
let max_turns_str = self.config.max_turns.to_string();
// Spawn with PTY on Unix to fix Node.js stdout buffering.
// All arguments are passed individually via execve — never through
// a shell interpreter. This eliminates shell injection by construction.
#[cfg(unix)]
let (mut child, stdout, stderr) = {
let (pty, pts) = pty_process::open().map_err(|e| WorkerError::ExecutionFailed {
reason: format!("failed to allocate PTY: {}", e),
})?;
let mut cmd = pty_process::Command::new("claude");
cmd = cmd
.arg("-p")
.arg(prompt)
.arg("--output-format")
.arg("stream-json")
.arg("--verbose")
.arg("--max-turns")
.arg(&max_turns_str)
.arg("--model")
.arg(&self.config.model);
if let Some(sid) = resume_session_id {
cmd = cmd.arg("--resume").arg(sid);
}
cmd = cmd.envs(extra_env.iter());
cmd = cmd.current_dir("/workspace");
// Keep stderr on a separate pipe — pty-process attaches the PTY
// to all fds by default, which would merge stderr into the PTY
// stream and break NDJSON parsing.
cmd = cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn(pts).map_err(|e| WorkerError::ExecutionFailed {
reason: format!("failed to spawn claude with PTY: {}", e),
})?;
let stderr = child
.stderr
.take()
.ok_or_else(|| WorkerError::ExecutionFailed {
reason: "failed to capture claude stderr".to_string(),
})?;
// stdout comes from the PTY master, which implements AsyncRead
let stdout: Box<dyn tokio::io::AsyncRead + Unpin + Send> = Box::new(pty);
(child, stdout, stderr)
};
// Non-Unix fallback (Windows CI) — no PTY, direct spawn.
// Claude bridge only runs in Linux Docker containers, so this path
// exists solely for compilation on Windows targets.
#[cfg(not(unix))]
let (mut child, stdout, stderr) = {
let mut cmd = Command::new("claude");
cmd.arg("-p")
.arg(prompt)
.arg("--output-format")
.arg("stream-json")
.arg("--verbose")
.arg("--max-turns")
.arg(&max_turns_str)
.arg("--model")
.arg(&self.config.model);
if let Some(sid) = resume_session_id {
cmd.arg("--resume").arg(sid);
}
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_pipe = 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(),
})?;
let stdout: Box<dyn tokio::io::AsyncRead + Unpin + Send> = Box::new(stdout_pipe);
(child, stdout, stderr)
};
// 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<String> = 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::<ClaudeStreamEvent>(&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<Option<PromptResponse>, 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<JobEventPayload> {
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<usize> {
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<String> = ["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);
}
/// Regression test: arguments are passed individually (not via shell string),
/// so shell metacharacters in prompt/model/session_id are harmless.
#[test]
fn command_args_no_shell_interpretation() {
// Prompt, model, and session_id may contain shell metacharacters from
// user-supplied task descriptions or LLM output. Since we use
// Command::arg() (execve), these are passed as literal strings.
let prompt = "Fix the user's bug; echo $HOME && rm -rf /";
let model = "claude-3-opus-20240229";
let session_id = "'; DROP TABLE jobs; --";
let max_turns = 10u32;
let max_turns_str = max_turns.to_string();
let args: Vec<&str> = vec![
"-p",
prompt,
"--output-format",
"stream-json",
"--verbose",
"--max-turns",
&max_turns_str,
"--model",
model,
"--resume",
session_id,
];
// All values present as literal strings — no shell interpretation
// ["-p", prompt, "--output-format", "stream-json", "--verbose",
// "--max-turns", "10", "--model", model, "--resume", session_id]
assert_eq!(args[1], prompt);
assert_eq!(args[8], model);
assert_eq!(args[10], session_id);
// Shell metacharacters preserved, not expanded
assert!(args[1].contains("$HOME"));
assert!(args[1].contains("&&"));
assert!(args[10].contains("'; DROP TABLE"));
}
/// Verify PTY is available on Unix platforms.
#[cfg(unix)]
#[tokio::test]
async fn pty_opens_successfully() {
let result = pty_process::open();
assert!(result.is_ok(), "PTY allocation should succeed on Unix");
}
}