mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-02 09:39:37 +00:00
- 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]>
1139 lines
42 KiB
Rust
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");
|
|
}
|
|
}
|