1use crate::backend::{
15 AgentBackend, AgentEvent, AgentSession, PromptMode, SessionExit, SessionSpec,
16};
17use crate::error::{EngineError, Result};
18use crate::stream_bounds::{drain_to_tail, BoundedLines, STDERR_TAIL_CAP};
19use crate::types::TokenUsage;
20use serde_json::{json, Value};
21use std::collections::{HashMap, HashSet, VecDeque};
22use std::path::{Path, PathBuf};
23use std::process::Stdio;
24use std::sync::{Arc, Mutex};
25use tokio::io::AsyncWriteExt;
26use tokio::process::{Child, ChildStdin, ChildStdout};
27use tokio::task::JoinHandle;
28
29const SUMMARY_MAX_CHARS: usize = 200;
31const STDERR_TAIL_CHARS: usize = 500;
33
34#[cfg(windows)]
65pub(crate) mod win_job {
66 use std::os::windows::io::RawHandle;
67 use windows::core::PCWSTR;
68 use windows::Win32::Foundation::{CloseHandle, HANDLE};
69 use windows::Win32::System::JobObjects::{
70 AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
71 SetInformationJobObject, TerminateJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
72 JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
73 };
74
75 #[derive(Debug)]
80 pub(crate) struct JobHandle {
81 job: HANDLE,
82 }
83
84 unsafe impl Send for JobHandle {}
89 unsafe impl Sync for JobHandle {}
90
91 impl JobHandle {
92 pub(crate) fn create_and_assign(child_handle: RawHandle) -> windows::core::Result<Self> {
106 let job = unsafe { CreateJobObjectW(None, PCWSTR::null())? };
117 let guard = JobHandle { job };
119
120 let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
121 info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
122 unsafe {
126 SetInformationJobObject(
127 guard.job,
128 JobObjectExtendedLimitInformation,
129 &info as *const _ as *const core::ffi::c_void,
130 std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
131 )?;
132 }
133
134 unsafe {
139 AssignProcessToJobObject(guard.job, HANDLE(child_handle))?;
140 }
141 Ok(guard)
142 }
143
144 pub(crate) fn kill(&self) {
149 let _ = unsafe { TerminateJobObject(self.job, 1) };
153 }
154 }
155
156 impl Drop for JobHandle {
157 fn drop(&mut self) {
158 let _ = unsafe { CloseHandle(self.job) };
162 }
163 }
164}
165
166pub fn discover_claude_binary(configured: Option<&str>) -> Result<PathBuf> {
186 let mut candidates: Vec<PathBuf> = Vec::new();
187 candidates.push(PathBuf::from("claude"));
190 #[cfg(windows)]
191 {
192 candidates.push(PathBuf::from("claude.cmd"));
193 candidates.push(PathBuf::from("claude.exe"));
194 }
195 candidates.extend(fallback_candidates());
196 discover_claude_binary_from(
197 configured,
198 std::env::var_os("KRANZ_CLAUDE_BIN").as_deref(),
199 candidates,
200 probe_version,
201 )
202}
203
204fn discover_claude_binary_from(
206 configured: Option<&str>,
207 env_bin: Option<&std::ffi::OsStr>,
208 candidates: Vec<PathBuf>,
209 mut probe: impl FnMut(&Path) -> std::result::Result<String, String>,
210) -> Result<PathBuf> {
211 let explicit = if let Some(configured) = configured.filter(|s| !s.trim().is_empty()) {
212 if !Path::new(configured).is_absolute() {
213 return Err(EngineError::Config(format!(
214 "configured claude binary {configured:?} must be an absolute path: a relative \
215 path resolves against the process working directory, so which program runs \
216 depends on where kranz was invoked"
217 )));
218 }
219 Some((PathBuf::from(configured), "claudeBinary"))
220 } else {
221 env_bin
222 .filter(|path| !path.is_empty())
223 .map(|path| (PathBuf::from(path), "KRANZ_CLAUDE_BIN"))
224 };
225 if let Some((candidate, source)) = explicit {
226 return probe(&candidate).map(|_| candidate.clone()).map_err(|why| {
227 EngineError::Config(format!(
228 "{source} override {} failed: {why}; refusing to fall back to another executable",
229 candidate.display()
230 ))
231 });
232 }
233
234 let mut deduped: Vec<PathBuf> = Vec::new();
236 for candidate in candidates {
237 if !deduped.contains(&candidate) {
238 deduped.push(candidate);
239 }
240 }
241
242 let mut attempts: Vec<String> = Vec::new();
243 for candidate in deduped {
244 match probe(&candidate) {
245 Ok(_version) => return Ok(candidate),
246 Err(why) => attempts.push(format!("{} ({why})", candidate.display())),
247 }
248 }
249 Err(EngineError::Config(format!(
250 "no working claude binary found; tried: {}. Install Claude Code \
251 (npm install -g @anthropic-ai/claude-code) or point kranz at it via \
252 the claudeBinary config field or the KRANZ_CLAUDE_BIN environment \
253 variable.",
254 attempts.join(", ")
255 )))
256}
257
258#[cfg(not(windows))]
260fn fallback_candidates() -> Vec<PathBuf> {
261 let home = std::env::var_os("HOME").map(PathBuf::from);
262 let mut out = Vec::new();
263 if let Some(home) = &home {
264 out.push(home.join(".npm-global").join("bin").join("claude"));
265 }
266 out.push(PathBuf::from("/opt/homebrew/bin/claude"));
267 out.push(PathBuf::from("/usr/local/bin/claude"));
268 if let Some(home) = &home {
269 out.push(home.join(".local").join("bin").join("claude"));
270 }
271 out
272}
273
274#[cfg(windows)]
276fn fallback_candidates() -> Vec<PathBuf> {
277 let mut out = Vec::new();
278 if let Some(profile) = std::env::var_os("USERPROFILE").map(PathBuf::from) {
279 for dir in [
280 profile.join("AppData").join("Roaming").join("npm"),
281 profile.join(".npm-global").join("bin"),
282 profile.join(".local").join("bin"),
283 ] {
284 for name in ["claude.cmd", "claude.exe", "claude"] {
285 out.push(dir.join(name));
286 }
287 }
288 }
289 out
290}
291
292const VERSION_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
296
297fn probe_version(binary: &Path) -> std::result::Result<String, String> {
300 crate::backend_probe::probe_version(binary, VERSION_PROBE_TIMEOUT)
301}
302
303pub const CLAUDE_CREDENTIALS_ENTRY: &str = ".credentials.json";
310
311pub fn claude_min_config_entries() -> &'static [&'static str] {
324 &[CLAUDE_CREDENTIALS_ENTRY]
325}
326
327pub const SCRATCH_ROOT_ENV: &str = "KRANZ_SCRATCH_ROOT";
343
344pub fn scratch_root_base() -> std::path::PathBuf {
351 match std::env::var_os(SCRATCH_ROOT_ENV).map(std::path::PathBuf::from) {
352 Some(root) if root.is_absolute() => root,
353 Some(root) => {
354 tracing::warn!(
355 override_path = %root.display(),
356 variable = SCRATCH_ROOT_ENV,
357 "ignoring a relative scratch-root override; scratch paths must be absolute \
358 because container mounts and sandbox profiles resolve them elsewhere"
359 );
360 std::env::temp_dir()
361 }
362 None => std::env::temp_dir(),
363 }
364}
365
366pub fn scratch_home_root(session_id: &str) -> std::path::PathBuf {
371 scratch_root_base().join(format!("kranz-worker-home-{session_id}"))
372}
373
374pub fn seed_worker_scratch_home(
399 scratch_root: &std::path::Path,
400 real_home: Option<&std::path::Path>,
401 real_config_dir: Option<&std::path::Path>,
402) -> std::io::Result<(std::path::PathBuf, std::path::PathBuf)> {
403 let home_dir = scratch_root.join("home");
404 let config_dir = home_dir.join(".claude");
405 std::fs::create_dir_all(&config_dir)?;
406
407 let source_config_dir = real_config_dir
408 .map(std::path::Path::to_path_buf)
409 .or_else(|| real_home.map(|home| home.join(".claude")));
410 if let Some(source_config_dir) = source_config_dir {
411 for entry in claude_min_config_entries() {
412 let src = source_config_dir.join(entry);
413 if src.is_file() {
414 std::fs::copy(&src, config_dir.join(entry))?;
415 }
416 }
417 }
418
419 #[cfg(target_os = "macos")]
420 if let Some(real_home) = real_home {
421 let real_keychains = real_home.join("Library").join("Keychains");
422 if real_keychains.is_dir() {
423 let scratch_library = home_dir.join("Library");
424 std::fs::create_dir_all(&scratch_library)?;
425 let link = scratch_library.join("Keychains");
426 if !link.exists() {
427 std::os::unix::fs::symlink(&real_keychains, &link)?;
428 }
429 }
430 }
431
432 Ok((home_dir, config_dir))
433}
434
435pub fn build_args(spec: &SessionSpec) -> Vec<String> {
443 let mut args: Vec<String> = vec![
444 "-p".into(),
445 "--setting-sources".into(),
456 "user".into(),
457 "--output-format".into(),
458 "stream-json".into(),
459 "--verbose".into(),
460 "--model".into(),
461 spec.model.clone(),
462 "--effort".into(),
463 spec.effort.clone(),
464 ];
465 if let Some(system) = &spec.append_system_prompt {
466 args.push("--append-system-prompt".into());
467 args.push(system.clone());
468 }
469 match &spec.resume {
470 Some(previous) => {
471 args.push("--resume".into());
472 args.push(previous.clone());
473 }
474 None => {
475 args.push("--session-id".into());
476 args.push(spec.session_id.clone());
477 }
478 }
479 if let Some(mode) = &spec.permission_mode {
480 args.push("--permission-mode".into());
481 args.push(mode.clone());
482 }
483 if !spec.allowed_tools.is_empty() {
484 args.push("--allowedTools".into());
485 args.extend(spec.allowed_tools.iter().cloned());
486 }
487 if !spec.disallowed_tools.is_empty() {
488 args.push("--disallowedTools".into());
489 args.extend(spec.disallowed_tools.iter().cloned());
490 }
491 if !spec.tools.is_empty() {
492 args.push("--tools".into());
493 args.extend(spec.tools.iter().cloned());
494 }
495 if let Some(settings) = &spec.settings_json {
496 args.push("--settings".into());
497 args.push(settings.to_string()); }
499 if let Some(schema) = &spec.json_schema {
500 args.push("--json-schema".into());
501 args.push(schema.to_string()); }
503 if let Some(budget) = spec.max_budget_usd {
504 args.push("--max-budget-usd".into());
505 args.push(budget.to_string());
506 }
507 match &spec.prompt {
508 PromptMode::Streaming(_) => {
509 args.push("--input-format".into());
511 args.push("stream-json".into());
512 }
513 PromptMode::SingleShot(prompt) => {
514 args.push(prompt.clone());
516 }
517 }
518 args
519}
520
521pub fn sandbox_command(
528 profile_path: &Path,
529 binary: &Path,
530 args: &[String],
531) -> (PathBuf, Vec<String>) {
532 let mut full_args: Vec<String> = vec!["-f".to_string(), profile_path.display().to_string()];
533 full_args.push(binary.display().to_string());
534 full_args.extend(args.iter().cloned());
535 (PathBuf::from("sandbox-exec"), full_args)
536}
537
538pub fn user_message_line(text: &str) -> String {
540 let value = json!({
541 "type": "user",
542 "message": {
543 "role": "user",
544 "content": [{ "type": "text", "text": text }],
545 },
546 });
547 format!("{value}\n")
548}
549
550pub fn parse_stream_line(line: &str) -> Vec<AgentEvent> {
559 match serde_json::from_str::<Value>(line) {
560 Ok(value) => parse_stream_value(value),
561 Err(_) => vec![AgentEvent::Other {
562 raw: json!({ "unparsed": line }),
563 }],
564 }
565}
566
567pub fn parse_stream_value(value: Value) -> Vec<AgentEvent> {
571 let line_type = value.get("type").and_then(Value::as_str).unwrap_or("");
572 match line_type {
573 "system" if value.get("subtype").and_then(Value::as_str) == Some("init") => {
574 vec![AgentEvent::Init {
575 session_id: str_field(&value, "session_id"),
576 model: str_field(&value, "model"),
577 raw: value,
578 }]
579 }
580 "assistant" => parse_assistant(value),
581 "user" => parse_user(value),
582 "result" => vec![parse_result(value)],
583 _ => vec![AgentEvent::Other { raw: value }],
584 }
585}
586
587fn str_field(value: &Value, key: &str) -> String {
588 value
589 .get(key)
590 .and_then(Value::as_str)
591 .unwrap_or_default()
592 .to_string()
593}
594
595fn parse_assistant(value: Value) -> Vec<AgentEvent> {
599 let Some(blocks) = value
600 .pointer("/message/content")
601 .and_then(Value::as_array)
602 .cloned()
603 else {
604 return vec![AgentEvent::Other { raw: value }];
605 };
606 let mut events = Vec::new();
607 for block in &blocks {
608 match block.get("type").and_then(Value::as_str) {
609 Some("text") => {
610 let text = block.get("text").and_then(Value::as_str).unwrap_or("");
611 if !text.is_empty() {
612 events.push(AgentEvent::Text {
613 text: text.to_string(),
614 raw: value.clone(),
615 });
616 }
617 }
618 Some("tool_use") => {
619 let tool = block
620 .get("name")
621 .and_then(Value::as_str)
622 .unwrap_or("unknown")
623 .to_string();
624 let summary = tool_use_summary(&tool, block.get("input"));
625 events.push(AgentEvent::ToolUse {
626 tool,
627 summary,
628 raw: value.clone(),
629 });
630 }
631 _ => events.push(AgentEvent::Other { raw: value.clone() }),
632 }
633 }
634 events
635}
636
637fn tool_use_summary(tool: &str, input: Option<&Value>) -> String {
640 let null = Value::Null;
641 let input = input.unwrap_or(&null);
642 let picked = match tool {
643 "Bash" => input.get("command").and_then(Value::as_str),
644 "Edit" | "Write" | "Read" => input.get("file_path").and_then(Value::as_str),
645 _ => None,
646 };
647 match picked {
648 Some(text) => text.to_string(),
649 None => truncate_chars(&input.to_string(), SUMMARY_MAX_CHARS),
650 }
651}
652
653fn parse_user(value: Value) -> Vec<AgentEvent> {
664 let blocks = value
665 .pointer("/message/content")
666 .and_then(Value::as_array)
667 .cloned()
668 .unwrap_or_default();
669 let mut events = Vec::new();
670 for block in &blocks {
671 if block.get("type").and_then(Value::as_str) != Some("tool_result") {
672 continue;
673 }
674 let text = tool_result_text(block);
675 let is_error = block
676 .get("is_error")
677 .and_then(Value::as_bool)
678 .unwrap_or(false);
679 let lower = text.to_lowercase();
680 let denied = (is_error && lower.contains("permission"))
681 || (lower.contains("hook")
682 && (lower.contains("block")
683 || lower.contains("denied")
684 || lower.contains("reject")))
685 || (is_error && lower.contains("requires approval"))
686 || (is_error && lower.contains("contains expansion"))
687 || (lower.contains("output redirection") && lower.contains("blocked"));
688 events.push(AgentEvent::ToolResult {
689 tool: None,
690 denied,
691 summary: truncate_chars(&text, SUMMARY_MAX_CHARS),
692 raw: value.clone(),
693 });
694 }
695 if events.is_empty() {
696 return vec![AgentEvent::Other { raw: value }];
697 }
698 events
699}
700
701fn tool_result_text(block: &Value) -> String {
704 match block.get("content") {
705 Some(Value::String(text)) => text.clone(),
706 Some(Value::Array(parts)) => parts
707 .iter()
708 .filter_map(|part| {
709 if part.get("type").and_then(Value::as_str) == Some("text") {
710 part.get("text").and_then(Value::as_str)
711 } else {
712 None
713 }
714 })
715 .collect::<Vec<_>>()
716 .join("\n"),
717 _ => String::new(),
718 }
719}
720
721fn parse_result(value: Value) -> AgentEvent {
722 let usage_field = |key: &str| {
723 value
724 .pointer(&format!("/usage/{key}"))
725 .and_then(Value::as_u64)
726 .unwrap_or(0)
727 };
728 AgentEvent::Result {
729 text: value
730 .get("result")
731 .and_then(Value::as_str)
732 .unwrap_or("")
733 .to_string(),
734 is_error: value
735 .get("is_error")
736 .and_then(Value::as_bool)
737 .unwrap_or(false),
738 usage: TokenUsage {
739 input: usage_field("input_tokens"),
740 output: usage_field("output_tokens"),
741 cache_read: usage_field("cache_read_input_tokens"),
742 cache_write: usage_field("cache_creation_input_tokens"),
743 },
744 cost_usd: value.get("total_cost_usd").and_then(Value::as_f64),
745 num_turns: value
746 .get("num_turns")
747 .and_then(Value::as_u64)
748 .map(|n| n as u32),
749 raw: value,
750 }
751}
752
753fn truncate_chars(text: &str, max: usize) -> String {
755 if text.chars().count() <= max {
756 text.to_string()
757 } else {
758 text.chars().take(max).collect()
759 }
760}
761
762fn last_chars(text: &str, max: usize) -> String {
764 let chars: Vec<char> = text.chars().collect();
765 let start = chars.len().saturating_sub(max);
766 chars[start..].iter().collect()
767}
768
769#[derive(Debug, Clone)]
775pub struct ClaudeBackend {
776 binary: PathBuf,
777}
778
779impl ClaudeBackend {
780 pub fn new(binary: impl Into<PathBuf>) -> Self {
782 ClaudeBackend {
783 binary: binary.into(),
784 }
785 }
786
787 pub fn discover(configured: Option<&str>) -> Result<Self> {
789 Ok(ClaudeBackend {
790 binary: discover_claude_binary(configured)?,
791 })
792 }
793
794 pub fn binary(&self) -> &Path {
796 &self.binary
797 }
798}
799
800const CLAUDE_AUTH_ENV: &str = "ANTHROPIC_API_KEY";
805
806const CLAUDE_TMPDIR_ENV: &str = "CLAUDE_CODE_TMPDIR";
811
812fn pin_claude_tmpdir(mut env: HashMap<String, String>) -> HashMap<String, String> {
813 if let Some(tmpdir) = env.get("TMPDIR").cloned() {
814 env.insert(CLAUDE_TMPDIR_ENV.to_string(), tmpdir);
815 }
816 env
817}
818
819fn claude_child_env(spec: &SessionSpec) -> HashMap<String, String> {
833 if spec.env.contains_key("HOME") {
834 return pin_claude_tmpdir(crate::agent_env::agent_session_env(
835 &spec.env,
836 &spec.session_id,
837 Some(CLAUDE_AUTH_ENV),
838 ));
839 }
840 let real_home = std::env::var_os("HOME").map(PathBuf::from);
841 let real_config_dir = std::env::var_os("CLAUDE_CONFIG_DIR").map(PathBuf::from);
842 let scratch_root = scratch_home_root(&spec.session_id);
843 match seed_worker_scratch_home(
844 &scratch_root,
845 real_home.as_deref(),
846 real_config_dir.as_deref(),
847 ) {
848 Ok((home, _config_dir)) => {
849 tracing::info!(
857 session_id = %spec.session_id,
858 decision = "scratch-seeded",
859 "session spec carried no relocated HOME; spawning into a freshly seeded \
860 scratch HOME (agent-env-clear)"
861 );
862 pin_claude_tmpdir(crate::agent_env::session_env_with_home(
863 &spec.env,
864 &spec.session_id,
865 Some(CLAUDE_AUTH_ENV),
866 &home,
867 ))
868 }
869 Err(e) => {
870 tracing::warn!(
871 session_id = %spec.session_id,
872 error = %e,
873 "scratch HOME seeding failed; session spawns into an empty scratch HOME \
874 and will fail auth loudly if no API key is injected"
875 );
876 pin_claude_tmpdir(crate::agent_env::agent_session_env(
877 &spec.env,
878 &spec.session_id,
879 Some(CLAUDE_AUTH_ENV),
880 ))
881 }
882 }
883}
884
885#[async_trait::async_trait]
886impl AgentBackend for ClaudeBackend {
887 async fn start(&self, spec: SessionSpec) -> Result<Box<dyn AgentSession>> {
888 let streaming = matches!(spec.prompt, PromptMode::Streaming(_));
889 let args = build_args(&spec);
890 let child_env = claude_child_env(&spec);
896 #[cfg(windows)]
897 let mut appcontainer_lease = None;
898
899 if let Some(resolved) = &spec.sandbox {
900 crate::sandbox::validate_git_config_protection(
901 &resolved.inputs,
902 matches!(
903 resolved.backend,
904 crate::sandbox::SandboxBackend::Bubblewrap
905 | crate::sandbox::SandboxBackend::Container
906 ),
907 )?;
908 }
909 let mut command = match &spec.sandbox {
910 Some(resolved)
911 if resolved.backend == crate::sandbox::SandboxBackend::Seatbelt
912 && cfg!(target_os = "macos") =>
913 {
914 let profile = crate::sandbox::generate_profile(&resolved.inputs);
915 let profile_dir = resolved.inputs.mission_dir.join("runs");
920 let profile_path = crate::sandbox::write_profile_file(&profile_dir, &profile)
921 .or_else(|_| {
922 crate::sandbox::write_profile_file(&resolved.inputs.tmpdir, &profile)
923 })
924 .map_err(|e| {
925 EngineError::Backend(format!("failed to write sandbox profile: {e}"))
926 })?;
927 let (program, sandboxed_args) = sandbox_command(&profile_path, &self.binary, &args);
928 let mut command = tokio::process::Command::new(program);
929 command.args(&sandboxed_args);
930 command
931 }
932 Some(resolved)
933 if resolved.backend == crate::sandbox::SandboxBackend::Bubblewrap
934 && cfg!(target_os = "linux") =>
935 {
936 let mut command = tokio::process::Command::new("bwrap");
937 command.args(crate::sandbox::bubblewrap_args(
938 &resolved.inputs,
939 &self.binary,
940 &args,
941 )?);
942 command
943 }
944 Some(resolved) if resolved.backend == crate::sandbox::SandboxBackend::Container => {
945 let container = resolved.container.as_ref().ok_or_else(|| {
946 EngineError::Backend(
947 "resolved container sandbox is missing its runtime/image spec".to_string(),
948 )
949 })?;
950 let mut command = tokio::process::Command::new(container.runtime.binary());
951 command.args(crate::sandbox_container::container_run_args(
955 &resolved.inputs,
956 container,
957 &self.binary,
958 &args,
959 spec.env
960 .get(crate::egress_proxy::HTTPS_PROXY_ENV)
961 .map(String::as_str),
962 ));
963 command
964 }
965 #[cfg(windows)]
966 Some(resolved) if resolved.backend == crate::sandbox::SandboxBackend::AppContainer => {
967 let prepared = crate::appcontainer_windows::prepare_launch(
968 &resolved.inputs,
969 &self.binary,
970 &args,
971 &child_env,
972 )?;
973 appcontainer_lease = Some(prepared.lease);
974 let mut command = tokio::process::Command::new(prepared.program);
975 command.args(prepared.args);
976 command
977 }
978 Some(resolved) => {
979 return Err(EngineError::Backend(format!(
980 "resolved sandbox backend {:?} is unavailable on target_os={}",
981 resolved.backend,
982 std::env::consts::OS
983 )));
984 }
985 None => {
986 let mut command = tokio::process::Command::new(&self.binary);
987 command.args(&args);
988 command
989 }
990 };
991 command
996 .current_dir(&spec.cwd)
997 .env_clear()
998 .envs(child_env)
999 .stdin(if streaming {
1000 Stdio::piped()
1001 } else {
1002 Stdio::null()
1003 })
1004 .stdout(Stdio::piped())
1005 .stderr(Stdio::piped())
1006 .kill_on_drop(true);
1007 #[cfg(unix)]
1016 command.process_group(0);
1017
1018 let mut child = command.spawn().map_err(|e| {
1019 EngineError::Backend(format!("failed to spawn {}: {e}", self.binary.display()))
1020 })?;
1021
1022 #[cfg(windows)]
1030 let job = match child.raw_handle() {
1031 Some(handle) => match win_job::JobHandle::create_and_assign(handle) {
1032 Ok(job) => Some(job),
1033 Err(e) => {
1034 tracing::warn!(error = %e, "failed to create Job Object for claude child; \
1035 tree-kill on abort will be unavailable");
1036 None
1037 }
1038 },
1039 None => None,
1042 };
1043
1044 let stdout = child
1045 .stdout
1046 .take()
1047 .ok_or_else(|| EngineError::Backend("claude child has no stdout pipe".to_string()))?;
1048 let stderr = child
1049 .stderr
1050 .take()
1051 .ok_or_else(|| EngineError::Backend("claude child has no stderr pipe".to_string()))?;
1052 let mut stdin = if streaming { child.stdin.take() } else { None };
1053
1054 let stderr_buf = Arc::new(Mutex::new(String::new()));
1059 let stderr_task = {
1060 let buf = Arc::clone(&stderr_buf);
1061 tokio::spawn(async move {
1062 let tail = drain_to_tail(stderr, STDERR_TAIL_CAP).await;
1063 *buf.lock().expect("stderr buffer lock") = tail;
1064 })
1065 };
1066
1067 if let PromptMode::Streaming(initial) = &spec.prompt {
1068 let Some(handle) = stdin.as_mut() else {
1069 return Err(EngineError::Backend(
1070 "claude child has no stdin pipe for streaming input".to_string(),
1071 ));
1072 };
1073 handle
1074 .write_all(user_message_line(initial).as_bytes())
1075 .await?;
1076 handle.flush().await?;
1077 }
1078
1079 Ok(Box::new(ClaudeSession {
1080 session_id: spec.session_id.clone(),
1081 streaming,
1082 max_turns: spec.max_turns,
1083 child,
1084 #[cfg(windows)]
1085 job,
1086 #[cfg(windows)]
1087 appcontainer_lease,
1088 stdin,
1089 lines: BoundedLines::new(stdout),
1090 stderr_buf,
1091 stderr_task: Some(stderr_task),
1092 queue: VecDeque::new(),
1093 assistant_ids: HashSet::new(),
1094 saw_result: false,
1095 saw_success_result: false,
1096 accounted_cost_usd: 0.0,
1097 exit: None,
1098 }))
1099 }
1100}
1101
1102#[cfg(unix)]
1109pub(crate) fn kill_group(pgid: i32) -> bool {
1110 debug_assert!(pgid > 0, "kill_group needs a positive group id");
1111 unsafe { libc::kill(-pgid, libc::SIGKILL) == 0 }
1114}
1115
1116#[cfg(unix)]
1119pub(crate) fn kill_unreaped_group(child: &Child) {
1120 if let Some(pid) = child.id().and_then(|pid| i32::try_from(pid).ok()) {
1121 if pid > 0 {
1122 kill_group(pid);
1123 }
1124 }
1125}
1126
1127pub struct ClaudeSession {
1129 session_id: String,
1131 streaming: bool,
1132 max_turns: Option<u32>,
1133 child: Child,
1134 #[cfg(windows)]
1142 job: Option<win_job::JobHandle>,
1143 #[cfg(windows)]
1148 appcontainer_lease: Option<crate::appcontainer_windows::AppContainerLease>,
1149 stdin: Option<ChildStdin>,
1151 lines: BoundedLines<ChildStdout>,
1152 stderr_buf: Arc<Mutex<String>>,
1153 stderr_task: Option<JoinHandle<()>>,
1154 queue: VecDeque<AgentEvent>,
1156 assistant_ids: HashSet<String>,
1158 saw_result: bool,
1159 saw_success_result: bool,
1160 accounted_cost_usd: f64,
1162 exit: Option<SessionExit>,
1163}
1164
1165#[cfg(unix)]
1166impl Drop for ClaudeSession {
1167 fn drop(&mut self) {
1168 kill_unreaped_group(&self.child);
1169 }
1170}
1171
1172impl ClaudeSession {
1173 fn cleanup_appcontainer(&mut self) -> Result<()> {
1177 #[cfg(windows)]
1178 {
1179 if let Some(lease) = self.appcontainer_lease.as_mut() {
1180 lease.cleanup()?;
1181 }
1182 self.appcontainer_lease = None;
1183 }
1184 Ok(())
1185 }
1186
1187 fn observe(&mut self, event: &mut AgentEvent) {
1189 match event {
1190 AgentEvent::Init { session_id, .. } => {
1191 if self.session_id != *session_id {
1192 self.accounted_cost_usd = 0.0;
1193 }
1194 self.session_id = session_id.clone();
1195 }
1196 AgentEvent::Result {
1197 is_error,
1198 cost_usd,
1199 raw,
1200 ..
1201 } => {
1202 if self.streaming {
1203 if let Some(id) = raw.get("session_id").and_then(Value::as_str) {
1206 if id != self.session_id {
1207 self.accounted_cost_usd = 0.0;
1208 self.session_id = id.to_string();
1209 }
1210 }
1211 if let Some(total) = *cost_usd {
1212 *cost_usd = if total.is_finite() && total >= 0.0 {
1213 let delta = (total - self.accounted_cost_usd).max(0.0);
1214 self.accounted_cost_usd = self.accounted_cost_usd.max(total);
1217 Some(delta)
1218 } else {
1219 None
1220 };
1221 }
1222 }
1223 self.saw_result = true;
1224 if !*is_error {
1225 self.saw_success_result = true;
1226 }
1227 }
1228 AgentEvent::Other { raw }
1229 if raw.get("type").and_then(Value::as_str) == Some("system")
1230 && raw.get("subtype").and_then(Value::as_str) == Some("conversation_reset") =>
1231 {
1232 self.accounted_cost_usd = 0.0;
1233 if let Some(id) = raw.get("session_id").and_then(Value::as_str) {
1234 self.session_id = id.to_string();
1235 }
1236 }
1237 _ => {}
1238 }
1239 }
1240
1241 fn over_turn_budget(&mut self, value: &Value) -> bool {
1244 let Some(max_turns) = self.max_turns else {
1245 return false;
1246 };
1247 if value.get("type").and_then(Value::as_str) != Some("assistant") {
1248 return false;
1249 }
1250 let Some(id) = value.pointer("/message/id").and_then(Value::as_str) else {
1251 return false;
1252 };
1253 if self.assistant_ids.insert(id.to_string()) {
1254 self.assistant_ids.len() > max_turns as usize
1255 } else {
1256 false
1257 }
1258 }
1259
1260 async fn kill_child(&mut self) {
1280 self.stdin = None;
1281 #[cfg(unix)]
1282 {
1283 let pgid = self
1286 .child
1287 .id()
1288 .and_then(|pid| i32::try_from(pid).ok())
1289 .filter(|pid| *pid > 0);
1290 let group_killed = matches!(pgid, Some(pgid) if kill_group(pgid));
1291 if !group_killed {
1292 let _ = self.child.start_kill();
1293 }
1294 let _ = self.child.wait().await;
1295 if group_killed {
1296 if let Some(pgid) = pgid {
1297 let _ = kill_group(pgid);
1299 }
1300 }
1301 }
1302 #[cfg(windows)]
1303 {
1304 match &self.job {
1308 Some(job) => job.kill(),
1309 None => {
1310 let _ = self.child.start_kill();
1311 }
1312 }
1313 let _ = self.child.wait().await;
1314 }
1315 #[cfg(all(not(unix), not(windows)))]
1318 {
1319 let _ = self.child.start_kill();
1320 let _ = self.child.wait().await;
1321 }
1322 if let Some(task) = self.stderr_task.take() {
1323 let _ = task.await;
1324 }
1325 }
1326
1327 async fn finish_at_eof(&mut self) {
1329 self.stdin = None;
1330 let status = self.child.wait().await;
1331 if let Some(task) = self.stderr_task.take() {
1334 let _ = task.await;
1335 }
1336 let mut exit = match status {
1337 Ok(status) if status.success() && self.saw_result => SessionExit::Completed,
1338 Ok(status) => SessionExit::Failed(format!(
1339 "claude exited with {status}{}; stderr tail: {}",
1340 if self.saw_result {
1341 ""
1342 } else {
1343 " without emitting a result message"
1344 },
1345 self.stderr_tail(),
1346 )),
1347 Err(e) => SessionExit::Failed(format!(
1348 "failed to reap claude process: {e}; stderr tail: {}",
1349 self.stderr_tail(),
1350 )),
1351 };
1352 if let Err(error) = self.cleanup_appcontainer() {
1353 exit = SessionExit::Failed(format!(
1354 "claude process exited but AppContainer host-state cleanup failed: {error}"
1355 ));
1356 }
1357 self.exit = Some(exit);
1358 }
1359
1360 fn stderr_tail(&self) -> String {
1361 let captured = self
1362 .stderr_buf
1363 .lock()
1364 .map(|guard| guard.clone())
1365 .unwrap_or_default();
1366 last_chars(captured.trim_end(), STDERR_TAIL_CHARS)
1367 }
1368}
1369
1370#[async_trait::async_trait]
1371impl AgentSession for ClaudeSession {
1372 fn session_id(&self) -> String {
1373 self.session_id.clone()
1374 }
1375
1376 async fn next_event(&mut self) -> Result<Option<AgentEvent>> {
1377 loop {
1378 if let Some(event) = self.queue.pop_front() {
1381 return Ok(Some(event));
1382 }
1383 if self.exit.is_some() {
1384 return Ok(None);
1385 }
1386 let line = match self.lines.next_line().await {
1387 Ok(Some(line)) => line,
1388 Ok(None) => {
1389 self.finish_at_eof().await;
1390 return Ok(None);
1391 }
1392 Err(e) => {
1393 self.kill_child().await;
1394 let cleanup = self
1395 .cleanup_appcontainer()
1396 .err()
1397 .map(|error| format!("; AppContainer cleanup failed: {error}"))
1398 .unwrap_or_default();
1399 self.exit = Some(SessionExit::Failed(format!(
1400 "error reading claude stdout: {e}; stderr tail: {}{cleanup}",
1401 self.stderr_tail(),
1402 )));
1403 return Ok(None);
1404 }
1405 };
1406 if line.trim().is_empty() {
1407 continue;
1408 }
1409 let value: Value = match serde_json::from_str(&line) {
1410 Ok(value) => value,
1411 Err(_) => {
1412 self.queue.push_back(AgentEvent::Other {
1413 raw: json!({ "unparsed": line }),
1414 });
1415 continue;
1416 }
1417 };
1418 if self.over_turn_budget(&value) {
1419 self.kill_child().await;
1422 self.exit = Some(match self.cleanup_appcontainer() {
1423 Ok(()) => SessionExit::Aborted,
1424 Err(error) => SessionExit::Failed(format!(
1425 "turn-budget abort could not clean AppContainer host state: {error}"
1426 )),
1427 });
1428 continue; }
1430 let mut events = parse_stream_value(value);
1431 for event in &mut events {
1432 self.observe(event);
1433 }
1434 self.queue.extend(events);
1435 }
1436 }
1437
1438 async fn send_user_message(&mut self, text: &str) -> Result<()> {
1439 if !self.streaming {
1440 return Err(EngineError::Backend(
1441 "send_user_message on a single-shot session".to_string(),
1442 ));
1443 }
1444 let Some(stdin) = self.stdin.as_mut() else {
1445 return Err(EngineError::Backend(
1446 "send_user_message on a closed session (stdin dropped)".to_string(),
1447 ));
1448 };
1449 stdin.write_all(user_message_line(text).as_bytes()).await?;
1450 stdin.flush().await?;
1451 Ok(())
1452 }
1453
1454 async fn abort(&mut self) -> Result<()> {
1455 let already_exited = matches!(self.child.try_wait(), Ok(Some(_)));
1458 self.kill_child().await;
1459 self.cleanup_appcontainer()?;
1460 if self.saw_success_result && already_exited {
1461 self.exit = Some(SessionExit::Completed);
1462 } else {
1463 self.exit = Some(SessionExit::Aborted);
1464 }
1465 Ok(())
1466 }
1467
1468 fn exit_status(&self) -> Option<SessionExit> {
1469 self.exit.clone()
1470 }
1471}
1472
1473#[cfg(test)]
1474mod discovery_tests {
1475 use super::*;
1476
1477 #[test]
1478 fn claude_discovery_explicit_selection_never_probes_another_candidate() {
1479 let root = tempfile::tempdir().unwrap();
1480 let configured = root.path().join("configured claude ");
1481 let environment = root.path().join("environment-claude");
1482 let fallback = root.path().join("fallback-claude");
1483 for use_config in [true, false] {
1484 let selected = if use_config {
1485 &configured
1486 } else {
1487 &environment
1488 };
1489 for failure in [
1490 None,
1491 Some("--version exited with status 17"),
1492 Some("--version did not exit within 3s (killed)"),
1493 ] {
1494 let mut attempts = Vec::new();
1495 let result = discover_claude_binary_from(
1496 use_config.then(|| configured.to_str().unwrap()),
1497 Some(environment.as_os_str()),
1498 vec![fallback.clone()],
1499 |path| {
1500 attempts.push(path.to_path_buf());
1501 if path == selected {
1502 failure
1503 .map_or_else(|| Ok("fixture version".into()), |why| Err(why.into()))
1504 } else {
1505 Ok("successful fallback sentinel".into())
1506 }
1507 },
1508 );
1509 assert_eq!(attempts, vec![selected.clone()]);
1510 if let Some(why) = failure {
1511 let error = result.unwrap_err().to_string();
1512 assert!(error.contains(&selected.display().to_string()), "{error}");
1513 assert!(error.contains(why), "{error}");
1514 assert!(
1515 error.contains(if use_config {
1516 "claudeBinary"
1517 } else {
1518 "KRANZ_CLAUDE_BIN"
1519 }),
1520 "{error}"
1521 );
1522 } else {
1523 assert_eq!(result.unwrap(), *selected);
1524 }
1525 }
1526 }
1527 assert!(discover_claude_binary_from(
1528 Some(" /not-an-absolute-path"),
1529 None,
1530 vec![fallback],
1531 |_| panic!("relative configured paths must be refused before probing"),
1532 )
1533 .is_err());
1534 }
1535
1536 #[test]
1537 fn claude_discovery_automatic_selection_preserves_order_and_deduplication() {
1538 let first = PathBuf::from("path-claude");
1539 let second = PathBuf::from("known-location-claude");
1540 let mut attempts = Vec::new();
1541 let found = discover_claude_binary_from(
1542 None,
1543 None,
1544 vec![first.clone(), first.clone(), second.clone()],
1545 |path| {
1546 attempts.push(path.to_path_buf());
1547 if path == first {
1548 Err("not executable".into())
1549 } else {
1550 Ok("fixture version".into())
1551 }
1552 },
1553 )
1554 .unwrap();
1555 assert_eq!(found, second);
1556 assert_eq!(attempts, vec![first.clone(), second.clone()]);
1557 let error = discover_claude_binary_from(
1558 Some(" "),
1559 Some(std::ffi::OsStr::new("")),
1560 vec![first, second],
1561 |_| Err("fixture unavailable".into()),
1562 )
1563 .unwrap_err()
1564 .to_string();
1565 assert!(
1566 error.contains("path-claude (fixture unavailable)"),
1567 "{error}"
1568 );
1569 assert!(
1570 error.contains("known-location-claude (fixture unavailable)"),
1571 "{error}"
1572 );
1573 }
1574
1575 #[cfg(unix)]
1576 #[test]
1577 fn claude_discovery_failed_and_hung_overrides_never_execute_working_fallback() {
1578 use std::os::unix::fs::PermissionsExt as _;
1579 let root = tempfile::tempdir().unwrap();
1580 let script = |name: &str, body: &str| {
1581 let staged = root.path().join(format!(".{name}.tmp"));
1582 let path = root.path().join(name);
1583 std::fs::write(&staged, format!("#!/bin/sh\n{body}\n")).unwrap();
1584 std::fs::set_permissions(&staged, std::fs::Permissions::from_mode(0o755)).unwrap();
1585 std::fs::rename(staged, &path).unwrap();
1586 path
1587 };
1588 let fallback = script(
1589 "fallback",
1590 "printf probed > \"$0.marker\"; printf 'fixture version' ",
1591 );
1592 let marker = fallback.with_extension("marker");
1593 assert_eq!(probe_version(&fallback).unwrap(), "fixture version");
1594 assert!(marker.exists(), "the fallback sentinel works");
1595 std::fs::remove_file(&marker).unwrap();
1596 for (name, body, cause) in [
1597 (
1598 "failed",
1599 "printf intentional-probe-failure >&2; exit 17",
1600 "intentional-probe-failure",
1601 ),
1602 ("hung", "exec /bin/sleep 30", "did not exit within 3s"),
1603 ] {
1604 let explicit = script(name, body);
1605 for use_config in [true, false] {
1606 let mut attempts = Vec::new();
1607 let start = std::time::Instant::now();
1608 let error = discover_claude_binary_from(
1609 use_config.then(|| explicit.to_str().unwrap()),
1610 Some(if use_config {
1611 fallback.as_os_str()
1612 } else {
1613 explicit.as_os_str()
1614 }),
1615 vec![fallback.clone()],
1616 |path| {
1617 attempts.push(path.to_path_buf());
1618 probe_version(path)
1619 },
1620 )
1621 .unwrap_err()
1622 .to_string();
1623 assert_eq!(attempts, vec![explicit.clone()]);
1624 assert!(error.contains(cause), "{error}");
1625 assert!(error.contains(&explicit.display().to_string()), "{error}");
1626 assert!(!marker.exists(), "explicit failure executed the fallback");
1627 assert!(start.elapsed() < std::time::Duration::from_secs(10));
1628 }
1629 }
1630 }
1631}
1632
1633#[cfg(test)]
1636mod scratch_root_tests {
1637 use super::*;
1638
1639 fn isolated_case(name: &str, value: Option<&std::path::Path>) -> bool {
1642 if std::env::var("KRANZ_SCRATCH_TEST_CASE").as_deref() == Ok(name) {
1643 return false;
1644 }
1645 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
1646 command
1647 .args([
1648 &format!("backend_claude::scratch_root_tests::{name}"),
1649 "--exact",
1650 "--nocapture",
1651 ])
1652 .env("KRANZ_SCRATCH_TEST_CASE", name);
1653 if let Some(value) = value {
1654 command.env(SCRATCH_ROOT_ENV, value);
1655 } else {
1656 command.env_remove(SCRATCH_ROOT_ENV);
1657 }
1658 let output = command.output().unwrap();
1659 assert!(
1660 output.status.success(),
1661 "{}",
1662 String::from_utf8_lossy(&output.stderr)
1663 );
1664 assert!(String::from_utf8_lossy(&output.stdout).contains("test result: ok. 1 passed;"));
1665 true
1666 }
1667
1668 #[test]
1669 fn an_absolute_override_moves_scratch_off_the_temp_root() {
1670 let shared = tempfile::tempdir().unwrap();
1671 if isolated_case(
1672 "an_absolute_override_moves_scratch_off_the_temp_root",
1673 Some(shared.path()),
1674 ) {
1675 return;
1676 }
1677 let expected = std::path::PathBuf::from(std::env::var_os(SCRATCH_ROOT_ENV).unwrap());
1678 assert_eq!(
1679 scratch_home_root("sess-1"),
1680 expected.join("kranz-worker-home-sess-1")
1681 );
1682 }
1683
1684 #[test]
1685 fn a_relative_override_is_ignored_rather_than_resolved_somewhere_surprising() {
1686 if isolated_case(
1687 "a_relative_override_is_ignored_rather_than_resolved_somewhere_surprising",
1688 Some(std::path::Path::new("relative/scratch")),
1689 ) {
1690 return;
1691 }
1692 assert_eq!(scratch_root_base(), std::env::temp_dir());
1693 }
1694
1695 #[test]
1696 fn no_override_keeps_the_system_temp_root() {
1697 if isolated_case("no_override_keeps_the_system_temp_root", None) {
1698 return;
1699 }
1700 assert_eq!(scratch_root_base(), std::env::temp_dir());
1701 }
1702}
1703
1704#[cfg(all(test, unix))]
1705mod tests {
1706 use super::*;
1707
1708 #[test]
1709 fn probe_version_kills_a_hung_binary_within_the_deadline() {
1710 use std::os::unix::fs::PermissionsExt;
1711 let dir = tempfile::tempdir().unwrap();
1712 let stub = dir.path().join("hung-claude");
1713 std::fs::write(&stub, "#!/bin/sh\nsleep 30\n").unwrap();
1714 std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
1715
1716 let start = std::time::Instant::now();
1717 let result = probe_version(&stub);
1718
1719 let error = result.expect_err("a hung probe must be reported as broken");
1720 assert!(error.contains("did not exit"), "{error}");
1721 assert!(
1722 start.elapsed() < std::time::Duration::from_secs(10),
1723 "probe returned within the deadline, not after the stub's sleep"
1724 );
1725 }
1726
1727 fn write_env_dump_stub(dir: &Path, capture: &Path) -> PathBuf {
1735 use std::os::unix::fs::PermissionsExt;
1736 let stub = dir.join("claude-env-dump-stub.sh");
1737 std::fs::write(
1738 &stub,
1739 format!(
1740 "#!/bin/sh\n\
1741 env > '{}'\n\
1742 printf '%s\\n' \\\n\
1743 '{{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"stub\",\"model\":\"stub\"}}' \\\n\
1744 '{{\"type\":\"result\",\"is_error\":false,\"result\":\"done\",\"total_cost_usd\":0.0,\"usage\":{{\"input_tokens\":1,\"output_tokens\":1}},\"num_turns\":1}}'\n\
1745 exit 0\n",
1746 capture.display()
1747 ),
1748 )
1749 .unwrap();
1750 std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
1751 stub
1752 }
1753
1754 fn env_dump_spec(cwd: &Path, session_id: &str, env: HashMap<String, String>) -> SessionSpec {
1755 SessionSpec {
1756 cwd: cwd.to_path_buf(),
1757 prompt: PromptMode::SingleShot("hi".to_string()),
1758 append_system_prompt: None,
1759 model: "stub".to_string(),
1760 effort: "low".to_string(),
1761 session_id: session_id.to_string(),
1762 resume: None,
1763 permission_mode: None,
1764 allowed_tools: Vec::new(),
1765 disallowed_tools: Vec::new(),
1766 tools: Vec::new(),
1767 writable: false,
1768 settings_json: None,
1769 json_schema: None,
1770 max_budget_usd: None,
1771 max_turns: None,
1772 env,
1773 sandbox: None,
1774 hook_status: None,
1775 }
1776 }
1777
1778 async fn spawn_and_capture_env(binary: &Path, spec: SessionSpec, capture: &Path) -> String {
1779 let backend = ClaudeBackend::new(binary);
1780 let mut session = backend.start(spec).await.expect("stub session spawns");
1781 while session
1782 .next_event()
1783 .await
1784 .expect("stub stream parses")
1785 .is_some()
1786 {}
1787 std::fs::read_to_string(capture).expect("stub dumped the child env")
1788 }
1789
1790 #[tokio::test]
1796 async fn spawned_session_env_is_cleared_of_ambient_secrets() {
1797 let dir = tempfile::tempdir().unwrap();
1798 let capture = dir.path().join("child.env");
1799 let stub = write_env_dump_stub(dir.path(), &capture);
1800 let scratch = tempfile::tempdir().unwrap();
1801
1802 let _poison = crate::agent_env::EnvTestGuard::engage(&[
1803 ("GH_TOKEN", "hunter2"),
1804 ("SLACK_BOT_TOKEN", "x"),
1805 ("AWS_SECRET_ACCESS_KEY", "y"),
1806 ("ANTHROPIC_API_KEY", "sk-ant-poison"),
1807 ]);
1808
1809 let mut spec_env = HashMap::new();
1810 spec_env.insert("HOME".to_string(), scratch.path().display().to_string());
1811 spec_env.insert(
1812 "CLAUDE_CONFIG_DIR".to_string(),
1813 scratch.path().join(".claude").display().to_string(),
1814 );
1815 spec_env.insert("KRANZ_BASE_SHA".to_string(), "deadbeef".to_string());
1816 let spec = env_dump_spec(dir.path(), "env-clear-worker", spec_env);
1817
1818 let child_env = spawn_and_capture_env(&stub, spec, &capture).await;
1819
1820 for leaked in ["GH_TOKEN", "SLACK_BOT_TOKEN", "AWS_SECRET_ACCESS_KEY"] {
1821 assert!(
1822 !child_env.contains(leaked),
1823 "spawned session env leaked {leaked}:\n{child_env}"
1824 );
1825 }
1826 for leaked_value in ["hunter2", "xoxb", "aws-poison"] {
1827 assert!(
1828 !child_env.contains(leaked_value),
1829 "spawned session env leaked a poisoned value ({leaked_value}):\n{child_env}"
1830 );
1831 }
1832 assert!(
1833 child_env.contains("ANTHROPIC_API_KEY=sk-ant-poison"),
1834 "the claude backend's own auth key must be injected explicitly:\n{child_env}"
1835 );
1836 assert!(
1837 child_env.contains(&format!("HOME={}", scratch.path().display())),
1838 "HOME must be the session's scratch dir:\n{child_env}"
1839 );
1840 assert!(
1841 child_env.contains(&format!(
1842 "CLAUDE_CONFIG_DIR={}",
1843 scratch.path().join(".claude").display()
1844 )),
1845 "the seeded config dir must survive clearing (auth probe shape):\n{child_env}"
1846 );
1847 assert!(
1848 child_env.contains(&format!("TMPDIR={}", scratch.path().join("tmp").display())),
1849 "TMPDIR must be <scratch>/tmp:\n{child_env}"
1850 );
1851 assert!(
1852 child_env.contains(&format!(
1853 "CLAUDE_CODE_TMPDIR={}",
1854 scratch.path().join("tmp").display()
1855 )),
1856 "Claude's private temp root must equal the sandbox-writable TMPDIR:\n{child_env}"
1857 );
1858 assert!(child_env.contains("PATH="), "PATH must cross:\n{child_env}");
1859 assert!(
1860 child_env.contains("KRANZ_BASE_SHA=deadbeef"),
1861 "spec env must cross verbatim:\n{child_env}"
1862 );
1863 }
1864
1865 #[tokio::test]
1870 async fn home_less_spec_spawns_into_a_freshly_seeded_scratch_home() {
1871 let dir = tempfile::tempdir().unwrap();
1872 let capture = dir.path().join("child.env");
1873 let stub = write_env_dump_stub(dir.path(), &capture);
1874 let real_config = tempfile::tempdir().unwrap();
1877 std::fs::write(
1878 real_config.path().join(".credentials.json"),
1879 "{\"token\":\"oauth\"}",
1880 )
1881 .unwrap();
1882 let real_config_str = real_config.path().display().to_string();
1883
1884 let _poison = crate::agent_env::EnvTestGuard::engage(&[
1885 ("GH_TOKEN", "hunter2"),
1886 ("CLAUDE_CONFIG_DIR", &real_config_str),
1887 ]);
1888
1889 let session_id = "env-clear-orchestrator";
1890 let spec = env_dump_spec(dir.path(), session_id, HashMap::new());
1891
1892 let child_env = spawn_and_capture_env(&stub, spec, &capture).await;
1893
1894 let expected_home = scratch_home_root(session_id).join("home");
1895 assert!(
1896 child_env.contains(&format!("HOME={}", expected_home.display())),
1897 "a HOME-less spec must spawn into the per-session scratch HOME:\n{child_env}"
1898 );
1899 assert!(
1900 child_env.contains(&format!(
1901 "CLAUDE_CODE_TMPDIR={}",
1902 expected_home.join("tmp").display()
1903 )),
1904 "validator/orchestrator Claude temp state must stay under the scratch HOME:\n{child_env}"
1905 );
1906 assert!(
1907 !child_env.contains("CLAUDE_CONFIG_DIR"),
1908 "CLAUDE_CONFIG_DIR must NOT be set (it poisons keychain OAuth; \
1909 HOME/.claude resolves implicitly):\n{child_env}"
1910 );
1911 assert!(
1912 !child_env.contains("GH_TOKEN") && !child_env.contains("hunter2"),
1913 "ambient secrets must not cross:\n{child_env}"
1914 );
1915 let seeded = expected_home.join(".claude").join(CLAUDE_CREDENTIALS_ENTRY);
1916 assert_eq!(
1917 std::fs::read_to_string(&seeded).expect("scratch HOME was seeded"),
1918 "{\"token\":\"oauth\"}",
1919 "the OAuth credential copy must land in the seeded scratch config dir"
1920 );
1921 }
1922
1923 #[cfg(target_os = "macos")]
1928 #[test]
1929 fn seed_worker_scratch_home_links_the_real_keychain_dir() {
1930 let real_home = tempfile::tempdir().unwrap();
1931 let real_keychains = real_home.path().join("Library").join("Keychains");
1932 std::fs::create_dir_all(&real_keychains).unwrap();
1933 std::fs::write(real_keychains.join("login.keychain-db"), "db").unwrap();
1934 let scratch = tempfile::tempdir().unwrap();
1935
1936 let (home, _config) =
1937 seed_worker_scratch_home(scratch.path(), Some(real_home.path()), None).unwrap();
1938
1939 let link = home.join("Library").join("Keychains");
1940 let target = std::fs::read_link(&link).expect("Keychains must be a symlink");
1941 assert_eq!(target, real_keychains);
1942 assert_eq!(
1944 std::fs::read_to_string(link.join("login.keychain-db")).unwrap(),
1945 "db"
1946 );
1947
1948 let bare_home = tempfile::tempdir().unwrap();
1950 let scratch2 = tempfile::tempdir().unwrap();
1951 let (home2, _) =
1952 seed_worker_scratch_home(scratch2.path(), Some(bare_home.path()), None).unwrap();
1953 assert!(!home2.join("Library").join("Keychains").exists());
1954 }
1955
1956 #[tokio::test]
1961 async fn over_long_stdout_line_is_truncated_and_the_session_completes() {
1962 use std::os::unix::fs::PermissionsExt;
1963 let dir = tempfile::tempdir().unwrap();
1964 let stub = dir.path().join("claude-long-line-stub.sh");
1965 std::fs::write(
1966 &stub,
1967 "#!/bin/sh\n\
1968 printf '%s\\n' '{\"type\":\"system\",\"subtype\":\"init\",\"session_id\":\"stub\",\"model\":\"stub\"}'\n\
1969 head -c 9000000 /dev/zero | tr '\\0' 'x'\n\
1970 printf '\\n'\n\
1971 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\
1972 exit 0\n",
1973 )
1974 .unwrap();
1975 std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
1976
1977 let backend = ClaudeBackend::new(&stub);
1978 let spec = env_dump_spec(dir.path(), "long-line", HashMap::new());
1979 let mut session = backend.start(spec).await.expect("stub session spawns");
1980 let mut saw_truncated_other = false;
1981 while let Some(event) = session.next_event().await.expect("stream reads") {
1982 if let AgentEvent::Other { raw } = &event {
1983 if raw
1984 .to_string()
1985 .contains(crate::stream_bounds::TRUNCATION_MARKER)
1986 {
1987 saw_truncated_other = true;
1988 }
1989 }
1990 }
1991
1992 assert!(
1993 saw_truncated_other,
1994 "the over-long line surfaced as a truncated unparsed Other"
1995 );
1996 assert_eq!(
1997 session.exit_status(),
1998 Some(SessionExit::Completed),
1999 "the session completes on the result line after the truncated one"
2000 );
2001 }
2002
2003 #[tokio::test]
2007 async fn endless_stderr_is_drained_and_only_the_tail_is_surfaced() {
2008 use std::os::unix::fs::PermissionsExt;
2009 let dir = tempfile::tempdir().unwrap();
2010 let stub = dir.path().join("claude-noisy-stderr-stub.sh");
2011 std::fs::write(
2012 &stub,
2013 "#!/bin/sh\n\
2014 head -c 200000 /dev/zero | tr '\\0' 'y' >&2\n\
2015 echo 'STDERR-END' >&2\n\
2016 exit 3\n",
2017 )
2018 .unwrap();
2019 std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755)).unwrap();
2020
2021 let backend = ClaudeBackend::new(&stub);
2022 let spec = env_dump_spec(dir.path(), "noisy-stderr", HashMap::new());
2023 let mut session = backend.start(spec).await.expect("stub session spawns");
2024 while session.next_event().await.expect("stream reads").is_some() {}
2025
2026 match session.exit_status() {
2027 Some(SessionExit::Failed(message)) => {
2028 assert!(
2029 message.contains(crate::stream_bounds::TRUNCATION_MARKER),
2030 "expected the truncation marker, got: {message}"
2031 );
2032 assert!(
2033 message.contains("STDERR-END"),
2034 "expected the END of stderr to be kept, got: {message}"
2035 );
2036 assert!(
2037 message.len() < 1024,
2038 "the surfaced stderr tail stayed bounded, got {} bytes",
2039 message.len()
2040 );
2041 }
2042 other => panic!("expected SessionExit::Failed, got {other:?}"),
2043 }
2044 }
2045}