use crate::backend::{
AgentBackend, AgentEvent, AgentSession, PromptMode, SessionExit, SessionSpec,
};
use crate::error::{EngineError, Result};
use crate::stream_bounds::{drain_to_tail, BoundedLines, STDERR_TAIL_CAP};
use crate::types::TokenUsage;
use serde_json::{json, Value};
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, Mutex};
use tokio::io::AsyncWriteExt;
use tokio::process::{Child, ChildStdin, ChildStdout};
use tokio::task::JoinHandle;
const SUMMARY_MAX_CHARS: usize = 200;
const STDERR_TAIL_CHARS: usize = 500;
#[cfg(windows)]
pub(crate) mod win_job {
use std::os::windows::io::RawHandle;
use windows::core::PCWSTR;
use windows::Win32::Foundation::{CloseHandle, HANDLE};
use windows::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
SetInformationJobObject, TerminateJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
};
#[derive(Debug)]
pub(crate) struct JobHandle {
job: HANDLE,
}
unsafe impl Send for JobHandle {}
unsafe impl Sync for JobHandle {}
impl JobHandle {
pub(crate) fn create_and_assign(child_handle: RawHandle) -> windows::core::Result<Self> {
let job = unsafe { CreateJobObjectW(None, PCWSTR::null())? };
let guard = JobHandle { job };
let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
unsafe {
SetInformationJobObject(
guard.job,
JobObjectExtendedLimitInformation,
&info as *const _ as *const core::ffi::c_void,
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
)?;
}
unsafe {
AssignProcessToJobObject(guard.job, HANDLE(child_handle))?;
}
Ok(guard)
}
pub(crate) fn kill(&self) {
let _ = unsafe { TerminateJobObject(self.job, 1) };
}
}
impl Drop for JobHandle {
fn drop(&mut self) {
let _ = unsafe { CloseHandle(self.job) };
}
}
}
pub fn discover_claude_binary(configured: Option<&str>) -> Result<PathBuf> {
let mut candidates: Vec<PathBuf> = Vec::new();
candidates.push(PathBuf::from("claude"));
#[cfg(windows)]
{
candidates.push(PathBuf::from("claude.cmd"));
candidates.push(PathBuf::from("claude.exe"));
}
candidates.extend(fallback_candidates());
discover_claude_binary_from(
configured,
std::env::var_os("KRANZ_CLAUDE_BIN").as_deref(),
candidates,
probe_version,
)
}
fn discover_claude_binary_from(
configured: Option<&str>,
env_bin: Option<&std::ffi::OsStr>,
candidates: Vec<PathBuf>,
mut probe: impl FnMut(&Path) -> std::result::Result<String, String>,
) -> Result<PathBuf> {
let explicit = if let Some(configured) = configured.filter(|s| !s.trim().is_empty()) {
if !Path::new(configured).is_absolute() {
return Err(EngineError::Config(format!(
"configured claude binary {configured:?} must be an absolute path: a relative \
path resolves against the process working directory, so which program runs \
depends on where kranz was invoked"
)));
}
Some((PathBuf::from(configured), "claudeBinary"))
} else {
env_bin
.filter(|path| !path.is_empty())
.map(|path| (PathBuf::from(path), "KRANZ_CLAUDE_BIN"))
};
if let Some((candidate, source)) = explicit {
return probe(&candidate).map(|_| candidate.clone()).map_err(|why| {
EngineError::Config(format!(
"{source} override {} failed: {why}; refusing to fall back to another executable",
candidate.display()
))
});
}
let mut deduped: Vec<PathBuf> = Vec::new();
for candidate in candidates {
if !deduped.contains(&candidate) {
deduped.push(candidate);
}
}
let mut attempts: Vec<String> = Vec::new();
for candidate in deduped {
match probe(&candidate) {
Ok(_version) => return Ok(candidate),
Err(why) => attempts.push(format!("{} ({why})", candidate.display())),
}
}
Err(EngineError::Config(format!(
"no working claude binary found; tried: {}. Install Claude Code \
(npm install -g @anthropic-ai/claude-code) or point kranz at it via \
the claudeBinary config field or the KRANZ_CLAUDE_BIN environment \
variable.",
attempts.join(", ")
)))
}
#[cfg(not(windows))]
fn fallback_candidates() -> Vec<PathBuf> {
let home = std::env::var_os("HOME").map(PathBuf::from);
let mut out = Vec::new();
if let Some(home) = &home {
out.push(home.join(".npm-global").join("bin").join("claude"));
}
out.push(PathBuf::from("/opt/homebrew/bin/claude"));
out.push(PathBuf::from("/usr/local/bin/claude"));
if let Some(home) = &home {
out.push(home.join(".local").join("bin").join("claude"));
}
out
}
#[cfg(windows)]
fn fallback_candidates() -> Vec<PathBuf> {
let mut out = Vec::new();
if let Some(profile) = std::env::var_os("USERPROFILE").map(PathBuf::from) {
for dir in [
profile.join("AppData").join("Roaming").join("npm"),
profile.join(".npm-global").join("bin"),
profile.join(".local").join("bin"),
] {
for name in ["claude.cmd", "claude.exe", "claude"] {
out.push(dir.join(name));
}
}
}
out
}
const VERSION_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
fn probe_version(binary: &Path) -> std::result::Result<String, String> {
crate::backend_probe::probe_version(binary, VERSION_PROBE_TIMEOUT)
}
pub const CLAUDE_CREDENTIALS_ENTRY: &str = ".credentials.json";
pub fn claude_min_config_entries() -> &'static [&'static str] {
&[CLAUDE_CREDENTIALS_ENTRY]
}
pub const SCRATCH_ROOT_ENV: &str = "KRANZ_SCRATCH_ROOT";
pub fn scratch_root_base() -> std::path::PathBuf {
match std::env::var_os(SCRATCH_ROOT_ENV).map(std::path::PathBuf::from) {
Some(root) if root.is_absolute() => root,
Some(root) => {
tracing::warn!(
override_path = %root.display(),
variable = SCRATCH_ROOT_ENV,
"ignoring a relative scratch-root override; scratch paths must be absolute \
because container mounts and sandbox profiles resolve them elsewhere"
);
std::env::temp_dir()
}
None => std::env::temp_dir(),
}
}
pub fn scratch_home_root(session_id: &str) -> std::path::PathBuf {
scratch_root_base().join(format!("kranz-worker-home-{session_id}"))
}
pub fn seed_worker_scratch_home(
scratch_root: &std::path::Path,
real_home: Option<&std::path::Path>,
real_config_dir: Option<&std::path::Path>,
) -> std::io::Result<(std::path::PathBuf, std::path::PathBuf)> {
let home_dir = scratch_root.join("home");
let config_dir = home_dir.join(".claude");
std::fs::create_dir_all(&config_dir)?;
let source_config_dir = real_config_dir
.map(std::path::Path::to_path_buf)
.or_else(|| real_home.map(|home| home.join(".claude")));
if let Some(source_config_dir) = source_config_dir {
for entry in claude_min_config_entries() {
let src = source_config_dir.join(entry);
if src.is_file() {
std::fs::copy(&src, config_dir.join(entry))?;
}
}
}
#[cfg(target_os = "macos")]
if let Some(real_home) = real_home {
let real_keychains = real_home.join("Library").join("Keychains");
if real_keychains.is_dir() {
let scratch_library = home_dir.join("Library");
std::fs::create_dir_all(&scratch_library)?;
let link = scratch_library.join("Keychains");
if !link.exists() {
std::os::unix::fs::symlink(&real_keychains, &link)?;
}
}
}
Ok((home_dir, config_dir))
}
pub fn build_args(spec: &SessionSpec) -> Vec<String> {
let mut args: Vec<String> = vec![
"-p".into(),
"--setting-sources".into(),
"user".into(),
"--output-format".into(),
"stream-json".into(),
"--verbose".into(),
"--model".into(),
spec.model.clone(),
"--effort".into(),
spec.effort.clone(),
];
if let Some(system) = &spec.append_system_prompt {
args.push("--append-system-prompt".into());
args.push(system.clone());
}
match &spec.resume {
Some(previous) => {
args.push("--resume".into());
args.push(previous.clone());
}
None => {
args.push("--session-id".into());
args.push(spec.session_id.clone());
}
}
if let Some(mode) = &spec.permission_mode {
args.push("--permission-mode".into());
args.push(mode.clone());
}
if !spec.allowed_tools.is_empty() {
args.push("--allowedTools".into());
args.extend(spec.allowed_tools.iter().cloned());
}
if !spec.disallowed_tools.is_empty() {
args.push("--disallowedTools".into());
args.extend(spec.disallowed_tools.iter().cloned());
}
if !spec.tools.is_empty() {
args.push("--tools".into());
args.extend(spec.tools.iter().cloned());
}
if let Some(settings) = &spec.settings_json {
args.push("--settings".into());
args.push(settings.to_string()); }
if let Some(schema) = &spec.json_schema {
args.push("--json-schema".into());
args.push(schema.to_string()); }
if let Some(budget) = spec.max_budget_usd {
args.push("--max-budget-usd".into());
args.push(budget.to_string());
}
match &spec.prompt {
PromptMode::Streaming(_) => {
args.push("--input-format".into());
args.push("stream-json".into());
}
PromptMode::SingleShot(prompt) => {
args.push(prompt.clone());
}
}
args
}
pub fn sandbox_command(
profile_path: &Path,
binary: &Path,
args: &[String],
) -> (PathBuf, Vec<String>) {
let mut full_args: Vec<String> = vec!["-f".to_string(), profile_path.display().to_string()];
full_args.push(binary.display().to_string());
full_args.extend(args.iter().cloned());
(PathBuf::from("sandbox-exec"), full_args)
}
pub fn user_message_line(text: &str) -> String {
let value = json!({
"type": "user",
"message": {
"role": "user",
"content": [{ "type": "text", "text": text }],
},
});
format!("{value}\n")
}
pub fn parse_stream_line(line: &str) -> Vec<AgentEvent> {
match serde_json::from_str::<Value>(line) {
Ok(value) => parse_stream_value(value),
Err(_) => vec![AgentEvent::Other {
raw: json!({ "unparsed": line }),
}],
}
}
pub fn parse_stream_value(value: Value) -> Vec<AgentEvent> {
let line_type = value.get("type").and_then(Value::as_str).unwrap_or("");
match line_type {
"system" if value.get("subtype").and_then(Value::as_str) == Some("init") => {
vec![AgentEvent::Init {
session_id: str_field(&value, "session_id"),
model: str_field(&value, "model"),
raw: value,
}]
}
"assistant" => parse_assistant(value),
"user" => parse_user(value),
"result" => vec![parse_result(value)],
_ => vec![AgentEvent::Other { raw: value }],
}
}
fn str_field(value: &Value, key: &str) -> String {
value
.get(key)
.and_then(Value::as_str)
.unwrap_or_default()
.to_string()
}
fn parse_assistant(value: Value) -> Vec<AgentEvent> {
let Some(blocks) = value
.pointer("/message/content")
.and_then(Value::as_array)
.cloned()
else {
return vec![AgentEvent::Other { raw: value }];
};
let mut events = Vec::new();
for block in &blocks {
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() {
events.push(AgentEvent::Text {
text: text.to_string(),
raw: value.clone(),
});
}
}
Some("tool_use") => {
let tool = block
.get("name")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
let summary = tool_use_summary(&tool, block.get("input"));
events.push(AgentEvent::ToolUse {
tool,
summary,
raw: value.clone(),
});
}
_ => events.push(AgentEvent::Other { raw: value.clone() }),
}
}
events
}
fn tool_use_summary(tool: &str, input: Option<&Value>) -> String {
let null = Value::Null;
let input = input.unwrap_or(&null);
let picked = match tool {
"Bash" => input.get("command").and_then(Value::as_str),
"Edit" | "Write" | "Read" => input.get("file_path").and_then(Value::as_str),
_ => None,
};
match picked {
Some(text) => text.to_string(),
None => truncate_chars(&input.to_string(), SUMMARY_MAX_CHARS),
}
}
fn parse_user(value: Value) -> Vec<AgentEvent> {
let blocks = value
.pointer("/message/content")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let mut events = Vec::new();
for block in &blocks {
if block.get("type").and_then(Value::as_str) != Some("tool_result") {
continue;
}
let text = tool_result_text(block);
let is_error = block
.get("is_error")
.and_then(Value::as_bool)
.unwrap_or(false);
let lower = text.to_lowercase();
let denied = (is_error && lower.contains("permission"))
|| (lower.contains("hook")
&& (lower.contains("block")
|| lower.contains("denied")
|| lower.contains("reject")))
|| (is_error && lower.contains("requires approval"))
|| (is_error && lower.contains("contains expansion"))
|| (lower.contains("output redirection") && lower.contains("blocked"));
events.push(AgentEvent::ToolResult {
tool: None,
denied,
summary: truncate_chars(&text, SUMMARY_MAX_CHARS),
raw: value.clone(),
});
}
if events.is_empty() {
return vec![AgentEvent::Other { raw: value }];
}
events
}
fn tool_result_text(block: &Value) -> String {
match block.get("content") {
Some(Value::String(text)) => text.clone(),
Some(Value::Array(parts)) => parts
.iter()
.filter_map(|part| {
if part.get("type").and_then(Value::as_str) == Some("text") {
part.get("text").and_then(Value::as_str)
} else {
None
}
})
.collect::<Vec<_>>()
.join("\n"),
_ => String::new(),
}
}
fn parse_result(value: Value) -> AgentEvent {
let usage_field = |key: &str| {
value
.pointer(&format!("/usage/{key}"))
.and_then(Value::as_u64)
.unwrap_or(0)
};
AgentEvent::Result {
text: value
.get("result")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
is_error: value
.get("is_error")
.and_then(Value::as_bool)
.unwrap_or(false),
usage: TokenUsage {
input: usage_field("input_tokens"),
output: usage_field("output_tokens"),
cache_read: usage_field("cache_read_input_tokens"),
cache_write: usage_field("cache_creation_input_tokens"),
},
cost_usd: value.get("total_cost_usd").and_then(Value::as_f64),
num_turns: value
.get("num_turns")
.and_then(Value::as_u64)
.map(|n| n as u32),
raw: value,
}
}
fn truncate_chars(text: &str, max: usize) -> String {
if text.chars().count() <= max {
text.to_string()
} else {
text.chars().take(max).collect()
}
}
fn last_chars(text: &str, max: usize) -> String {
let chars: Vec<char> = text.chars().collect();
let start = chars.len().saturating_sub(max);
chars[start..].iter().collect()
}
#[derive(Debug, Clone)]
pub struct ClaudeBackend {
binary: PathBuf,
}
impl ClaudeBackend {
pub fn new(binary: impl Into<PathBuf>) -> Self {
ClaudeBackend {
binary: binary.into(),
}
}
pub fn discover(configured: Option<&str>) -> Result<Self> {
Ok(ClaudeBackend {
binary: discover_claude_binary(configured)?,
})
}
pub fn binary(&self) -> &Path {
&self.binary
}
}
const CLAUDE_AUTH_ENV: &str = "ANTHROPIC_API_KEY";
const CLAUDE_TMPDIR_ENV: &str = "CLAUDE_CODE_TMPDIR";
fn pin_claude_tmpdir(mut env: HashMap<String, String>) -> HashMap<String, String> {
if let Some(tmpdir) = env.get("TMPDIR").cloned() {
env.insert(CLAUDE_TMPDIR_ENV.to_string(), tmpdir);
}
env
}
fn claude_child_env(spec: &SessionSpec) -> HashMap<String, String> {
if spec.env.contains_key("HOME") {
return pin_claude_tmpdir(crate::agent_env::agent_session_env(
&spec.env,
&spec.session_id,
Some(CLAUDE_AUTH_ENV),
));
}
let real_home = std::env::var_os("HOME").map(PathBuf::from);
let real_config_dir = std::env::var_os("CLAUDE_CONFIG_DIR").map(PathBuf::from);
let scratch_root = scratch_home_root(&spec.session_id);
match seed_worker_scratch_home(
&scratch_root,
real_home.as_deref(),
real_config_dir.as_deref(),
) {
Ok((home, _config_dir)) => {
tracing::info!(
session_id = %spec.session_id,
decision = "scratch-seeded",
"session spec carried no relocated HOME; spawning into a freshly seeded \
scratch HOME (agent-env-clear)"
);
pin_claude_tmpdir(crate::agent_env::session_env_with_home(
&spec.env,
&spec.session_id,
Some(CLAUDE_AUTH_ENV),
&home,
))
}
Err(e) => {
tracing::warn!(
session_id = %spec.session_id,
error = %e,
"scratch HOME seeding failed; session spawns into an empty scratch HOME \
and will fail auth loudly if no API key is injected"
);
pin_claude_tmpdir(crate::agent_env::agent_session_env(
&spec.env,
&spec.session_id,
Some(CLAUDE_AUTH_ENV),
))
}
}
}
#[async_trait::async_trait]
impl AgentBackend for ClaudeBackend {
async fn start(&self, spec: SessionSpec) -> Result<Box<dyn AgentSession>> {
let streaming = matches!(spec.prompt, PromptMode::Streaming(_));
let args = build_args(&spec);
let child_env = claude_child_env(&spec);
#[cfg(windows)]
let mut appcontainer_lease = None;
if let Some(resolved) = &spec.sandbox {
crate::sandbox::validate_git_config_protection(
&resolved.inputs,
matches!(
resolved.backend,
crate::sandbox::SandboxBackend::Bubblewrap
| crate::sandbox::SandboxBackend::Container
),
)?;
}
let mut command = match &spec.sandbox {
Some(resolved)
if resolved.backend == crate::sandbox::SandboxBackend::Seatbelt
&& cfg!(target_os = "macos") =>
{
let profile = crate::sandbox::generate_profile(&resolved.inputs);
let profile_dir = resolved.inputs.mission_dir.join("runs");
let profile_path = crate::sandbox::write_profile_file(&profile_dir, &profile)
.or_else(|_| {
crate::sandbox::write_profile_file(&resolved.inputs.tmpdir, &profile)
})
.map_err(|e| {
EngineError::Backend(format!("failed to write sandbox profile: {e}"))
})?;
let (program, sandboxed_args) = sandbox_command(&profile_path, &self.binary, &args);
let mut command = tokio::process::Command::new(program);
command.args(&sandboxed_args);
command
}
Some(resolved)
if resolved.backend == crate::sandbox::SandboxBackend::Bubblewrap
&& cfg!(target_os = "linux") =>
{
let mut command = tokio::process::Command::new("bwrap");
command.args(crate::sandbox::bubblewrap_args(
&resolved.inputs,
&self.binary,
&args,
)?);
command
}
Some(resolved) if resolved.backend == crate::sandbox::SandboxBackend::Container => {
let container = resolved.container.as_ref().ok_or_else(|| {
EngineError::Backend(
"resolved container sandbox is missing its runtime/image spec".to_string(),
)
})?;
let mut command = tokio::process::Command::new(container.runtime.binary());
command.args(crate::sandbox_container::container_run_args(
&resolved.inputs,
container,
&self.binary,
&args,
spec.env
.get(crate::egress_proxy::HTTPS_PROXY_ENV)
.map(String::as_str),
));
command
}
#[cfg(windows)]
Some(resolved) if resolved.backend == crate::sandbox::SandboxBackend::AppContainer => {
let prepared = crate::appcontainer_windows::prepare_launch(
&resolved.inputs,
&self.binary,
&args,
&child_env,
)?;
appcontainer_lease = Some(prepared.lease);
let mut command = tokio::process::Command::new(prepared.program);
command.args(prepared.args);
command
}
Some(resolved) => {
return Err(EngineError::Backend(format!(
"resolved sandbox backend {:?} is unavailable on target_os={}",
resolved.backend,
std::env::consts::OS
)));
}
None => {
let mut command = tokio::process::Command::new(&self.binary);
command.args(&args);
command
}
};
command
.current_dir(&spec.cwd)
.env_clear()
.envs(child_env)
.stdin(if streaming {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
#[cfg(unix)]
command.process_group(0);
let mut child = command.spawn().map_err(|e| {
EngineError::Backend(format!("failed to spawn {}: {e}", self.binary.display()))
})?;
#[cfg(windows)]
let job = match child.raw_handle() {
Some(handle) => match win_job::JobHandle::create_and_assign(handle) {
Ok(job) => Some(job),
Err(e) => {
tracing::warn!(error = %e, "failed to create Job Object for claude child; \
tree-kill on abort will be unavailable");
None
}
},
None => None,
};
let stdout = child
.stdout
.take()
.ok_or_else(|| EngineError::Backend("claude child has no stdout pipe".to_string()))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| EngineError::Backend("claude child has no stderr pipe".to_string()))?;
let mut stdin = if streaming { child.stdin.take() } else { None };
let stderr_buf = Arc::new(Mutex::new(String::new()));
let stderr_task = {
let buf = Arc::clone(&stderr_buf);
tokio::spawn(async move {
let tail = drain_to_tail(stderr, STDERR_TAIL_CAP).await;
*buf.lock().expect("stderr buffer lock") = tail;
})
};
if let PromptMode::Streaming(initial) = &spec.prompt {
let Some(handle) = stdin.as_mut() else {
return Err(EngineError::Backend(
"claude child has no stdin pipe for streaming input".to_string(),
));
};
handle
.write_all(user_message_line(initial).as_bytes())
.await?;
handle.flush().await?;
}
Ok(Box::new(ClaudeSession {
session_id: spec.session_id.clone(),
streaming,
max_turns: spec.max_turns,
child,
#[cfg(windows)]
job,
#[cfg(windows)]
appcontainer_lease,
stdin,
lines: BoundedLines::new(stdout),
stderr_buf,
stderr_task: Some(stderr_task),
queue: VecDeque::new(),
assistant_ids: HashSet::new(),
saw_result: false,
saw_success_result: false,
accounted_cost_usd: 0.0,
exit: None,
}))
}
}
#[cfg(unix)]
pub(crate) fn kill_group(pgid: i32) -> bool {
debug_assert!(pgid > 0, "kill_group needs a positive group id");
unsafe { libc::kill(-pgid, libc::SIGKILL) == 0 }
}
#[cfg(unix)]
pub(crate) fn kill_unreaped_group(child: &Child) {
if let Some(pid) = child.id().and_then(|pid| i32::try_from(pid).ok()) {
if pid > 0 {
kill_group(pid);
}
}
}
pub struct ClaudeSession {
session_id: String,
streaming: bool,
max_turns: Option<u32>,
child: Child,
#[cfg(windows)]
job: Option<win_job::JobHandle>,
#[cfg(windows)]
appcontainer_lease: Option<crate::appcontainer_windows::AppContainerLease>,
stdin: Option<ChildStdin>,
lines: BoundedLines<ChildStdout>,
stderr_buf: Arc<Mutex<String>>,
stderr_task: Option<JoinHandle<()>>,
queue: VecDeque<AgentEvent>,
assistant_ids: HashSet<String>,
saw_result: bool,
saw_success_result: bool,
accounted_cost_usd: f64,
exit: Option<SessionExit>,
}
#[cfg(unix)]
impl Drop for ClaudeSession {
fn drop(&mut self) {
kill_unreaped_group(&self.child);
}
}
impl ClaudeSession {
fn cleanup_appcontainer(&mut self) -> Result<()> {
#[cfg(windows)]
{
if let Some(lease) = self.appcontainer_lease.as_mut() {
lease.cleanup()?;
}
self.appcontainer_lease = None;
}
Ok(())
}
fn observe(&mut self, event: &mut AgentEvent) {
match event {
AgentEvent::Init { session_id, .. } => {
if self.session_id != *session_id {
self.accounted_cost_usd = 0.0;
}
self.session_id = session_id.clone();
}
AgentEvent::Result {
is_error,
cost_usd,
raw,
..
} => {
if self.streaming {
if let Some(id) = raw.get("session_id").and_then(Value::as_str) {
if id != self.session_id {
self.accounted_cost_usd = 0.0;
self.session_id = id.to_string();
}
}
if let Some(total) = *cost_usd {
*cost_usd = if total.is_finite() && total >= 0.0 {
let delta = (total - self.accounted_cost_usd).max(0.0);
self.accounted_cost_usd = self.accounted_cost_usd.max(total);
Some(delta)
} else {
None
};
}
}
self.saw_result = true;
if !*is_error {
self.saw_success_result = true;
}
}
AgentEvent::Other { raw }
if raw.get("type").and_then(Value::as_str) == Some("system")
&& raw.get("subtype").and_then(Value::as_str) == Some("conversation_reset") =>
{
self.accounted_cost_usd = 0.0;
if let Some(id) = raw.get("session_id").and_then(Value::as_str) {
self.session_id = id.to_string();
}
}
_ => {}
}
}
fn over_turn_budget(&mut self, value: &Value) -> bool {
let Some(max_turns) = self.max_turns else {
return false;
};
if value.get("type").and_then(Value::as_str) != Some("assistant") {
return false;
}
let Some(id) = value.pointer("/message/id").and_then(Value::as_str) else {
return false;
};
if self.assistant_ids.insert(id.to_string()) {
self.assistant_ids.len() > max_turns as usize
} else {
false
}
}
async fn kill_child(&mut self) {
self.stdin = None;
#[cfg(unix)]
{
let pgid = self
.child
.id()
.and_then(|pid| i32::try_from(pid).ok())
.filter(|pid| *pid > 0);
let group_killed = matches!(pgid, Some(pgid) if kill_group(pgid));
if !group_killed {
let _ = self.child.start_kill();
}
let _ = self.child.wait().await;
if group_killed {
if let Some(pgid) = pgid {
let _ = kill_group(pgid);
}
}
}
#[cfg(windows)]
{
match &self.job {
Some(job) => job.kill(),
None => {
let _ = self.child.start_kill();
}
}
let _ = self.child.wait().await;
}
#[cfg(all(not(unix), not(windows)))]
{
let _ = self.child.start_kill();
let _ = self.child.wait().await;
}
if let Some(task) = self.stderr_task.take() {
let _ = task.await;
}
}
async fn finish_at_eof(&mut self) {
self.stdin = None;
let status = self.child.wait().await;
if let Some(task) = self.stderr_task.take() {
let _ = task.await;
}
let mut exit = match status {
Ok(status) if status.success() && self.saw_result => SessionExit::Completed,
Ok(status) => SessionExit::Failed(format!(
"claude exited with {status}{}; stderr tail: {}",
if self.saw_result {
""
} else {
" without emitting a result message"
},
self.stderr_tail(),
)),
Err(e) => SessionExit::Failed(format!(
"failed to reap claude process: {e}; stderr tail: {}",
self.stderr_tail(),
)),
};
if let Err(error) = self.cleanup_appcontainer() {
exit = SessionExit::Failed(format!(
"claude process exited but AppContainer host-state cleanup failed: {error}"
));
}
self.exit = Some(exit);
}
fn stderr_tail(&self) -> String {
let captured = self
.stderr_buf
.lock()
.map(|guard| guard.clone())
.unwrap_or_default();
last_chars(captured.trim_end(), STDERR_TAIL_CHARS)
}
}
#[async_trait::async_trait]
impl AgentSession for ClaudeSession {
fn session_id(&self) -> String {
self.session_id.clone()
}
async fn next_event(&mut self) -> Result<Option<AgentEvent>> {
loop {
if let Some(event) = self.queue.pop_front() {
return Ok(Some(event));
}
if self.exit.is_some() {
return Ok(None);
}
let line = match self.lines.next_line().await {
Ok(Some(line)) => line,
Ok(None) => {
self.finish_at_eof().await;
return Ok(None);
}
Err(e) => {
self.kill_child().await;
let cleanup = self
.cleanup_appcontainer()
.err()
.map(|error| format!("; AppContainer cleanup failed: {error}"))
.unwrap_or_default();
self.exit = Some(SessionExit::Failed(format!(
"error reading claude stdout: {e}; stderr tail: {}{cleanup}",
self.stderr_tail(),
)));
return Ok(None);
}
};
if line.trim().is_empty() {
continue;
}
let value: Value = match serde_json::from_str(&line) {
Ok(value) => value,
Err(_) => {
self.queue.push_back(AgentEvent::Other {
raw: json!({ "unparsed": line }),
});
continue;
}
};
if self.over_turn_budget(&value) {
self.kill_child().await;
self.exit = Some(match self.cleanup_appcontainer() {
Ok(()) => SessionExit::Aborted,
Err(error) => SessionExit::Failed(format!(
"turn-budget abort could not clean AppContainer host state: {error}"
)),
});
continue; }
let mut events = parse_stream_value(value);
for event in &mut events {
self.observe(event);
}
self.queue.extend(events);
}
}
async fn send_user_message(&mut self, text: &str) -> Result<()> {
if !self.streaming {
return Err(EngineError::Backend(
"send_user_message on a single-shot session".to_string(),
));
}
let Some(stdin) = self.stdin.as_mut() else {
return Err(EngineError::Backend(
"send_user_message on a closed session (stdin dropped)".to_string(),
));
};
stdin.write_all(user_message_line(text).as_bytes()).await?;
stdin.flush().await?;
Ok(())
}
async fn abort(&mut self) -> Result<()> {
let already_exited = matches!(self.child.try_wait(), Ok(Some(_)));
self.kill_child().await;
self.cleanup_appcontainer()?;
if self.saw_success_result && already_exited {
self.exit = Some(SessionExit::Completed);
} else {
self.exit = Some(SessionExit::Aborted);
}
Ok(())
}
fn exit_status(&self) -> Option<SessionExit> {
self.exit.clone()
}
}
#[cfg(test)]
mod discovery_tests {
use super::*;
#[test]
fn claude_discovery_explicit_selection_never_probes_another_candidate() {
let root = tempfile::tempdir().unwrap();
let configured = root.path().join("configured claude ");
let environment = root.path().join("environment-claude");
let fallback = root.path().join("fallback-claude");
for use_config in [true, false] {
let selected = if use_config {
&configured
} else {
&environment
};
for failure in [
None,
Some("--version exited with status 17"),
Some("--version did not exit within 3s (killed)"),
] {
let mut attempts = Vec::new();
let result = discover_claude_binary_from(
use_config.then(|| configured.to_str().unwrap()),
Some(environment.as_os_str()),
vec![fallback.clone()],
|path| {
attempts.push(path.to_path_buf());
if path == selected {
failure
.map_or_else(|| Ok("fixture version".into()), |why| Err(why.into()))
} else {
Ok("successful fallback sentinel".into())
}
},
);
assert_eq!(attempts, vec![selected.clone()]);
if let Some(why) = failure {
let error = result.unwrap_err().to_string();
assert!(error.contains(&selected.display().to_string()), "{error}");
assert!(error.contains(why), "{error}");
assert!(
error.contains(if use_config {
"claudeBinary"
} else {
"KRANZ_CLAUDE_BIN"
}),
"{error}"
);
} else {
assert_eq!(result.unwrap(), *selected);
}
}
}
assert!(discover_claude_binary_from(
Some(" /not-an-absolute-path"),
None,
vec![fallback],
|_| panic!("relative configured paths must be refused before probing"),
)
.is_err());
}
#[test]
fn claude_discovery_automatic_selection_preserves_order_and_deduplication() {
let first = PathBuf::from("path-claude");
let second = PathBuf::from("known-location-claude");
let mut attempts = Vec::new();
let found = discover_claude_binary_from(
None,
None,
vec![first.clone(), first.clone(), second.clone()],
|path| {
attempts.push(path.to_path_buf());
if path == first {
Err("not executable".into())
} else {
Ok("fixture version".into())
}
},
)
.unwrap();
assert_eq!(found, second);
assert_eq!(attempts, vec![first.clone(), second.clone()]);
let error = discover_claude_binary_from(
Some(" "),
Some(std::ffi::OsStr::new("")),
vec![first, second],
|_| Err("fixture unavailable".into()),
)
.unwrap_err()
.to_string();
assert!(
error.contains("path-claude (fixture unavailable)"),
"{error}"
);
assert!(
error.contains("known-location-claude (fixture unavailable)"),
"{error}"
);
}
#[cfg(unix)]
#[test]
fn claude_discovery_failed_and_hung_overrides_never_execute_working_fallback() {
use std::os::unix::fs::PermissionsExt as _;
let root = tempfile::tempdir().unwrap();
let script = |name: &str, body: &str| {
let staged = root.path().join(format!(".{name}.tmp"));
let path = root.path().join(name);
std::fs::write(&staged, format!("#!/bin/sh\n{body}\n")).unwrap();
std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::rename(staged, &path).unwrap();
path
};
let fallback = script(
"fallback",
"printf probed > \"$0.marker\"; printf 'fixture version' ",
);
let marker = fallback.with_extension("marker");
assert_eq!(probe_version(&fallback).unwrap(), "fixture version");
assert!(marker.exists(), "the fallback sentinel works");
std::fs::remove_file(&marker).unwrap();
for (name, body, cause) in [
(
"failed",
"printf intentional-probe-failure >&2; exit 17",
"intentional-probe-failure",
),
("hung", "exec /bin/sleep 30", "did not exit within 3s"),
] {
let explicit = script(name, body);
for use_config in [true, false] {
let mut attempts = Vec::new();
let start = std::time::Instant::now();
let error = discover_claude_binary_from(
use_config.then(|| explicit.to_str().unwrap()),
Some(if use_config {
fallback.as_os_str()
} else {
explicit.as_os_str()
}),
vec![fallback.clone()],
|path| {
attempts.push(path.to_path_buf());
probe_version(path)
},
)
.unwrap_err()
.to_string();
assert_eq!(attempts, vec![explicit.clone()]);
assert!(error.contains(cause), "{error}");
assert!(error.contains(&explicit.display().to_string()), "{error}");
assert!(!marker.exists(), "explicit failure executed the fallback");
assert!(start.elapsed() < std::time::Duration::from_secs(10));
}
}
}
}
#[cfg(test)]
mod scratch_root_tests {
use super::*;
fn isolated_case(name: &str, value: Option<&std::path::Path>) -> bool {
if std::env::var("KRANZ_SCRATCH_TEST_CASE").as_deref() == Ok(name) {
return false;
}
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
command
.args([
&format!("backend_claude::scratch_root_tests::{name}"),
"--exact",
"--nocapture",
])
.env("KRANZ_SCRATCH_TEST_CASE", name);
if let Some(value) = value {
command.env(SCRATCH_ROOT_ENV, value);
} else {
command.env_remove(SCRATCH_ROOT_ENV);
}
let output = command.output().unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(String::from_utf8_lossy(&output.stdout).contains("test result: ok. 1 passed;"));
true
}
#[test]
fn an_absolute_override_moves_scratch_off_the_temp_root() {
let shared = tempfile::tempdir().unwrap();
if isolated_case(
"an_absolute_override_moves_scratch_off_the_temp_root",
Some(shared.path()),
) {
return;
}
let expected = std::path::PathBuf::from(std::env::var_os(SCRATCH_ROOT_ENV).unwrap());
assert_eq!(
scratch_home_root("sess-1"),
expected.join("kranz-worker-home-sess-1")
);
}
#[test]
fn a_relative_override_is_ignored_rather_than_resolved_somewhere_surprising() {
if isolated_case(
"a_relative_override_is_ignored_rather_than_resolved_somewhere_surprising",
Some(std::path::Path::new("relative/scratch")),
) {
return;
}
assert_eq!(scratch_root_base(), std::env::temp_dir());
}
#[test]
fn no_override_keeps_the_system_temp_root() {
if isolated_case("no_override_keeps_the_system_temp_root", None) {
return;
}
assert_eq!(scratch_root_base(), std::env::temp_dir());
}
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
#[test]
fn probe_version_kills_a_hung_binary_within_the_deadline() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let stub = dir.path().join("hung-claude");
std::fs::write(&stub, "#!/bin/sh\nsleep 30\n").unwrap();
std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
let start = std::time::Instant::now();
let result = probe_version(&stub);
let error = result.expect_err("a hung probe must be reported as broken");
assert!(error.contains("did not exit"), "{error}");
assert!(
start.elapsed() < std::time::Duration::from_secs(10),
"probe returned within the deadline, not after the stub's sleep"
);
}
fn write_env_dump_stub(dir: &Path, capture: &Path) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let stub = dir.join("claude-env-dump-stub.sh");
std::fs::write(
&stub,
format!(
"#!/bin/sh\n\
env > '{}'\n\
printf '%s\\n' \\\n\
'{{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"stub\",\"model\":\"stub\"}}' \\\n\
'{{\"type\":\"result\",\"is_error\":false,\"result\":\"done\",\"total_cost_usd\":0.0,\"usage\":{{\"input_tokens\":1,\"output_tokens\":1}},\"num_turns\":1}}'\n\
exit 0\n",
capture.display()
),
)
.unwrap();
std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
stub
}
fn env_dump_spec(cwd: &Path, session_id: &str, env: HashMap<String, String>) -> SessionSpec {
SessionSpec {
cwd: cwd.to_path_buf(),
prompt: PromptMode::SingleShot("hi".to_string()),
append_system_prompt: None,
model: "stub".to_string(),
effort: "low".to_string(),
session_id: session_id.to_string(),
resume: None,
permission_mode: None,
allowed_tools: Vec::new(),
disallowed_tools: Vec::new(),
tools: Vec::new(),
writable: false,
settings_json: None,
json_schema: None,
max_budget_usd: None,
max_turns: None,
env,
sandbox: None,
hook_status: None,
}
}
async fn spawn_and_capture_env(binary: &Path, spec: SessionSpec, capture: &Path) -> String {
let backend = ClaudeBackend::new(binary);
let mut session = backend.start(spec).await.expect("stub session spawns");
while session
.next_event()
.await
.expect("stub stream parses")
.is_some()
{}
std::fs::read_to_string(capture).expect("stub dumped the child env")
}
#[tokio::test]
async fn spawned_session_env_is_cleared_of_ambient_secrets() {
let dir = tempfile::tempdir().unwrap();
let capture = dir.path().join("child.env");
let stub = write_env_dump_stub(dir.path(), &capture);
let scratch = tempfile::tempdir().unwrap();
let _poison = crate::agent_env::EnvTestGuard::engage(&[
("GH_TOKEN", "hunter2"),
("SLACK_BOT_TOKEN", "x"),
("AWS_SECRET_ACCESS_KEY", "y"),
("ANTHROPIC_API_KEY", "sk-ant-poison"),
]);
let mut spec_env = HashMap::new();
spec_env.insert("HOME".to_string(), scratch.path().display().to_string());
spec_env.insert(
"CLAUDE_CONFIG_DIR".to_string(),
scratch.path().join(".claude").display().to_string(),
);
spec_env.insert("KRANZ_BASE_SHA".to_string(), "deadbeef".to_string());
let spec = env_dump_spec(dir.path(), "env-clear-worker", spec_env);
let child_env = spawn_and_capture_env(&stub, spec, &capture).await;
for leaked in ["GH_TOKEN", "SLACK_BOT_TOKEN", "AWS_SECRET_ACCESS_KEY"] {
assert!(
!child_env.contains(leaked),
"spawned session env leaked {leaked}:\n{child_env}"
);
}
for leaked_value in ["hunter2", "xoxb", "aws-poison"] {
assert!(
!child_env.contains(leaked_value),
"spawned session env leaked a poisoned value ({leaked_value}):\n{child_env}"
);
}
assert!(
child_env.contains("ANTHROPIC_API_KEY=sk-ant-poison"),
"the claude backend's own auth key must be injected explicitly:\n{child_env}"
);
assert!(
child_env.contains(&format!("HOME={}", scratch.path().display())),
"HOME must be the session's scratch dir:\n{child_env}"
);
assert!(
child_env.contains(&format!(
"CLAUDE_CONFIG_DIR={}",
scratch.path().join(".claude").display()
)),
"the seeded config dir must survive clearing (auth probe shape):\n{child_env}"
);
assert!(
child_env.contains(&format!("TMPDIR={}", scratch.path().join("tmp").display())),
"TMPDIR must be <scratch>/tmp:\n{child_env}"
);
assert!(
child_env.contains(&format!(
"CLAUDE_CODE_TMPDIR={}",
scratch.path().join("tmp").display()
)),
"Claude's private temp root must equal the sandbox-writable TMPDIR:\n{child_env}"
);
assert!(child_env.contains("PATH="), "PATH must cross:\n{child_env}");
assert!(
child_env.contains("KRANZ_BASE_SHA=deadbeef"),
"spec env must cross verbatim:\n{child_env}"
);
}
#[tokio::test]
async fn home_less_spec_spawns_into_a_freshly_seeded_scratch_home() {
let dir = tempfile::tempdir().unwrap();
let capture = dir.path().join("child.env");
let stub = write_env_dump_stub(dir.path(), &capture);
let real_config = tempfile::tempdir().unwrap();
std::fs::write(
real_config.path().join(".credentials.json"),
"{\"token\":\"oauth\"}",
)
.unwrap();
let real_config_str = real_config.path().display().to_string();
let _poison = crate::agent_env::EnvTestGuard::engage(&[
("GH_TOKEN", "hunter2"),
("CLAUDE_CONFIG_DIR", &real_config_str),
]);
let session_id = "env-clear-orchestrator";
let spec = env_dump_spec(dir.path(), session_id, HashMap::new());
let child_env = spawn_and_capture_env(&stub, spec, &capture).await;
let expected_home = scratch_home_root(session_id).join("home");
assert!(
child_env.contains(&format!("HOME={}", expected_home.display())),
"a HOME-less spec must spawn into the per-session scratch HOME:\n{child_env}"
);
assert!(
child_env.contains(&format!(
"CLAUDE_CODE_TMPDIR={}",
expected_home.join("tmp").display()
)),
"validator/orchestrator Claude temp state must stay under the scratch HOME:\n{child_env}"
);
assert!(
!child_env.contains("CLAUDE_CONFIG_DIR"),
"CLAUDE_CONFIG_DIR must NOT be set (it poisons keychain OAuth; \
HOME/.claude resolves implicitly):\n{child_env}"
);
assert!(
!child_env.contains("GH_TOKEN") && !child_env.contains("hunter2"),
"ambient secrets must not cross:\n{child_env}"
);
let seeded = expected_home.join(".claude").join(CLAUDE_CREDENTIALS_ENTRY);
assert_eq!(
std::fs::read_to_string(&seeded).expect("scratch HOME was seeded"),
"{\"token\":\"oauth\"}",
"the OAuth credential copy must land in the seeded scratch config dir"
);
}
#[cfg(target_os = "macos")]
#[test]
fn seed_worker_scratch_home_links_the_real_keychain_dir() {
let real_home = tempfile::tempdir().unwrap();
let real_keychains = real_home.path().join("Library").join("Keychains");
std::fs::create_dir_all(&real_keychains).unwrap();
std::fs::write(real_keychains.join("login.keychain-db"), "db").unwrap();
let scratch = tempfile::tempdir().unwrap();
let (home, _config) =
seed_worker_scratch_home(scratch.path(), Some(real_home.path()), None).unwrap();
let link = home.join("Library").join("Keychains");
let target = std::fs::read_link(&link).expect("Keychains must be a symlink");
assert_eq!(target, real_keychains);
assert_eq!(
std::fs::read_to_string(link.join("login.keychain-db")).unwrap(),
"db"
);
let bare_home = tempfile::tempdir().unwrap();
let scratch2 = tempfile::tempdir().unwrap();
let (home2, _) =
seed_worker_scratch_home(scratch2.path(), Some(bare_home.path()), None).unwrap();
assert!(!home2.join("Library").join("Keychains").exists());
}
#[tokio::test]
async fn over_long_stdout_line_is_truncated_and_the_session_completes() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let stub = dir.path().join("claude-long-line-stub.sh");
std::fs::write(
&stub,
"#!/bin/sh\n\
printf '%s\\n' '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"stub\",\"model\":\"stub\"}'\n\
head -c 9000000 /dev/zero | tr '\\0' 'x'\n\
printf '\\n'\n\
printf '%s\\n' '{\"type\":\"result\",\"is_error\":false,\"result\":\"done\",\"total_cost_usd\":0.0,\"usage\":{\"input_tokens\":1,\"output_tokens\":1},\"num_turns\":1}'\n\
exit 0\n",
)
.unwrap();
std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
let backend = ClaudeBackend::new(&stub);
let spec = env_dump_spec(dir.path(), "long-line", HashMap::new());
let mut session = backend.start(spec).await.expect("stub session spawns");
let mut saw_truncated_other = false;
while let Some(event) = session.next_event().await.expect("stream reads") {
if let AgentEvent::Other { raw } = &event {
if raw
.to_string()
.contains(crate::stream_bounds::TRUNCATION_MARKER)
{
saw_truncated_other = true;
}
}
}
assert!(
saw_truncated_other,
"the over-long line surfaced as a truncated unparsed Other"
);
assert_eq!(
session.exit_status(),
Some(SessionExit::Completed),
"the session completes on the result line after the truncated one"
);
}
#[tokio::test]
async fn endless_stderr_is_drained_and_only_the_tail_is_surfaced() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let stub = dir.path().join("claude-noisy-stderr-stub.sh");
std::fs::write(
&stub,
"#!/bin/sh\n\
head -c 200000 /dev/zero | tr '\\0' 'y' >&2\n\
echo 'STDERR-END' >&2\n\
exit 3\n",
)
.unwrap();
std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
let backend = ClaudeBackend::new(&stub);
let spec = env_dump_spec(dir.path(), "noisy-stderr", HashMap::new());
let mut session = backend.start(spec).await.expect("stub session spawns");
while session.next_event().await.expect("stream reads").is_some() {}
match session.exit_status() {
Some(SessionExit::Failed(message)) => {
assert!(
message.contains(crate::stream_bounds::TRUNCATION_MARKER),
"expected the truncation marker, got: {message}"
);
assert!(
message.contains("STDERR-END"),
"expected the END of stderr to be kept, got: {message}"
);
assert!(
message.len() < 1024,
"the surfaced stderr tail stayed bounded, got {} bytes",
message.len()
);
}
other => panic!("expected SessionExit::Failed, got {other:?}"),
}
}
}