Skip to main content

kranz_engine/
backend_claude.rs

1//! Real agent backend: drives the `claude` CLI headless.
2//!
3//! Ground truth is docs/design.md "Verified CLI behavior" (claude 2.1.198):
4//! `claude -p --output-format stream-json --verbose` emits JSONL that this
5//! module parses into [`AgentEvent`]s. Parsers MUST tolerate unknown line
6//! types (`rate_limit_event`, `system/thinking_tokens`,
7//! `system/post_turn_summary`, ...) — they map to [`AgentEvent::Other`] and
8//! keep the raw line for transcript fidelity.
9//!
10//! The CLI dropped `--max-turns`, so turn budgets are engine-enforced here:
11//! distinct assistant `message.id` values are counted and the session is
12//! aborted once the count exceeds `SessionSpec::max_turns`.
13
14use 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
29/// Max characters kept in tool-use / tool-result summaries.
30const SUMMARY_MAX_CHARS: usize = 200;
31/// Max characters of captured stderr included in failure messages.
32const STDERR_TAIL_CHARS: usize = 500;
33
34// ---------------------------------------------------------------------------
35// Windows process-tree kill via Job Objects
36// ---------------------------------------------------------------------------
37
38/// Windows process-tree kill, mirroring the unix process-group approach.
39///
40/// COMPILES AND RUNS ONLY UNDER `cfg(windows)`. This whole module is
41/// `#[cfg(windows)]`, so it is absent from the macOS/Linux build entirely and
42/// is validated exclusively by the `windows-latest` CI job — never by the dev
43/// host. Keep the unsafe surface tiny and every `HANDLE` closed exactly once.
44///
45/// Windows has no process groups. The equivalent is a **Job Object** with
46/// `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`: every process assigned to the job —
47/// and every descendant it spawns, which inherit job membership — is
48/// terminated the moment the last handle to the job closes. So a `claude` CLI
49/// (or `sh`/`cmd` wrapper) assigned to such a job takes its whole tool-child
50/// tree (test runners, builds) down with it on abort, timeout, or a plain
51/// drop of the job handle.
52///
53/// Usage: [`JobHandle::create_and_assign`] right after spawn, store the
54/// returned guard alongside the child, then either call [`JobHandle::kill`]
55/// (explicit `TerminateJobObject`) or just drop the guard (`CloseHandle` +
56/// `KILL_ON_JOB_CLOSE`) — both kill the tree.
57///
58/// Assignment happens *after* `spawn()` (tokio's `Command` exposes no
59/// `CREATE_SUSPENDED`), so there is a microsecond window in which the child
60/// could `spawn` a grandchild before it is assigned — that grandchild would
61/// escape the job. In practice `claude`/`cmd` has not forked a tool child in
62/// the gap between `spawn()` and the assign, so this matches the unix
63/// process-group approach (which has an analogous fork race) closely enough.
64#[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    /// RAII owner of a Job Object `HANDLE`. `Drop` calls `CloseHandle` exactly
76    /// once, which (because the job was created with
77    /// `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`) also terminates every process
78    /// still assigned to the job.
79    #[derive(Debug)]
80    pub(crate) struct JobHandle {
81        job: HANDLE,
82    }
83
84    // The stored HANDLE is a kernel object handle owned solely by this guard;
85    // it is safe to move across threads (the child is polled from tokio tasks).
86    // SAFETY: a Job Object HANDLE is not tied to any thread; Win32 permits use
87    // and close from any thread. We own it exclusively (closed once on Drop).
88    unsafe impl Send for JobHandle {}
89    unsafe impl Sync for JobHandle {}
90
91    impl JobHandle {
92        /// Create a kill-on-close job, assign the process behind `child_handle`
93        /// to it, and return the owning guard. The process's descendants inherit
94        /// membership, so the whole tree dies when this guard is killed or
95        /// dropped.
96        ///
97        /// `child_handle` is the child process's raw handle — on Windows,
98        /// `tokio::process::Child::raw_handle()`. It is borrowed for the
99        /// assignment only: it stays owned by the `Child` and is never closed
100        /// here.
101        ///
102        /// Errors carry the failing Win32 call so a CI failure is diagnosable;
103        /// the caller treats a job-setup failure as non-fatal (the child still
104        /// runs, just without tree-kill — same as the pre-job behaviour).
105        pub(crate) fn create_and_assign(child_handle: RawHandle) -> windows::core::Result<Self> {
106            // SAFETY: `None` security attributes plus a null name creates an
107            // unnamed, default-security job. The crate's own wrapper maps a
108            // null return to the thread's last OS error, so no manual
109            // GetLastError handling is needed here.
110            //
111            // This used a hand-declared `extern "system"` kernel32 import,
112            // justified by a comment claiming the manifest does not enable the
113            // `Win32_Security` feature that gates this wrapper. It does enable
114            // it (crates/engine/Cargo.toml), so the raw import was only
115            // discarding the crate's type checking.
116            let job = unsafe { CreateJobObjectW(None, PCWSTR::null())? };
117            // Wrap immediately so any early return below still closes the job.
118            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            // SAFETY: `job` is a valid job handle; we pass a pointer to a
123            // correctly typed, fully initialized info struct together with its
124            // exact byte length, as the API requires.
125            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            // `RawHandle` is already `*mut c_void`, exactly HANDLE's field type.
135            // SAFETY: `guard.job` is valid; `child_handle` is the child's live
136            // process handle (borrowed — not closed here). AssignProcessToJobObject
137            // only reads it.
138            unsafe {
139                AssignProcessToJobObject(guard.job, HANDLE(child_handle))?;
140            }
141            Ok(guard)
142        }
143
144        /// Terminate every process in the job now (explicit kill path). Dropping
145        /// the guard would achieve the same via `KILL_ON_JOB_CLOSE`, but the
146        /// explicit call makes the kill deterministic even while the guard is
147        /// still held.
148        pub(crate) fn kill(&self) {
149            // SAFETY: `self.job` is a valid job handle owned by this guard;
150            // TerminateJobObject takes it plus an exit code and returns a
151            // Result we deliberately ignore (best-effort kill).
152            let _ = unsafe { TerminateJobObject(self.job, 1) };
153        }
154    }
155
156    impl Drop for JobHandle {
157        fn drop(&mut self) {
158            // SAFETY: `self.job` was returned by CreateJobObjectW and is closed
159            // exactly once, here. Closing the last handle to a
160            // KILL_ON_JOB_CLOSE job also terminates any surviving members.
161            let _ = unsafe { CloseHandle(self.job) };
162        }
163    }
164}
165
166// ---------------------------------------------------------------------------
167// Binary discovery
168// ---------------------------------------------------------------------------
169
170/// Locate a working `claude` binary.
171///
172/// A nonempty `configured` path is exclusive; otherwise a nonempty
173/// `KRANZ_CLAUDE_BIN` is exclusive. A failed `--version` probe returns the
174/// selected path and cause without trying another executable. Only absent
175/// overrides permit discovery through PATH and then well-known locations.
176///
177/// A RELATIVE `configured` path is refused outright rather than tried
178/// (audit 2026-09-01 H1): candidate one is executed, and a relative path
179/// resolves against the process working directory, so `{"claudeBinary":
180/// "./scripts/helper"}` in a repository's config layer plus a committed
181/// executable is code execution as the operator on the first command that
182/// resolves a backend. `config::validate_claude_binary` refuses it earlier
183/// and with more context (it also knows the repo root); this is the same
184/// refusal at the execution site, for the callers that pass a raw string.
185pub fn discover_claude_binary(configured: Option<&str>) -> Result<PathBuf> {
186    let mut candidates: Vec<PathBuf> = Vec::new();
187    // Bare names resolve through PATH (std::process handles .cmd/.exe lookup
188    // rules per-platform).
189    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
204/// Explicit inputs keep selection tests independent of installed backends.
205fn 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    // Dedupe, preserving priority order.
235    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/// Well-known install locations checked after PATH.
259#[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/// Well-known install locations checked after PATH (Windows).
275#[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
292/// Deadline for a `--version` probe. Generous for a healthy CLI, but bounds
293/// a hung shim on PATH so binary discovery (`kranz ready`, session spawn)
294/// can never block forever on a candidate.
295const VERSION_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3);
296
297/// Validate a candidate by running `<candidate> --version`, draining both
298/// output pipes concurrently while enforcing [`VERSION_PROBE_TIMEOUT`].
299fn probe_version(binary: &Path) -> std::result::Result<String, String> {
300    crate::backend_probe::probe_version(binary, VERSION_PROBE_TIMEOUT)
301}
302
303// ---------------------------------------------------------------------------
304// Minimal config-dir entry set (worker-sandboxing open question 1)
305// ---------------------------------------------------------------------------
306
307/// The credential entry the `claude` CLI reads for file-based (non-Keychain)
308/// OAuth auth, relative to `CLAUDE_CONFIG_DIR` (default `$HOME/.claude`).
309pub const CLAUDE_CREDENTIALS_ENTRY: &str = ".credentials.json";
310
311/// Single source of truth for the minimal `CLAUDE_CONFIG_DIR` entry set a
312/// scratch worker HOME/config dir needs to carry so the `claude` CLI can
313/// authenticate and run headless (`-p --output-format stream-json`).
314///
315/// See `docs/scoping/claude-cli-min-env.md` for the full probe: why
316/// `.credentials.json` is required for file-based auth but irrelevant when
317/// auth comes from the macOS Keychain or `ANTHROPIC_API_KEY`, and why
318/// `CLAUDE_CONFIG_DIR` relocation does not also relocate `$HOME/.claude.json`
319/// (a worker HOME must be set too for that file to land in the sandbox).
320///
321/// Names only — never actual secret values. Consumed by the (not-yet-built)
322/// scratch-HOME-seeding feature; not wired into spawning here.
323pub fn claude_min_config_entries() -> &'static [&'static str] {
324    &[CLAUDE_CREDENTIALS_ENTRY]
325}
326
327// ---------------------------------------------------------------------------
328// Scratch worker HOME/config-dir seeding (worker env hygiene)
329// ---------------------------------------------------------------------------
330
331/// Environment override for the directory per-session scratch lives under.
332///
333/// The default is the system temp dir, which is right everywhere except one
334/// case that matters: a container mission whose runtime does not share the
335/// temp dir. Colima shares only the home directory by default and macOS puts
336/// `TMPDIR` under `/var/folders`, so the scratch a worker writes into is
337/// exactly the path its container cannot see. `kranz` refuses that mission
338/// rather than losing its output (see
339/// [`crate::sandbox_container::MountProof`]), and this variable is the cheap
340/// way out: point scratch at a directory the runtime already shares, instead
341/// of reconfiguring the runtime.
342pub const SCRATCH_ROOT_ENV: &str = "KRANZ_SCRATCH_ROOT";
343
344/// The directory per-session scratch roots live under.
345///
346/// [`SCRATCH_ROOT_ENV`] when it names an ABSOLUTE path, else the system temp
347/// dir. A relative override is ignored rather than honored: scratch paths are
348/// handed to container mounts and sandbox profiles, both of which resolve
349/// them against a working directory the operator did not choose.
350pub 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
366/// Where a worker session's scratch `HOME` lives for a given session id.
367///
368/// Unique per session under [`scratch_root_base`] so concurrent worker
369/// sessions never share (or race on) scratch state.
370pub fn scratch_home_root(session_id: &str) -> std::path::PathBuf {
371    scratch_root_base().join(format!("kranz-worker-home-{session_id}"))
372}
373
374/// Seed `scratch_root` with a scratch `HOME` containing exactly the
375/// [`claude_min_config_entries`] allowlist, copied opaquely (bytes only, no
376/// parsing/logging of contents) from the real config dir when present.
377///
378/// The source config dir is `real_config_dir` when given (the operator's
379/// `CLAUDE_CONFIG_DIR` override, if set), else falls back to `real_home`'s
380/// `.claude` dir — mirroring how the `claude` CLI itself resolves its config
381/// location. Passing neither yields an empty (but present) scratch config.
382///
383/// macOS Keychain auth additionally needs `$HOME/Library/Keychains`: the CLI
384/// resolves the login keychain by HOME-relative path, so a relocated HOME
385/// without it fails "Not logged in" even though the keychain item exists
386/// (observed 2026-07-29 after the CLI migrated token storage from
387/// `.credentials.json` to the keychain). Seed a SYMLINK to the real one —
388/// the OAuth credential the session legitimately needs, same trust class as
389/// the file-based credentials copy. macOS-only; other platforms store auth
390/// file-side.
391///
392/// Returns `(home_dir, config_dir)`: `home_dir` is what the caller should set
393/// `HOME` to (so `$HOME/.claude.json` resolves inside the sandbox), and
394/// `config_dir` — `home_dir/.claude` — is what the caller should set
395/// `CLAUDE_CONFIG_DIR` to. The resulting `config_dir` contains only
396/// allowlisted entries that existed in the source, and nothing else: no
397/// arbitrary operator dotfiles are copied.
398pub 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
435// ---------------------------------------------------------------------------
436// Argument construction
437// ---------------------------------------------------------------------------
438
439/// Build the argv (excluding the binary itself) for one session.
440///
441/// Public so tests can assert the exact CLI wire format without spawning.
442pub fn build_args(spec: &SessionSpec) -> Vec<String> {
443    let mut args: Vec<String> = vec![
444        "-p".into(),
445        // Load only the operator's own settings. Verified 2026-09-02 against
446        // Claude Code 2.1.220 in a `-p` session with a fresh scratch HOME
447        // and no trust record: a repository's `.claude/settings.json`
448        // `SessionStart` hook ran, and a repository `.mcp.json` server
449        // command started, both before the model's first turn and outside
450        // its permission system. With `--setting-sources user` neither
451        // fired. Repository content is the untrusted input every other
452        // guard in this crate assumes, so project and local settings are
453        // never loaded; kranz's own hook projection still arrives through
454        // `--settings` below (2026-09-01 adversarial audit, exec I1).
455        "--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()); // compact JSON
498    }
499    if let Some(schema) = &spec.json_schema {
500        args.push("--json-schema".into());
501        args.push(schema.to_string()); // compact JSON
502    }
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            // Initial prompt goes via stdin as a stream-json user message.
510            args.push("--input-format".into());
511            args.push("stream-json".into());
512        }
513        PromptMode::SingleShot(prompt) => {
514            // Positional prompt must be the last argument.
515            args.push(prompt.clone());
516        }
517    }
518    args
519}
520
521/// Build the `sandbox-exec` argv that wraps a `binary` invocation with a
522/// generated Seatbelt `profile_path`: program `sandbox-exec`, args
523/// `["-f", <profile_path>, <binary>, <args...>]` in that exact order.
524///
525/// Pure and platform-independent so it is unit-testable without spawning;
526/// callers gate its use on `cfg!(target_os = "macos")`.
527pub 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
538/// One stdin line injecting a user message into a streaming-input session.
539pub 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
550// ---------------------------------------------------------------------------
551// Stream-json line parsing
552// ---------------------------------------------------------------------------
553
554/// Parse one stdout line into zero or more [`AgentEvent`]s.
555///
556/// Unparseable lines become [`AgentEvent::Other`] with
557/// `raw = {"unparsed": <line>}` so nothing is ever dropped from transcripts.
558pub 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
567/// Map one parsed stream-json value to events (see module docs / fixture).
568/// This stateless parser preserves protocol costs; live streaming sessions
569/// normalize cumulative costs before delivering events to the engine.
570pub 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
595/// One event per content block: text → `Text` (empty skipped), tool_use →
596/// `ToolUse`, anything else (thinking, ...) → `Other`. Every event carries
597/// the full raw line.
598fn 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
637/// Human-readable summary of a tool invocation: the command for Bash, the
638/// file path for Edit/Write/Read, else the compact input JSON (truncated).
639fn 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
653/// `type: "user"` lines carry tool results echoed back to the model. Each
654/// `tool_result` block becomes a `ToolResult`; the `denied` heuristic flags
655/// permission-rule and hook blocks (§4.7 guardrail surfacing) plus the
656/// structured refusal shapes observed in the m-9e4ef3/m-3cda6a blocks that
657/// carry neither word — "requires approval", "Contains expansion", and
658/// "output redirection … blocked". Those are matched only when they carry
659/// denial context (is_error, or a block/deny/reject phrase), not by bare
660/// substring: a false positive parks an operator-visible, deny-by-default
661/// grant request, while a false negative silently aborts the session with
662/// deniedToolResults=0 — the miss is the expensive one.
663fn 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
701/// A tool_result `content` is either a plain string or an array of
702/// `{type:"text", text}` parts.
703fn 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
753/// Keep at most `max` characters (not bytes — never splits a code point).
754fn 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
762/// Last `max` characters of `text` (for stderr tails in error messages).
763fn 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// ---------------------------------------------------------------------------
770// Backend
771// ---------------------------------------------------------------------------
772
773/// The real [`AgentBackend`]: spawns the `claude` CLI headless.
774#[derive(Debug, Clone)]
775pub struct ClaudeBackend {
776    binary: PathBuf,
777}
778
779impl ClaudeBackend {
780    /// Use an explicit binary path (no validation performed).
781    pub fn new(binary: impl Into<PathBuf>) -> Self {
782        ClaudeBackend {
783            binary: binary.into(),
784        }
785    }
786
787    /// Discover the binary via [`discover_claude_binary`].
788    pub fn discover(configured: Option<&str>) -> Result<Self> {
789        Ok(ClaudeBackend {
790            binary: discover_claude_binary(configured)?,
791        })
792    }
793
794    /// The binary this backend spawns.
795    pub fn binary(&self) -> &Path {
796        &self.binary
797    }
798}
799
800/// The ambient env var a `claude` session may legitimately authenticate
801/// with (API-key deploys, docs/deploy.md); injected by [`claude_child_env`]
802/// only when the operator actually has it set. OAuth instead flows through
803/// the scratch-HOME seeding below, never through ambient inheritance.
804const CLAUDE_AUTH_ENV: &str = "ANTHROPIC_API_KEY";
805
806/// Claude Code's own temp-root override. Without it current CLIs place Bash
807/// session plumbing under `/tmp/claude-<uid>` even when `TMPDIR` points at the
808/// per-session scratch HOME, which is outside an enforced sandbox's writable
809/// set. Always pin it to the cleared env's already-private `TMPDIR`.
810const 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
819/// The cleared environment one `claude` session spawns with (ticket
820/// `agent-env-clear`; see [`crate::agent_env`]).
821///
822/// When the spec carries a relocated scratch `HOME` (worker relocation —
823/// the env the auth preflight proved out), that HOME is used verbatim.
824/// Otherwise (orchestrator/validator sessions, and the preflight fail-safe
825/// branch that USED TO mean "inherit the operator's real HOME") a fresh
826/// per-session scratch HOME is seeded with the minimal credential set — the
827/// same [`seed_worker_scratch_home`] recipe — so OAuth file-based auth
828/// keeps working without the child ever seeing the operator's real HOME.
829/// Seeding failure degrades to an empty scratch home: the session then
830/// fails auth loudly rather than silently inheriting. `ANTHROPIC_API_KEY`
831/// is injected explicitly when set (logged name-only in `agent_env`).
832fn 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            // CLAUDE_CONFIG_DIR is deliberately NOT set: when present it
850            // poisons the CLI's keychain-backed OAuth resolution entirely
851            // ("Not logged in", probed 2026-07-29 — even with the real
852            // config contents copied in), and it is redundant for a
853            // relocated HOME, where `$HOME/.claude` resolves implicitly.
854            // The seeded scratch home (config entries + Library/Keychains
855            // symlink + USER passthrough) is the whole recipe.
856            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        // Seed the cleared child environment before generating a Seatbelt
891        // profile. The per-session scratch root may not exist yet; creating it
892        // first lets `generate_profile` include both `/var/...` and its
893        // canonical `/private/var/...` spelling on macOS. Building the profile
894        // first left Claude unable to create `$HOME/.claude/session-env`.
895        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                // Profiles are runtime evidence, not committed mission
916                // artifacts. `kranz init` ignores `missions/*/runs/`; writing
917                // them at the mission root left every sandboxed run with an
918                // untracked dirty checkout (live M8 proof m-bb3632).
919                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                // A proxy-routed fs+net session (runner wired spec.env) needs
952                // the proxy endpoint INSIDE the container — `docker run` does
953                // not forward client env, so the builder emits -e flags.
954                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        // agent-env-clear: the child spawns with a CLEARED environment
992        // rebuilt from the minimal allowlist (PATH, a scratch HOME, locale)
993        // — never the full ambient set, so server secrets (GH_TOKEN,
994        // SLACK_*, AWS_*) cannot reach this prompt-injectable child.
995        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        // Unix: make the child the leader of a fresh process group so aborts
1008        // can kill the whole tree — tool subprocesses (test runners, builds)
1009        // die with the CLI instead of surviving an interrupt/turn-budget
1010        // abort. See [`ClaudeSession::kill_child`].
1011        // Windows has no process groups. The trusted AppContainer helper owns
1012        // the hostile child in a kill-on-close Job before resuming it; the
1013        // outer Job below additionally supervises the helper. Unsandboxed
1014        // Windows sessions retain the existing post-spawn Job behavior.
1015        #[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        // Windows: assign the child to a kill-on-close Job Object so its whole
1023        // descendant tree (tool children — test runners, builds) dies on
1024        // abort/turn-budget kill, mirroring the unix process-group behaviour.
1025        // Outer Job setup failure is non-fatal: an AppContainer helper still
1026        // owns the hostile descendant tree in its fail-closed inner Job, while
1027        // an unsandboxed session retains the pre-Job-Object behavior. Behind
1028        // cfg(windows); compiled and validated only on windows-latest CI.
1029        #[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            // The child already exited between spawn and here — nothing to
1040            // assign; kill_child falls back to the direct reap.
1041            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        // Capture stderr concurrently so a chatty child never blocks on a
1055        // full pipe and failure messages can include the tail. The stream is
1056        // drained to EOF but only a bounded tail is retained — a noisy or
1057        // malicious CLI must not exhaust host memory (stream_bounds).
1058        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// ---------------------------------------------------------------------------
1103// Session
1104// ---------------------------------------------------------------------------
1105
1106/// Send SIGKILL to the process group `pgid`. Returns whether the signal was
1107/// delivered to at least one process (false means the group is gone).
1108#[cfg(unix)]
1109pub(crate) fn kill_group(pgid: i32) -> bool {
1110    debug_assert!(pgid > 0, "kill_group needs a positive group id");
1111    // SAFETY: kill(2) takes a pid and a signal number; no pointers or shared
1112    // state are involved. A negative pid targets the whole process group.
1113    unsafe { libc::kill(-pgid, libc::SIGKILL) == 0 }
1114}
1115
1116/// Future cancellation can drop a session without reaching async abort.
1117/// A reaped Child has no id, so this never signals a recycled leader pid.
1118#[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
1127/// A live `claude` CLI session (the [`AgentSession`] impl).
1128pub struct ClaudeSession {
1129    /// Updated by the last `system/init` seen; defaults to the spec value.
1130    session_id: String,
1131    streaming: bool,
1132    max_turns: Option<u32>,
1133    child: Child,
1134    /// Windows only: the kill-on-close Job Object owning the child's process
1135    /// tree. Ordered *after* `child` so `child` drops first (Rust drops fields
1136    /// top-to-bottom); either order is safe, but killing the tree after the
1137    /// child's own `kill_on_drop` is the tidier sequence. Dropping this guard
1138    /// closes the job handle, which (via `KILL_ON_JOB_CLOSE`) also terminates
1139    /// any surviving descendants. `None` if job setup failed at spawn.
1140    /// Compiled and validated only on windows-latest CI.
1141    #[cfg(windows)]
1142    job: Option<win_job::JobHandle>,
1143    /// Windows AppContainer profile + retained no-follow DACL handles. Kept
1144    /// until the wrapper and its hostile child are gone; normal completion or
1145    /// abort explicitly removes this profile SID's ACEs and deletes the
1146    /// disposable profile. Drop remains a best-effort crash fallback.
1147    #[cfg(windows)]
1148    appcontainer_lease: Option<crate::appcontainer_windows::AppContainerLease>,
1149    /// Held open for streaming-input sessions; dropped to close stdin.
1150    stdin: Option<ChildStdin>,
1151    lines: BoundedLines<ChildStdout>,
1152    stderr_buf: Arc<Mutex<String>>,
1153    stderr_task: Option<JoinHandle<()>>,
1154    /// Multi-block lines queue several events; popped one per `next_event`.
1155    queue: VecDeque<AgentEvent>,
1156    /// Distinct assistant message ids seen (engine-enforced turn budget).
1157    assistant_ids: HashSet<String>,
1158    saw_result: bool,
1159    saw_success_result: bool,
1160    /// Streaming result costs are cumulative; usage tokens are per turn.
1161    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    /// Explicit Windows host-state teardown after the helper and hostile child
1174    /// are reaped. On other platforms this is a no-op, keeping the event-loop
1175    /// call sites uniform.
1176    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    /// Record bookkeeping the session derives from its own event stream.
1188    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                    // A conversation reset changes the session id. A new
1204                    // process (including --resume) starts with a zero ledger.
1205                    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                            // Crashes may report zero: retain the high-water
1215                            // mark so earlier spend is never counted twice.
1216                            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    /// True when this line pushes the distinct-assistant-id count over the
1242    /// turn budget.
1243    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    /// Kill the child and reap it, best-effort; also closes stdin and joins
1261    /// the stderr capture task.
1262    ///
1263    /// Unix: the child was spawned as the leader of its own process group
1264    /// (`process_group(0)` in [`ClaudeBackend::start`]), so SIGKILL is sent
1265    /// to the whole group via `kill(-pid, SIGKILL)` — tool subprocesses
1266    /// (test runners, builds) die with the CLI. A first group kill can race
1267    /// a concurrent `fork` inside the group (the mid-fork child misses the
1268    /// signal), so after reaping the leader — membership is stable then —
1269    /// the group is swept with a second SIGKILL. When the group kill fails
1270    /// (e.g. the child is already reaped), the direct `start_kill` is the
1271    /// fallback.
1272    ///
1273    /// Windows: the child was assigned to a kill-on-close Job Object at spawn
1274    /// (see [`ClaudeBackend::start`]). `TerminateJobObject` kills every process
1275    /// in the job — the CLI and its whole tool-child tree — then the child is
1276    /// reaped. If job setup had failed (`job == None`) this degrades to the
1277    /// old direct-child `start_kill`. The Job Object block compiles and is
1278    /// validated only on windows-latest CI, never on the dev host.
1279    async fn kill_child(&mut self) {
1280        self.stdin = None;
1281        #[cfg(unix)]
1282        {
1283            // `id()` is None once the child has been reaped; the leader's
1284            // pid doubles as the group id (`process_group(0)` at spawn).
1285            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                    // Sweep stragglers that raced the first kill mid-fork.
1298                    let _ = kill_group(pgid);
1299                }
1300            }
1301        }
1302        #[cfg(windows)]
1303        {
1304            // Kill the whole tree via the job; fall back to the direct child
1305            // if job setup had failed at spawn. Then reap the CLI so its pipes
1306            // (and the stderr capture task below) close.
1307            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        // Any other (hypothetical) non-unix, non-windows target: direct child
1316        // kill only, no tree semantics available.
1317        #[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    /// stdout hit EOF: reap the child and classify the exit.
1328    async fn finish_at_eof(&mut self) {
1329        self.stdin = None;
1330        let status = self.child.wait().await;
1331        // The stderr pipe closes with the process, so the capture task is
1332        // about to finish; join it before reading the buffer.
1333        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            // Drain queued events first — even after an abort, so multi-block
1379            // lines already parsed are never lost.
1380            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                // Engine-enforced turn budget (the CLI has no --max-turns):
1420                // abort internally; the over-budget message is not emitted.
1421                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; // queue is empty here → next iteration returns None
1429            }
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        // Whether the process had already exited on its own before we killed
1456        // it (an abort after natural completion keeps Completed).
1457        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// Cross-platform: the scratch root is chosen the same way on every host,
1634// and the container hazard it exists for is not unix-specific.
1635#[cfg(test)]
1636mod scratch_root_tests {
1637    use super::*;
1638
1639    // Each assertion runs alone in a child process. Mutating this test
1640    // process's environment would race unrelated sandbox and worker tests.
1641    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    // -----------------------------------------------------------------------
1728    // agent-env-clear: exfiltration-shaped spawn tests
1729    // -----------------------------------------------------------------------
1730
1731    /// A `claude` stub that dumps its FULL environment to `capture` and then
1732    /// emits the minimal stream-json (init + success result) a session needs
1733    /// to complete cleanly.
1734    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    /// The ticket's acceptance test, worker shape (spec carries a relocated
1791    /// scratch HOME — the exact env shape the auth probe proves and worker
1792    /// relocation produces): a poisoned ambient env must NOT reach the
1793    /// spawned session, while PATH/scratch-HOME/TMPDIR and the backend's own
1794    /// auth key do.
1795    #[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    /// Orchestrator/validator shape (spec carries NO relocated HOME): the
1866    /// session must spawn into a FRESHLY SEEDED per-session scratch HOME —
1867    /// never the operator's real home — with the OAuth credentials copy in
1868    /// place, so file-based auth keeps working through the cleared env.
1869    #[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        // The operator's "real" config dir, holding the file-based OAuth
1875        // credential the seeding recipe copies.
1876        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    /// macOS Keychain auth (the CLI's current token storage): the seeded
1924    /// scratch HOME must carry `Library/Keychains` as a symlink to the real
1925    /// one — the CLI resolves the login keychain by HOME-relative path, so
1926    /// without it a relocated HOME fails "Not logged in" (2026-07-29).
1927    #[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        // And reads through it work (the CLI's keychain-file lookup shape).
1943        assert_eq!(
1944            std::fs::read_to_string(link.join("login.keychain-db")).unwrap(),
1945            "db"
1946        );
1947
1948        // No real keychain dir: no link, no error (file-based auth hosts).
1949        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    /// Hostile-workload bound: a stub emitting one over-long line (9 MB,
1957    /// past the 8 MiB per-line cap) is drained without unbounded memory; the
1958    /// truncated line surfaces as an unparsed `Other` carrying the marker,
1959    /// and the session still completes on the result line that follows.
1960    #[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    /// Hostile-workload bound: a stub streaming more stderr than the 64 KiB
2004    /// retention cap still has its whole pipe drained (no deadlock), and the
2005    /// failure message carries only the bounded tail plus the marker.
2006    #[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}