use std::collections::HashMap;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::{mpsc, Mutex};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use bamboo_agent_core::{AgentEvent, TokenUsage, ToolResult};
use bamboo_subagent::executor::{ChildExecutor, ChildOutcome, EventSink, HostBridge, SteerInbox};
use bamboo_subagent::proto::RunSpec;
const MAX_STDOUT_LINE_BYTES: usize = 10 * 1024 * 1024;
const STDERR_TAIL_BYTES: usize = 16 * 1024;
const TOOL_RESULT_TRUNCATE_CHARS: usize = 20_000;
const GRACEFUL_EXIT_WAIT: Duration = Duration::from_secs(5);
const SIGTERM_WAIT: Duration = Duration::from_secs(2);
const APPROVAL_RELAY_TIMEOUT: Duration = Duration::from_secs(300);
const ENV_ALLOWLIST: &[&str] = &[
"HOME", "PATH", "SHELL", "TERM", "LANG", "TMPDIR", "USER", "LOGNAME",
];
pub fn resolve_claude_code_state_dir(storage_dir: &Option<String>, child_id: &str) -> PathBuf {
storage_dir
.clone()
.map(PathBuf::from)
.unwrap_or_else(|| std::env::temp_dir().join("bamboo-subagents").join(child_id))
}
const STATE_FILE_NAME: &str = "claude-code-session.json";
const HISTORY_PREAMBLE_MAX_CHARS: usize = 24_000;
const HISTORY_PREAMBLE_MAX_MESSAGES: usize = 40;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ClaudeSessionState {
session_id: String,
workspace: Option<String>,
updated_at: DateTime<Utc>,
}
pub struct ClaudeCodeExecutor {
binary: String,
model: Option<String>,
permission_mode: Option<String>,
workspace: Option<String>,
state_dir: Option<PathBuf>,
inherit_user_config: bool,
forward_env: Vec<String>,
relay_timeout: Duration,
}
impl ClaudeCodeExecutor {
#[allow(clippy::too_many_arguments)]
pub fn new(
binary: Option<String>,
model: Option<String>,
permission_mode: Option<String>,
workspace: Option<String>,
state_dir: Option<PathBuf>,
inherit_user_config: bool,
forward_env: Vec<String>,
) -> Self {
Self {
binary: binary.unwrap_or_else(|| "claude".to_string()),
model,
permission_mode,
workspace,
state_dir,
inherit_user_config,
forward_env,
relay_timeout: APPROVAL_RELAY_TIMEOUT,
}
}
#[cfg(test)]
fn with_relay_timeout_for_test(mut self, timeout: Duration) -> Self {
self.relay_timeout = timeout;
self
}
fn state_file_path(&self) -> Option<PathBuf> {
self.state_dir.as_ref().map(|dir| dir.join(STATE_FILE_NAME))
}
async fn read_state(&self) -> Option<ClaudeSessionState> {
let path = self.state_file_path()?;
let bytes = tokio::fs::read(&path).await.ok()?;
serde_json::from_slice(&bytes).ok()
}
async fn delete_state_file(&self) {
if let Some(path) = self.state_file_path() {
let _ = tokio::fs::remove_file(&path).await;
}
}
async fn write_state(&self, session_id: &str) {
let Some(dir) = &self.state_dir else { return };
let path = dir.join(STATE_FILE_NAME);
let state = ClaudeSessionState {
session_id: session_id.to_string(),
workspace: self.workspace.clone(),
updated_at: Utc::now(),
};
let Ok(bytes) = serde_json::to_vec_pretty(&state) else {
return;
};
if tokio::fs::create_dir_all(dir).await.is_err() {
return;
}
let tmp_path = dir.join(format!("{STATE_FILE_NAME}.{}.tmp", std::process::id()));
if tokio::fs::write(&tmp_path, &bytes).await.is_err() {
return;
}
let _ = tokio::fs::rename(&tmp_path, &path).await;
}
async fn resolve_resume_id(&self) -> Option<String> {
let state = self.read_state().await?;
if state.workspace != self.workspace {
tracing::warn!(
recorded = ?state.workspace,
current = ?self.workspace,
"claude code: state file workspace mismatch; falling back to history rehydration"
);
return None;
}
Some(state.session_id)
}
fn build_command(&self, resume_id: Option<&str>) -> Command {
let mut cmd = Command::new(&self.binary);
cmd.arg("--output-format")
.arg("stream-json")
.arg("--input-format")
.arg("stream-json")
.arg("--permission-prompt-tool")
.arg("stdio")
.arg("--replay-user-messages")
.arg("--verbose");
cmd.arg("--permission-mode")
.arg(self.permission_mode.as_deref().unwrap_or("default"));
if let Some(model) = &self.model {
cmd.arg("--model").arg(model);
}
if let Some(id) = resume_id {
cmd.arg("--resume").arg(id);
}
if !self.inherit_user_config {
cmd.arg("--strict-mcp-config");
cmd.arg("--setting-sources").arg("project");
}
cmd.env_clear();
for (key, value) in std::env::vars() {
if ENV_ALLOWLIST.contains(&key.as_str()) || key.starts_with("LC_") {
cmd.env(key, value);
}
}
for name in &self.forward_env {
if let Ok(value) = std::env::var(name) {
cmd.env(name, value);
}
}
cmd.env_remove("CLAUDECODE");
if let Some(ws) = &self.workspace {
cmd.current_dir(ws);
}
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
cmd.kill_on_drop(true);
#[cfg(unix)]
{
cmd.process_group(0);
}
cmd
}
async fn handle_frame(
&self,
value: Value,
events: &EventSink,
write_tx: &mpsc::UnboundedSender<Value>,
pending: &mut HashMap<String, JoinHandle<()>>,
last_text: &mut String,
) -> Option<ChildOutcome> {
let frame_type = value.get("type").and_then(Value::as_str).unwrap_or("");
match frame_type {
"system" => {
let session_id = value
.get("session_id")
.and_then(|v| v.as_str())
.unwrap_or("");
let model = value.get("model").and_then(|v| v.as_str()).unwrap_or("");
tracing::debug!(session_id, model, "claude code: session bootstrap");
if !session_id.is_empty() {
self.write_state(session_id).await;
}
None
}
"assistant" => {
if let Some(blocks) = value.pointer("/message/content").and_then(Value::as_array) {
for block in blocks {
emit_assistant_block(block, events, last_text);
}
}
None
}
"user" => {
if let Some(blocks) = value.pointer("/message/content").and_then(Value::as_array) {
for block in blocks {
emit_tool_result_block(block, events);
}
}
None
}
"result" => {
let subtype = value.get("subtype").and_then(Value::as_str).unwrap_or("");
if matches!(subtype, "compact" | "compaction") {
tracing::debug!("claude code: mid-turn compaction result, continuing");
return None;
}
if let Some(session_id) = value.get("session_id").and_then(Value::as_str) {
if !session_id.is_empty() {
self.write_state(session_id).await;
}
}
let final_text = value
.get("result")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string)
.unwrap_or_else(|| last_text.clone());
let usage = value
.get("usage")
.map(|u| {
let prompt = u.get("input_tokens").and_then(Value::as_u64).unwrap_or(0);
let completion =
u.get("output_tokens").and_then(Value::as_u64).unwrap_or(0);
TokenUsage {
prompt_tokens: prompt,
completion_tokens: completion,
total_tokens: prompt.saturating_add(completion),
}
})
.unwrap_or_default();
events.emit(event_json(AgentEvent::Complete { usage }));
Some(ChildOutcome::completed(final_text))
}
"control_request" => {
self.handle_control_request(value, events, write_tx, pending);
None
}
"control_cancel_request" => {
let request_id = value
.get("request_id")
.and_then(Value::as_str)
.unwrap_or("");
if let Some(handle) = pending.remove(request_id) {
handle.abort();
}
None
}
other => {
tracing::debug!(frame_type = other, "claude code: unrecognized stdout frame");
None
}
}
}
fn handle_control_request(
&self,
value: Value,
events: &EventSink,
write_tx: &mpsc::UnboundedSender<Value>,
pending: &mut HashMap<String, JoinHandle<()>>,
) {
let request_id = value
.get("request_id")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let request = value.get("request").cloned().unwrap_or_else(|| json!({}));
let subtype = request.get("subtype").and_then(Value::as_str).unwrap_or("");
if subtype != "can_use_tool" {
send_control_response(
write_tx,
&request_id,
false,
None,
Some(format!("unsupported control_request subtype '{subtype}'")),
);
return;
}
let tool_name = request
.get("tool_name")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let input = request.get("input").cloned().unwrap_or_else(|| json!({}));
let host = events.host().cloned();
let permission_mode = self.permission_mode.clone();
let relay_timeout = self.relay_timeout;
let write_tx = write_tx.clone();
let task_request_id = request_id.clone();
let handle = tokio::spawn(async move {
decide_and_respond(
host,
permission_mode,
relay_timeout,
&task_request_id,
&tool_name,
input,
&write_tx,
)
.await;
});
pending.insert(request_id, handle);
}
async fn shutdown_child(child: &mut Child) {
if tokio::time::timeout(GRACEFUL_EXIT_WAIT, child.wait())
.await
.is_ok()
{
return;
}
signal_process_group(child, ProcessSignal::Term);
if tokio::time::timeout(SIGTERM_WAIT, child.wait())
.await
.is_ok()
{
return;
}
signal_process_group(child, ProcessSignal::Kill);
let _ = child.wait().await;
}
}
impl ClaudeCodeExecutor {
async fn run_once(
&self,
body: &str,
resume_id: Option<&str>,
events: &EventSink,
cancel: &CancellationToken,
) -> (ChildOutcome, bool) {
let mut child = match spawn_with_etxtbsy_retry(|| self.build_command(resume_id)).await {
Ok(c) => c,
Err(e) => {
return (
ChildOutcome::error(format!("spawn '{}': {e}", self.binary)),
false,
)
}
};
let Some(stdin) = child.stdin.take() else {
return (
ChildOutcome::error("claude child has no stdin pipe".to_string()),
false,
);
};
let Some(stdout) = child.stdout.take() else {
return (
ChildOutcome::error("claude child has no stdout pipe".to_string()),
false,
);
};
let stderr = child.stderr.take();
let stderr_tail = Arc::new(Mutex::new(String::new()));
let stderr_task = stderr.map(|stderr| {
let tail = stderr_tail.clone();
tokio::spawn(async move { drain_stderr_tail(stderr, tail).await })
});
let (write_tx, writer_handle) = spawn_stdin_writer(stdin);
let assignment_frame = json!({
"type": "user",
"message": { "role": "user", "content": body },
});
if write_tx.send(assignment_frame).is_err() {
let _ = child.start_kill();
return (
ChildOutcome::error(
"claude code executor: failed to queue the assignment on stdin".to_string(),
),
false,
);
}
let mut reader = tokio::io::BufReader::with_capacity(64 * 1024, stdout);
let mut pending: HashMap<String, JoinHandle<()>> = HashMap::new();
let mut last_text = String::new();
let (outcome, exited_without_result) = loop {
tokio::select! {
_ = cancel.cancelled() => {
break (ChildOutcome::cancelled(), false);
}
line = read_bounded_line(&mut reader, MAX_STDOUT_LINE_BYTES) => {
match line {
Ok(Some(bytes)) => {
if bytes.iter().all(u8::is_ascii_whitespace) {
continue;
}
let value: Value = match serde_json::from_slice(&bytes) {
Ok(v) => v,
Err(e) => {
tracing::debug!("claude code: unparsable stdout line ({e}); skipping");
continue;
}
};
if let Some(outcome) = self
.handle_frame(value, events, &write_tx, &mut pending, &mut last_text)
.await
{
break (outcome, false);
}
}
Ok(None) => {
let code = child.wait().await.ok().and_then(|s| s.code());
let tail = stderr_tail.lock().await.clone();
break (ChildOutcome::error(format!(
"claude exited (code {code:?}) without a result frame; stderr tail: {}",
if tail.is_empty() { "<empty>" } else { tail.trim() }
)), true);
}
Err(e) => {
break (ChildOutcome::error(format!("claude stdout read error: {e}")), false);
}
}
}
}
};
for (_, handle) in pending.drain() {
handle.abort();
}
drop(write_tx);
let _ = tokio::time::timeout(Duration::from_millis(500), writer_handle).await;
if let Some(stderr_task) = stderr_task {
stderr_task.abort();
}
Self::shutdown_child(&mut child).await;
(outcome, exited_without_result)
}
}
#[async_trait]
impl ChildExecutor for ClaudeCodeExecutor {
async fn run(
&self,
spec: RunSpec,
events: EventSink,
mut steer: SteerInbox,
cancel: CancellationToken,
) -> ChildOutcome {
let steer_drain = tokio::spawn(async move { while steer.recv().await.is_some() {} });
if spec.messages.is_empty() {
self.delete_state_file().await;
}
let resume_id = if spec.messages.is_empty() {
None
} else {
self.resolve_resume_id().await
};
let body = build_turn_body(&spec, resume_id.as_deref());
let used_resume = resume_id.is_some();
let (outcome, exited_without_result) = self
.run_once(&body, resume_id.as_deref(), &events, &cancel)
.await;
let outcome = if used_resume && exited_without_result {
tracing::warn!(
"claude code: --resume spawn exited without a result frame; \
retrying once without --resume"
);
self.delete_state_file().await;
let fallback_body = build_turn_body(&spec, None);
self.run_once(&fallback_body, None, &events, &cancel)
.await
.0
} else {
outcome
};
steer_drain.abort();
outcome
}
}
async fn spawn_with_etxtbsy_retry(mut build: impl FnMut() -> Command) -> std::io::Result<Child> {
let mut last_err = None;
for _ in 0..5 {
match build().spawn() {
Ok(child) => return Ok(child),
Err(e) if e.raw_os_error() == Some(26) => {
last_err = Some(e);
tokio::time::sleep(Duration::from_millis(10)).await;
}
Err(e) => return Err(e),
}
}
Err(last_err.expect("retry loop always records an error before exhausting"))
}
enum ProcessSignal {
Term,
Kill,
}
#[cfg(unix)]
fn signal_process_group(child: &Child, signal: ProcessSignal) {
if let Some(pid) = child.id() {
let signo = match signal {
ProcessSignal::Term => libc::SIGTERM,
ProcessSignal::Kill => libc::SIGKILL,
};
unsafe {
libc::kill(-(pid as libc::pid_t), signo);
}
}
}
#[cfg(not(unix))]
fn signal_process_group(_child: &Child, _signal: ProcessSignal) {}
fn spawn_stdin_writer(
mut stdin: tokio::process::ChildStdin,
) -> (mpsc::UnboundedSender<Value>, JoinHandle<()>) {
let (tx, mut rx) = mpsc::unbounded_channel::<Value>();
let handle = tokio::spawn(async move {
while let Some(value) = rx.recv().await {
let Ok(mut line) = serde_json::to_vec(&value) else {
continue;
};
line.push(b'\n');
if stdin.write_all(&line).await.is_err() {
break;
}
if stdin.flush().await.is_err() {
break;
}
}
});
(tx, handle)
}
async fn read_bounded_line<R>(reader: &mut R, max_bytes: usize) -> std::io::Result<Option<Vec<u8>>>
where
R: tokio::io::AsyncBufRead + Unpin,
{
let mut out = Vec::new();
loop {
let (found, consumed) = {
let available = reader.fill_buf().await?;
if available.is_empty() {
return Ok(if out.is_empty() { None } else { Some(out) });
}
match available.iter().position(|&b| b == b'\n') {
Some(pos) => {
out.extend_from_slice(&available[..pos]);
(true, pos + 1)
}
None => {
out.extend_from_slice(available);
(false, available.len())
}
}
};
reader.consume(consumed);
if found {
return Ok(Some(out));
}
if out.len() > max_bytes {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("stdout line exceeded {max_bytes} bytes"),
));
}
}
}
async fn drain_stderr_tail(stderr: tokio::process::ChildStderr, tail: Arc<Mutex<String>>) {
let mut reader = BufReader::new(stderr);
let mut buf = Vec::new();
loop {
buf.clear();
match reader.read_until(b'\n', &mut buf).await {
Ok(0) | Err(_) => return,
Ok(_) => {
let mut t = tail.lock().await;
t.push_str(&String::from_utf8_lossy(&buf));
if t.len() > STDERR_TAIL_BYTES {
let excess = t.len() - STDERR_TAIL_BYTES;
let cut = t
.char_indices()
.map(|(i, _)| i)
.find(|&i| i >= excess)
.unwrap_or(t.len());
t.drain(..cut);
}
}
}
}
}
fn emit_assistant_block(block: &Value, events: &EventSink, last_text: &mut String) {
match block.get("type").and_then(Value::as_str) {
Some("text") => {
let text = block.get("text").and_then(Value::as_str).unwrap_or("");
if !text.is_empty() {
last_text.push_str(text);
events.emit(event_json(AgentEvent::Token {
content: text.to_string(),
}));
}
}
Some("thinking") => {
let text = block.get("thinking").and_then(Value::as_str).unwrap_or("");
if !text.is_empty() {
events.emit(event_json(AgentEvent::ReasoningToken {
content: text.to_string(),
}));
}
}
Some("tool_use") => {
let tool_call_id = block
.get("id")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let tool_name = block
.get("name")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let arguments = block.get("input").cloned().unwrap_or_else(|| json!({}));
events.emit(event_json(AgentEvent::ToolStart {
tool_call_id,
tool_name,
arguments,
}));
}
_ => {}
}
}
fn emit_tool_result_block(block: &Value, events: &EventSink) {
if block.get("type").and_then(Value::as_str) != Some("tool_result") {
return;
}
let tool_call_id = block
.get("tool_use_id")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let is_error = block
.get("is_error")
.and_then(Value::as_bool)
.unwrap_or(false);
let text = truncate_chars(
&tool_result_text(block.get("content")),
TOOL_RESULT_TRUNCATE_CHARS,
);
let event = if is_error {
AgentEvent::ToolError {
tool_call_id,
error: text,
}
} else {
AgentEvent::ToolComplete {
tool_call_id,
result: ToolResult::text(true, text),
}
};
events.emit(event_json(event));
}
fn tool_result_text(content: Option<&Value>) -> String {
match content {
Some(Value::String(s)) => s.clone(),
Some(Value::Array(items)) => items
.iter()
.filter_map(|b| b.get("text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("\n"),
Some(other) => other.to_string(),
None => String::new(),
}
}
fn build_turn_body(spec: &RunSpec, resume_id: Option<&str>) -> String {
if resume_id.is_some() || spec.messages.is_empty() {
return spec.assignment.clone();
}
match render_history_preamble(&spec.messages, &spec.assignment) {
Some(preamble) => format!("{preamble}\n\n## Current task\n\n{}", spec.assignment),
None => spec.assignment.clone(),
}
}
fn render_history_preamble(messages: &[Value], assignment: &str) -> Option<String> {
let mut entries: Vec<(String, String)> = messages
.iter()
.filter_map(|m| {
let role = m.get("role").and_then(Value::as_str)?.to_string();
let content = m.get("content").and_then(Value::as_str)?;
if content.is_empty() {
return None;
}
Some((role, content.to_string()))
})
.collect();
if let Some((role, content)) = entries.last() {
if role == "user" && content == assignment {
entries.pop();
}
}
if entries.is_empty() {
return None;
}
let dropped_by_count = entries.len().saturating_sub(HISTORY_PREAMBLE_MAX_MESSAGES);
if dropped_by_count > 0 {
entries.drain(0..dropped_by_count);
}
let mut rendered: Vec<String> = entries
.iter()
.map(|(role, content)| format!("**{role}**: {content}"))
.collect();
let mut dropped_by_chars = 0usize;
while rendered.len() > 1
&& rendered
.iter()
.map(|s| s.chars().count() + 2)
.sum::<usize>()
> HISTORY_PREAMBLE_MAX_CHARS
{
rendered.remove(0);
dropped_by_chars += 1;
}
if let [only] = rendered.as_mut_slice() {
if only.chars().count() > HISTORY_PREAMBLE_MAX_CHARS {
*only = truncate_chars(only, HISTORY_PREAMBLE_MAX_CHARS);
}
}
let mut out = String::from("## Prior conversation (rehydrated)\n\n");
if dropped_by_count > 0 || dropped_by_chars > 0 {
out.push_str(&format!(
"_[truncated: {} earlier message(s) omitted]_\n\n",
dropped_by_count + dropped_by_chars
));
}
out.push_str(&rendered.join("\n\n"));
Some(out)
}
fn truncate_chars(s: &str, max_chars: usize) -> String {
if s.chars().count() <= max_chars {
return s.to_string();
}
let head: String = s.chars().take(max_chars).collect();
let dropped = s.chars().count() - max_chars;
format!("{head}\n… [truncated, {dropped} more chars]")
}
async fn decide_and_respond(
host: Option<HostBridge>,
permission_mode: Option<String>,
relay_timeout: Duration,
request_id: &str,
tool_name: &str,
input: Value,
write_tx: &mpsc::UnboundedSender<Value>,
) {
if tool_name == "AskUserQuestion" {
send_control_response(
write_tx,
request_id,
false,
None,
Some(
"interactive questions are not supported by the Claude Code executor yet"
.to_string(),
),
);
return;
}
let (allow, deny_message) = if let Some(host) = host {
let body = json!({ "tool_name": tool_name, "input": input });
match tokio::time::timeout(relay_timeout, host.approval_call(body)).await {
Ok(Ok(reply)) => {
let approved = reply
.get("approved")
.and_then(Value::as_bool)
.unwrap_or(false);
let msg = (!approved).then(|| "denied by host approver".to_string());
(approved, msg)
}
Ok(Err(e)) => (false, Some(format!("approval relay failed: {e}"))),
Err(_) => (
false,
Some(format!(
"approval relay timed out after {}s; denying",
relay_timeout.as_secs()
)),
),
}
} else if permission_mode.as_deref() == Some("bypassPermissions") {
(true, None)
} else {
(
false,
Some(
"permission relay unavailable; run with bypassPermissions or attach a host bridge"
.to_string(),
),
)
};
let updated_input = allow.then_some(input);
send_control_response(write_tx, request_id, allow, updated_input, deny_message);
}
fn send_control_response(
write_tx: &mpsc::UnboundedSender<Value>,
request_id: &str,
allow: bool,
updated_input: Option<Value>,
deny_message: Option<String>,
) {
let response = if allow {
json!({
"behavior": "allow",
"updatedInput": updated_input.unwrap_or_else(|| json!({})),
})
} else {
json!({
"behavior": "deny",
"message": deny_message.unwrap_or_default(),
})
};
let frame = json!({
"type": "control_response",
"response": {
"subtype": "success",
"request_id": request_id,
"response": response,
},
});
let _ = write_tx.send(frame);
}
fn event_json(event: AgentEvent) -> Value {
serde_json::to_value(event).unwrap_or_else(|_| json!({}))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use bamboo_subagent::executor::EventSink;
use bamboo_subagent::proto::TerminalStatus;
fn write_stub(dir: &std::path::Path, body: &str) -> PathBuf {
let path = dir.join("claude");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, "#!/bin/sh").unwrap();
f.write_all(body.as_bytes()).unwrap();
let mut perms = std::fs::metadata(&path).unwrap().permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&path, perms).unwrap();
path
}
fn executor(binary: PathBuf) -> ClaudeCodeExecutor {
executor_with_state(binary, None, None)
}
fn executor_with_state(
binary: PathBuf,
state_dir: Option<PathBuf>,
workspace: Option<String>,
) -> ClaudeCodeExecutor {
ClaudeCodeExecutor::new(
Some(binary.to_string_lossy().into_owned()),
None,
None,
workspace,
state_dir,
false,
Vec::new(),
)
}
fn run_spec(assignment: &str) -> RunSpec {
RunSpec {
assignment: assignment.to_string(),
reasoning_effort: None,
messages: Vec::new(),
}
}
fn run_spec_with_messages(assignment: &str, messages: Vec<Value>) -> RunSpec {
RunSpec {
assignment: assignment.to_string(),
reasoning_effort: None,
messages,
}
}
fn msg(role: &str, content: &str) -> Value {
json!({ "role": role, "content": content })
}
fn read_argv(dir: &std::path::Path, name: &str) -> String {
std::fs::read_to_string(dir.join(name)).unwrap_or_default()
}
fn stdin_body(dir: &std::path::Path, name: &str) -> String {
let raw = std::fs::read_to_string(dir.join(name)).unwrap();
let value: Value = serde_json::from_str(raw.trim()).unwrap();
value["message"]["content"].as_str().unwrap().to_string()
}
#[tokio::test]
async fn happy_path_streams_events_then_completes() {
let dir = tempfile::tempdir().unwrap();
let bin = write_stub(
dir.path(),
r#"
read -r _assignment
echo '{"type":"system","session_id":"s1","model":"stub-model"}'
echo '{"type":"assistant","message":{"content":[{"type":"text","text":"Working on it"},{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"echo hi"}}]}}'
echo '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"hi","is_error":false}]}}'
echo '{"type":"result","subtype":"success","result":"done: hi","usage":{"input_tokens":10,"output_tokens":5}}'
"#,
);
let (sink, mut rx) = EventSink::channel();
let outcome = executor(bin)
.run(
run_spec("say hi"),
sink,
SteerInbox::disconnected(),
CancellationToken::new(),
)
.await;
assert_eq!(outcome.status, TerminalStatus::Completed);
assert_eq!(outcome.result.as_deref(), Some("done: hi"));
let mut events = Vec::new();
while let Ok(e) = rx.try_recv() {
events.push(e);
}
let types: Vec<&str> = events
.iter()
.map(|e| e["type"].as_str().unwrap_or(""))
.collect();
assert_eq!(
types,
vec!["token", "tool_start", "tool_complete", "complete"]
);
assert_eq!(events[0]["content"], "Working on it");
assert_eq!(events[1]["tool_name"], "Bash");
assert_eq!(events[2]["result"]["result"], "hi");
assert_eq!(events[3]["usage"]["prompt_tokens"], 10);
assert_eq!(events[3]["usage"]["completion_tokens"], 5);
}
#[tokio::test]
async fn compaction_result_does_not_complete_the_run() {
let dir = tempfile::tempdir().unwrap();
let bin = write_stub(
dir.path(),
r#"
read -r _assignment
echo '{"type":"result","subtype":"compact","result":"mid-turn compaction, ignore"}'
echo '{"type":"assistant","message":{"content":[{"type":"text","text":"back after compaction"}]}}'
echo '{"type":"result","subtype":"success","result":"final answer"}'
"#,
);
let (sink, _rx) = EventSink::channel();
let outcome = executor(bin)
.run(
run_spec("do the thing"),
sink,
SteerInbox::disconnected(),
CancellationToken::new(),
)
.await;
assert_eq!(outcome.status, TerminalStatus::Completed);
assert_eq!(outcome.result.as_deref(), Some("final answer"));
}
#[tokio::test]
async fn control_request_with_no_host_denies_and_run_continues() {
let dir = tempfile::tempdir().unwrap();
let bin = write_stub(
dir.path(),
r#"
DIR="$(cd "$(dirname "$0")" && pwd)"
read -r _assignment
echo '{"type":"control_request","request_id":"r1","request":{"subtype":"can_use_tool","tool_name":"Bash","input":{"command":"rm -rf /"}}}'
read -r control_response_line
printf '%s\n' "$control_response_line" > "$DIR/control_response.json"
echo '{"type":"result","subtype":"success","result":"continued after deny"}'
"#,
);
let (sink, _rx) = EventSink::channel(); let outcome = executor(bin.clone())
.run(
run_spec("do something dangerous"),
sink,
SteerInbox::disconnected(),
CancellationToken::new(),
)
.await;
assert_eq!(outcome.status, TerminalStatus::Completed);
assert_eq!(outcome.result.as_deref(), Some("continued after deny"));
let written = std::fs::read_to_string(dir.path().join("control_response.json")).unwrap();
let value: Value = serde_json::from_str(written.trim()).unwrap();
assert_eq!(value["response"]["request_id"], "r1");
assert_eq!(value["response"]["response"]["behavior"], "deny");
assert!(value["response"]["response"]["message"]
.as_str()
.unwrap()
.contains("permission relay unavailable"));
}
#[tokio::test]
async fn control_request_with_host_bridge_relays_and_allows() {
let dir = tempfile::tempdir().unwrap();
let bin = write_stub(
dir.path(),
r#"
DIR="$(cd "$(dirname "$0")" && pwd)"
read -r _assignment
echo '{"type":"control_request","request_id":"r1","request":{"subtype":"can_use_tool","tool_name":"Write","input":{"file_path":"/tmp/x"}}}'
read -r control_response_line
printf '%s\n' "$control_response_line" > "$DIR/control_response.json"
echo '{"type":"result","subtype":"success","result":"wrote file"}'
"#,
);
let (bridge, mut req_rx) = HostBridge::channel();
let approver = tokio::spawn(async move {
let req = req_rx.recv().await.expect("a host approval request");
assert_eq!(req.body["tool_name"], "Write");
let _ = req.reply.send(json!({ "approved": true }));
});
let (sink, _rx) = EventSink::channel();
let sink = sink.with_host_bridge(bridge);
let outcome = executor(bin)
.run(
run_spec("write a file"),
sink,
SteerInbox::disconnected(),
CancellationToken::new(),
)
.await;
approver.await.unwrap();
assert_eq!(outcome.status, TerminalStatus::Completed);
let written = std::fs::read_to_string(dir.path().join("control_response.json")).unwrap();
let value: Value = serde_json::from_str(written.trim()).unwrap();
assert_eq!(value["response"]["response"]["behavior"], "allow");
assert_eq!(
value["response"]["response"]["updatedInput"]["file_path"],
"/tmp/x"
);
}
#[tokio::test]
async fn control_request_approval_relay_times_out_and_denies() {
let dir = tempfile::tempdir().unwrap();
let bin = write_stub(
dir.path(),
r#"
DIR="$(cd "$(dirname "$0")" && pwd)"
read -r _assignment
echo '{"type":"control_request","request_id":"r1","request":{"subtype":"can_use_tool","tool_name":"Bash","input":{"command":"echo hi"}}}'
read -r control_response_line
printf '%s\n' "$control_response_line" > "$DIR/control_response.json"
echo '{"type":"result","subtype":"success","result":"continued after timeout"}'
"#,
);
let (bridge, mut req_rx) = HostBridge::channel();
let held = tokio::spawn(async move { req_rx.recv().await });
let (sink, _rx) = EventSink::channel();
let sink = sink.with_host_bridge(bridge);
let outcome = executor(bin)
.with_relay_timeout_for_test(Duration::from_millis(50))
.run(
run_spec("do something"),
sink,
SteerInbox::disconnected(),
CancellationToken::new(),
)
.await;
assert_eq!(outcome.status, TerminalStatus::Completed);
assert_eq!(outcome.result.as_deref(), Some("continued after timeout"));
let written = std::fs::read_to_string(dir.path().join("control_response.json")).unwrap();
let value: Value = serde_json::from_str(written.trim()).unwrap();
assert_eq!(value["response"]["response"]["behavior"], "deny");
let msg = value["response"]["response"]["message"].as_str().unwrap();
assert!(msg.contains("timed out"), "unexpected deny message: {msg}");
assert!(msg.contains("denying"), "unexpected deny message: {msg}");
let _req = held.await.unwrap();
}
#[tokio::test]
async fn env_allowlist_blocks_canary_secret_but_forwards_listed_var() {
let dir = tempfile::tempdir().unwrap();
let bin = write_stub(
dir.path(),
r#"
DIR="$(cd "$(dirname "$0")" && pwd)"
env > "$DIR/env-dump.txt"
read -r _assignment
echo '{"type":"result","subtype":"success","result":"ok"}'
"#,
);
std::env::set_var("FAKE_SECRET_API_KEY", "leaked-if-broken");
std::env::set_var("BAMBOO_TEST_FORWARD_ME", "forwarded-value");
let exec = ClaudeCodeExecutor::new(
Some(bin.to_string_lossy().into_owned()),
None,
None,
None,
None,
false,
vec!["BAMBOO_TEST_FORWARD_ME".to_string()],
);
let (sink, _rx) = EventSink::channel();
let outcome = exec
.run(
run_spec("hi"),
sink,
SteerInbox::disconnected(),
CancellationToken::new(),
)
.await;
std::env::remove_var("FAKE_SECRET_API_KEY");
std::env::remove_var("BAMBOO_TEST_FORWARD_ME");
assert_eq!(outcome.status, TerminalStatus::Completed);
let dump = std::fs::read_to_string(dir.path().join("env-dump.txt")).unwrap();
assert!(
!dump.contains("FAKE_SECRET_API_KEY"),
"canary secret leaked into the child env:\n{dump}"
);
assert!(
dump.contains("BAMBOO_TEST_FORWARD_ME=forwarded-value"),
"forward_env-listed var missing from the child env:\n{dump}"
);
assert!(dump.contains("PATH="), "PATH missing from the child env");
}
#[tokio::test]
async fn cancel_kills_the_child_process_group() {
let dir = tempfile::tempdir().unwrap();
let bin = write_stub(
dir.path(),
r#"
read -r _assignment
echo '{"type":"system","session_id":"s1"}'
sleep 30
echo '{"type":"result","subtype":"success","result":"too late"}'
"#,
);
let (sink, _rx) = EventSink::channel();
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
let run = tokio::spawn(async move {
executor(bin)
.run(
run_spec("a long task"),
sink,
SteerInbox::disconnected(),
cancel_clone,
)
.await
});
tokio::time::sleep(Duration::from_millis(200)).await;
cancel.cancel();
let outcome = tokio::time::timeout(Duration::from_secs(15), run)
.await
.expect("run finished within the shutdown bound")
.unwrap();
assert_eq!(outcome.status, TerminalStatus::Cancelled);
}
#[tokio::test]
async fn oversized_single_stdout_line_parses() {
let dir = tempfile::tempdir().unwrap();
let big_text = "x".repeat(150_000);
let script = format!(
r#"
read -r _assignment
echo '{{"type":"assistant","message":{{"content":[{{"type":"text","text":"{big_text}"}}]}}}}'
echo '{{"type":"result","subtype":"success","result":"ok"}}'
"#
);
let bin = write_stub(dir.path(), &script);
let (sink, mut rx) = EventSink::channel();
let outcome = executor(bin)
.run(
run_spec("emit a huge line"),
sink,
SteerInbox::disconnected(),
CancellationToken::new(),
)
.await;
assert_eq!(outcome.status, TerminalStatus::Completed);
assert_eq!(outcome.result.as_deref(), Some("ok"));
let mut saw_big_token = false;
while let Ok(e) = rx.try_recv() {
if e["type"] == "token" {
assert_eq!(e["content"].as_str().unwrap().len(), 150_000);
saw_big_token = true;
}
}
assert!(saw_big_token, "expected the oversized token event");
}
#[tokio::test]
async fn missing_binary_errors_without_hanging() {
let (sink, _rx) = EventSink::channel();
let outcome = ClaudeCodeExecutor::new(
Some("/nonexistent/definitely-not-claude".into()),
None,
None,
None,
None,
false,
Vec::new(),
)
.run(
run_spec("hi"),
sink,
SteerInbox::disconnected(),
CancellationToken::new(),
)
.await;
assert_eq!(outcome.status, TerminalStatus::Error);
assert!(outcome.error.unwrap().contains("spawn"));
}
#[test]
fn truncate_chars_caps_and_reports_dropped_count() {
let long = "a".repeat(50);
let out = truncate_chars(&long, 10);
assert!(out.starts_with(&"a".repeat(10)));
assert!(out.contains("40 more chars"));
assert_eq!(truncate_chars("short", 10), "short");
}
#[test]
fn tool_result_text_flattens_string_and_block_array() {
assert_eq!(tool_result_text(Some(&json!("plain"))), "plain".to_string());
assert_eq!(
tool_result_text(Some(
&json!([{"type":"text","text":"a"},{"type":"text","text":"b"}])
)),
"a\nb".to_string()
);
assert_eq!(tool_result_text(None), "".to_string());
}
const MULTI_RUN_STUB: &str = r#"
DIR="$(cd "$(dirname "$0")" && pwd)"
N=$(cat "$DIR/count" 2>/dev/null || echo 0)
N=$((N+1))
echo "$N" > "$DIR/count"
printf '%s\n' "$@" > "$DIR/argv-$N.txt"
read -r line
printf '%s\n' "$line" > "$DIR/stdin-$N.txt"
if [ "$N" = "1" ]; then
echo '{"type":"system","session_id":"s-1"}'
echo '{"type":"result","subtype":"success","result":"first"}'
elif [ "$N" = "2" ]; then
echo '{"type":"system","session_id":"s-2"}'
echo '{"type":"result","subtype":"success","result":"second"}'
else
echo '{"type":"result","subtype":"success","result":"third"}'
fi
"#;
#[tokio::test]
async fn resume_state_written_reused_and_cleared_across_activations() {
let bin_dir = tempfile::tempdir().unwrap();
let state_dir = tempfile::tempdir().unwrap();
let bin = write_stub(bin_dir.path(), MULTI_RUN_STUB);
let exec = executor_with_state(bin, Some(state_dir.path().to_path_buf()), None);
let state_path = state_dir.path().join("claude-code-session.json");
let (sink, _rx) = EventSink::channel();
let outcome = exec
.run(
run_spec("task one"),
sink,
SteerInbox::disconnected(),
CancellationToken::new(),
)
.await;
assert_eq!(outcome.status, TerminalStatus::Completed);
assert!(!read_argv(bin_dir.path(), "argv-1.txt").contains("--resume"));
let state: Value =
serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap();
assert_eq!(state["session_id"], "s-1");
let (sink, _rx) = EventSink::channel();
let messages = vec![msg("user", "task one"), msg("assistant", "did it")];
let outcome = exec
.run(
run_spec_with_messages("task two", messages),
sink,
SteerInbox::disconnected(),
CancellationToken::new(),
)
.await;
assert_eq!(outcome.status, TerminalStatus::Completed);
let argv2 = read_argv(bin_dir.path(), "argv-2.txt");
assert!(argv2.contains("--resume"));
assert!(argv2.contains("s-1"));
let state: Value =
serde_json::from_str(&std::fs::read_to_string(&state_path).unwrap()).unwrap();
assert_eq!(state["session_id"], "s-2");
assert_eq!(stdin_body(bin_dir.path(), "stdin-2.txt"), "task two");
let (sink, _rx) = EventSink::channel();
let outcome = exec
.run(
run_spec("task three"),
sink,
SteerInbox::disconnected(),
CancellationToken::new(),
)
.await;
assert_eq!(outcome.status, TerminalStatus::Completed);
assert!(!read_argv(bin_dir.path(), "argv-3.txt").contains("--resume"));
assert!(!state_path.exists());
}
#[tokio::test]
async fn fallback_rehydration_renders_preamble_without_state() {
let bin_dir = tempfile::tempdir().unwrap();
let state_dir = tempfile::tempdir().unwrap();
let bin = write_stub(
bin_dir.path(),
r#"
DIR="$(cd "$(dirname "$0")" && pwd)"
printf '%s\n' "$@" > "$DIR/argv.txt"
read -r line
printf '%s\n' "$line" > "$DIR/stdin.txt"
echo '{"type":"result","subtype":"success","result":"ok"}'
"#,
);
let exec = executor_with_state(bin, Some(state_dir.path().to_path_buf()), None);
let messages = vec![
msg("user", "please do X"),
msg("assistant", "sure, doing X"),
msg("user", "continue"),
];
let (sink, _rx) = EventSink::channel();
let outcome = exec
.run(
run_spec_with_messages("continue", messages),
sink,
SteerInbox::disconnected(),
CancellationToken::new(),
)
.await;
assert_eq!(outcome.status, TerminalStatus::Completed);
assert!(!read_argv(bin_dir.path(), "argv.txt").contains("--resume"));
let body = stdin_body(bin_dir.path(), "stdin.txt");
assert!(body.contains("## Prior conversation (rehydrated)"));
assert!(body.contains("please do X"));
assert!(body.contains("## Current task"));
assert!(!body.contains("truncated"));
assert_eq!(body.matches("continue").count(), 1);
}
#[tokio::test]
async fn fallback_rehydration_truncates_over_message_cap() {
let bin_dir = tempfile::tempdir().unwrap();
let state_dir = tempfile::tempdir().unwrap();
let bin = write_stub(
bin_dir.path(),
r#"
DIR="$(cd "$(dirname "$0")" && pwd)"
read -r line
printf '%s\n' "$line" > "$DIR/stdin.txt"
echo '{"type":"result","subtype":"success","result":"ok"}'
"#,
);
let exec = executor_with_state(bin, Some(state_dir.path().to_path_buf()), None);
let mut messages: Vec<Value> = (0..50)
.map(|i| {
let role = if i % 2 == 0 { "user" } else { "assistant" };
msg(role, &format!("message {i}"))
})
.collect();
messages.push(msg("user", "final ask"));
let (sink, _rx) = EventSink::channel();
let outcome = exec
.run(
run_spec_with_messages("final ask", messages),
sink,
SteerInbox::disconnected(),
CancellationToken::new(),
)
.await;
assert_eq!(outcome.status, TerminalStatus::Completed);
let body = stdin_body(bin_dir.path(), "stdin.txt");
assert!(body.contains("truncated"));
assert!(!body.contains("message 0"));
assert!(body.contains("message 49"));
}
#[tokio::test]
async fn fallback_rehydration_truncates_oversized_single_message() {
let bin_dir = tempfile::tempdir().unwrap();
let state_dir = tempfile::tempdir().unwrap();
let bin = write_stub(
bin_dir.path(),
r#"
DIR="$(cd "$(dirname "$0")" && pwd)"
read -r line
printf '%s\n' "$line" > "$DIR/stdin.txt"
echo '{"type":"result","subtype":"success","result":"ok"}'
"#,
);
let exec = executor_with_state(bin, Some(state_dir.path().to_path_buf()), None);
let huge = "x".repeat(30_000);
let messages = vec![msg("user", &huge), msg("user", "current ask")];
let (sink, _rx) = EventSink::channel();
let outcome = exec
.run(
run_spec_with_messages("current ask", messages),
sink,
SteerInbox::disconnected(),
CancellationToken::new(),
)
.await;
assert_eq!(outcome.status, TerminalStatus::Completed);
let body = stdin_body(bin_dir.path(), "stdin.txt");
assert!(body.contains("truncated"));
assert!(body.len() < huge.len());
}
#[tokio::test]
async fn workspace_mismatch_state_treated_as_unusable_falls_back() {
let bin_dir = tempfile::tempdir().unwrap();
let state_dir = tempfile::tempdir().unwrap();
let workspace_dir = tempfile::tempdir().unwrap();
std::fs::write(
state_dir.path().join("claude-code-session.json"),
serde_json::to_vec(&json!({
"session_id": "stale-id",
"workspace": "/some/other/workspace",
"updated_at": chrono::Utc::now(),
}))
.unwrap(),
)
.unwrap();
let bin = write_stub(
bin_dir.path(),
r#"
DIR="$(cd "$(dirname "$0")" && pwd)"
printf '%s\n' "$@" > "$DIR/argv.txt"
read -r _line
echo '{"type":"result","subtype":"success","result":"ok"}'
"#,
);
let exec = executor_with_state(
bin,
Some(state_dir.path().to_path_buf()),
Some(workspace_dir.path().to_string_lossy().into_owned()),
);
let messages = vec![msg("user", "hi"), msg("user", "go")];
let (sink, _rx) = EventSink::channel();
let outcome = exec
.run(
run_spec_with_messages("go", messages),
sink,
SteerInbox::disconnected(),
CancellationToken::new(),
)
.await;
assert_eq!(outcome.status, TerminalStatus::Completed);
let argv = read_argv(bin_dir.path(), "argv.txt");
assert!(!argv.contains("--resume"));
assert!(!argv.contains("stale-id"));
}
#[tokio::test]
async fn resume_failure_retries_once_with_fallback_history() {
let bin_dir = tempfile::tempdir().unwrap();
let state_dir = tempfile::tempdir().unwrap();
std::fs::write(
state_dir.path().join("claude-code-session.json"),
serde_json::to_vec(&json!({
"session_id": "dead-id",
"workspace": null,
"updated_at": chrono::Utc::now(),
}))
.unwrap(),
)
.unwrap();
let bin = write_stub(
bin_dir.path(),
r#"
DIR="$(cd "$(dirname "$0")" && pwd)"
N=$(cat "$DIR/count" 2>/dev/null || echo 0)
N=$((N+1))
echo "$N" > "$DIR/count"
printf '%s\n' "$@" > "$DIR/argv-$N.txt"
case "$*" in
*--resume*)
exit 1
;;
*)
read -r line
printf '%s\n' "$line" > "$DIR/stdin-$N.txt"
echo '{"type":"system","session_id":"s-fresh"}'
echo '{"type":"result","subtype":"success","result":"recovered"}'
;;
esac
"#,
);
let exec = executor_with_state(bin, Some(state_dir.path().to_path_buf()), None);
let messages = vec![
msg("user", "earlier context"),
msg("user", "please continue"),
];
let (sink, _rx) = EventSink::channel();
let outcome = exec
.run(
run_spec_with_messages("please continue", messages),
sink,
SteerInbox::disconnected(),
CancellationToken::new(),
)
.await;
assert_eq!(outcome.status, TerminalStatus::Completed);
assert_eq!(outcome.result.as_deref(), Some("recovered"));
assert_eq!(read_argv(bin_dir.path(), "count").trim(), "2");
let argv1 = read_argv(bin_dir.path(), "argv-1.txt");
assert!(argv1.contains("--resume"));
assert!(argv1.contains("dead-id"));
let argv2 = read_argv(bin_dir.path(), "argv-2.txt");
assert!(!argv2.contains("--resume"));
let body2 = stdin_body(bin_dir.path(), "stdin-2.txt");
assert!(body2.contains("## Prior conversation (rehydrated)"));
assert!(body2.contains("earlier context"));
let state: Value = serde_json::from_str(
&std::fs::read_to_string(state_dir.path().join("claude-code-session.json")).unwrap(),
)
.unwrap();
assert_eq!(state["session_id"], "s-fresh");
let leftover_tmp = std::fs::read_dir(state_dir.path())
.unwrap()
.filter_map(|e| e.ok())
.any(|e| e.file_name().to_string_lossy().contains(".tmp"));
assert!(!leftover_tmp);
}
}