From fa24d95cae0c49126ecbdbdecdfec320746811c4 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Thu, 12 Nov 2026 13:34:39 +0800 Subject: [PATCH 01/11] fix(shell): bound stdin close delivery and SIGPIPE workers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fresh-review follow-ups on the stdin EOF slice (review 5779706794): - Host kill: a bounded stdin write that hits the deadline abandons a thread blocked mid-write on the handle; the documented stall recovery (killing the task, or the stalled command exiting later) closed the read end, and the resumed write raised SIGPIPE on a process that runs with SIGPIPE at SIG_DFL (run_with_args) — killing the whole host process. Every stdin write worker (unix fd-clone, owned fallback, the new close delivery) now blocks SIGPIPE for the write (BlockedSigpipeGuard) so the abandoned write observes EPIPE and the existing write-error arm handles it; the sync-lane inline payload write gets the same guard. Not directly testable on unix CI (test binaries keep SIG_IGN), so this is a mechanism fix pinned by code path, not a red/green test. - Session wedge: interact close_stdin on a unix terminal task delivered the ^D bytes inline under the shell-manager lock; two bytes into a tty whose input queue filled earlier (completed payload, no stall flag) block as forever as a large payload would. The delivery now runs on a bounded worker (bounded_stdin_eof_delivery): Delivered claims end-of-file, a failed write consumes the handle without claiming, and a deadline stall restores the handle under the stall flag. - The unix ^D pin is now cfg-gated like its sibling tty pins — the close semantics it asserts exist only under all(unix, not(target_env = "ohos")); on Windows it was deterministically red and on ohos it panicked. - The windows-only killed_shell test's BackgroundShell literal seeds the two stdin state fields it was missing (E0063 on windows test builds). - Worker spawns use thread::Builder and map spawn failure to a write error instead of panicking through the shell-manager lock (permanent poison); the owned fallback documents that a spawn failure releases the writer with the failed closure. - A spawn-time stdin write failure now says the run never started (no task id exists for the kill-and-restart copy), and the MAX_INLINE_STDIN_BYTES / STDIN_WRITE_DEADLINE comments state what the deadline actually does today (bounded waits, n x 5 s aggregate under the lock, spawn aborts). - Copy: the eof refusal says "stdin was closed" (terminal closes are not pipes); guidance names tty in the no-terminal-stdin clause; the schema's close_stdin describes the newline + EOF delivery; the stale PTY-close comments (variant doc, field doc, spawn path, test gate justification) now match the delivered behavior; Windows close_stdin on a terminal task refuses explicitly instead of returning a silent Ok (ConPTY has no EOF to deliver). New pin close_stdin_on_a_stalled_terminal_task_is_bounded_and_claims_no_eof is red against the stall-conjunct-removal mutant (verified: the mutant's close waits the full deadline and the pin reds at its elapsed bound) and green here. Targeted: tools::shell 169 passed / 0 failed / 1 ignored (pre-existing), tools::verifier 9 / 0, tools::registry 48 / 0; cargo fmt --all -- --check clean; fork-CI clippy flag set clean on codewhale-tui in an isolated target dir. The windows-gated edits are verified by construction only (field names copied from the declarations; platform-neutral syntax) — this host has no windows C cross toolchain for the dependency build scripts. Signed-off-by: asto18089 --- crates/tui/src/tools/shell.rs | 359 +++++++++++++++++++------ crates/tui/src/tools/shell/guidance.rs | 2 +- crates/tui/src/tools/shell/tests.rs | 311 ++++++++++++++++++++- 3 files changed, 590 insertions(+), 82 deletions(-) diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index af7965ef2f..5e416e748d 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -877,9 +877,11 @@ enum StdinWriter { /// writes can run on the bounded worker thread exactly like pipe /// writes. The reader thread holds its own clone of the master fd for /// the task's whole lifetime, so this dup is NOT the last master-side - /// handle: dropping it delivers nothing to the slave (the child sees no - /// EOF from a close here), which is why PTY closes never claim EOF — - /// the same observable behavior the `take_writer` box had. + /// handle: dropping it delivers nothing to the slave on its own. An + /// explicit `close_stdin` therefore delivers the ^D bytes itself, + /// through the same bounded worker as a payload write (see + /// [`bounded_stdin_eof_delivery`]), and a delivery that fails or + /// stalls claims no end-of-file. /// The cfg mirrors the only constructor (the unix arm inside the /// `not(target_env = "ohos")` PTY block — ohos returns from tty mode /// before PTY setup): keeping the variant off ohos avoids a @@ -913,6 +915,110 @@ impl StdinWriter { } } +/// Blocks SIGPIPE for the guarded region on unix, restoring it on drop. +/// The process deliberately runs with SIGPIPE at SIG_DFL (`run_with_args` +/// re-arms it so delegated children see conventional pipe semantics), which +/// arms it for in-process threads too. A bounded stdin write that times out +/// deliberately leaves its worker blocked mid-write on the handle, and the +/// documented stall recovery — killing the task, or the stalled command +/// simply exiting later — closes the read end: the resumed write would +/// otherwise kill the whole host process with SIGPIPE instead of failing +/// with EPIPE like every handled write error. +struct BlockedSigpipeGuard { + #[cfg(unix)] + previous: libc::sigset_t, +} + +impl BlockedSigpipeGuard { + fn new() -> Self { + #[cfg(unix)] + { + // SAFETY: `zeroed` is valid for `sigset_t` (an array of + // integer words) and both pointers below are local stack + // values; a failed `pthread_sigmask` simply leaves the + // process default disposition and the failure mode is the + // pre-guard one. + unsafe { + let mut blocked: libc::sigset_t = std::mem::zeroed(); + let mut previous: libc::sigset_t = std::mem::zeroed(); + libc::sigemptyset(&mut blocked); + libc::sigaddset(&mut blocked, libc::SIGPIPE); + // Best-effort: if the mask cannot be set, the write runs + // with the process default and the failure mode is simply + // the pre-guard one. + libc::pthread_sigmask(libc::SIG_BLOCK, &blocked, &mut previous); + Self { previous } + } + } + #[cfg(not(unix))] + Self {} + } +} + +#[cfg(unix)] +impl Drop for BlockedSigpipeGuard { + fn drop(&mut self) { + unsafe { + libc::pthread_sigmask(libc::SIG_SETMASK, &self.previous, std::ptr::null_mut()); + } + } +} + +#[cfg(not(unix))] +impl Drop for BlockedSigpipeGuard { + fn drop(&mut self) {} +} + +/// Outcome of the bounded end-of-file delivery behind an explicit +/// `close_stdin` on a unix terminal task: `Delivered` means the ^D bytes +/// landed, `Failed` means the write errored without landing (typically the +/// child was already gone and the master write failed), and `Stalled` means +/// the tty input queue was still full at the deadline. +#[cfg(all(unix, not(target_env = "ohos")))] +enum BoundedEofDelivery { + Delivered, + Failed, + Stalled, +} + +/// Deliver `\n` + VEOF through the given master fd on the same kind of +/// bounded worker a payload write uses. The leading newline flushes any +/// pending partial line, because canonical mode only turns VEOF into a +/// read of length zero from an EMPTY line buffer — the same two bytes the +/// old ownership box injected on drop. In raw mode both bytes are ordinary +/// input, exactly like that box drop; the caller records a delivery only +/// when the write really completed, so the flag never claims an end-of-file +/// that did not land. +#[cfg(all(unix, not(target_env = "ohos")))] +fn bounded_stdin_eof_delivery(file: &std::fs::File) -> BoundedEofDelivery { + use std::os::fd::AsFd; + let dup_fd = match file.as_fd().try_clone_to_owned() { + Ok(fd) => fd, + // No clone means no worker can attempt the delivery; report it as + // a failed delivery — a clone failure is a process-fd problem, not + // a queue-full condition, and the flag must stay down either way. + Err(_) => return BoundedEofDelivery::Failed, + }; + let mut writer = std::process::ChildStdin::from(dup_fd); + let (sender, receiver) = std::sync::mpsc::channel(); + let worker = std::thread::Builder::new() + .name("stdin-eof-delivery".to_string()) + .spawn(move || { + let _sigpipe = BlockedSigpipeGuard::new(); + let _ = sender.send(writer.write_all(b"\n\x04").and_then(|_| writer.flush())); + }); + let receiver = match worker { + Ok(_) => receiver, + Err(_) => return BoundedEofDelivery::Failed, + }; + match receiver.recv_timeout(STDIN_WRITE_DEADLINE) { + Ok(Ok(())) => BoundedEofDelivery::Delivered, + Ok(Err(_)) => BoundedEofDelivery::Failed, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => BoundedEofDelivery::Stalled, + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => BoundedEofDelivery::Failed, + } +} + /// The bounded stdin write for the platforms that cannot clone an fd out of /// their handle (Windows and ohos, see [`ShellTask::write_stdin`]): the /// worker thread takes the writer by value, finishes the payload, and hands @@ -923,29 +1029,46 @@ impl StdinWriter { /// `Err(RecvTimeoutError::Disconnected)` means the worker died before /// completing (a panic in the write path): the writer is gone, and the /// caller must NOT mark a stall — a writer failure is not the command's -/// fault. Present on every target so the unix-host test suite exercises it -/// directly; production unix keeps the fd-clone path, which is why the -/// allow only covers the non-unix side. +/// fault. An `Err` from the function itself means the worker thread could +/// not be spawned at all: nothing was written, and the writer is gone with +/// the failed closure (ownership was already handed over), so the caller +/// must report the task as having no writable stdin. A plain +/// `thread::spawn` panic here would unwind through the shell-manager lock +/// and poison it for the rest of the process. Present on every target so +/// the unix-host test suite exercises it directly; production unix keeps +/// the fd-clone path, which is why the allow only covers the non-unix side. +#[cfg_attr(all(unix, not(target_env = "ohos")), allow(dead_code))] +type OwnedBoundedWriteOutcome = ( + Option, + Result, std::sync::mpsc::RecvTimeoutError>, +); + #[cfg_attr(all(unix, not(target_env = "ohos")), allow(dead_code))] fn bounded_stdin_write_owned( writer: StdinWriter, payload: Vec, -) -> ( - Option, - Result, std::sync::mpsc::RecvTimeoutError>, -) { +) -> anyhow::Result { let (sender, receiver) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let mut writer = writer; - let result = writer.write_all(&payload).and_then(|_| writer.flush()); - // The writer moves back out only on the send path; a send failure - // (receiver gone) drops it, which is fine — the caller already - // gave up on the handle at that point. - let _ = sender.send((writer, result)); - }); + let worker = std::thread::Builder::new() + .name("stdin-bounded-write".to_string()) + .spawn(move || { + let _sigpipe = BlockedSigpipeGuard::new(); + let mut writer = writer; + let result = writer.write_all(&payload).and_then(|_| writer.flush()); + // The writer moves back out only on the send path; a send failure + // (receiver gone) drops it, which is fine — the caller already + // gave up on the handle at that point. + let _ = sender.send((writer, result)); + }); + let receiver = match worker { + Ok(_) => receiver, + Err(error) => { + return Err(anyhow!(error).context("Failed to spawn the bounded stdin writer thread")); + } + }; match receiver.recv_timeout(STDIN_WRITE_DEADLINE) { - Ok((writer, result)) => (Some(writer), Ok(result)), - Err(error) => (None, Err(error)), + Ok((writer, result)) => Ok((Some(writer), Ok(result))), + Err(error) => Ok((None, Err(error))), } } @@ -1133,12 +1256,14 @@ pub struct BackgroundShell { stderr_cursor: usize, completion_reported: bool, stdin: Option, - /// Set when the stdin *pipe* was closed through `write_stdin`, i.e. the - /// child has really been handed end-of-file. Stays false when the task - /// never exposed a writable stdin (persistent services spawn with a null - /// stdin, PTY lanes keep the slave open through the reader) or when the - /// handle was simply released after completion, so `interact` can state - /// the true reason stdin is unavailable. + /// Set when the child has really been handed end-of-file: either its + /// stdin pipe was closed through `write_stdin`, or an explicit unix + /// terminal close delivered the ^D bytes through the bounded worker. + /// Stays false when the task never exposed a writable stdin (persistent + /// services spawn with a null stdin, PTY lanes keep the slave open + /// through the reader), when the handle was simply released after + /// completion, and when a close delivery failed or stalled — so + /// `interact` can state the true reason stdin is unavailable. stdin_closed_at_eof: bool, /// Set when a stdin write outlived [`STDIN_WRITE_DEADLINE`] because the /// command stopped consuming its pipe. The abandoned bytes keep flowing @@ -1564,10 +1689,27 @@ impl BackgroundShell { let mut writer = std::process::ChildStdin::from(dup_fd); let payload = input.as_bytes().to_vec(); let (sender, receiver) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - let _ = - sender.send(writer.write_all(&payload).and_then(|_| writer.flush())); - }); + let worker = std::thread::Builder::new() + .name("stdin-bounded-write".to_string()) + .spawn(move || { + let _sigpipe = BlockedSigpipeGuard::new(); + let _ = sender + .send(writer.write_all(&payload).and_then(|_| writer.flush())); + }); + let receiver = match worker { + Ok(_) => receiver, + Err(error) => { + // The spawn failed before any byte was written + // and the worker holds nothing: restore the + // handle and fail like a plain write error. A + // bare `thread::spawn` panic here would unwind + // through the shell-manager mutex and poison it + // for the rest of the process. + self.stdin = slot; + return Err(anyhow!(error) + .context("Failed to spawn the bounded stdin writer thread")); + } + }; match receiver.recv_timeout(STDIN_WRITE_DEADLINE) { Ok(Ok(())) => {} Ok(Err(error)) => { @@ -1619,8 +1761,17 @@ impl BackgroundShell { #[cfg(any(target_env = "ohos", not(unix)))] { let writer = slot.take().expect("slot held above"); - let (returned, result) = - bounded_stdin_write_owned(writer, input.as_bytes().to_vec()); + let (returned, result) = bounded_stdin_write_owned( + writer, + input.as_bytes().to_vec(), + ) + .map_err(|error| { + // The worker could not be spawned and the writer + // went with the failed closure: `slot` stays None, + // so the task honestly reports no writable stdin + // from here on. + anyhow!(error).context("Failed to spawn the bounded stdin writer thread") + })?; match result { Ok(Ok(())) => { slot = returned; @@ -1665,29 +1816,64 @@ impl BackgroundShell { // on a pty exited 0 the instant it dropped), and the dup // the terminal lane now holds delivers nothing on its own // because the reader holds the master — so write the same - // ^D semantics through the dup, then drop it. The flag - // only follows a real delivery. A task whose last write - // STALLED must not claim EOF either: the abandoned worker - // still holds a clone of the handle and the bytes have not - // finished landing, so the flag stays down and the tail - // refusal reports the stall instead. - match slot { - Some(StdinWriter::Pipe(_)) => { - self.stdin_closed_at_eof = !self.stdin_write_stalled; - } - #[cfg(all(unix, not(target_env = "ohos")))] - Some(StdinWriter::PtyFile(mut file)) if !self.stdin_write_stalled => { - { - use std::io::Write as _; - // Ignored if the child is already gone; in raw - // mode VEOF reads as ordinary input, exactly - // like the box drop it replaces. - let _ = file.write_all(b"\n\x04"); - let _ = file.flush(); + // ^D semantics through the dup, then drop it. The delivery + // runs on the same kind of bounded worker as a payload + // write: two bytes into a tty whose input queue is already + // full (the earlier payload completed inside the deadline, + // so the stall flag is still down) block just as forever + // as a large payload would, and inline under the manager + // lock they wedge every shell in the session. + #[cfg(all(unix, not(target_env = "ohos")))] + if let Some(StdinWriter::PtyFile(file)) = slot.as_ref() + && !self.stdin_write_stalled + { + match bounded_stdin_eof_delivery(file) { + BoundedEofDelivery::Delivered => { + self.stdin = None; + self.stdin_closed_at_eof = true; + return Ok(()); + } + // The close consumed the handle as requested, but + // the bytes did not land (typically the child was + // already gone and the master write failed): keep + // the flag down so later refusals do not claim + // readers saw end-of-file. + BoundedEofDelivery::Failed => { + self.stdin = None; + return Ok(()); + } + // Queue still full at the deadline: restore the + // handle under the stall flag, mirroring the + // payload-timeout disposition, so later writes + // cite the stall instead of a delivery that has + // not happened. + BoundedEofDelivery::Stalled => { + self.stdin_write_stalled = true; + self.stdin = slot; + return Ok(()); } - self.stdin_closed_at_eof = true; } - _ => {} + } + // Windows cannot deliver end-of-file to a console child at + // all (portable-pty's ConPTY writer has no EOF-on-drop — + // that exists only in its unix writer), so an explicit + // close here only releases the handle. Say so instead of + // returning a silent success that the schema's "Close + // stdin" would read as a delivered end-of-file. + #[cfg(all(not(unix), not(target_env = "ohos")))] + if matches!(slot, Some(StdinWriter::Pty(_))) { + self.stdin = None; + return Err(anyhow!( + "stdin close for task {} released the terminal's stdin handle, but this platform cannot deliver end-of-file to a console child; the command keeps running until it finishes or its timeout ends it.", + self.id + )); + } + // A task whose last write STALLED must not claim EOF: the + // abandoned worker still holds a clone of the handle and + // the bytes have not finished landing, so the flag stays + // down and the tail refusal reports the stall instead. + if let Some(StdinWriter::Pipe(_)) = slot { + self.stdin_closed_at_eof = !self.stdin_write_stalled; } self.stdin = None; } else { @@ -1709,7 +1895,7 @@ impl BackgroundShell { if self.stdin_closed_at_eof { return Err(anyhow!( - "stdin is not available for task {}: its stdin pipe was closed, so stdin readers have seen end-of-file; start input-driven work with background=true and feed it with action=\"interact\"", + "stdin is not available for task {}: its stdin was closed, so stdin readers have seen end-of-file; start input-driven work with background=true and feed it with action=\"interact\"", self.id )); } @@ -2850,6 +3036,11 @@ impl ShellManager { if let Some(input) = stdin_data && let Some(mut stdin) = child.stdin.take() { + // Same SIGPIPE discipline as the bounded stdin workers (see + // [`BlockedSigpipeGuard`]): the child dying mid-write must + // surface as a handled write error, not kill a process that + // runs with SIGPIPE at its default disposition. + let _sigpipe = BlockedSigpipeGuard::new(); stdin .write_all(input.as_bytes()) .context("Failed to write to stdin")?; @@ -3177,12 +3368,14 @@ impl ShellManager { // as an immediate end-of-file (probe-pinned: `cat` on a pty // exited 0 the instant the box was dropped). The dup keeps // a master-side description open — no EOF for the slave - // while the reader's clone is open too. `close_stdin` on - // this lane drops the dup: with the reader's clone still - // open the child sees nothing (the PTY close never promised - // an EOF and `stdin_closed_at_eof` never claimed one); the - // final master handle closes at teardown, after the child - // can no longer observe it. + // while the reader's clone is open too. A plain drop of the + // dup (the spawn-path close never touches this lane, and a + // stalled close releases it) still shows the child nothing; + // an explicit `close_stdin` instead delivers the ^D bytes + // itself through the bounded worker and claims end-of-file + // only when that write really landed; the final master + // handle closes at teardown, after the child can no longer + // observe it. #[cfg(unix)] let stdin_writer = { use std::os::fd::FromRawFd; @@ -3374,8 +3567,14 @@ impl ShellManager { if let Some(input) = stdin_data && let Err(err) = bg_shell.write_stdin(input, false) { + // No task id exists yet — the record is only registered below — + // so `write_stdin`'s "kill it and start a new one" recovery copy + // has no target here. The spawn aborts instead: the child is + // killed and the caller learns the run never started. let _ = bg_shell.kill(); - return Err(err); + return Err(err.context( + "the run never started: delivering the inline stdin payload failed, so the command was killed and no task was created", + )); } if let Err(err) = bg_shell.publish_lifecycle() { @@ -8019,25 +8218,27 @@ async fn execute_foreground_via_background( const BASH_MAX_TIMEOUT_MS: u64 = i32::MAX as u64; -/// Upper bound for one inline `stdin` payload. The spawn delivers it with a -/// blocking `write_all` while the shell-manager lock is held; on Linux a -/// payload equal to one pipe buffer (exactly 64 KiB) always completes -/// without the child reading, so at this cap the write can never wedge the -/// executor thread there. Windows and macOS pipe buffers are -/// system-decided/best-effort and may be smaller — the cap is a strict -/// improvement over the uncapped base everywhere, and the never-wedge -/// guarantee as stated is a Linux fact. Anything larger is refused with a -/// pointer at background `interact` streaming instead of hanging the -/// session. +/// Upper bound for one inline `stdin` payload. Every accepted payload is +/// delivered through the bounded stdin writer (see +/// [`STDIN_WRITE_DEADLINE`]), so a command that stops reading surfaces as a +/// deadline stall rather than a wedged executor; the cap additionally keeps +/// oversized payloads from ever reaching the writer, and a 64 KiB payload +/// fits a Linux pipe buffer in one atomic write so the common +/// payload-then-close foreground shape delivers without stalling. Anything +/// larger is refused with a pointer at background `interact` streaming +/// instead of hanging the session. const MAX_INLINE_STDIN_BYTES: usize = 64 * 1024; -/// Wall-clock bound on one stdin write. The same blocking `write_all` runs -/// for `interact` payloads (and for the spawn payload on platforms whose -/// pipe buffer is smaller than the cap), where a command that stops -/// consuming stdin fills its pipe and would hold the shell-manager lock -/// forever. At the bound the write is abandoned to a background worker (the -/// bytes still land if the child resumes reading) and the task's stdin is -/// marked stalled. +/// Wall-clock bound on one stdin write. At the bound an `interact` write is +/// abandoned to its background worker (the bytes still land if the child +/// resumes reading), the task's stdin is marked stalled, and the manager +/// lock is released — so each stalled write costs one bounded wait under +/// the lock, and n stalled tasks serialize into n such waits. A stalled +/// SPAWN payload is different: no task id exists yet, so the spawn aborts +/// and the child is killed instead of running task-less. The abandoned +/// worker is SIGPIPE-blocked (see [`BlockedSigpipeGuard`]): when the task +/// is later killed — the documented stall recovery — the worker observes +/// EPIPE instead of taking the process down. const STDIN_WRITE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(5); /// Initial cadence for foreground-via-background completion polling. Fast @@ -8422,7 +8623,7 @@ impl ToolSpec for BashTool { }, "close_stdin": { "type": "boolean", - "description": "Close stdin after sending (action=interact)" + "description": "Close stdin after sending (action=interact); on a terminal task the close delivers end-of-file, sent as a literal newline followed by the EOF byte" }, "all": { "type": "boolean", diff --git a/crates/tui/src/tools/shell/guidance.rs b/crates/tui/src/tools/shell/guidance.rs index 258512032a..5ca9ff5aab 100644 --- a/crates/tui/src/tools/shell/guidance.rs +++ b/crates/tui/src/tools/shell/guidance.rs @@ -86,7 +86,7 @@ pub(super) fn description() -> &'static str { \"interact\" sends stdin to a background task; \"cancel\" kills a background task. \ Pass wait=false for a nonblocking task snapshot. Foreground mode is for bounded \ commands; use background=true for work expected to take >5 seconds. Foreground \ - commands without `interactive` or `combined_output` run without a \ + commands without `interactive`, `tty`, or `combined_output` run without a \ terminal stdin — interactive programs and a bare `$SHELL` receive EOF and \ exit; `tty` always selects a background terminal task, and a foreground \ command with `combined_output` runs on a terminal lane that nothing \ diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index 5186e6de27..4c1b363a32 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -8501,6 +8501,8 @@ fn killed_shell_does_not_wait_for_blocked_reader_threads() { completion_reported: false, bounded_output: None, stdin: None, + stdin_closed_at_eof: false, + stdin_write_stalled: false, child: None, stop_unconfirmed: false, windows_job: None, @@ -9871,6 +9873,208 @@ async fn inline_stdin_payload_over_the_cap_refuses() { ); } +/// The bounded stdin write: a command that never consumes its pipe fills +/// it, and past [`STDIN_WRITE_DEADLINE`] the write errors and the task's +/// stdin is marked stalled instead of holding the shell-manager lock (and +/// with it every other shell in the session) forever. The stall flag then +/// refuses further writes without another wait — two writers could +/// interleave on one pipe. +// Unix-only: the bounded write (worker + deadline) the test exercises is +// `#[cfg(unix)]`, as are the raw-mode tty semantics it pins; the +// Windows/ohos owned path is bounded by the same deadline through +// `bounded_stdin_write_owned` and covered by its own helper test. +#[cfg(unix)] +#[test] +fn write_stdin_to_a_child_that_stops_consuming_errors_instead_of_wedging() { + let (sender, receiver) = std::sync::mpsc::channel::>(); + let handle = std::thread::spawn(move || { + let run = || -> Result<(), String> { + let tmp = tempdir().map_err(|error| error.to_string())?; + let mut manager = ShellManager::new(tmp.path().to_path_buf()); + let result = manager + .execute_with_options_env( + &sleep_command(60), + None, + 60_000, + true, + None, + false, + None, + HashMap::new(), + ) + .map_err(|error| error.to_string())?; + let task_id = result + .task_id + .ok_or_else(|| "background execution should return task_id".to_string())?; + // Far past any pipe buffer on any platform: the write cannot + // complete unless the child reads, and it never does. + let payload = "x".repeat(200 * 1024); + let started = std::time::Instant::now(); + let error = manager + .write_stdin(&task_id, &payload, false) + .err() + .ok_or_else(|| "the stalled write must error at the deadline".to_string())? + .to_string(); + if !error.contains("did not complete within") { + return Err(error); + } + let elapsed = started.elapsed(); + if elapsed < STDIN_WRITE_DEADLINE { + return Err(format!( + "the write returned before the deadline: {elapsed:?}" + )); + } + // No upper bound on the elapsed time: the load-bearing assertions + // are that the deadline FIRED (elapsed ≥ deadline) and further + // writes are refused. A fixed upper bound is load-fragile at any + // multiple — under a saturated full-suite parallel run the + // deadline worker was observed starving past 30 s — while a + // broken-mechanism mutant (deadline removed) never returns and + // trips the outer hang guard instead. + // Further writes are refused by the stall flag without waiting. + let started = std::time::Instant::now(); + let again = manager.write_stdin(&task_id, "y", false); + if again.is_ok() { + return Err("the stalled task must refuse further writes".to_string()); + } + if started.elapsed() > std::time::Duration::from_millis(500) { + return Err("the stall refusal must not wait on another write".to_string()); + } + // A close after the stall must not claim the end-of-file the + // abandoned writer never delivered: the post-close refusal must + // still name the stall (the `stdin_closed_at_eof` stall conjunct + // and the stall-first check order together keep the EOF claim + // off; a reorder or conjunct drop that surfaced the EOF message + // reddens here). + let _closed = manager.write_stdin(&task_id, "", true); + let after_close = manager + .write_stdin(&task_id, "z", false) + .err() + .ok_or_else(|| "the post-stall close must leave writes refused".to_string())? + .to_string(); + if !after_close.contains("stalled") || after_close.contains("end-of-file") { + return Err(format!( + "a close after a stall must not claim an EOF the abandoned writer has not delivered: {after_close}" + )); + } + manager.kill(&task_id).map_err(|error| error.to_string())?; + Ok(()) + }; + let verdict = run(); + let _ = sender.send(verdict.clone()); + verdict + }); + let _ = receiver + .recv_timeout(std::time::Duration::from_secs(60)) + .expect("the bounded-write pin hung: the STDIN_WRITE_DEADLINE regression is live"); + handle + .join() + .expect("the bounded-write worker panicked") + .expect("bounded-write assertions"); +} +/// The `interact` payload carries the same per-call bound as the inline +/// spawn payload: one oversized call is refused with a pointer at chunking +/// before any write is attempted. +#[tokio::test] +async fn interact_payload_over_the_cap_refuses() { + let workspace = tempdir().expect("workspace"); + let context = ToolContext::new(workspace.path().to_path_buf()); + let oversized = "x".repeat(super::MAX_INLINE_STDIN_BYTES + 1); + let output = BashTool::new("Bash") + .execute( + json!({"action": "interact", "command": "pwd", "task_id": "no-such-task", "stdin": oversized}), + &context, + ) + .await + .expect("the refusal is a result, not an error"); + assert!(!output.success, "{}", output.content); + assert!( + output.content.contains("per interact call"), + "the refusal must name the per-call bound: {}", + output.content + ); + // The advertised bound stays in sync with the constant (the copy + // interpolates it; this asserts the value actually reaches the model). + assert!( + output + .content + .contains(&super::MAX_INLINE_STDIN_BYTES.to_string()), + "the refusal must carry the exact bound value: {}", + output.content + ); +} +/// The tool description states the foreground stdin contract; the sentence is +/// the model-facing contract for why stdin readers exit, so it is pinned. +#[test] +fn guidance_states_the_foreground_stdin_contract() { + let description = super::guidance::description(); + assert!( + description.contains("Foreground commands without `interactive`, `tty`, or `combined_output` run without a terminal stdin"), + "guidance must state the bounded foreground stdin: {description}" + ); + assert!( + description.contains("`tty` always selects a background terminal task"), + "guidance must state where tty commands actually run — the run path forces them background: {description}" + ); + assert!( + description.contains( + "a terminal lane that nothing writes to unless a session member interacts with the task" + ), + "guidance must state the combined_output foreground stdin contract accurately: {description}" + ); + assert!( + description.contains("action=\"interact\""), + "guidance must point at background interact for input-driven work: {description}" + ); +} +/// A foreground command that reads stdin with NO stdin data used to wedge on +/// a pipe whose write end the tool kept open: the live repro was `$SHELL` +/// holding the turn until the foreground watchdog killed it at the full +/// timeout (`cat` behaves identically). The foreground lane must deliver EOF +/// at spawn instead. Background keeps the pipe open for deferred +/// `interact` streaming — that contract is pinned by +/// `test_write_stdin_streams_output` and must not regress. +#[tokio::test] +async fn foreground_without_stdin_data_gives_stdin_readers_eof() { + let workspace = tempdir().expect("workspace"); + let context = ToolContext::new(workspace.path().to_path_buf()); + // `cat` on Unix, `more` on cmd, `[Console]::In.ReadToEnd()` on PowerShell — + // each blocks until stdin reaches EOF. + let echo_stdin = echo_stdin_command(); + + let started = Instant::now(); + let result = BashTool::new("Bash") + .execute(json!({"command": echo_stdin, "timeout_ms": 3000}), &context) + .await + .expect("foreground stdin-reader must spawn"); + let elapsed = started.elapsed(); + + assert!( + result.success, + "a stdin-reading foreground command with no stdin data must see EOF \ + and exit promptly, not run to the timeout; took {elapsed:?}: {}", + result.content + ); + // The elapsed bound is a unix pin: on Windows a cold PowerShell startup + // can legitimately approach the 3000 ms watchdog under parallel load, + // while `success` above already fails if the watchdog killed the run. + #[cfg(unix)] + assert!( + elapsed < Duration::from_millis(2900), + "EOF must be delivered right after spawn, not discovered at the \ + timeout; took {elapsed:?}: {}", + result.content + ); +} +#[cfg(unix)] +const SYNC_STDIN_EOF_HELPER_ENV: &str = "CODEWHALE_SYNC_STDIN_EOF_HELPER"; +#[cfg(unix)] +const SYNC_STDIN_EOF_RESULT_FILE_ENV: &str = "CODEWHALE_SYNC_STDIN_EOF_RESULT_FILE"; +/// Helper half of +/// `sync_lane_without_stdin_data_gets_eof_not_inherited_stdin`: runs one +/// synchronous (background=false) stdin-reading command from a process whose +/// own stdin is a pipe the driver deliberately holds open, and records the +/// resulting status for the driver to judge. #[cfg(unix)] #[test] fn readonly_operand_gate_refuses_cd_operands_the_shell_reinterprets() { @@ -11488,6 +11692,102 @@ fn readonly_compound_refusal_names_the_cmd_comment_wall() { } } +/// An explicit `close_stdin` on a terminal task whose last write already +/// STALLED must stay bounded and must not claim an end-of-file the +/// abandoned worker never delivered: the stall conjunct skips the ^D +/// delivery, the close releases the handle, and the tail refusal still +/// names the stall. A mutation that claims EOF unconditionally on the +/// terminal close (dropping the stall conjunct) reddens on the refusal +/// text; a close that wedges on the full input queue trips the outer +/// guard instead. +#[cfg(all(unix, not(target_env = "ohos")))] +#[test] +fn close_stdin_on_a_stalled_terminal_task_is_bounded_and_claims_no_eof() { + let (sender, receiver) = std::sync::mpsc::channel::>(); + let handle = std::thread::spawn(move || { + let run = || -> Result<(), String> { + let tmp = tempdir().map_err(|error| error.to_string())?; + let mut manager = ShellManager::new(tmp.path().to_path_buf()); + let result = manager + // Same shape as the stalled-write pin above: a raw-mode + // tty whose command never reads backpressures the master + // write, and the marker only prints after `stty` succeeds. + .execute_with_options_env( + "stty raw -echo 2>/dev/null && printf RAW-READY; sleep 60", + None, + 60_000, + true, + None, + true, + None, + HashMap::new(), + ) + .map_err(|error| error.to_string())?; + let task_id = result + .task_id + .ok_or_else(|| "background execution should return task_id".to_string())?; + let mut raw_ready = false; + for _ in 0..40 { + std::thread::sleep(Duration::from_millis(150)); + let snapshot = manager.inspect_job(&task_id).expect("inspect job").snapshot; + if snapshot.stdout_tail.contains("RAW-READY") { + raw_ready = true; + break; + } + } + assert!( + raw_ready, + "raw mode must be established before the stalled write: {:?}", + manager.inspect_job(&task_id).expect("inspect job").snapshot + ); + let payload = "x".repeat(200 * 1024); + let error = manager + .write_stdin(&task_id, &payload, false) + .err() + .ok_or_else(|| "the stalled tty write must error at the deadline".to_string())? + .to_string(); + if !error.contains("did not complete within") { + return Err(error); + } + // The close itself must return promptly (bounded path, stall + // guard) instead of wedging on the full input queue. + let started = std::time::Instant::now(); + manager + .write_stdin(&task_id, "", true) + .map_err(|error| error.to_string())?; + if started.elapsed() > STDIN_WRITE_DEADLINE { + return Err(format!( + "the close on a stalled terminal task must not wait on the queue: {:?}", + started.elapsed() + )); + } + // And the tail refusal must still report the stall: no EOF was + // delivered, so claiming end-of-file here would be a lie. + let after_close = manager + .write_stdin(&task_id, "y", false) + .err() + .ok_or_else(|| "the post-close write must be refused".to_string())? + .to_string(); + if !after_close.contains("stalled") || after_close.contains("end-of-file") { + return Err(format!( + "a terminal close after a stall must not claim an EOF that never landed: {after_close}" + )); + } + manager.kill(&task_id).map_err(|error| error.to_string())?; + Ok(()) + }; + let verdict = run(); + let _ = sender.send(verdict.clone()); + verdict + }); + let verdict = receiver + .recv_timeout(Duration::from_secs(60)) + .unwrap_or_else(|_| { + let _ = handle.join(); + Err("the stalled-terminal-close test hung past its 60 s guard".to_string()) + }); + assert!(verdict.is_ok(), "{verdict:?}"); +} /// The owned-writer bounded stdin write (the Windows/ohos fallback path): /// a consuming child gets the payload and the writer handed back; a child /// that never reads fills its pipe buffer and past the deadline the helper @@ -11507,7 +11807,8 @@ fn bounded_stdin_write_owned_returns_the_writer_and_times_out_clean() { .expect("cat spawns"); let stdin = child.stdin.take().expect("stdin piped"); let (returned, result) = - super::bounded_stdin_write_owned(super::StdinWriter::Pipe(stdin), b"hello\n".to_vec()); + super::bounded_stdin_write_owned(super::StdinWriter::Pipe(stdin), b"hello\n".to_vec()) + .expect("the bounded-write worker thread must spawn"); let Ok(result) = result else { panic!("a consuming child must complete the write, not lose the worker: {result:?}"); }; @@ -11526,7 +11827,8 @@ fn bounded_stdin_write_owned_returns_the_writer_and_times_out_clean() { let payload = vec![b'x'; 256 * 1024]; // well past the 64 KiB pipe buffer let started = std::time::Instant::now(); let (returned, result) = - super::bounded_stdin_write_owned(super::StdinWriter::Pipe(stdin), payload); + super::bounded_stdin_write_owned(super::StdinWriter::Pipe(stdin), payload) + .expect("the bounded-write worker thread must spawn"); let elapsed = started.elapsed(); assert!( matches!(result, Err(std::sync::mpsc::RecvTimeoutError::Timeout)), @@ -11829,6 +12131,11 @@ fn bash_schema_stdin_max_length_matches_the_enforced_inline_cap() { } } +// The ^D close delivery is compiled only for the unix PTY lane (the same +// predicate as the `PtyFile` variant): Windows has no console EOF to +// deliver and ohos has no tty lane at all, so both the exit and the +// end-of-file refusal assertions below are meaningless there. +#[cfg(all(unix, not(target_env = "ohos")))] #[tokio::test] async fn interact_close_stdin_delivers_eof_to_a_terminal_command() { // Base delivered end-of-file to a terminal task through the ownership From 0371c73e14620e83cd764bb7d3aae9facf4b37fc Mon Sep 17 00:00:00 2001 From: asto18089 Date: Thu, 12 Nov 2026 13:34:50 +0800 Subject: [PATCH 02/11] fix(shell): consume pending SIGPIPE, honest stalled closes, one bounded writer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 fresh-review fixes (comment 5781947688): - Host kill (BLOCKER): the SIGPIPE guard only deferred it. A SIGPIPE generated while blocked stays PENDING on the worker thread, and the guard's SIG_SETMASK restore delivers it before pthread_sigmask returns — with the process at SIG_DFL (run_with_args) the host died at signal 13 one instruction after the "handled" EPIPE, in exactly the documented stall-recovery shape (reproduced standalone: exit 141 for both the immediate-EPIPE and the blocked-mid-write recovery; a sigwait consume survives). The drop now checks this thread's pending set and consumes a pending SIGPIPE with sigwait before restoring the mask. Std's SIG_IGN makes test binaries blind to this (a generated SIGPIPE is discarded at generation and never becomes pending), so the new sigpipe_guard_survives_a_broken_pipe_restore pin re-execs the test binary as a helper that re-arms SIG_DFL first: block-and-restore kills the helper at signal 13, consume-before-restore exits 0. - Honest close results (MAJOR): a stalled close_stdin on a terminal task returned Ok while the schema promises a delivered end-of-file, and a second close silently destroyed the interact surface (also Ok). Both stalled close paths now return Err citing the stall (the delivery timed out / the close was not applied) and keep the handle attached; the Delivered/Failed arms keep their dispositions with the comments now describing them accurately. The stalled-terminal-close pin asserts the Err, and a new unit pin (bounded_stdin_eof_delivery_reports_stalled_failed_and_delivered) classifies the close worker's outcomes against real pipes. - One bounded writer (MAJOR): the worker machinery existed three times (the eof-delivery worker, the owned fallback, the inline unix clone block), and the SIGPIPE fix had to touch all three. The eof delivery and the unix clone payload write now ride the single bounded_stdin_write_owned helper — a returned clone is dropped, never reattached, so the original slot keeps deciding what is restored — and the deadline-dependent refusal copy is shared in named functions (format captures need fns, not consts). The cfg_attr dead-code allows the duplication required are gone, and the helper doc now describes the real ownership split (clone vs handoff) without the ShellTask link that pointed at a type that does not exist. - Honesty and copy: the EOF refusal names the raw-mode exception; the generic refusal names the lost-writer case; close_stdin's schema discloses raw mode and the Windows handle-only release; the inline cap docs drop the "atomic" claim, scope the pipe-buffer rationale to Linux, and note the byte counting; the run-never-started context says the mentioned task id was never registered; the foreground close treats an evicted record as success instead of aborting a finished run; the PTY lane acquires the interact writer before the reader thread spawns so a dup/take_writer failure no longer orphans a spawned reader thread; the VEOF hardcode is acknowledged against portable-pty's live c_cc[VEOF] injection. - Test hygiene: the three hang-guard scaffolds collapse into run_stdin_scenario_with_hang_guard (worker panics surface as failed verdicts), the stalled-close guard bounds with >=, and the lowercase tool description's stdin sentence gets its own pin. Deferred unchanged, disclosed: the sync-lane inline payload write stays SIGPIPE-guarded but deadline-less (no production caller can reach it with a payload today; same family as the tracked run_bounded_child_observed/process.rs follow-up); Windows-lane CI coverage for the owned-path helper test. Targeted: tools::shell 167 passed / 0 failed / 1 ignored (pre-existing), tools::verifier 9 / 0; cargo fmt clean; fork-CI clippy flag set clean in an isolated target dir. The ohos/windows gated edits are verified by construction only — this host has no ohos/windows C cross toolchain for the dependency build scripts (rquickjs-sys/sqlite build scripts fail before codewhale-tui). Signed-off-by: asto18089 --- crates/tui/src/tools/shell.rs | 368 +++++++++++++++++----------- crates/tui/src/tools/shell/tests.rs | 311 ++++++++++++++++++----- 2 files changed, 475 insertions(+), 204 deletions(-) diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index 5e416e748d..5af7a6f699 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -924,6 +924,20 @@ impl StdinWriter { /// simply exiting later — closes the read end: the resumed write would /// otherwise kill the whole host process with SIGPIPE instead of failing /// with EPIPE like every handled write error. +/// +/// Blocking alone is not sufficient: a SIGPIPE generated while blocked is +/// not discarded — it stays PENDING on the worker thread, and unblocking +/// delivers a pending unblocked signal before `pthread_sigmask` returns. +/// With SIGPIPE at SIG_DFL that would kill the process one instruction +/// after the "handled" EPIPE, exactly where the guard ends. The drop +/// therefore checks this thread's pending set and consumes a pending +/// SIGPIPE with `sigwait` BEFORE restoring the mask (`sigpending` only +/// sees thread-directed signals, so a pending SIGPIPE here is one this +/// guarded region generated). Test binaries run with std's SIG_IGN, under +/// which a generated SIGPIPE is discarded at generation and never becomes +/// pending — which is why the suite cannot catch a block-and-restore guard; +/// the consume path is pinned by re-arming SIG_DFL inside a helper process +/// (see `sigpipe_guard_survives_a_broken_pipe_restore`). struct BlockedSigpipeGuard { #[cfg(unix)] previous: libc::sigset_t, @@ -959,6 +973,23 @@ impl BlockedSigpipeGuard { impl Drop for BlockedSigpipeGuard { fn drop(&mut self) { unsafe { + // A write that ended in EPIPE raised SIGPIPE while it was + // blocked here. Consume it before the restore — otherwise the + // unblock delivers the pending signal at the process + // disposition (SIG_DFL in production) and the guard's own Drop + // kills the process it exists to protect (see the struct doc). + let mut pending: libc::sigset_t = std::mem::zeroed(); + libc::sigpending(&mut pending); + if libc::sigismember(&pending, libc::SIGPIPE) == 1 { + let mut blocked: libc::sigset_t = std::mem::zeroed(); + libc::sigemptyset(&mut blocked); + libc::sigaddset(&mut blocked, libc::SIGPIPE); + let mut delivered: i32 = 0; + // SAFETY: the signal is pending on this thread, so the + // wait returns immediately; the sets are local stack + // values. + libc::sigwait(&blocked, &mut delivered); + } libc::pthread_sigmask(libc::SIG_SETMASK, &self.previous, std::ptr::null_mut()); } } @@ -975,20 +1006,25 @@ impl Drop for BlockedSigpipeGuard { /// child was already gone and the master write failed), and `Stalled` means /// the tty input queue was still full at the deadline. #[cfg(all(unix, not(target_env = "ohos")))] +#[derive(Debug)] enum BoundedEofDelivery { Delivered, Failed, Stalled, } -/// Deliver `\n` + VEOF through the given master fd on the same kind of -/// bounded worker a payload write uses. The leading newline flushes any -/// pending partial line, because canonical mode only turns VEOF into a -/// read of length zero from an EMPTY line buffer — the same two bytes the -/// old ownership box injected on drop. In raw mode both bytes are ordinary -/// input, exactly like that box drop; the caller records a delivery only -/// when the write really completed, so the flag never claims an end-of-file -/// that did not land. +/// Deliver `\n` + VEOF through the given master fd on the same bounded +/// worker a payload write uses ([`bounded_stdin_write_owned`]). The leading +/// newline flushes any pending partial line, because canonical mode only +/// turns VEOF into a read of length zero from an EMPTY line buffer — the +/// same two bytes the old ownership box injected on drop. In raw mode both +/// bytes are ordinary input, exactly like that box drop; the caller records +/// a delivery only when the write really completed, so the flag never +/// claims an end-of-file that did not land. The EOF byte is written +/// literally (`0x04`) rather than read from `c_cc[VEOF]`: portable-pty's +/// unix writer injects the live byte at drop, so a terminal with VEOF +/// disabled received plain bytes from the box too — the parity holds in +/// every realistic termios state. #[cfg(all(unix, not(target_env = "ohos")))] fn bounded_stdin_eof_delivery(file: &std::fs::File) -> BoundedEofDelivery { use std::os::fd::AsFd; @@ -999,33 +1035,32 @@ fn bounded_stdin_eof_delivery(file: &std::fs::File) -> BoundedEofDelivery { // a queue-full condition, and the flag must stay down either way. Err(_) => return BoundedEofDelivery::Failed, }; - let mut writer = std::process::ChildStdin::from(dup_fd); - let (sender, receiver) = std::sync::mpsc::channel(); - let worker = std::thread::Builder::new() - .name("stdin-eof-delivery".to_string()) - .spawn(move || { - let _sigpipe = BlockedSigpipeGuard::new(); - let _ = sender.send(writer.write_all(b"\n\x04").and_then(|_| writer.flush())); - }); - let receiver = match worker { - Ok(_) => receiver, - Err(_) => return BoundedEofDelivery::Failed, - }; - match receiver.recv_timeout(STDIN_WRITE_DEADLINE) { - Ok(Ok(())) => BoundedEofDelivery::Delivered, - Ok(Err(_)) => BoundedEofDelivery::Failed, - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => BoundedEofDelivery::Stalled, - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => BoundedEofDelivery::Failed, - } -} - -/// The bounded stdin write for the platforms that cannot clone an fd out of -/// their handle (Windows and ohos, see [`ShellTask::write_stdin`]): the -/// worker thread takes the writer by value, finishes the payload, and hands -/// the writer back through the channel. `Ok` returns `(Some(writer), -/// Ok(io_result))`. `Err(RecvTimeoutError::Timeout)` returns `(None, ..)`: -/// the writer stays with the abandoned worker, the bytes still land if the -/// command resumes reading, and the caller marks the stall. + match bounded_stdin_write_owned( + StdinWriter::Pipe(std::process::ChildStdin::from(dup_fd)), + b"\n\x04".to_vec(), + ) { + // The clone is dropped on every arm: the dup this function was + // given stays with the task, and only the write's outcome decides + // what the close may claim. + Err(_) => BoundedEofDelivery::Failed, + Ok((_, Ok(Ok(())))) => BoundedEofDelivery::Delivered, + Ok((_, Ok(Err(_)))) => BoundedEofDelivery::Failed, + Ok((_, Err(std::sync::mpsc::RecvTimeoutError::Timeout))) => BoundedEofDelivery::Stalled, + Ok((_, Err(std::sync::mpsc::RecvTimeoutError::Disconnected))) => BoundedEofDelivery::Failed, + } +} + +/// The single bounded stdin write every platform uses: the worker thread +/// takes the writer by value, writes and flushes the payload, and hands the +/// writer back through the channel. Unix reaches it with a clone of the +/// task's handle — pipes clone the `ChildStdin` fd and the PTY lane clones +/// the dup'd master fd (see [`BackgroundShell::write_stdin`] and +/// [`bounded_stdin_eof_delivery`]); Windows and ohos cannot clone an fd out +/// of their handle, so the handoff moves the real writer. +/// `Ok((Some(writer), Ok(io_result)))` returns the writer to the caller. +/// `Ok((None, Err(RecvTimeoutError::Timeout)))` means the deadline hit +/// first: the writer stays with the abandoned worker, the bytes still land +/// if the command resumes reading, and the caller marks the stall. /// `Err(RecvTimeoutError::Disconnected)` means the worker died before /// completing (a panic in the write path): the writer is gone, and the /// caller must NOT mark a stall — a writer failure is not the command's @@ -1034,16 +1069,12 @@ fn bounded_stdin_eof_delivery(file: &std::fs::File) -> BoundedEofDelivery { /// the failed closure (ownership was already handed over), so the caller /// must report the task as having no writable stdin. A plain /// `thread::spawn` panic here would unwind through the shell-manager lock -/// and poison it for the rest of the process. Present on every target so -/// the unix-host test suite exercises it directly; production unix keeps -/// the fd-clone path, which is why the allow only covers the non-unix side. -#[cfg_attr(all(unix, not(target_env = "ohos")), allow(dead_code))] +/// and poison it for the rest of the process. type OwnedBoundedWriteOutcome = ( Option, Result, std::sync::mpsc::RecvTimeoutError>, ); -#[cfg_attr(all(unix, not(target_env = "ohos")), allow(dead_code))] fn bounded_stdin_write_owned( writer: StdinWriter, payload: Vec, @@ -1686,38 +1717,24 @@ impl BackgroundShell { )); } }; - let mut writer = std::process::ChildStdin::from(dup_fd); - let payload = input.as_bytes().to_vec(); - let (sender, receiver) = std::sync::mpsc::channel(); - let worker = std::thread::Builder::new() - .name("stdin-bounded-write".to_string()) - .spawn(move || { - let _sigpipe = BlockedSigpipeGuard::new(); - let _ = sender - .send(writer.write_all(&payload).and_then(|_| writer.flush())); - }); - let receiver = match worker { - Ok(_) => receiver, - Err(error) => { - // The spawn failed before any byte was written - // and the worker holds nothing: restore the - // handle and fail like a plain write error. A - // bare `thread::spawn` panic here would unwind - // through the shell-manager mutex and poison it - // for the rest of the process. - self.stdin = slot; - return Err(anyhow!(error) - .context("Failed to spawn the bounded stdin writer thread")); - } - }; - match receiver.recv_timeout(STDIN_WRITE_DEADLINE) { - Ok(Ok(())) => {} - Ok(Err(error)) => { - // The worker completed and handed the handle - // back: restore it so a write error does not - // silently strip the task's interact surface - // (the base behavior kept the handle attached - // after a write error too). + // The bounded write is the shared helper (the same one + // the owned platforms and the ^D close delivery use): + // the clone rides it by value, so only the ORIGINAL + // slot decides what is restored below — a returned + // clone is dropped, never reattached. + match bounded_stdin_write_owned( + StdinWriter::Pipe(std::process::ChildStdin::from(dup_fd)), + input.as_bytes().to_vec(), + ) { + // The clone came back with a completed write: drop + // it and keep the original attached. + Ok((_, Ok(Ok(())))) => {} + Ok((_, Ok(Err(error)))) => { + // The worker completed: restore the ORIGINAL + // handle so a write error does not silently + // strip the task's interact surface (the base + // behavior kept the handle attached after a + // write error too). self.stdin = slot; return Err(anyhow!(error).context("Failed to write to stdin")); } @@ -1726,20 +1743,29 @@ impl BackgroundShell { // path): that is a writer failure, not a stall, so // the task is not marked stalled — a healthy write // after this must not be refused by the flag. - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + Ok((_, Err(std::sync::mpsc::RecvTimeoutError::Disconnected))) => { self.stdin = slot; - return Err(anyhow!( - "the stdin writer for task {} failed: the background write ended without completing or reporting (the writer task panicked); the payload's delivery is unknown. Kill the task and start a new one.", - self.id - )); + return Err(anyhow!(stdin_writer_failed_refusal(&self.id))); } - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + Ok((_, Err(std::sync::mpsc::RecvTimeoutError::Timeout))) => { + // The clone stays with the abandoned worker; + // the original handle is restored, and the + // stall flag refuses any further write so two + // writers can never interleave on one handle. self.stdin_write_stalled = true; self.stdin = slot; - return Err(anyhow!( - "the write to task {} did not complete within {STDIN_WRITE_DEADLINE:?}: the command stopped consuming its stdin, so its input buffer filled and the rest of the payload was abandoned to a background writer (it still lands if the command resumes reading). Further stdin writes to this task are refused; kill it and start a new one.", - self.id - )); + return Err(anyhow!(stdin_stalled_write_refusal(&self.id))); + } + Err(error) => { + // The spawn failed before any byte was written + // and the worker holds nothing: restore the + // handle and fail like a plain write error. A + // bare `thread::spawn` panic here would unwind + // through the shell-manager mutex and poison it + // for the rest of the process. + self.stdin = slot; + return Err(anyhow!(error) + .context("Failed to spawn the bounded stdin writer thread")); } } } else { @@ -1789,10 +1815,7 @@ impl BackgroundShell { // `slot` stays None: the abandoned worker owns // the handle, so the close below cannot claim // EOF and further writes hit the stall refusal. - return Err(anyhow!( - "the write to task {} did not complete within {STDIN_WRITE_DEADLINE:?}: the command stopped consuming its stdin, so its input buffer filled and the rest of the payload was abandoned to a background writer (it still lands if the command resumes reading). Further stdin writes to this task are refused; kill it and start a new one.", - self.id - )); + return Err(anyhow!(stdin_stalled_write_refusal(&self.id))); } Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { // The worker died before completing (a panic in @@ -1801,10 +1824,7 @@ impl BackgroundShell { // healthy write after this is not refused, and // the writer is gone with the worker (nothing // to restore). - return Err(anyhow!( - "the stdin writer for task {} failed: the background write ended without completing or reporting (the writer task panicked); the payload's delivery is unknown. Kill the task and start a new one.", - self.id - )); + return Err(anyhow!(stdin_writer_failed_refusal(&self.id))); } } } @@ -1824,35 +1844,49 @@ impl BackgroundShell { // as a large payload would, and inline under the manager // lock they wedge every shell in the session. #[cfg(all(unix, not(target_env = "ohos")))] - if let Some(StdinWriter::PtyFile(file)) = slot.as_ref() - && !self.stdin_write_stalled - { - match bounded_stdin_eof_delivery(file) { - BoundedEofDelivery::Delivered => { - self.stdin = None; - self.stdin_closed_at_eof = true; - return Ok(()); - } - // The close consumed the handle as requested, but - // the bytes did not land (typically the child was - // already gone and the master write failed): keep - // the flag down so later refusals do not claim - // readers saw end-of-file. - BoundedEofDelivery::Failed => { - self.stdin = None; - return Ok(()); - } - // Queue still full at the deadline: restore the - // handle under the stall flag, mirroring the - // payload-timeout disposition, so later writes - // cite the stall instead of a delivery that has - // not happened. - BoundedEofDelivery::Stalled => { - self.stdin_write_stalled = true; - self.stdin = slot; - return Ok(()); + if let Some(StdinWriter::PtyFile(file)) = slot.as_ref() { + if !self.stdin_write_stalled { + match bounded_stdin_eof_delivery(file) { + BoundedEofDelivery::Delivered => { + self.stdin = None; + self.stdin_closed_at_eof = true; + return Ok(()); + } + // The close consumed the handle as requested, + // but the bytes did not land (typically the + // child was already gone and the master write + // failed): keep the flag down so later refusals + // do not claim readers saw end-of-file. Ok is + // honest here — the release the caller asked + // for happened, and no delivery is possible + // once the child is gone. + BoundedEofDelivery::Failed => { + self.stdin = None; + return Ok(()); + } + // Queue still full at the deadline: restore the + // handle under the stall flag and report the + // close as FAILED. The schema's close promises a + // delivered end-of-file, so a close that neither + // delivered nor released must not read as + // success — the same disposition the + // payload-timeout arm applies. + BoundedEofDelivery::Stalled => { + self.stdin_write_stalled = true; + self.stdin = slot; + return Err(anyhow!(stdin_stalled_close_refusal(&self.id))); + } } } + // A task whose last write stalled gets no delivery + // attempt at all: the queue that stalled the payload + // would stall the two bytes too, so the close is + // refused without waiting and without destroying the + // interact surface — the model is told the close was + // not applied instead of reading a success that + // delivered nothing. + self.stdin = slot; + return Err(anyhow!(stdin_stalled_close_not_applied_refusal(&self.id))); } // Windows cannot deliver end-of-file to a console child at // all (portable-pty's ConPTY writer has no EOF-on-drop — @@ -1895,12 +1929,12 @@ impl BackgroundShell { if self.stdin_closed_at_eof { return Err(anyhow!( - "stdin is not available for task {}: its stdin was closed, so stdin readers have seen end-of-file; start input-driven work with background=true and feed it with action=\"interact\"", + "stdin is not available for task {}: its stdin was closed, so stdin readers have seen end-of-file (a program that put its terminal into raw mode consumes the EOF byte as ordinary input instead); start input-driven work with background=true and feed it with action=\"interact\"", self.id )); } Err(anyhow!( - "stdin is not available for task {}: no writable stdin handle is attached to it (a finished command has released its stdin, a persistent command never exposes one, and a terminal command exposes one only while it runs and only until its stdin is closed); start input-driven work with background=true and feed it with action=\"interact\"", + "stdin is not available for task {}: no writable stdin handle is attached to it (a finished command has released its stdin, a persistent command never exposes one, a terminal command exposes one only while it runs and only until its stdin is closed, and a bounded stdin writer that failed to spawn or panicked takes its handle with it); start input-driven work with background=true and feed it with action=\"interact\"", self.id )) } @@ -3358,8 +3392,13 @@ impl ShellManager { return Err(err).context("Failed to clone PTY reader"); } }; - let stdout_thread = Some(spawn_reader_thread(reader, Arc::clone(&stdout_buffer))); - + // The interact writer is acquired BEFORE the reader thread + // spawns, so a writer-acquisition failure aborts with only + // the child to clean up (kill + wait) instead of orphaning + // an already-spawned reader thread. Nothing observes the + // order: `pair.master` is alive across the whole block, so + // the slave sees no end-of-file in either arrangement. + // // Unix: the interact writer is a dup of the master fd, and // `take_writer` is NOT called — its box's Drop injects a // newline plus `VEOF` into the master (that is how "dropping @@ -3416,6 +3455,7 @@ impl ShellManager { return Err(err).context("Failed to take PTY writer"); } })); + let stdout_thread = Some(spawn_reader_thread(reader, Arc::clone(&stdout_buffer))); (ShellChild::Pty(child), stdin_writer, stdout_thread, None) } @@ -3573,7 +3613,7 @@ impl ShellManager { // killed and the caller learns the run never started. let _ = bg_shell.kill(); return Err(err.context( - "the run never started: delivering the inline stdin payload failed, so the command was killed and no task was created", + "the run never started: delivering the inline stdin payload failed, so the command was killed and NO task was created (the task id the error above mentions was never registered — do not try to cancel or kill it)", )); } @@ -3678,13 +3718,15 @@ impl ShellManager { /// PTY writer is invisible to the child (the reader holds the master) /// but destroys the interact surface a session member can still use /// while a terminal command runs — the foreground terminal lane's - /// contract. + /// contract. A missing record is success, not an error: only eviction + /// removes a record, and eviction drops the stdin handle with it, so + /// the pipe cannot still be open — and the close runs between spawn + /// and the first caller-visible step, where an error would abort a run + /// whose result is already determined. fn close_stdin_pipe(&mut self, task_id: &str) -> Result<()> { - let shell = self - .processes - .get_mut(task_id) - .ok_or_else(|| anyhow!("Task {task_id} not found"))?; - shell.close_stdin_pipe(); + if let Some(shell) = self.processes.get_mut(task_id) { + shell.close_stdin_pipe(); + } Ok(()) } @@ -8111,10 +8153,10 @@ async fn execute_foreground_via_background( // them. The task-id extraction above is the one step between spawn and // close, and `spawn_background_sandboxed` always returns an id on `Ok`, // so the close reaches the shell layer without a fallible step in - // between (the manager-level `close_stdin_pipe` wrapper can still fail - // when the record was already evicted — and eviction drops the stdin - // handle with it, so the invariant that matters holds: no later - // failure can leave the pipe open). + // between (the manager-level `close_stdin_pipe` wrapper treats an + // already-evicted record as success — eviction drops the stdin + // handle with the record, so the invariant that matters holds: no + // later failure can leave the pipe open). // // Foreground has no operator at the keyboard, so whatever the command // will ever read from stdin must be bounded now. With stdin data the @@ -8218,15 +8260,17 @@ async fn execute_foreground_via_background( const BASH_MAX_TIMEOUT_MS: u64 = i32::MAX as u64; -/// Upper bound for one inline `stdin` payload. Every accepted payload is -/// delivered through the bounded stdin writer (see -/// [`STDIN_WRITE_DEADLINE`]), so a command that stops reading surfaces as a -/// deadline stall rather than a wedged executor; the cap additionally keeps -/// oversized payloads from ever reaching the writer, and a 64 KiB payload -/// fits a Linux pipe buffer in one atomic write so the common -/// payload-then-close foreground shape delivers without stalling. Anything -/// larger is refused with a pointer at background `interact` streaming -/// instead of hanging the session. +/// Upper bound for one inline `stdin` payload (counted in UTF-8 bytes). +/// Every payload the tool surface accepts is delivered through the bounded +/// stdin writer (see [`STDIN_WRITE_DEADLINE`]), so a command that stops +/// reading surfaces as a deadline stall rather than a wedged executor; the +/// cap additionally keeps oversized payloads from ever reaching the +/// writer, and on Linux's default 64 KiB pipe buffer a cap-sized payload +/// completes into an empty pipe without a reader, so the common +/// payload-then-close foreground shape delivers without stalling there. +/// Platforms with smaller default pipe buffers lean on the deadline +/// instead. Anything larger is refused with a pointer at background +/// `interact` streaming instead of hanging the session. const MAX_INLINE_STDIN_BYTES: usize = 64 * 1024; /// Wall-clock bound on one stdin write. At the bound an `interact` write is @@ -8241,6 +8285,34 @@ const MAX_INLINE_STDIN_BYTES: usize = 64 * 1024; /// EPIPE instead of taking the process down. const STDIN_WRITE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(5); +/// One shared copy per stdin refusal shape so the unix fd-clone path and +/// the owned-writer path (and the close paths) cannot drift apart — the +/// pins assert substrings of these. They are functions rather than consts +/// so the deadline interpolation stays a real format capture. +fn stdin_stalled_write_refusal(task_id: &str) -> String { + format!( + "the write to task {task_id} did not complete within {STDIN_WRITE_DEADLINE:?}: the command stopped consuming its stdin, so its input buffer filled and the rest of the payload was abandoned to a background writer (it still lands if the command resumes reading). Further stdin writes to this task are refused; kill it and start a new one." + ) +} + +fn stdin_writer_failed_refusal(task_id: &str) -> String { + format!( + "the stdin writer for task {task_id} failed: the background write ended without completing or reporting (the writer task panicked); the payload's delivery is unknown. Kill the task and start a new one." + ) +} + +fn stdin_stalled_close_refusal(task_id: &str) -> String { + format!( + "the stdin close for task {task_id} did not complete within {STDIN_WRITE_DEADLINE:?}: the terminal's input queue stayed full because the command stopped consuming input, so the end-of-file bytes were not delivered; the close was not applied and the interact surface stays attached. Further stdin writes to this task are refused; kill it and start a new one." + ) +} + +fn stdin_stalled_close_not_applied_refusal(task_id: &str) -> String { + format!( + "the stdin close for task {task_id} was not applied: its last stdin write stalled because the command stopped consuming input, so the end-of-file was never delivered and no close can deliver it now; the interact surface stays attached and further stdin writes are refused; kill it and start a new one." + ) +} + /// Initial cadence for foreground-via-background completion polling. Fast /// commands dominate real agent traffic; detection latency on `true`-class /// commands drops from ~100ms to ~10ms, while long commands reach the @@ -8575,7 +8647,7 @@ impl ToolSpec for BashTool { "stdin": { "type": "string", "maxLength": 65536, - "description": "Stdin data to send (action=run: before waiting; action=interact: to the background task). Also accepted as `input` or `data` — send only one. Inline payloads are capped at 64 KiB: a larger write to a command that is not reading would block the executor; for bigger payloads run the command in the background and feed it through `action=interact` in chunks." + "description": "Stdin data to send (action=run: before waiting; action=interact: to the background task). Also accepted as `input` or `data` — send only one. Inline payloads are capped at 64 KiB, counted as UTF-8 bytes (maxLength mirrors the same number): a larger write to a command that is not reading would block the executor; for bigger payloads run the command in the background and feed it through `action=interact` in chunks." }, "input": { "type": "string", @@ -8623,7 +8695,7 @@ impl ToolSpec for BashTool { }, "close_stdin": { "type": "boolean", - "description": "Close stdin after sending (action=interact); on a terminal task the close delivers end-of-file, sent as a literal newline followed by the EOF byte" + "description": "Close stdin after sending (action=interact); on a terminal task the close delivers end-of-file, sent as a literal newline followed by the EOF byte — a program that put its terminal into raw mode receives those bytes as ordinary input instead, and on Windows the close only releases the terminal handle (a console child has no end-of-file to deliver)" }, "all": { "type": "boolean", @@ -8797,10 +8869,12 @@ impl ToolSpec for BashTool { // the caller holds the shell-manager lock: past the pipe // buffer a command that never reads would wedge the // executor thread and every other shell in the session - // before any watchdog is reached. The cap equals the Linux - // pipe buffer, so there a write that passes it completes - // unconditionally; bigger payloads belong on the background - // lane, fed through `action=interact` in chunks. + // before any watchdog is reached. On Linux the cap equals + // the default pipe buffer, so a cap-sized write into an + // empty pipe completes without a reader there; smaller + // default buffers lean on the bounded writer's deadline. + // Bigger payloads belong on the background lane, fed + // through `action=interact` in chunks. if payload.len() > MAX_INLINE_STDIN_BYTES { return Ok(ToolResult::error(format!( "The inline stdin payload is {} bytes; the shell tool accepts at most {MAX_INLINE_STDIN_BYTES} inline — run the command with background=true and feed it through action=interact in chunks.", diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index 4c1b363a32..65aece5e0f 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -9872,6 +9872,114 @@ async fn inline_stdin_payload_over_the_cap_refuses() { output.content ); } +/// Runs a stdin scenario on a worker thread behind a 60 s outer guard. The +/// scenario's own deadline machinery is what should finish it; a mechanism +/// regression that never returns trips this guard instead (the join can +/// extend past the guard while the scenario's child still holds the blocked +/// write open — the child's own watchdog releases it, so the guard +/// terminates, loudly late). A worker panic surfaces as a failed verdict, +/// never as a hung test. +#[cfg(unix)] +fn run_stdin_scenario_with_hang_guard( + name: &'static str, + body: impl FnOnce() -> Result<(), String> + Send + 'static, +) -> Result<(), String> { + let (sender, receiver) = std::sync::mpsc::channel::>(); + let handle = std::thread::spawn(move || { + let verdict = body(); + let _ = sender.send(verdict.clone()); + verdict + }); + match receiver.recv_timeout(Duration::from_secs(60)) { + Ok(verdict) => { + // Reaps the worker; the verdict from the channel is the + // scenario outcome, so the join's own copy adds nothing. + let _ = handle.join().expect("the scenario worker panicked"); + verdict + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + let _ = handle.join(); + Err(format!("{name} hung past its 60 s guard")) + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + // The worker died before its verdict reached the channel. + let _ = handle.join().expect_err("the scenario worker panicked"); + Err(format!("{name} panicked before reporting a verdict")) + } + } +} + +#[cfg(unix)] +const SIGPIPE_GUARD_HELPER_ENV: &str = "CODEWHALE_SIGPIPE_GUARD_HELPER"; + +/// Helper half of `sigpipe_guard_survives_a_broken_pipe_restore`: runs in a +/// process that re-armed SIGPIPE at SIG_DFL (what `run_with_args` does in +/// production), takes the guard, writes into a readerless pipe, and only +/// reaches the implicit test exit if the guard's mask restore did not +/// deliver the pending signal. +#[cfg(unix)] +#[test] +fn sigpipe_guard_helper_process() { + if std::env::var(SIGPIPE_GUARD_HELPER_ENV).ok().as_deref() != Some("1") { + return; + } + // The suite binary runs with std's SIG_IGN, under which a broken-pipe + // SIGPIPE is discarded at generation and never becomes pending — the + // bug cannot reproduce there, so the helper re-arms the production + // disposition first. + unsafe { + libc::signal(libc::SIGPIPE, libc::SIG_DFL); + let mut fds = [0 as libc::c_int; 2]; + assert_eq!(libc::pipe(fds.as_mut_ptr()), 0, "pipe"); + libc::close(fds[0]); + { + let guard = super::BlockedSigpipeGuard::new(); + let buf = [b'x'; 4096]; + let written = libc::write(fds[1], buf.as_ptr().cast(), buf.len()); + assert_eq!(written, -1, "the readerless write must fail"); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::EPIPE), + "the readerless write must fail with EPIPE" + ); + // Dropping the guard restores the mask. A block-and-restore + // guard delivers the pending SIGPIPE here and this process + // dies at signal 13 before reaching the end of the test; the + // consume-before-restore fix reaches it and exits 0. + drop(guard); + } + libc::close(fds[1]); + } +} + +/// The SIGPIPE guard must consume the signal it blocked before restoring +/// the mask: a bounded write that ends in EPIPE leaves SIGPIPE pending on +/// the worker thread, and a plain block-and-restore delivers it at the +/// process disposition (SIG_DFL in production, see `run_with_args`) — +/// killing the whole host one instruction after the "handled" EPIPE. The +/// suite cannot see this (test binaries run SIG_IGN, so no SIGPIPE ever +/// becomes pending), so the driver re-execs this binary as a helper that +/// re-arms SIG_DFL first: with the consume the helper exits 0; without it +/// the helper dies at signal 13 and the non-success status fails here. +#[cfg(unix)] +#[test] +fn sigpipe_guard_survives_a_broken_pipe_restore() { + let test_binary = std::env::current_exe().expect("current test binary"); + let mut helper = Command::new(&test_binary) + .arg("--exact") + .arg("tools::shell::tests::sigpipe_guard_helper_process") + .env(SIGPIPE_GUARD_HELPER_ENV, "1") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn sigpipe guard helper"); + let status = helper.wait().expect("wait sigpipe guard helper"); + assert!( + status.success(), + "the guard's mask restore must not deliver the pending SIGPIPE (a broken guard kills the helper at signal 13): {status}" + ); +} /// The bounded stdin write: a command that never consumes its pipe fills /// it, and past [`STDIN_WRITE_DEADLINE`] the write errors and the task's @@ -9886,9 +9994,9 @@ async fn inline_stdin_payload_over_the_cap_refuses() { #[cfg(unix)] #[test] fn write_stdin_to_a_child_that_stops_consuming_errors_instead_of_wedging() { - let (sender, receiver) = std::sync::mpsc::channel::>(); - let handle = std::thread::spawn(move || { - let run = || -> Result<(), String> { + let verdict = run_stdin_scenario_with_hang_guard( + "the bounded-write pin", + || -> Result<(), String> { let tmp = tempdir().map_err(|error| error.to_string())?; let mut manager = ShellManager::new(tmp.path().to_path_buf()); let result = manager @@ -9959,18 +10067,9 @@ fn write_stdin_to_a_child_that_stops_consuming_errors_instead_of_wedging() { } manager.kill(&task_id).map_err(|error| error.to_string())?; Ok(()) - }; - let verdict = run(); - let _ = sender.send(verdict.clone()); - verdict - }); - let _ = receiver - .recv_timeout(std::time::Duration::from_secs(60)) - .expect("the bounded-write pin hung: the STDIN_WRITE_DEADLINE regression is live"); - handle - .join() - .expect("the bounded-write worker panicked") - .expect("bounded-write assertions"); + }, + ); + verdict.expect("bounded-write assertions"); } /// The `interact` payload carries the same per-call bound as the inline /// spawn payload: one oversized call is refused with a pointer at chunking @@ -10027,6 +10126,21 @@ fn guidance_states_the_foreground_stdin_contract() { "guidance must point at background interact for input-driven work: {description}" ); } +/// The lowercase tool's own description carries the same foreground stdin +/// contract: its legacy translation accepts only command/timeout/sandbox +/// fields, so that sentence is the whole stdin story on that surface. +#[test] +fn lowercase_bash_description_states_the_foreground_stdin_contract() { + let description = LowercaseBashTool.description(); + assert!( + description.contains("This foreground surface runs without a terminal stdin"), + "the lowercase description must state the bounded foreground stdin: {description}" + ); + assert!( + description.contains("a bare `$SHELL` receive end-of-file"), + "the bare-shell probe must be named as exiting on EOF: {description}" + ); +} /// A foreground command that reads stdin with NO stdin data used to wedge on /// a pipe whose write end the tool kept open: the live repro was `$SHELL` /// holding the turn until the foreground watchdog killed it at the full @@ -10904,9 +11018,9 @@ async fn foreground_terminal_command_keeps_its_interact_surface() { #[test] #[cfg(all(unix, not(target_env = "ohos")))] fn interact_write_to_a_stalled_tty_command_is_bounded() { - let (sender, receiver) = std::sync::mpsc::channel::>(); - let handle = std::thread::spawn(move || { - let run = || -> Result<(), String> { + let verdict = run_stdin_scenario_with_hang_guard( + "the bounded tty-write pin", + || -> Result<(), String> { let tmp = tempdir().map_err(|error| error.to_string())?; let mut manager = ShellManager::new(tmp.path().to_path_buf()); let result = manager @@ -10986,17 +11100,8 @@ fn interact_write_to_a_stalled_tty_command_is_bounded() { } manager.kill(&task_id).map_err(|error| error.to_string())?; Ok(()) - }; - let verdict = run(); - let _ = sender.send(verdict.clone()); - verdict - }); - let verdict = receiver - .recv_timeout(Duration::from_secs(60)) - .unwrap_or_else(|_| { - let _ = handle.join(); - Err("the bounded tty-write test hung past its 60 s guard".to_string()) - }); + }, + ); assert!(verdict.is_ok(), "{verdict:?}"); } @@ -11693,19 +11798,19 @@ fn readonly_compound_refusal_names_the_cmd_comment_wall() { } /// An explicit `close_stdin` on a terminal task whose last write already -/// STALLED must stay bounded and must not claim an end-of-file the -/// abandoned worker never delivered: the stall conjunct skips the ^D -/// delivery, the close releases the handle, and the tail refusal still -/// names the stall. A mutation that claims EOF unconditionally on the -/// terminal close (dropping the stall conjunct) reddens on the refusal -/// text; a close that wedges on the full input queue trips the outer +/// STALLED must stay bounded, must not claim an end-of-file the abandoned +/// worker never delivered, and must not read as success either: the stall +/// skip refuses the close outright ("was not applied") and keeps the +/// interact surface attached, and the tail refusal still names the stall. +/// A mutation that reports the stalled close as Ok reddens on the +/// expect_err; a close that wedges on the full input queue trips the outer /// guard instead. #[cfg(all(unix, not(target_env = "ohos")))] #[test] fn close_stdin_on_a_stalled_terminal_task_is_bounded_and_claims_no_eof() { - let (sender, receiver) = std::sync::mpsc::channel::>(); - let handle = std::thread::spawn(move || { - let run = || -> Result<(), String> { + let verdict = run_stdin_scenario_with_hang_guard( + "the stalled-terminal-close pin", + || -> Result<(), String> { let tmp = tempdir().map_err(|error| error.to_string())?; let mut manager = ShellManager::new(tmp.path().to_path_buf()); let result = manager @@ -11749,43 +11854,44 @@ fn close_stdin_on_a_stalled_terminal_task_is_bounded_and_claims_no_eof() { if !error.contains("did not complete within") { return Err(error); } - // The close itself must return promptly (bounded path, stall - // guard) instead of wedging on the full input queue. + // The close itself must return promptly (no delivery attempt + // on a stalled queue) and must NOT report success: with the + // end-of-file undelivered, an Ok close would tell the model + // stdin is closed when it is not. let started = std::time::Instant::now(); - manager + let close_error = manager .write_stdin(&task_id, "", true) - .map_err(|error| error.to_string())?; - if started.elapsed() > STDIN_WRITE_DEADLINE { + .expect_err("a close on a stalled terminal task must not claim success") + .to_string(); + if started.elapsed() >= STDIN_WRITE_DEADLINE { return Err(format!( "the close on a stalled terminal task must not wait on the queue: {:?}", started.elapsed() )); } - // And the tail refusal must still report the stall: no EOF was - // delivered, so claiming end-of-file here would be a lie. + if !close_error.contains("was not applied") { + return Err(format!( + "the stalled-close refusal must say the close was not applied: {close_error}" + )); + } + // And any further stdin traffic must still be refused without + // claiming an EOF: no delivery happened, so "end-of-file" would + // be a lie (the refusal here comes from the write-stall gate — + // the refused close left the handle attached). let after_close = manager .write_stdin(&task_id, "y", false) .err() .ok_or_else(|| "the post-close write must be refused".to_string())? .to_string(); - if !after_close.contains("stalled") || after_close.contains("end-of-file") { + if after_close.contains("end-of-file") { return Err(format!( "a terminal close after a stall must not claim an EOF that never landed: {after_close}" )); } manager.kill(&task_id).map_err(|error| error.to_string())?; Ok(()) - }; - let verdict = run(); - let _ = sender.send(verdict.clone()); - verdict - }); - let verdict = receiver - .recv_timeout(Duration::from_secs(60)) - .unwrap_or_else(|_| { - let _ = handle.join(); - Err("the stalled-terminal-close test hung past its 60 s guard".to_string()) - }); + }, + ); assert!(verdict.is_ok(), "{verdict:?}"); } /// The owned-writer bounded stdin write (the Windows/ohos fallback path): @@ -11846,6 +11952,97 @@ fn bounded_stdin_write_owned_returns_the_writer_and_times_out_clean() { let _ = child.wait(); } +/// `bounded_stdin_eof_delivery` — the worker the explicit terminal close +/// rides — must classify its outcome honestly: a write that lands reports +/// `Delivered`, a write into a FULL pipe (nothing consuming) reports +/// `Stalled` only at the deadline, and a write to a readerless pipe reports +/// `Failed`. A Stalled that came back as Delivered would let `write_stdin` +/// claim an end-of-file that never landed. The call is deadline-bounded by +/// construction, so a broken deadline hangs this test into the nextest +/// slow-timeout instead of passing. +// Unix PTY-lane cfg, matching the function it pins. +#[cfg(all(unix, not(target_env = "ohos")))] +#[test] +fn bounded_stdin_eof_delivery_reports_stalled_failed_and_delivered() { + use std::os::fd::FromRawFd; + + // Delivered: /dev/null accepts the two bytes unconditionally. + let null = std::fs::OpenOptions::new() + .write(true) + .open("/dev/null") + .expect("open /dev/null"); + assert!( + matches!( + super::bounded_stdin_eof_delivery(&null), + super::BoundedEofDelivery::Delivered + ), + "a write that lands must report Delivered" + ); + + // Failed: a readerless pipe errors the write without delivering (the + // suite runs with SIG_IGN, so the EPIPE surfaces as an error, not a + // signal — the process-death discipline is pinned by the sigpipe guard + // helper test). + let mut fds = [0 as libc::c_int; 2]; + assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0, "pipe"); + unsafe { libc::close(fds[0]) }; + let orphan = unsafe { std::fs::File::from_raw_fd(fds[1]) }; + assert!( + matches!( + super::bounded_stdin_eof_delivery(&orphan), + super::BoundedEofDelivery::Failed + ), + "a readerless pipe must report Failed" + ); + drop(orphan); + + // Stalled: fill a pipe to capacity without a consumer (O_NONBLOCK so + // the test thread never blocks), restore blocking mode for the worker, + // and the two-byte delivery cannot land until the deadline. + let mut fds = [0 as libc::c_int; 2]; + assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0, "pipe"); + let read_fd = fds[0]; + let full = unsafe { std::fs::File::from_raw_fd(fds[1]) }; + unsafe { libc::fcntl(fds[1], libc::F_SETFL, libc::O_NONBLOCK) }; + let chunk = [b'x'; 4096]; + loop { + let written = unsafe { libc::write(fds[1], chunk.as_ptr().cast(), chunk.len()) }; + if written <= 0 { + break; // EAGAIN: the pipe is full + } + } + unsafe { libc::fcntl(fds[1], libc::F_SETFL, 0) }; + let started = std::time::Instant::now(); + let outcome = super::bounded_stdin_eof_delivery(&full); + drop(full); + assert!( + matches!(outcome, super::BoundedEofDelivery::Stalled), + "a full pipe must stall the delivery: {outcome:?}" + ); + assert!( + started.elapsed() >= super::STDIN_WRITE_DEADLINE, + "the stall must honor the deadline: {:?}", + started.elapsed() + ); + // Unblock the abandoned worker: a reader consumes the queued bytes, the + // worker's write lands, its send fails on the dropped receiver, and the + // thread exits instead of parking for the process lifetime. The drain + // is bounded so the reader cannot hang once the pipe ends are closed. + let drainer = std::thread::spawn(move || { + let mut buf = [0u8; 4096]; + let mut total = 0usize; + while total < 512 * 1024 { + let n = unsafe { libc::read(read_fd, buf.as_mut_ptr().cast(), buf.len()) }; + if n <= 0 { + break; + } + total += n as usize; + } + unsafe { libc::close(read_fd) }; + }); + drainer.join().expect("drain the stalled pipe"); +} + /// An unconfirmed stop keeps its write scope across every coordination /// query — until a later poll reaps the real child and clears the /// uncertainty. The flag models the un-reaped state; the confirmation cycle From e5d5ba9c25e1c4c5fbccec8af463a9dd4c7bcac1 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Thu, 12 Nov 2026 13:35:02 +0800 Subject: [PATCH 03/11] fix(shell): gate unix-only close refusals, pin the fresh-stall close arm Round-3 wave from the fresh 10-lane re-review at f28a861c6: - BLOCKER: stdin_stalled_close_refusal and stdin_stalled_close_not_applied_refusal had no cfg, but their only references sit in the all(unix, not(target_env = "ohos")) PTY-close arm - on Windows and ohos both fns are dead code, fatal under -D warnings (ci.yml RUSTFLAGS, ohos.yml, fork-ci clippy). Gate them with the arm's predicate, mirroring the PtyFile variant. - The bounded_stdin_eof_delivery doc claimed a terminal with VEOF disabled "received plain bytes from the box too"; portable-pty 0.9.0 writes nothing at all in that state (unix.rs guards if eot != 0), so the comment now states the real divergence and the close_stdin schema discloses the disabled-VEOF state alongside raw mode. - Accuracy: the guard doc's "sigpending only sees thread-directed signals" parenthetical (sigpending also reports process-directed signals); write_stdin's lead comment (the owned-path Timeout, Disconnected, and spawn-failure arms deliberately leave the slot empty); the stdin_write_stalled field doc (a stalled close sets it). - New pin close_stdin_that_stalls_on_fresh_delivery_refuses_and_ stays_attached: the fresh-stall close arm (Stalled -> Err) had no guard; the record-level pin drives write_stdin("", true) against a full pipe wrapped in PtyFile and asserts the Err, the deadline wait, the restored handle + stall flag, the no-EOF flag, and the fast follow-up refusal. Mutation-verified red (Stalled -> Ok revert). - Harden sigpipe_guard_survives_a_broken_pipe_restore: the helper now also asserts SIGPIPE is unblocked after the drop (a no-op restore went undetected; mutation-verified red) and writes a ran-canary the driver asserts, so a renamed helper cannot leave the pin silently green via libtest's exit-0-on-unmatched-filter. Gates: tools::shell 174/0/1, tools::verifier 9/0, fmt clean, fork-CI clippy flags clean (isolated target dir). Signed-off-by: asto18089 --- crates/tui/src/tools/shell.rs | 49 ++++--- crates/tui/src/tools/shell/tests.rs | 196 +++++++++++++++++++++++++++- 2 files changed, 226 insertions(+), 19 deletions(-) diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index 5af7a6f699..e9955e0639 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -931,9 +931,12 @@ impl StdinWriter { /// With SIGPIPE at SIG_DFL that would kill the process one instruction /// after the "handled" EPIPE, exactly where the guard ends. The drop /// therefore checks this thread's pending set and consumes a pending -/// SIGPIPE with `sigwait` BEFORE restoring the mask (`sigpending` only -/// sees thread-directed signals, so a pending SIGPIPE here is one this -/// guarded region generated). Test binaries run with std's SIG_IGN, under +/// SIGPIPE with `sigwait` BEFORE restoring the mask. A write-failure +/// SIGPIPE is thread-directed, so a pending SIGPIPE inside a guarded +/// region is one this region's write generated; `sigpending` also reports +/// process-directed signals, but nothing in this process raises SIGPIPE +/// that way, and consuming such a signal under SIG_DFL would only spare +/// the process. Test binaries run with std's SIG_IGN, under /// which a generated SIGPIPE is discarded at generation and never becomes /// pending — which is why the suite cannot catch a block-and-restore guard; /// the consume path is pinned by re-arming SIG_DFL inside a helper process @@ -1022,9 +1025,12 @@ enum BoundedEofDelivery { /// a delivery only when the write really completed, so the flag never /// claims an end-of-file that did not land. The EOF byte is written /// literally (`0x04`) rather than read from `c_cc[VEOF]`: portable-pty's -/// unix writer injects the live byte at drop, so a terminal with VEOF -/// disabled received plain bytes from the box too — the parity holds in -/// every realistic termios state. +/// unix writer reads the live byte at drop and writes nothing at all when +/// VEOF is disabled (`_POSIX_VDISABLE`), so the two behaviors differ in +/// that state — this close sends the two bytes as ordinary input, exactly +/// like raw mode, and no end-of-file ever arrives. The `close_stdin` +/// schema discloses that; the old box delivered nothing in the same +/// state, so the close still reports honestly what it did. #[cfg(all(unix, not(target_env = "ohos")))] fn bounded_stdin_eof_delivery(file: &std::fs::File) -> BoundedEofDelivery { use std::os::fd::AsFd; @@ -1297,10 +1303,11 @@ pub struct BackgroundShell { /// `interact` can state the true reason stdin is unavailable. stdin_closed_at_eof: bool, /// Set when a stdin write outlived [`STDIN_WRITE_DEADLINE`] because the - /// command stopped consuming its pipe. The abandoned bytes keep flowing - /// from the background writer if the child resumes, so further writes - /// are refused (two writers could interleave) instead of queued behind - /// an unknown pipe state. + /// command stopped consuming its pipe, or when a terminal close's own + /// ^D delivery stalled the same way (same deadline, same refusal + /// family). The abandoned bytes keep flowing from the background writer + /// if the child resumes, so further writes are refused (two writers + /// could interleave) instead of queued behind an unknown pipe state. stdin_write_stalled: bool, child: Option, /// A stop was delivered but the direct child's exit was never observed: @@ -1651,9 +1658,12 @@ impl BackgroundShell { fn write_stdin(&mut self, input: &str, close: bool) -> Result<()> { // Taken owned so the Windows/ohos fallback can move the writer into - // its bounded worker and hand it back; every exit below restores the + // its bounded worker and hand it back. Every unix exit restores the // slot first, keeping the stall/eof refusal ordering identical with - // the pinned unix behavior. + // the pinned unix behavior; on the ownership-fallback cfgs the + // Timeout, Disconnected, and spawn-failure arms deliberately leave + // the slot empty — the writer is gone with the worker, and the stall + // flag or the returned refusal reports that. // `mut` is real only on the ownership-fallback cfgs (they re-take and // reassign the slot); the unix fd-clone path only moves it, so the // same predicate that strips the fallback also silences `unused_mut`. @@ -8286,9 +8296,9 @@ const MAX_INLINE_STDIN_BYTES: usize = 64 * 1024; const STDIN_WRITE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(5); /// One shared copy per stdin refusal shape so the unix fd-clone path and -/// the owned-writer path (and the close paths) cannot drift apart — the -/// pins assert substrings of these. They are functions rather than consts -/// so the deadline interpolation stays a real format capture. +/// the owned-writer path cannot drift apart — the pins assert substrings +/// of these. They are functions rather than consts so the deadline +/// interpolation stays a real format capture. fn stdin_stalled_write_refusal(task_id: &str) -> String { format!( "the write to task {task_id} did not complete within {STDIN_WRITE_DEADLINE:?}: the command stopped consuming its stdin, so its input buffer filled and the rest of the payload was abandoned to a background writer (it still lands if the command resumes reading). Further stdin writes to this task are refused; kill it and start a new one." @@ -8301,12 +8311,19 @@ fn stdin_writer_failed_refusal(task_id: &str) -> String { ) } +/// Serves only the unix PTY close arm (the Windows/ohos close arm has its +/// own inline copy and never takes the unix delivery path). Same +/// predicate as the `PtyFile` variant, so `deny(warnings)`' fatal +/// `dead_code` cannot fire on platforms where the arm is compiled out. +#[cfg(all(unix, not(target_env = "ohos")))] fn stdin_stalled_close_refusal(task_id: &str) -> String { format!( "the stdin close for task {task_id} did not complete within {STDIN_WRITE_DEADLINE:?}: the terminal's input queue stayed full because the command stopped consuming input, so the end-of-file bytes were not delivered; the close was not applied and the interact surface stays attached. Further stdin writes to this task are refused; kill it and start a new one." ) } +/// See [`stdin_stalled_close_refusal`]: unix-PTY-only, same cfg predicate. +#[cfg(all(unix, not(target_env = "ohos")))] fn stdin_stalled_close_not_applied_refusal(task_id: &str) -> String { format!( "the stdin close for task {task_id} was not applied: its last stdin write stalled because the command stopped consuming input, so the end-of-file was never delivered and no close can deliver it now; the interact surface stays attached and further stdin writes are refused; kill it and start a new one." @@ -8695,7 +8712,7 @@ impl ToolSpec for BashTool { }, "close_stdin": { "type": "boolean", - "description": "Close stdin after sending (action=interact); on a terminal task the close delivers end-of-file, sent as a literal newline followed by the EOF byte — a program that put its terminal into raw mode receives those bytes as ordinary input instead, and on Windows the close only releases the terminal handle (a console child has no end-of-file to deliver)" + "description": "Close stdin after sending (action=interact); on a terminal task the close delivers end-of-file, sent as a literal newline followed by the EOF byte — a program that put its terminal into raw mode, or disabled VEOF, receives those bytes as ordinary input instead, and on Windows the close only releases the terminal handle (a console child has no end-of-file to deliver)" }, "all": { "type": "boolean", diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index 65aece5e0f..73daf0e684 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -9912,11 +9912,19 @@ fn run_stdin_scenario_with_hang_guard( #[cfg(unix)] const SIGPIPE_GUARD_HELPER_ENV: &str = "CODEWHALE_SIGPIPE_GUARD_HELPER"; +/// Lets the driver verify the helper actually RAN: libtest exits 0 on an +/// unmatched `--exact` filter, so a future rename of the helper test would +/// otherwise leave the pin silently green while guarding nothing. +#[cfg(unix)] +const SIGPIPE_GUARD_HELPER_CANARY_ENV: &str = "CODEWHALE_SIGPIPE_GUARD_HELPER_CANARY"; + /// Helper half of `sigpipe_guard_survives_a_broken_pipe_restore`: runs in a /// process that re-armed SIGPIPE at SIG_DFL (what `run_with_args` does in -/// production), takes the guard, writes into a readerless pipe, and only -/// reaches the implicit test exit if the guard's mask restore did not -/// deliver the pending signal. +/// production), takes the guard, writes into a readerless pipe, checks that +/// the drop restored the prior mask, and only then writes the ran-canary — +/// so the driver fails on a helper that died at signal 13 (consume removed), +/// panicked on a left-blocked mask (restore removed), or never ran (rename +/// drift). #[cfg(unix)] #[test] fn sigpipe_guard_helper_process() { @@ -9948,8 +9956,29 @@ fn sigpipe_guard_helper_process() { // consume-before-restore fix reaches it and exits 0. drop(guard); } + // The restore half is pinned too: after the drop, SIGPIPE must no + // longer be blocked on this thread. A no-op restore would leave + // every later SIGPIPE pending on this thread forever — invisible + // to the exit-status assertion above, so query the mask directly. + let mut now_blocked: libc::sigset_t = std::mem::zeroed(); + assert_eq!( + libc::pthread_sigmask(libc::SIG_BLOCK, std::ptr::null(), &mut now_blocked), + 0, + "query the signal mask" + ); + assert_eq!( + libc::sigismember(&now_blocked, libc::SIGPIPE), + 0, + "the guard drop must restore the prior mask (SIGPIPE must not stay blocked)" + ); libc::close(fds[1]); } + // Ran-canary for the driver: written only after every assertion above + // passed, so an unmatched filter (helper never ran) is distinguishable + // from a green run. + if let Some(canary) = std::env::var_os(SIGPIPE_GUARD_HELPER_CANARY_ENV) { + std::fs::write(&canary, b"ran").expect("write the ran-canary"); + } } /// The SIGPIPE guard must consume the signal it blocked before restoring @@ -9965,10 +9994,16 @@ fn sigpipe_guard_helper_process() { #[test] fn sigpipe_guard_survives_a_broken_pipe_restore() { let test_binary = std::env::current_exe().expect("current test binary"); + let canary = std::env::temp_dir().join(format!( + "codewhale-sigpipe-guard-canary-{}", + std::process::id() + )); + let _ = std::fs::remove_file(&canary); let mut helper = Command::new(&test_binary) .arg("--exact") .arg("tools::shell::tests::sigpipe_guard_helper_process") .env(SIGPIPE_GUARD_HELPER_ENV, "1") + .env(SIGPIPE_GUARD_HELPER_CANARY_ENV, &canary) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) @@ -9979,6 +10014,11 @@ fn sigpipe_guard_survives_a_broken_pipe_restore() { status.success(), "the guard's mask restore must not deliver the pending SIGPIPE (a broken guard kills the helper at signal 13): {status}" ); + assert!( + canary.exists(), + "the helper must actually run — libtest exits 0 on an unmatched --exact filter, so a green status without the canary means this pin guarded nothing (helper renamed?)" + ); + let _ = std::fs::remove_file(&canary); } /// The bounded stdin write: a command that never consumes its pipe fills @@ -12043,6 +12083,156 @@ fn bounded_stdin_eof_delivery_reports_stalled_failed_and_delivered() { drainer.join().expect("drain the stalled pipe"); } +/// The FRESH-stall close arm — a terminal close whose own ^D delivery +/// stalls with the stall flag still down (no prior write set it) — must +/// wait the bounded deadline, return the close refusal that cites it, +/// restore the handle under the stall flag, and refuse the follow-up write +/// without another deadline wait. A mutant reporting this arm as Ok would +/// read as the schema's delivered end-of-file while the bytes never +/// landed; the not-applied pin next door only reaches the SKIP arm (its +/// scenario stalls a write first), so this record-level pin is the only +/// guard on the arm itself. Drives `write_stdin` on a record whose stdin +/// is a `PtyFile` over a full pipe — exactly what a real PTY task hands +/// the arm. +#[cfg(all(unix, not(target_env = "ohos")))] +#[test] +fn close_stdin_that_stalls_on_fresh_delivery_refuses_and_stays_attached() { + use std::os::fd::FromRawFd; + + // Fill a pipe to capacity without a consumer (O_NONBLOCK so the test + // thread never blocks), restore blocking mode for the worker: the + // two-byte ^D delivery cannot land until the deadline. + let mut fds = [0 as libc::c_int; 2]; + assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0, "pipe"); + let read_fd = fds[0]; + let full = unsafe { std::fs::File::from_raw_fd(fds[1]) }; + unsafe { libc::fcntl(fds[1], libc::F_SETFL, libc::O_NONBLOCK) }; + let chunk = [b'x'; 4096]; + loop { + let written = unsafe { libc::write(fds[1], chunk.as_ptr().cast(), chunk.len()) }; + if written <= 0 { + break; // EAGAIN: the pipe is full + } + } + unsafe { libc::fcntl(fds[1], libc::F_SETFL, 0) }; + + let now = std::time::Instant::now(); + let mut shell = BackgroundShell { + id: "fresh-stall-close".to_string(), + owner_session_id: "stdin-test-session".to_string(), + command: "cat".to_string(), + working_dir: std::path::PathBuf::from("."), + status: ShellStatus::Running, + exit_code: None, + started_at: now, + finished_at: None, + last_output_at: now, + last_observed_output_len: 0, + sandbox_type: SandboxType::None, + ownership: ShellOwnership::Managed, + linked_task_id: None, + owner_agent: None, + origin_tool_call_id: None, + origin_turn_id: None, + stdout_buffer: super::new_shared_raw_output(), + stderr_buffer: None, + bounded_output: None, + heavy_permit: None, + stdout_cursor: 0, + stderr_cursor: 0, + completion_reported: false, + stdin: Some(StdinWriter::PtyFile(full)), + stdin_closed_at_eof: false, + stdin_write_stalled: false, + child: None, + stop_unconfirmed: false, + stdout_thread: None, + stderr_thread: None, + work_lifecycle: None, + lifecycle_seq: 0, + last_lifecycle_status: None, + last_lifecycle_bytes: 0, + }; + + let started = std::time::Instant::now(); + let close_err = shell + .write_stdin("", true) + .expect_err("a close whose own delivery stalls must be refused, not reported Ok"); + assert!( + started.elapsed() >= super::STDIN_WRITE_DEADLINE, + "the fresh-stall close must attempt the bounded delivery first: {:?}", + started.elapsed() + ); + let message = format!("{close_err:#}"); + assert!( + message.starts_with("the stdin close for task fresh-stall-close"), + "the refusal must be the close refusal: {message}" + ); + assert!( + message.contains("did not complete within"), + "the fresh-stall refusal must cite the deadline: {message}" + ); + assert!( + !message.contains("no close can deliver it now"), + "a fresh stall must not take the not-applied skip arm: {message}" + ); + assert!( + !message.contains("stdin writes to task"), + "the close refusal must not be the write-stall gate: {message}" + ); + assert!( + shell.stdin.is_some(), + "the refused close must keep the interact surface attached" + ); + assert!( + shell.stdin_write_stalled, + "the fresh-stall close must mark the stall for later refusals" + ); + assert!( + !shell.stdin_closed_at_eof, + "a stalled delivery must not claim an end-of-file" + ); + + // The follow-up write is refused by the stall gate without another + // bounded wait. + let started = std::time::Instant::now(); + let write_err = shell + .write_stdin("x", false) + .expect_err("a write after a stalled close must be refused"); + assert!( + started.elapsed() < std::time::Duration::from_millis(500), + "the stall flag must refuse the follow-up write without another deadline wait: {:?}", + started.elapsed() + ); + let message = format!("{write_err:#}"); + assert!( + message.contains("stdin writes to task fresh-stall-close are refused"), + "the follow-up refusal must be the stall gate: {message}" + ); + + // Unblock the abandoned worker exactly like the classification pin: a + // bounded reader consumes the queued bytes, the worker's write lands, + // its send fails on the dropped receiver, and the thread exits. The + // record must drop first — it still holds the ORIGINAL pipe write end, + // and until every write end closes the drainer's read never sees EOF, + // so joining before the drop would hang the harness (the classification + // pin avoids this by dropping `full` before its call). + drop(shell); + let drainer = std::thread::spawn(move || { + let mut buf = [0u8; 4096]; + let mut total = 0usize; + while total < 512 * 1024 { + let n = unsafe { libc::read(read_fd, buf.as_mut_ptr().cast(), buf.len()) }; + if n <= 0 { + break; + } + total += n as usize; + } + unsafe { libc::close(read_fd) }; + }); + drainer.join().expect("drain the stalled pipe"); +} + /// An unconfirmed stop keeps its write scope across every coordination /// query — until a later poll reaps the real child and clears the /// uncertainty. The flag models the un-reaped state; the confirmation cycle From 2f54bf8b28287b85061a3f711741e3a6e987dc1b Mon Sep 17 00:00:00 2001 From: asto18089 Date: Thu, 12 Nov 2026 13:35:14 +0800 Subject: [PATCH 04/11] fix(shell): collapse gate close if, honest stdin copy, spawn-abort pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-4 review wave at 8a390dee: - collapse the verifier gate-close if-let pair into a let chain: the upstream all-targets clippy variant (-D warnings, collapsible_if not allowed) reported the verifier.rs block this branch adds as a third collapsible_if; the two remaining reports are the pre-existing base sites (core/engine/turn_loop.rs:5611, tools/subagent/mod.rs:8058) - the stdin schema and the run-cap comment claimed an oversized write "would block the executor"; since the bounded writer, every such write surfaces as the 5-second deadline-stall refusal - the sync-lane payload write now documents that this manager-API lane has neither the inline cap nor the bounded writer's deadline - the closed-stdin refusal and the close_stdin schema name disabled or remapped VEOF next to raw mode as states that consume the EOF bytes as ordinary input - manager close_stdin_pipe returns () — the close cannot fail, so the false Result contract and the ? at the foreground call site are gone - new pin: spawn_aborts_with_run_never_started_context_when_the_payload_ stalls drives the uncapped manager lane against a non-reading child and asserts the run-never-started / never-registered abort copy and the bounded-deadline elapsed bound Gates: tools::shell 175/0/1, tools::verifier 9/0, fmt clean, fork-CI clippy flags clean. Signed-off-by: asto18089 --- crates/tui/src/tools/shell.rs | 42 +++++++++++++---------- crates/tui/src/tools/shell/tests.rs | 53 +++++++++++++++++++++++++++++ crates/tui/src/tools/verifier.rs | 13 ++++--- 3 files changed, 83 insertions(+), 25 deletions(-) diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index e9955e0639..5271a0f7f9 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -1939,7 +1939,7 @@ impl BackgroundShell { if self.stdin_closed_at_eof { return Err(anyhow!( - "stdin is not available for task {}: its stdin was closed, so stdin readers have seen end-of-file (a program that put its terminal into raw mode consumes the EOF byte as ordinary input instead); start input-driven work with background=true and feed it with action=\"interact\"", + "stdin is not available for task {}: its stdin was closed, so stdin readers have seen end-of-file (a program that put its terminal into raw mode — or disabled or remapped VEOF — consumes those bytes as ordinary input instead); start input-driven work with background=true and feed it with action=\"interact\"", self.id )); } @@ -3083,7 +3083,13 @@ impl ShellManager { // Same SIGPIPE discipline as the bounded stdin workers (see // [`BlockedSigpipeGuard`]): the child dying mid-write must // surface as a handled write error, not kill a process that - // runs with SIGPIPE at its default disposition. + // runs with SIGPIPE at its default disposition. Unlike the + // tool surface this manager-API lane has neither + // [`MAX_INLINE_STDIN_BYTES`] nor the bounded writer's + // deadline — every production payload goes through the + // background lane's bounded writer (see + // [`STDIN_WRITE_DEADLINE`]), so a direct caller passing a + // payload here must keep it within what the child reads. let _sigpipe = BlockedSigpipeGuard::new(); stdin .write_all(input.as_bytes()) @@ -3728,16 +3734,15 @@ impl ShellManager { /// PTY writer is invisible to the child (the reader holds the master) /// but destroys the interact surface a session member can still use /// while a terminal command runs — the foreground terminal lane's - /// contract. A missing record is success, not an error: only eviction + /// contract. A missing record is a no-op, not an error: only eviction /// removes a record, and eviction drops the stdin handle with it, so /// the pipe cannot still be open — and the close runs between spawn /// and the first caller-visible step, where an error would abort a run /// whose result is already determined. - fn close_stdin_pipe(&mut self, task_id: &str) -> Result<()> { + fn close_stdin_pipe(&mut self, task_id: &str) { if let Some(shell) = self.processes.get_mut(task_id) { shell.close_stdin_pipe(); } - Ok(()) } pub fn write_stdin_for_session( @@ -8192,7 +8197,7 @@ async fn execute_foreground_via_background( .shell_manager .lock() .map_err(|_| anyhow!("shell manager lock poisoned"))?; - manager.close_stdin_pipe(&task_id)?; + manager.close_stdin_pipe(&task_id); } if let Some(permit) = heavy_permit { let mut manager = context @@ -8664,7 +8669,7 @@ impl ToolSpec for BashTool { "stdin": { "type": "string", "maxLength": 65536, - "description": "Stdin data to send (action=run: before waiting; action=interact: to the background task). Also accepted as `input` or `data` — send only one. Inline payloads are capped at 64 KiB, counted as UTF-8 bytes (maxLength mirrors the same number): a larger write to a command that is not reading would block the executor; for bigger payloads run the command in the background and feed it through `action=interact` in chunks." + "description": "Stdin data to send (action=run: before waiting; action=interact: to the background task). Also accepted as `input` or `data` — send only one. Inline payloads are capped at 64 KiB, counted as UTF-8 bytes (maxLength mirrors the same number): a write to a command that is not reading ends as a bounded deadline-stall refusal instead of blocking the session; for bigger payloads run the command in the background and feed it through `action=interact` in chunks." }, "input": { "type": "string", @@ -8712,7 +8717,7 @@ impl ToolSpec for BashTool { }, "close_stdin": { "type": "boolean", - "description": "Close stdin after sending (action=interact); on a terminal task the close delivers end-of-file, sent as a literal newline followed by the EOF byte — a program that put its terminal into raw mode, or disabled VEOF, receives those bytes as ordinary input instead, and on Windows the close only releases the terminal handle (a console child has no end-of-file to deliver)" + "description": "Close stdin after sending (action=interact); on a terminal task the close delivers end-of-file, sent as a literal newline followed by the EOF byte — a program that put its terminal into raw mode, or disabled or remapped VEOF, receives those bytes as ordinary input instead, and on Windows the close only releases the terminal handle (a console child has no end-of-file to deliver)" }, "all": { "type": "boolean", @@ -8882,16 +8887,17 @@ impl ToolSpec for BashTool { .as_str() .ok_or_else(|| type_mismatch(name, value, "a string"))? .to_string(); - // The spawn delivers the payload with a blocking write while - // the caller holds the shell-manager lock: past the pipe - // buffer a command that never reads would wedge the - // executor thread and every other shell in the session - // before any watchdog is reached. On Linux the cap equals - // the default pipe buffer, so a cap-sized write into an - // empty pipe completes without a reader there; smaller - // default buffers lean on the bounded writer's deadline. - // Bigger payloads belong on the background lane, fed - // through `action=interact` in chunks. + // The tool surface's inline cap keeps an oversized payload + // from ever reaching the writer; the spawn delivers the + // payload through the bounded stdin writer, so a command + // that never reads surfaces as the writer's 5-second + // deadline stall refusal rather than a wedged executor. On + // Linux the cap equals the default pipe buffer, so a + // cap-sized write into an empty pipe completes without a + // reader there; smaller default buffers lean on the same + // deadline on the bounded lanes. Bigger payloads belong on + // the background lane, fed through `action=interact` in + // chunks. if payload.len() > MAX_INLINE_STDIN_BYTES { return Ok(ToolResult::error(format!( "The inline stdin payload is {} bytes; the shell tool accepts at most {MAX_INLINE_STDIN_BYTES} inline — run the command with background=true and feed it through action=interact in chunks.", diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index 73daf0e684..3c8bf17b47 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -10220,6 +10220,59 @@ async fn foreground_without_stdin_data_gives_stdin_readers_eof() { result.content ); } +/// When the inline payload cannot be delivered at spawn, the run must abort +/// with the run-never-started context: no task id exists yet, so the child +/// is killed and the error must say the mentioned task id was never +/// registered — the caller must not try to cancel or kill it. The +/// manager-level lane has no inline cap, so an over-pipe-buffer payload +/// against a non-reading child reaches the bounded writer's deadline here; +/// the tool surface cannot produce this stall on Linux (its 64 KiB cap +/// equals the default pipe buffer), which is why this pin drives the +/// manager API directly. +#[test] +#[cfg(unix)] +fn spawn_aborts_with_run_never_started_context_when_the_payload_stalls() { + let tmp = tempdir().expect("tempdir"); + let mut manager = ShellManager::new(tmp.path().to_path_buf()); + // 2 MiB: far past any default pipe buffer, so the bounded writer's + // deadline is what ends the write. + let payload = "x".repeat(2 * 1024 * 1024); + + let started = Instant::now(); + let err = manager + .execute_with_options_env( + &sleep_command(60), + None, + 60_000, + true, + Some(payload.as_str()), + false, + None, + HashMap::new(), + ) + .expect_err("the spawn must abort when the inline payload stalls"); + let elapsed = started.elapsed(); + + let text = format!("{err:#}"); + assert!( + text.contains("the run never started"), + "error must carry the run-never-started context; got: {text}" + ); + assert!( + text.contains("never registered"), + "error must tell the caller the task id was never registered; got: {text}" + ); + assert!( + !text.contains("Kill the task"), + "the task-targeted recovery copy must not appear on the spawn-abort \ + path; got: {text}" + ); + assert!( + elapsed >= STDIN_WRITE_DEADLINE, + "the abort must come from the bounded writer's deadline, not an \ + instant failure; took {elapsed:?}: {text}" + ); +} #[cfg(unix)] const SYNC_STDIN_EOF_HELPER_ENV: &str = "CODEWHALE_SYNC_STDIN_EOF_HELPER"; #[cfg(unix)] diff --git a/crates/tui/src/tools/verifier.rs b/crates/tui/src/tools/verifier.rs index 4abd1f3c40..ed686eab87 100644 --- a/crates/tui/src/tools/verifier.rs +++ b/crates/tui/src/tools/verifier.rs @@ -588,14 +588,13 @@ fn start_background_gates( .as_ref() .ok() .and_then(|result| result.task_id.as_deref()) + && let Err(error) = manager.write_stdin(task_id, "", true) { - if let Err(error) = manager.write_stdin(task_id, "", true) { - // Unreachable today (the job was just inserted under the - // held lock and the close writes nothing), but a silent - // `.ok()` here would quietly revert gates to idling on - // the open pipe until the background gate timeout. - tracing::warn!(%task_id, %error, "closing the gate job's stdin failed; the gate may idle until its timeout"); - } + // Unreachable today (the job was just inserted under the + // held lock and the close writes nothing), but a silent + // `.ok()` here would quietly revert gates to idling on + // the open pipe until the background gate timeout. + tracing::warn!(%task_id, %error, "closing the gate job's stdin failed; the gate may idle until its timeout"); } spawned }; From bb0acea0a654e4bd1c122d5c89e1b7d3ae12357f Mon Sep 17 00:00:00 2001 From: asto18089 Date: Thu, 12 Nov 2026 13:35:23 +0800 Subject: [PATCH 05/11] fix(shell): correct two stale stdin comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PTY spawn comment said "a stalled close releases it" about the dup'd master fd: no stall path releases it — both stall arms restore the handle into the record (the fresh-stall arm under the stall flag, the not-applied arm verbatim), and it is the Failed delivery arm that releases. The comment now says that. The BlockedSigpipeGuard SAFETY note claimed a failed `pthread_sigmask` leaves "the failure mode ... the pre-guard one". The write-phase half of that claim holds (the write runs at the process default disposition), but the drop's restore would then install the zeroed `previous` mask — an empty `SIG_SETMASK`, leaving this thread's signals unblocked — which is not the pre-guard mode. The comment now describes the real consequence instead of a false reassurance. Comment-only; no behavior change. Gates at this tree: tools::shell 175/0/1, tools::verifier 9/0, `cargo fmt --all -- --check` clean, fork-CI clippy flags clean. Signed-off-by: asto18089 --- crates/tui/src/tools/shell.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index 5271a0f7f9..1fbb69a95e 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -952,9 +952,11 @@ impl BlockedSigpipeGuard { { // SAFETY: `zeroed` is valid for `sigset_t` (an array of // integer words) and both pointers below are local stack - // values; a failed `pthread_sigmask` simply leaves the - // process default disposition and the failure mode is the - // pre-guard one. + // values. A failed `pthread_sigmask` is not expected with + // these local sets; if it did fail, the write would run with + // the process default disposition (the pre-guard behavior) + // and the drop's restore would install the zeroed `previous` + // mask — an empty set, leaving this thread's signals unblocked. unsafe { let mut blocked: libc::sigset_t = std::mem::zeroed(); let mut previous: libc::sigset_t = std::mem::zeroed(); @@ -3425,7 +3427,8 @@ impl ShellManager { // a master-side description open — no EOF for the slave // while the reader's clone is open too. A plain drop of the // dup (the spawn-path close never touches this lane, and a - // stalled close releases it) still shows the child nothing; + // close whose delivery failed releases it) still shows the + // child nothing; // an explicit `close_stdin` instead delivers the ^D bytes // itself through the bounded worker and claims end-of-file // only when that write really landed; the final master From 7a70e454ff39b1f70116ac17c1736153b76b0137 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Thu, 12 Nov 2026 13:35:37 +0800 Subject: [PATCH 06/11] fix(shell): keep the PTY writer dup close-on-exec, honest spawn-abort copy The unix interact writer dup'd the PTY master with a bare libc::dup, which clears FD_CLOEXEC on the new descriptor. The dup lives for the terminal task's whole lifetime, so every command spawned meanwhile (other shell tasks, verifier gate jobs, sync commands) inherited a live master-side fd aliasing that task's terminal: a child could write into it (inject input), read from it (capture output), or hold it past the task's teardown (delaying slave EIO). portable-pty's own descriptors and the take_writer box this replaces are all cloexec'd, so base leaked nothing. Duplicate with F_DUPFD_CLOEXEC instead and pin the invariant two ways: the flag is set on the dup, and a spawned child's fd table carries no descriptor for the pipe (matched by kernel inode, not fd number, so parallel tests cannot collide). Also reword the payload-delivery spawn abort: the process WAS created before the kill, so the error no longer claims the run never started. Requested by review on #139 (P2: resource ownership/isolation across launches; follow-up: spawn-abort copy). --- crates/tui/src/tools/shell.rs | 44 +++++++++--- crates/tui/src/tools/shell/tests.rs | 100 +++++++++++++++++++++++++++- 2 files changed, 132 insertions(+), 12 deletions(-) diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index 1fbb69a95e..62640f8b59 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -1111,6 +1111,26 @@ fn bounded_stdin_write_owned( } } +/// Duplicate `fd` with the close-on-exec flag set (`F_DUPFD_CLOEXEC`). +/// +/// The dup'd PTY master stays in this process for a task's whole lifetime, +/// so it must behave like every other handle this crate holds for the task: +/// invisible to children spawned meanwhile. portable-pty opens the pty with +/// `FD_CLOEXEC` and its own reader/writer clones re-apply it +/// (`F_DUPFD_CLOEXEC`), but a bare `libc::dup` CLEARS the flag on the new +/// descriptor — every command spawned while a terminal task runs would +/// inherit a live master-side fd aliasing that task's terminal, so a child +/// could write into (inject input to) or read (capture output from) another +/// task's terminal and keep the master alive past the task's teardown. +#[cfg(all(unix, not(target_env = "ohos")))] +fn dup_cloexec(fd: std::os::fd::RawFd) -> std::io::Result { + let dup_fd = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) }; + if dup_fd < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(dup_fd) +} + fn spawn_reader_thread( mut reader: R, buffer: SharedRawOutput, @@ -3433,19 +3453,23 @@ impl ShellManager { // itself through the bounded worker and claims end-of-file // only when that write really landed; the final master // handle closes at teardown, after the child can no longer - // observe it. + // observe it. The dup is CLOEXEC (`dup_cloexec`): children + // spawned while the task runs must not inherit a master-side + // fd, matching the `take_writer` box it replaces and + // portable-pty's own cloexec'd descriptors. #[cfg(unix)] let stdin_writer = { use std::os::fd::FromRawFd; match pair.master.as_raw_fd() { Some(fd) => { - let dup_fd = unsafe { libc::dup(fd) }; - if dup_fd < 0 { - let _ = child.kill(); - let _ = child.wait(); - return Err(std::io::Error::last_os_error()) - .context("Failed to dup the PTY master for the stdin writer"); - } + let dup_fd = dup_cloexec(fd) + .inspect_err(|_| { + let _ = child.kill(); + let _ = child.wait(); + }) + .with_context( + || "Failed to dup the PTY master for the stdin writer", + )?; // SAFETY: `dup_fd` is a freshly duplicated fd owned // by this call; `File::from_raw_fd` takes ownership. Some(StdinWriter::PtyFile(unsafe { @@ -3629,10 +3653,10 @@ impl ShellManager { // No task id exists yet — the record is only registered below — // so `write_stdin`'s "kill it and start a new one" recovery copy // has no target here. The spawn aborts instead: the child is - // killed and the caller learns the run never started. + // killed and the caller learns the run was ended at spawn. let _ = bg_shell.kill(); return Err(err.context( - "the run never started: delivering the inline stdin payload failed, so the command was killed and NO task was created (the task id the error above mentions was never registered — do not try to cancel or kill it)", + "the run was killed at spawn: the process was created, but delivering the inline stdin payload failed, so the command was killed before it could do any work and NO task was created (the task id the error above mentions was never registered — do not try to cancel or kill it)", )); } diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index 3c8bf17b47..b9c6e76ad1 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -10021,6 +10021,97 @@ fn sigpipe_guard_survives_a_broken_pipe_restore() { let _ = std::fs::remove_file(&canary); } +/// The PTY interact writer dup must carry close-on-exec: it stays open in +/// this process for a terminal task's whole lifetime, so a bare `dup` +/// (which CLEARS `FD_CLOEXEC`) would leak a live master-side terminal fd +/// into every command spawned while the task runs — a child could inject +/// input into or capture output from another task's terminal, and hold the +/// master open past the task's teardown. portable-pty's own descriptors are +/// cloexec'd; this pin holds the dup to the same invariant, and to the +/// same-description write semantics the writer relies on. +#[cfg(all(unix, not(target_env = "ohos")))] +#[test] +fn pty_writer_dup_carries_close_on_exec() { + use std::io::{Read, Write}; + use std::os::fd::{AsRawFd, FromRawFd}; + + let mut fds = [0 as libc::c_int; 2]; + assert_eq!( + unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) }, + 0, + "pipe2() failed" + ); + let mut reader = unsafe { std::fs::File::from_raw_fd(fds[0]) }; + let mut writer = unsafe { std::fs::File::from_raw_fd(fds[1]) }; + let dup_fd = dup_cloexec(writer.as_raw_fd()).expect("dup_cloexec"); + let flags = unsafe { libc::fcntl(dup_fd, libc::F_GETFD) }; + assert!(flags >= 0, "F_GETFD on the dup failed: {flags}"); + assert!( + flags & libc::FD_CLOEXEC != 0, + "the PTY writer dup must set FD_CLOEXEC (a bare libc::dup clears it): flags={flags}" + ); + + // Same open description as the source: bytes written through the dup + // surface on the reader end, and the task keeps writing after the + // original handle is gone (both write ends must close before EOF). + let mut dup_file = unsafe { std::fs::File::from_raw_fd(dup_fd) }; + dup_file + .write_all(b"cloexec-") + .and_then(|_| writer.write_all(b"dup")) + .expect("write through the dup and the original"); + drop(writer); + drop(dup_file); + let mut rest = Vec::new(); + reader.read_to_end(&mut rest).expect("read through the dup"); + assert_eq!(rest, b"cloexec-dup", "bytes written via the dup must land"); +} + +/// Behavioral half of `pty_writer_dup_carries_close_on_exec`: a command +/// spawned after the dup must not see the duplicated descriptor in its fd +/// table (Linux `/proc` pin; the cloexec assert above holds the same +/// invariant on every unix target). +#[cfg(all(target_os = "linux", not(target_env = "ohos")))] +#[test] +fn spawned_child_does_not_inherit_the_pty_writer_dup() { + use std::os::fd::{AsRawFd, FromRawFd}; + + let mut fds = [0 as libc::c_int; 2]; + assert_eq!( + unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) }, + 0, + "pipe2() failed" + ); + let reader = unsafe { std::fs::File::from_raw_fd(fds[0]) }; + let writer = unsafe { std::fs::File::from_raw_fd(fds[1]) }; + let dup_fd = dup_cloexec(writer.as_raw_fd()).expect("dup_cloexec"); + let dup_file = unsafe { std::fs::File::from_raw_fd(dup_fd) }; + + // Identify OUR pipe by kernel inode, not by fd number: a parallel test + // may legally hold (and leak) unrelated fds with colliding numbers. + let mut stat: libc::stat = unsafe { std::mem::zeroed() }; + assert_eq!( + unsafe { libc::fstat(writer.as_raw_fd(), &mut stat) }, + 0, + "fstat on the pipe write end failed" + ); + let pipe_target = format!("pipe:[{}]", stat.st_ino); + + let output = Command::new("sh") + .arg("-c") + .arg("for f in /proc/self/fd/*; do readlink \"$f\"; done") + .stdin(Stdio::null()) + .output() + .expect("spawn fd-listing child"); + let listing = String::from_utf8_lossy(&output.stdout); + assert!( + !listing.lines().any(|line| line.trim() == pipe_target), + "spawned child must not inherit the dup'd descriptor ({pipe_target}); its fd targets:\n{listing}" + ); + drop(dup_file); + drop(writer); + drop(reader); +} + /// The bounded stdin write: a command that never consumes its pipe fills /// it, and past [`STDIN_WRITE_DEADLINE`] the write errors and the task's /// stdin is marked stalled instead of holding the shell-manager lock (and @@ -10255,8 +10346,13 @@ fn spawn_aborts_with_run_never_started_context_when_the_payload_stalls() { let text = format!("{err:#}"); assert!( - text.contains("the run never started"), - "error must carry the run-never-started context; got: {text}" + text.contains("the run was killed at spawn"), + "error must carry the killed-at-spawn context (the process WAS created, \ + so the copy must not claim the run never started); got: {text}" + ); + assert!( + text.contains("the command was killed before it could do any work"), + "error must state the process was created and then killed; got: {text}" ); assert!( text.contains("never registered"), From b8e99269e2eb05327c79a4bb9d4d9a57b97132c1 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Thu, 12 Nov 2026 13:36:08 +0800 Subject: [PATCH 07/11] fix(fleet): the test role states its real contract Third slice of the #126 split: the `test` role's honest surface. - The roster description, the type schema, VERIFIER_AGENT_INTRO, and both SUBAGENTS role tables state the real contract: built-in verification gates with test-selection arguments, plus the classifier-bounded read-only shell for evidence gathering; custom commands stay refused. - The Verifier joins `role_requires_read_only_shell` and `allows_bounded_readonly_bash` (the latter now reads that one predicate), gaining the same bounded probe lane as the inspection roles; the carve-out is Verifier-only and keys on a recorded parent-shell grant, so a read-only-parent's child never regains authority through the role name alone (chain pins cover the sub-subagent path). - Shell authority (`holds_shell_authority`) keys on the held policy plus the grant sentinel; fleet receipts report the verifier's effective shell as `read_only`; the worker catalog omits bash from envelopes without read-only shell authority and pins the refusal copy's load-bearing claims. Tests: verifier contract/authority/carve-out pins incl. the sub-subagent chain, catalog and receipts labels; the deleted no-shell verifier catalog test is replaced by the probe-lane variant. Targeted: `tools::subagent::` + `tools::registry::` + `fleet::` suites green; tree of this commit equals the reviewed Pinvou/CodeWhale#126 (stacks on fix/live-test-findings-subagent-shell after fix/shell-stdin-eof); full content preserved on archive/p126-pre-split-8705ccd85. Signed-off-by: asto18089 --- crates/tui/src/fleet/executor.rs | 5 +- crates/tui/src/fleet/manager.rs | 8 +- crates/tui/src/fleet/role.rs | 37 +- crates/tui/src/fleet/worker_runtime.rs | 7 +- crates/tui/src/tools/registry.rs | 11 +- crates/tui/src/tools/registry/tests.rs | 56 ++ crates/tui/src/tools/subagent/mod.rs | 100 ++- crates/tui/src/tools/subagent/tests.rs | 955 ++++++++++++++++++++++++- docs/SUBAGENTS.md | 25 +- docs/zh_hans/FLEET.md | 2 +- docs/zh_hans/SUBAGENTS.md | 8 +- 11 files changed, 1148 insertions(+), 66 deletions(-) diff --git a/crates/tui/src/fleet/executor.rs b/crates/tui/src/fleet/executor.rs index a508e8545e..a82ee4eacc 100644 --- a/crates/tui/src/fleet/executor.rs +++ b/crates/tui/src/fleet/executor.rs @@ -243,7 +243,10 @@ pub(crate) fn authority_envelope_for_worker( && spec.runtime_profile.shell.allows_shell() { // Scout, Reviewer, and Planner get one classifier-bounded - // foreground shell. Verifiers keep the dedicated Run surface, + // foreground shell. On this executor path the Verifier keeps only + // the dedicated Run surface below (the in-process registry + // additionally admits it to the classifier-bounded `bash` evidence + // lane — see `SubAgentToolRegistry::allows_bounded_readonly_bash`), // consultants remain shell-less, and writers retain the historical // subprocess contract. ToolShellAuthority::ReadOnly diff --git a/crates/tui/src/fleet/manager.rs b/crates/tui/src/fleet/manager.rs index ec3257002e..35a3ae2f23 100644 --- a/crates/tui/src/fleet/manager.rs +++ b/crates/tui/src/fleet/manager.rs @@ -4884,7 +4884,13 @@ esac !permissions.write, "verifier receipt {key} must stay read-only" ); - assert_eq!(permissions.shell, "full"); + // The verifier joined the read-only-shell roles: its bash + // is the classifier-bounded probe lane, and the receipt + // records that effective posture (the old `full` label + // overclaimed a raw shell the clamp always denied). The + // built-in verification surface stays keyed on the shell + // ceiling, not on this label. + assert_eq!(permissions.shell, "read_only"); } role => panic!("unexpected receipt role for {key}: {role:?}"), } diff --git a/crates/tui/src/fleet/role.rs b/crates/tui/src/fleet/role.rs index fe729af6b7..79e3bd47d4 100644 --- a/crates/tui/src/fleet/role.rs +++ b/crates/tui/src/fleet/role.rs @@ -193,7 +193,7 @@ impl FleetRole { "Lands focused code changes cleanly with the minimum surrounding edit." } Self::Verifier => { - "Runs the test suite and validation gates, reports pass/fail with evidence." + "Runs the built-in verification gates with test-selection arguments, plus — for in-session delegations — a bounded read-only shell for evidence gathering; cannot execute custom commands — use a write-capable role for those." } Self::Consultant => { "Read-only high-reasoning counsel for judgement calls and design critique." @@ -350,9 +350,11 @@ pub(crate) const MUTATING_TOOL_DENYLIST: &[&str] = &[ /// receipt says `write=false`. Denying the raw shell entries and leaving the /// bounded verification surface (`Run` / `run_tests` / `run_verifiers`) intact /// keeps the verifier able to do its job under a contract that is true. -/// Scout/reviewer read-only inspection selectively removes only canonical `Bash` from this -/// deny list after the role is known; its input-specific read-only classifier -/// remains the authority for that narrow exception. +/// Scout/reviewer/planner read-only inspection selectively removes only canonical `Bash` from this +/// deny list after the role is known (`from_runtime_role`'s retain); a verifier's +/// classifier-bounded `bash` is admitted separately by the subagent registry's +/// probe lane (`allows_bounded_readonly_bash`), not by that retain; the +/// input-specific read-only classifier remains the authority for both narrow exceptions. /// /// That surface is bounded only in its **default** form, and the distinction is /// load-bearing: `run_verifiers` accepts a `commands` array of arbitrary @@ -514,8 +516,10 @@ impl ChildAuthority { // Raw shell requires the ceiling to *say* `shell = "full"`. Any narrower // shell posture — `none` or `read_only` — loses the raw command surface // outright. `from_runtime_role` may subsequently - // retain canonical `Bash` for a named scout/reviewer, whose concrete - // calls are bounded by the strict read-only classifier. + // retain canonical `Bash` for the Scout/Reviewer/Planner trio, whose concrete + // calls are bounded by the strict read-only classifier; a Verifier's probe + // `bash` is admitted by the registry lane instead, without touching this + // deny list. // // This is deliberately keyed on the shell field rather than only on // `write`, and that is the whole repair: the execution envelope reads @@ -644,9 +648,13 @@ impl ChildAuthority { FleetRole::Scout | FleetRole::Reviewer | FleetRole::Planner ) && authority.ceiling.shell != ShellCeiling::None { - // Runtime's Scout/Reviewer policy permits classifier-bounded Bash - // inspection. Keep the canonical entry while all other shell and - // execution aliases remain denied. + // Runtime's Scout/Reviewer/Planner policy permits classifier-bounded + // Bash inspection. Keep the canonical entry while all other shell and + // execution aliases remain denied. This literal is the one role set + // that intentionally differs from `role_requires_read_only_shell` + // (which adds Verifier): a Verifier's probe `bash` is admitted over + // the deny by the registry lane, not by this retain, so changing + // either side means changing the member fingerprint or dropping a lane. authority .disallowed_tools .retain(|name| !name.eq_ignore_ascii_case("Bash")); @@ -715,11 +723,20 @@ pub(crate) fn posture_role_for(ceiling: PermissionCeiling) -> &'static str { /// Whether a Fleet role is never allowed a mutating shell, whatever its /// requested runtime profile says. Spawn narrows the child to a read-only /// shell for these roles, and every receipt must report that same posture. +/// +/// The narrowing downgrades only the raw-command policy: canonical `bash` +/// becomes the classifier-bounded probe lane, while the bounded built-in +/// verification surface keeps keying on the role's shell *ceiling* and the +/// shell-authority sentinel — the `test` role keeps both, because running the +/// workspace's own gates is its entire purpose. "Keeps both" describes the +/// in-session registry path; the fleet executor envelope gives the Verifier +/// only the bounded verification surface (`shell = none`, no probe lane) by +/// design. #[must_use] pub(crate) fn role_requires_read_only_shell(role: &FleetRole) -> bool { matches!( role, - FleetRole::Scout | FleetRole::Reviewer | FleetRole::Planner + FleetRole::Scout | FleetRole::Reviewer | FleetRole::Planner | FleetRole::Verifier ) } diff --git a/crates/tui/src/fleet/worker_runtime.rs b/crates/tui/src/fleet/worker_runtime.rs index 6d86a1c8e9..e947b5cfea 100644 --- a/crates/tui/src/fleet/worker_runtime.rs +++ b/crates/tui/src/fleet/worker_runtime.rs @@ -1600,7 +1600,12 @@ mod tests { ..WorkerRuntimeProfile::default() }; - for role in [FleetRole::Scout, FleetRole::Reviewer, FleetRole::Planner] { + for role in [ + FleetRole::Scout, + FleetRole::Reviewer, + FleetRole::Planner, + FleetRole::Verifier, + ] { let effective = role::effective_runtime_profile_for_role(&role, &requested); assert_eq!(effective.shell, ShellPolicy::ReadOnly, "{role:?}"); assert_eq!( diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index df854b680c..17130b5066 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -263,7 +263,16 @@ impl ToolRegistry { .filter(|tool| tool.model_visible()) .filter(|tool| { read_only_authority.is_none_or(|authority| { - readonly_evidence_tool(tool.as_ref()) + // The classifier-bounded probe lane is an evidence tool, + // but a worker whose envelope grants no shell authority + // refuses every bash call at dispatch + // (`enforce_tool_authority` demands + // `ToolShellAuthority::ReadOnly`); listing it would + // advertise a surface the worker can never use. + let evidence = readonly_evidence_tool(tool.as_ref()) + && !(matches!(tool.name(), "bash" | "Bash") + && authority.shell != super::spec::ToolShellAuthority::ReadOnly); + evidence || (tool.name() == "Run" && authority.verification == super::spec::ToolVerificationAuthority::Bounded) diff --git a/crates/tui/src/tools/registry/tests.rs b/crates/tui/src/tools/registry/tests.rs index a56963ee52..abd677b868 100644 --- a/crates/tui/src/tools/registry/tests.rs +++ b/crates/tui/src/tools/registry/tests.rs @@ -711,6 +711,38 @@ fn readonly_verifier_context(workspace: &std::path::Path) -> ToolContext { .expect("bounded verifier authority") } +/// A worker whose envelope grants no shell authority refuses every bash call +/// at dispatch (`enforce_tool_authority` demands `ToolShellAuthority::ReadOnly` +/// for bash/Bash), so the evidence catalog must not advertise the lane it can +/// never use — while a worker whose envelope DOES grant read-only shell keeps +/// the classifier-bounded probe lane. +#[test] +fn machine_catalog_omits_bash_without_shell_authority() { + let tmp = tempdir().expect("tempdir"); + let shell_less = ToolRegistryBuilder::new() + .with_read_only_shell_tool() + .build(readonly_verifier_context(tmp.path())); + let shell_less_tools = shell_less.to_api_tools(); + let names: Vec<&str> = shell_less_tools + .iter() + .map(|tool| tool.name.as_str()) + .collect(); + assert!( + !names.contains(&"bash") && !names.contains(&"Bash"), + "a shell-less verifier catalog must not list the bash lane: {names:?}" + ); + // The twin: a read-only-shell envelope keeps the lane. + let scoped = ToolRegistryBuilder::new() + .with_read_only_shell_tool() + .build(readonly_scout_context(tmp.path(), true)); + let scoped_tools = scoped.to_api_tools(); + let names: Vec<&str> = scoped_tools.iter().map(|tool| tool.name.as_str()).collect(); + assert!( + names.contains(&"bash"), + "a read-only-shell scout catalog keeps the classified probe lane: {names:?}" + ); +} + #[test] fn machine_verifier_catalog_and_dispatch_add_only_bounded_run() { let tmp = tempdir().expect("tempdir"); @@ -917,6 +949,30 @@ fn fleet_authority_intersects_readonly_github_bash_with_network_ceiling() { .expect_err("network denial must win") .to_string(); assert!(error.contains("does not grant network access"), "{error}"); + // The envelope admits Bash through the parallel classifier, which refuses + // compounds and globs outright, so a gh read inside one fails closed + // before the per-stage network predicate is consulted — with or without + // a network grant. Compound probes are an in-session `agent` capability, + // not a fleet one. + for command in [ + "pwd; gh repo view", + "gh pr list --search '*'", + "gh repo view | head -n 3", + ] { + let compound = json!({"action": "run", "command": command}); + for context in [&offline, &networked] { + let error = enforce_tool_authority("Bash", &compound, &shell, context) + .expect_err("a compound gh read fails closed under the fleet envelope") + .to_string(); + // The wall that fires is the parallel classifier's (compounds + // are never parallel-admitted), not the network ceiling: name it + // so a refusal from any other wall cannot green this leg. + assert!( + error.contains("outside its machine-readable authority envelope"), + "{command}: {error}" + ); + } + } } #[tokio::test] diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 3abcf7e7db..222ca0af99 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -404,7 +404,7 @@ fn subagent_perf_enabled() -> bool { const VALID_SUBAGENT_TYPES: &str = "general, explore, planner, reviewer, implement, test, advisor, custom \ (legacy aliases remain accepted: worker, scout, builder, verifier, consultant, general-purpose, general_purpose, default, exploration/explorer, plan/planning/awaiter, review/code-review/code_review, implementer/implementation, verify/verification/validator/tester, oracle)"; -const SUBAGENT_TYPE_DESCRIPTION: &str = "Fleet role for this delegated worker. general: full tool access for multi-step tasks; write-capable, so it claims the parent workspace unless write_roots narrows it — parallel generals without disjoint write_roots contend, so prefer a read-only role for research fan-outs. explore: fast read-only exploration. planner: grounded strategy with read-only probes. reviewer: reads and grades code. implement: lands focused code changes; write-capable under the same workspace-claim rules as general, so parallel implements need disjoint write_roots or worktree=true. test: runs tests/validation gates and reports evidence. advisor: read-only high-reasoning counsel for judgement calls and design critique. custom: the tools listed in allowed_tools on the parent's posture; without an explicit write authority it starts read-only and never contends. Legacy aliases remain accepted at deserialization boundaries."; +const SUBAGENT_TYPE_DESCRIPTION: &str = "Fleet role for this delegated worker. general: full tool access for multi-step tasks; write-capable, so it claims the parent workspace unless write_roots narrows it — parallel generals without disjoint write_roots contend, so prefer a read-only role for research fan-outs. explore: fast read-only exploration. planner: grounded strategy with read-only probes. reviewer: reads and grades code. implement: lands focused code changes; write-capable under the same workspace-claim rules as general, so parallel implements need disjoint write_roots or worktree=true. test: runs the built-in verification gates and reports evidence, with a bounded read-only shell — for in-session delegations — for gathering it; custom commands are refused. advisor: read-only high-reasoning counsel for judgement calls and design critique. custom: the tools listed in allowed_tools on the parent's posture; without an explicit write authority it starts read-only and never contends. Legacy aliases remain accepted at deserialization boundaries."; // === Types === @@ -15636,6 +15636,14 @@ struct SubAgentToolRegistry { accept_edits: bool, agent_type: FleetRole, runtime_profile: WorkerRuntimeProfile, + /// Whether the child's requested shell was a full shell *after the parent + /// clamp but before the role's own narrowing* — i.e. the read-only shell + /// it now runs under came from its role's own narrowing, not from a + /// parent that itself held no shell authority. Keys the Verifier's + /// verification-surface carve-out in [`Self::holds_shell_authority`] so a + /// read-only role parent cannot obtain gate execution it never had by + /// delegating to `test`. + parent_shell_authority: bool, // Depth is the only special authority governing nested `agent` calls. can_spawn_child: bool, // Every mutation is attributed to the child and checked against its live @@ -15711,6 +15719,12 @@ impl SubAgentToolRegistry { // has a full shell. let parent_shell = ShellPolicy::from_legacy_allow_shell(runtime.allow_shell); let mut child_shell = runtime.worker_profile.shell.min_with(parent_shell); + // Capture the grant before the role narrowing below: a Full shell + // here means the parent itself could run processes, so the role's + // read-only downgrade is the child's own posture. A clamped + // (read-only-parent) child must not regain authority through a + // role carve-out it merely shares the name of. + let parent_shell_authority = matches!(child_shell, ShellPolicy::Full); if crate::fleet::role::role_requires_read_only_shell(&agent_type) && child_shell.allows_shell() { @@ -15756,6 +15770,7 @@ impl SubAgentToolRegistry { accept_edits: runtime.accept_edits, agent_type, runtime_profile: effective_profile, + parent_shell_authority, can_spawn_child, owner_agent_id, owner_agent_name, @@ -16373,8 +16388,18 @@ impl SubAgentToolRegistry { { return true; } - matches!(self.runtime_profile.shell, ShellPolicy::Full) - && role_posture_permits(&self.agent_type, ApprovalRequirement::Required) + // Canonical `bash` on a lane role is deliberately NOT + // covered by the authority check below: the classifier + // carve-out above is its only door, so a command the + // grammar refuses keeps the `[shell.readonly.command]` + // refusal instead of being waved through to the + // envelope. The check is case-aware because legacy + // `Bash` is the raw-shell alias — the carve-out is + // exact-name `bash` only, and the alias must not + // re-enter through this branch. + let bash_must_use_classifier_lane = name.eq_ignore_ascii_case("bash") + && self.allows_bounded_readonly_bash("bash"); + !bash_must_use_classifier_lane && self.holds_shell_authority() } }, None => true, @@ -16391,9 +16416,11 @@ impl SubAgentToolRegistry { /// Whether this child may surface and dispatch the canonical lowercase /// `bash` tool under the read-only inspection posture. /// - /// Scout, Reviewer, and Planner keep exactly one shell entry point — - /// canonical `bash` — whose concrete calls the strict read-only - /// classifier bounds. + /// Scout, Reviewer, Planner, and Verifier keep exactly one shell entry + /// point — canonical `bash` — whose concrete calls the strict read-only + /// classifier bounds. For the Verifier this is the evidence lane: running + /// the built-in gates is only half the role, and "pass/fail with + /// evidence" needs probes like `wc -c` the raw surface can never allow. /// Catalog visibility and dispatch authorization consult this same /// predicate, so a tool that appears on the wire can always be called and /// one that is denied never appears. @@ -16413,10 +16440,7 @@ impl SubAgentToolRegistry { /// first-turn catalog actually offers is bounded by the classifier. fn allows_bounded_readonly_bash(&self, name: &str) -> bool { name == "bash" - && matches!( - &self.agent_type, - FleetRole::Scout | FleetRole::Reviewer | FleetRole::Planner - ) + && crate::fleet::role::role_requires_read_only_shell(&self.agent_type) && self.runtime_profile.shell != crate::worker_profile::ShellPolicy::None } @@ -16474,8 +16498,10 @@ impl SubAgentToolRegistry { fn role_blocks_unhardened_process_tool(&self, name: &str) -> bool { // Catalog filtering is defense in depth. Scout/Reviewer see only the - // hardened evidence profile; Verifier may receive bounded Run but not - // any raw or session-oriented shell path. Dispatch repeats this check. + // hardened evidence profile; Verifier may receive bounded Run and the + // same classifier-bounded canonical `bash` lane as the inspection + // roles, but not any raw or session-oriented shell path. Dispatch + // repeats this check. let lower = name.to_ascii_lowercase(); let evidence_tool = crate::tools::registry::readonly_evidence_tool_name(name) || self @@ -16485,14 +16511,19 @@ impl SubAgentToolRegistry { let bounded_inspection = matches!(&self.agent_type, FleetRole::Scout | FleetRole::Reviewer) && lower != "agent" && !evidence_tool; - let raw_shell = lower == "bash" - || lower.starts_with("exec_shell") + let raw_shell = lower.starts_with("exec_shell") || matches!( lower.as_str(), "exec_wait" | "exec_interact" | "task_shell_start" | "task_shell_wait" ) || lower.starts_with("terminal/"); - bounded_inspection || matches!(&self.agent_type, FleetRole::Verifier) && raw_shell + // For a verifier, canonical `bash` is hardened exactly when the + // bounded read-only lane admits it (`allows_bounded_readonly_bash`); + // every other raw-shell spelling — and legacy `Bash`, which fails the + // lane's exact-name check — stays unhardened and blocked. + let verifier_shell = matches!(&self.agent_type, FleetRole::Verifier) + && (raw_shell || (lower == "bash" && !self.allows_bounded_readonly_bash(name))); + bounded_inspection || verifier_shell } fn network_is_denied(&self) -> bool { @@ -16505,12 +16536,33 @@ impl SubAgentToolRegistry { !self.runtime_profile.permissions.write } + /// Shell *authority* — the right to start the processes the built-in + /// verification surface runs. A full runtime shell holds it. The one + /// narrowed exception is the Verifier whose *parent granted a full + /// shell* (`parent_shell_authority`): `role_requires_read_only_shell` + /// downgrades the Verifier's raw-command policy to the classifier-bounded + /// probe lane, and running the workspace's own gates is the role's entire + /// purpose, so that role-own downgrade must not take the gates away + /// (`reject_unbounded_verification` and the envelope still bound every + /// concrete call). The grant cannot travel through delegation: a Verifier + /// child of a read-only role parent was clamped by that parent, holds no + /// authority of its own, and stays refused — delegation moves work, never + /// authority. No other role regains authority through a narrowed shell: a + /// Builder or Custom child of a read-only parent inherits + /// `ShellPolicy::ReadOnly` and must not start the test runner its parent + /// cannot — keying this on the role's *default* posture would hand + /// exactly that to every role whose default shell is full. + fn holds_shell_authority(&self) -> bool { + matches!(self.runtime_profile.shell, ShellPolicy::Full) + || (self.parent_shell_authority + && matches!(self.agent_type, FleetRole::Verifier) + && !matches!(self.runtime_profile.shell, ShellPolicy::None)) + } + fn shell_is_denied(&self) -> bool { - // Likewise, shell requires both a Full profile and no explicit shell - // sentinel. Read-only evidence commands are Auto-classified exceptions, - // not a Full-shell grant. - !matches!(self.runtime_profile.shell, ShellPolicy::Full) - || self.is_tool_denied(SHELL_AUTHORITY_SENTINEL) + // Read-only evidence commands are Auto-classified exceptions, not a + // shell-authority grant; the explicit shell sentinel always wins. + !self.holds_shell_authority() || self.is_tool_denied(SHELL_AUTHORITY_SENTINEL) } fn execution_envelope(&self) -> crate::tools::execution_envelope::ExecutionEnvelope { @@ -16609,10 +16661,9 @@ impl SubAgentToolRegistry { // Visibility and dispatch consult the same capability guard. These // representative calls let a read-only bash schema survive catalog // shaping without treating an empty input as arbitrary shell authority. - if !matches!( - &self.agent_type, - FleetRole::Scout | FleetRole::Reviewer | FleetRole::Planner - ) { + // The role set is the one `allows_bounded_readonly_bash` admits: the + // read-only-shell roles, including the verifier's evidence lane. + if !crate::fleet::role::role_requires_read_only_shell(&self.agent_type) { return None; } match name { @@ -17831,6 +17882,7 @@ const CONSULTANT_AGENT_INTRO: &str = concat!( const VERIFIER_AGENT_INTRO: &str = concat!( "You are a trusted Fleet test agent (role: `test`). Your job is to run the requested gates with your bounded validation tools — the allowed test/check selections — and report results. You never write: patching the workspace is denied. Unbounded shell forms are refused; use the verification surface.\n", + "When the session grants it — for in-session delegations, on envelopes that carry the probe lane — you also hold a bounded read-only shell, under the same classifier-gated grammar as the read-only roles, for gathering evidence with probes such as `wc`, `ls`, `cat`, and `grep` pipelines; envelopes without it omit the shell entirely and shell attempts refuse, and custom commands are refused.\n", "Report PASS/FAIL/FLAKY at the top of SUMMARY with exact command evidence.\n", "Capture failing assertion and file:line; put obvious fixes under RISKS.\n", "You may use more tool calls than quick exploration, but stop after decisive pass/fail evidence.\n", diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 659e8d9bab..2596f6451d 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -9,8 +9,10 @@ use std::process::Command; use std::sync::atomic::{AtomicUsize, Ordering}; use tempfile::{Builder as TempDirBuilder, tempdir}; mod identity_selection; + mod launch_receipt; mod operation_identity; + fn built_in_whale_name_that_cannot_be_generated_for(agent_id: &str) -> &'static str { WHALE_NICKNAMES .iter() @@ -31,6 +33,7 @@ fn built_in_whale_name_that_cannot_be_generated_for(agent_id: &str) -> &'static .find(|name| generated_whale_name_base(agent_id, name).is_none()) .expect("the combined pools contain labels not generated for one id") } + #[test] fn generated_whale_names_follow_session_language_without_mixing() { let localized_pools: &[(&str, &[&str])] = &[ @@ -80,6 +83,7 @@ fn generated_whale_names_follow_session_language_without_mixing() { whale_name_for_id_in_locale("fallback", "en") ); } + #[test] fn locale_matched_whale_collision_suffix_stays_in_language() { let id = "agent_locale_collision"; @@ -91,6 +95,7 @@ fn locale_matched_whale_collision_suffix_stays_in_language() { assert!(unique.starts_with(&base)); assert!(!unique.is_ascii()); } + #[test] fn localized_whale_displays_rederive_legacy_names_from_neutral_ids() { let generated_a = whale_name_for_id_in_locale("agent_english_a", "zh-Hans"); @@ -133,6 +138,7 @@ fn localized_whale_displays_rederive_legacy_names_from_neutral_ids() { "a built-in whale word belonging to another id remains user-owned" ); } + #[test] fn exact_deterministic_whale_match_remains_generated_without_provenance() { let agent_id = "agent_ambiguous_whale"; @@ -149,9 +155,11 @@ fn exact_deterministic_whale_match_remains_generated_without_provenance() { "a collision suffix remains presentation-only" ); } + fn make_assignment() -> SubAgentAssignment { SubAgentAssignment::new("prompt".to_string(), Some("worker".to_string())) } + fn make_snapshot(status: SubAgentStatus) -> SubAgentResult { SubAgentResult { name: "agent_test".to_string(), @@ -179,6 +187,7 @@ fn make_snapshot(status: SubAgentStatus) -> SubAgentResult { from_prior_session: false, } } + #[test] fn forkguard_interrupted_snapshot_preserves_checkpoint_recovery() { let _spill = crate::tools::truncate::TEST_SPILLOVER_GUARD @@ -248,6 +257,7 @@ fn forkguard_interrupted_snapshot_preserves_checkpoint_recovery() { } }); } + fn make_worker_spec(worker_id: &str, workspace: PathBuf) -> AgentWorkerSpec { let tool_profile = AgentWorkerToolProfile::Explicit(vec!["read_file".to_string(), "grep_files".to_string()]); @@ -278,6 +288,7 @@ fn make_worker_spec(worker_id: &str, workspace: PathBuf) -> AgentWorkerSpec { launch_manifest: None, } } + fn make_write_worker_spec(worker_id: &str, workspace: PathBuf, root: &str) -> AgentWorkerSpec { let mut spec = make_worker_spec(worker_id, workspace.clone()); spec.agent_type = FleetRole::Builder; @@ -302,6 +313,7 @@ fn make_write_worker_spec(worker_id: &str, workspace: PathBuf, root: &str) -> Ag }); spec } + #[test] fn child_approval_ids_stay_unique_within_and_across_manager_boots() { // #5615: the per-manager sequence restarts at zero, so a resumed agent @@ -342,6 +354,7 @@ fn child_approval_ids_stay_unique_within_and_across_manager_boots() { ); } } + #[test] fn active_worker_records_are_never_pruned_by_history_retention() { let tmp = tempdir().expect("tempdir"); @@ -365,6 +378,7 @@ fn active_worker_records_are_never_pruned_by_history_retention() { .all(|record| !record.status.is_terminal()) ); } + #[test] fn headless_worker_record_tracks_lifecycle_without_tui_projection() { let tmp = tempdir().expect("tempdir"); @@ -469,6 +483,7 @@ fn headless_worker_record_tracks_lifecycle_without_tui_projection() { .any(|event| event.tool_name.as_deref() == Some("read_file")) ); } + #[test] fn worker_record_usage_accumulates_provider_tokens() { let tmp = tempdir().expect("tempdir"); @@ -531,6 +546,7 @@ fn worker_record_usage_accumulates_provider_tokens() { record.usage.note ); } + #[test] fn token_budget_scope_is_shared_across_nested_workers_and_blocks_when_spent() { let tmp = tempdir().expect("tempdir"); @@ -601,6 +617,7 @@ fn token_budget_scope_is_shared_across_nested_workers_and_blocks_when_spent() { assert_eq!(override_scope.limit, 20); assert_eq!(override_scope.spent, 0); } + #[test] fn agent_worker_profile_derives_from_parent_without_escalation() { let mut runtime = stub_runtime(); @@ -641,6 +658,7 @@ fn agent_worker_profile_derives_from_parent_without_escalation() { ToolScope::Explicit(vec!["read_file".to_string(), "write_file".to_string()]) ); } + #[test] fn declared_read_only_write_roles_derive_without_mutating_shell() { for input in [ @@ -668,6 +686,7 @@ fn declared_read_only_write_roles_derive_without_mutating_shell() { assert_eq!(profile.shell, ShellPolicy::None, "{request:?}"); } } + #[test] fn custom_runtime_inherits_the_parent_posture_and_explicit_authority_is_a_no_op_superset() { let runtime = stub_runtime().background_runtime(); @@ -715,6 +734,7 @@ fn custom_runtime_inherits_the_parent_posture_and_explicit_authority_is_a_no_op_ "scout parents carry the read-only inspection posture" ); } + #[test] fn subagent_progress_displays_legacy_shell_names_as_lowercase_bash() { assert_eq!(subagent_progress_tool_display_name("exec_shell"), "bash"); @@ -732,6 +752,7 @@ fn subagent_progress_displays_legacy_shell_names_as_lowercase_bash() { "read_file" ); } + #[test] fn agent_progress_preserves_event_channel_headroom_under_load() { let (tx, mut rx) = mpsc::channel(40); @@ -782,6 +803,7 @@ fn agent_progress_preserves_event_channel_headroom_under_load() { )); assert!(rx.try_recv().is_err()); } + #[test] fn agent_progress_uses_small_event_channels_without_headroom_reservation() { let (tx, mut rx) = mpsc::channel(8); @@ -806,6 +828,7 @@ fn agent_progress_uses_small_event_channels_without_headroom_reservation() { && activity.step == Some(1) )); } + #[test] fn headless_worker_records_persist_with_subagent_state() { let tmp = tempdir().expect("tempdir"); @@ -845,6 +868,7 @@ fn headless_worker_records_persist_with_subagent_state() { .any(|event| event.status == AgentWorkerStatus::Failed) ); } + #[test] fn persisted_subagent_state_has_bounded_serialized_size() { // #3885 / item 4: `subagents.v1.json` is transitively bounded but had no @@ -892,6 +916,7 @@ fn persisted_subagent_state_has_bounded_serialized_size() { "all registered workers should be persisted" ); } + #[test] fn coordination_ledger_persists_and_replays_bounded_contracts() { let tmp = tempdir().expect("tempdir"); @@ -943,6 +968,7 @@ fn coordination_ledger_persists_and_replays_bounded_contracts() { assert_eq!(inspect["decisions"][0]["decision_id"], "decision_storage"); assert_eq!(inspect["write_claims"][0]["claim"]["owner"], "agent_writer"); } + #[test] fn invalid_decision_and_reconciliation_inputs_cannot_poison_persisted_replay() { let tmp = tempdir().expect("tempdir"); @@ -1034,6 +1060,7 @@ fn invalid_decision_and_reconciliation_inputs_cannot_poison_persisted_replay() { assert_eq!(replayed.coordination.decisions.len(), 2); assert!(replayed.coordination.reconciliations.is_empty()); } + #[test] fn coordination_hot_paths_count_only_active_authoritative_owners() { let tmp = tempdir().expect("tempdir"); @@ -1072,6 +1099,7 @@ fn coordination_hot_paths_count_only_active_authoritative_owners() { }] ); } + #[test] fn headless_worker_registration_enforces_live_claims_and_projects_context() { let tmp = tempdir().expect("tempdir"); @@ -1152,6 +1180,7 @@ fn headless_worker_registration_enforces_live_claims_and_projects_context() { .expect("non-overlapping workers may proceed concurrently"); assert_eq!(manager.coordination.write_claims.len(), 2); } + #[test] fn session_close_finalizes_live_fleet_and_releases_write_claims() { let tmp = tempdir().expect("tempdir"); @@ -1225,6 +1254,7 @@ fn session_close_finalizes_live_fleet_and_releases_write_claims() { )) .expect("claim stays released after a second close pass"); } + #[test] fn terminal_result_releases_owner_from_write_claim_contention() { let tmp = tempdir().expect("tempdir"); @@ -1284,6 +1314,7 @@ fn terminal_result_releases_owner_from_write_claim_contention() { )) .expect("overlapping claim admitted after terminal result"); } + #[test] fn prior_session_owners_never_count_as_active_coordination_owners() { let tmp = tempdir().expect("tempdir"); @@ -1326,6 +1357,7 @@ fn prior_session_owners_never_count_as_active_coordination_owners() { )) .expect("prior-session owner must not block a new writer"); } + #[test] fn isolated_worktree_workers_skip_the_coordination_process_lock() { let tmp = tempdir().expect("tempdir"); @@ -1370,6 +1402,7 @@ fn isolated_worktree_workers_skip_the_coordination_process_lock() { .preflight_worker_coordination(&shared) .expect("shared-workspace writer proceeds once the lock is held"); } + #[test] fn coordination_detail_projection_reports_process_lock_ownership() { let tmp = tempdir().expect("tempdir"); @@ -1398,6 +1431,7 @@ fn coordination_detail_projection_reports_process_lock_ownership() { "shared projection carries no note" ); } + /// A second Codewhale session in the same workspace is ordinary usage. Losing /// the coordination flock means "I cannot append to the shared ledger" — it /// must never mean "my agents are dead". Bookkeeping failure and liveness are @@ -1452,6 +1486,7 @@ fn a_second_session_without_the_lock_keeps_its_running_agents_live() { snap.status ); } + /// A second session shares the workspace coordination read-lock and must load /// the existing ledger before it persists any of its own state. That preserves /// prior decisions instead of replacing them with an empty ledger. @@ -1521,6 +1556,7 @@ fn a_second_session_with_the_shared_lock_loads_the_workspace_ledger() { "the second session must not overwrite the workspace ledger with an empty one" ); } + /// The complement of the test above: an orphan still terminalizes, but only on /// heartbeat evidence. Lock ownership is not part of the decision either way. #[test] @@ -1589,6 +1625,7 @@ fn cleanup_terminalizes_orphans_on_heartbeat_evidence_not_on_lock_ownership() { "a heartbeat-stale orphan still becomes Interrupted" ); } + #[test] fn neutral_reconciliation_requires_the_nearest_common_planner() { let tmp = tempdir().expect("tempdir"); @@ -1673,6 +1710,7 @@ fn neutral_reconciliation_requires_the_nearest_common_planner() { assert_eq!(receipt.retry_limit, 3); assert_eq!(receipt.candidate_handles.len(), 2); } + #[test] fn coordination_acceptance_preserves_scopes_candidates_and_replay() { let repo = init_subagent_git_repo(); @@ -1967,6 +2005,7 @@ fn coordination_acceptance_preserves_scopes_candidates_and_replay() { "pub const B: u8 = 2;" ); } + fn init_subagent_git_repo() -> tempfile::TempDir { let dir = tempdir().expect("tempdir"); @@ -2016,6 +2055,7 @@ fn init_subagent_git_repo() -> tempfile::TempDir { dir } + fn git(repo: &Path, args: &[&str]) { let output = Command::new("git") .args(args) @@ -2028,6 +2068,7 @@ fn git(repo: &Path, args: &[&str]) { String::from_utf8_lossy(&output.stderr) ); } + fn git_stdout(repo: &Path, args: &[&str]) -> String { let output = Command::new("git") .args(args) @@ -2041,6 +2082,7 @@ fn git_stdout(repo: &Path, args: &[&str]) -> String { ); String::from_utf8_lossy(&output.stdout).trim().to_string() } + fn text_message(role: &str, text: &str) -> Message { Message { role: Role::from(role), @@ -2050,15 +2092,18 @@ fn text_message(role: &str, text: &str) -> Message { }], } } + fn make_checkpoint(agent_id: &str, steps_taken: u32, messages: Vec) -> SubAgentCheckpoint { build_subagent_checkpoint(agent_id, "test_checkpoint", &messages, steps_taken, true) } + fn message_text(message: &Message) -> &str { match message.content.first() { Some(ContentBlock::Text { text, .. }) => text.as_str(), other => panic!("expected text content block, got {other:?}"), } } + async fn delayed_chat_client( first_delay: Duration, response_text: &str, @@ -2126,6 +2171,7 @@ async fn delayed_chat_client( let client = DeepSeekClient::new(&config).expect("fake chat client"); (client, calls, bodies) } + #[tokio::test] async fn detached_interactive_usage_after_mailbox_seal_reaches_session_accounting() { let _cost_scope = crate::cost_status::test_scope(); @@ -2277,6 +2323,7 @@ async fn detached_interactive_usage_after_mailbox_seal_reaches_session_accountin ); } } + /// Like [`delayed_chat_client`] but delays *every* attempt, so the per-step /// API timeout fires on the first call and on every retry — the shape needed /// to drive the timeout-retry budget to exhaustion. @@ -2334,6 +2381,7 @@ async fn always_delayed_chat_client( let client = DeepSeekClient::new(&config).expect("fake always-slow chat client"); (client, calls) } + #[tokio::test] async fn tool_free_subagent_omits_chat_tools_and_tool_choice() { let tmp = tempdir().expect("tempdir"); @@ -2375,6 +2423,7 @@ async fn tool_free_subagent_omits_chat_tools_and_tool_choice() { "tool_choice must be omitted: {body}" ); } + async fn transient_header_timeout_then_success_chat_client( response_text: &str, ) -> (DeepSeekClient, Arc) { @@ -2439,6 +2488,7 @@ async fn transient_header_timeout_then_success_chat_client( let client = DeepSeekClient::new(&config).expect("fake transient chat client"); (client, calls) } + async fn always_rate_limited_chat_client() -> (DeepSeekClient, Arc) { let calls = Arc::new(AtomicUsize::new(0)); let app = Router::new().route( @@ -2487,6 +2537,7 @@ async fn always_rate_limited_chat_client() -> (DeepSeekClient, Arc) let client = DeepSeekClient::new(&config).expect("fake rate-limited chat client"); (client, calls) } + async fn always_invalid_request_chat_client() -> (DeepSeekClient, Arc) { let calls = Arc::new(AtomicUsize::new(0)); let app = Router::new().route( @@ -2536,9 +2587,11 @@ async fn always_invalid_request_chat_client() -> (DeepSeekClient, Arc usize { text.chars().count().div_ceil(3) } + #[test] fn test_agent_type_from_str() { assert_eq!(FleetRole::from_str("general"), Some(FleetRole::Worker)); @@ -2554,6 +2607,7 @@ fn test_agent_type_from_str() { assert_eq!(FleetRole::from_str("awaiter"), Some(FleetRole::Planner)); assert_eq!(FleetRole::from_str("invalid"), None); } + #[test] fn test_agent_type_implementer_aliases() { // #404 — Builder accepts the obvious legacy aliases the model is @@ -2568,6 +2622,7 @@ fn test_agent_type_implementer_aliases() { // Case-insensitive. assert_eq!(FleetRole::from_str("IMPLEMENTER"), Some(FleetRole::Builder)); } + #[test] fn test_agent_type_verifier_aliases() { // #404 — Verifier accepts test/validate aliases distinct from @@ -2581,6 +2636,7 @@ fn test_agent_type_verifier_aliases() { } assert_eq!(FleetRole::from_str("VERIFY"), Some(FleetRole::Verifier)); } + #[test] fn test_agent_type_round_trips_via_as_str() { // Every type should serialize to a string that round-trips back @@ -2602,6 +2658,7 @@ fn test_agent_type_round_trips_via_as_str() { assert_eq!(back, t, "round-trip failed for {t:?} via {label:?}"); } } + #[test] fn fleet_role_labels_are_canonical_while_legacy_snapshot_wire_stays_readable() { assert_eq!(FleetRole::Scout.as_str(), "explore"); @@ -2632,6 +2689,7 @@ fn fleet_role_labels_are_canonical_while_legacy_snapshot_wire_stays_readable() { "\"explore\"" ); } + #[test] fn fleet_role_deserialize_rejects_unknown_values_with_canonical_hint() { // Unknown role tokens fail closed at the serde boundary, and the error @@ -2667,6 +2725,7 @@ fn fleet_role_deserialize_rejects_unknown_values_with_canonical_hint() { "error must not advertise legacy aliases: {message}" ); } + #[test] fn write_capable_children_get_verify_contract_in_system_prompt() { let implementer = build_subagent_system_prompt( @@ -2692,6 +2751,7 @@ fn write_capable_children_get_verify_contract_in_system_prompt() { "read-only scout must not get write verify contract" ); } + #[test] fn test_implementer_and_verifier_have_distinct_prompts() { // The whole point of adding the types is that they carry distinct @@ -2727,6 +2787,7 @@ fn test_implementer_and_verifier_have_distinct_prompts() { "Verifier prompt should reference its role: {verifier}" ); } + #[test] fn test_agent_type_prompts_include_shared_output_contract_once() { for (agent_type, marker) in [ @@ -2768,6 +2829,7 @@ fn test_agent_type_prompts_include_shared_output_contract_once() { } } } + #[test] fn explore_prompt_orients_before_searching() { let prompt = FleetRole::Scout.system_prompt(); @@ -2776,6 +2838,7 @@ fn explore_prompt_orients_before_searching() { assert!(prompt.contains("workspace/project root")); assert!(prompt.contains("compressed evidence")); } + #[test] fn explore_prompt_is_quick_bounded_and_read_only() { let prompt = FleetRole::Scout.system_prompt(); @@ -2795,6 +2858,7 @@ fn explore_prompt_is_quick_bounded_and_read_only() { assert!(reviewer.contains("allowed read-only navigation/rg")); assert!(reviewer.contains("shell control actions are unavailable")); } + #[test] fn implementer_prompt_is_not_forced_into_explorer_cap() { let prompt = FleetRole::Builder.system_prompt(); @@ -2802,6 +2866,7 @@ fn implementer_prompt_is_not_forced_into_explorer_cap() { assert!(prompt.contains("Checkpoint before expanding scope")); assert!(!prompt.contains("Default to `EFFORT: quick`")); } + #[test] fn role_prompts_use_lowercase_primitive_contract() { let explore = FleetRole::Scout.system_prompt(); @@ -2822,6 +2887,7 @@ fn role_prompts_use_lowercase_primitive_contract() { } } } + #[test] fn review_and_verifier_prompts_stop_after_decisive_evidence() { let review = FleetRole::Reviewer.system_prompt(); @@ -2830,6 +2896,7 @@ fn review_and_verifier_prompts_stop_after_decisive_evidence() { assert!(review.contains("private `todo_write` list as editable working notes")); assert!(verifier.contains("stop after decisive pass/fail evidence")); } + #[test] fn child_artifact_copy_surfaces_working_notes_in_the_complete_transcript() { let artifacts = default_subagent_artifacts("agent_scout_notes"); @@ -2841,6 +2908,7 @@ fn child_artifact_copy_surfaces_working_notes_in_the_complete_transcript() { assert!(transcript.description.contains("todo_write working notes")); assert!(transcript.description.contains("transcript_handle")); } + #[test] fn agent_description_explains_background_child_and_transcript_handle() { let tmp = tempdir().expect("tempdir"); @@ -2910,6 +2978,7 @@ fn agent_description_explains_background_child_and_transcript_handle() { description.chars().count() ); } + #[test] fn stringy_deliberate_is_refused_not_dropped() { // A stringy "true" used to coerce to the default `false`, which skipped @@ -2926,6 +2995,7 @@ fn stringy_deliberate_is_refused_not_dropped() { "error must name expected and received types: {err}" ); } + #[test] fn bare_string_disallowed_tools_is_refused_not_dropped() { // A restriction the harness silently drops is worse than one never @@ -2953,6 +3023,7 @@ fn bare_string_disallowed_tools_is_refused_not_dropped() { "trimming and de-duplication survive the strict parse" ); } + #[test] fn spawn_parameters_refuse_type_mismatches_by_name() { // A representative sample across the parameter kinds the spawn parser @@ -2999,6 +3070,7 @@ fn spawn_parameters_refuse_type_mismatches_by_name() { })) .expect("explicit nulls read as absent"); } + #[test] fn deliberate_spawn_requires_delegation_fields() { let missing = parse_spawn_request(&json!({ @@ -3030,6 +3102,7 @@ fn deliberate_spawn_requires_delegation_fields() { "workspace_policy shared must not materialize a worktree" ); } + #[test] fn declared_workspace_policy_worktree_materializes_a_worktree_request() { // TUI-DOG-017: a declared policy must be enforced, not decorative. The @@ -3054,6 +3127,7 @@ fn declared_workspace_policy_worktree_materializes_a_worktree_request() { "shared policy plus explicit worktree must fail closed" ); } + #[test] fn builder_plus_read_only_authority_fails_closed() { // #5123: never launch a labeled builder that will only get read-only inspection tools. @@ -3072,6 +3146,7 @@ fn builder_plus_read_only_authority_fails_closed() { ); } } + #[test] fn read_only_worker_is_an_ordinary_general_child() { // Worker is the unnamed default (it renders as "general"); its capability @@ -3087,6 +3162,7 @@ fn read_only_worker_is_an_ordinary_general_child() { assert_eq!(request.agent_type, FleetRole::Worker); assert_eq!(request.write_authority, Some(SpawnWriteAuthority::ReadOnly)); } + #[test] fn roster_role_plus_read_only_authority_still_spawns() { // #5123 fail-closed must not swallow the read-only roster task. `role` is @@ -3119,6 +3195,7 @@ fn roster_role_plus_read_only_authority_still_spawns() { "a role alias resolves the type without claiming write capability" ); } + #[test] fn declared_write_authority_parses_and_worktree_write_requires_isolation() { let read_only = parse_spawn_request(&json!({ @@ -3183,6 +3260,7 @@ fn declared_write_authority_parses_and_worktree_write_requires_isolation() { .expect("explicit bounded custom write parses"); assert!(spawn_request_is_write_capable(&custom_writer)); } + #[test] fn prompt_only_general_children_default_read_only_instead_of_claiming_the_repo() { let request = parse_spawn_request(&json!({ @@ -3225,6 +3303,7 @@ fn prompt_only_general_children_default_read_only_instead_of_claiming_the_repo() .expect("resolved write-capable fleet role defaults write scope to parent workspace"); assert_eq!(fleet_role.write_roots, vec![".".to_string()]); } + #[test] fn read_only_roles_reject_write_authority_but_implementers_can_be_narrowed() { let reviewer = parse_spawn_request(&json!({ @@ -3253,6 +3332,7 @@ fn read_only_roles_reject_write_authority_but_implementers_can_be_narrowed() { Some(SpawnWriteAuthority::ReadOnly) ); } + /// #4752: Consultant must be a role, not a special-cased code path — so it has to /// travel the same spawn/schema machinery as every other role, and be refused /// write authority by the same guard that refuses reviewer. @@ -3275,6 +3355,7 @@ fn consultant_spawns_as_a_first_class_read_only_role() { .to_string(); assert!(escalation.contains("read-only role"), "{escalation}"); } + #[test] fn direct_consultant_aliases_apply_role_reasoning_default_after_inheritance() { for parent_effort in [None, Some("low")] { @@ -3360,6 +3441,7 @@ fn direct_consultant_aliases_apply_role_reasoning_default_after_inheritance() { "first-party DeepSeek keeps an explicit low child effort" ); } + /// The role name has to survive the wire, or receipts and resumed sessions /// silently reclassify a consultant as the default worker. #[test] @@ -3392,6 +3474,7 @@ fn consultant_round_trips_canonically_and_accepts_compatibility_aliases() { assert!(!FLEET_ROLE_SCHEMA_VALUES.contains(&"oracle")); assert!(FLEET_ROLE_SCHEMA_VALUES.contains(&"advisor")); } + #[test] fn declared_write_scope_is_normalized_and_rejects_traversal() { let request = parse_spawn_request(&json!({ @@ -3645,6 +3728,7 @@ fn shared_child_cwd_claims_use_one_root_namespace_for_collisions_and_mutations() "{outside}" ); } + #[test] fn resident_file_context_is_workspace_relative_bounded_and_exclusive() { let tmp = tempdir().expect("tempdir"); @@ -3713,6 +3797,7 @@ fn resident_file_context_is_workspace_relative_bounded_and_exclusive() { rollback_pending_resident_lease(&resident.lease_key); rollback_pending_resident_lease(&other.lease_key); } + #[cfg(unix)] #[test] fn resident_file_context_rejects_symlink_aliases() { @@ -3726,11 +3811,13 @@ fn resident_file_context_rejects_symlink_aliases() { .to_string(); assert!(error.contains("must not traverse symlinks"), "{error}"); } + #[test] fn new_session_tools_use_single_agent_name() { let manager = Arc::new(RwLock::new(SubAgentManager::new(PathBuf::from("."), 1))); assert_eq!(AgentTool::new(manager, stub_runtime()).name(), "agent"); } + #[test] fn test_parse_spawn_request_accepts_message_and_agent_type_aliases() { let input = json!({ @@ -3742,6 +3829,7 @@ fn test_parse_spawn_request_accepts_message_and_agent_type_aliases() { assert_eq!(parsed.agent_type, FleetRole::Scout); assert_eq!(parsed.assignment.role.as_deref(), Some("explore")); } + #[test] fn test_parse_spawn_request_accepts_objective_and_role_alias() { let input = json!({ @@ -3753,6 +3841,7 @@ fn test_parse_spawn_request_accepts_objective_and_role_alias() { assert_eq!(parsed.agent_type, FleetRole::Planner); assert_eq!(parsed.assignment.role.as_deref(), Some("planner")); } + #[test] fn test_parse_spawn_request_accepts_items_payload() { let input = json!({ @@ -3767,6 +3856,7 @@ fn test_parse_spawn_request_accepts_items_payload() { assert!(parsed.prompt.contains("[mention:$drive](app://drive)")); assert_eq!(parsed.agent_type, FleetRole::Scout); } + #[test] fn test_parse_spawn_request_accepts_fork_context() { let input = json!({ @@ -3788,6 +3878,7 @@ fn test_parse_spawn_request_accepts_fork_context() { let parsed = parse_spawn_request(&input).expect("spawn request should parse"); assert_eq!(parsed.fork_context, None); } + #[test] fn test_parse_spawn_request_accepts_model_strength() { let input = json!({ @@ -3806,6 +3897,7 @@ fn test_parse_spawn_request_accepts_model_strength() { let parsed = parse_spawn_request(&input).expect("spawn request should parse"); assert_eq!(parsed.model_strength, SubAgentModelStrength::Same); } + #[test] fn explore_subagent_inherits_active_model_by_default() { // Role names never silently change the model. A Fleet without custom @@ -3839,6 +3931,7 @@ fn explore_subagent_inherits_active_model_by_default() { assert_eq!(parsed.agent_type, FleetRole::Scout); assert_eq!(parsed.model_strength, SubAgentModelStrength::Same); } + #[test] fn non_explore_subagents_keep_default_same_model_strength() { // Non-explore roles keep the conservative Same default even with no model. @@ -3858,6 +3951,7 @@ fn non_explore_subagents_keep_default_same_model_strength() { ); } } + #[test] fn test_parse_spawn_request_accepts_child_thinking() { let input = json!({ @@ -3887,6 +3981,7 @@ fn test_parse_spawn_request_accepts_child_thinking() { let parsed = parse_spawn_request(&input).expect("spawn request should parse"); assert_eq!(parsed.thinking, SubAgentThinking::Auto); } + #[test] fn test_parse_spawn_request_rejects_invalid_model_strength() { let input = json!({ @@ -3899,6 +3994,7 @@ fn test_parse_spawn_request_rejects_invalid_model_strength() { .contains("model_strength must be one of: same, faster") ); } + #[test] fn test_parse_spawn_request_rejects_invalid_child_thinking() { let input = json!({ @@ -3911,6 +4007,7 @@ fn test_parse_spawn_request_rejects_invalid_child_thinking() { .contains("thinking must be one of: inherit, auto, off, low, medium, high, max") ); } + #[test] fn test_parse_spawn_request_accepts_session_name_for_agent() { let input = json!({ @@ -3924,6 +4021,7 @@ fn test_parse_spawn_request_accepts_session_name_for_agent() { assert_eq!(parsed.fork_context, Some(true)); assert_eq!(parsed.max_depth, Some(0)); } + #[test] fn test_parse_spawn_request_rejects_invalid_session_name() { let input = json!({ @@ -3933,6 +4031,7 @@ fn test_parse_spawn_request_rejects_invalid_session_name() { let err = parse_spawn_request(&input).expect_err("space in name should fail"); assert!(err.to_string().contains("name must not contain whitespace")); } + #[test] fn test_parse_spawn_request_rejects_out_of_range_max_depth() { let ceiling = codewhale_config::MAX_SPAWN_DEPTH_CEILING; @@ -3947,6 +4046,7 @@ fn test_parse_spawn_request_rejects_out_of_range_max_depth() { .contains(&format!("max_depth must be between 0 and {ceiling}")) ); } + #[test] fn test_parse_spawn_request_accepts_profile_and_preserves_safe_selector() { let input = json!({ @@ -3973,6 +4073,7 @@ fn test_parse_spawn_request_accepts_profile_and_preserves_safe_selector() { .expect("human model label should parse"); assert_eq!(parsed.profile.as_deref(), Some("DeepSeek V4 Flash")); } + #[test] fn test_parse_spawn_request_rejects_invalid_profile_token() { for bad in ["reviewer\nscout", "reviewer\tscout"] { @@ -3989,6 +4090,7 @@ fn test_parse_spawn_request_rejects_invalid_profile_token() { .expect_err("oversized selector should fail"); assert!(err.to_string().contains("at most 128"), "{err}"); } + #[tokio::test] async fn agent_roster_action_and_spawn_resolve_the_same_roles() { let tmp = tempdir().expect("tempdir"); @@ -4037,6 +4139,7 @@ async fn agent_roster_action_and_spawn_resolve_the_same_roles() { assert_eq!(request.agent_type, FleetRole::Scout); assert_eq!(request.profile.as_deref(), Some("explore")); } + #[tokio::test] async fn agent_roster_lists_host_presented_profiles_spawn_resolves() { let tmp = tempdir().expect("tempdir"); @@ -4108,6 +4211,7 @@ async fn agent_roster_lists_host_presented_profiles_spawn_resolves() { assert_eq!(request.profile.as_deref(), Some(profile_id)); } } + #[tokio::test] async fn agent_roster_truncates_host_profiles_at_the_listing_cap() { let tmp = tempdir().expect("tempdir"); @@ -4152,6 +4256,7 @@ async fn agent_roster_truncates_host_profiles_at_the_listing_cap() { // Descriptions fall back to the role name when the profile carries none. assert_eq!(host_profiles[0]["description"], json!("exp-host-000")); } + #[tokio::test] async fn agent_roster_profile_query_discovers_members_beyond_the_listing_cap() { // The T7 discovery-contract regression: a spawnable profile past @@ -4260,6 +4365,7 @@ async fn agent_roster_profile_query_discovers_members_beyond_the_listing_cap() { assert_eq!(resolved.id, "exp-host-049"); assert_eq!(request.profile.as_deref(), Some("exp-host-049")); } + #[tokio::test] async fn agent_roster_never_lists_ids_that_spawn_a_different_profile() { // `[fleet.profiles]` is a byte-order BTreeMap, so `Expert`, `expert`, and @@ -4340,6 +4446,7 @@ async fn agent_roster_never_lists_ids_that_spawn_a_different_profile() { .expect("host member is returned"); assert_eq!(resolved.id, "Expert"); } + #[tokio::test] async fn agent_roster_listing_and_spawn_agree_on_conflatable_config_ids() { // The wiring-level half of the collapse invariant. The `action=roster` @@ -4463,6 +4570,7 @@ async fn agent_roster_listing_and_spawn_agree_on_conflatable_config_ids() { ); } } + #[test] fn roster_host_profile_listing_cap_is_pinned_at_48() { // Copy-truth pin: the app-side roster contract teaches a 48-row @@ -4471,6 +4579,7 @@ fn roster_host_profile_listing_cap_is_pinned_at_48() { // this test, in the same style as `subagent_runtime_default_max_depth_is_three`. assert_eq!(ROSTER_HOST_PROFILE_LIMIT, 48); } + #[tokio::test] async fn forkguard_agent_roster_lists_only_spawnable_host_profiles() { let tmp = tempdir().expect("tempdir"); @@ -4586,6 +4695,7 @@ async fn forkguard_agent_roster_lists_only_spawnable_host_profiles() { "{error}" ); } + #[test] fn test_resolve_spawn_role_unknown_lists_roles() { let mut request = @@ -4609,6 +4719,7 @@ fn test_resolve_spawn_role_unknown_lists_roles() { assert!(message.contains(role), "missing {role}: {message}"); } } + #[test] fn forkguard_host_profile_overlay_is_config_only_and_prompt_only() { let profile_id = "exp-host-reviewer"; @@ -4674,6 +4785,7 @@ fn forkguard_host_profile_overlay_is_config_only_and_prompt_only() { .to_string(); assert!(error.contains("not prompt-only"), "{error}"); } + #[test] fn test_resolve_spawn_role_rejects_conflicting_explicit_type() { let mut request = parse_spawn_request(&json!({ @@ -4693,6 +4805,7 @@ fn test_resolve_spawn_role_rejects_conflicting_explicit_type() { "{message}" ); } + #[test] fn test_resolve_spawn_role_accepts_agreeing_explicit_type() { let mut request = parse_spawn_request(&json!({ @@ -4706,6 +4819,7 @@ fn test_resolve_spawn_role_accepts_agreeing_explicit_type() { assert_eq!(request.profile.as_deref(), Some("reviewer")); assert_eq!(request.assignment.role.as_deref(), Some("reviewer")); } + #[test] fn test_resolve_spawn_role_scout_yields_explore_type_and_inherits_route() { let mut request = parse_spawn_request(&json!({"prompt": "map the parser", "profile": "scout"})) @@ -4721,6 +4835,7 @@ fn test_resolve_spawn_role_scout_yields_explore_type_and_inherits_route() { ); assert_eq!(selected.source, SpawnRouteSource::RunModel); } + #[test] fn test_resolve_spawn_role_accepts_legacy_alias() { let mut request = parse_spawn_request(&json!({"prompt": "merge findings", "profile": "plan"})) @@ -4729,6 +4844,7 @@ fn test_resolve_spawn_role_accepts_legacy_alias() { assert_eq!(request.agent_type, FleetRole::Planner); assert_eq!(request.profile.as_deref(), Some("planner")); } + #[test] fn spawn_model_selection_has_stable_three_tier_precedence_and_source() { let mut runtime = stub_runtime(); @@ -4780,6 +4896,7 @@ fn spawn_model_selection_has_stable_three_tier_precedence_and_source() { assert_eq!(selected.model_route, ModelRoute::Inherit); assert_eq!(selected.source, SpawnRouteSource::RunModel); } + #[test] fn providerless_spawn_model_gate_rejects_known_foreign_route_before_spawn() { let runtime = stub_runtime_for_provider("moonshot"); @@ -4831,6 +4948,7 @@ fn providerless_spawn_model_gate_rejects_known_foreign_route_before_spawn() { "the child and receipt must use the provider's exact wire id" ); } + #[test] fn providerless_foreign_spawn_default_inherits_session_route() { // #5099 / checklist §2.2: a moonshot parent spawning a default child whose @@ -4883,6 +5001,7 @@ fn providerless_foreign_spawn_default_inherits_session_route() { owned.model_route ); } + #[test] fn spawn_route_sources_refresh_overlays_live_config_on_launch_time_defaults() { // #5099: role-model defaults are a launch-time snapshot, so a mid-session @@ -4925,6 +5044,7 @@ fn spawn_route_sources_refresh_overlays_live_config_on_launch_time_defaults() { "worker override covers the general alias too" ); } + #[test] fn test_child_max_spawn_depth_request_only_narrows() { // No request: inherit unchanged. @@ -4938,6 +5058,7 @@ fn test_child_max_spawn_depth_request_only_narrows() { // A request below the inherited budget is still honored (clamp, don't force). assert_eq!(child_max_spawn_depth_for_spawn(5, 0, Some(3)), 3); } + /// A descendant subagent must not widen the absolute recursion budget its root /// session selected by supplying an explicit `max_depth` on a nested spawn /// (#5253). The inherited budget is a hard cap even when the request (clamped @@ -4956,6 +5077,7 @@ fn test_child_max_spawn_depth_request_cannot_widen_inherited_budget() { // A request below the inherited budget is still honored (clamp, don't force). assert_eq!(child_max_spawn_depth_for_spawn(5, 0, Some(3)), 3); } + /// An explicit spawn-time thinking tier reaches the request unchanged: roles /// carry no reasoning tier of their own, so there is nothing to outrank or /// inherit — the caller's tier is the tier. @@ -4983,6 +5105,7 @@ fn test_explicit_spawn_thinking_reaches_the_request() { ); assert_eq!(route.reasoning_effort.as_deref(), Some("off")); } + #[test] fn test_unset_spawn_thinking_inherits_the_session_tier() { let mut request = parse_spawn_request(&json!({"prompt": "look around", "profile": "explore"})) @@ -4990,6 +5113,7 @@ fn test_unset_spawn_thinking_inherits_the_session_tier() { resolve_spawn_role(&mut request).expect("resolve"); assert_eq!(request.thinking, SubAgentThinking::Inherit); } + /// Roles bind no route (#5046 inverted): the dispatching model may always set /// `model` or `model_strength` — every role accepts model routing options. #[test] @@ -5040,6 +5164,7 @@ fn role_dispatch_allows_model_and_model_strength_options() { assert!(request.model_strength_explicit); assert_eq!(request.model_strength, SubAgentModelStrength::Faster); } + /// A custom (non-role) profile name fails closed: only the closed role set /// resolves — there are no saved members to promote. #[test] @@ -5058,6 +5183,7 @@ fn custom_profile_name_fails_closed() { "error must name the custom profile: {message}" ); } + /// A type alias dispatches as its role and keeps an explicit `model`: there /// is no saved member to promote to and no pinned model to collide with. #[test] @@ -5077,6 +5203,7 @@ fn type_alias_dispatch_keeps_explicit_model() { "explicit model is kept: roles pin no model" ); } + /// A Fleet worker subprocess launches as `--model --reasoning-effort /// auto`. That is a FIXED model with Auto reasoning: the raw `"auto"` sentinel /// must resolve, not travel to a provider that has no such tier. @@ -5107,6 +5234,7 @@ fn fixed_model_runtime_with_a_raw_auto_tier_resolves_instead_of_staying_raw() { assert_eq!(route.reasoning_effort.as_deref(), Some("max")); assert_eq!(route.tuning.reasoning_effort, Some(ReasoningEffort::Max)); } + #[test] fn a_concrete_runtime_tier_is_not_mistaken_for_auto() { let runtime = stub_runtime().with_reasoning_effort(Some("off".to_string()), false); @@ -5121,6 +5249,7 @@ fn a_concrete_runtime_tier_is_not_mistaken_for_auto() { assert_eq!(route.reasoning_effort.as_deref(), Some("off")); } + #[tokio::test] async fn session_projection_exposes_forked_prefix_cache_contract() { let mut snapshot = make_snapshot(SubAgentStatus::Running); @@ -5148,6 +5277,7 @@ async fn session_projection_exposes_forked_prefix_cache_contract() { assert_eq!(projection.transcript_handle.kind, "var_handle"); assert_eq!(projection.transcript_handle.name, "transcript"); } + #[tokio::test] async fn terminal_session_projection_prefers_full_transcript_handle() { let mut snapshot = make_snapshot(SubAgentStatus::Completed); @@ -5179,6 +5309,7 @@ async fn terminal_session_projection_prefers_full_transcript_handle() { assert_eq!(projection.transcript_handle, full_handle); assert_eq!(projection.transcript_handle.name, "full_transcript"); } + #[tokio::test] async fn interrupted_projection_exposes_checkpoint_metadata_and_messages() { let mut snapshot = make_snapshot(SubAgentStatus::Interrupted( @@ -5231,6 +5362,7 @@ async fn interrupted_projection_exposes_checkpoint_metadata_and_messages() { assert!(timed_out_projection.timed_out); assert!(timed_out_projection.timed_out_with_checkpoint); } + #[test] fn test_delegate_defaults_to_fork_context() { let input = with_default_fork_context(json!({ "prompt": "review current work" }), true); @@ -5244,6 +5376,7 @@ fn test_delegate_defaults_to_fork_context() { let parsed = parse_spawn_request(&input).expect("delegate override should parse"); assert_eq!(parsed.fork_context, Some(false)); } + #[test] fn spawn_request_parses_token_budget_override() { let parsed = parse_spawn_request(&json!({ @@ -5270,6 +5403,7 @@ fn spawn_request_parses_token_budget_override() { "clear token budget error: {err}" ); } + #[test] fn forked_subagent_messages_preserve_parent_prefix_then_append_task() { let parent_message = Message { @@ -5306,6 +5440,7 @@ fn forked_subagent_messages_preserve_parent_prefix_then_append_task() { assert_eq!(messages[3].role, "user"); assert!(message_text(&messages[3]).contains("inspect parser")); } + #[test] fn fresh_subagent_messages_keep_existing_single_turn_shape() { let assignment = SubAgentAssignment::new("list files".to_string(), None); @@ -5316,6 +5451,7 @@ fn fresh_subagent_messages_keep_existing_single_turn_shape() { assert_eq!(messages[0].role, "user"); assert!(message_text(&messages[0]).contains("list files")); } + #[test] fn test_parse_spawn_request_rejects_text_and_items_together() { let input = json!({ @@ -5325,6 +5461,7 @@ fn test_parse_spawn_request_rejects_text_and_items_together() { let err = parse_spawn_request(&input).expect_err("text+items should fail"); assert!(err.to_string().contains("either prompt text or items")); } + #[test] fn test_parse_spawn_request_rejects_human_model_name_as_role() { // A model name is not a role: without a roster of saved members there is @@ -5344,6 +5481,7 @@ fn test_parse_spawn_request_rejects_human_model_name_as_role() { "{err}" ); } + #[test] fn test_parse_spawn_request_accepts_fleet_role_token_for_runtime_resolution() { let input = json!({ @@ -5370,6 +5508,7 @@ fn test_parse_spawn_request_accepts_fleet_role_token_for_runtime_resolution() { resolve_spawn_role(&mut scout).expect("scout should resolve"); assert_eq!(scout.agent_type, FleetRole::Scout); } + #[test] fn test_parse_spawn_request_accepts_full_role_vocabulary() { // Regression for #2649: roles that `FleetRole::from_str` accepts must @@ -5441,6 +5580,7 @@ fn test_parse_spawn_request_accepts_full_role_vocabulary() { .unwrap_or_else(|e| panic!("role {role:?} should resolve without a profile: {e}")); } } + #[test] fn test_invalid_role_error_lists_real_aliases() { // Well-formed fleet role tokens parse and then fail clearly at role @@ -5468,6 +5608,7 @@ fn test_invalid_role_error_lists_real_aliases() { "hint should explain compatibility aliases: {err}" ); } + #[test] fn plugin_agent_profile_loads_but_is_not_a_spawn_role() { let _lock = crate::test_support::lock_test_env(); @@ -5510,17 +5651,20 @@ fn plugin_agent_profile_loads_but_is_not_a_spawn_role() { "reload removes the disabled plugin Agent" ); } + fn schema_property_description<'a>(schema: &'a Value, property: &str) -> &'a str { schema["properties"][property]["description"] .as_str() .unwrap_or_else(|| panic!("missing description for schema property {property:?}")) } + fn draft_2020_validator(schema: &Value) -> jsonschema::Validator { jsonschema::options() .with_draft(jsonschema::Draft::Draft202012) .build(schema) .expect("valid Draft 2020-12 tool schema") } + #[test] fn subagent_tool_schemas_advertise_real_type_and_role_vocabulary() { let tmp = tempdir().expect("tempdir"); @@ -5619,6 +5763,7 @@ fn subagent_tool_schemas_advertise_real_type_and_role_vocabulary() { "worktree description should teach isolated parallel edits: {worktree}" ); } + // The wait surfaces block the turn, so the model-facing text must disclose // the bounded block (default 30s, max 120s) and the timed_out receipt shape; // an undisclosed finite block reads as a hang when nothing settles. @@ -5680,6 +5825,7 @@ fn wait_schema_text_discloses_timeout_bound_and_timed_out_receipt() { the timed_out receipt:\n{wait_description}" ); } + // The two wait faces advertise from two independent constant sets, so each // face's own pin can stay green while the surfaces drift numerically apart; // this cross-assertion closes that gap. @@ -5704,6 +5850,7 @@ fn wait_bound_constants_agree_across_both_wait_faces() { same minimum timeout" ); } + #[test] fn wait_timeout_alias_stays_parse_accepted() { // docs/SUBAGENTS.md pins the `timeout` alias (same meaning as @@ -5717,6 +5864,7 @@ fn wait_timeout_alias_stays_parse_accepted() { assert_eq!(read(json!({"timeout": 7})), Some(7)); assert_eq!(read(json!({})), None); } + #[test] fn agent_tool_unadvertised_fields_remain_parse_accepted() { // #5324 compat: the fields removed from the advertised schema must stay @@ -5809,6 +5957,7 @@ fn agent_tool_unadvertised_fields_remain_parse_accepted() { AgentToolAction::Interrupt ); } + #[test] fn agent_tool_role_schema_is_a_closed_canonical_enum() { let tmp = tempdir().expect("tempdir"); @@ -5856,6 +6005,7 @@ fn agent_tool_role_schema_is_a_closed_canonical_enum() { ); } } + #[test] fn provider_schema_sanitizers_preserve_the_closed_fleet_role_enum() { use crate::tools::schema_sanitize; @@ -5932,6 +6082,7 @@ fn provider_schema_sanitizers_preserve_the_closed_fleet_role_enum() { "Kimi MFJS must receive a schema without dependentSchemas" ); } + #[test] fn agent_tool_prompt_schema_keeps_ordinary_starts_message_first() { let tmp = tempdir().expect("tempdir"); @@ -5958,6 +6109,7 @@ fn agent_tool_prompt_schema_keeps_ordinary_starts_message_first() { ); } } + #[test] fn agent_tool_schema_advertises_lifecycle_and_coordination_actions() { let tmp = tempdir().expect("tempdir"); @@ -5980,6 +6132,7 @@ fn agent_tool_schema_advertises_lifecycle_and_coordination_actions() { // absence; `timeout_secs` returned to the advertised surface in #119, // and the compat test below still covers both plus the `timeout` alias. } + #[test] fn agent_tool_schema_bounds_fields_by_explicit_action() { let tmp = tempdir().expect("tempdir"); @@ -6028,6 +6181,7 @@ fn agent_tool_schema_bounds_fields_by_explicit_action() { assert!(branch(action).get("anyOf").is_none()); } } + #[test] fn agent_tool_schema_rejects_empty_input_across_provider_forms() { use crate::tools::schema_sanitize; @@ -6096,6 +6250,7 @@ fn agent_tool_schema_rejects_empty_input_across_provider_forms() { "agent schema must reject empty input because runtime defaults it to start and then rejects the missing prompt; permissive forms: {permissive:?}" ); } + #[tokio::test] async fn agent_message_queues_and_followup_delivers_as_user_provenance() { let tmp = tempdir().expect("tempdir"); @@ -6195,6 +6350,7 @@ async fn agent_message_queues_and_followup_delivers_as_user_provenance() { .to_string(); assert!(absent.contains("not found"), "{absent}"); } + #[tokio::test] async fn agent_tool_status_returns_running_child_projection() { let tmp = tempdir().expect("tempdir"); @@ -6243,6 +6399,7 @@ async fn agent_tool_status_returns_running_child_projection() { assert!(result.content.contains("running")); assert!(result.content.contains("transcript_handle")); } + #[tokio::test] async fn agent_tool_status_reconciles_stale_single_agent_projection() { let tmp = tempdir().expect("tempdir"); @@ -6293,6 +6450,7 @@ async fn agent_tool_status_reconciles_stale_single_agent_projection() { assert!(result.content.contains("Auto-cancelled")); assert_eq!(manager.read().await.running_count(), 0); } + #[tokio::test] async fn agent_tool_cancel_stops_running_child() { let tmp = tempdir().expect("tempdir"); @@ -6361,6 +6519,7 @@ async fn agent_tool_cancel_stops_running_child() { "repeated stop must not append a second terminal outcome" ); } + #[tokio::test] async fn model_wait_cancel_fans_in_once_and_preserves_checkpoint() { use tokio_util::sync::CancellationToken; @@ -6459,6 +6618,7 @@ async fn model_wait_cancel_fans_in_once_and_preserves_checkpoint() { 1 ); } + #[tokio::test] async fn coordination_interrupt_fans_in_once_and_preserves_checkpoint() { use tokio_util::sync::CancellationToken; @@ -6579,6 +6739,7 @@ async fn coordination_interrupt_fans_in_once_and_preserves_checkpoint() { "repeated interrupt must not append a second terminal or parked outcome" ); } + #[tokio::test] async fn late_completion_does_not_overwrite_cancelled_outcome() { let tmp = tempdir().expect("tempdir"); @@ -6630,6 +6791,7 @@ async fn late_completion_does_not_overwrite_cancelled_outcome() { assert_eq!(terminal.len(), 1); assert_eq!(terminal[0].status, AgentWorkerStatus::Cancelled); } + #[tokio::test] async fn completion_claim_preserves_running_gate_and_excludes_late_cancel() { let tmp = tempdir().expect("tempdir"); @@ -6705,6 +6867,7 @@ async fn completion_claim_preserves_running_gate_and_excludes_late_cancel() { .count(); assert_eq!(terminal, 1, "exactly one terminal outcome is recorded"); } + #[test] fn test_parse_spawn_request_rejects_conflicting_type_and_role() { let input = json!({ @@ -6718,6 +6881,7 @@ fn test_parse_spawn_request_rejects_conflicting_type_and_role() { .contains("Fleet role conflicts with the explicit legacy agent type") ); } + #[test] fn test_build_allowed_tools_independent_of_allow_shell() { // v0.6.6: allow_shell no longer filters at the build_allowed_tools @@ -6729,6 +6893,7 @@ fn test_build_allowed_tools_independent_of_allow_shell() { assert!(with_shell.is_none()); assert!(without_shell.is_none()); } + #[test] fn test_allowed_tools_are_deduplicated() { let tools = build_allowed_tools( @@ -6747,11 +6912,13 @@ fn test_allowed_tools_are_deduplicated() { Some(vec!["read_file".to_string(), "grep_files".to_string()]) ); } + #[test] fn test_custom_agent_requires_allowed_tools() { let err = build_allowed_tools(&FleetRole::Custom, None, true).unwrap_err(); assert!(err.to_string().contains("requires")); } + #[test] fn role_posture_blocks_writes_and_shell_for_read_only_roles() { // #3217: read-only roles may never run write/edit/patch tools, regardless @@ -6813,6 +6980,7 @@ fn role_posture_blocks_writes_and_shell_for_read_only_roles() { ApprovalRequirement::Required )); } + #[test] fn test_build_assignment_prompt_includes_metadata() { let assignment = SubAgentAssignment::new( @@ -6824,6 +6992,7 @@ fn test_build_assignment_prompt_includes_metadata() { assert!(prompt.contains("resolved_type: explore")); assert!(prompt.contains("role: explore")); } + #[test] fn subagent_model_strength_defaults_to_parent_even_when_parent_auto_model() { let mut runtime = stub_runtime().with_auto_model(true); @@ -6841,6 +7010,7 @@ fn subagent_model_strength_defaults_to_parent_even_when_parent_auto_model() { assert_eq!(route.model, "deepseek-v4-pro", "prompt {prompt:?}"); } } + #[test] fn subagent_model_strength_faster_uses_known_family_sibling() { let mut runtime = stub_runtime().with_auto_model(true); @@ -6857,6 +7027,7 @@ fn subagent_model_strength_faster_uses_known_family_sibling() { assert_eq!(route.model, "deepseek-v4-flash"); assert_eq!(route.reasoning_effort.as_deref(), Some("off")); } + #[test] fn subagent_model_strength_explicit_model_wins_over_faster() { let runtime = stub_runtime().with_auto_model(true); @@ -6874,6 +7045,7 @@ fn subagent_model_strength_explicit_model_wins_over_faster() { ); assert_eq!(route.model, "deepseek-v4-pro"); } + #[test] fn explicit_child_thinking_overrides_faster_default_off() { let mut runtime = stub_runtime().with_reasoning_effort(Some("max".to_string()), false); @@ -6890,6 +7062,7 @@ fn explicit_child_thinking_overrides_faster_default_off() { assert_eq!(route.reasoning_effort.as_deref(), Some("high")); assert_eq!(route.tuning.reasoning_effort, Some(ReasoningEffort::High)); } + #[test] fn explicit_child_auto_thinking_resolves_from_child_prompt() { let runtime = stub_runtime().with_reasoning_effort(Some("off".to_string()), false); @@ -6903,6 +7076,7 @@ fn explicit_child_auto_thinking_resolves_from_child_prompt() { ); assert_eq!(route.reasoning_effort.as_deref(), Some("max")); } + #[tokio::test] async fn route_resolution_matrix_uses_explicit_model_strength_routes() { let mut runtime = stub_runtime() @@ -6998,6 +7172,7 @@ async fn route_resolution_matrix_uses_explicit_model_strength_routes() { ); } } + #[test] fn subagent_auto_reasoning_resolves_to_distinct_v4_tiers() { let runtime = stub_runtime().with_reasoning_effort(Some("high".to_string()), true); @@ -7025,6 +7200,7 @@ fn subagent_auto_reasoning_resolves_to_distinct_v4_tiers() { Some("max".to_string()) ); } + #[test] fn test_subagent_tool_registry_reports_unavailable_tools() { let tmp = tempdir().expect("tempdir"); @@ -7048,6 +7224,7 @@ fn test_subagent_tool_registry_reports_unavailable_tools() { vec!["update_goal".to_string(), "missing_tool".to_string()] ); } + #[test] fn test_subagent_tools_respect_nested_agent_depth_budget() { let tmp = tempdir().expect("tempdir"); @@ -7086,9 +7263,11 @@ fn test_subagent_tools_respect_nested_agent_depth_budget() { ); assert!(!capped.is_tool_allowed("agent")); } + fn tool_names(tools: Vec) -> HashSet { tools.into_iter().map(|tool| tool.name).collect() } + #[test] fn every_named_role_has_one_complete_capability_based_surface() { const INSPECTION: &[&str] = &[ @@ -7154,6 +7333,7 @@ fn every_named_role_has_one_complete_capability_based_surface() { "Web", "agent", "automation", + "bash", "diagnostics", "file_search", "finance", @@ -7300,6 +7480,7 @@ fn every_named_role_has_one_complete_capability_based_surface() { assert_eq!(names, expected, "{role:?} visible surface drifted"); } } + /// An explicit parent tool subset is a restriction, not a note. The registry /// consults `allowed_tools`, so a `ToolScope::Explicit` that never reaches it /// leaves the child holding the parent's whole surface while the profile claims @@ -7339,6 +7520,7 @@ fn an_explicit_parent_tool_scope_is_enforced_by_the_child_registry() { assert!(!names.contains("Bash"), "{names:?}"); assert!(!names.contains("Git"), "{names:?}"); } + /// A child may narrow inside its parent's explicit subset; it may not step /// outside it, however it asks. #[test] @@ -7381,6 +7563,7 @@ fn a_child_allowlist_cannot_widen_an_explicit_parent_tool_scope() { None ); } + fn enabled_agent_surface_options() -> AgentToolSurfaceOptions { let mut options = AgentToolSurfaceOptions::new(ShellPolicy::Full); options.apply_patch_enabled = true; @@ -7389,6 +7572,7 @@ fn enabled_agent_surface_options() -> AgentToolSurfaceOptions { options.goal_state = Some(crate::tools::goal::new_shared_goal_state()); options } + /// Return the exact model-visible catalog built for a default General child. /// /// Request-boundary tests use this fixture so Moonshot compatibility coverage @@ -7417,6 +7601,7 @@ pub(crate) fn kimi_general_child_request_tools_fixture() -> Vec { let mut surface = SubAgentToolSurface::new(catalog, &[]); model_request_tools(&mut surface) } + fn small_surface_registry(role: FleetRole) -> SubAgentToolRegistry { let mut runtime = stub_runtime().with_agent_tool_surface_options(enabled_agent_surface_options()); @@ -7429,9 +7614,11 @@ fn small_surface_registry(role: FleetRole) -> SubAgentToolRegistry { crate::tools::plan::new_shared_plan_state(), ) } + fn model_tool_names(tools: Vec) -> BTreeSet { tools.into_iter().map(|tool| tool.name).collect() } + #[test] fn small_surface_legacy_rules_cover_lowercase_primitives_with_deny_wins() { let file = vec!["File".to_string()]; @@ -7478,9 +7665,11 @@ fn small_surface_legacy_rules_cover_lowercase_primitives_with_deny_wins() { ); } } + fn model_request_tools(surface: &mut SubAgentToolSurface) -> Vec { surface.request_tools(surface.catalog.clone(), false) } + fn forked_child_request_fixture( registry: &SubAgentToolRegistry, role: &FleetRole, @@ -7496,6 +7685,7 @@ fn forked_child_request_fixture( let catalog = registry.deferred_catalog_for_model(role); (messages, SubAgentToolSurface::new(catalog, &[])) } + async fn execute_surface_tool( registry: &SubAgentToolRegistry, surface: &mut SubAgentToolSurface, @@ -7508,6 +7698,7 @@ async fn execute_surface_tool( .await .map(|result| result.result.content) } + #[test] fn small_surface_starts_with_only_pi_head_and_search() { let registry = small_surface_registry(FleetRole::Builder); @@ -7538,6 +7729,7 @@ fn small_surface_starts_with_only_pi_head_and_search() { Some(true) ); } + #[tokio::test] async fn small_surface_read_only_child_discovers_web_deferred() { let registry = small_surface_registry(FleetRole::Scout); @@ -7581,6 +7773,7 @@ async fn small_surface_read_only_child_discovers_web_deferred() { assert!(same_batch.result.content.contains("deferred")); assert!(model_tool_names(model_request_tools(&mut surface)).contains("Web")); } + #[tokio::test] async fn small_surface_fork_context_survives_fresh_child_discovery() { let registry = small_surface_registry(FleetRole::Builder); @@ -7654,6 +7847,7 @@ async fn small_surface_fork_context_survives_fresh_child_discovery() { assert!(names.contains("Web")); assert!(names.contains("apply_patch")); } + #[tokio::test] async fn small_surface_denied_warm_tool_is_not_resurrected() { let mut runtime = @@ -7681,6 +7875,7 @@ async fn small_surface_denied_warm_tool_is_not_resurrected() { assert!(!searched.contains("\"tool_name\":\"Web\"")); assert!(surface.hydrate("Web").is_err()); } + fn synthetic_deferred_tool(name: &str, description_bytes: usize) -> Tool { Tool { tool_type: Some("function".to_string()), @@ -7694,6 +7889,7 @@ fn synthetic_deferred_tool(name: &str, description_bytes: usize) -> Tool { cache_control: None, } } + // Pinvou fork: the toolbox size bounds are disabled (see the constant docs in // core/session.rs). Upstream tests that construct near-budget activations to // fire bounds eviction or oversize rejection are not carried here — those @@ -7746,6 +7942,7 @@ fn forkguard_small_surface_caches_are_independent_unbounded_and_revalidated() { assert!(byte_names.contains("bytes_0")); assert!(byte_names.contains("bytes_1") && byte_names.contains("bytes_2")); } + #[tokio::test] async fn small_surface_successful_cached_use_touches_lru() { let registry = small_surface_registry(FleetRole::Builder); @@ -7765,6 +7962,7 @@ async fn small_surface_successful_cached_use_touches_lru() { surface.hydrate(&ninth).expect("ninth activation"); assert!(model_tool_names(model_request_tools(&mut surface)).contains("get_goal")); } + /// The child's ride channel mirrors the parent's: parked notes ride success /// and error results alike (a note held back on `Err` would strand until a /// later `Ok` — or forever if the child ends first), and names the ride @@ -7833,6 +8031,7 @@ fn child_parked_note_rides_success_and_error_results_and_nets_re_admissions() { netted.result.content ); } + /// The park side is covered above; this pin is the consumption side — /// `execute_from_surface` must hand parked notes to the child's recorded /// result, the production edge that once discarded every report. Driven @@ -7865,6 +8064,7 @@ async fn parked_note_rides_through_the_execute_from_surface_glue() { surface.pending_eviction_notes ); } + #[test] fn small_surface_depth_cap_removes_only_agent() { let mut runtime = @@ -7890,11 +8090,13 @@ fn small_surface_depth_cap_removes_only_agent() { .collect() ); } + fn disabled_feature_agent_surface_options() -> AgentToolSurfaceOptions { let mut options = AgentToolSurfaceOptions::new(ShellPolicy::Full); options.goal_state = Some(crate::tools::goal::new_shared_goal_state()); options } + #[test] fn subagent_general_catalog_keeps_parent_surface_except_root_goal_mutators() { let tmp = tempdir().expect("tempdir"); @@ -7936,6 +8138,7 @@ fn subagent_general_catalog_keeps_parent_surface_except_root_goal_mutators() { "default General sub-agent catalog must match the parent Agent surface except for root-owned goal mutators" ); } + #[test] fn subagent_feature_gates_match_parent_agent_surface() { let tmp = tempdir().expect("tempdir"); @@ -7976,6 +8179,7 @@ fn subagent_feature_gates_match_parent_agent_surface() { assert!(!child_names.contains(name), "{name} should be child-gated"); } } + /// Model the clamp's deny list the way a real spawn threads it in /// (fleet/worker_runtime.rs): a `write: false` member loses the raw shell /// surface and the non-shell execution surface even when its posture holds @@ -8003,6 +8207,7 @@ fn seed_read_only_role_deny_list(runtime: &mut SubAgentRuntime) { } } } + /// #5426 acceptance point 1, gate-level: a live scout must be able to run /// the three canonical read-only inspection commands DIRECTLY through /// canonical `bash` — `git -C ... log`, `find ... | head`, `npm view` — with @@ -8083,6 +8288,7 @@ fn scout_posture_gate_admits_agent_readonly_bash_commands() { "legacy `Bash` is not the carve-out name; the envelope must keep refusing it" ); } + /// #5595: catalog admission, role posture, and the execution envelope are not /// enough. The concrete read-only executor must accept the canonical Git shape /// agents use when the repository is not their process cwd. This is the exact @@ -8135,6 +8341,7 @@ async fn read_only_inspection_roles_execute_pwd_and_absolute_git_log() { } } } + /// Read-only text filters may transform stdout, but they must not reach their /// file-output or helper-program forms through the same bounded bash carve-out. #[tokio::test] @@ -8217,6 +8424,344 @@ async fn read_only_inspection_roles_cannot_write_through_text_filters() { // bounded read-only probes the inspection roles use — so "with evidence" // includes evidence the child gathered itself (`wc`, `ls`, `cat`, `grep`) — // while custom commands stay refused and the workspace stays read-only. + +/// The roster description must promise exactly the surface the role has: +/// built-in gates plus a bounded read-only evidence shell, no custom commands. +#[test] +fn verifier_roster_description_states_the_bounded_contract() { + let description = FleetRole::Verifier.description(); + assert!( + description.contains("built-in verification gates"), + "roster description must name the built-in gates: {description}" + ); + assert!( + description.contains("bounded read-only shell"), + "roster description must promise the bounded evidence shell: {description}" + ); + assert!( + description.contains("cannot execute custom commands"), + "roster description must refuse custom commands: {description}" + ); + // The Agent-tool `type` description carries the same truth, briefly. + assert!( + SUBAGENT_TYPE_DESCRIPTION.contains("test: runs the built-in verification gates"), + "type description must match the roster: {SUBAGENT_TYPE_DESCRIPTION}" + ); +} + +/// Acceptance for the verifier lane, sibling of +/// `read_only_inspection_roles_execute_pwd_and_absolute_git_log`: a live +/// `test` child must execute a bounded read-only probe through canonical +/// `bash` — `wc -c` on a fixture — with the raw-shell deny list still +/// installed, reaching the lane *through* it via +/// `allows_bounded_readonly_bash`. +#[cfg_attr(not(unix), ignore = "wc and unix paths")] +#[tokio::test] +async fn verifier_child_executes_a_bounded_readonly_bash_probe_for_evidence() { + let (tmp, registry) = read_only_with_shell_registry(); + let fixture = tmp.path().join("evidence.txt"); + std::fs::write(&fixture, "countable\n").expect("fixture"); + + let command = format!("wc -c {}", fixture.display()); + assert!( + !registry.role_blocks_unhardened_process_tool("bash"), + "canonical bash must reach the bounded lane, not the unhardened wall" + ); + let output = registry + .execute( + "agent_verifier_evidence", + "bash", + json!({ "command": command }), + ) + .await + .unwrap_or_else(|error| panic!("verifier probe must run `{command}`: {error}")); + // The count is wc's FIRST whitespace-separated field (`10 /path`), not a + // substring: `contains("10")` would also match line noise like `210`. + let count = output.split_ascii_whitespace().next().unwrap_or_default(); + assert_eq!( + count, "10", + "verifier probe must report the fixture's byte count as wc's first field: {output}" + ); +} + +/// The walls around the lane stay up: the legacy raw `Bash` spelling, a +/// custom `Run` command, and a workspace write are all refused for a +/// verifier — at dispatch, and with the `reject_unbounded_verification` +/// contract text for the custom command. +#[tokio::test] +async fn verifier_child_still_refuses_raw_shell_custom_commands_and_writes() { + let (_tmp, registry) = read_only_with_shell_registry(); + + let raw = registry + .execute( + "agent_verifier_walls", + "Bash", + json!({"action": "run", "command": "rm -rf src"}), + ) + .await + .expect_err("legacy raw Bash must stay refused for a verifier"); + let raw = raw.to_string(); + assert!( + raw.contains("not available") || raw.contains("not allowed"), + "raw Bash refusal did not identify the wall: {raw}" + ); + // The second legacy-`Bash` wall is the posture's classifier-lane rule: a + // command the classifier cannot prove read-only must not re-enter + // through the alias, pinned here on this Verifier-labeled registry (the + // sibling pin covers the same wall through a Scout fixture). A + // proven-read-only shape still fails this wall — the posture rule + // refuses the legacy alias name itself (`bash_must_use_classifier_lane` + // is case-insensitive), so the alias never re-enters at any + // provability. + assert!( + !registry.posture_permits_tool("Bash", Some(&json!({"command": "rm -rf src"}))), + "the posture wall must refuse unprovable commands through the legacy `Bash` alias for a Verifier" + ); + + let custom_command = json!({ + "action": "verifiers", + "commands": [{"name": "x", "program": "bash", "args": ["-lc", "id"]}] + }); + // The contract text, at the guard that owns it: a custom command is the + // unbounded form, refused for a write-denied child with or without shell. + assert!( + super::reject_unbounded_verification("Run", &custom_command, true).is_err() + && super::reject_unbounded_verification("Run", &custom_command, false).is_err(), + "custom Run commands must be refused by the unbounded-verification guard" + ); + // …and the refusal reaches the real dispatch path too, from one of the + // two walls that own it — the approval gate (a Required call with no + // session decision) or the unbounded-verification guard — rather than + // failing for some unrelated reason. + let custom = registry + .execute("agent_verifier_walls", "Run", custom_command) + .await + .expect_err("custom Run commands are refused by design"); + let custom = custom.to_string(); + assert!( + custom.contains("requires approval") || custom.contains("operator-supplied commands"), + "custom command refusal must come from the approval or unbounded wall: {custom}" + ); + + let write = registry + .execute( + "agent_verifier_walls", + "File", + json!({"action": "write", "path": "a.rs", "content": "b"}), + ) + .await + .expect_err("a verifier must not mutate the workspace"); + assert!( + write.to_string().contains("not allowed") || write.to_string().contains("read-only"), + "workspace-write refusal did not identify the denial: {write}" + ); +} + +/// A registry for a write-capable role whose parent handed it a read-only +/// shell and no writes — what `derive_child` produces for a Builder, Worker, +/// or Custom child of a Scout or Planner on the plain `agent` nesting path, +/// where no Fleet deny list is installed. +fn read_only_shell_child_registry(role: FleetRole) -> (tempfile::TempDir, SubAgentToolRegistry) { + let tmp = tempdir().expect("tempdir"); + let mut runtime = + stub_runtime().with_agent_tool_surface_options(enabled_agent_surface_options()); + runtime.context = ToolContext::new(tmp.path().to_path_buf()); + runtime.allow_shell = true; + let mut profile = WorkerRuntimeProfile::for_role(role.clone()); + profile.permissions = crate::worker_profile::PermissionSet::read_only_with_network(); + profile.shell = crate::worker_profile::ShellPolicy::ReadOnly; + runtime.worker_profile = profile; + let registry = SubAgentToolRegistry::new( + runtime, + role, + None, + Arc::new(Mutex::new(TodoList::new())), + Arc::new(Mutex::new(PlanState::default())), + ); + (tmp, registry) +} + +/// Shell authority is the Verifier's carve-out alone. Every other role whose +/// runtime shell was narrowed to read-only — the Builder, Worker, or Custom +/// child of a read-only parent — holds no process authority: the built-in +/// test runner stays refused at posture and envelope and absent from the +/// catalog, exactly as before the verifier lane existed, so delegation +/// cannot widen a read-only parent into a child that runs the suite. +#[tokio::test] +async fn read_only_shell_children_of_other_roles_hold_no_shell_authority() { + let gate = json!({"action": "tests", "args": "--lib fleet::exact"}); + for role in [FleetRole::Builder, FleetRole::Worker, FleetRole::Custom] { + let (_tmp, registry) = read_only_shell_child_registry(role.clone()); + assert!( + !registry.posture_permits_tool("Run", Some(&gate)), + "{role:?} with a read-only shell must not hold the Required surface" + ); + assert!( + registry.envelope_refusal("Run", &gate).is_some(), + "{role:?} envelope must report no shell authority" + ); + let names: Vec = registry + .tools_for_model(&role) + .iter() + .map(|tool| tool.name.clone()) + .collect(); + assert!( + !names.iter().any(|name| name == "Run"), + "{role:?} catalog must not offer the verification gate: {names:?}" + ); + let refused = registry + .execute("agent_read_only_child", "Run", gate.clone()) + .await + .expect_err("the test runner must stay refused for a read-only-shell child"); + assert!( + refused.to_string().contains("not permitted") || refused.to_string().contains("shell"), + "{role:?}: {refused}" + ); + } + // The carve-out itself: the same narrowed shell on the Verifier keeps the + // bounded gates (pinned in detail by + // `verifier_child_keeps_the_default_and_selection_bounded_run_surface`). + let (_tmp, verifier) = read_only_with_shell_registry(); + assert!(verifier.posture_permits_tool("Run", Some(&gate))); + assert!(verifier.envelope_refusal("Run", &gate).is_none()); +} + +/// The purpose survives the narrowing: default gates and test-selection +/// arguments stay admitted for the verifier at posture and envelope, and the +/// catalog still offers `Run`. +#[test] +fn verifier_child_keeps_the_default_and_selection_bounded_run_surface() { + let (_tmp, registry) = read_only_with_shell_registry(); + + for input in [ + json!({}), + json!({"action": "tests"}), + json!({"action": "tests", "args": "-p codewhale-tui"}), + json!({"action": "tests", "args": "--lib fleet::exact"}), + ] { + assert!( + registry.posture_permits_tool("Run", Some(&input)), + "verifier posture must admit the bounded gate: {input}" + ); + assert!( + registry.envelope_refusal("Run", &input).is_none(), + "verifier envelope must admit the bounded gate: {input}" + ); + } + let tools = registry.tools_for_model(&FleetRole::Verifier); + let names: Vec<&str> = tools.iter().map(|tool| tool.name.as_str()).collect(); + assert!( + names.contains(&"Run"), + "catalog must keep offering the verification gate: {names:?}" + ); +} + +/// A Verifier child of a read-only role parent (its shell was clamped by the +/// parent, not narrowed by the Verifier role) regains nothing through the +/// carve-out: the built-in gate runner stays refused at posture and envelope +/// and off the catalog, while the classifier-bounded bash evidence lane — +/// which the read-only parent holds too — keeps working. Delegation moves +/// work, never authority. +#[tokio::test] +async fn clamped_verifier_child_of_read_only_parent_holds_no_run_surface() { + let (_tmp, registry) = read_only_shell_child_registry(FleetRole::Verifier); + for input in [ + json!({}), + json!({"action": "tests"}), + json!({"action": "tests", "args": "-p codewhale-tui"}), + ] { + assert!( + !registry.posture_permits_tool("Run", Some(&input)), + "a clamped verifier must not run gates: {input}" + ); + assert!( + registry.envelope_refusal("Run", &input).is_some(), + "the envelope must refuse the gate for a clamped verifier: {input}" + ); + } + let tools = registry.tools_for_model(&FleetRole::Verifier); + let names: Vec<&str> = tools.iter().map(|tool| tool.name.as_str()).collect(); + assert!( + !names.contains(&"Run"), + "the clamped verifier catalog must not offer the gate: {names:?}" + ); + assert!( + names.contains(&"bash"), + "the clamped verifier keeps the shared bash evidence lane: {names:?}" + ); + assert!( + registry.posture_permits_tool("bash", Some(&json!({"command": "pwd"}))), + "the shared evidence lane admits read-only probes" + ); + assert!( + !registry.posture_permits_tool("bash", Some(&json!({"command": "rm -rf src"}))), + "the evidence lane stays classifier-bounded" + ); +} + +/// The sub-subagent chain: a carve-out Verifier parent (its effective shell +/// is the role's read-only narrowing; the Run grant is a registry surface, +/// not shell authority) spawns a Builder or Worker child. The child +/// derivation re-clamps before it captures shell authority, so the chain +/// holds no extra authority: the spawn ceiling the carve-out parent +/// publishes stays read-only, and a child built under that ceiling keeps +/// the same no-Run, classifier-bounded shape this module pins elsewhere. +/// The round-3 re-key bug lived exactly in this ordering. +#[test] +fn sub_subagent_chain_of_a_carveout_verifier_holds_no_extra_authority() { + // A carve-out Verifier in a full session: the coarse session flag stays + // `allow_shell = true` while its effective runtime shell is the + // read-only narrowing. The ceiling its spawns publish must read the + // effective shell, not the flag. + let mut runtime = + stub_runtime().with_agent_tool_surface_options(enabled_agent_surface_options()); + runtime.allow_shell = true; + runtime.worker_profile.shell = crate::worker_profile::ShellPolicy::ReadOnly; + let ceiling = session_permission_ceiling(&runtime); + assert_eq!( + ceiling.shell, + codewhale_workflow::ShellCeiling::ReadOnly, + "the carve-out must not widen the shell ceiling a verifier publishes for its own spawns" + ); + // The chain child registry shape (what the derivation above produces for + // the next hop): no built-in gate surface, evidence lane classifier- + // bounded — for the Builder the chain would most plausibly reach for. + let (_tmp, registry) = read_only_shell_child_registry(FleetRole::Builder); + assert!( + !registry.posture_permits_tool("Run", Some(&json!({"action": "tests"}))), + "a chain Builder must not hold the verification gate" + ); + assert!( + registry + .envelope_refusal("Run", &json!({"action": "tests"})) + .is_some(), + "a chain Builder envelope must refuse the verification gate" + ); + assert!( + !registry.posture_permits_tool("bash", Some(&json!({"command": "rm -rf src"}))), + "a chain Builder keeps the evidence lane classifier-bounded" + ); +} + +/// The verifier intro tells the child about its bounded read-only shell and +/// the custom-command refusal, scoped to in-session delegations the way +/// `FleetRole`'s roster description scopes the role. +#[test] +fn verifier_intro_states_the_evidence_contract() { + assert!( + VERIFIER_AGENT_INTRO.contains("bounded read-only shell"), + "verifier intro must name the bounded read-only evidence shell: {VERIFIER_AGENT_INTRO}" + ); + assert!( + VERIFIER_AGENT_INTRO.contains("custom commands are refused"), + "verifier intro must repeat the custom-command refusal: {VERIFIER_AGENT_INTRO}" + ); + assert!( + VERIFIER_AGENT_INTRO.contains("in-session delegations"), + "the verifier's bounded read-only shell must be scoped to in-session delegations: {VERIFIER_AGENT_INTRO}" + ); +} + #[test] fn explore_catalog_inherits_web_but_hides_write_shell_and_fim_tools() { let tmp = tempdir().expect("tempdir"); @@ -8268,6 +8813,7 @@ fn explore_catalog_inherits_web_but_hides_write_shell_and_fim_tools() { file.input_schema ); } + /// Under the real spawn clamp — raw-shell deny list installed, `Bash` /// included — Scout and Reviewer reach exactly one shell entry point: /// canonical lowercase `bash`, bounded by the strict read-only classifier. @@ -8482,6 +9028,7 @@ async fn read_only_roles_expose_and_dispatch_lowercase_bash_only() { ); } } + #[tokio::test] async fn planner_exposes_and_dispatches_read_only_bash_probes() { let tmp = tempdir().expect("tempdir"); @@ -8535,6 +9082,7 @@ async fn planner_exposes_and_dispatches_read_only_bash_probes() { .expect("planner must dispatch a bounded read"); assert_eq!(output, sentinel); } + #[tokio::test] async fn scout_shell_respects_parent_shell_and_network_ceilings() { let tmp = tempdir().expect("tempdir"); @@ -8597,21 +9145,36 @@ async fn scout_shell_respects_parent_shell_and_network_ceilings() { crate::tools::todo::new_shared_todo_list(), crate::tools::plan::new_shared_plan_state(), ); - assert!( - !tool_names(registry.tools_for_model(&role)).contains("bash"), - "{role:?} must not inherit the read-only inspection bash catalog" - ); - if role == FleetRole::Verifier { + if role == FleetRole::Consultant { assert!( - registry - .execute("agent_verifier", "bash", json!({"command": "pwd"})) - .await - .is_err(), - "Verifier keeps bounded Run, never arbitrary Bash" + !tool_names(registry.tools_for_model(&role)).contains("bash"), + "{role:?} must not inherit the read-only inspection bash catalog" + ); + } else { + // The verifier shares the inspection lane: canonical lowercase + // `bash` is advertised and classifier-bounded — evidence probes + // run, arbitrary shell keeps the bounded-lane refusal. + assert!( + tool_names(registry.tools_for_model(&role)).contains("bash"), + "verifier must advertise the bounded read-only bash lane" + ); + let probe = registry + .execute("agent_verifier", "bash", json!({"command": "pwd"})) + .await + .expect("the verifier's bounded read-only probe runs"); + assert!(!probe.trim().is_empty(), "pwd must return evidence"); + let arbitrary = registry + .execute("agent_verifier", "bash", json!({"command": "rm -rf src"})) + .await + .expect_err("arbitrary shell stays refused behind the lane"); + assert!( + arbitrary.to_string().contains("[shell.readonly.command]"), + "unexpected refusal: {arbitrary}" ); } } } + /// An auto-approved Worker registry (shell-capable, everything else default) /// carrying the given exec-policy engine — the smallest fixture that reaches /// the delegated-call execpolicy gate past every posture gate. @@ -8621,6 +9184,7 @@ fn auto_approved_worker_registry_with_engine( ) -> SubAgentToolRegistry { worker_registry_with_engine_and_approval(tmp, exec_policy_engine, true) } + /// The same fixture with the parent session's approval posture under the /// caller's control: `false` models every prompting/fail-closed posture /// (Suggest/Auto/Never), where the main line would surface an approval @@ -8644,6 +9208,7 @@ fn worker_registry_with_engine_and_approval( crate::tools::plan::new_shared_plan_state(), ) } + /// Fork-delta behavior test: a command the parent session's execpolicy /// hard-denies must fail closed inside the child with the main line's deny /// wording — the auto-approve escape hatch this wiring closes — while a @@ -8835,6 +9400,7 @@ async fn subagent_execpolicy_empty_ruleset_keeps_auto_approve_behavior() { .to_string(); assert!(error.contains("denied prefix rule 'echo'"), "{error}"); } + /// A typed Deny rule on a file path blocks the child's File read of that path /// while a workspace-relative read outside the rule still lands. #[tokio::test] @@ -8871,6 +9437,7 @@ async fn subagent_execpolicy_blocks_denied_file_read_but_allows_workspace_relati .expect("a workspace-relative read outside the deny rule passes"); assert!(output.contains("plain notes"), "{output}"); } + /// Typed ask rules are prompt decisions on the main line. Under a /// parent-auto-approving session the main line would auto-run, so the child /// passes the call through and it really executes; the refusal face under a @@ -8896,6 +9463,7 @@ async fn subagent_execpolicy_prompt_decision_passes_under_parent_auto_approve() .expect("an ask-rule (prompt) decision must not block a child"); assert!(output.contains("askme"), "{output}"); } + /// The registry's engine handle is the parent's live engine, not a snapshot: /// a ruleset installed after the registry was built binds its delegated calls. #[tokio::test] @@ -8929,6 +9497,7 @@ async fn subagent_execpolicy_ruleset_update_binds_already_delegated_calls() { .to_string(); assert!(error.contains("denied prefix rule 'echo'"), "{error}"); } + #[test] fn implementer_catalog_inherits_patch_and_fim_when_enabled() { let tmp = tempdir().expect("tempdir"); @@ -8962,6 +9531,7 @@ fn implementer_catalog_inherits_patch_and_fim_when_enabled() { ); } } + #[test] fn every_fleet_role_catalog_advertises_one_executable_load_skill() { // load_skill contract (#4651): the parent Agent surface and every @@ -9047,12 +9617,13 @@ fn every_fleet_role_catalog_advertises_one_executable_load_skill() { "read-only role {role:?} keeps load_skill without gaining {denied}" ); } - // Scout/reviewer/planner expose only canonical lowercase bash, - // whose concrete calls are reclassified. Verifier keeps its - // bounded Run surface but not raw bash. + // Inspection roles and the verifier expose only canonical + // lowercase bash, whose concrete calls are reclassified by the + // read-only classifier (the verifier's evidence lane). Consultant + // is shell-less and gains no bash at all. if matches!( &role, - FleetRole::Scout | FleetRole::Reviewer | FleetRole::Planner + FleetRole::Scout | FleetRole::Reviewer | FleetRole::Planner | FleetRole::Verifier ) { assert!(names.contains("bash"), "{role:?} keeps read-only bash"); for denied in ["exec_shell", "task_shell_start"] { @@ -9076,6 +9647,7 @@ fn every_fleet_role_catalog_advertises_one_executable_load_skill() { } } } + #[test] fn custom_child_allowlist_omitting_load_skill_fails_closed() { // Custom children get exactly their explicit allow-list: load_skill is @@ -9117,6 +9689,7 @@ fn custom_child_allowlist_omitting_load_skill_fails_closed() { "explicitly listed load_skill is granted: {names:?}" ); } + #[tokio::test] async fn plan_parent_profile_narrows_even_implementer_child_to_read_only() { let tmp = tempdir().expect("tempdir"); @@ -9164,6 +9737,7 @@ async fn plan_parent_profile_narrows_even_implementer_child_to_read_only() { ); assert!(!workspace.join("plan-parent-write.txt").exists()); } + #[tokio::test] async fn api_timeout_preserves_checkpoint_and_returns_needs_input_without_parking() { let tmp = tempdir().expect("tempdir"); @@ -9338,6 +9912,7 @@ async fn api_timeout_preserves_checkpoint_and_returns_needs_input_without_parkin "projection inspection must not respawn the child implicitly" ); } + #[tokio::test] async fn subagent_retries_api_timeout_before_succeeding() { let tmp = tempdir().expect("tempdir"); @@ -9417,6 +9992,7 @@ async fn subagent_retries_api_timeout_before_succeeding() { assert_eq!(snapshot.status, SubAgentStatus::Completed); assert_eq!(snapshot.result.as_deref(), Some("recovered answer")); } + #[test] fn api_timeout_retry_backoff_doubles_and_caps() { let base = SUBAGENT_API_TIMEOUT_INITIAL_BACKOFF; @@ -9430,6 +10006,7 @@ fn api_timeout_retry_backoff_doubles_and_caps() { ); } } + #[test] fn api_timeout_retry_delay_stays_within_jitter_bounds() { let base = SUBAGENT_API_TIMEOUT_INITIAL_BACKOFF; @@ -9449,12 +10026,14 @@ fn api_timeout_retry_delay_stays_within_jitter_bounds() { } } } + #[test] fn transient_provider_classifier_matches_sse_header_timeout() { let err = anyhow::anyhow!("SSE stream request did not receive response headers after 45s"); assert!(is_transient_subagent_provider_error(&err)); } + #[test] fn transient_provider_classifier_matches_body_decode_failures() { // A provider that accepts the request and dies mid-response is a @@ -9475,6 +10054,7 @@ fn transient_provider_classifier_matches_body_decode_failures() { "auth failures stay fatal" ); } + #[test] fn transient_provider_classifier_matches_structured_rate_limit() { let err = anyhow::Error::new(crate::llm_client::LlmError::RateLimited { @@ -9485,6 +10065,7 @@ fn transient_provider_classifier_matches_structured_rate_limit() { assert!(is_transient_subagent_provider_error(&err)); } + #[tokio::test] async fn subagent_retries_transient_provider_header_timeout_before_succeeding() { let tmp = tempdir().expect("tempdir"); @@ -9559,6 +10140,7 @@ async fn subagent_retries_transient_provider_header_timeout_before_succeeding() assert_eq!(snapshot.status, SubAgentStatus::Completed); assert_eq!(snapshot.result.as_deref(), Some("recovered answer")); } + #[tokio::test] async fn subagent_rate_limit_exhaustion_interrupts_with_checkpoint() { let tmp = tempdir().expect("tempdir"); @@ -9644,6 +10226,7 @@ async fn subagent_rate_limit_exhaustion_interrupts_with_checkpoint() { assert!(checkpoint.continuable); assert!(snapshot.needs_input.is_some()); } + #[tokio::test] async fn spawn_duplicate_session_name_error_names_conflicting_agent() { // #2656: the duplicate-name error must identify the conflicting agent so a @@ -9704,6 +10287,7 @@ async fn spawn_duplicate_session_name_error_names_conflicting_agent() { "includes elapsed time since spawn: {msg}" ); } + #[tokio::test] async fn spawn_session_name_held_by_prior_session_agent_does_not_collide() { // A completed worker hydrated from an earlier session's ledger is not @@ -9763,6 +10347,7 @@ async fn spawn_session_name_held_by_prior_session_agent_does_not_collide() { assert_eq!(fresh.session_name, "researcher"); assert!(!guard.is_from_prior_session(fresh)); } + /// Seed a same-session child holding `name` in the given status; returns its /// agent id. Shared by the name-release tests below. async fn seed_named_same_session_agent( @@ -9797,6 +10382,7 @@ async fn seed_named_same_session_agent( assert!(!guard.is_from_prior_session(&guard.agents[&id])); id } + #[tokio::test] async fn spawn_session_name_released_once_same_session_holder_settles() { // 2026-10-23 research fan-out: the parent cancelled `macro-policy`, got @@ -9862,6 +10448,7 @@ async fn spawn_session_name_released_once_same_session_holder_settles() { ); } } + #[tokio::test] async fn spawn_session_name_still_held_by_interrupted_same_session_child() { // An interrupted child is parked, not finished: its name stays reserved @@ -9914,6 +10501,7 @@ async fn spawn_session_name_still_held_by_interrupted_same_session_child() { "cancel is a silent no-op on an interrupted child: {msg}" ); } + #[tokio::test] async fn spawn_session_name_running_holder_advises_wait_or_cancel() { // A running holder can be waited out or cancelled; the refusal must keep @@ -9956,6 +10544,7 @@ async fn spawn_session_name_running_holder_advises_wait_or_cancel() { "a running holder can be waited out or cancelled: {msg}" ); } + #[test] fn session_name_resolution_prefers_live_holder_then_newest_settled() { let tmp = tempdir().expect("tempdir"); @@ -10005,6 +10594,7 @@ fn session_name_resolution_prefers_live_holder_then_newest_settled() { older_id ); } + #[test] fn session_name_resolution_ignores_prior_boot_interrupted_ghost() { // Hydration rewrites a child that was Running at shutdown into a @@ -10058,6 +10648,7 @@ fn session_name_resolution_ignores_prior_boot_interrupted_ghost() { retry_id ); } + #[tokio::test] async fn id_shaped_session_name_requires_an_explicit_name_selector() { // Name reuse cannot reinterpret a durable receipt. Both live and settled @@ -10115,6 +10706,7 @@ async fn id_shaped_session_name_requires_an_explicit_name_selector() { ); } } + #[test] fn spawn_contention_error_names_the_read_only_path() { // The ledger text is written for writers. The refusal seen in the @@ -10140,6 +10732,7 @@ fn spawn_contention_error_names_the_read_only_path() { let other = "write-claim capacity is 64 active owners; complete, serialize, or isolate existing work before admitting another writer".to_string(); assert_eq!(spawn_claim_error_with_guidance(other.clone()), other); } + #[test] fn general_role_text_discloses_workspace_claim_and_read_only_alternative() { // The roster entry, the type enum text and the tool description are the @@ -10165,6 +10758,7 @@ fn general_role_text_discloses_workspace_claim_and_read_only_alternative() { "{description}" ); } + #[tokio::test] async fn shared_write_claim_is_registered_before_parallel_launch_and_manifested() { let tmp = tempdir().expect("tempdir"); @@ -10246,6 +10840,7 @@ async fn shared_write_claim_is_registered_before_parallel_launch_and_manifested( assert_eq!(manifest.coordination_contracts, vec!["public-api"]); assert_eq!(manifest.expected_artifact.as_deref(), Some("tested patch")); } + #[tokio::test] async fn write_capable_agent_does_not_launch_when_durable_registration_fails() { let tmp = tempdir().expect("tempdir"); @@ -10296,6 +10891,7 @@ async fn write_capable_agent_does_not_launch_when_durable_registration_fails() { ); assert!(blocked_state_path.is_dir()); } + #[tokio::test] async fn write_scope_contention_covers_regular_agent_and_active_fleet_writer() { let tmp = tempdir().expect("tempdir"); @@ -10369,6 +10965,7 @@ async fn write_scope_contention_covers_regular_agent_and_active_fleet_writer() { "{launch}" ); } + #[tokio::test] async fn test_running_count_counts_only_agents_with_live_task_handles() { let mut manager = SubAgentManager::new(PathBuf::from("."), 1); @@ -10401,6 +10998,7 @@ async fn test_running_count_counts_only_agents_with_live_task_handles() { .expect("live task handle") .abort(); } + #[test] fn test_running_count_ignores_running_status_without_task_handle() { let mut manager = SubAgentManager::new(PathBuf::from("."), 1); @@ -10422,6 +11020,7 @@ fn test_running_count_ignores_running_status_without_task_handle() { assert_eq!(manager.running_count(), 0); } + #[tokio::test] async fn test_running_count_counts_running_agents_until_status_reconciles() { let mut manager = SubAgentManager::new(PathBuf::from("."), 1); @@ -10448,6 +11047,7 @@ async fn test_running_count_counts_running_agents_until_status_reconciles() { assert_eq!(manager.running_count(), 1); } + #[tokio::test] async fn admission_limit_counts_queued_and_running_workers_separately() { let mut manager = SubAgentManager::new(PathBuf::from("."), 2).with_admission_limit(4); @@ -10517,6 +11117,7 @@ async fn admission_limit_counts_queued_and_running_workers_separately() { .abort(); } } + #[tokio::test] async fn cleanup_auto_cancels_stale_running_agent_and_releases_slot() { use tokio_util::sync::CancellationToken; @@ -10593,6 +11194,7 @@ async fn cleanup_auto_cancels_stale_running_agent_and_releases_slot() { AgentWorkerStatus::Cancelled ); } + #[tokio::test] async fn status_projection_reconciles_stale_running_agent() { let mut inner = SubAgentManager::new(PathBuf::from("."), 1) @@ -10643,6 +11245,7 @@ async fn status_projection_reconciles_stale_running_agent() { .contains("Auto-cancelled") ); } + #[tokio::test] async fn cleanup_keeps_recent_running_agent() { let mut manager = SubAgentManager::new(PathBuf::from("."), 1) @@ -10680,6 +11283,7 @@ async fn cleanup_keeps_recent_running_agent() { .expect("live task handle") .abort(); } + #[tokio::test] async fn touch_refreshes_stale_running_agent_heartbeat() { // Use a heartbeat timeout that is comfortably larger than the synchronous @@ -10724,6 +11328,7 @@ async fn touch_refreshes_stale_running_agent_heartbeat() { .expect("live task handle") .abort(); } + #[test] fn test_persist_and_reload_marks_running_agent_as_interrupted() { let tmp = tempdir().expect("tempdir"); @@ -10764,6 +11369,7 @@ fn test_persist_and_reload_marks_running_agent_as_interrupted() { if message.contains(SUBAGENT_RESTART_REASON) )); } + #[test] fn generated_whale_name_is_not_persisted_or_replayed_on_load() { let tmp = tempdir().expect("tempdir"); @@ -10820,6 +11426,7 @@ fn generated_whale_name_is_not_persisted_or_replayed_on_load() { assert_eq!(snapshot.name, "docs-worker"); assert_eq!(snapshot.nickname, None); } + #[test] fn explicit_nonmatching_whale_word_is_persisted_and_loaded() { let tmp = tempdir().expect("tempdir"); @@ -10865,6 +11472,7 @@ fn explicit_nonmatching_whale_word_is_persisted_and_loaded() { let snapshot = reloaded.get_result(agent_id).expect("agent survives load"); assert_eq!(snapshot.nickname.as_deref(), Some(explicit_whale)); } + #[test] fn persist_and_reload_preserves_checkpoint_for_interrupted_running_agent() { let tmp = tempdir().expect("tempdir"); @@ -10915,6 +11523,7 @@ fn persist_and_reload_preserves_checkpoint_for_interrupted_running_agent() { assert_eq!(checkpoint.messages.len(), 2); assert_eq!(message_text(&checkpoint.messages[1]), "partial progress"); } + #[test] fn restart_reconciles_every_orphan_execution_status_once_and_preserves_receipts() { let tmp = tempdir().expect("tempdir"); @@ -11087,6 +11696,7 @@ fn restart_reconciles_every_orphan_execution_status_once_and_preserves_receipts( "a later restart does not append duplicate interrupted receipts" ); } + #[cfg(unix)] #[test] fn load_state_rejects_symlinked_state_file() { @@ -11111,6 +11721,7 @@ fn load_state_rejects_symlinked_state_file() { .expect_err("symlinked state should fail"); assert!(format!("{err:#}").contains("must not traverse symlinks")); } + #[test] fn persist_state_rejects_state_path_outside_state_root() { let tmp = tempdir().expect("tempdir"); @@ -11125,6 +11736,7 @@ fn persist_state_rejects_state_path_outside_state_root() { assert!(format!("{err:#}").contains("must stay within state root")); } + #[test] fn explicit_state_roots_isolate_managers_for_the_same_execution_workspace() { let tmp = tempdir().expect("tempdir"); @@ -11199,6 +11811,7 @@ fn explicit_state_roots_isolate_managers_for_the_same_execution_workspace() { "an explicit state root must keep control-plane files out of the execution workspace" ); } + #[cfg(unix)] #[test] fn persist_state_rejects_symlinked_state_directory() { @@ -11218,6 +11831,7 @@ fn persist_state_rejects_symlinked_state_directory() { || format!("{err:#}").contains("must not traverse symlinks") ); } + #[test] fn test_interrupted_status_name_and_summary() { let snapshot = make_snapshot(SubAgentStatus::Interrupted( @@ -11228,6 +11842,7 @@ fn test_interrupted_status_name_and_summary() { } // === v0.6.6 — sub-agent authority unification === + #[test] fn build_allowed_tools_general_returns_none_for_full_inheritance() { // Default behavior: General agent with no explicit list inherits the @@ -11238,6 +11853,7 @@ fn build_allowed_tools_general_returns_none_for_full_inheritance() { "General with no explicit_tools should default to full inheritance (None), got {result:?}" ); } + #[test] fn build_allowed_tools_explore_returns_none_for_full_inheritance() { // Per-type allowlists are now advisory — Explore also gets the full @@ -11248,6 +11864,7 @@ fn build_allowed_tools_explore_returns_none_for_full_inheritance() { "Explore with no explicit_tools should default to full inheritance" ); } + #[test] fn build_allowed_tools_custom_requires_explicit_list() { // Custom is the one type that REQUIRES explicit allowed_tools. @@ -11257,12 +11874,14 @@ fn build_allowed_tools_custom_requires_explicit_list() { "got: {err}" ); } + #[test] fn build_allowed_tools_explicit_list_returned_as_some() { let explicit = vec!["read_file".to_string(), "list_dir".to_string()]; let result = build_allowed_tools(&FleetRole::Custom, Some(explicit.clone()), true).unwrap(); assert_eq!(result, Some(explicit)); } + #[test] fn build_allowed_tools_explicit_list_dedupes_and_trims() { let explicit = vec![ @@ -11277,6 +11896,7 @@ fn build_allowed_tools_explicit_list_dedupes_and_trims() { Some(vec!["read_file".to_string(), "list_dir".to_string()]) ); } + #[test] fn parse_spawn_request_extracts_cwd_when_present() { let input = json!({ @@ -11289,6 +11909,7 @@ fn parse_spawn_request_extracts_cwd_when_present() { Some(".worktrees/feature-a".to_string()) ); } + #[test] fn parse_spawn_request_accepts_worktree_isolation() { let input = json!({ @@ -11310,6 +11931,7 @@ fn parse_spawn_request_accepts_worktree_isolation() { Some("feature-a".to_string()) ); } + #[test] fn parse_spawn_request_accepts_cwd_with_worktree_isolation() { let input = json!({ @@ -11321,6 +11943,7 @@ fn parse_spawn_request_accepts_cwd_with_worktree_isolation() { assert!(parsed.worktree.is_some()); assert!(parsed.cwd.is_some()); } + #[test] fn git_repo_root_finds_repo_from_direct_cwd() { let repo = init_subagent_git_repo(); @@ -11330,6 +11953,7 @@ fn git_repo_root_finds_repo_from_direct_cwd() { repo.path().canonicalize().expect("canonical repo") ); } + #[test] fn git_repo_root_discovers_one_level_nested_repo_from_harness() { let repo = init_subagent_git_repo(); @@ -11349,6 +11973,7 @@ fn git_repo_root_discovers_one_level_nested_repo_from_harness() { nested.canonicalize().expect("canonical nested") ); } + #[test] fn git_repo_root_reports_attempted_paths_when_no_repo_found() { // Use the system temp dir rather than the checkout's parent: a checkout @@ -11379,18 +12004,21 @@ fn git_repo_root_reports_attempted_paths_when_no_repo_found() { "expected friendly attempted-path error, got: {message}" ); } + #[test] fn parse_spawn_request_cwd_absent_yields_none() { let input = json!({ "prompt": "no cwd" }); let parsed = parse_spawn_request(&input).expect("spawn request should parse"); assert!(parsed.cwd.is_none()); } + #[test] fn parse_spawn_request_cwd_empty_string_yields_none() { let input = json!({ "prompt": "empty cwd", "cwd": " " }); let parsed = parse_spawn_request(&input).expect("spawn request should parse"); assert!(parsed.cwd.is_none(), "whitespace-only cwd should be None"); } + #[test] fn create_isolated_worktree_creates_branch_checkout_outside_parent_repo() { let repo = init_subagent_git_repo(); @@ -11419,6 +12047,7 @@ fn create_isolated_worktree_creates_branch_checkout_outside_parent_repo() { Some("codex/agent-isolated-test") ); } + #[test] fn create_isolated_worktree_rejects_invalid_branch_as_input() { let repo = init_subagent_git_repo(); @@ -11442,6 +12071,7 @@ fn create_isolated_worktree_rejects_invalid_branch_as_input() { "unexpected error: {err}" ); } + fn init_git_repo_at(path: &std::path::Path) { let init = Command::new("git") .arg("init") @@ -11465,6 +12095,7 @@ fn init_git_repo_at(path: &std::path::Path) { .expect("git commit should run"); assert!(commit.status.success(), "git commit failed"); } + #[test] fn create_isolated_worktree_discovers_nested_repo_from_harness_parent() { let harness = tempdir().expect("harness"); @@ -11492,6 +12123,7 @@ fn create_isolated_worktree_discovers_nested_repo_from_harness_parent() { Some("codex/agent-harness-nested") ); } + #[test] fn create_isolated_worktree_reports_friendly_error_when_no_repo_found() { let harness = tempdir().expect("harness"); @@ -11525,6 +12157,7 @@ fn create_isolated_worktree_reports_friendly_error_when_no_repo_found() { "expected actionable discovery error, got: {message}" ); } + #[test] fn create_isolated_worktree_rejects_ambiguous_nested_repos() { let harness = tempdir().expect("harness"); @@ -11626,18 +12259,21 @@ fn build_subagent_system_prompt_appends_role_when_set() { // The shared background-worker / caller framing follows the role line. assert!(prompt.contains("background sub-agent")); } + #[test] fn build_subagent_system_prompt_skips_role_when_none() { let assignment = SubAgentAssignment::new("p".to_string(), None); let prompt = build_subagent_system_prompt(&FleetRole::Worker, &assignment); assert!(!prompt.contains("You are operating in the role of")); } + #[test] fn build_subagent_system_prompt_skips_role_when_blank() { let assignment = SubAgentAssignment::new("p".to_string(), Some(" ".to_string())); let prompt = build_subagent_system_prompt(&FleetRole::Worker, &assignment); assert!(!prompt.contains("You are operating in the role of")); } + #[test] fn fresh_forked_and_nested_subagents_share_authority_bound_skill_catalogs() { let _env = crate::test_support::lock_test_env(); @@ -11751,6 +12387,7 @@ fn fresh_forked_and_nested_subagents_share_authority_bound_skill_catalogs() { "workspace plugin authority must not leak into another worktree: {isolated_system}" ); } + #[test] fn subagent_done_sentinel_format_is_well_formed() { let res = make_snapshot(SubAgentStatus::Completed); @@ -11777,6 +12414,7 @@ fn subagent_done_sentinel_format_is_well_formed() { assert!(parsed.get("duration_ms").is_none()); assert!(parsed.get("steps").is_none()); } + #[test] fn subagent_done_sentinel_keeps_large_result_out_of_metadata() { let mut res = make_snapshot(SubAgentStatus::Completed); @@ -11797,6 +12435,7 @@ fn subagent_done_sentinel_keeps_large_result_out_of_metadata() { "sentinel should not duplicate large result text" ); } + #[test] fn subagent_done_sentinel_marks_truncated_summaries() { // issue #2652: when the child summary was length-gated, the sentinel must @@ -11809,6 +12448,7 @@ fn subagent_done_sentinel_marks_truncated_summaries() { let parsed: serde_json::Value = serde_json::from_str(inner).expect("inner JSON parses"); assert_eq!(parsed["summary_kind"], "truncated"); } + #[test] fn failed_subagent_completion_is_high_priority_and_retrievable() { let mut res = make_snapshot(SubAgentStatus::Failed( @@ -11842,6 +12482,7 @@ fn failed_subagent_completion_is_high_priority_and_retrievable() { let completed = subagent_completion_from_result(&make_snapshot(SubAgentStatus::Completed)); assert!(!completed.is_high_priority_failure()); } + #[test] fn budget_exhaustion_is_a_high_priority_failure_event() { let completion = @@ -11859,6 +12500,7 @@ fn budget_exhaustion_is_a_high_priority_failure_event() { .contains(r#""failure_class":"token_budget""#) ); } + #[test] fn stamp_subagent_summary_appends_note_when_short() { // issue #2652: a short (complete) summary gets the soft self-report note @@ -11875,6 +12517,7 @@ fn stamp_subagent_summary_appends_note_when_short() { "short summary must not get the truncation footer" ); } + #[test] fn forkguard_subagent_report_artifact_keeps_exact_bytes() { let home = tempdir().expect("artifact home"); @@ -11916,6 +12559,7 @@ fn forkguard_subagent_report_artifact_keeps_exact_bytes() { assert!(spill_subagent_final_report(blocked_session, &result).is_none()); }); } + #[test] fn stamp_subagent_summary_truncates_when_over_budget() { // issue #2652: a summary exceeding the budget is head+tail truncated using @@ -11950,6 +12594,7 @@ fn stamp_subagent_summary_truncates_when_over_budget() { assert!(saved.contains("activate it via tool_search first")); assert!(saved.contains("if neither route is available")); } + #[test] fn subagent_failed_sentinel_format_is_well_formed() { let mut result = make_snapshot(SubAgentStatus::Failed("boom".to_string())); @@ -11975,6 +12620,7 @@ fn subagent_failed_sentinel_format_is_well_formed() { // Stays lean — the error text lives on the previous line, not the sentinel. assert!(parsed.get("error").is_none()); } + #[test] fn annotated_failure_message_composes_class_tag_and_model_hint() { // #3884: the failure recorder composes subagent_failure_message (adds the @@ -12017,6 +12663,7 @@ fn annotated_failure_message_composes_class_tag_and_model_hint() { assert!(annotated.contains("route:"), "{annotated}"); assert!(annotated.contains("explicit model id"), "{annotated}"); } + #[test] fn subagent_failure_message_preserves_error_chain() { // #3884: `to_string()` on an anyhow error prints only the outermost @@ -12057,6 +12704,7 @@ fn subagent_failure_message_preserves_error_chain() { let message = subagent_failure_message(&err); assert_eq!(message, "outer: boom"); } + #[test] fn forkguard_quota_exhausted_skips_rate_limit_governor_and_stays_fatal() { // PR#43 follow-up: only LlmError::RateLimited feeds the rate-limit @@ -12090,6 +12738,7 @@ fn forkguard_quota_exhausted_skips_rate_limit_governor_and_stays_fatal() { }); assert!(is_governor_reported_rate_limit(&rate_limited_err)); } + #[test] fn annotate_child_model_error_adds_actionable_hint() { // #2653: a bare provider 403 becomes actionable by naming the model and the @@ -12135,6 +12784,7 @@ fn annotate_child_model_error_adds_actionable_hint() { "OpenAI-style rejection gets the hint: {openai_style}" ); } + #[test] fn child_launch_error_names_provider_model_and_route_source() { // #4049: a model-not-found child launch failure must name the provider @@ -12178,11 +12828,13 @@ fn child_launch_error_names_provider_model_and_route_source() { "inherit route source: {inherited}" ); } + #[test] fn subagent_runtime_default_max_depth_is_three() { // Sanity-check the constant — bumping it without a test means stale docs. assert_eq!(DEFAULT_MAX_SPAWN_DEPTH, 3); } + #[test] fn would_exceed_depth_at_boundary() { // depth=2, max=3 → next spawn (depth 3) is allowed (allow-equal). @@ -12204,6 +12856,7 @@ fn would_exceed_depth_at_boundary() { "depth 2 + max 3 → next is 3, allowed" ); } + #[test] fn clamp_child_max_spawn_depth_enforces_absolute_ceiling() { let ceiling = codewhale_config::MAX_SPAWN_DEPTH_CEILING; @@ -12227,6 +12880,7 @@ fn clamp_child_max_spawn_depth_enforces_absolute_ceiling() { "at the ceiling, a further spawn must be blocked regardless of max_depth" ); } + #[tokio::test] #[allow(clippy::await_holding_lock)] async fn rate_limit_pause_blocks_subagent_spawn() { @@ -12261,6 +12915,7 @@ async fn rate_limit_pause_blocks_subagent_spawn() { "refused spawn must not register or launch a worker" ); } + #[test] fn child_runtime_increments_depth_and_preserves_auto_approve() { let mut parent = stub_runtime(); @@ -12291,6 +12946,7 @@ fn child_runtime_increments_depth_and_preserves_auto_approve() { // `WorkRuntime::matches_todos` keys on `Arc::ptr_eq`, drove the parent's work // graph). These tests pin the invariant: every spawned agent owns its list, // and no agent can reach a parent's or a sibling's. + /// Run `work_update` against `todos` with `runtime`'s own tool context, the /// way a live agent's registry would. async fn write_todos_as(runtime: &SubAgentRuntime, contents: &[&str]) { @@ -12303,6 +12959,7 @@ async fn write_todos_as(runtime: &SubAgentRuntime, contents: &[&str]) { .await .expect("work_update must succeed against the agent's own list"); } + async fn todo_contents(todos: &crate::tools::todo::SharedTodoList) -> Vec { todos .lock() @@ -12313,6 +12970,7 @@ async fn todo_contents(todos: &crate::tools::todo::SharedTodoList) -> Vec crate::todo_snapshot::TodoSource { crate::todo_snapshot::TodoSource::new( runtime.context.runtime.work.clone(), runtime.todos.clone(), ) } + /// A child sends its stored messages and nothing else. Its To-do state is /// already in those messages, as the result of its own `work_update` call — /// no step appends a synthetic copy of the list. @@ -12538,6 +13203,7 @@ async fn child_request_messages_are_exactly_its_stored_messages() { assert!(!text.contains("child step one"), "{text}"); } } + /// Sibling and parent isolation: each agent's snapshot states only its own /// list. This is what the fork handoff and the agent card both read. #[tokio::test] @@ -12585,6 +13251,7 @@ async fn child_todo_snapshot_never_leaks_across_siblings_or_to_the_parent() { } } } + #[test] fn child_and_background_runtimes_preserve_step_api_timeout() { let timeout = Duration::from_secs(7); @@ -12601,6 +13268,7 @@ fn child_and_background_runtimes_preserve_step_api_timeout() { assert_eq!(background.step_api_timeout, timeout); assert_eq!(background.api_timeout_retry_base_backoff, backoff); } + #[tokio::test] async fn subagent_registry_blocks_approval_tools_without_parent_auto_approve() { let mut runtime = stub_runtime(); @@ -12627,6 +13295,7 @@ async fn subagent_registry_blocks_approval_tools_without_parent_auto_approve() { "unexpected error: {err}" ); } + #[tokio::test] async fn prompt_only_general_cannot_mutate_under_parent_auto_approve() { let tmp = tempdir().expect("tempdir"); @@ -12672,7 +13341,9 @@ async fn prompt_only_general_cannot_mutate_under_parent_auto_approve() { assert!(!tmp.path().join("forbidden.txt").exists()); assert!(!tmp.path().join("shell.txt").exists()); } + const MCP_ACTION_TOOL: &str = "mcp_github_create_pull_request"; + fn subagent_registry_with_mcp_action(auto_approve: bool) -> SubAgentToolRegistry { let mut runtime = stub_runtime(); runtime.context.auto_approve = auto_approve; @@ -12690,6 +13361,7 @@ fn subagent_registry_with_mcp_action(auto_approve: bool) -> SubAgentToolRegistry )); registry } + #[tokio::test] async fn child_write_tool_fails_closed_outside_registered_scope() { let _env_lock = crate::test_support::lock_test_env(); @@ -12891,6 +13563,7 @@ async fn child_write_tool_fails_closed_outside_registered_scope() { ); assert!(!tmp.path().join("docs/run-escape.txt").exists()); } + /// A lone writer in the shared checkout keeps its shell. /// /// The gate above exists so concurrent children cannot overwrite each other. @@ -12955,6 +13628,7 @@ async fn lone_shared_writer_keeps_unbounded_shell() { ); } } + #[test] fn shared_claim_shell_gate_normalizes_only_the_run_action() { assert!(is_unbounded_shell_run( @@ -12973,6 +13647,7 @@ fn shared_claim_shell_gate_normalizes_only_the_run_action() { ); } } + /// Build one registry per contended child, both bound to the same shared /// manager and workspace — the two-builder shared-checkout topology. fn contended_builder_registry( @@ -12995,6 +13670,7 @@ fn contended_builder_registry( Arc::new(Mutex::new(PlanState::default())), ) } + /// Two live write-capable children with disjoint `exact_files` claims in one /// shared checkout: a bash call the read-only classifier proves mutation-free /// must still run on both. The contention gate used to re-test @@ -13041,6 +13717,7 @@ async fn contended_shared_writers_keep_proven_readonly_shell() { }); } } + /// The same contention still refuses a shell call the classifier cannot prove /// read-only — and the refusal names the blocking peer and its remediation so /// the child can recover instead of retrying blindly. @@ -13394,6 +14071,7 @@ async fn panicked_child_task_terminalizes_and_stops_gating_peers() { "a panicked child must not keep gating peers as a live writer" ); } + /// A headless fleet worker (a worker record with no paired agent entry) that /// reaches `WaitingForUser` can never be answered — nothing will resume or /// finalize it — so it must not count as a live coordination owner, and the @@ -14327,6 +15005,7 @@ async fn claim_less_worktree_child_still_confers_isolation_through_its_manifest( "the manifest fallback still puts the claim on the isolated footing" ); } + #[tokio::test] async fn subagent_blocks_mcp_action_without_parent_auto_approve() { let registry = subagent_registry_with_mcp_action(false); @@ -14343,6 +15022,7 @@ async fn subagent_blocks_mcp_action_without_parent_auto_approve() { "unexpected MCP approval error: {err}" ); } + #[tokio::test] async fn auto_approved_subagent_passes_mcp_action_approval_gate() { let registry = subagent_registry_with_mcp_action(true); @@ -14361,6 +15041,7 @@ async fn auto_approved_subagent_passes_mcp_action_approval_gate() { "auto-approved MCP actions must not stop at the approval gate: {message}" ); } + #[tokio::test] async fn implementer_delegation_allows_suggest_write_without_parent_auto_approve() { // Issue #1828: implementer agents could not write files even when their @@ -14398,6 +15079,7 @@ async fn implementer_delegation_allows_suggest_write_without_parent_auto_approve "successful write should not look like an approval error: {result}" ); } + #[tokio::test] async fn workflow_accept_edits_allows_general_file_write_without_parent_auto_approve() { // Workflow-spawned children accept Suggest-level file edits for write-capable @@ -14446,6 +15128,7 @@ async fn workflow_accept_edits_allows_general_file_write_without_parent_auto_app "unexpected: {err}" ); } + #[tokio::test] async fn general_delegation_still_blocks_suggest_write_without_parent_auto_approve() { let tmp = tempdir().expect("tempdir"); @@ -14480,6 +15163,7 @@ async fn general_delegation_still_blocks_suggest_write_without_parent_auto_appro "general write must not land without parent auto-approve" ); } + #[tokio::test] async fn explore_role_still_blocks_suggest_writes_without_parent_auto_approve() { // Read-only stances (explore, plan, review, verifier) must not gain @@ -14519,6 +15203,7 @@ async fn explore_role_still_blocks_suggest_writes_without_parent_auto_approve() "file must not have been written" ); } + #[tokio::test] async fn explore_role_blocks_writes_even_under_parent_auto_approve() { // #3217: the authoritative per-role posture closes the auto-approve bypass — @@ -14564,6 +15249,7 @@ async fn explore_role_blocks_writes_even_under_parent_auto_approve() { "file must not have been written under auto-approve" ); } + #[tokio::test] async fn delegated_write_role_still_blocks_required_tools() { // Required-level tools such as Bash remain gated behind parent @@ -14595,6 +15281,7 @@ async fn delegated_write_role_still_blocks_required_tools() { "expected Required-level approval message, got: {err}" ); } + #[test] fn read_only_role_starts_do_not_require_approval() { // #5186: an explicit canonical read-only role spawns without a modal in @@ -14635,6 +15322,7 @@ fn read_only_role_starts_do_not_require_approval() { ApprovalRequirement::Auto ); } + #[test] fn write_capable_or_unproven_starts_keep_the_approval_gate() { // #5186: anything the parser cannot prove read-only keeps the modal. @@ -14674,6 +15362,7 @@ fn write_capable_or_unproven_starts_keep_the_approval_gate() { ApprovalRequirement::Auto ); } + #[tokio::test] async fn worker_child_inherits_workspace_write_carve_out_without_parent_auto_approve() { // #5186: a write-posture child that is NOT an explicit write-delegated @@ -14724,6 +15413,7 @@ async fn worker_child_inherits_workspace_write_carve_out_without_parent_auto_app ); } } + #[tokio::test] async fn worker_child_write_carve_out_requires_a_git_work_tree() { // #5186: without a `.git` marker the child keeps the old delegation @@ -14753,6 +15443,7 @@ async fn worker_child_write_carve_out_requires_a_git_work_tree() { "unexpected error: {err}" ); } + #[tokio::test] async fn builder_child_runs_bounded_verification_but_not_shell_without_parent_auto_approve() { // #5186: the bounded built-in verification surface is delegated to @@ -14811,6 +15502,7 @@ async fn builder_child_runs_bounded_verification_but_not_shell_without_parent_au "unexpected error: {argv_err}" ); } + #[tokio::test] async fn auto_approved_parent_runs_required_tools_in_subagent() { // Baseline: when the parent runtime IS auto-approved, every approval @@ -14839,6 +15531,7 @@ async fn auto_approved_parent_runs_required_tools_in_subagent() { .await .expect("auto-approved parent should allow writes"); } + #[test] fn subagent_request_budget_inherits_the_resolved_route_allowance() { assert_eq!( @@ -14847,6 +15540,7 @@ fn subagent_request_budget_inherits_the_resolved_route_allowance() { "sub-agents must not impose a smaller internal output ceiling" ); } + #[test] fn incomplete_response_failure_keeps_the_provider_stop_cause() { let response = MessageResponse { @@ -14866,6 +15560,7 @@ fn incomplete_response_failure_keeps_the_provider_stop_cause() { assert!(failure.contains("`content_filter`"), "{failure}"); assert!(!failure.contains("budget exhausted"), "{failure}"); } + #[test] fn child_cancellation_cascades_from_parent() { let parent = stub_runtime(); @@ -14877,6 +15572,7 @@ fn child_cancellation_cascades_from_parent() { "parent cancel() must propagate to child via child_token()" ); } + #[test] fn detached_background_children_survive_parent_cancellation() { let parent = stub_runtime(); @@ -14890,6 +15586,7 @@ fn detached_background_children_survive_parent_cancellation() { "parent stop must leave every detached child running until explicitly cancelled" ); } + #[test] fn agent_start_is_turn_owned_unless_detached_is_explicit() { let owned = parse_spawn_request(&json!({"prompt": "inspect the workspace"})) @@ -14909,6 +15606,7 @@ fn agent_start_is_turn_owned_unless_detached_is_explicit() { "only an explicit detached=true may outlive the turn" ); } + #[test] fn agent_start_marks_only_explicit_detachment_as_durable_work() { let runtime = stub_runtime(); @@ -14926,6 +15624,7 @@ fn agent_start_marks_only_explicit_detachment_as_durable_work() { "only detached=true may opt into durable background scheduling" ); } + #[tokio::test] async fn foreground_turn_cancellation_joins_direct_children_once_and_excludes_detached() { let registry = Arc::new(ForegroundChildRegistry::new()); @@ -14982,6 +15681,7 @@ async fn foreground_turn_cancellation_joins_direct_children_once_and_excludes_de assert!(late_token.is_cancelled()); assert_eq!(registry.active_count(), 0); } + #[tokio::test] async fn foreground_registration_releases_when_the_child_future_returns_or_unwinds() { let registry = Arc::new(ForegroundChildRegistry::new()); @@ -15012,6 +15712,7 @@ async fn foreground_registration_releases_when_the_child_future_returns_or_unwin .await .expect("return and panic-unwind both release foreground ownership"); } + #[tokio::test] async fn turn_owned_descendants_join_and_park_with_the_same_foreground_registry() { let registry = Arc::new(ForegroundChildRegistry::new()); @@ -15081,6 +15782,7 @@ async fn turn_owned_descendants_join_and_park_with_the_same_foreground_registry( ); assert!(!detached.cancel_token.is_cancelled()); } + #[test] fn derived_child_cancellation_reaches_tool_context_while_detached_stays_independent() { let root = stub_runtime(); @@ -15121,6 +15823,7 @@ fn derived_child_cancellation_reaches_tool_context_while_detached_stays_independ .is_some_and(CancellationToken::is_cancelled) ); } + #[tokio::test] async fn turn_end_parking_preserves_a_step_zero_resumable_checkpoint() { let tmp = tempdir().expect("tempdir"); @@ -15193,6 +15896,7 @@ async fn turn_end_parking_preserves_a_step_zero_resumable_checkpoint() { assert!(explicit_needs_input.is_none()); assert_eq!(explicit_worker_status, AgentWorkerStatus::Cancelled); } + #[test] fn turn_end_parking_wins_over_the_final_child_step_limit() { let agent_id = "agent_turn_end_final_step"; @@ -15219,6 +15923,7 @@ fn turn_end_parking_wins_over_the_final_child_step_limit() { assert!(needs_input.is_some()); assert_eq!(worker_status, AgentWorkerStatus::Interrupted); } + #[test] fn mailbox_propagates_through_child_runtime_chain() { use crate::tools::subagent::mailbox::Mailbox; @@ -15238,6 +15943,7 @@ fn mailbox_propagates_through_child_runtime_chain() { "grandchild inherits via the cloned Arc inside Mailbox" ); } + #[test] fn subagent_rejects_interactive_shell_terminal_takeover() { let err = reject_subagent_terminal_takeover( @@ -15280,6 +15986,7 @@ fn subagent_rejects_interactive_shell_terminal_takeover() { ) .expect("background shell remains allowed"); } + #[tokio::test] async fn mailbox_close_as_cancel_propagates_to_grandchild_runtime() { use crate::tools::subagent::mailbox::Mailbox; @@ -15304,6 +16011,7 @@ async fn mailbox_close_as_cancel_propagates_to_grandchild_runtime() { "close-as-cancel must propagate across max_spawn_depth=3" ); } + #[tokio::test] async fn mailbox_orders_messages_from_parent_and_child_runtimes() { use crate::tools::subagent::mailbox::{Mailbox, MailboxMessage}; @@ -15355,6 +16063,7 @@ async fn mailbox_orders_messages_from_parent_and_child_runtimes() { other => panic!("unexpected message order: {other:?}"), } } + #[test] fn persisted_empty_allowed_tools_loads_as_full_inheritance() { // Backward-compat: a v0.6.5 session that persisted with an empty Vec @@ -15387,6 +16096,7 @@ fn persisted_empty_allowed_tools_loads_as_full_inheritance() { "empty Vec on disk → None (full inheritance)" ); } + #[test] fn persisted_non_empty_allowed_tools_loads_as_narrow() { // Backward-compat the other way: a v0.6.5 session that persisted with @@ -15419,6 +16129,7 @@ fn persisted_non_empty_allowed_tools_loads_as_narrow() { "non-empty Vec → Some(list), narrow scope preserved" ); } + #[test] fn persisted_advisory_assignment_roles_replay_and_repersist_as_consultant() { for alias in ["oracle", "advisor"] { @@ -15466,6 +16177,7 @@ fn persisted_advisory_assignment_roles_replay_and_repersist_as_consultant() { assert_eq!(repersisted["agents"][0]["agent_type"], json!("advisor")); } } + /// Build a minimal `SubAgentRuntime` for tests that exercise pure runtime /// helpers (depth, cancellation, child_runtime). Doesn't construct a real /// HTTP client — calls that hit `runtime.client` would fail, but the @@ -15531,6 +16243,7 @@ pub(crate) fn stub_runtime() -> SubAgentRuntime { ask_on_guardrail: false, } } + #[test] fn root_operate_dispatch_delegates_file_edits_without_bypassing_required_tools() { let mut runtime = stub_runtime(); @@ -15548,6 +16261,7 @@ fn root_operate_dispatch_delegates_file_edits_without_bypassing_required_tools() "Operate dispatch must not silently grant Required tools such as shell" ); } + #[tokio::test] async fn root_operate_dispatch_delegates_builtin_verification_but_not_shell() { let tmp = tempdir().expect("tempdir"); @@ -15625,6 +16339,7 @@ async fn root_operate_dispatch_delegates_builtin_verification_but_not_shell() { "Operate verification delegation must not propagate past the direct worker" ); } + #[test] fn worker_lifecycle_records_direct_operate_approval_without_delegating_authority() { let todos = crate::tools::todo::new_shared_todo_list(); @@ -15723,6 +16438,7 @@ fn worker_lifecycle_records_direct_operate_approval_without_delegating_authority "nested workers must not inherit Operate approval authority" ); } + /// A minimal stub client. Test helpers below only ever check struct fields /// (depth, cancel_token, context); they don't call the network. We need a /// *some* `DeepSeekClient` because `SubAgentRuntime.client` isn't @@ -15733,6 +16449,7 @@ fn stub_runtime_for_provider(provider: &str) -> SubAgentRuntime { runtime.client = stub_client_for_provider(provider); runtime } + fn stub_client_for_provider(provider: &str) -> DeepSeekClient { let _ = rustls::crypto::ring::default_provider().install_default(); let mut providers = crate::config::ProvidersConfig::default(); @@ -15781,6 +16498,7 @@ fn stub_client_for_provider(provider: &str) -> DeepSeekClient { }; DeepSeekClient::new(&config).expect("stub client should construct") } + fn stub_client() -> DeepSeekClient { let _ = rustls::crypto::ring::default_provider().install_default(); let config = crate::config::Config { @@ -15795,6 +16513,7 @@ fn stub_client() -> DeepSeekClient { // There are no provider pins outside the durable Fleet runs: every child runs // on the parent's provider. Child and background runtimes clone the session // client and config unchanged — no cross-provider build, no misroute. + /// A session runtime on `deepseek` with the cross-provider `Config` threaded in, /// exactly as the engine wires it via `with_api_config`. fn cross_provider_runtime() -> SubAgentRuntime { @@ -15822,6 +16541,7 @@ fn cross_provider_runtime() -> SubAgentRuntime { runtime.client = client; runtime } + #[test] fn spawn_child_runtime_inherits_session_client_and_config() { let runtime = cross_provider_runtime(); @@ -15864,6 +16584,7 @@ fn spawn_child_runtime_inherits_session_client_and_config() { // carry the prior boot id and are classified as `from_prior_session`. // Listings default to current-session only; `include_archived=true` surfaces // the prior-session records with the flag set. + fn insert_prior_session_agent( manager: &mut SubAgentManager, id: &str, @@ -15909,6 +16630,7 @@ fn insert_prior_session_agent( } manager.agents.insert(id.to_string(), agent); } + fn assign_test_session_owner( manager: &mut SubAgentManager, agent_id: &str, @@ -15923,12 +16645,14 @@ fn assign_test_session_owner( record.owner_session_id = owner_session_id.to_string(); } } + #[test] fn session_boot_ids_are_unique_per_manager() { let a = SubAgentManager::new(PathBuf::from("."), 1); let b = SubAgentManager::new(PathBuf::from("."), 1); assert_ne!(a.session_boot_id(), b.session_boot_id()); } + #[test] fn terminal_synthesis_is_scoped_to_owner_and_keeps_delivery_history() { let mut manager = SubAgentManager::new(PathBuf::from("."), 5); @@ -15971,6 +16695,7 @@ fn terminal_synthesis_is_scoped_to_owner_and_keeps_delivery_history() { "returning A -> B -> A must not re-synthesize an already delivered terminal result" ); } + #[test] fn active_session_owns_every_roster_and_control_resolution() { let workspace = tempdir().expect("tempdir"); @@ -16086,6 +16811,7 @@ fn active_session_owns_every_roster_and_control_resolution() { "A -> B -> A restores A's archived control row" ); } + #[test] fn forkguard_cancel_all_running_is_session_scoped_and_idempotent() { let workspace = tempdir().expect("tempdir"); @@ -16143,6 +16869,7 @@ fn forkguard_cancel_all_running_is_session_scoped_and_idempotent() { "idempotent cleanup may not append another terminal event" ); } + #[test] fn ownerless_legacy_rows_fail_closed_and_scoped_close_preserves_foreign_workers() { let workspace = tempdir().expect("tempdir"); @@ -16194,6 +16921,7 @@ fn ownerless_legacy_rows_fail_closed_and_scoped_close_preserves_foreign_workers( .is_terminal() ); } + #[test] fn scoped_handle_eviction_drain_preserves_foreign_pending_ids() { let mut manager = SubAgentManager::new(PathBuf::from("."), 2); @@ -16211,6 +16939,7 @@ fn scoped_handle_eviction_drain_preserves_foreign_pending_ids() { vec!["agent-b".to_string()] ); } + #[test] fn coordination_records_are_visible_and_mutable_only_to_their_owner_session() { let mut manager = SubAgentManager::new(PathBuf::from("."), 2); @@ -16241,6 +16970,7 @@ fn coordination_records_are_visible_and_mutable_only_to_their_owner_session() { "B must not gain mutation authority over A's decision" ); } + #[test] fn list_filtered_drops_prior_session_terminals_by_default() { let mut manager = SubAgentManager::new(PathBuf::from("."), 5); @@ -16287,6 +17017,7 @@ fn list_filtered_drops_prior_session_terminals_by_default() { .unwrap(); assert!(!current.from_prior_session); } + #[test] fn list_snapshots_refresh_git_branch_from_agent_workspace() { let repo = init_subagent_git_repo(); @@ -16318,6 +17049,7 @@ fn list_snapshots_refresh_git_branch_from_agent_workspace() { .expect("current agent should still be listed"); assert_eq!(agent.git_branch.as_deref(), Some("feature/agent-new")); } + #[test] fn list_filtered_with_include_archived_returns_everything() { let mut manager = SubAgentManager::new(PathBuf::from("."), 5); @@ -16351,6 +17083,7 @@ fn list_filtered_with_include_archived_returns_everything() { .unwrap(); assert!(!current.from_prior_session); } + #[test] fn agents_with_empty_boot_id_classify_as_prior_session() { // Records persisted before #405 land with an empty `session_boot_id` @@ -16372,6 +17105,7 @@ fn agents_with_empty_boot_id_classify_as_prior_session() { .unwrap(); assert!(legacy.from_prior_session); } + #[test] fn persist_round_trip_preserves_session_and_boot_ownership() { let dir = tempdir().expect("tempdir"); @@ -16453,6 +17187,7 @@ fn persist_round_trip_preserves_session_and_boot_ownership() { // loop drains that channel; for nested agents the running parent sub-agent // owns a local receiver and injects the completion into its own transcript. // These tests cover the routing logic and no-channel safety. + fn runtime_with_depth( spawn_depth: u32, parent_completion_tx: Option>, @@ -16462,6 +17197,7 @@ fn runtime_with_depth( rt.parent_completion_tx = parent_completion_tx; rt } + #[test] fn emit_parent_completion_fires_for_direct_child() { let (tx, mut rx) = mpsc::unbounded_channel::(); @@ -16476,6 +17212,7 @@ fn emit_parent_completion_fires_for_direct_child() { assert_eq!(received.payload, "summary line\n"); assert!(rx.try_recv().is_err(), "should be exactly one message"); } + #[test] fn child_runtime_inherits_speech_output_dir() { let output_dir = PathBuf::from("configured-speech-output"); @@ -16489,6 +17226,7 @@ fn child_runtime_inherits_speech_output_dir() { Some(PathBuf::from("configured-speech-output")) ); } + #[test] fn emit_parent_completion_fires_for_nested_child() { let (tx, mut rx) = mpsc::unbounded_channel::(); @@ -16502,6 +17240,7 @@ fn emit_parent_completion_fires_for_nested_child() { assert_eq!(received.agent_id, "agent_grandchild"); assert_eq!(received.payload, "nested summary"); } + #[test] fn emit_parent_completion_skips_engine_self() { // depth 0 is the engine itself — the engine never spawns a task at @@ -16517,6 +17256,7 @@ fn emit_parent_completion_skips_engine_self() { ); assert!(rx.try_recv().is_err()); } + #[test] fn emit_parent_completion_no_channel_is_noop() { let runtime = runtime_with_depth(1, None); @@ -16528,6 +17268,7 @@ fn emit_parent_completion_no_channel_is_noop() { "missing channel should be a silent no-op, not a panic" ); } + #[test] fn emit_parent_completion_dropped_receiver_does_not_panic() { let (tx, rx) = mpsc::unbounded_channel::(); @@ -16544,6 +17285,7 @@ fn emit_parent_completion_dropped_receiver_does_not_panic() { "we still attempt the send; the engine being gone is not our problem" ); } + #[test] fn terminal_results_excluding_returns_only_current_root_undelivered_agents() { let tmp = tempdir().expect("tempdir"); @@ -16618,6 +17360,7 @@ fn terminal_results_excluding_returns_only_current_root_undelivered_agents() { let delivered = HashSet::from(["agent_root_done".to_string()]); assert!(manager.terminal_results_excluding(&delivered).is_empty()); } + #[tokio::test] async fn run_subagent_task_claims_before_delivery_and_then_finalizes() { let manager = Arc::new(RwLock::new(SubAgentManager::new(PathBuf::from("."), 2))); @@ -16695,6 +17438,7 @@ async fn run_subagent_task_claims_before_delivery_and_then_finalizes() { snapshot.status ); } + #[tokio::test] async fn cancellation_wins_task_race_but_still_fans_in_exactly_once() { use tokio_util::sync::CancellationToken; @@ -16816,6 +17560,7 @@ async fn cancellation_wins_task_race_but_still_fans_in_exactly_once() { if id == &snapshot.agent_id && result.contains(r#""status":"cancelled""#) )); } + /// Call 1 answers with a tool call (so the child banks a real step); /// every later call fails with a non-retryable 400. async fn tool_call_then_invalid_request_chat_client() -> (DeepSeekClient, Arc) { @@ -16894,6 +17639,7 @@ async fn tool_call_then_invalid_request_chat_client() -> (DeepSeekClient, Arc(); @@ -17244,6 +17995,7 @@ fn nested_tool_runtime_routes_child_completions_to_local_inbox() { "root engine must not receive nested child completion directly" ); } + #[test] fn subagent_completion_from_result_surfaces_step_limit_not_silent_success() { let snap = make_snapshot(SubAgentStatus::Failed( @@ -17257,6 +18009,7 @@ fn subagent_completion_from_result_surfaces_step_limit_not_silent_success() { assert!(completion.payload.contains("max_steps"), "{completion:?}"); assert!(!completion.payload.contains("Completed (no output)")); } + #[test] fn subagent_completion_from_result_preserves_missing_final_summary_diagnostic() { let snap = make_snapshot(SubAgentStatus::Completed); @@ -17266,6 +18019,7 @@ fn subagent_completion_from_result_preserves_missing_final_summary_diagnostic() "{completion:?}" ); } + #[test] fn subagent_budget_exhaustion_completion_carries_budget_exhausted_sentinel() { let mut snap = make_snapshot(SubAgentStatus::BudgetExhausted); @@ -17288,6 +18042,7 @@ fn subagent_budget_exhaustion_completion_carries_budget_exhausted_sentinel() { assert_eq!(parsed["failure_class"], "token_budget"); assert_eq!(parsed["error_location"], "previous_line"); } + #[test] fn subagent_completion_inlines_evidence_before_sentinel() { let mut snap = make_snapshot(SubAgentStatus::Completed); @@ -17309,6 +18064,7 @@ fn subagent_completion_inlines_evidence_before_sentinel() { "summary before evidence" ); } + #[test] fn subagent_completion_skips_empty_evidence_on_failed_child() { let mut snap = make_snapshot(SubAgentStatus::Failed("boom".to_string())); @@ -17316,6 +18072,7 @@ fn subagent_completion_skips_empty_evidence_on_failed_child() { let completion = subagent_completion_from_result(&snap); assert!(!completion.payload.contains("### EVIDENCE")); } + #[test] fn child_completion_runtime_message_preserves_agent_and_provenance_guidance() { let message = child_completion_runtime_message(&[SubAgentCompletion { @@ -17333,6 +18090,7 @@ fn child_completion_runtime_message_preserves_agent_and_provenance_guidance() { assert!(text.contains("cite the child agent_id and the EVIDENCE lines")); assert!(text.contains("src/lib.rs:1-3")); } + #[test] fn subagent_runtime_default_step_api_timeout_matches_config_default() { // The runtime default is derived from the config default so call sites @@ -17349,11 +18107,13 @@ fn subagent_runtime_default_step_api_timeout_matches_config_default() { std::time::Duration::from_secs(600) ); } + #[test] fn with_step_api_timeout_overrides_runtime_field() { let runtime = stub_runtime().with_step_api_timeout(std::time::Duration::from_secs(900)); assert_eq!(runtime.step_api_timeout.as_secs(), 900); } + #[test] fn tool_timeout_defaults_to_generous_budget_and_survives_spawn() { // Track A raised the per-tool timeout from the old 30s (which killed long @@ -17368,6 +18128,7 @@ fn tool_timeout_defaults_to_generous_budget_and_survives_spawn() { assert_eq!(parent.child_runtime().tool_timeout, expected); assert_eq!(parent.background_runtime().tool_timeout, expected); } + #[test] fn child_runtime_preserves_step_api_timeout() { // Real sub-agents spawn through `child_runtime()` / `background_runtime()`; @@ -17388,6 +18149,7 @@ fn child_runtime_preserves_step_api_timeout() { "background_runtime (detached) must also preserve the parent's timeout" ); } + #[test] fn subagent_completion_payload_carries_existing_sentinel_format() { // The payload format is the same one already documented in @@ -17423,6 +18185,7 @@ fn subagent_completion_payload_carries_existing_sentinel_format() { "sentinel should not duplicate the human summary line" ); } + /// #2683 — Verify the model-facing tool catalog only advertises canonical /// subagent tools and never exposes legacy superseded names. #[test] @@ -17454,6 +18217,7 @@ fn model_catalog_only_advertises_canonical_subagent_tools() { } // ── #3018: provider-aware auto routing and model validation ───────────────── + #[tokio::test] async fn faster_route_on_provider_without_known_sibling_stays_on_parent_model() { // AC: Ollama must never build a request with a DeepSeek id; even when the @@ -17479,6 +18243,7 @@ async fn faster_route_on_provider_without_known_sibling_stays_on_parent_model() ); } } + #[test] fn faster_route_uses_known_deepseek_and_glm_family_siblings() { let mut deepseek = stub_runtime(); @@ -17518,6 +18283,7 @@ fn faster_route_uses_known_deepseek_and_glm_family_siblings() { assert_eq!(route.model, "z-ai/glm-5-turbo"); assert_ne!(route.model, "z-ai/glm-5.1"); } + #[test] fn inherit_route_remaps_stale_deepseek_model_for_sakana_provider() { let mut runtime = stub_runtime_for_provider("sakana"); @@ -17540,6 +18306,7 @@ fn inherit_route_remaps_stale_deepseek_model_for_sakana_provider() { "Sakana inherit must not keep DeepSeek ids: {validated}" ); } + #[test] fn faster_route_remaps_stale_deepseek_model_for_sakana_provider() { let mut runtime = stub_runtime_for_provider("sakana"); @@ -17556,6 +18323,7 @@ fn faster_route_remaps_stale_deepseek_model_for_sakana_provider() { .expect("faster should remap to operator route"); assert_eq!(validated, crate::config::DEFAULT_SAKANA_MODEL); } + #[test] fn fixed_route_rejects_deepseek_model_for_sakana_provider() { let runtime = stub_runtime_for_provider("sakana"); @@ -17570,6 +18338,7 @@ fn fixed_route_rejects_deepseek_model_for_sakana_provider() { "error should name the model: {err}" ); } + #[test] fn normalize_requested_subagent_model_rejects_cross_namespace_for_sakana() { let err = normalize_requested_subagent_model( @@ -17583,6 +18352,7 @@ fn normalize_requested_subagent_model_rejects_cross_namespace_for_sakana() { "error should name the model: {err}" ); } + #[test] fn gpt55_faster_route_stays_on_gpt55_with_low_reasoning() { // AC: a faster/explore child of a GPT-5.5 (OpenAI Codex) parent must stay @@ -17626,6 +18396,7 @@ fn gpt55_faster_route_stays_on_gpt55_with_low_reasoning() { assert_eq!(route.reasoning_effort.as_deref(), Some("low")); assert_ne!(route.reasoning_effort.as_deref(), Some("off")); } + #[test] fn role_model_validation_accepts_provider_native_ids() { // AC: [subagents] worker_model = "kimi-k2.5" on Moonshot must not fail @@ -17639,6 +18410,7 @@ fn role_model_validation_accepts_provider_native_ids() { .expect("provider-native id is accepted"); assert_eq!(model.as_deref(), Some("kimi-k2.5")); } + #[test] fn consultant_reads_released_advisory_role_model_override_keys() { for legacy_key in ["oracle", "consultant"] { @@ -17652,6 +18424,7 @@ fn consultant_reads_released_advisory_role_model_override_keys() { assert_eq!(model.as_deref(), Some("deepseek-v4-flash"), "{legacy_key}"); } } + #[test] fn canonical_consultant_model_override_precedes_compatibility_keys() { let mut runtime = stub_runtime(); @@ -17669,6 +18442,7 @@ fn canonical_consultant_model_override_precedes_compatibility_keys() { .expect("canonical consultant override should resolve"); assert_eq!(model.as_deref(), Some("deepseek-v4-pro")); } + #[test] fn raw_advisory_role_prefers_canonical_consultant_model_override() { for alias in ["oracle", "consultant"] { @@ -17686,6 +18460,7 @@ fn raw_advisory_role_prefers_canonical_consultant_model_override() { assert_eq!(model.as_deref(), Some("deepseek-v4-pro"), "alias={alias}"); } } + #[test] fn role_model_validation_stays_strict_on_official_deepseek() { let mut runtime = stub_runtime(); @@ -17702,6 +18477,7 @@ fn role_model_validation_stays_strict_on_official_deepseek() { "lists accepted ids from model_completion_names_for_provider: {msg}" ); } + #[test] fn operator_model_for_subagent_enumerates_from_catalog_facade() { // #4116: the operator-route fallback must source its model from the @@ -17746,6 +18522,7 @@ fn operator_model_for_subagent_enumerates_from_catalog_facade() { ); } } + #[test] fn normalize_requested_subagent_model_is_provider_aware() { assert_eq!( @@ -17778,15 +18555,18 @@ fn normalize_requested_subagent_model_is_provider_aware() { } // ── #3030: step-counter formatting ────────────────────────────────────────── + #[test] fn format_step_counter_hides_unbounded_sentinel() { assert_eq!(format_step_counter(16, 0), "step 16"); } + #[test] fn format_step_counter_keeps_concrete_budgets() { assert_eq!(format_step_counter(3, 25), "step 3/25"); assert_eq!(format_step_counter(0, 1), "step 0/1"); } + #[test] fn child_step_override_wins_and_clamps_to_hard_ceiling() { assert_eq!(resolve_max_steps(FleetRole::Scout, None, None), 0); @@ -17806,6 +18586,7 @@ fn child_step_override_wins_and_clamps_to_hard_ceiling() { MAX_SUBAGENT_STEPS ); } + #[test] fn child_wall_timeout_reason_is_typed_and_actionable() { let reason = child_wall_time_exhausted_reason(Duration::from_millis(1)); @@ -17817,6 +18598,7 @@ fn child_wall_timeout_reason_is_typed_and_actionable() { } // ── #3095: sub-agent launch gate ───────────────────────────────────────────── + #[test] fn launch_gate_defaults_to_launch_concurrency_capped_by_max_agents() { let tmp = tempdir().expect("tempdir"); @@ -17837,6 +18619,7 @@ fn launch_gate_defaults_to_launch_concurrency_capped_by_max_agents() { "clamps down to max_agents" ); } + #[tokio::test] async fn launch_gate_queues_extra_direct_children() { use tokio_util::sync::CancellationToken; @@ -17984,6 +18767,7 @@ async fn launch_gate_queues_extra_direct_children() { "queued child must start only after queuing, then complete: {messages:?}" ); } + #[tokio::test] async fn queued_turn_owned_child_parks_without_a_false_start_transition() { use tokio_util::sync::CancellationToken; @@ -18136,6 +18920,7 @@ async fn queued_turn_owned_child_parks_without_a_false_start_transition() { drop(manager); drop(held_launch_permit); } + #[tokio::test] async fn launch_gate_wait_counts_against_child_wall_timeout() { use tokio_util::sync::CancellationToken; @@ -18261,6 +19046,7 @@ async fn launch_gate_wait_counts_against_child_wall_timeout() { drop(manager); drop(held_launch_permit); } + /// Stub chat server that always replies with a final assistant text whose /// `usage` reports the given token counts. Returns the client plus a call /// counter so tests can assert how many model turns ran before a budget cap @@ -18321,6 +19107,7 @@ async fn token_heavy_chat_client( let client = DeepSeekClient::new(&config).expect("fake chat client"); (client, calls) } + /// First response carries partial text plus a tool call and the requested /// incomplete/output-limit reason. A second request, when the runtime is /// allowed to retry, completes normally. @@ -18396,6 +19183,7 @@ async fn incomplete_then_complete_chat_client( let client = DeepSeekClient::new(&config).expect("fake incomplete-response client"); (client, calls) } + async fn run_incomplete_response_worker( workspace: &Path, stop_reason: &str, @@ -18479,6 +19267,7 @@ async fn run_incomplete_response_worker( }; (result, calls, mailbox_messages, total_tokens) } + fn assert_partial_tool_was_not_executed(messages: &[MailboxMessage]) { assert!( !messages @@ -18487,6 +19276,7 @@ fn assert_partial_tool_was_not_executed(messages: &[MailboxMessage]) { "an incomplete response must never dispatch its partial tool call: {messages:?}" ); } + #[tokio::test] async fn output_limit_on_last_step_preserves_partial_text_and_exact_cause() { let tmp = tempdir().expect("tempdir"); @@ -18521,6 +19311,7 @@ async fn output_limit_on_last_step_preserves_partial_text_and_exact_cause() { "the partial assistant text must remain in the transcript checkpoint" ); } + #[tokio::test] async fn output_limit_cause_wins_over_generic_token_budget() { let tmp = tempdir().expect("tempdir"); @@ -18544,6 +19335,7 @@ async fn output_limit_cause_wins_over_generic_token_budget() { assert_eq!(total_tokens, Some(15), "usage must still be recorded"); assert_partial_tool_was_not_executed(&mailbox); } + #[tokio::test] async fn non_output_incomplete_cause_wins_over_generic_token_budget() { let tmp = tempdir().expect("tempdir"); @@ -18564,6 +19356,7 @@ async fn non_output_incomplete_cause_wins_over_generic_token_budget() { assert_eq!(total_tokens, Some(15), "usage must still be recorded"); assert_partial_tool_was_not_executed(&mailbox); } + #[tokio::test] async fn output_limit_never_retries_or_executes_partial_tools() { let tmp = tempdir().expect("tempdir"); @@ -18583,6 +19376,7 @@ async fn output_limit_never_retries_or_executes_partial_tools() { assert_eq!(total_tokens, Some(15)); assert_partial_tool_was_not_executed(&mailbox); } + /// Shared scaffolding for the per-worker token-budget runtime tests: spins up /// a general worker against `token_heavy_chat_client` with the given cap and /// returns the manager, agent id, call counter, and spawned task handle. @@ -18650,6 +19444,7 @@ async fn spawn_budget_capped_worker( let task_handle = tokio::spawn(run_subagent_task(task)); (manager, agent_id, calls, task_handle) } + #[tokio::test] async fn worker_stops_with_typed_wall_time_reason() { let tmp = tempdir().expect("tempdir"); @@ -18675,6 +19470,7 @@ async fn worker_stops_with_typed_wall_time_reason() { other => panic!("expected typed wall-time failure, got {other:?}"), } } + #[tokio::test] async fn worker_stops_when_per_worker_token_budget_exceeded() { let tmp = tempdir().expect("tempdir"); @@ -18705,6 +19501,7 @@ async fn worker_stops_when_per_worker_token_budget_exceeded() { result.status ); } + #[tokio::test] async fn worker_without_per_worker_token_budget_runs_to_completion() { let tmp = tempdir().expect("tempdir"); @@ -18730,6 +19527,7 @@ async fn worker_without_per_worker_token_budget_runs_to_completion() { result.status ); } + #[tokio::test] async fn per_worker_token_budget_does_not_double_count_scope_accounting() { let tmp = tempdir().expect("tempdir"); @@ -18767,6 +19565,7 @@ async fn per_worker_token_budget_does_not_double_count_scope_accounting() { worker_record.usage ); } + /// Variant of [`spawn_budget_capped_worker`] that attaches the worker to a /// shared workflow budget scope before its first model turn (no per-worker /// cap), returning the manager, agent id, call counter, and task handle. @@ -18832,6 +19631,7 @@ async fn spawn_scope_budgeted_worker( let task_handle = tokio::spawn(run_subagent_task(task)); (calls, task_handle) } + #[tokio::test] async fn shared_scope_budget_stops_admitted_children_mid_run() { // A workflow run's token_budget must be a collective ceiling for the @@ -18917,14 +19717,17 @@ async fn shared_scope_budget_stops_admitted_children_mid_run() { "collective spend is accounted once per child" ); } + /// Clears the process-wide rate-limit window on drop so a panicking test /// body cannot leak a live pause into concurrently running tests. struct ClearRateLimitOnDrop; + impl Drop for ClearRateLimitOnDrop { fn drop(&mut self) { crate::retry_status::clear_rate_limit(); } } + #[tokio::test] #[allow(clippy::await_holding_lock)] async fn worker_is_not_stranded_by_transient_global_rate_limit_window() { @@ -18965,6 +19768,7 @@ async fn worker_is_not_stranded_by_transient_global_rate_limit_window() { result.status ); } + /// #4217: terminal worker records must age out of the persisted ledger so /// long-lived sessions do not rewrite multi-MB `subagents.v1.json` forever. #[test] @@ -19046,6 +19850,7 @@ fn cleanup_evicts_stale_terminal_worker_records_and_keeps_live_ones() { assert!(reloaded.get_worker_record("agent_recent_done").is_some()); assert!(reloaded.get_worker_record("agent_still_running").is_some()); } + #[test] fn cleanup_removes_complete_transcript_after_worker_retention_expires() { let tmp = tempdir().expect("tempdir"); @@ -19074,6 +19879,7 @@ fn cleanup_removes_complete_transcript_after_worker_retention_expires() { "artifact must share the terminal worker retention lifecycle" ); } + #[test] fn cleanup_due_gates_write_locked_cleanup_to_a_bounded_cadence() { // #3803: a fresh manager is always due (never cleaned); right after a @@ -19100,6 +19906,7 @@ fn cleanup_due_gates_write_locked_cleanup_to_a_bounded_cadence() { } // ── #3882: bounded sub-agent output under Fleet fanout ───────────────────── + /// Serialize-and-restore guard for the shared spillover test root, mirroring /// the pattern in `tools::truncate::tests`. fn with_spillover_root(root: &std::path::Path, f: F) { @@ -19122,6 +19929,7 @@ fn with_spillover_root(root: &std::path::Path, f: F) { let _restore = Restore(prior, prior_artifacts); f(); } + #[test] fn bounded_tail_messages_keeps_recent_within_budget_and_counts_omitted() { let messages: Vec = (0..10) @@ -19145,6 +19953,7 @@ fn bounded_tail_messages_keeps_recent_within_budget_and_counts_omitted() { "kept tail exceeds budget by more than one message: {total}" ); } + #[test] fn bounded_tail_messages_always_keeps_the_final_message() { let messages = vec![ @@ -19162,6 +19971,7 @@ fn bounded_tail_messages_always_keeps_the_final_message() { assert_eq!(omitted, 1); assert!(message_text(&kept[0]).starts_with('b')); } + #[tokio::test] async fn complete_transcript_artifact_survives_resident_handle_compaction() { let tmp = tempdir().expect("tempdir"); @@ -19233,6 +20043,7 @@ async fn complete_transcript_artifact_survives_resident_handle_compaction() { ); } } + #[test] fn malformed_transcript_artifact_fails_closed_instead_of_showing_partial_chat() { let tmp = tempdir().expect("tempdir"); @@ -19254,6 +20065,7 @@ fn malformed_transcript_artifact_fails_closed_instead_of_showing_partial_chat() .expect_err("a malformed stream must not masquerade as a complete chat"); assert!(error.to_string().contains("line"), "{error:#}"); } + #[cfg(unix)] #[test] fn transcript_artifact_reader_rejects_symlink_replacement() { @@ -19276,6 +20088,7 @@ fn transcript_artifact_reader_rejects_symlink_replacement() { .expect_err("transcript reader must reject symlink replacement"); assert!(error.to_string().contains("must not traverse symlinks")); } + #[test] fn checkpoints_are_byte_bounded_under_fanout_scale_output() { // Simulates the #3882 report shape: a worker whose tool results are @@ -19306,6 +20119,7 @@ fn checkpoints_are_byte_bounded_under_fanout_scale_output() { serialized.len() ); } + #[test] fn checkpoint_without_omitted_field_still_deserializes() { // Records persisted before v0.8.67 carry no omitted_messages key. @@ -19323,6 +20137,7 @@ fn checkpoint_without_omitted_field_still_deserializes() { serde_json::from_str(legacy).expect("legacy checkpoint should load"); assert_eq!(checkpoint.omitted_messages, 0); } + #[test] fn forkguard_subagent_classic_hint_names_the_actual_storage_call() { use crate::tools::large_output_router::WorkshopConfig; @@ -19489,6 +20304,7 @@ fn subagent_tool_results_spill_to_disk_and_stay_bounded_inline() { ); }); } + #[test] fn fanout_of_workers_with_huge_outputs_keeps_resident_state_bounded() { // Acceptance shape for #3882: multiple workers, each emitting multi-MB @@ -19537,6 +20353,7 @@ fn fanout_of_workers_with_huge_outputs_keeps_resident_state_bounded() { ); }); } + #[test] fn write_json_atomic_survives_concurrent_writers() { use std::sync::Arc; @@ -19577,6 +20394,7 @@ fn write_json_atomic_survives_concurrent_writers() { .collect(); assert!(leftover.is_empty(), "temp files leaked: {leftover:?}"); } + #[test] fn coordination_process_lock_rejects_second_process() { const ROLE_ENV: &str = "CODEWHALE_TEST_COORDINATION_LOCK_ROLE"; @@ -19669,6 +20487,7 @@ fn coordination_process_lock_rejects_second_process() { } // === agent(action="wait") + peek throttling (#4097) === + fn insert_running_agent(inner: &mut SubAgentManager, name: &str) -> String { let current_boot = inner.session_boot_id().to_string(); let (input_tx, _input_rx) = mpsc::unbounded_channel(); @@ -19692,6 +20511,7 @@ fn insert_running_agent(inner: &mut SubAgentManager, name: &str) -> String { inner.agents.insert(agent_id.clone(), agent); agent_id } + #[tokio::test] async fn agent_wait_returns_immediately_with_no_children() { let manager = Arc::new(RwLock::new(SubAgentManager::new(PathBuf::from("."), 1))); @@ -19709,6 +20529,7 @@ async fn agent_wait_returns_immediately_with_no_children() { .is_empty() ); } + #[tokio::test] async fn agent_wait_wakes_when_child_settles() { let mut inner = SubAgentManager::new(PathBuf::from("."), 1); @@ -19746,6 +20567,7 @@ async fn agent_wait_wakes_when_child_settles() { assert_eq!(settled[0]["status"], json!("completed")); assert_eq!(payload["timed_out"], json!(false)); } + #[tokio::test] async fn agent_wait_times_out_and_reports_running_child() { let mut inner = SubAgentManager::new(PathBuf::from("."), 1); @@ -19771,6 +20593,7 @@ async fn agent_wait_times_out_and_reports_running_child() { .is_empty() ); } + #[tokio::test] async fn agent_wait_rejects_unknown_agent_ref() { let manager = Arc::new(RwLock::new(SubAgentManager::new(PathBuf::from("."), 1))); @@ -19784,6 +20607,7 @@ async fn agent_wait_rejects_unknown_agent_ref() { .expect_err("unknown agent ref must fail fast instead of blocking"); assert!(matches!(err, ToolError::InvalidInput { .. })); } + #[tokio::test] async fn agent_peek_unchanged_within_window_returns_compact_nudge() { let mut inner = SubAgentManager::new(PathBuf::from("."), 1); @@ -19818,6 +20642,7 @@ async fn agent_peek_unchanged_within_window_returns_compact_nudge() { "nudge should point at agent(action=wait)" ); } + #[test] fn agent_action_parses_wait_aliases() { for alias in ["wait", "join", "await", "block"] { @@ -19842,6 +20667,7 @@ fn agent_action_parses_wait_aliases() { // Deny always wins over allow. Wildcards (`prefix*`) and case-insensitive // matching mirror the session-side `command_denies_tool()`. // =========================================================================== + /// Build a stub runtime with the parent's `disallowed_tools` set on the /// `WorkerRuntimeProfile`. The registry reads deny lists from the profile at /// construction, and `child_runtime()` clones the profile so the list @@ -19851,6 +20677,7 @@ fn stub_runtime_with_disallowed(disallowed: Vec) -> SubAgentRuntime { rt.worker_profile.denied_tools = disallowed; rt } + /// Build a `SubAgentToolRegistry` wired with `disallowed_tools`. Passes the /// runtime through `SubAgentToolRegistry::new()` so the constructor picks up /// `worker_profile.denied_tools`. `allowed_tools` is forwarded directly. @@ -19866,6 +20693,7 @@ fn new_registry_with_disallowed( Arc::new(Mutex::new(PlanState::default())), ) } + #[test] fn test_disallowed_tools_inheritance_denies_tool() { let tmp = tempdir().expect("tempdir"); @@ -19905,6 +20733,7 @@ fn test_disallowed_tools_inheritance_denies_tool() { "the lowercase edit primitive survives: only write_file was denied" ); } + #[test] fn test_disallowed_tools_deny_wins_over_allow() { let tmp = tempdir().expect("tempdir"); @@ -19937,6 +20766,7 @@ fn test_disallowed_tools_deny_wins_over_allow() { ); assert!(!names.contains("bash"), "denied shell alias removes bash"); } + #[test] fn test_disallowed_tools_wildcard_matching() { let tmp = tempdir().expect("tempdir"); @@ -19957,6 +20787,7 @@ fn test_disallowed_tools_wildcard_matching() { "non-MCP tools are unaffected by mcp_* deny" ); } + #[test] fn test_disallowed_tools_case_insensitive_match() { let tmp = tempdir().expect("tempdir"); @@ -19977,6 +20808,7 @@ fn test_disallowed_tools_case_insensitive_match() { "unrelated tool unaffected" ); } + #[test] fn test_disallowed_tools_specific_server_wildcard() { let tmp = tempdir().expect("tempdir"); @@ -19993,6 +20825,7 @@ fn test_disallowed_tools_specific_server_wildcard() { "different server prefix is not denied" ); } + #[test] fn test_disallowed_tools_tools_for_model_excludes_denied() { let tmp = tempdir().expect("tempdir"); @@ -20023,6 +20856,7 @@ fn test_disallowed_tools_tools_for_model_excludes_denied() { "the lowercase edit primitive survives: only write_file was denied" ); } + #[tokio::test] async fn test_disallowed_tools_execute_rejects_denied_tool() { let tmp = tempdir().expect("tempdir"); @@ -20046,6 +20880,7 @@ async fn test_disallowed_tools_execute_rejects_denied_tool() { } // === deny-list propagation through runtime cloning === + #[test] fn test_disallowed_tools_propagates_through_child_runtime() { let runtime = stub_runtime_with_disallowed(vec!["exec_shell".to_string()]); @@ -20056,6 +20891,7 @@ fn test_disallowed_tools_propagates_through_child_runtime() { "child_runtime() must preserve parent's denied_tools" ); } + #[test] fn test_disallowed_tools_propagates_through_background_runtime() { let runtime = stub_runtime_with_disallowed(vec!["write_file".to_string()]); @@ -20066,6 +20902,7 @@ fn test_disallowed_tools_propagates_through_background_runtime() { "background_runtime() must preserve parent's denied_tools" ); } + #[test] fn test_disallowed_tools_across_two_generations() { let tmp = tempdir().expect("tempdir"); @@ -20093,6 +20930,7 @@ fn test_disallowed_tools_across_two_generations() { } // === spawn-path opt-out simulation === + #[test] fn test_disallowed_tools_opt_out_clears_inherited_denies() { // Simulate the spawn-path merge: parent runtime has denies, child sets @@ -20121,6 +20959,7 @@ fn test_disallowed_tools_opt_out_clears_inherited_denies() { ); assert!(registry.is_tool_allowed("read_file")); } + #[test] fn test_disallowed_tools_opt_out_keeps_explicit_caller_deny() { // Opt-out clears inherited denies, but explicit caller disallowed_tools @@ -20154,6 +20993,7 @@ fn test_disallowed_tools_opt_out_keeps_explicit_caller_deny() { } // === parse_spawn_request disallowed_tools === + #[test] fn test_parse_spawn_request_reads_disallowed_tools() { let input = json!({ @@ -20166,6 +21006,7 @@ fn test_parse_spawn_request_reads_disallowed_tools() { Some(vec!["exec_shell".to_string(), "write_file".to_string()]) ); } + #[test] fn test_parse_spawn_request_disallowed_tools_dedupes_and_trims() { let input = json!({ @@ -20179,6 +21020,7 @@ fn test_parse_spawn_request_disallowed_tools_dedupes_and_trims() { "blanks and duplicates are dropped" ); } + #[test] fn test_parse_spawn_request_disallowed_tools_defaults_to_none() { let input = json!({"prompt": "do something"}); @@ -20188,6 +21030,7 @@ fn test_parse_spawn_request_disallowed_tools_defaults_to_none() { "disallowed_tools should be None when not provided" ); } + #[test] fn test_parse_spawn_request_inherit_disallowed_tools_defaults_true() { let input = json!({"prompt": "do something"}); @@ -20197,6 +21040,7 @@ fn test_parse_spawn_request_inherit_disallowed_tools_defaults_true() { "inherit_disallowed_tools should default to true" ); } + #[test] fn test_parse_spawn_request_inherit_disallowed_tools_explicit_false() { let input = json!({ @@ -20209,6 +21053,7 @@ fn test_parse_spawn_request_inherit_disallowed_tools_explicit_false() { "inherit_disallowed_tools should parse an explicit false" ); } + /// #3874 acceptance: the no-progress heartbeat must not kill a worker whose /// only pending work is a tracked *running* background shell task. /// @@ -20327,6 +21172,7 @@ async fn tracked_running_background_shell_keeps_its_owner_off_the_heartbeat_reap .expect("live task handle") .abort(); } + /// #4810: a child publishes its *own* list, only when it actually changes. #[tokio::test] async fn child_work_state_publishes_only_real_changes_from_its_own_list() { @@ -20398,6 +21244,7 @@ async fn child_work_state_publishes_only_real_changes_from_its_own_list() { // These assert on the registry the child actually runs with, not on the // `ChildAuthority` value that produced it. A ceiling that is only a label on a // receipt is not a ceiling. + /// `tools = false` must leave the child with **zero** model tools. The empty /// allowlist is the mechanism; this is the proof it lands. #[test] @@ -20433,6 +21280,7 @@ fn an_exact_member_with_tools_false_gets_no_model_tools_at_all() { ); } } + /// `network_tool = false` removes every model-visible network, browser, and /// remote-MCP surface — even though `tools = true` — while keeping exactly the /// `Web` family's two read-only actions (`search`/`fetch`), and refusing a @@ -20564,6 +21412,7 @@ async fn an_exact_member_without_a_network_tool_really_loses_the_network_surface "the standalone browse tool stays denied by name" ); } + /// A Runtime scout under a network-denied parent gets exactly the bounded web /// surface — `Web` with /// `search`/`fetch` — while every reaching spelling (`web.run`, `fetch_url`, @@ -20678,6 +21527,7 @@ async fn a_read_only_inspection_member_gets_only_bounded_web_search() { "full member keeps the whole Web enum" ); } + /// The unified `rlm` tool routes to the same code as the legacy aliases through /// an `action` parameter. A deny list that stops at the alias names leaves /// `rlm{action:"open", url:...}` callable — the whole reach by another spelling. @@ -20734,6 +21584,7 @@ fn the_unified_rlm_action_cannot_bypass_a_denied_alias() { "the compatibility RLM family must stay hidden from new child turns" ); } + /// The generic layer: a network-denied child that reaches a remote address /// through *any* tool's URL-bearing field is refused at the execution seam, even /// when the tool itself is local and permitted. This is what catches the next @@ -20813,6 +21664,7 @@ fn a_network_denied_child_cannot_address_a_remote_location_through_any_tool() { ); } } + /// A read-only member keeps `Run.verifiers` so it can do its job, but the /// `commands` array spawns arbitrary programs — `bash -lc 'rm -rf src'` is the /// raw shell the deny list just removed, re-entered through the door left open @@ -20867,6 +21719,7 @@ fn a_read_only_member_cannot_smuggle_commands_through_the_verifier_surface() { ); } } + /// The shipped `verifier` role is `write = false, shell = "full"`, and running /// the suite is its documented job. A test *selection* must survive at the same /// guard that refuses a command line — otherwise the indirect-execution fix has @@ -20927,6 +21780,7 @@ fn a_shell_capable_read_only_member_keeps_test_selection_arguments() { ); } } + /// The parent posture wins. A saved `full` member inside a read-only, /// no-network session runs with the session's ceiling, in the real registry. #[tokio::test] @@ -21000,6 +21854,7 @@ async fn a_parent_read_only_session_narrows_a_full_exact_member_in_the_child_reg "the URL-input guard stays deny-closed inside a read-only session" ); } + /// The session ceiling is read off the live parent runtime, so a Fleet can /// never widen what the operator is currently allowed to do. #[test] @@ -21042,6 +21897,7 @@ fn the_session_ceiling_reflects_the_live_parent_posture() { // half-contract: visibility without dispatch means a model that guesses the // name still wins, and dispatch without visibility means the model is offered a // capability it will be refused for using. + /// The child registry a Runtime verifier under a full parent actually runs with. fn read_only_with_shell_registry() -> (tempfile::TempDir, SubAgentToolRegistry) { let tmp = tempdir().expect("tempdir"); @@ -21076,6 +21932,7 @@ fn read_only_with_shell_registry() -> (tempfile::TempDir, SubAgentToolRegistry) ); (tmp, registry) } + /// Adversarial payloads — raw mutation, deletion, network reach, and scheduled /// execution — refused at the real dispatch guard. #[test] @@ -21107,6 +21964,7 @@ fn indirect_execution_payloads_are_refused_at_dispatch() { ); } } + /// The verifier's job, in the registry it actually runs with: a bounded test /// selection passes the real dispatch guard, and everything that could name a /// program or touch the workspace does not. @@ -21159,10 +22017,13 @@ fn a_verifier_runs_bounded_test_selections_and_nothing_else_at_dispatch() { ); } } + /// Catalog and dispatch must agree: the verification surface the guard admits -/// is offered, and the raw shell it refuses is absent. +/// is offered, and the legacy raw-shell spellings it refuses are absent (the +/// verifier's shell surface is the bounded canonical `bash` tool pinned by +/// the read-only-shell tests, not the legacy `Bash`/`exec_shell` names). #[test] -fn the_verifier_catalog_offers_the_verification_surface_and_no_shell() { +fn the_verifier_catalog_offers_run_without_the_legacy_raw_shell_spellings() { let (_tmp, registry) = read_only_with_shell_registry(); let tools = registry.tools_for_model(&FleetRole::Verifier); let names: Vec<&str> = tools.iter().map(|tool| tool.name.as_str()).collect(); @@ -21178,6 +22039,7 @@ fn the_verifier_catalog_offers_the_verification_surface_and_no_shell() { ); } } + /// The refusal reaches the *real* `execute` path, not only the classifier: the /// envelope check sits behind the allowlist, posture, and approval gates, and a /// guard that is never reached is not a guard. @@ -21202,6 +22064,7 @@ async fn a_verifier_is_refused_arbitrary_execution_at_the_real_execute_boundary( ); } } + /// The same payloads must never be *offered*. A model that can see a tool will /// try it, and a refusal is a worse experience than an absent capability. #[test] @@ -21255,6 +22118,7 @@ fn indirect_execution_actions_are_pruned_from_the_model_catalog() { ); } } + /// The bounded positives. This is the half that makes the contract honest: the /// point of `shell = "full"` on a read-only member is that it can still run the /// checks, and durable-task bookkeeping is exactly what such a member is for. @@ -21297,6 +22161,7 @@ fn bounded_read_only_and_verification_paths_survive_the_ceiling() { "durable-task bookkeeping must stay visible; got {actions:?}" ); } + /// A write-capable Runtime builder is unaffected. The guard narrows authority; /// it is not a new global restriction. #[test] @@ -21336,6 +22201,7 @@ fn a_write_capable_member_keeps_every_execution_gate() { ); } } + /// The unified-family bypass, for the durable-work families this time: a deny /// list naming `task_gate_run` has to reach `tasks{action:"gate_run"}`, or the /// canonical name is simply a second spelling that nothing checks. @@ -21373,6 +22239,7 @@ fn the_durable_work_families_resolve_their_actions_through_the_policy_seam() { } // ── A child may never widen its parent's envelope ─────────────────────────── + /// `inherit_disallowed_tools: false` is an escape hatch for *preference*, not /// for a ceiling. A Fleet member clamped to `network_tool = false` that spawns a /// grandchild asking for a clean surface must not hand it the network back. @@ -21423,6 +22290,7 @@ fn posture_denials_survive_a_child_that_declines_to_inherit() { } // ── The launch authority must reach the runtime, or the spawn fails ───────── + /// The fingerprint is the value the spawn boundary checks. It has to change /// whenever anything the child is constructed from changes, or checking it /// proves nothing. @@ -21460,6 +22328,7 @@ fn the_authority_fingerprint_distinguishes_every_envelope_it_names() { ); assert_ne!(narrow.fingerprint(), fingerprint("builder")); } + /// The spawn boundary itself. A missing, unparseable, or mismatched fingerprint /// must refuse the launch — a Runtime envelope that does not reach the child /// is not enforced authority. @@ -21521,6 +22390,7 @@ fn the_spawn_boundary_fails_closed_on_a_missing_or_mismatched_authority() { ); } } + /// End to end for the value that used to be computed and never read: the /// authority a launch resolves is the one whose fingerprint rides the receipt, /// and that fingerprint is what the spawn boundary accepts. @@ -21556,6 +22426,7 @@ fn the_launched_authority_is_the_one_the_spawn_boundary_accepts() { "one member's receipt must not authorize another member's child" ); } + /// R6 injection-size regressions (finish-operator 2026-08-02): build — never /// send — the real assembled payloads and pin them to measured-current +10%. /// Growth past the ceiling must be a deliberate, reviewed act, not drift. @@ -21569,6 +22440,7 @@ const READ_ONLY_CHILD_ENVELOPE_BYTE_CEILING: usize = 89_000; /// prompt-only start, review/tasks/verify/web/search/github/goal/terminal/ /// git/ocr/extract truthful-limit disclosures); +10%. const PARENT_SURFACE_BYTE_CEILING: usize = 90_000; + #[tokio::test] async fn read_only_child_envelope_stays_within_measured_ceiling() { let tmp = tempdir().expect("tempdir"); @@ -21602,6 +22474,7 @@ async fn read_only_child_envelope_stays_within_measured_ceiling() { "read-only child envelope grew past its reviewed ceiling: {envelope_bytes}B > {READ_ONLY_CHILD_ENVELOPE_BYTE_CEILING}B. If deliberate, re-measure and raise the ceiling in the same commit." ); } + #[tokio::test] async fn parent_agent_surface_stays_within_measured_ceiling() { let tmp = tempdir().expect("tempdir"); @@ -21632,6 +22505,7 @@ async fn parent_agent_surface_stays_within_measured_ceiling() { "parent prompt+catalog surface grew past its reviewed ceiling: {surface_bytes}B > {PARENT_SURFACE_BYTE_CEILING}B. If deliberate, re-measure and raise the ceiling in the same commit." ); } + fn init_claim_repo(root: &Path) { let run = |args: &[&str]| { let output = Command::new("git") @@ -21661,6 +22535,7 @@ fn init_claim_repo(root: &Path) { .expect("git available"); assert!(output.status.success(), "git commit: {output:?}"); } + #[test] fn completed_claim_of_untouched_file_taints_verification() { // R7 (finish-operator 2026-08-02): the morning report caught a child @@ -21691,6 +22566,7 @@ fn completed_claim_of_untouched_file_taints_verification() { } // ─── resume_from continuation-chain tests (#425) ───────────────────────────── + #[test] fn parse_spawn_request_accepts_resume_from() { let input = json!({ @@ -21700,6 +22576,7 @@ fn parse_spawn_request_accepts_resume_from() { let parsed = parse_spawn_request(&input).expect("spawn request should parse"); assert_eq!(parsed.resume_from.as_deref(), Some("agent_abc123")); } + #[test] fn parse_spawn_request_accepts_resume_from_camel_case() { let input = json!({ @@ -21709,12 +22586,14 @@ fn parse_spawn_request_accepts_resume_from_camel_case() { let parsed = parse_spawn_request(&input).expect("camelCase resumeFrom should parse"); assert_eq!(parsed.resume_from.as_deref(), Some("my-session")); } + #[test] fn parse_spawn_request_resume_from_absent_is_none() { let input = json!({ "prompt": "fresh start" }); let parsed = parse_spawn_request(&input).expect("prompt-only request should parse"); assert!(parsed.resume_from.is_none()); } + #[test] fn parse_spawn_request_resume_from_empty_string_is_none() { let input = json!({ @@ -21725,6 +22604,7 @@ fn parse_spawn_request_resume_from_empty_string_is_none() { .expect("whitespace-only resume_from should be treated as absent"); assert!(parsed.resume_from.is_none()); } + /// Spawning with resume_from + fork_context=false is contradictory and must /// be rejected at the spawn seam (not at parse time, since that is too early /// to know whether fork_context was explicit). @@ -21743,6 +22623,7 @@ fn parse_spawn_request_resume_from_with_fork_context_false_is_parseable() { assert_eq!(parsed.resume_from.as_deref(), Some("agent_abc123")); assert_eq!(parsed.fork_context, Some(false)); } + #[test] fn resume_from_rejects_running_source() { let tmp = tempdir().expect("tempdir"); @@ -21756,6 +22637,7 @@ fn resume_from_rejects_running_source() { "error should mention running status: {err}" ); } + #[test] fn completed_claim_matching_workspace_state_stays_untainted() { let tmp = tempdir().expect("tempdir"); @@ -21813,6 +22695,7 @@ fn completed_claim_matching_workspace_state_stays_untainted() { record.verification.summary ); } + #[tokio::test] async fn spawn_receipt_compacts_and_verbose_restores_the_archive() { // Morning-report issue #4 (W6 rest): every spawn returned ~12KB because @@ -21894,6 +22777,7 @@ async fn spawn_receipt_compacts_and_verbose_restores_the_archive() { assert!(verbose.get("compact").is_none()); assert_eq!(verbose, full, "verbose: true must restore the old shape"); } + fn spawn_route_metadata(provider: &str, model: &str, source: &str) -> WorkflowTaskSpawnMetadata { let child_route = ChildRouteReceipt { requested_type: "explore".to_string(), @@ -21927,9 +22811,11 @@ fn spawn_route_metadata(provider: &str, model: &str, source: &str) -> WorkflowTa resume_from_agent_id: None, } } + fn spawn_child_route_projection(metadata: &WorkflowTaskSpawnMetadata) -> ChildRouteReceipt { metadata.child_route.clone() } + #[test] fn spawn_receipt_route_names_the_provider_and_model_the_child_actually_got() { // #5305: a Fleet profile can route a child onto a provider the parent @@ -21963,6 +22849,7 @@ fn spawn_receipt_route_names_the_provider_and_model_the_child_actually_got() { assert_eq!(route["model_id"], json!("deepseek-v4-pro")); assert_eq!(route["route_source"], json!("task.model")); } + #[tokio::test] async fn spawn_receipt_route_survives_compaction_for_a_type_only_spawn() { // A type-only spawn resolves no roster member: no profile, no pins. The @@ -22016,6 +22903,7 @@ async fn spawn_receipt_route_survives_compaction_for_a_type_only_spawn() { "a type-only spawn resolves no profile: {receipt}" ); } + #[tokio::test] async fn unscoped_status_compacts_running_children_and_keeps_terminal_full() { // Morning-report issue #4: one unscoped status poll returned 203KB @@ -22087,6 +22975,7 @@ async fn unscoped_status_compacts_running_children_and_keeps_terminal_full() { assert!(verbose_row.get("snapshot").is_some(), "{verbose_row}"); assert!(verbose_row.get("compact").is_none(), "{verbose_row}"); } + #[test] fn resume_from_rejects_missing_source() { let tmp = tempdir().expect("tempdir"); @@ -22099,6 +22988,7 @@ fn resume_from_rejects_missing_source() { "error should mention not found: {err}" ); } + #[test] fn resume_from_accepts_completed_source() { let tmp = tempdir().expect("tempdir"); @@ -22112,6 +23002,7 @@ fn resume_from_accepts_completed_source() { validate_resume_from_source(&manager, &completed_id, tmp.path()) .expect("completed agent must be accepted as a resume source"); } + #[test] fn resume_from_accepts_interrupted_source() { let tmp = tempdir().expect("tempdir"); @@ -22128,6 +23019,7 @@ fn resume_from_accepts_interrupted_source() { validate_resume_from_source(&manager, &interrupted_id, tmp.path()) .expect("interrupted agent must be accepted as a resume source"); } + #[test] fn resume_from_accepts_failed_source() { let tmp = tempdir().expect("tempdir"); @@ -22140,6 +23032,7 @@ fn resume_from_accepts_failed_source() { validate_resume_from_source(&manager, &failed_id, tmp.path()) .expect("failed agent may be used as a resume source"); } + #[test] fn resume_from_accepts_cancelled_source() { let tmp = tempdir().expect("tempdir"); @@ -22152,6 +23045,7 @@ fn resume_from_accepts_cancelled_source() { validate_resume_from_source(&manager, &cancelled_id, tmp.path()) .expect("cancelled agent may be used as a resume source"); } + #[test] fn resume_from_rejects_cross_workspace_source() { let tmp_parent = tempdir().expect("parent tempdir"); @@ -22171,6 +23065,7 @@ fn resume_from_rejects_cross_workspace_source() { "error should mention workspace mismatch: {err}" ); } + #[test] fn resume_from_loads_transcript_artifact_when_available() { let tmp = tempdir().expect("tempdir"); @@ -22196,6 +23091,7 @@ fn resume_from_loads_transcript_artifact_when_available() { "resume transcript reads must not fall back to the execution workspace" ); } + #[test] fn resume_from_falls_back_to_checkpoint_when_artifact_missing() { let tmp = tempdir().expect("tempdir"); @@ -22225,6 +23121,7 @@ fn resume_from_falls_back_to_checkpoint_when_artifact_missing() { assert_eq!(fallback_messages.len(), 2); assert_eq!(message_text(&fallback_messages[0]), "checkpoint task"); } + #[test] fn resume_from_session_name_resolves_to_agent_id() { let tmp = tempdir().expect("tempdir"); @@ -22243,6 +23140,7 @@ fn resume_from_session_name_resolves_to_agent_id() { validate_resume_from_source(&manager, "named-source", tmp.path()) .expect("resolution by session name must work for resume_from"); } + #[test] fn child_launch_manifest_carries_resume_from_agent_id() { let tmp = tempdir().expect("tempdir"); @@ -22262,6 +23160,7 @@ fn child_launch_manifest_carries_resume_from_agent_id() { } // ── validation helper exposed for the tests above ──────────────────────────── + /// Extracted validation logic mirroring what `spawn_subagent_from_input` does. /// Returns `Ok(agent_id)` when the source is acceptable, `Err(message)` when /// it must be rejected. @@ -22299,6 +23198,7 @@ fn validate_resume_from_source( Ok(source_id) } + /// Owner report 2026-08-04: a model/provider switch spawns the new engine's /// manager while the old engine still holds the workspace coordination flock /// in this same process. That losing acquisition must classify itself as a @@ -22332,6 +23232,7 @@ fn coordination_lock_loss_to_own_process_reads_as_handover_and_self_heals() { } // -- resume-from-checkpoint tests (checkpoint-based continuation) -- + #[tokio::test] async fn resume_from_checkpoint_spawns_seeded_agent_with_checkpoint_context() { let tmp = tempdir().unwrap(); @@ -22392,6 +23293,7 @@ async fn resume_from_checkpoint_spawns_seeded_agent_with_checkpoint_context() { let prior = guard.agents.get(&agent_id).expect("prior record"); assert!(matches!(prior.status, SubAgentStatus::Interrupted(_))); } + #[tokio::test] async fn resume_from_checkpoint_is_idempotent_across_repeated_followups() { let tmp = tempdir().unwrap(); @@ -22441,6 +23343,7 @@ async fn resume_from_checkpoint_is_idempotent_across_repeated_followups() { "a repeated followup must reach the resumed target" ); } + #[tokio::test] async fn resume_from_checkpoint_rejects_non_interrupted_agents() { let tmp = tempdir().unwrap(); @@ -22462,6 +23365,7 @@ async fn resume_from_checkpoint_rejects_non_interrupted_agents() { "{err}" ); } + #[tokio::test] async fn resume_from_checkpoint_rejects_missing_continuable_checkpoint() { let tmp = tempdir().unwrap(); @@ -22494,6 +23398,7 @@ async fn resume_from_checkpoint_rejects_missing_continuable_checkpoint() { "{err}" ); } + /// The resume lane must honor the same isolation rule as a fresh worktree /// spawn: a worktree child's boundary is the worktree alone, so a multi-root /// parent's attached roots must not re-widen the resumed child. Both @@ -22600,6 +23505,7 @@ async fn resume_of_isolated_worktree_child_keeps_the_worktree_as_its_only_root() root set — the parent's attached root must not stay writable" ); } + /// Isolation must not key on the write claim's existence: a claim can be /// released or never created for a worktree child. The worker record's /// launch manifest durably pins the worktree spawn, so the claim-less resume @@ -22700,6 +23606,7 @@ async fn claim_less_resume_of_a_recorded_worktree_child_stays_isolated() { "a claim-less worktree child must not inherit the caller's writable roots" ); } + /// The rebuild idiom shared by both worktree clear sites: after the roots set /// is cleared, the WorkspaceWrite face of the cloned policy re-derives from /// the child's `(workspace, workspace_roots)` pair — the worktree alone — and @@ -22748,6 +23655,7 @@ fn rederive_sandbox_policy_roots_confines_the_exec_lane_to_the_cleared_set() { assert_eq!(context.elevated_sandbox_policy, Some(policy)); } } + #[test] fn user_follow_up_to_running_child_counts_queued_until_the_loop_takes_it() { let tmp = tempdir().expect("tempdir"); @@ -22794,6 +23702,7 @@ fn user_follow_up_to_running_child_counts_queued_until_the_loop_takes_it() { "nothing queued once the loop took both" ); } + #[test] fn user_follow_up_to_cancelled_child_fails_closed_with_the_reason() { let tmp = tempdir().expect("tempdir"); @@ -22810,6 +23719,7 @@ fn user_follow_up_to_cancelled_child_fails_closed_with_the_reason() { assert!(err.to_string().contains("cancelled"), "{err}"); assert!(manager.queued_follow_up_counts().is_empty()); } + #[test] fn user_follow_up_to_completed_child_requires_a_runtime_to_resume() { let tmp = tempdir().expect("tempdir"); @@ -22831,6 +23741,7 @@ fn user_follow_up_to_completed_child_requires_a_runtime_to_resume() { } // === child permission gate: the session posture applied to a worker's calls === + mod child_permission_gate { use super::*; use crate::approval_log::{ApprovalOutcome, ApprovalReceipt, ApprovalReceiptStore}; @@ -23562,6 +24473,7 @@ mod child_permission_gate { } // ── #5462: `agent` is the only model-facing sub-agent surface ──────────────── + /// The six narrow tools that used to sit beside `agent` in the model catalog. /// Kept as one list so a test cannot silently check five of them. const RETIRED_AGENTS_TOOLS: &[&str] = &[ @@ -23572,6 +24484,7 @@ const RETIRED_AGENTS_TOOLS: &[&str] = &[ "agents/coordinate", "agents/wait", ]; + fn subagent_registry_for_catalog(tmp: &std::path::Path) -> crate::tools::ToolRegistry { let runtime = stub_runtime(); let manager = runtime.manager.clone(); @@ -23579,6 +24492,7 @@ fn subagent_registry_for_catalog(tmp: &std::path::Path) -> crate::tools::ToolReg .with_subagent_tools(manager, runtime) .build(crate::tools::spec::ToolContext::new(tmp.to_path_buf())) } + /// The narrow tools must vanish from the advertised catalog while staying /// registered: a persisted transcript that replays `agents/followup` has to /// keep dispatching to the same implementation, exactly as `rlm` and @@ -23615,6 +24529,7 @@ fn retired_agents_tools_stay_registered_but_leave_the_model_catalog() { ); } } + /// Hiding a tool from the initial catalog is worthless if `tool_search` can /// hand it back. Both matching paths read the same catalog, so both are /// exercised — with queries chosen to hit the retired tools' own names and @@ -23667,6 +24582,7 @@ fn tool_search_cannot_return_a_retired_agents_tool() { } } } + /// A name the model can read is a name the model will try to call. The `agent` /// description and schema must not advertise a tool that is no longer in the /// catalog — the failure mode that motivated this change was the description @@ -23698,6 +24614,7 @@ fn the_agent_surface_never_names_a_retired_tool() { "the replacement action must be advertised" ); } + /// The claim case, end to end, through the `agent` surface. /// /// This is the test the audit demanded: the coordinate wire key is `roots` @@ -23799,6 +24716,7 @@ async fn agent_claim_expands_the_callers_write_scope() { .expect("the expanded scope admits the previously refused write"); assert!(tmp.path().join("docs/note.txt").exists()); } + /// `expand_write_claim` returns the unchanged claim with `Ok` when every list /// is empty, so an empty claim would read as "granted". Refuse it at the seam. #[test] @@ -23825,6 +24743,7 @@ fn agent_claim_refuses_a_scopeless_call() { "the coordinate wire key is `roots`: {translated}" ); } + /// Folding six tools into one multi-action tool must not hand a read-only role /// an authority its catalog previously withheld. `agents/coordinate` was kept /// off an inspection role's surface by the execution envelope; `claim` has to @@ -23902,6 +24821,7 @@ async fn agent_claim_is_withheld_from_a_role_with_no_write_authority() { .to_string(); assert!(refusal.contains("no write authority to widen"), "{refusal}"); } + // Regression (stopship scout repair): the workflow's read-only scout // activates `grep_files` with one `tool_search` call before searching. That // two-step path only works while the scout surface keeps a first-turn-active @@ -23954,6 +24874,7 @@ async fn forkguard_scout_surface_keeps_tool_search_grep_files_activation_path() "the hidden File alias must stay out of scout catalogs" ); } + // Regression (stopship scout repair, Pinvou #490 phantom-tool class): fleet // workflow briefs are model-facing text too. The scout brief must name tools // the child catalog can actually see (`tool_search`, `grep_files`) and never @@ -23995,6 +24916,7 @@ fn forkguard_workflow_briefs_name_catalog_visible_tools() { "workflow briefs must not command calls a catalog can never return: {cited:?}" ); } + // Behavioral counterpart to the two composition guards above: on the scout's // live surface, one `tool_search` call must make the deferred `grep_files` // dispatchable through the same gate the child step loop uses, while the @@ -24123,6 +25045,7 @@ fn forkguard_subagent_skill_catalog_uses_tool_search_discovery() { ); assert!(!catalog.contains("try `load_skill` directly anyway")); } + // Worker records, takeover targets, and transcript artifact descriptions all // point the parent at `handle_read`, which is deferred on stock hosts; every // prose site that names it must carry the `tool_search` activation hint so diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index 506c0347d9..a7a9fc9dac 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -69,9 +69,9 @@ stewardship. | `general` | flexible; do whatever the parent says | yes | yes | yes | the default; multi-step tasks | | `explore` | read-only; map the relevant code fast | no | yes | read-only (net + bounded verify) | "find every call site of `Foo`; check the PR with gh" | | `planner` | analyse and produce a strategy | no | yes | read-only probes | "design the migration; don't execute" | -| `reviewer` | read-and-grade with severity scores | no | yes | read-only (net + bounded verify) | "audit this PR for bugs" | +| `reviewer` | read-and-grade with severity scores | no | yes | read-only (net) | "audit this PR for bugs" | | `implement` | land a specific change with min edit | yes | yes | yes | "rewrite `bar.rs::Foo::bar` to do X" | -| `test` | run tests / validation, report outcome | no | yes | bounded verification (no writes) | "verify the diff with the bounded test checks; report PASS/FAIL" | +| `test` | run the built-in verification gates and report outcome | no | yes | built-in gates + read-only probes (no custom commands) | "verify the diff with the built-in checks; report PASS/FAIL" | | `advisor` | short-lived, high-reasoning counsel | no | yes | none | "what are we missing in this design?" | | `custom` | explicit narrow tool allowlist | inherits | inherits | inherits | hand-picked tools on the parent's posture | @@ -90,11 +90,16 @@ builder) is a supported escape hatch for *work capacity* — the child brings its own model, route, and step budget — but the child's authority is clamped against the delegating parent's live posture, not the operator's: a scout's builder child lands read-only with raw shell and mutating tools denied, and -canonical `Bash` is denied to it too (only bounded-inspection roles keep the +canonical `Bash` is denied to it too (only the bounded-inspection roles and the verifier keep the classified read-only shell). Delegating to obtain shell is therefore mechanically useless — the scout's own bounded shell (`git -C … log`, `find … | head`, `npm view …`, classifier-gated) is the only shell path a -read-only parent has. Read-only is transitive through any delegation chain: +read-only parent has. The bounded grammar also admits compound probes of +those families — segments joined by `;`, `&&`, `||`, or `|`, where every +segment must individually be admitted (e.g. `wc -c f; wc -l f`, +`cd sub && wc -c notes.md`, `cat x | od -c`) — and a workspace-confined +`cd ` in any sequence position its connector always runs (joined by `;`, or first — a `cd` after `&&`/`||` may never execute, so it refuses). +Read-only is transitive through any delegation chain: the clamp (`ChildAuthority::clamp` in `fleet/exact.rs`) intersects every field with the narrower side, the deny-list union means a descendant can never drop an ancestor's restriction, and `inherit_disallowed_tools: false` @@ -314,9 +319,15 @@ OUTPUT: VERDICT, EVIDENCE, GAPS, NEXT. - **`test`** — when the parent needs an authoritative pass/fail on the test suite or other validation. Verifiers don't fix failures; they capture the failing assertion + stack and put fix - candidates under RISKS. The verifier posture never writes, and shell - is clamped to the bounded built-in verification surface: the write - ceiling is read-only and unbounded shell forms are refused (#5186). + candidates under RISKS. The verifier posture never writes; its shell + is the bounded built-in verification surface plus — on in-session + delegations whose envelope grants it — the same classifier-gated + read-only probe lane the inspection roles hold (`wc`, `ls`, `cat`, + `grep` pipelines) for gathering evidence; envelopes without the + lane omit it and shell attempts refuse. The + write ceiling is read-only, unbounded shell forms are refused + (#5186), and custom commands are refused — use a write-capable role + for those. - **`advisor`** — when the operator wants a high-leverage second opinion before cheaper execution continues. Consultants read enough to ground a recommendation, but cannot write or run shell commands. `oracle` and diff --git a/docs/zh_hans/FLEET.md b/docs/zh_hans/FLEET.md index df9652850c..0ae5afa5e6 100644 --- a/docs/zh_hans/FLEET.md +++ b/docs/zh_hans/FLEET.md @@ -121,7 +121,7 @@ call_reasrning = "lrw" 在运行时,它只能为已经冻结的 worker route 选择 reasoning 级别。它不能改变 worker、provider、model、role、tools 或 permissions。Router 调用本身被限制在 `off` 或 `low`;更贵的值会被拒绝。手动选择的 worker reasoning 级别不会产生 Router 调用。Route 与 reasoning 回执会指名 worker model,并在使用 Router 时给出 Router 的确切 provider/model,让操作者看到哪个 model 干了哪份活。如果相同的裸 Router 或 Fleet 名字在两个根里都存在,请用 `workspace/` 或 `codewhale_home/` 限定它,而不是依赖遮蔽(shadowing)。 -每个成员的 `permissions` 预设是一个**上限**,绝不是授权:它会与会话的实时姿态取交集,因此只读会话里的 `read_write` 成员以只读方式运行。这个交集成为子任务真实的工具包——`permissions = "none"` 让它一个工具都没有,而没有网络工具的成员会失去每一个可触达的网络表面——`web.run`、`fetch_url`、`web_search`、`github`、`mcp*` 系列以及可触达的 `rlm` 动作——而不是仅仅在调用时被拒绝。唯一刻意的例外是规范 `Web` 家族的 `search`/`fetch` 动作:它们是只读成员有权使用的只读 web 表面(与普通 scout 对等),因此网络被禁的成员恰好保留 `Web {search, fetch}`。它在事实上保持只读,而不仅仅是名义上:按 URL 寻址的 `fetch` 在派发时被拒绝,表面上没有任何其他东西被授予。不能写入的成员会失去可变的文件工具,并且——当它保留了 `shell = "full"` 以便运行检查时——也会失去原始 shell,只保留它为之而存在的有界验证工具(`run_tests`、`run_verifiers`):任意 shell 命令和 `write_file` 一样确定无疑地改变工作区,所以保留它会令 `write = false` 不成立。那个验证表面只在它的*默认*形式下有界,因此无界参数会随 shell 一起消失:被拒绝写入的成员可以运行内置 gate,但不能用显式 `commands` 数组运行 `run_verifiers`,也不能用原始 `args` 字符串运行 `run_tests`——这两者都会启动操作者提供的程序,是换了名字的原始 shell。 +每个成员的 `permissions` 预设是一个**上限**,绝不是授权:它会与会话的实时姿态取交集,因此只读会话里的 `read_write` 成员以只读方式运行。这个交集成为子任务真实的工具包——`permissions = "none"` 让它一个工具都没有,而没有网络工具的成员会失去每一个可触达的网络表面——`web.run`、`fetch_url`、`web_search`、`github`、`mcp*` 系列以及可触达的 `rlm` 动作——而不是仅仅在调用时被拒绝。唯一刻意的例外是规范 `Web` 家族的 `search`/`fetch` 动作:它们是只读成员有权使用的只读 web 表面(与普通 scout 对等),因此网络被禁的成员恰好保留 `Web {search, fetch}`。它在事实上保持只读,而不仅仅是名义上:按 URL 寻址的 `fetch` 在派发时被拒绝,表面上没有任何其他东西被授予。不能写入的成员会失去可变的文件工具,并且——当它保留了 `shell = "full"` 以便运行检查时——也会失去原始 shell,只保留它为之而存在的有界验证工具(`run_tests`、`run_verifiers`;经 `agent` 工具嵌套的 verifier 额外持有分类器门控的只读取证探针通道,但 Fleet 成员信封把有界验证与 Bash 权限定义为互斥,因此不带该探针通道):任意 shell 命令和 `write_file` 一样确定无疑地改变工作区,所以保留它会令 `write = false` 不成立。那个验证表面只在它的*默认*形式下有界,因此无界参数会随 shell 一起消失:被拒绝写入的成员可以运行内置 gate,但不能用显式 `commands` 数组运行 `run_verifiers`,也不能用原始 `args` 字符串运行 `run_tests`——这两者都会启动操作者提供的程序,是换了名字的原始 shell。 这些拒绝中有两项比工具名更窄,因为有两个工具触及了它们名字之外的地方。`rlm` 通过在进程*内部*、以它自己的名字调用 fetch 工具来加载 `url`,而 `rlm` 的 `eval` 动作针对活内核运行 Python——套接字和文件系统都触及。因此该家族**按动作**被拒绝,而不是整体拒绝: diff --git a/docs/zh_hans/SUBAGENTS.md b/docs/zh_hans/SUBAGENTS.md index 708cc14dda..a9dab671be 100644 --- a/docs/zh_hans/SUBAGENTS.md +++ b/docs/zh_hans/SUBAGENTS.md @@ -29,15 +29,15 @@ Fleet 角色是面向用户的委派工作词汇:父代理通过 `agent` 启 | `worker` | 灵活;父代理说什么就做什么 | 是 | 是 | 是 | 默认角色;多步任务 | | `scout` | 只读;快速摸清相关代码 | 否 | 是 | 只读(网络 + 有界验证) | "找到 `Foo` 的每个调用点;用 gh 检查这个 PR" | | `planner` | 分析并产出策略 | 否 | 是 | 只读探针 | "设计迁移方案;不要执行" | -| `reviewer` | 带严重度评分的阅读与评审 | 否 | 是 | 只读(网络 + 有界验证) | "审计这个 PR 的 bug" | +| `reviewer` | 带严重度评分的阅读与评审 | 否 | 是 | 只读(网络) | "审计这个 PR 的 bug" | | `builder` | 以最小改动落地某个具体变更 | 是 | 是 | 是 | "把 `bar.rs::Foo::bar` 重写为做 X" | -| `verifier` | 运行测试/验证并报告结果 | 否 | 是 | 测试导向 | "运行 cargo test --workspace,并报告" | +| `verifier` | 运行内置验证门禁并报告结果(只读探针取证) | 否 | 是 | 内置门禁 + 只读探针(无自定义命令) | "用内置检查验证这个 diff,并报告 PASS/FAIL" | | `consultant` | 短期、高推理密度的咨询 | 否 | 是 | 无 | "这个设计我们漏掉了什么?" | | `custom` | 显式的窄工具 allowlist | 继承 | 继承 | 继承 | 在父代理姿态上精选的工具 | 角色的默认值就是该角色*想要*的姿态,而父代理的有效姿态永远是天花板(子代理绝不会扩得比父代理更宽)。只读角色按意图扣留**工作区写入**;默认不拿走任何其他东西——每个角色都保留网络读取,`custom` 继承父代理的写入/网络/shell 姿态,并且只被它的显式工具列表或发起调用收窄。被聚焦的 worker 的头部会依据运行时自身的权限快照声明有效姿态(`scout · read-only · network · read-only shell`)。 -**委派移动的是工作,绝不是权限**(#5426 的遏制答案)。只读角色委派给可写角色(scout → builder)是*工作容量*受支持的逃生舱——子代理自带其模型、路由和步骤预算——但子代理的权限被钳制在委派父代理的实时姿态上,而不是操作者的姿态上:scout 的 builder 子代理以只读落地,raw shell 和可变工具被拒绝,规范的 `Bash` 对它也被拒绝(只有有界检查角色保留分类的只读 shell)。因此通过委派来获得 shell 在机制上是无用的——scout 自带的受限 shell(`git -C … log`、`find … | head`、`npm view …`,分类器门控)是只读父代理唯一的 shell 路径。只读通过任何委派链都是传递的:钳制(`fleet/exact.rs` 中的 `ChildAuthority::clamp`)把每个字段与更窄的一侧求交,拒绝列表的并集意味着后代永远无法去掉祖先的限制,而 `inherit_disallowed_tools: false` 无法去掉姿态拒绝(`is_posture_denial`)。这一点由 `crates/tui/src/fleet/exact.rs` 测试中的 `a_read_only_parents_delegation_never_widens_authority` 钉死。 +**委派移动的是工作,绝不是权限**(#5426 的遏制答案)。只读角色委派给可写角色(scout → builder)是*工作容量*受支持的逃生舱——子代理自带其模型、路由和步骤预算——但子代理的权限被钳制在委派父代理的实时姿态上,而不是操作者的姿态上:scout 的 builder 子代理以只读落地,raw shell 和可变工具被拒绝,规范的 `Bash` 对它也被拒绝(只有有界检查角色与 verifier 保留分类的只读 shell)。因此通过委派来获得 shell 在机制上是无用的——scout 自带的受限 shell(`git -C … log`、`find … | head`、`npm view …`,分类器门控)是只读父代理唯一的 shell 路径。该有界语法还允许把这些已准入的探针组合成复合序列——以 `;`、`&&`、`||` 或 `|` 连接的段,且每个段都必须单独准入(例如 `wc -c f; wc -l f`、`cd sub && wc -c notes.md`、`cat x | od -c`)——以及工作区受限的 `cd <单个相对路径>`(可单独使用,或出现在连接符必定执行它的序列位置——以 `;` 连接或位于序列首位;`&&`/`||` 之后的 `cd` 可能根本不会执行,因此会被拒绝)。只读通过任何委派链都是传递的:钳制(`fleet/exact.rs` 中的 `ChildAuthority::clamp`)把每个字段与更窄的一侧求交,拒绝列表的并集意味着后代永远无法去掉祖先的限制,而 `inherit_disallowed_tools: false` 无法去掉姿态拒绝(`is_posture_denial`)。这一点由 `crates/tui/src/fleet/exact.rs` 测试中的 `a_read_only_parents_delegation_never_widens_authority` 钉死。 会话的**权限姿态**在每个子代理内部的应用方式与父代理回合完全一致:在 Auto-Review 下,同一个确定性底线和一次性模型守护者决定 worker 的被扣留调用(绝不是提示词;守护者不可用时拒绝,fail closed);在 Ask 下,角色无法委派的被扣留调用会作为审批提示在父代理的 UI 中弹出,worker 可见地等待(`waiting for user`),或者在无法提示的主机上带着原因被拒绝;Full Access 仍然在不可绕过的安全底线上 fail closed。每一次没有人被提示的决策都是该 worker 转录中的一行备注(聚焦时可见)和一条审计日志记录。参见 `docs/MODES.md`。 @@ -122,7 +122,7 @@ OUTPUT: VERDICT、EVIDENCE、GAPS、NEXT。 - **`planner`** —— 当父代理有目标但没有可执行的分解。planner 写工件(`todo_write` 条目、响应体里的策略),但不执行它们。 - **`reviewer`** —— 当已经有一个变更,父代理想要它被评分。reviewer 不打补丁——他们在发现里描述修复方案,这样如果判定是"修它",父代理可以派一个 builder。 - **`builder`** —— 当变更已经被明确指定、只需要落地。builder 保持严格的范围:最小改动,不做顺手重构,交回前跑一次快速验证。 -- **`verifier`** —— 当父代理需要测试套件或其他验证上的权威通过/失败结论。verifier 不修失败;他们记录失败的断言 + 栈,把修复候选放在 RISKS 下。 +- **`verifier`** —— 当父代理需要测试套件或其他验证上的权威通过/失败结论。verifier 不修失败;他们记录失败的断言 + 栈,把修复候选放在 RISKS 下。verifier 姿态从不写入;其 shell 是有界内置验证面,加上——在授予该通道的会话内委派上——与检查角色相同的分类器门控只读探针通道(`wc`、`ls`、`cat`、`grep` 管道)用于取证;未授予该通道的 envelope 会省略它,shell 尝试一律拒绝。写入上限是只读,无界 shell 形式被拒绝(#5186),自定义命令也被拒绝——请改用具备写能力的角色。 - **`consultant`** —— 当操作者想在更便宜的执行继续之前得到一个高杠杆的第二意见。consultant 读足够的材料来支撑一条建议,但不能写,也不能运行 shell 命令。`oracle` 和 `advisor` 只在加载更老的请求或持久化记录时被接受;新的提示词、回执和 UI 使用 `consultant`。 - **`custom`** —— 只有当父代理需要显式约束工具集时。通过 legacy/internal 子代理记录上的 `allowed_tools` 字段传 allowlist;面向模型的 `agent` 工具刻意保持公共 schema 很小。 From e619b7d5fc8b84c14670df0a3a40ace3f8828bb1 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Thu, 12 Nov 2026 13:36:30 +0800 Subject: [PATCH 08/11] fix(fleet): round-1 review fixes - scope the gates contract and pin the chain honestly Review round 1 on this slice found three should-fix items and a set of consistency nits; this wave fixes everything actionable: - The gates claim is now conditional everywhere it appears (intro sentence 1, the roster description, the type description, the docs bullet and role tables in en+zh): a Verifier child of a read-only shell parent keeps the probe lane and loses the gates, which is the exact shape clamped_verifier_child_of_read_only_parent_holds_no_run_ surface pins. The unconditional phrasing was the PR's own overclaim. - sub_subagent_chain_of_a_carveout_verifier_holds_no_extra_authority now really chains: hop 2 is derived from the carve-out verifier's published narrowed profile through derive_child, and the Builder below it is asserted to hold no gate surface and no probe lane at posture, envelope, catalog, and dispatch. It guards the propagation side of the round-3 fix (a registry that leaked a pre-narrowing profile to its spawns would redden it), no longer claiming ordering coverage it did not have. - Read-only-shell children of other roles pin the probe lane's absence from their catalogs: a widened role set must not silently hand the classifier-bounded shell to write-capable children. - Docs consistency: the explore row (en) and scout row (zh) drop the stale 'bounded verify' claim the reviewer row fix left behind; the scout brief attributes the probe lane, not the verification surface; the fleet-envelope contract statement gains its English counterpart. - Copy honesty sweep: the fleet roster blurb and the FleetRole::Verifier rustdoc state the real contract; the network posture warning names the effective (narrowed) shell like the receipts do; three stale comments updated (role_posture_permits docstring, the narrowing comment, the delegation-refusal comment). - Test walls corrected: the legacy-Bash alias provability pin moved to the dispatch wall that owns it (posture's Auto classification admits provable shapes for any role), with the half-wrong comment fixed. Targeted tools::subagent:: + tools::registry:: + fleet:: + verifier:: suites 1028/0; cargo fmt and fork-CI-exact clippy clean. Signed-off-by: asto18089 --- crates/tui/src/fleet/role.rs | 12 ++- crates/tui/src/fleet/roster.rs | 2 +- crates/tui/src/fleet/worker_runtime.rs | 6 +- crates/tui/src/tools/subagent/mod.rs | 33 +++--- crates/tui/src/tools/subagent/tests.rs | 144 ++++++++++++++++++++----- docs/SUBAGENTS.md | 23 ++-- docs/zh_hans/SUBAGENTS.md | 8 +- 7 files changed, 167 insertions(+), 61 deletions(-) diff --git a/crates/tui/src/fleet/role.rs b/crates/tui/src/fleet/role.rs index 79e3bd47d4..7839988404 100644 --- a/crates/tui/src/fleet/role.rs +++ b/crates/tui/src/fleet/role.rs @@ -77,10 +77,12 @@ pub enum FleetRole { /// posture pushes hard on landing the change cleanly with the /// minimum surrounding edit (#404). Builder, - /// Verification — focused on running the test suite or other - /// validation gates and reporting pass/fail with evidence. - /// Distinct from `Reviewer` in that Reviewer reads code and grades it; - /// Verifier *runs* tests and reports the outcome (#404). + /// Verification — runs the built-in verification gates and reports + /// pass/fail with evidence; the gates are refused when a read-only-shell + /// parent clamps the child's shell authority. In-session delegations + /// also carry the bounded read-only probe lane. Distinct from `Reviewer` + /// in that Reviewer reads code and grades it; Verifier *runs* the gates + /// and reports the outcome (#404). Verifier, /// Advisory counsel — a strong-model second opinion the operator can ask /// for guidance, judgement calls, and design critique (#4752). @@ -193,7 +195,7 @@ impl FleetRole { "Lands focused code changes cleanly with the minimum surrounding edit." } Self::Verifier => { - "Runs the built-in verification gates with test-selection arguments, plus — for in-session delegations — a bounded read-only shell for evidence gathering; cannot execute custom commands — use a write-capable role for those." + "Runs the built-in verification gates with test-selection arguments unless a read-only-shell parent clamped its shell authority (the clamped child keeps the bounded read-only probes and loses the gates), plus — for in-session delegations — a bounded read-only shell for evidence gathering; cannot execute custom commands — use a write-capable role for those." } Self::Consultant => { "Read-only high-reasoning counsel for judgement calls and design critique." diff --git a/crates/tui/src/fleet/roster.rs b/crates/tui/src/fleet/roster.rs index c9ac4a2c7d..fef749f24b 100644 --- a/crates/tui/src/fleet/roster.rs +++ b/crates/tui/src/fleet/roster.rs @@ -481,7 +481,7 @@ impl FleetRoster { "verifier", FleetSlot::Verifier, FleetLoadout::Inherit, - "Runs builds and tests to verify claims; reports evidence, does not patch.", + "Runs the built-in verification gates to verify claims; reports evidence, does not patch, holds no shell.", None, ), ( diff --git a/crates/tui/src/fleet/worker_runtime.rs b/crates/tui/src/fleet/worker_runtime.rs index e947b5cfea..8d862dfb6b 100644 --- a/crates/tui/src/fleet/worker_runtime.rs +++ b/crates/tui/src/fleet/worker_runtime.rs @@ -1521,11 +1521,15 @@ pub(crate) fn network_posture_warning_for_task( return None; } + // Name the shell the role actually runs under (read-only roles run + // narrowed), not the requested default — the same truth the receipts + // report. + let effective = crate::fleet::role::effective_runtime_profile_for_role(&agent_type, &runtime); Some(format!( "Fleet task `{}` mentions network-backed verification, but role `{}` has network=off and shell={}. Dispatch a `worker` role with shell `read_only` for gh/curl evidence, or revise the brief.", task.id, role.as_deref().unwrap_or("worker"), - shell_policy_label(runtime.shell), + shell_policy_label(effective.shell), )) } diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 222ca0af99..20f899b09d 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -404,7 +404,7 @@ fn subagent_perf_enabled() -> bool { const VALID_SUBAGENT_TYPES: &str = "general, explore, planner, reviewer, implement, test, advisor, custom \ (legacy aliases remain accepted: worker, scout, builder, verifier, consultant, general-purpose, general_purpose, default, exploration/explorer, plan/planning/awaiter, review/code-review/code_review, implementer/implementation, verify/verification/validator/tester, oracle)"; -const SUBAGENT_TYPE_DESCRIPTION: &str = "Fleet role for this delegated worker. general: full tool access for multi-step tasks; write-capable, so it claims the parent workspace unless write_roots narrows it — parallel generals without disjoint write_roots contend, so prefer a read-only role for research fan-outs. explore: fast read-only exploration. planner: grounded strategy with read-only probes. reviewer: reads and grades code. implement: lands focused code changes; write-capable under the same workspace-claim rules as general, so parallel implements need disjoint write_roots or worktree=true. test: runs the built-in verification gates and reports evidence, with a bounded read-only shell — for in-session delegations — for gathering it; custom commands are refused. advisor: read-only high-reasoning counsel for judgement calls and design critique. custom: the tools listed in allowed_tools on the parent's posture; without an explicit write authority it starts read-only and never contends. Legacy aliases remain accepted at deserialization boundaries."; +const SUBAGENT_TYPE_DESCRIPTION: &str = "Fleet role for this delegated worker. general: full tool access for multi-step tasks; write-capable, so it claims the parent workspace unless write_roots narrows it — parallel generals without disjoint write_roots contend, so prefer a read-only role for research fan-outs. explore: fast read-only exploration. planner: grounded strategy with read-only probes. reviewer: reads and grades code. implement: lands focused code changes; write-capable under the same workspace-claim rules as general, so parallel implements need disjoint write_roots or worktree=true. test: runs the built-in verification gates and reports evidence, with a bounded read-only shell — for in-session delegations — for gathering it; a read-only-shell parent clamps the gates (probes remain), and custom commands are refused. advisor: read-only high-reasoning counsel for judgement calls and design critique. custom: the tools listed in allowed_tools on the parent's posture; without an explicit write authority it starts read-only and never contends. Legacy aliases remain accepted at deserialization boundaries."; // === Types === @@ -15560,12 +15560,14 @@ impl SubAgentToolSurface { /// Role-only approval posture; the registry allow/deny and runtime envelope /// remain the authoritative per-tool checks. /// -/// `Auto` is safe for every role. `Suggest` requires a write-capable role; -/// `Required` requires the full-shell posture. A Custom worker may describe -/// either posture, but its explicit allowlist and the runtime envelope still -/// narrow what it can execute. Read-only bash operations are classified Auto, -/// so Scouts and Reviewers do not gain general shell authority through this -/// branch. +/// Only the `Auto` and `Suggest` answers remain live: the posture gate's +/// `Required` branch keys on the held shell policy plus the verifier's +/// recorded parent-shell grant (`holds_shell_authority`), no longer on this +/// role-only table. `Suggest` still requires a write-capable role. A Custom +/// worker may describe either posture, but its explicit allowlist and the +/// runtime envelope still narrow what it can execute. Read-only bash +/// operations are classified Auto, so the read-only roles do not gain +/// general shell authority through this branch. fn role_posture_permits(agent_type: &FleetRole, approval: ApprovalRequirement) -> bool { if matches!(agent_type, FleetRole::Custom) { return true; @@ -15714,9 +15716,9 @@ impl SubAgentToolRegistry { let coordination_manager = Arc::clone(&runtime.manager); let mut surface_options = runtime.agent_tool_surface_options.clone(); // Shell authority is an intersection: a parent cannot delegate more - // than it owns, and read-only inspection roles (Scout, Reviewer) are - // narrowed to the hardened read-only classifier even when the parent - // has a full shell. + // than it owns, and read-only roles (Scout, Reviewer, Planner, + // Verifier) are narrowed to the hardened read-only classifier even + // when the parent has a full shell. let parent_shell = ShellPolicy::from_legacy_allow_shell(runtime.allow_shell); let mut child_shell = runtime.worker_profile.shell.min_with(parent_shell); // Capture the grant before the role narrowing below: a Full shell @@ -15789,10 +15791,11 @@ impl SubAgentToolRegistry { /// clears the role-based delegation rules on its own. fn delegation_refusal(&self, name: &str, input: &Value) -> Option { // #5595/#5438: session approval is transport, not a second shell - // classifier. Scout/Reviewer/Planner calls already proven by the exact - // predicate used at posture, envelope, and execute boundaries are - // delegated reads even though lowercase `bash` deliberately keeps the - // stricter parent-parallel classifier as its generic approval baseline. + // classifier. Scout/Reviewer/Planner/Verifier calls already proven by + // the exact predicate used at posture, envelope, and execute + // boundaries are delegated reads even though lowercase `bash` + // deliberately keeps the stricter parent-parallel classifier as its + // generic approval baseline. if self.bounded_readonly_bash_evidence(name, input) { return None; } @@ -17881,7 +17884,7 @@ const CONSULTANT_AGENT_INTRO: &str = concat!( ); const VERIFIER_AGENT_INTRO: &str = concat!( - "You are a trusted Fleet test agent (role: `test`). Your job is to run the requested gates with your bounded validation tools — the allowed test/check selections — and report results. You never write: patching the workspace is denied. Unbounded shell forms are refused; use the verification surface.\n", + "You are a trusted Fleet test agent (role: `test`). Your job is to run the requested gates with your bounded validation tools — the allowed test/check selections — and report results; the gates are yours on envelopes whose parent grants shell authority, while a child clamped by a read-only-shell parent keeps the read-only probes but has its gates refused. You never write: patching the workspace is denied. Unbounded shell forms are refused; use the verification surface.\n", "When the session grants it — for in-session delegations, on envelopes that carry the probe lane — you also hold a bounded read-only shell, under the same classifier-gated grammar as the read-only roles, for gathering evidence with probes such as `wc`, `ls`, `cat`, and `grep` pipelines; envelopes without it omit the shell entirely and shell attempts refuse, and custom commands are refused.\n", "Report PASS/FAIL/FLAKY at the top of SUMMARY with exact command evidence.\n", "Capture failing assertion and file:line; put obvious fixes under RISKS.\n", diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 2596f6451d..2d07e943ee 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -8442,6 +8442,10 @@ fn verifier_roster_description_states_the_bounded_contract() { description.contains("cannot execute custom commands"), "roster description must refuse custom commands: {description}" ); + assert!( + description.contains("unless a read-only-shell parent clamped its shell authority"), + "roster description must condition the gates on shell authority — a clamped child keeps probes and loses gates: {description}" + ); // The Agent-tool `type` description carries the same truth, briefly. assert!( SUBAGENT_TYPE_DESCRIPTION.contains("test: runs the built-in verification gates"), @@ -8506,17 +8510,29 @@ async fn verifier_child_still_refuses_raw_shell_custom_commands_and_writes() { "raw Bash refusal did not identify the wall: {raw}" ); // The second legacy-`Bash` wall is the posture's classifier-lane rule: a - // command the classifier cannot prove read-only must not re-enter - // through the alias, pinned here on this Verifier-labeled registry (the - // sibling pin covers the same wall through a Scout fixture). A - // proven-read-only shape still fails this wall — the posture rule - // refuses the legacy alias name itself (`bash_must_use_classifier_lane` - // is case-insensitive), so the alias never re-enters at any - // provability. + // command the classifier cannot prove read-only stays `Required`, the + // case-insensitive lane-door check refuses the alias name itself, and + // the call is refused at posture — pinned here on this Verifier-labeled + // registry (the sibling pin covers the same wall through a Scout + // fixture). A *proven*-read-only shape clears posture's Auto + // classification for any role, so the alias-at-any-provability refusal + // is pinned below at the wall that actually owns it: the role clamp. assert!( !registry.posture_permits_tool("Bash", Some(&json!({"command": "rm -rf src"}))), "the posture wall must refuse unprovable commands through the legacy `Bash` alias for a Verifier" ); + // The exact-name asymmetry at the dispatch wall: only canonical + // lowercase `bash` reaches the lane, so the alias is refused even for a + // provably read-only shape — a case-relaxed lane check cannot re-admit + // it at any provability. + registry + .execute( + "agent_verifier_walls", + "Bash", + json!({"command": "pwd"}), + ) + .await + .expect_err("the legacy `Bash` alias must stay refused even for a provably read-only command — the lane is exact-name"); let custom_command = json!({ "action": "verifiers", @@ -8609,6 +8625,16 @@ async fn read_only_shell_children_of_other_roles_hold_no_shell_authority() { !names.iter().any(|name| name == "Run"), "{role:?} catalog must not offer the verification gate: {names:?}" ); + // The probe lane is read-only-roles-only: a widened role set must not + // silently hand the classifier-bounded shell to write-capable + // children. The catalog refusal keys on the same + // `allows_bounded_readonly_bash` predicate the production dispatch + // walls (the raw-shell deny rules' role-gated rescue) key on, so a + // widened role set flips both — and this catalog pin reddens first. + assert!( + !names.iter().any(|name| name == "bash"), + "{role:?} catalog must not offer the classifier probe lane: {names:?}" + ); let refused = registry .execute("agent_read_only_child", "Run", gate.clone()) .await @@ -8654,6 +8680,12 @@ fn verifier_child_keeps_the_default_and_selection_bounded_run_surface() { names.contains(&"Run"), "catalog must keep offering the verification gate: {names:?}" ); + // Catalog and dispatch must agree on the carve-out: the probe lane stays + // visible, not merely dispatchable. + assert!( + names.contains(&"bash"), + "catalog must keep offering the probe lane beside the gates: {names:?}" + ); } /// A Verifier child of a read-only role parent (its shell was clamped by the @@ -8701,14 +8733,16 @@ async fn clamped_verifier_child_of_read_only_parent_holds_no_run_surface() { /// The sub-subagent chain: a carve-out Verifier parent (its effective shell /// is the role's read-only narrowing; the Run grant is a registry surface, -/// not shell authority) spawns a Builder or Worker child. The child -/// derivation re-clamps before it captures shell authority, so the chain -/// holds no extra authority: the spawn ceiling the carve-out parent -/// publishes stays read-only, and a child built under that ceiling keeps -/// the same no-Run, classifier-bounded shape this module pins elsewhere. -/// The round-3 re-key bug lived exactly in this ordering. -#[test] -fn sub_subagent_chain_of_a_carveout_verifier_holds_no_extra_authority() { +/// not shell authority) spawning a Builder child. Hop 2 is built from hop +/// 1's actual published profile through the same intersection the +/// production spawn path runs, so the pin is a real two-hop chain: the +/// spawn ceiling the carve-out parent publishes stays read-only, and the +/// child derived under that ceiling keeps the no-Run, lane-less shape. It +/// guards the propagation side of the round-3 fix — if the registry ever +/// leaked a pre-narrowing profile to its spawns, the Builder below would +/// regain the gate surface, and this test would redden. +#[tokio::test] +async fn sub_subagent_chain_of_a_carveout_verifier_holds_no_extra_authority() { // A carve-out Verifier in a full session: the coarse session flag stays // `allow_shell = true` while its effective runtime shell is the // read-only narrowing. The ceiling its spawns publish must read the @@ -8723,29 +8757,82 @@ fn sub_subagent_chain_of_a_carveout_verifier_holds_no_extra_authority() { codewhale_workflow::ShellCeiling::ReadOnly, "the carve-out must not widen the shell ceiling a verifier publishes for its own spawns" ); - // The chain child registry shape (what the derivation above produces for - // the next hop): no built-in gate surface, evidence lane classifier- - // bounded — for the Builder the chain would most plausibly reach for. - let (_tmp, registry) = read_only_shell_child_registry(FleetRole::Builder); + // Hop 2 is derived from hop 1's actual output: the carve-out verifier's + // narrowed effective profile is what the registry publishes to its own + // spawns, so a Builder one hop below the verifier is built through the + // same derivation the production spawn path runs + // (`worker_profile_for_spawn` intersects the parent's effective profile + // with the child's role request). If the registry ever leaked a + // pre-narrowing profile to its spawns, the Builder below would regain + // the gate surface — this pin exists to redden exactly that regression. + let (_verifier_tmp, verifier) = read_only_with_shell_registry(); + assert_eq!( + verifier.runtime_profile.shell, + crate::worker_profile::ShellPolicy::ReadOnly, + "the carve-out fixture must publish the narrowed shell to its spawns" + ); + let derived = verifier + .runtime_profile + .derive_child(&WorkerRuntimeProfile::for_role(FleetRole::Builder)); + assert_eq!( + derived.shell, + crate::worker_profile::ShellPolicy::ReadOnly, + "the chain Builder must derive the read-only shell from the verifier's narrowed profile" + ); + let child_tmp = tempfile::tempdir().expect("tempdir"); + let mut child_runtime = + stub_runtime().with_agent_tool_surface_options(enabled_agent_surface_options()); + child_runtime.context = ToolContext::new(child_tmp.path().to_path_buf()); + child_runtime.allow_shell = true; + child_runtime.worker_profile = derived; + let builder = SubAgentToolRegistry::new( + child_runtime, + FleetRole::Builder, + None, + Arc::new(Mutex::new(TodoList::new())), + Arc::new(Mutex::new(PlanState::default())), + ); + // No gate surface at any wall, and no probe lane: the Builder two hops + // into a shell-granting chain holds exactly the read-only shell its + // ancestors owned. + let gate = json!({"action": "tests", "args": "--lib fleet::exact"}); assert!( - !registry.posture_permits_tool("Run", Some(&json!({"action": "tests"}))), + !builder.posture_permits_tool("Run", Some(&gate)), "a chain Builder must not hold the verification gate" ); assert!( - registry - .envelope_refusal("Run", &json!({"action": "tests"})) - .is_some(), + builder.envelope_refusal("Run", &gate).is_some(), "a chain Builder envelope must refuse the verification gate" ); + let names: Vec = builder + .tools_for_model(&FleetRole::Builder) + .iter() + .map(|tool| tool.name.clone()) + .collect(); assert!( - !registry.posture_permits_tool("bash", Some(&json!({"command": "rm -rf src"}))), - "a chain Builder keeps the evidence lane classifier-bounded" + !names.iter().any(|name| name == "Run"), + "a chain Builder catalog must not offer the verification gate: {names:?}" + ); + assert!( + !names.iter().any(|name| name == "bash"), + "a chain Builder catalog must not offer the classifier probe lane: {names:?}" + ); + let refused = builder + .execute("agent_chain_builder", "Run", gate.clone()) + .await + .expect_err("the test runner must stay refused one hop below the carve-out verifier"); + assert!( + refused.to_string().contains("not permitted") || refused.to_string().contains("shell"), + "chain Builder refusal did not identify the wall: {refused}" ); } /// The verifier intro tells the child about its bounded read-only shell and /// the custom-command refusal, scoped to in-session delegations the way -/// `FleetRole`'s roster description scopes the role. +/// `FleetRole`'s roster description scopes the role. The gates claim is +/// conditional too: a child clamped by a read-only-shell parent keeps the +/// probes and loses the gates — the intro must not promise the gates +/// unconditionally. #[test] fn verifier_intro_states_the_evidence_contract() { assert!( @@ -8760,6 +8847,11 @@ fn verifier_intro_states_the_evidence_contract() { VERIFIER_AGENT_INTRO.contains("in-session delegations"), "the verifier's bounded read-only shell must be scoped to in-session delegations: {VERIFIER_AGENT_INTRO}" ); + assert!( + VERIFIER_AGENT_INTRO + .contains("a child clamped by a read-only-shell parent keeps the read-only probes but has its gates refused"), + "the intro's gates claim must carry the read-only-parent conditional: {VERIFIER_AGENT_INTRO}" + ); } #[test] diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index a7a9fc9dac..49d0963e3d 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -67,11 +67,11 @@ stewardship. | Role | Stance | Writes? | Network? | Shell posture | Typical use | |---------------|----------------------------------------|---------|----------|---------------|----------------------------------------------| | `general` | flexible; do whatever the parent says | yes | yes | yes | the default; multi-step tasks | -| `explore` | read-only; map the relevant code fast | no | yes | read-only (net + bounded verify) | "find every call site of `Foo`; check the PR with gh" | +| `explore` | read-only; map the relevant code fast | no | yes | read-only (net) | "find every call site of `Foo`; check the PR with gh" | | `planner` | analyse and produce a strategy | no | yes | read-only probes | "design the migration; don't execute" | | `reviewer` | read-and-grade with severity scores | no | yes | read-only (net) | "audit this PR for bugs" | | `implement` | land a specific change with min edit | yes | yes | yes | "rewrite `bar.rs::Foo::bar` to do X" | -| `test` | run the built-in verification gates and report outcome | no | yes | built-in gates + read-only probes (no custom commands) | "verify the diff with the built-in checks; report PASS/FAIL" | +| `test` | run the built-in verification gates and report outcome | no | yes | built-in gates unless parent-clamped (then probes only) + read-only probes (no custom commands) | "verify the diff with the built-in checks; report PASS/FAIL" | | `advisor` | short-lived, high-reasoning counsel | no | yes | none | "what are we missing in this design?" | | `custom` | explicit narrow tool allowlist | inherits | inherits | inherits | hand-picked tools on the parent's posture | @@ -257,8 +257,8 @@ OUTPUT: VERDICT, EVIDENCE, GAPS, NEXT ``` `scout` briefs default to quick, read-only investigation (no writes, but -network reach and the bounded verification surface are available for real -scouting). About 3-5 tool calls +network reach and the classifier-gated read-only probe lane are available +for real scouting — no verification gates). About 3-5 tool calls is enough for quick exploration: orient, search, read the decisive lines, and return. Do not repeat `ALREADY_KNOWN` work unless evidence contradicts it. Review and verifier briefs can spend more calls, but should stop after decisive @@ -320,11 +320,16 @@ OUTPUT: VERDICT, EVIDENCE, GAPS, NEXT. on the test suite or other validation. Verifiers don't fix failures; they capture the failing assertion + stack and put fix candidates under RISKS. The verifier posture never writes; its shell - is the bounded built-in verification surface plus — on in-session - delegations whose envelope grants it — the same classifier-gated - read-only probe lane the inspection roles hold (`wc`, `ls`, `cat`, - `grep` pipelines) for gathering evidence; envelopes without the - lane omit it and shell attempts refuse. The + is the bounded built-in verification surface — refused for an + in-session child only when a read-only-shell parent clamps its + authority (the clamped child keeps the probe lane and loses the + gates) — plus, on in-session delegations whose envelope grants it, + the same classifier-gated read-only probe lane the inspection roles + hold (`wc`, `ls`, `cat`, `grep` pipelines) for gathering evidence; + envelopes without the lane omit it and shell attempts refuse. On + Fleet members the envelope is stricter still: bounded verification + and Bash are mutually exclusive there, so a fleet verifier holds the + gates without the probe lane. The write ceiling is read-only, unbounded shell forms are refused (#5186), and custom commands are refused — use a write-capable role for those. diff --git a/docs/zh_hans/SUBAGENTS.md b/docs/zh_hans/SUBAGENTS.md index a9dab671be..83b01da271 100644 --- a/docs/zh_hans/SUBAGENTS.md +++ b/docs/zh_hans/SUBAGENTS.md @@ -27,11 +27,11 @@ Fleet 角色是面向用户的委派工作词汇:父代理通过 `agent` 启 | 角色 | 姿态 | 可写? | 联网? | Shell 姿态 | 典型用途 | |---|---|---|---|---|---| | `worker` | 灵活;父代理说什么就做什么 | 是 | 是 | 是 | 默认角色;多步任务 | -| `scout` | 只读;快速摸清相关代码 | 否 | 是 | 只读(网络 + 有界验证) | "找到 `Foo` 的每个调用点;用 gh 检查这个 PR" | +| `scout` | 只读;快速摸清相关代码 | 否 | 是 | 只读(网络) | "找到 `Foo` 的每个调用点;用 gh 检查这个 PR" | | `planner` | 分析并产出策略 | 否 | 是 | 只读探针 | "设计迁移方案;不要执行" | | `reviewer` | 带严重度评分的阅读与评审 | 否 | 是 | 只读(网络) | "审计这个 PR 的 bug" | | `builder` | 以最小改动落地某个具体变更 | 是 | 是 | 是 | "把 `bar.rs::Foo::bar` 重写为做 X" | -| `verifier` | 运行内置验证门禁并报告结果(只读探针取证) | 否 | 是 | 内置门禁 + 只读探针(无自定义命令) | "用内置检查验证这个 diff,并报告 PASS/FAIL" | +| `verifier` | 运行内置验证门禁并报告结果(只读探针取证) | 否 | 是 | 内置门禁(被父代理钳制时仅剩探针)+ 只读探针(无自定义命令) | "用内置检查验证这个 diff,并报告 PASS/FAIL" | | `consultant` | 短期、高推理密度的咨询 | 否 | 是 | 无 | "这个设计我们漏掉了什么?" | | `custom` | 显式的窄工具 allowlist | 继承 | 继承 | 继承 | 在父代理姿态上精选的工具 | @@ -84,7 +84,7 @@ STOP_CONDITION: OUTPUT: VERDICT, EVIDENCE, GAPS, NEXT ``` -`scout` 简报默认为快速的只读调查(不写,但网络触达和有界验证面可用于真正的侦察)。约 3-5 次工具调用足以完成快速探索:定位、搜索、读取决定性代码行,然后返回。除非证据与之矛盾,否则不要重复 `ALREADY_KNOWN` 的工作。review 和 verifier 简报可以花更多调用,但应在拿到决定性证据后停止。builder 和修复型简报应在扩展示范围之前或反复失败之后设置检查点,而不是设置一个很小的调用上限。 +`scout` 简报默认为快速的只读调查(不写,但网络触达和分类器门控的只读探针通道可用于真正的侦察——不含验证门禁)。约 3-5 次工具调用足以完成快速探索:定位、搜索、读取决定性代码行,然后返回。除非证据与之矛盾,否则不要重复 `ALREADY_KNOWN` 的工作。review 和 verifier 简报可以花更多调用,但应在拿到决定性证据后停止。builder 和修复型简报应在扩展示范围之前或反复失败之后设置检查点,而不是设置一个很小的调用上限。 好的委派提示词示例: @@ -122,7 +122,7 @@ OUTPUT: VERDICT、EVIDENCE、GAPS、NEXT。 - **`planner`** —— 当父代理有目标但没有可执行的分解。planner 写工件(`todo_write` 条目、响应体里的策略),但不执行它们。 - **`reviewer`** —— 当已经有一个变更,父代理想要它被评分。reviewer 不打补丁——他们在发现里描述修复方案,这样如果判定是"修它",父代理可以派一个 builder。 - **`builder`** —— 当变更已经被明确指定、只需要落地。builder 保持严格的范围:最小改动,不做顺手重构,交回前跑一次快速验证。 -- **`verifier`** —— 当父代理需要测试套件或其他验证上的权威通过/失败结论。verifier 不修失败;他们记录失败的断言 + 栈,把修复候选放在 RISKS 下。verifier 姿态从不写入;其 shell 是有界内置验证面,加上——在授予该通道的会话内委派上——与检查角色相同的分类器门控只读探针通道(`wc`、`ls`、`cat`、`grep` 管道)用于取证;未授予该通道的 envelope 会省略它,shell 尝试一律拒绝。写入上限是只读,无界 shell 形式被拒绝(#5186),自定义命令也被拒绝——请改用具备写能力的角色。 +- **`verifier`** —— 当父代理需要测试套件或其他验证上的权威通过/失败结论。verifier 不修失败;他们记录失败的断言 + 栈,把修复候选放在 RISKS 下。verifier 姿态从不写入;其 shell 是有界内置验证面——对会话内子代理,只有当只读 shell 父代理钳制了其权限时才会被拒绝,被钳制的子代理保留探针通道、失去验证门禁——加上,在授予该通道的会话内委派上,与检查角色相同的分类器门控只读探针通道(`wc`、`ls`、`cat`、`grep` 管道)用于取证;未授予该通道的 envelope 会省略它,shell 尝试一律拒绝。Fleet 成员的 envelope 更严格:在那里有界验证与 Bash 权限互斥,因此 fleet verifier 只持有门禁、不带探针通道。写入上限是只读,无界 shell 形式被拒绝(#5186),自定义命令也被拒绝——请改用具备写能力的角色。 - **`consultant`** —— 当操作者想在更便宜的执行继续之前得到一个高杠杆的第二意见。consultant 读足够的材料来支撑一条建议,但不能写,也不能运行 shell 命令。`oracle` 和 `advisor` 只在加载更老的请求或持久化记录时被接受;新的提示词、回执和 UI 使用 `consultant`。 - **`custom`** —— 只有当父代理需要显式约束工具集时。通过 legacy/internal 子代理记录上的 `allowed_tools` 字段传 allowlist;面向模型的 `agent` 工具刻意保持公共 schema 很小。 From 5bbb62eae1552d6b412123f941326a6fe79d555e Mon Sep 17 00:00:00 2001 From: asto18089 Date: Thu, 12 Nov 2026 13:36:55 +0800 Subject: [PATCH 09/11] fix(fleet): round-2 review fixes - publish the narrowed chain end-to-end and finish the copy sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the chain pin now drives hop 2 through the real spawn path: the carve-out verifier's agent tool starts a Builder and the recorded runtime_profile is the profile run_subagent derived from the runtime the registry published, so a publication-line regression (child_runtime.worker_profile reverted to the parent-runtime profile) reddens it; - the agent tool description's test blurb states the real contract (built-in verification gates, custom commands refused) instead of the unconditioned "runs tests" family, with the canonical-vocabulary pin extended; - the diff drops the file-wide blank-line churn in subagent/tests.rs (570 pure blank-line insertions that no formatter requires); - comment honesty: the fleet receipt test names the verifier's real bash-less fleet surface, the executor envelope comment scopes the probe lane to in-session delegations, and worker_profile's scout arm no longer claims the bounded verification surface; - docs: the zh verifier bullet and zh FLEET envelope paragraph use the established 信封 term and carry the envelope-grant qualifier, and the ChildAuthority::clamp path reference points at fleet/role.rs (en+zh). Round-2 fresh 6-lane review found no functional, security, or performance defect and no smuggled scope; these are its actionable findings. Signed-off-by: asto18089 --- crates/tui/src/fleet/executor.rs | 11 +- crates/tui/src/fleet/manager.rs | 15 +- crates/tui/src/tools/subagent/mod.rs | 2 +- crates/tui/src/tools/subagent/tests.rs | 1146 ++---------------------- crates/tui/src/worker_profile.rs | 9 +- docs/SUBAGENTS.md | 2 +- docs/zh_hans/FLEET.md | 2 +- docs/zh_hans/SUBAGENTS.md | 4 +- 8 files changed, 122 insertions(+), 1069 deletions(-) diff --git a/crates/tui/src/fleet/executor.rs b/crates/tui/src/fleet/executor.rs index a82ee4eacc..3871f1a970 100644 --- a/crates/tui/src/fleet/executor.rs +++ b/crates/tui/src/fleet/executor.rs @@ -244,10 +244,13 @@ pub(crate) fn authority_envelope_for_worker( { // Scout, Reviewer, and Planner get one classifier-bounded // foreground shell. On this executor path the Verifier keeps only - // the dedicated Run surface below (the in-process registry - // additionally admits it to the classifier-bounded `bash` evidence - // lane — see `SubAgentToolRegistry::allows_bounded_readonly_bash`), - // consultants remain shell-less, and writers retain the historical + // the dedicated Run surface below. (The classifier-bounded `bash` + // evidence lane named by + // `SubAgentToolRegistry::allows_bounded_readonly_bash` is an + // in-session delegation surface: a fleet member spawned here has no + // in-process subagent registry at all, so it never holds that lane — + // see the fleet mutual-exclusivity note in docs/SUBAGENTS.md.) + // Consultants remain shell-less, and writers retain the historical // subprocess contract. ToolShellAuthority::ReadOnly } else { diff --git a/crates/tui/src/fleet/manager.rs b/crates/tui/src/fleet/manager.rs index 35a3ae2f23..8a2df63982 100644 --- a/crates/tui/src/fleet/manager.rs +++ b/crates/tui/src/fleet/manager.rs @@ -4884,12 +4884,15 @@ esac !permissions.write, "verifier receipt {key} must stay read-only" ); - // The verifier joined the read-only-shell roles: its bash - // is the classifier-bounded probe lane, and the receipt - // records that effective posture (the old `full` label - // overclaimed a raw shell the clamp always denied). The - // built-in verification surface stays keyed on the shell - // ceiling, not on this label. + // The verifier joined the read-only-shell roles, so the + // receipt records the narrowed effective posture (the old + // `full` label overclaimed a raw shell the clamp always + // denied). On the fleet envelope the verifier holds the + // gates with no bash surface at all — the classifier + // probe lane is in-session only; in-session, its bash is + // the classifier-bounded lane. The built-in verification + // surface stays keyed on the shell ceiling, not on this + // label. assert_eq!(permissions.shell, "read_only"); } role => panic!("unexpected receipt role for {key}: {role:?}"), diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 20f899b09d..881bad346f 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -9513,7 +9513,7 @@ impl ToolSpec for AgentTool { concat!( "Start with action=start and prompt; returns a turn-owned agent_id immediately. A prompt-only start (no type/profile) launches read-only; pass a write-capable type (e.g. implement) or declare write_roots before write work. Read-only roles need no extra fields. Set detached=true only for work that must remain independently observable after the turn. ", "Use multiple starts for independent parallel tasks. ", - "type selects the Fleet role: general (full tool access), explore (fast read-only exploration), planner (grounded strategy, read-only probes), reviewer (reads and grades code), implement (lands focused code changes), test (runs tests and reports evidence), advisor (read-only design counsel), or custom (allowed_tools on the parent's posture); legacy aliases are still accepted. ", + "type selects the Fleet role: general (full tool access), explore (fast read-only exploration), planner (grounded strategy, read-only probes), reviewer (reads and grades code), implement (lands focused code changes), test (built-in verification gates; custom commands refused), advisor (read-only design counsel), or custom (allowed_tools on the parent's posture); legacy aliases are still accepted. ", "profile runs the child as a named Fleet role or an exact prompt-only profile explicitly presented by the embedding host — pass it only when the task needs that identity. The child follows the operator's per-role model configuration, else the parent's model; model or thinking overrides are not advertised (parse-accepted for compatibility). ", "Use action=roster to inspect Fleet roles and host-presented prompt-only profiles before choosing a type or profile. The host-profile listing is capped and unpaged — search a large pool with profile_query. ", "Child run budgets come from Fleet role defaults and operator [subagents] config; budget fields are not advertised (parse-accepted for compatibility). ", diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 2d07e943ee..3120be654f 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -12,7 +12,6 @@ mod identity_selection; mod launch_receipt; mod operation_identity; - fn built_in_whale_name_that_cannot_be_generated_for(agent_id: &str) -> &'static str { WHALE_NICKNAMES .iter() @@ -33,7 +32,6 @@ fn built_in_whale_name_that_cannot_be_generated_for(agent_id: &str) -> &'static .find(|name| generated_whale_name_base(agent_id, name).is_none()) .expect("the combined pools contain labels not generated for one id") } - #[test] fn generated_whale_names_follow_session_language_without_mixing() { let localized_pools: &[(&str, &[&str])] = &[ @@ -83,7 +81,6 @@ fn generated_whale_names_follow_session_language_without_mixing() { whale_name_for_id_in_locale("fallback", "en") ); } - #[test] fn locale_matched_whale_collision_suffix_stays_in_language() { let id = "agent_locale_collision"; @@ -95,7 +92,6 @@ fn locale_matched_whale_collision_suffix_stays_in_language() { assert!(unique.starts_with(&base)); assert!(!unique.is_ascii()); } - #[test] fn localized_whale_displays_rederive_legacy_names_from_neutral_ids() { let generated_a = whale_name_for_id_in_locale("agent_english_a", "zh-Hans"); @@ -138,7 +134,6 @@ fn localized_whale_displays_rederive_legacy_names_from_neutral_ids() { "a built-in whale word belonging to another id remains user-owned" ); } - #[test] fn exact_deterministic_whale_match_remains_generated_without_provenance() { let agent_id = "agent_ambiguous_whale"; @@ -155,11 +150,9 @@ fn exact_deterministic_whale_match_remains_generated_without_provenance() { "a collision suffix remains presentation-only" ); } - fn make_assignment() -> SubAgentAssignment { SubAgentAssignment::new("prompt".to_string(), Some("worker".to_string())) } - fn make_snapshot(status: SubAgentStatus) -> SubAgentResult { SubAgentResult { name: "agent_test".to_string(), @@ -187,7 +180,6 @@ fn make_snapshot(status: SubAgentStatus) -> SubAgentResult { from_prior_session: false, } } - #[test] fn forkguard_interrupted_snapshot_preserves_checkpoint_recovery() { let _spill = crate::tools::truncate::TEST_SPILLOVER_GUARD @@ -257,7 +249,6 @@ fn forkguard_interrupted_snapshot_preserves_checkpoint_recovery() { } }); } - fn make_worker_spec(worker_id: &str, workspace: PathBuf) -> AgentWorkerSpec { let tool_profile = AgentWorkerToolProfile::Explicit(vec!["read_file".to_string(), "grep_files".to_string()]); @@ -288,7 +279,6 @@ fn make_worker_spec(worker_id: &str, workspace: PathBuf) -> AgentWorkerSpec { launch_manifest: None, } } - fn make_write_worker_spec(worker_id: &str, workspace: PathBuf, root: &str) -> AgentWorkerSpec { let mut spec = make_worker_spec(worker_id, workspace.clone()); spec.agent_type = FleetRole::Builder; @@ -313,7 +303,6 @@ fn make_write_worker_spec(worker_id: &str, workspace: PathBuf, root: &str) -> Ag }); spec } - #[test] fn child_approval_ids_stay_unique_within_and_across_manager_boots() { // #5615: the per-manager sequence restarts at zero, so a resumed agent @@ -354,7 +343,6 @@ fn child_approval_ids_stay_unique_within_and_across_manager_boots() { ); } } - #[test] fn active_worker_records_are_never_pruned_by_history_retention() { let tmp = tempdir().expect("tempdir"); @@ -378,7 +366,6 @@ fn active_worker_records_are_never_pruned_by_history_retention() { .all(|record| !record.status.is_terminal()) ); } - #[test] fn headless_worker_record_tracks_lifecycle_without_tui_projection() { let tmp = tempdir().expect("tempdir"); @@ -483,7 +470,6 @@ fn headless_worker_record_tracks_lifecycle_without_tui_projection() { .any(|event| event.tool_name.as_deref() == Some("read_file")) ); } - #[test] fn worker_record_usage_accumulates_provider_tokens() { let tmp = tempdir().expect("tempdir"); @@ -546,7 +532,6 @@ fn worker_record_usage_accumulates_provider_tokens() { record.usage.note ); } - #[test] fn token_budget_scope_is_shared_across_nested_workers_and_blocks_when_spent() { let tmp = tempdir().expect("tempdir"); @@ -617,7 +602,6 @@ fn token_budget_scope_is_shared_across_nested_workers_and_blocks_when_spent() { assert_eq!(override_scope.limit, 20); assert_eq!(override_scope.spent, 0); } - #[test] fn agent_worker_profile_derives_from_parent_without_escalation() { let mut runtime = stub_runtime(); @@ -658,7 +642,6 @@ fn agent_worker_profile_derives_from_parent_without_escalation() { ToolScope::Explicit(vec!["read_file".to_string(), "write_file".to_string()]) ); } - #[test] fn declared_read_only_write_roles_derive_without_mutating_shell() { for input in [ @@ -686,7 +669,6 @@ fn declared_read_only_write_roles_derive_without_mutating_shell() { assert_eq!(profile.shell, ShellPolicy::None, "{request:?}"); } } - #[test] fn custom_runtime_inherits_the_parent_posture_and_explicit_authority_is_a_no_op_superset() { let runtime = stub_runtime().background_runtime(); @@ -734,7 +716,6 @@ fn custom_runtime_inherits_the_parent_posture_and_explicit_authority_is_a_no_op_ "scout parents carry the read-only inspection posture" ); } - #[test] fn subagent_progress_displays_legacy_shell_names_as_lowercase_bash() { assert_eq!(subagent_progress_tool_display_name("exec_shell"), "bash"); @@ -752,7 +733,6 @@ fn subagent_progress_displays_legacy_shell_names_as_lowercase_bash() { "read_file" ); } - #[test] fn agent_progress_preserves_event_channel_headroom_under_load() { let (tx, mut rx) = mpsc::channel(40); @@ -803,7 +783,6 @@ fn agent_progress_preserves_event_channel_headroom_under_load() { )); assert!(rx.try_recv().is_err()); } - #[test] fn agent_progress_uses_small_event_channels_without_headroom_reservation() { let (tx, mut rx) = mpsc::channel(8); @@ -828,7 +807,6 @@ fn agent_progress_uses_small_event_channels_without_headroom_reservation() { && activity.step == Some(1) )); } - #[test] fn headless_worker_records_persist_with_subagent_state() { let tmp = tempdir().expect("tempdir"); @@ -868,7 +846,6 @@ fn headless_worker_records_persist_with_subagent_state() { .any(|event| event.status == AgentWorkerStatus::Failed) ); } - #[test] fn persisted_subagent_state_has_bounded_serialized_size() { // #3885 / item 4: `subagents.v1.json` is transitively bounded but had no @@ -916,7 +893,6 @@ fn persisted_subagent_state_has_bounded_serialized_size() { "all registered workers should be persisted" ); } - #[test] fn coordination_ledger_persists_and_replays_bounded_contracts() { let tmp = tempdir().expect("tempdir"); @@ -968,7 +944,6 @@ fn coordination_ledger_persists_and_replays_bounded_contracts() { assert_eq!(inspect["decisions"][0]["decision_id"], "decision_storage"); assert_eq!(inspect["write_claims"][0]["claim"]["owner"], "agent_writer"); } - #[test] fn invalid_decision_and_reconciliation_inputs_cannot_poison_persisted_replay() { let tmp = tempdir().expect("tempdir"); @@ -1060,7 +1035,6 @@ fn invalid_decision_and_reconciliation_inputs_cannot_poison_persisted_replay() { assert_eq!(replayed.coordination.decisions.len(), 2); assert!(replayed.coordination.reconciliations.is_empty()); } - #[test] fn coordination_hot_paths_count_only_active_authoritative_owners() { let tmp = tempdir().expect("tempdir"); @@ -1099,7 +1073,6 @@ fn coordination_hot_paths_count_only_active_authoritative_owners() { }] ); } - #[test] fn headless_worker_registration_enforces_live_claims_and_projects_context() { let tmp = tempdir().expect("tempdir"); @@ -1180,7 +1153,6 @@ fn headless_worker_registration_enforces_live_claims_and_projects_context() { .expect("non-overlapping workers may proceed concurrently"); assert_eq!(manager.coordination.write_claims.len(), 2); } - #[test] fn session_close_finalizes_live_fleet_and_releases_write_claims() { let tmp = tempdir().expect("tempdir"); @@ -1254,7 +1226,6 @@ fn session_close_finalizes_live_fleet_and_releases_write_claims() { )) .expect("claim stays released after a second close pass"); } - #[test] fn terminal_result_releases_owner_from_write_claim_contention() { let tmp = tempdir().expect("tempdir"); @@ -1314,7 +1285,6 @@ fn terminal_result_releases_owner_from_write_claim_contention() { )) .expect("overlapping claim admitted after terminal result"); } - #[test] fn prior_session_owners_never_count_as_active_coordination_owners() { let tmp = tempdir().expect("tempdir"); @@ -1357,7 +1327,6 @@ fn prior_session_owners_never_count_as_active_coordination_owners() { )) .expect("prior-session owner must not block a new writer"); } - #[test] fn isolated_worktree_workers_skip_the_coordination_process_lock() { let tmp = tempdir().expect("tempdir"); @@ -1402,7 +1371,6 @@ fn isolated_worktree_workers_skip_the_coordination_process_lock() { .preflight_worker_coordination(&shared) .expect("shared-workspace writer proceeds once the lock is held"); } - #[test] fn coordination_detail_projection_reports_process_lock_ownership() { let tmp = tempdir().expect("tempdir"); @@ -1431,7 +1399,6 @@ fn coordination_detail_projection_reports_process_lock_ownership() { "shared projection carries no note" ); } - /// A second Codewhale session in the same workspace is ordinary usage. Losing /// the coordination flock means "I cannot append to the shared ledger" — it /// must never mean "my agents are dead". Bookkeeping failure and liveness are @@ -1486,7 +1453,6 @@ fn a_second_session_without_the_lock_keeps_its_running_agents_live() { snap.status ); } - /// A second session shares the workspace coordination read-lock and must load /// the existing ledger before it persists any of its own state. That preserves /// prior decisions instead of replacing them with an empty ledger. @@ -1556,7 +1522,6 @@ fn a_second_session_with_the_shared_lock_loads_the_workspace_ledger() { "the second session must not overwrite the workspace ledger with an empty one" ); } - /// The complement of the test above: an orphan still terminalizes, but only on /// heartbeat evidence. Lock ownership is not part of the decision either way. #[test] @@ -1625,7 +1590,6 @@ fn cleanup_terminalizes_orphans_on_heartbeat_evidence_not_on_lock_ownership() { "a heartbeat-stale orphan still becomes Interrupted" ); } - #[test] fn neutral_reconciliation_requires_the_nearest_common_planner() { let tmp = tempdir().expect("tempdir"); @@ -1710,7 +1674,6 @@ fn neutral_reconciliation_requires_the_nearest_common_planner() { assert_eq!(receipt.retry_limit, 3); assert_eq!(receipt.candidate_handles.len(), 2); } - #[test] fn coordination_acceptance_preserves_scopes_candidates_and_replay() { let repo = init_subagent_git_repo(); @@ -2005,7 +1968,6 @@ fn coordination_acceptance_preserves_scopes_candidates_and_replay() { "pub const B: u8 = 2;" ); } - fn init_subagent_git_repo() -> tempfile::TempDir { let dir = tempdir().expect("tempdir"); @@ -2055,7 +2017,6 @@ fn init_subagent_git_repo() -> tempfile::TempDir { dir } - fn git(repo: &Path, args: &[&str]) { let output = Command::new("git") .args(args) @@ -2068,7 +2029,6 @@ fn git(repo: &Path, args: &[&str]) { String::from_utf8_lossy(&output.stderr) ); } - fn git_stdout(repo: &Path, args: &[&str]) -> String { let output = Command::new("git") .args(args) @@ -2082,7 +2042,6 @@ fn git_stdout(repo: &Path, args: &[&str]) -> String { ); String::from_utf8_lossy(&output.stdout).trim().to_string() } - fn text_message(role: &str, text: &str) -> Message { Message { role: Role::from(role), @@ -2092,18 +2051,15 @@ fn text_message(role: &str, text: &str) -> Message { }], } } - fn make_checkpoint(agent_id: &str, steps_taken: u32, messages: Vec) -> SubAgentCheckpoint { build_subagent_checkpoint(agent_id, "test_checkpoint", &messages, steps_taken, true) } - fn message_text(message: &Message) -> &str { match message.content.first() { Some(ContentBlock::Text { text, .. }) => text.as_str(), other => panic!("expected text content block, got {other:?}"), } } - async fn delayed_chat_client( first_delay: Duration, response_text: &str, @@ -2171,7 +2127,6 @@ async fn delayed_chat_client( let client = DeepSeekClient::new(&config).expect("fake chat client"); (client, calls, bodies) } - #[tokio::test] async fn detached_interactive_usage_after_mailbox_seal_reaches_session_accounting() { let _cost_scope = crate::cost_status::test_scope(); @@ -2323,7 +2278,6 @@ async fn detached_interactive_usage_after_mailbox_seal_reaches_session_accountin ); } } - /// Like [`delayed_chat_client`] but delays *every* attempt, so the per-step /// API timeout fires on the first call and on every retry — the shape needed /// to drive the timeout-retry budget to exhaustion. @@ -2381,7 +2335,6 @@ async fn always_delayed_chat_client( let client = DeepSeekClient::new(&config).expect("fake always-slow chat client"); (client, calls) } - #[tokio::test] async fn tool_free_subagent_omits_chat_tools_and_tool_choice() { let tmp = tempdir().expect("tempdir"); @@ -2423,7 +2376,6 @@ async fn tool_free_subagent_omits_chat_tools_and_tool_choice() { "tool_choice must be omitted: {body}" ); } - async fn transient_header_timeout_then_success_chat_client( response_text: &str, ) -> (DeepSeekClient, Arc) { @@ -2488,7 +2440,6 @@ async fn transient_header_timeout_then_success_chat_client( let client = DeepSeekClient::new(&config).expect("fake transient chat client"); (client, calls) } - async fn always_rate_limited_chat_client() -> (DeepSeekClient, Arc) { let calls = Arc::new(AtomicUsize::new(0)); let app = Router::new().route( @@ -2537,7 +2488,6 @@ async fn always_rate_limited_chat_client() -> (DeepSeekClient, Arc) let client = DeepSeekClient::new(&config).expect("fake rate-limited chat client"); (client, calls) } - async fn always_invalid_request_chat_client() -> (DeepSeekClient, Arc) { let calls = Arc::new(AtomicUsize::new(0)); let app = Router::new().route( @@ -2587,11 +2537,9 @@ async fn always_invalid_request_chat_client() -> (DeepSeekClient, Arc usize { text.chars().count().div_ceil(3) } - #[test] fn test_agent_type_from_str() { assert_eq!(FleetRole::from_str("general"), Some(FleetRole::Worker)); @@ -2607,7 +2555,6 @@ fn test_agent_type_from_str() { assert_eq!(FleetRole::from_str("awaiter"), Some(FleetRole::Planner)); assert_eq!(FleetRole::from_str("invalid"), None); } - #[test] fn test_agent_type_implementer_aliases() { // #404 — Builder accepts the obvious legacy aliases the model is @@ -2622,7 +2569,6 @@ fn test_agent_type_implementer_aliases() { // Case-insensitive. assert_eq!(FleetRole::from_str("IMPLEMENTER"), Some(FleetRole::Builder)); } - #[test] fn test_agent_type_verifier_aliases() { // #404 — Verifier accepts test/validate aliases distinct from @@ -2636,7 +2582,6 @@ fn test_agent_type_verifier_aliases() { } assert_eq!(FleetRole::from_str("VERIFY"), Some(FleetRole::Verifier)); } - #[test] fn test_agent_type_round_trips_via_as_str() { // Every type should serialize to a string that round-trips back @@ -2658,7 +2603,6 @@ fn test_agent_type_round_trips_via_as_str() { assert_eq!(back, t, "round-trip failed for {t:?} via {label:?}"); } } - #[test] fn fleet_role_labels_are_canonical_while_legacy_snapshot_wire_stays_readable() { assert_eq!(FleetRole::Scout.as_str(), "explore"); @@ -2689,7 +2633,6 @@ fn fleet_role_labels_are_canonical_while_legacy_snapshot_wire_stays_readable() { "\"explore\"" ); } - #[test] fn fleet_role_deserialize_rejects_unknown_values_with_canonical_hint() { // Unknown role tokens fail closed at the serde boundary, and the error @@ -2725,7 +2668,6 @@ fn fleet_role_deserialize_rejects_unknown_values_with_canonical_hint() { "error must not advertise legacy aliases: {message}" ); } - #[test] fn write_capable_children_get_verify_contract_in_system_prompt() { let implementer = build_subagent_system_prompt( @@ -2751,7 +2693,6 @@ fn write_capable_children_get_verify_contract_in_system_prompt() { "read-only scout must not get write verify contract" ); } - #[test] fn test_implementer_and_verifier_have_distinct_prompts() { // The whole point of adding the types is that they carry distinct @@ -2787,7 +2728,6 @@ fn test_implementer_and_verifier_have_distinct_prompts() { "Verifier prompt should reference its role: {verifier}" ); } - #[test] fn test_agent_type_prompts_include_shared_output_contract_once() { for (agent_type, marker) in [ @@ -2829,7 +2769,6 @@ fn test_agent_type_prompts_include_shared_output_contract_once() { } } } - #[test] fn explore_prompt_orients_before_searching() { let prompt = FleetRole::Scout.system_prompt(); @@ -2838,7 +2777,6 @@ fn explore_prompt_orients_before_searching() { assert!(prompt.contains("workspace/project root")); assert!(prompt.contains("compressed evidence")); } - #[test] fn explore_prompt_is_quick_bounded_and_read_only() { let prompt = FleetRole::Scout.system_prompt(); @@ -2858,7 +2796,6 @@ fn explore_prompt_is_quick_bounded_and_read_only() { assert!(reviewer.contains("allowed read-only navigation/rg")); assert!(reviewer.contains("shell control actions are unavailable")); } - #[test] fn implementer_prompt_is_not_forced_into_explorer_cap() { let prompt = FleetRole::Builder.system_prompt(); @@ -2866,7 +2803,6 @@ fn implementer_prompt_is_not_forced_into_explorer_cap() { assert!(prompt.contains("Checkpoint before expanding scope")); assert!(!prompt.contains("Default to `EFFORT: quick`")); } - #[test] fn role_prompts_use_lowercase_primitive_contract() { let explore = FleetRole::Scout.system_prompt(); @@ -2887,7 +2823,6 @@ fn role_prompts_use_lowercase_primitive_contract() { } } } - #[test] fn review_and_verifier_prompts_stop_after_decisive_evidence() { let review = FleetRole::Reviewer.system_prompt(); @@ -2896,7 +2831,6 @@ fn review_and_verifier_prompts_stop_after_decisive_evidence() { assert!(review.contains("private `todo_write` list as editable working notes")); assert!(verifier.contains("stop after decisive pass/fail evidence")); } - #[test] fn child_artifact_copy_surfaces_working_notes_in_the_complete_transcript() { let artifacts = default_subagent_artifacts("agent_scout_notes"); @@ -2908,7 +2842,6 @@ fn child_artifact_copy_surfaces_working_notes_in_the_complete_transcript() { assert!(transcript.description.contains("todo_write working notes")); assert!(transcript.description.contains("transcript_handle")); } - #[test] fn agent_description_explains_background_child_and_transcript_handle() { let tmp = tempdir().expect("tempdir"); @@ -2931,7 +2864,7 @@ fn agent_description_explains_background_child_and_transcript_handle() { "planner (grounded strategy", "reviewer (reads and grades code)", "implement (lands focused code changes)", - "test (runs tests and reports evidence)", + "test (built-in verification gates; custom commands refused)", "advisor (read-only design counsel)", "custom (allowed_tools", "legacy aliases are still accepted", @@ -2978,7 +2911,6 @@ fn agent_description_explains_background_child_and_transcript_handle() { description.chars().count() ); } - #[test] fn stringy_deliberate_is_refused_not_dropped() { // A stringy "true" used to coerce to the default `false`, which skipped @@ -2995,7 +2927,6 @@ fn stringy_deliberate_is_refused_not_dropped() { "error must name expected and received types: {err}" ); } - #[test] fn bare_string_disallowed_tools_is_refused_not_dropped() { // A restriction the harness silently drops is worse than one never @@ -3023,7 +2954,6 @@ fn bare_string_disallowed_tools_is_refused_not_dropped() { "trimming and de-duplication survive the strict parse" ); } - #[test] fn spawn_parameters_refuse_type_mismatches_by_name() { // A representative sample across the parameter kinds the spawn parser @@ -3070,7 +3000,6 @@ fn spawn_parameters_refuse_type_mismatches_by_name() { })) .expect("explicit nulls read as absent"); } - #[test] fn deliberate_spawn_requires_delegation_fields() { let missing = parse_spawn_request(&json!({ @@ -3102,7 +3031,6 @@ fn deliberate_spawn_requires_delegation_fields() { "workspace_policy shared must not materialize a worktree" ); } - #[test] fn declared_workspace_policy_worktree_materializes_a_worktree_request() { // TUI-DOG-017: a declared policy must be enforced, not decorative. The @@ -3127,7 +3055,6 @@ fn declared_workspace_policy_worktree_materializes_a_worktree_request() { "shared policy plus explicit worktree must fail closed" ); } - #[test] fn builder_plus_read_only_authority_fails_closed() { // #5123: never launch a labeled builder that will only get read-only inspection tools. @@ -3146,7 +3073,6 @@ fn builder_plus_read_only_authority_fails_closed() { ); } } - #[test] fn read_only_worker_is_an_ordinary_general_child() { // Worker is the unnamed default (it renders as "general"); its capability @@ -3162,7 +3088,6 @@ fn read_only_worker_is_an_ordinary_general_child() { assert_eq!(request.agent_type, FleetRole::Worker); assert_eq!(request.write_authority, Some(SpawnWriteAuthority::ReadOnly)); } - #[test] fn roster_role_plus_read_only_authority_still_spawns() { // #5123 fail-closed must not swallow the read-only roster task. `role` is @@ -3195,7 +3120,6 @@ fn roster_role_plus_read_only_authority_still_spawns() { "a role alias resolves the type without claiming write capability" ); } - #[test] fn declared_write_authority_parses_and_worktree_write_requires_isolation() { let read_only = parse_spawn_request(&json!({ @@ -3260,7 +3184,6 @@ fn declared_write_authority_parses_and_worktree_write_requires_isolation() { .expect("explicit bounded custom write parses"); assert!(spawn_request_is_write_capable(&custom_writer)); } - #[test] fn prompt_only_general_children_default_read_only_instead_of_claiming_the_repo() { let request = parse_spawn_request(&json!({ @@ -3303,7 +3226,6 @@ fn prompt_only_general_children_default_read_only_instead_of_claiming_the_repo() .expect("resolved write-capable fleet role defaults write scope to parent workspace"); assert_eq!(fleet_role.write_roots, vec![".".to_string()]); } - #[test] fn read_only_roles_reject_write_authority_but_implementers_can_be_narrowed() { let reviewer = parse_spawn_request(&json!({ @@ -3332,7 +3254,6 @@ fn read_only_roles_reject_write_authority_but_implementers_can_be_narrowed() { Some(SpawnWriteAuthority::ReadOnly) ); } - /// #4752: Consultant must be a role, not a special-cased code path — so it has to /// travel the same spawn/schema machinery as every other role, and be refused /// write authority by the same guard that refuses reviewer. @@ -3355,7 +3276,6 @@ fn consultant_spawns_as_a_first_class_read_only_role() { .to_string(); assert!(escalation.contains("read-only role"), "{escalation}"); } - #[test] fn direct_consultant_aliases_apply_role_reasoning_default_after_inheritance() { for parent_effort in [None, Some("low")] { @@ -3441,7 +3361,6 @@ fn direct_consultant_aliases_apply_role_reasoning_default_after_inheritance() { "first-party DeepSeek keeps an explicit low child effort" ); } - /// The role name has to survive the wire, or receipts and resumed sessions /// silently reclassify a consultant as the default worker. #[test] @@ -3474,7 +3393,6 @@ fn consultant_round_trips_canonically_and_accepts_compatibility_aliases() { assert!(!FLEET_ROLE_SCHEMA_VALUES.contains(&"oracle")); assert!(FLEET_ROLE_SCHEMA_VALUES.contains(&"advisor")); } - #[test] fn declared_write_scope_is_normalized_and_rejects_traversal() { let request = parse_spawn_request(&json!({ @@ -3504,148 +3422,6 @@ fn declared_write_scope_is_normalized_and_rejects_traversal() { "{err}" ); } -/// A scoped child that spells its target as an absolute path inside its own -/// workspace is writing the same file the relative spelling names (live -/// session, 2026-10-24: `write` of `/fixture/calc/inside.txt` -/// was refused as "must be repo-relative" while `fixture/calc/inside.txt` -/// succeeded). Absolute paths outside the workspace and `..` traversal stay -/// refused. -#[test] -fn scoped_mutation_paths_accept_absolute_targets_inside_the_workspace() { - let tmp = tempdir().expect("tempdir"); - let workspace = tmp.path().join("workspace"); - std::fs::create_dir_all(workspace.join("fixture/calc")).expect("workspace tree"); - let canonical = workspace.canonicalize().expect("canonical workspace"); - - // Existing target, canonical spelling. - std::fs::write(workspace.join("fixture/calc/existing.txt"), "x").expect("seed"); - let paths = mutation_paths( - "write", - &json!({ "path": canonical.join("fixture/calc/existing.txt").to_string_lossy() }), - &workspace, - ) - .expect("absolute in-workspace path is admitted"); - assert_eq!(paths, vec!["fixture/calc/existing.txt".to_string()]); - - // Not-yet-existing target (the write creates it), spelled through the - // un-canonicalized workspace path. - let paths = mutation_paths( - "write", - &json!({ "path": workspace.join("fixture/calc/new.txt").to_string_lossy() }), - &workspace, - ) - .expect("absolute path to a file the write will create is admitted"); - assert_eq!(paths, vec!["fixture/calc/new.txt".to_string()]); - - // Relative spelling is unchanged. - let paths = mutation_paths( - "write", - &json!({ "path": "fixture/calc/new.txt" }), - &workspace, - ) - .expect("relative path"); - assert_eq!(paths, vec!["fixture/calc/new.txt".to_string()]); - - // Outside the workspace: refused as before. - let err = mutation_paths( - "write", - &json!({ "path": tmp.path().join("escape.txt").to_string_lossy() }), - &workspace, - ) - .expect_err("absolute path outside the workspace is refused") - .to_string(); - assert!(err.contains("repo-relative"), "{err}"); - - // Traversal: refused as before, relative or absolute. - for path in ["../escape.txt", "fixture/../../escape.txt"] { - let err = mutation_paths("write", &json!({ "path": path }), &workspace) - .expect_err("traversal is refused") - .to_string(); - assert!(err.contains("traversal"), "{path}: {err}"); - } - let err = mutation_paths( - "write", - &json!({ "path": workspace.join("fixture/../../escape.txt").to_string_lossy() }), - &workspace, - ) - .expect_err("absolute traversal out of the workspace is refused") - .to_string(); - assert!(err.contains("repo-relative"), "{err}"); - - // Whitespace-padded spellings are judged EXACTLY as the write tool - // resolves them — the raw string, never trimmed. A padded absolute is - // NOT absolute to the tool (it joins onto the workspace as - // `/␣␣//…`), so the claim lane validates that literal - // space-led spelling, never the trimmed absolute target the tool does - // not touch. (The pre-round-4 trim made this spelling validate as - // `fixture/calc/padded.txt` while the tool wrote the space-led path.) - let padded_relative_target = "fixture/calc/padded.txt"; - let padded_absolute = format!( - " {}", - canonical.join(padded_relative_target).to_string_lossy() - ); - let paths = mutation_paths("write", &json!({ "path": padded_absolute }), &workspace) - .expect("a padded spelling still normalizes"); - // The expected is the ledger's canonical frame of the raw spelling, built - // here independently of the code under test: backslashes fold to forward - // slashes and leading separators collapse into the single one the - // component join emits (a Windows verbatim `\\?\` prefix reduces to its - // literal `?` component), while the space-led first component survives - // verbatim — trimming would instead validate the unpadded absolute target - // the tool never touches. - let ledger_frame = canonical - .join(padded_relative_target) - .to_string_lossy() - .replace('\\', "/"); - assert_eq!( - paths, - vec![format!(" /{}", ledger_frame.trim_start_matches('/'))], - "the claim lane judges the raw spelling in the forward-slash ledger frame: \ - the padded path is not absolute, so it validates as the literal \ - space-led components the tool would join onto the workspace" - ); - - // A padded relative spelling keeps its literal form for the same - // reason: trimming here would validate one path and write another. - let paths = mutation_paths( - "write", - &json!({ "path": "fixture/calc/ tail.txt " }), - &workspace, - ) - .expect("a padded relative spelling still normalizes"); - assert_eq!(paths, vec!["fixture/calc/ tail.txt ".to_string()]); - - // Whitespace-only spellings refuse outright instead of normalizing to - // "." — the whole-workspace scope no write input ever meant. - for path in [" ", "\t"] { - let err = mutation_paths("write", &json!({ "path": path }), &workspace) - .expect_err("a whitespace-only path is refused") - .to_string(); - assert!( - err.contains("non-empty repo-relative path"), - "{path}: {err}" - ); - } -} - -/// The ledger frame's separator contract, pinned cross-platform: a -/// Windows-spelled relative path folds to forward slashes and a padded -/// spelling keeps its space-led first component. This is the contract the -/// padded-path assertion above relies on for native-Windows equality, where -/// a verbatim canonical path would otherwise never compare equal to the -/// raw backslash spelling. -#[test] -fn normalize_claim_path_folds_backslashes_into_the_ledger_frame() { - assert_eq!( - normalize_claim_path(r"fixture\calc\tail.txt").expect("windows-spelled relative path"), - "fixture/calc/tail.txt" - ); - assert_eq!( - normalize_claim_path(" fixture\\calc\\tail.txt").expect("padded windows spelling"), - " fixture/calc/tail.txt" - ); -} - #[test] fn shared_child_cwd_claims_use_one_root_namespace_for_collisions_and_mutations() { let repo = tempdir().expect("repo"); @@ -3728,7 +3504,6 @@ fn shared_child_cwd_claims_use_one_root_namespace_for_collisions_and_mutations() "{outside}" ); } - #[test] fn resident_file_context_is_workspace_relative_bounded_and_exclusive() { let tmp = tempdir().expect("tempdir"); @@ -3797,7 +3572,6 @@ fn resident_file_context_is_workspace_relative_bounded_and_exclusive() { rollback_pending_resident_lease(&resident.lease_key); rollback_pending_resident_lease(&other.lease_key); } - #[cfg(unix)] #[test] fn resident_file_context_rejects_symlink_aliases() { @@ -3811,13 +3585,11 @@ fn resident_file_context_rejects_symlink_aliases() { .to_string(); assert!(error.contains("must not traverse symlinks"), "{error}"); } - #[test] fn new_session_tools_use_single_agent_name() { let manager = Arc::new(RwLock::new(SubAgentManager::new(PathBuf::from("."), 1))); assert_eq!(AgentTool::new(manager, stub_runtime()).name(), "agent"); } - #[test] fn test_parse_spawn_request_accepts_message_and_agent_type_aliases() { let input = json!({ @@ -3829,7 +3601,6 @@ fn test_parse_spawn_request_accepts_message_and_agent_type_aliases() { assert_eq!(parsed.agent_type, FleetRole::Scout); assert_eq!(parsed.assignment.role.as_deref(), Some("explore")); } - #[test] fn test_parse_spawn_request_accepts_objective_and_role_alias() { let input = json!({ @@ -3841,7 +3612,6 @@ fn test_parse_spawn_request_accepts_objective_and_role_alias() { assert_eq!(parsed.agent_type, FleetRole::Planner); assert_eq!(parsed.assignment.role.as_deref(), Some("planner")); } - #[test] fn test_parse_spawn_request_accepts_items_payload() { let input = json!({ @@ -3856,7 +3626,6 @@ fn test_parse_spawn_request_accepts_items_payload() { assert!(parsed.prompt.contains("[mention:$drive](app://drive)")); assert_eq!(parsed.agent_type, FleetRole::Scout); } - #[test] fn test_parse_spawn_request_accepts_fork_context() { let input = json!({ @@ -3878,7 +3647,6 @@ fn test_parse_spawn_request_accepts_fork_context() { let parsed = parse_spawn_request(&input).expect("spawn request should parse"); assert_eq!(parsed.fork_context, None); } - #[test] fn test_parse_spawn_request_accepts_model_strength() { let input = json!({ @@ -3897,7 +3665,6 @@ fn test_parse_spawn_request_accepts_model_strength() { let parsed = parse_spawn_request(&input).expect("spawn request should parse"); assert_eq!(parsed.model_strength, SubAgentModelStrength::Same); } - #[test] fn explore_subagent_inherits_active_model_by_default() { // Role names never silently change the model. A Fleet without custom @@ -3931,7 +3698,6 @@ fn explore_subagent_inherits_active_model_by_default() { assert_eq!(parsed.agent_type, FleetRole::Scout); assert_eq!(parsed.model_strength, SubAgentModelStrength::Same); } - #[test] fn non_explore_subagents_keep_default_same_model_strength() { // Non-explore roles keep the conservative Same default even with no model. @@ -3951,7 +3717,6 @@ fn non_explore_subagents_keep_default_same_model_strength() { ); } } - #[test] fn test_parse_spawn_request_accepts_child_thinking() { let input = json!({ @@ -3981,7 +3746,6 @@ fn test_parse_spawn_request_accepts_child_thinking() { let parsed = parse_spawn_request(&input).expect("spawn request should parse"); assert_eq!(parsed.thinking, SubAgentThinking::Auto); } - #[test] fn test_parse_spawn_request_rejects_invalid_model_strength() { let input = json!({ @@ -3994,7 +3758,6 @@ fn test_parse_spawn_request_rejects_invalid_model_strength() { .contains("model_strength must be one of: same, faster") ); } - #[test] fn test_parse_spawn_request_rejects_invalid_child_thinking() { let input = json!({ @@ -4007,7 +3770,6 @@ fn test_parse_spawn_request_rejects_invalid_child_thinking() { .contains("thinking must be one of: inherit, auto, off, low, medium, high, max") ); } - #[test] fn test_parse_spawn_request_accepts_session_name_for_agent() { let input = json!({ @@ -4021,7 +3783,6 @@ fn test_parse_spawn_request_accepts_session_name_for_agent() { assert_eq!(parsed.fork_context, Some(true)); assert_eq!(parsed.max_depth, Some(0)); } - #[test] fn test_parse_spawn_request_rejects_invalid_session_name() { let input = json!({ @@ -4031,7 +3792,6 @@ fn test_parse_spawn_request_rejects_invalid_session_name() { let err = parse_spawn_request(&input).expect_err("space in name should fail"); assert!(err.to_string().contains("name must not contain whitespace")); } - #[test] fn test_parse_spawn_request_rejects_out_of_range_max_depth() { let ceiling = codewhale_config::MAX_SPAWN_DEPTH_CEILING; @@ -4046,7 +3806,6 @@ fn test_parse_spawn_request_rejects_out_of_range_max_depth() { .contains(&format!("max_depth must be between 0 and {ceiling}")) ); } - #[test] fn test_parse_spawn_request_accepts_profile_and_preserves_safe_selector() { let input = json!({ @@ -4073,7 +3832,6 @@ fn test_parse_spawn_request_accepts_profile_and_preserves_safe_selector() { .expect("human model label should parse"); assert_eq!(parsed.profile.as_deref(), Some("DeepSeek V4 Flash")); } - #[test] fn test_parse_spawn_request_rejects_invalid_profile_token() { for bad in ["reviewer\nscout", "reviewer\tscout"] { @@ -4090,7 +3848,6 @@ fn test_parse_spawn_request_rejects_invalid_profile_token() { .expect_err("oversized selector should fail"); assert!(err.to_string().contains("at most 128"), "{err}"); } - #[tokio::test] async fn agent_roster_action_and_spawn_resolve_the_same_roles() { let tmp = tempdir().expect("tempdir"); @@ -4139,7 +3896,6 @@ async fn agent_roster_action_and_spawn_resolve_the_same_roles() { assert_eq!(request.agent_type, FleetRole::Scout); assert_eq!(request.profile.as_deref(), Some("explore")); } - #[tokio::test] async fn agent_roster_lists_host_presented_profiles_spawn_resolves() { let tmp = tempdir().expect("tempdir"); @@ -4211,7 +3967,6 @@ async fn agent_roster_lists_host_presented_profiles_spawn_resolves() { assert_eq!(request.profile.as_deref(), Some(profile_id)); } } - #[tokio::test] async fn agent_roster_truncates_host_profiles_at_the_listing_cap() { let tmp = tempdir().expect("tempdir"); @@ -4256,7 +4011,6 @@ async fn agent_roster_truncates_host_profiles_at_the_listing_cap() { // Descriptions fall back to the role name when the profile carries none. assert_eq!(host_profiles[0]["description"], json!("exp-host-000")); } - #[tokio::test] async fn agent_roster_profile_query_discovers_members_beyond_the_listing_cap() { // The T7 discovery-contract regression: a spawnable profile past @@ -4365,7 +4119,6 @@ async fn agent_roster_profile_query_discovers_members_beyond_the_listing_cap() { assert_eq!(resolved.id, "exp-host-049"); assert_eq!(request.profile.as_deref(), Some("exp-host-049")); } - #[tokio::test] async fn agent_roster_never_lists_ids_that_spawn_a_different_profile() { // `[fleet.profiles]` is a byte-order BTreeMap, so `Expert`, `expert`, and @@ -4446,7 +4199,6 @@ async fn agent_roster_never_lists_ids_that_spawn_a_different_profile() { .expect("host member is returned"); assert_eq!(resolved.id, "Expert"); } - #[tokio::test] async fn agent_roster_listing_and_spawn_agree_on_conflatable_config_ids() { // The wiring-level half of the collapse invariant. The `action=roster` @@ -4570,7 +4322,6 @@ async fn agent_roster_listing_and_spawn_agree_on_conflatable_config_ids() { ); } } - #[test] fn roster_host_profile_listing_cap_is_pinned_at_48() { // Copy-truth pin: the app-side roster contract teaches a 48-row @@ -4579,7 +4330,6 @@ fn roster_host_profile_listing_cap_is_pinned_at_48() { // this test, in the same style as `subagent_runtime_default_max_depth_is_three`. assert_eq!(ROSTER_HOST_PROFILE_LIMIT, 48); } - #[tokio::test] async fn forkguard_agent_roster_lists_only_spawnable_host_profiles() { let tmp = tempdir().expect("tempdir"); @@ -4695,7 +4445,6 @@ async fn forkguard_agent_roster_lists_only_spawnable_host_profiles() { "{error}" ); } - #[test] fn test_resolve_spawn_role_unknown_lists_roles() { let mut request = @@ -4719,7 +4468,6 @@ fn test_resolve_spawn_role_unknown_lists_roles() { assert!(message.contains(role), "missing {role}: {message}"); } } - #[test] fn forkguard_host_profile_overlay_is_config_only_and_prompt_only() { let profile_id = "exp-host-reviewer"; @@ -4785,7 +4533,6 @@ fn forkguard_host_profile_overlay_is_config_only_and_prompt_only() { .to_string(); assert!(error.contains("not prompt-only"), "{error}"); } - #[test] fn test_resolve_spawn_role_rejects_conflicting_explicit_type() { let mut request = parse_spawn_request(&json!({ @@ -4805,7 +4552,6 @@ fn test_resolve_spawn_role_rejects_conflicting_explicit_type() { "{message}" ); } - #[test] fn test_resolve_spawn_role_accepts_agreeing_explicit_type() { let mut request = parse_spawn_request(&json!({ @@ -4819,7 +4565,6 @@ fn test_resolve_spawn_role_accepts_agreeing_explicit_type() { assert_eq!(request.profile.as_deref(), Some("reviewer")); assert_eq!(request.assignment.role.as_deref(), Some("reviewer")); } - #[test] fn test_resolve_spawn_role_scout_yields_explore_type_and_inherits_route() { let mut request = parse_spawn_request(&json!({"prompt": "map the parser", "profile": "scout"})) @@ -4835,7 +4580,6 @@ fn test_resolve_spawn_role_scout_yields_explore_type_and_inherits_route() { ); assert_eq!(selected.source, SpawnRouteSource::RunModel); } - #[test] fn test_resolve_spawn_role_accepts_legacy_alias() { let mut request = parse_spawn_request(&json!({"prompt": "merge findings", "profile": "plan"})) @@ -4844,7 +4588,6 @@ fn test_resolve_spawn_role_accepts_legacy_alias() { assert_eq!(request.agent_type, FleetRole::Planner); assert_eq!(request.profile.as_deref(), Some("planner")); } - #[test] fn spawn_model_selection_has_stable_three_tier_precedence_and_source() { let mut runtime = stub_runtime(); @@ -4896,7 +4639,6 @@ fn spawn_model_selection_has_stable_three_tier_precedence_and_source() { assert_eq!(selected.model_route, ModelRoute::Inherit); assert_eq!(selected.source, SpawnRouteSource::RunModel); } - #[test] fn providerless_spawn_model_gate_rejects_known_foreign_route_before_spawn() { let runtime = stub_runtime_for_provider("moonshot"); @@ -4948,7 +4690,6 @@ fn providerless_spawn_model_gate_rejects_known_foreign_route_before_spawn() { "the child and receipt must use the provider's exact wire id" ); } - #[test] fn providerless_foreign_spawn_default_inherits_session_route() { // #5099 / checklist §2.2: a moonshot parent spawning a default child whose @@ -5001,7 +4742,6 @@ fn providerless_foreign_spawn_default_inherits_session_route() { owned.model_route ); } - #[test] fn spawn_route_sources_refresh_overlays_live_config_on_launch_time_defaults() { // #5099: role-model defaults are a launch-time snapshot, so a mid-session @@ -5044,7 +4784,6 @@ fn spawn_route_sources_refresh_overlays_live_config_on_launch_time_defaults() { "worker override covers the general alias too" ); } - #[test] fn test_child_max_spawn_depth_request_only_narrows() { // No request: inherit unchanged. @@ -5058,7 +4797,6 @@ fn test_child_max_spawn_depth_request_only_narrows() { // A request below the inherited budget is still honored (clamp, don't force). assert_eq!(child_max_spawn_depth_for_spawn(5, 0, Some(3)), 3); } - /// A descendant subagent must not widen the absolute recursion budget its root /// session selected by supplying an explicit `max_depth` on a nested spawn /// (#5253). The inherited budget is a hard cap even when the request (clamped @@ -5077,7 +4815,6 @@ fn test_child_max_spawn_depth_request_cannot_widen_inherited_budget() { // A request below the inherited budget is still honored (clamp, don't force). assert_eq!(child_max_spawn_depth_for_spawn(5, 0, Some(3)), 3); } - /// An explicit spawn-time thinking tier reaches the request unchanged: roles /// carry no reasoning tier of their own, so there is nothing to outrank or /// inherit — the caller's tier is the tier. @@ -5105,7 +4842,6 @@ fn test_explicit_spawn_thinking_reaches_the_request() { ); assert_eq!(route.reasoning_effort.as_deref(), Some("off")); } - #[test] fn test_unset_spawn_thinking_inherits_the_session_tier() { let mut request = parse_spawn_request(&json!({"prompt": "look around", "profile": "explore"})) @@ -5113,7 +4849,6 @@ fn test_unset_spawn_thinking_inherits_the_session_tier() { resolve_spawn_role(&mut request).expect("resolve"); assert_eq!(request.thinking, SubAgentThinking::Inherit); } - /// Roles bind no route (#5046 inverted): the dispatching model may always set /// `model` or `model_strength` — every role accepts model routing options. #[test] @@ -5164,7 +4899,6 @@ fn role_dispatch_allows_model_and_model_strength_options() { assert!(request.model_strength_explicit); assert_eq!(request.model_strength, SubAgentModelStrength::Faster); } - /// A custom (non-role) profile name fails closed: only the closed role set /// resolves — there are no saved members to promote. #[test] @@ -5183,7 +4917,6 @@ fn custom_profile_name_fails_closed() { "error must name the custom profile: {message}" ); } - /// A type alias dispatches as its role and keeps an explicit `model`: there /// is no saved member to promote to and no pinned model to collide with. #[test] @@ -5203,7 +4936,6 @@ fn type_alias_dispatch_keeps_explicit_model() { "explicit model is kept: roles pin no model" ); } - /// A Fleet worker subprocess launches as `--model --reasoning-effort /// auto`. That is a FIXED model with Auto reasoning: the raw `"auto"` sentinel /// must resolve, not travel to a provider that has no such tier. @@ -5234,7 +4966,6 @@ fn fixed_model_runtime_with_a_raw_auto_tier_resolves_instead_of_staying_raw() { assert_eq!(route.reasoning_effort.as_deref(), Some("max")); assert_eq!(route.tuning.reasoning_effort, Some(ReasoningEffort::Max)); } - #[test] fn a_concrete_runtime_tier_is_not_mistaken_for_auto() { let runtime = stub_runtime().with_reasoning_effort(Some("off".to_string()), false); @@ -5249,7 +4980,6 @@ fn a_concrete_runtime_tier_is_not_mistaken_for_auto() { assert_eq!(route.reasoning_effort.as_deref(), Some("off")); } - #[tokio::test] async fn session_projection_exposes_forked_prefix_cache_contract() { let mut snapshot = make_snapshot(SubAgentStatus::Running); @@ -5277,7 +5007,6 @@ async fn session_projection_exposes_forked_prefix_cache_contract() { assert_eq!(projection.transcript_handle.kind, "var_handle"); assert_eq!(projection.transcript_handle.name, "transcript"); } - #[tokio::test] async fn terminal_session_projection_prefers_full_transcript_handle() { let mut snapshot = make_snapshot(SubAgentStatus::Completed); @@ -5309,7 +5038,6 @@ async fn terminal_session_projection_prefers_full_transcript_handle() { assert_eq!(projection.transcript_handle, full_handle); assert_eq!(projection.transcript_handle.name, "full_transcript"); } - #[tokio::test] async fn interrupted_projection_exposes_checkpoint_metadata_and_messages() { let mut snapshot = make_snapshot(SubAgentStatus::Interrupted( @@ -5362,7 +5090,6 @@ async fn interrupted_projection_exposes_checkpoint_metadata_and_messages() { assert!(timed_out_projection.timed_out); assert!(timed_out_projection.timed_out_with_checkpoint); } - #[test] fn test_delegate_defaults_to_fork_context() { let input = with_default_fork_context(json!({ "prompt": "review current work" }), true); @@ -5376,7 +5103,6 @@ fn test_delegate_defaults_to_fork_context() { let parsed = parse_spawn_request(&input).expect("delegate override should parse"); assert_eq!(parsed.fork_context, Some(false)); } - #[test] fn spawn_request_parses_token_budget_override() { let parsed = parse_spawn_request(&json!({ @@ -5403,7 +5129,6 @@ fn spawn_request_parses_token_budget_override() { "clear token budget error: {err}" ); } - #[test] fn forked_subagent_messages_preserve_parent_prefix_then_append_task() { let parent_message = Message { @@ -5440,7 +5165,6 @@ fn forked_subagent_messages_preserve_parent_prefix_then_append_task() { assert_eq!(messages[3].role, "user"); assert!(message_text(&messages[3]).contains("inspect parser")); } - #[test] fn fresh_subagent_messages_keep_existing_single_turn_shape() { let assignment = SubAgentAssignment::new("list files".to_string(), None); @@ -5451,7 +5175,6 @@ fn fresh_subagent_messages_keep_existing_single_turn_shape() { assert_eq!(messages[0].role, "user"); assert!(message_text(&messages[0]).contains("list files")); } - #[test] fn test_parse_spawn_request_rejects_text_and_items_together() { let input = json!({ @@ -5461,7 +5184,6 @@ fn test_parse_spawn_request_rejects_text_and_items_together() { let err = parse_spawn_request(&input).expect_err("text+items should fail"); assert!(err.to_string().contains("either prompt text or items")); } - #[test] fn test_parse_spawn_request_rejects_human_model_name_as_role() { // A model name is not a role: without a roster of saved members there is @@ -5481,7 +5203,6 @@ fn test_parse_spawn_request_rejects_human_model_name_as_role() { "{err}" ); } - #[test] fn test_parse_spawn_request_accepts_fleet_role_token_for_runtime_resolution() { let input = json!({ @@ -5508,7 +5229,6 @@ fn test_parse_spawn_request_accepts_fleet_role_token_for_runtime_resolution() { resolve_spawn_role(&mut scout).expect("scout should resolve"); assert_eq!(scout.agent_type, FleetRole::Scout); } - #[test] fn test_parse_spawn_request_accepts_full_role_vocabulary() { // Regression for #2649: roles that `FleetRole::from_str` accepts must @@ -5580,7 +5300,6 @@ fn test_parse_spawn_request_accepts_full_role_vocabulary() { .unwrap_or_else(|e| panic!("role {role:?} should resolve without a profile: {e}")); } } - #[test] fn test_invalid_role_error_lists_real_aliases() { // Well-formed fleet role tokens parse and then fail clearly at role @@ -5608,7 +5327,6 @@ fn test_invalid_role_error_lists_real_aliases() { "hint should explain compatibility aliases: {err}" ); } - #[test] fn plugin_agent_profile_loads_but_is_not_a_spawn_role() { let _lock = crate::test_support::lock_test_env(); @@ -5651,20 +5369,17 @@ fn plugin_agent_profile_loads_but_is_not_a_spawn_role() { "reload removes the disabled plugin Agent" ); } - fn schema_property_description<'a>(schema: &'a Value, property: &str) -> &'a str { schema["properties"][property]["description"] .as_str() .unwrap_or_else(|| panic!("missing description for schema property {property:?}")) } - fn draft_2020_validator(schema: &Value) -> jsonschema::Validator { jsonschema::options() .with_draft(jsonschema::Draft::Draft202012) .build(schema) .expect("valid Draft 2020-12 tool schema") } - #[test] fn subagent_tool_schemas_advertise_real_type_and_role_vocabulary() { let tmp = tempdir().expect("tempdir"); @@ -5850,7 +5565,6 @@ fn wait_bound_constants_agree_across_both_wait_faces() { same minimum timeout" ); } - #[test] fn wait_timeout_alias_stays_parse_accepted() { // docs/SUBAGENTS.md pins the `timeout` alias (same meaning as @@ -5864,7 +5578,6 @@ fn wait_timeout_alias_stays_parse_accepted() { assert_eq!(read(json!({"timeout": 7})), Some(7)); assert_eq!(read(json!({})), None); } - #[test] fn agent_tool_unadvertised_fields_remain_parse_accepted() { // #5324 compat: the fields removed from the advertised schema must stay @@ -5957,7 +5670,6 @@ fn agent_tool_unadvertised_fields_remain_parse_accepted() { AgentToolAction::Interrupt ); } - #[test] fn agent_tool_role_schema_is_a_closed_canonical_enum() { let tmp = tempdir().expect("tempdir"); @@ -6005,7 +5717,6 @@ fn agent_tool_role_schema_is_a_closed_canonical_enum() { ); } } - #[test] fn provider_schema_sanitizers_preserve_the_closed_fleet_role_enum() { use crate::tools::schema_sanitize; @@ -6082,7 +5793,6 @@ fn provider_schema_sanitizers_preserve_the_closed_fleet_role_enum() { "Kimi MFJS must receive a schema without dependentSchemas" ); } - #[test] fn agent_tool_prompt_schema_keeps_ordinary_starts_message_first() { let tmp = tempdir().expect("tempdir"); @@ -6109,7 +5819,6 @@ fn agent_tool_prompt_schema_keeps_ordinary_starts_message_first() { ); } } - #[test] fn agent_tool_schema_advertises_lifecycle_and_coordination_actions() { let tmp = tempdir().expect("tempdir"); @@ -6132,7 +5841,6 @@ fn agent_tool_schema_advertises_lifecycle_and_coordination_actions() { // absence; `timeout_secs` returned to the advertised surface in #119, // and the compat test below still covers both plus the `timeout` alias. } - #[test] fn agent_tool_schema_bounds_fields_by_explicit_action() { let tmp = tempdir().expect("tempdir"); @@ -6181,7 +5889,6 @@ fn agent_tool_schema_bounds_fields_by_explicit_action() { assert!(branch(action).get("anyOf").is_none()); } } - #[test] fn agent_tool_schema_rejects_empty_input_across_provider_forms() { use crate::tools::schema_sanitize; @@ -6250,7 +5957,6 @@ fn agent_tool_schema_rejects_empty_input_across_provider_forms() { "agent schema must reject empty input because runtime defaults it to start and then rejects the missing prompt; permissive forms: {permissive:?}" ); } - #[tokio::test] async fn agent_message_queues_and_followup_delivers_as_user_provenance() { let tmp = tempdir().expect("tempdir"); @@ -6350,7 +6056,6 @@ async fn agent_message_queues_and_followup_delivers_as_user_provenance() { .to_string(); assert!(absent.contains("not found"), "{absent}"); } - #[tokio::test] async fn agent_tool_status_returns_running_child_projection() { let tmp = tempdir().expect("tempdir"); @@ -6399,7 +6104,6 @@ async fn agent_tool_status_returns_running_child_projection() { assert!(result.content.contains("running")); assert!(result.content.contains("transcript_handle")); } - #[tokio::test] async fn agent_tool_status_reconciles_stale_single_agent_projection() { let tmp = tempdir().expect("tempdir"); @@ -6450,7 +6154,6 @@ async fn agent_tool_status_reconciles_stale_single_agent_projection() { assert!(result.content.contains("Auto-cancelled")); assert_eq!(manager.read().await.running_count(), 0); } - #[tokio::test] async fn agent_tool_cancel_stops_running_child() { let tmp = tempdir().expect("tempdir"); @@ -6519,7 +6222,6 @@ async fn agent_tool_cancel_stops_running_child() { "repeated stop must not append a second terminal outcome" ); } - #[tokio::test] async fn model_wait_cancel_fans_in_once_and_preserves_checkpoint() { use tokio_util::sync::CancellationToken; @@ -6618,7 +6320,6 @@ async fn model_wait_cancel_fans_in_once_and_preserves_checkpoint() { 1 ); } - #[tokio::test] async fn coordination_interrupt_fans_in_once_and_preserves_checkpoint() { use tokio_util::sync::CancellationToken; @@ -6739,7 +6440,6 @@ async fn coordination_interrupt_fans_in_once_and_preserves_checkpoint() { "repeated interrupt must not append a second terminal or parked outcome" ); } - #[tokio::test] async fn late_completion_does_not_overwrite_cancelled_outcome() { let tmp = tempdir().expect("tempdir"); @@ -6791,7 +6491,6 @@ async fn late_completion_does_not_overwrite_cancelled_outcome() { assert_eq!(terminal.len(), 1); assert_eq!(terminal[0].status, AgentWorkerStatus::Cancelled); } - #[tokio::test] async fn completion_claim_preserves_running_gate_and_excludes_late_cancel() { let tmp = tempdir().expect("tempdir"); @@ -6867,7 +6566,6 @@ async fn completion_claim_preserves_running_gate_and_excludes_late_cancel() { .count(); assert_eq!(terminal, 1, "exactly one terminal outcome is recorded"); } - #[test] fn test_parse_spawn_request_rejects_conflicting_type_and_role() { let input = json!({ @@ -6881,7 +6579,6 @@ fn test_parse_spawn_request_rejects_conflicting_type_and_role() { .contains("Fleet role conflicts with the explicit legacy agent type") ); } - #[test] fn test_build_allowed_tools_independent_of_allow_shell() { // v0.6.6: allow_shell no longer filters at the build_allowed_tools @@ -6893,7 +6590,6 @@ fn test_build_allowed_tools_independent_of_allow_shell() { assert!(with_shell.is_none()); assert!(without_shell.is_none()); } - #[test] fn test_allowed_tools_are_deduplicated() { let tools = build_allowed_tools( @@ -6912,13 +6608,11 @@ fn test_allowed_tools_are_deduplicated() { Some(vec!["read_file".to_string(), "grep_files".to_string()]) ); } - #[test] fn test_custom_agent_requires_allowed_tools() { let err = build_allowed_tools(&FleetRole::Custom, None, true).unwrap_err(); assert!(err.to_string().contains("requires")); } - #[test] fn role_posture_blocks_writes_and_shell_for_read_only_roles() { // #3217: read-only roles may never run write/edit/patch tools, regardless @@ -6980,7 +6674,6 @@ fn role_posture_blocks_writes_and_shell_for_read_only_roles() { ApprovalRequirement::Required )); } - #[test] fn test_build_assignment_prompt_includes_metadata() { let assignment = SubAgentAssignment::new( @@ -6992,7 +6685,6 @@ fn test_build_assignment_prompt_includes_metadata() { assert!(prompt.contains("resolved_type: explore")); assert!(prompt.contains("role: explore")); } - #[test] fn subagent_model_strength_defaults_to_parent_even_when_parent_auto_model() { let mut runtime = stub_runtime().with_auto_model(true); @@ -7010,7 +6702,6 @@ fn subagent_model_strength_defaults_to_parent_even_when_parent_auto_model() { assert_eq!(route.model, "deepseek-v4-pro", "prompt {prompt:?}"); } } - #[test] fn subagent_model_strength_faster_uses_known_family_sibling() { let mut runtime = stub_runtime().with_auto_model(true); @@ -7027,7 +6718,6 @@ fn subagent_model_strength_faster_uses_known_family_sibling() { assert_eq!(route.model, "deepseek-v4-flash"); assert_eq!(route.reasoning_effort.as_deref(), Some("off")); } - #[test] fn subagent_model_strength_explicit_model_wins_over_faster() { let runtime = stub_runtime().with_auto_model(true); @@ -7045,7 +6735,6 @@ fn subagent_model_strength_explicit_model_wins_over_faster() { ); assert_eq!(route.model, "deepseek-v4-pro"); } - #[test] fn explicit_child_thinking_overrides_faster_default_off() { let mut runtime = stub_runtime().with_reasoning_effort(Some("max".to_string()), false); @@ -7062,7 +6751,6 @@ fn explicit_child_thinking_overrides_faster_default_off() { assert_eq!(route.reasoning_effort.as_deref(), Some("high")); assert_eq!(route.tuning.reasoning_effort, Some(ReasoningEffort::High)); } - #[test] fn explicit_child_auto_thinking_resolves_from_child_prompt() { let runtime = stub_runtime().with_reasoning_effort(Some("off".to_string()), false); @@ -7076,7 +6764,6 @@ fn explicit_child_auto_thinking_resolves_from_child_prompt() { ); assert_eq!(route.reasoning_effort.as_deref(), Some("max")); } - #[tokio::test] async fn route_resolution_matrix_uses_explicit_model_strength_routes() { let mut runtime = stub_runtime() @@ -7172,7 +6859,6 @@ async fn route_resolution_matrix_uses_explicit_model_strength_routes() { ); } } - #[test] fn subagent_auto_reasoning_resolves_to_distinct_v4_tiers() { let runtime = stub_runtime().with_reasoning_effort(Some("high".to_string()), true); @@ -7200,7 +6886,6 @@ fn subagent_auto_reasoning_resolves_to_distinct_v4_tiers() { Some("max".to_string()) ); } - #[test] fn test_subagent_tool_registry_reports_unavailable_tools() { let tmp = tempdir().expect("tempdir"); @@ -7224,7 +6909,6 @@ fn test_subagent_tool_registry_reports_unavailable_tools() { vec!["update_goal".to_string(), "missing_tool".to_string()] ); } - #[test] fn test_subagent_tools_respect_nested_agent_depth_budget() { let tmp = tempdir().expect("tempdir"); @@ -7263,11 +6947,9 @@ fn test_subagent_tools_respect_nested_agent_depth_budget() { ); assert!(!capped.is_tool_allowed("agent")); } - fn tool_names(tools: Vec) -> HashSet { tools.into_iter().map(|tool| tool.name).collect() } - #[test] fn every_named_role_has_one_complete_capability_based_surface() { const INSPECTION: &[&str] = &[ @@ -7480,7 +7162,6 @@ fn every_named_role_has_one_complete_capability_based_surface() { assert_eq!(names, expected, "{role:?} visible surface drifted"); } } - /// An explicit parent tool subset is a restriction, not a note. The registry /// consults `allowed_tools`, so a `ToolScope::Explicit` that never reaches it /// leaves the child holding the parent's whole surface while the profile claims @@ -7520,7 +7201,6 @@ fn an_explicit_parent_tool_scope_is_enforced_by_the_child_registry() { assert!(!names.contains("Bash"), "{names:?}"); assert!(!names.contains("Git"), "{names:?}"); } - /// A child may narrow inside its parent's explicit subset; it may not step /// outside it, however it asks. #[test] @@ -7563,7 +7243,6 @@ fn a_child_allowlist_cannot_widen_an_explicit_parent_tool_scope() { None ); } - fn enabled_agent_surface_options() -> AgentToolSurfaceOptions { let mut options = AgentToolSurfaceOptions::new(ShellPolicy::Full); options.apply_patch_enabled = true; @@ -7572,7 +7251,6 @@ fn enabled_agent_surface_options() -> AgentToolSurfaceOptions { options.goal_state = Some(crate::tools::goal::new_shared_goal_state()); options } - /// Return the exact model-visible catalog built for a default General child. /// /// Request-boundary tests use this fixture so Moonshot compatibility coverage @@ -7601,7 +7279,6 @@ pub(crate) fn kimi_general_child_request_tools_fixture() -> Vec { let mut surface = SubAgentToolSurface::new(catalog, &[]); model_request_tools(&mut surface) } - fn small_surface_registry(role: FleetRole) -> SubAgentToolRegistry { let mut runtime = stub_runtime().with_agent_tool_surface_options(enabled_agent_surface_options()); @@ -7614,11 +7291,9 @@ fn small_surface_registry(role: FleetRole) -> SubAgentToolRegistry { crate::tools::plan::new_shared_plan_state(), ) } - fn model_tool_names(tools: Vec) -> BTreeSet { tools.into_iter().map(|tool| tool.name).collect() } - #[test] fn small_surface_legacy_rules_cover_lowercase_primitives_with_deny_wins() { let file = vec!["File".to_string()]; @@ -7665,11 +7340,9 @@ fn small_surface_legacy_rules_cover_lowercase_primitives_with_deny_wins() { ); } } - fn model_request_tools(surface: &mut SubAgentToolSurface) -> Vec { surface.request_tools(surface.catalog.clone(), false) } - fn forked_child_request_fixture( registry: &SubAgentToolRegistry, role: &FleetRole, @@ -7685,7 +7358,6 @@ fn forked_child_request_fixture( let catalog = registry.deferred_catalog_for_model(role); (messages, SubAgentToolSurface::new(catalog, &[])) } - async fn execute_surface_tool( registry: &SubAgentToolRegistry, surface: &mut SubAgentToolSurface, @@ -7698,7 +7370,6 @@ async fn execute_surface_tool( .await .map(|result| result.result.content) } - #[test] fn small_surface_starts_with_only_pi_head_and_search() { let registry = small_surface_registry(FleetRole::Builder); @@ -7729,7 +7400,6 @@ fn small_surface_starts_with_only_pi_head_and_search() { Some(true) ); } - #[tokio::test] async fn small_surface_read_only_child_discovers_web_deferred() { let registry = small_surface_registry(FleetRole::Scout); @@ -7773,7 +7443,6 @@ async fn small_surface_read_only_child_discovers_web_deferred() { assert!(same_batch.result.content.contains("deferred")); assert!(model_tool_names(model_request_tools(&mut surface)).contains("Web")); } - #[tokio::test] async fn small_surface_fork_context_survives_fresh_child_discovery() { let registry = small_surface_registry(FleetRole::Builder); @@ -7847,7 +7516,6 @@ async fn small_surface_fork_context_survives_fresh_child_discovery() { assert!(names.contains("Web")); assert!(names.contains("apply_patch")); } - #[tokio::test] async fn small_surface_denied_warm_tool_is_not_resurrected() { let mut runtime = @@ -7875,7 +7543,6 @@ async fn small_surface_denied_warm_tool_is_not_resurrected() { assert!(!searched.contains("\"tool_name\":\"Web\"")); assert!(surface.hydrate("Web").is_err()); } - fn synthetic_deferred_tool(name: &str, description_bytes: usize) -> Tool { Tool { tool_type: Some("function".to_string()), @@ -7942,7 +7609,6 @@ fn forkguard_small_surface_caches_are_independent_unbounded_and_revalidated() { assert!(byte_names.contains("bytes_0")); assert!(byte_names.contains("bytes_1") && byte_names.contains("bytes_2")); } - #[tokio::test] async fn small_surface_successful_cached_use_touches_lru() { let registry = small_surface_registry(FleetRole::Builder); @@ -7962,7 +7628,6 @@ async fn small_surface_successful_cached_use_touches_lru() { surface.hydrate(&ninth).expect("ninth activation"); assert!(model_tool_names(model_request_tools(&mut surface)).contains("get_goal")); } - /// The child's ride channel mirrors the parent's: parked notes ride success /// and error results alike (a note held back on `Err` would strand until a /// later `Ok` — or forever if the child ends first), and names the ride @@ -8031,7 +7696,6 @@ fn child_parked_note_rides_success_and_error_results_and_nets_re_admissions() { netted.result.content ); } - /// The park side is covered above; this pin is the consumption side — /// `execute_from_surface` must hand parked notes to the child's recorded /// result, the production edge that once discarded every report. Driven @@ -8064,7 +7728,6 @@ async fn parked_note_rides_through_the_execute_from_surface_glue() { surface.pending_eviction_notes ); } - #[test] fn small_surface_depth_cap_removes_only_agent() { let mut runtime = @@ -8090,13 +7753,11 @@ fn small_surface_depth_cap_removes_only_agent() { .collect() ); } - fn disabled_feature_agent_surface_options() -> AgentToolSurfaceOptions { let mut options = AgentToolSurfaceOptions::new(ShellPolicy::Full); options.goal_state = Some(crate::tools::goal::new_shared_goal_state()); options } - #[test] fn subagent_general_catalog_keeps_parent_surface_except_root_goal_mutators() { let tmp = tempdir().expect("tempdir"); @@ -8138,7 +7799,6 @@ fn subagent_general_catalog_keeps_parent_surface_except_root_goal_mutators() { "default General sub-agent catalog must match the parent Agent surface except for root-owned goal mutators" ); } - #[test] fn subagent_feature_gates_match_parent_agent_surface() { let tmp = tempdir().expect("tempdir"); @@ -8179,7 +7839,6 @@ fn subagent_feature_gates_match_parent_agent_surface() { assert!(!child_names.contains(name), "{name} should be child-gated"); } } - /// Model the clamp's deny list the way a real spawn threads it in /// (fleet/worker_runtime.rs): a `write: false` member loses the raw shell /// surface and the non-shell execution surface even when its posture holds @@ -8207,7 +7866,6 @@ fn seed_read_only_role_deny_list(runtime: &mut SubAgentRuntime) { } } } - /// #5426 acceptance point 1, gate-level: a live scout must be able to run /// the three canonical read-only inspection commands DIRECTLY through /// canonical `bash` — `git -C ... log`, `find ... | head`, `npm view` — with @@ -8288,7 +7946,6 @@ fn scout_posture_gate_admits_agent_readonly_bash_commands() { "legacy `Bash` is not the carve-out name; the envelope must keep refusing it" ); } - /// #5595: catalog admission, role posture, and the execution envelope are not /// enough. The concrete read-only executor must accept the canonical Git shape /// agents use when the repository is not their process cwd. This is the exact @@ -8341,7 +7998,6 @@ async fn read_only_inspection_roles_execute_pwd_and_absolute_git_log() { } } } - /// Read-only text filters may transform stdout, but they must not reach their /// file-output or helper-program forms through the same bounded bash carve-out. #[tokio::test] @@ -8424,7 +8080,6 @@ async fn read_only_inspection_roles_cannot_write_through_text_filters() { // bounded read-only probes the inspection roles use — so "with evidence" // includes evidence the child gathered itself (`wc`, `ls`, `cat`, `grep`) — // while custom commands stay refused and the workspace stays read-only. - /// The roster description must promise exactly the surface the role has: /// built-in gates plus a bounded read-only evidence shell, no custom commands. #[test] @@ -8452,7 +8107,6 @@ fn verifier_roster_description_states_the_bounded_contract() { "type description must match the roster: {SUBAGENT_TYPE_DESCRIPTION}" ); } - /// Acceptance for the verifier lane, sibling of /// `read_only_inspection_roles_execute_pwd_and_absolute_git_log`: a live /// `test` child must execute a bounded read-only probe through canonical @@ -8487,7 +8141,6 @@ async fn verifier_child_executes_a_bounded_readonly_bash_probe_for_evidence() { "verifier probe must report the fixture's byte count as wc's first field: {output}" ); } - /// The walls around the lane stay up: the legacy raw `Bash` spelling, a /// custom `Run` command, and a workspace write are all refused for a /// verifier — at dispatch, and with the `reject_unbounded_verification` @@ -8572,7 +8225,6 @@ async fn verifier_child_still_refuses_raw_shell_custom_commands_and_writes() { "workspace-write refusal did not identify the denial: {write}" ); } - /// A registry for a write-capable role whose parent handed it a read-only /// shell and no writes — what `derive_child` produces for a Builder, Worker, /// or Custom child of a Scout or Planner on the plain `agent` nesting path, @@ -8596,7 +8248,6 @@ fn read_only_shell_child_registry(role: FleetRole) -> (tempfile::TempDir, SubAge ); (tmp, registry) } - /// Shell authority is the Verifier's carve-out alone. Every other role whose /// runtime shell was narrowed to read-only — the Builder, Worker, or Custom /// child of a read-only parent — holds no process authority: the built-in @@ -8651,7 +8302,6 @@ async fn read_only_shell_children_of_other_roles_hold_no_shell_authority() { assert!(verifier.posture_permits_tool("Run", Some(&gate))); assert!(verifier.envelope_refusal("Run", &gate).is_none()); } - /// The purpose survives the narrowing: default gates and test-selection /// arguments stay admitted for the verifier at posture and envelope, and the /// catalog still offers `Run`. @@ -8687,7 +8337,6 @@ fn verifier_child_keeps_the_default_and_selection_bounded_run_surface() { "catalog must keep offering the probe lane beside the gates: {names:?}" ); } - /// A Verifier child of a read-only role parent (its shell was clamped by the /// parent, not narrowed by the Verifier role) regains nothing through the /// carve-out: the built-in gate runner stays refused at posture and envelope @@ -8730,17 +8379,20 @@ async fn clamped_verifier_child_of_read_only_parent_holds_no_run_surface() { "the evidence lane stays classifier-bounded" ); } - /// The sub-subagent chain: a carve-out Verifier parent (its effective shell /// is the role's read-only narrowing; the Run grant is a registry surface, -/// not shell authority) spawning a Builder child. Hop 2 is built from hop -/// 1's actual published profile through the same intersection the -/// production spawn path runs, so the pin is a real two-hop chain: the -/// spawn ceiling the carve-out parent publishes stays read-only, and the -/// child derived under that ceiling keeps the no-Run, lane-less shape. It -/// guards the propagation side of the round-3 fix — if the registry ever -/// leaked a pre-narrowing profile to its spawns, the Builder below would -/// regain the gate surface, and this test would redden. +/// not shell authority) spawning a Builder child through the REAL spawn +/// path. Hop 2 is not hand-derived: the test starts the Builder through the +/// verifier's own `agent` tool and reads the child's recorded +/// `runtime_profile` — the profile `run_subagent` derived from the runtime +/// the registry published (`worker_profile_for_spawn` -> +/// `derive_child`). That recorded profile is the spawn ceiling the +/// carve-out parent actually published; a Builder built under it keeps the +/// no-Run, lane-less shape. This guards the propagation side of the +/// round-3 fix: if the registry ever leaked a pre-narrowing profile to its +/// spawns (`child_runtime.worker_profile` regressed to the parent-runtime +/// profile), the recorded shell below would come out `full` and this test +/// would redden. #[tokio::test] async fn sub_subagent_chain_of_a_carveout_verifier_holds_no_extra_authority() { // A carve-out Verifier in a full session: the coarse session flag stays @@ -8757,35 +8409,69 @@ async fn sub_subagent_chain_of_a_carveout_verifier_holds_no_extra_authority() { codewhale_workflow::ShellCeiling::ReadOnly, "the carve-out must not widen the shell ceiling a verifier publishes for its own spawns" ); - // Hop 2 is derived from hop 1's actual output: the carve-out verifier's - // narrowed effective profile is what the registry publishes to its own - // spawns, so a Builder one hop below the verifier is built through the - // same derivation the production spawn path runs - // (`worker_profile_for_spawn` intersects the parent's effective profile - // with the child's role request). If the registry ever leaked a - // pre-narrowing profile to its spawns, the Builder below would regain - // the gate surface — this pin exists to redden exactly that regression. - let (_verifier_tmp, verifier) = read_only_with_shell_registry(); + // Hop 2 through the real spawn path: the write-capable `agent` start + // needs an auto-approved surface, so the fixture runs in its + // auto-approving variant. Everything else about the carve-out shape is + // the fixture's own. + let (verifier_tmp, verifier) = read_only_with_shell_registry_auto_approving(true); assert_eq!( verifier.runtime_profile.shell, crate::worker_profile::ShellPolicy::ReadOnly, - "the carve-out fixture must publish the narrowed shell to its spawns" - ); - let derived = verifier - .runtime_profile - .derive_child(&WorkerRuntimeProfile::for_role(FleetRole::Builder)); - assert_eq!( - derived.shell, - crate::worker_profile::ShellPolicy::ReadOnly, - "the chain Builder must derive the read-only shell from the verifier's narrowed profile" + "the carve-out fixture must narrow the verifier's own profile" ); - let child_tmp = tempfile::tempdir().expect("tempdir"); - let mut child_runtime = - stub_runtime().with_agent_tool_surface_options(enabled_agent_surface_options()); - child_runtime.context = ToolContext::new(child_tmp.path().to_path_buf()); - child_runtime.allow_shell = true; - child_runtime.worker_profile = derived; - let builder = SubAgentToolRegistry::new( + let started = verifier + .execute( + "agent_chain_probe", + "agent", + json!({ + "action": "start", + "type": "builder", + "prompt": "noop chain probe", + }), + ) + .await + .expect("the carve-out verifier must be able to start a chain Builder"); + let started: Value = + serde_json::from_str(&started).expect("the agent start must answer with a JSON payload"); + let agent_id = started + .get("agent_id") + .and_then(Value::as_str) + .expect("the agent start must answer with an agent_id") + .to_string(); + // The record appears once the spawned task registers the child; the + // recorded profile is what production derived from the published + // runtime, not a field this test set. + let manager = verifier.gate_runtime.manager.clone(); + let record = tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Some(record) = manager.read().await.get_worker_record(&agent_id) + && record.spec.runtime_profile != WorkerRuntimeProfile::default() + { + return record; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + }) + .await + .expect("the chain Builder worker record must appear"); + assert_eq!( + record.spec.agent_type, + FleetRole::Builder, + "the spawned hop must be the requested Builder" + ); + assert_eq!( + record.spec.runtime_profile.shell, + crate::worker_profile::ShellPolicy::ReadOnly, + "the profile the registry published to its spawn must derive the read-only shell; \ + a pre-narrowing leak redds here" + ); + let child_tmp = tempfile::tempdir().expect("tempdir"); + let mut child_runtime = + stub_runtime().with_agent_tool_surface_options(enabled_agent_surface_options()); + child_runtime.context = ToolContext::new(child_tmp.path().to_path_buf()); + child_runtime.allow_shell = true; + child_runtime.worker_profile = record.spec.runtime_profile.clone(); + let builder = SubAgentToolRegistry::new( child_runtime, FleetRole::Builder, None, @@ -8825,8 +8511,8 @@ async fn sub_subagent_chain_of_a_carveout_verifier_holds_no_extra_authority() { refused.to_string().contains("not permitted") || refused.to_string().contains("shell"), "chain Builder refusal did not identify the wall: {refused}" ); + drop(verifier_tmp); } - /// The verifier intro tells the child about its bounded read-only shell and /// the custom-command refusal, scoped to in-session delegations the way /// `FleetRole`'s roster description scopes the role. The gates claim is @@ -8853,7 +8539,6 @@ fn verifier_intro_states_the_evidence_contract() { "the intro's gates claim must carry the read-only-parent conditional: {VERIFIER_AGENT_INTRO}" ); } - #[test] fn explore_catalog_inherits_web_but_hides_write_shell_and_fim_tools() { let tmp = tempdir().expect("tempdir"); @@ -8905,7 +8590,6 @@ fn explore_catalog_inherits_web_but_hides_write_shell_and_fim_tools() { file.input_schema ); } - /// Under the real spawn clamp — raw-shell deny list installed, `Bash` /// included — Scout and Reviewer reach exactly one shell entry point: /// canonical lowercase `bash`, bounded by the strict read-only classifier. @@ -9120,7 +8804,6 @@ async fn read_only_roles_expose_and_dispatch_lowercase_bash_only() { ); } } - #[tokio::test] async fn planner_exposes_and_dispatches_read_only_bash_probes() { let tmp = tempdir().expect("tempdir"); @@ -9174,7 +8857,6 @@ async fn planner_exposes_and_dispatches_read_only_bash_probes() { .expect("planner must dispatch a bounded read"); assert_eq!(output, sentinel); } - #[tokio::test] async fn scout_shell_respects_parent_shell_and_network_ceilings() { let tmp = tempdir().expect("tempdir"); @@ -9266,7 +8948,6 @@ async fn scout_shell_respects_parent_shell_and_network_ceilings() { } } } - /// An auto-approved Worker registry (shell-capable, everything else default) /// carrying the given exec-policy engine — the smallest fixture that reaches /// the delegated-call execpolicy gate past every posture gate. @@ -9276,7 +8957,6 @@ fn auto_approved_worker_registry_with_engine( ) -> SubAgentToolRegistry { worker_registry_with_engine_and_approval(tmp, exec_policy_engine, true) } - /// The same fixture with the parent session's approval posture under the /// caller's control: `false` models every prompting/fail-closed posture /// (Suggest/Auto/Never), where the main line would surface an approval @@ -9300,7 +8980,6 @@ fn worker_registry_with_engine_and_approval( crate::tools::plan::new_shared_plan_state(), ) } - /// Fork-delta behavior test: a command the parent session's execpolicy /// hard-denies must fail closed inside the child with the main line's deny /// wording — the auto-approve escape hatch this wiring closes — while a @@ -9492,7 +9171,6 @@ async fn subagent_execpolicy_empty_ruleset_keeps_auto_approve_behavior() { .to_string(); assert!(error.contains("denied prefix rule 'echo'"), "{error}"); } - /// A typed Deny rule on a file path blocks the child's File read of that path /// while a workspace-relative read outside the rule still lands. #[tokio::test] @@ -9529,7 +9207,6 @@ async fn subagent_execpolicy_blocks_denied_file_read_but_allows_workspace_relati .expect("a workspace-relative read outside the deny rule passes"); assert!(output.contains("plain notes"), "{output}"); } - /// Typed ask rules are prompt decisions on the main line. Under a /// parent-auto-approving session the main line would auto-run, so the child /// passes the call through and it really executes; the refusal face under a @@ -9555,7 +9232,6 @@ async fn subagent_execpolicy_prompt_decision_passes_under_parent_auto_approve() .expect("an ask-rule (prompt) decision must not block a child"); assert!(output.contains("askme"), "{output}"); } - /// The registry's engine handle is the parent's live engine, not a snapshot: /// a ruleset installed after the registry was built binds its delegated calls. #[tokio::test] @@ -9589,7 +9265,6 @@ async fn subagent_execpolicy_ruleset_update_binds_already_delegated_calls() { .to_string(); assert!(error.contains("denied prefix rule 'echo'"), "{error}"); } - #[test] fn implementer_catalog_inherits_patch_and_fim_when_enabled() { let tmp = tempdir().expect("tempdir"); @@ -9623,7 +9298,6 @@ fn implementer_catalog_inherits_patch_and_fim_when_enabled() { ); } } - #[test] fn every_fleet_role_catalog_advertises_one_executable_load_skill() { // load_skill contract (#4651): the parent Agent surface and every @@ -9739,7 +9413,6 @@ fn every_fleet_role_catalog_advertises_one_executable_load_skill() { } } } - #[test] fn custom_child_allowlist_omitting_load_skill_fails_closed() { // Custom children get exactly their explicit allow-list: load_skill is @@ -9781,7 +9454,6 @@ fn custom_child_allowlist_omitting_load_skill_fails_closed() { "explicitly listed load_skill is granted: {names:?}" ); } - #[tokio::test] async fn plan_parent_profile_narrows_even_implementer_child_to_read_only() { let tmp = tempdir().expect("tempdir"); @@ -9829,7 +9501,6 @@ async fn plan_parent_profile_narrows_even_implementer_child_to_read_only() { ); assert!(!workspace.join("plan-parent-write.txt").exists()); } - #[tokio::test] async fn api_timeout_preserves_checkpoint_and_returns_needs_input_without_parking() { let tmp = tempdir().expect("tempdir"); @@ -10004,7 +9675,6 @@ async fn api_timeout_preserves_checkpoint_and_returns_needs_input_without_parkin "projection inspection must not respawn the child implicitly" ); } - #[tokio::test] async fn subagent_retries_api_timeout_before_succeeding() { let tmp = tempdir().expect("tempdir"); @@ -10084,7 +9754,6 @@ async fn subagent_retries_api_timeout_before_succeeding() { assert_eq!(snapshot.status, SubAgentStatus::Completed); assert_eq!(snapshot.result.as_deref(), Some("recovered answer")); } - #[test] fn api_timeout_retry_backoff_doubles_and_caps() { let base = SUBAGENT_API_TIMEOUT_INITIAL_BACKOFF; @@ -10098,7 +9767,6 @@ fn api_timeout_retry_backoff_doubles_and_caps() { ); } } - #[test] fn api_timeout_retry_delay_stays_within_jitter_bounds() { let base = SUBAGENT_API_TIMEOUT_INITIAL_BACKOFF; @@ -10118,14 +9786,12 @@ fn api_timeout_retry_delay_stays_within_jitter_bounds() { } } } - #[test] fn transient_provider_classifier_matches_sse_header_timeout() { let err = anyhow::anyhow!("SSE stream request did not receive response headers after 45s"); assert!(is_transient_subagent_provider_error(&err)); } - #[test] fn transient_provider_classifier_matches_body_decode_failures() { // A provider that accepts the request and dies mid-response is a @@ -10146,7 +9812,6 @@ fn transient_provider_classifier_matches_body_decode_failures() { "auth failures stay fatal" ); } - #[test] fn transient_provider_classifier_matches_structured_rate_limit() { let err = anyhow::Error::new(crate::llm_client::LlmError::RateLimited { @@ -10157,7 +9822,6 @@ fn transient_provider_classifier_matches_structured_rate_limit() { assert!(is_transient_subagent_provider_error(&err)); } - #[tokio::test] async fn subagent_retries_transient_provider_header_timeout_before_succeeding() { let tmp = tempdir().expect("tempdir"); @@ -10232,7 +9896,6 @@ async fn subagent_retries_transient_provider_header_timeout_before_succeeding() assert_eq!(snapshot.status, SubAgentStatus::Completed); assert_eq!(snapshot.result.as_deref(), Some("recovered answer")); } - #[tokio::test] async fn subagent_rate_limit_exhaustion_interrupts_with_checkpoint() { let tmp = tempdir().expect("tempdir"); @@ -10318,7 +9981,6 @@ async fn subagent_rate_limit_exhaustion_interrupts_with_checkpoint() { assert!(checkpoint.continuable); assert!(snapshot.needs_input.is_some()); } - #[tokio::test] async fn spawn_duplicate_session_name_error_names_conflicting_agent() { // #2656: the duplicate-name error must identify the conflicting agent so a @@ -10379,7 +10041,6 @@ async fn spawn_duplicate_session_name_error_names_conflicting_agent() { "includes elapsed time since spawn: {msg}" ); } - #[tokio::test] async fn spawn_session_name_held_by_prior_session_agent_does_not_collide() { // A completed worker hydrated from an earlier session's ledger is not @@ -10439,7 +10100,6 @@ async fn spawn_session_name_held_by_prior_session_agent_does_not_collide() { assert_eq!(fresh.session_name, "researcher"); assert!(!guard.is_from_prior_session(fresh)); } - /// Seed a same-session child holding `name` in the given status; returns its /// agent id. Shared by the name-release tests below. async fn seed_named_same_session_agent( @@ -10474,7 +10134,6 @@ async fn seed_named_same_session_agent( assert!(!guard.is_from_prior_session(&guard.agents[&id])); id } - #[tokio::test] async fn spawn_session_name_released_once_same_session_holder_settles() { // 2026-10-23 research fan-out: the parent cancelled `macro-policy`, got @@ -10540,7 +10199,6 @@ async fn spawn_session_name_released_once_same_session_holder_settles() { ); } } - #[tokio::test] async fn spawn_session_name_still_held_by_interrupted_same_session_child() { // An interrupted child is parked, not finished: its name stays reserved @@ -10593,7 +10251,6 @@ async fn spawn_session_name_still_held_by_interrupted_same_session_child() { "cancel is a silent no-op on an interrupted child: {msg}" ); } - #[tokio::test] async fn spawn_session_name_running_holder_advises_wait_or_cancel() { // A running holder can be waited out or cancelled; the refusal must keep @@ -10636,7 +10293,6 @@ async fn spawn_session_name_running_holder_advises_wait_or_cancel() { "a running holder can be waited out or cancelled: {msg}" ); } - #[test] fn session_name_resolution_prefers_live_holder_then_newest_settled() { let tmp = tempdir().expect("tempdir"); @@ -10686,7 +10342,6 @@ fn session_name_resolution_prefers_live_holder_then_newest_settled() { older_id ); } - #[test] fn session_name_resolution_ignores_prior_boot_interrupted_ghost() { // Hydration rewrites a child that was Running at shutdown into a @@ -10740,7 +10395,6 @@ fn session_name_resolution_ignores_prior_boot_interrupted_ghost() { retry_id ); } - #[tokio::test] async fn id_shaped_session_name_requires_an_explicit_name_selector() { // Name reuse cannot reinterpret a durable receipt. Both live and settled @@ -10798,7 +10452,6 @@ async fn id_shaped_session_name_requires_an_explicit_name_selector() { ); } } - #[test] fn spawn_contention_error_names_the_read_only_path() { // The ledger text is written for writers. The refusal seen in the @@ -10824,7 +10477,6 @@ fn spawn_contention_error_names_the_read_only_path() { let other = "write-claim capacity is 64 active owners; complete, serialize, or isolate existing work before admitting another writer".to_string(); assert_eq!(spawn_claim_error_with_guidance(other.clone()), other); } - #[test] fn general_role_text_discloses_workspace_claim_and_read_only_alternative() { // The roster entry, the type enum text and the tool description are the @@ -10850,7 +10502,6 @@ fn general_role_text_discloses_workspace_claim_and_read_only_alternative() { "{description}" ); } - #[tokio::test] async fn shared_write_claim_is_registered_before_parallel_launch_and_manifested() { let tmp = tempdir().expect("tempdir"); @@ -10932,7 +10583,6 @@ async fn shared_write_claim_is_registered_before_parallel_launch_and_manifested( assert_eq!(manifest.coordination_contracts, vec!["public-api"]); assert_eq!(manifest.expected_artifact.as_deref(), Some("tested patch")); } - #[tokio::test] async fn write_capable_agent_does_not_launch_when_durable_registration_fails() { let tmp = tempdir().expect("tempdir"); @@ -10983,7 +10633,6 @@ async fn write_capable_agent_does_not_launch_when_durable_registration_fails() { ); assert!(blocked_state_path.is_dir()); } - #[tokio::test] async fn write_scope_contention_covers_regular_agent_and_active_fleet_writer() { let tmp = tempdir().expect("tempdir"); @@ -11057,7 +10706,6 @@ async fn write_scope_contention_covers_regular_agent_and_active_fleet_writer() { "{launch}" ); } - #[tokio::test] async fn test_running_count_counts_only_agents_with_live_task_handles() { let mut manager = SubAgentManager::new(PathBuf::from("."), 1); @@ -11090,7 +10738,6 @@ async fn test_running_count_counts_only_agents_with_live_task_handles() { .expect("live task handle") .abort(); } - #[test] fn test_running_count_ignores_running_status_without_task_handle() { let mut manager = SubAgentManager::new(PathBuf::from("."), 1); @@ -11112,7 +10759,6 @@ fn test_running_count_ignores_running_status_without_task_handle() { assert_eq!(manager.running_count(), 0); } - #[tokio::test] async fn test_running_count_counts_running_agents_until_status_reconciles() { let mut manager = SubAgentManager::new(PathBuf::from("."), 1); @@ -11139,7 +10785,6 @@ async fn test_running_count_counts_running_agents_until_status_reconciles() { assert_eq!(manager.running_count(), 1); } - #[tokio::test] async fn admission_limit_counts_queued_and_running_workers_separately() { let mut manager = SubAgentManager::new(PathBuf::from("."), 2).with_admission_limit(4); @@ -11209,7 +10854,6 @@ async fn admission_limit_counts_queued_and_running_workers_separately() { .abort(); } } - #[tokio::test] async fn cleanup_auto_cancels_stale_running_agent_and_releases_slot() { use tokio_util::sync::CancellationToken; @@ -11286,7 +10930,6 @@ async fn cleanup_auto_cancels_stale_running_agent_and_releases_slot() { AgentWorkerStatus::Cancelled ); } - #[tokio::test] async fn status_projection_reconciles_stale_running_agent() { let mut inner = SubAgentManager::new(PathBuf::from("."), 1) @@ -11337,7 +10980,6 @@ async fn status_projection_reconciles_stale_running_agent() { .contains("Auto-cancelled") ); } - #[tokio::test] async fn cleanup_keeps_recent_running_agent() { let mut manager = SubAgentManager::new(PathBuf::from("."), 1) @@ -11375,7 +11017,6 @@ async fn cleanup_keeps_recent_running_agent() { .expect("live task handle") .abort(); } - #[tokio::test] async fn touch_refreshes_stale_running_agent_heartbeat() { // Use a heartbeat timeout that is comfortably larger than the synchronous @@ -11420,7 +11061,6 @@ async fn touch_refreshes_stale_running_agent_heartbeat() { .expect("live task handle") .abort(); } - #[test] fn test_persist_and_reload_marks_running_agent_as_interrupted() { let tmp = tempdir().expect("tempdir"); @@ -11461,7 +11101,6 @@ fn test_persist_and_reload_marks_running_agent_as_interrupted() { if message.contains(SUBAGENT_RESTART_REASON) )); } - #[test] fn generated_whale_name_is_not_persisted_or_replayed_on_load() { let tmp = tempdir().expect("tempdir"); @@ -11518,7 +11157,6 @@ fn generated_whale_name_is_not_persisted_or_replayed_on_load() { assert_eq!(snapshot.name, "docs-worker"); assert_eq!(snapshot.nickname, None); } - #[test] fn explicit_nonmatching_whale_word_is_persisted_and_loaded() { let tmp = tempdir().expect("tempdir"); @@ -11564,7 +11202,6 @@ fn explicit_nonmatching_whale_word_is_persisted_and_loaded() { let snapshot = reloaded.get_result(agent_id).expect("agent survives load"); assert_eq!(snapshot.nickname.as_deref(), Some(explicit_whale)); } - #[test] fn persist_and_reload_preserves_checkpoint_for_interrupted_running_agent() { let tmp = tempdir().expect("tempdir"); @@ -11615,7 +11252,6 @@ fn persist_and_reload_preserves_checkpoint_for_interrupted_running_agent() { assert_eq!(checkpoint.messages.len(), 2); assert_eq!(message_text(&checkpoint.messages[1]), "partial progress"); } - #[test] fn restart_reconciles_every_orphan_execution_status_once_and_preserves_receipts() { let tmp = tempdir().expect("tempdir"); @@ -11788,7 +11424,6 @@ fn restart_reconciles_every_orphan_execution_status_once_and_preserves_receipts( "a later restart does not append duplicate interrupted receipts" ); } - #[cfg(unix)] #[test] fn load_state_rejects_symlinked_state_file() { @@ -11813,7 +11448,6 @@ fn load_state_rejects_symlinked_state_file() { .expect_err("symlinked state should fail"); assert!(format!("{err:#}").contains("must not traverse symlinks")); } - #[test] fn persist_state_rejects_state_path_outside_state_root() { let tmp = tempdir().expect("tempdir"); @@ -11828,7 +11462,6 @@ fn persist_state_rejects_state_path_outside_state_root() { assert!(format!("{err:#}").contains("must stay within state root")); } - #[test] fn explicit_state_roots_isolate_managers_for_the_same_execution_workspace() { let tmp = tempdir().expect("tempdir"); @@ -11903,7 +11536,6 @@ fn explicit_state_roots_isolate_managers_for_the_same_execution_workspace() { "an explicit state root must keep control-plane files out of the execution workspace" ); } - #[cfg(unix)] #[test] fn persist_state_rejects_symlinked_state_directory() { @@ -11923,7 +11555,6 @@ fn persist_state_rejects_symlinked_state_directory() { || format!("{err:#}").contains("must not traverse symlinks") ); } - #[test] fn test_interrupted_status_name_and_summary() { let snapshot = make_snapshot(SubAgentStatus::Interrupted( @@ -11934,7 +11565,6 @@ fn test_interrupted_status_name_and_summary() { } // === v0.6.6 — sub-agent authority unification === - #[test] fn build_allowed_tools_general_returns_none_for_full_inheritance() { // Default behavior: General agent with no explicit list inherits the @@ -11945,7 +11575,6 @@ fn build_allowed_tools_general_returns_none_for_full_inheritance() { "General with no explicit_tools should default to full inheritance (None), got {result:?}" ); } - #[test] fn build_allowed_tools_explore_returns_none_for_full_inheritance() { // Per-type allowlists are now advisory — Explore also gets the full @@ -11956,7 +11585,6 @@ fn build_allowed_tools_explore_returns_none_for_full_inheritance() { "Explore with no explicit_tools should default to full inheritance" ); } - #[test] fn build_allowed_tools_custom_requires_explicit_list() { // Custom is the one type that REQUIRES explicit allowed_tools. @@ -11966,14 +11594,12 @@ fn build_allowed_tools_custom_requires_explicit_list() { "got: {err}" ); } - #[test] fn build_allowed_tools_explicit_list_returned_as_some() { let explicit = vec!["read_file".to_string(), "list_dir".to_string()]; let result = build_allowed_tools(&FleetRole::Custom, Some(explicit.clone()), true).unwrap(); assert_eq!(result, Some(explicit)); } - #[test] fn build_allowed_tools_explicit_list_dedupes_and_trims() { let explicit = vec![ @@ -11988,7 +11614,6 @@ fn build_allowed_tools_explicit_list_dedupes_and_trims() { Some(vec!["read_file".to_string(), "list_dir".to_string()]) ); } - #[test] fn parse_spawn_request_extracts_cwd_when_present() { let input = json!({ @@ -12001,7 +11626,6 @@ fn parse_spawn_request_extracts_cwd_when_present() { Some(".worktrees/feature-a".to_string()) ); } - #[test] fn parse_spawn_request_accepts_worktree_isolation() { let input = json!({ @@ -12023,7 +11647,6 @@ fn parse_spawn_request_accepts_worktree_isolation() { Some("feature-a".to_string()) ); } - #[test] fn parse_spawn_request_accepts_cwd_with_worktree_isolation() { let input = json!({ @@ -12035,7 +11658,6 @@ fn parse_spawn_request_accepts_cwd_with_worktree_isolation() { assert!(parsed.worktree.is_some()); assert!(parsed.cwd.is_some()); } - #[test] fn git_repo_root_finds_repo_from_direct_cwd() { let repo = init_subagent_git_repo(); @@ -12045,7 +11667,6 @@ fn git_repo_root_finds_repo_from_direct_cwd() { repo.path().canonicalize().expect("canonical repo") ); } - #[test] fn git_repo_root_discovers_one_level_nested_repo_from_harness() { let repo = init_subagent_git_repo(); @@ -12065,7 +11686,6 @@ fn git_repo_root_discovers_one_level_nested_repo_from_harness() { nested.canonicalize().expect("canonical nested") ); } - #[test] fn git_repo_root_reports_attempted_paths_when_no_repo_found() { // Use the system temp dir rather than the checkout's parent: a checkout @@ -12096,21 +11716,18 @@ fn git_repo_root_reports_attempted_paths_when_no_repo_found() { "expected friendly attempted-path error, got: {message}" ); } - #[test] fn parse_spawn_request_cwd_absent_yields_none() { let input = json!({ "prompt": "no cwd" }); let parsed = parse_spawn_request(&input).expect("spawn request should parse"); assert!(parsed.cwd.is_none()); } - #[test] fn parse_spawn_request_cwd_empty_string_yields_none() { let input = json!({ "prompt": "empty cwd", "cwd": " " }); let parsed = parse_spawn_request(&input).expect("spawn request should parse"); assert!(parsed.cwd.is_none(), "whitespace-only cwd should be None"); } - #[test] fn create_isolated_worktree_creates_branch_checkout_outside_parent_repo() { let repo = init_subagent_git_repo(); @@ -12139,7 +11756,6 @@ fn create_isolated_worktree_creates_branch_checkout_outside_parent_repo() { Some("codex/agent-isolated-test") ); } - #[test] fn create_isolated_worktree_rejects_invalid_branch_as_input() { let repo = init_subagent_git_repo(); @@ -12163,7 +11779,6 @@ fn create_isolated_worktree_rejects_invalid_branch_as_input() { "unexpected error: {err}" ); } - fn init_git_repo_at(path: &std::path::Path) { let init = Command::new("git") .arg("init") @@ -12187,7 +11802,6 @@ fn init_git_repo_at(path: &std::path::Path) { .expect("git commit should run"); assert!(commit.status.success(), "git commit failed"); } - #[test] fn create_isolated_worktree_discovers_nested_repo_from_harness_parent() { let harness = tempdir().expect("harness"); @@ -12215,7 +11829,6 @@ fn create_isolated_worktree_discovers_nested_repo_from_harness_parent() { Some("codex/agent-harness-nested") ); } - #[test] fn create_isolated_worktree_reports_friendly_error_when_no_repo_found() { let harness = tempdir().expect("harness"); @@ -12249,7 +11862,6 @@ fn create_isolated_worktree_reports_friendly_error_when_no_repo_found() { "expected actionable discovery error, got: {message}" ); } - #[test] fn create_isolated_worktree_rejects_ambiguous_nested_repos() { let harness = tempdir().expect("harness"); @@ -12274,71 +11886,6 @@ fn create_isolated_worktree_rejects_ambiguous_nested_repos() { "expected ambiguity diagnostic, got: {message}" ); } -/// Discovery may adopt a nested checkout only at the anchor itself. A session -/// workspace that is not a repository used to walk up to `$HOME` and scan -/// *its* children (live session, 2026-10-24: "Multiple git repositories found -/// under /Users/asto. Specify cwd to disambiguate: /Users/asto/flutter, -/// /Users/asto/Spec2017-iOS") — leaking unrelated project names and, with a -/// single sibling, silently adopting a stranger's repository. -/// -/// The anchor probe itself (`git rev-parse` from the workspace) walks -/// unboundedly, so this test assumes the tempdir is not inside a checkout -/// (the base no-repo test nests six levels to dodge the same assumption). -#[test] -fn worktree_discovery_never_adopts_or_enumerates_an_ancestors_sibling_repos() { - let home = tempdir().expect("home"); - for name in ["flutter", "Spec2017-iOS"] { - let sibling = home.path().join(name); - std::fs::create_dir_all(&sibling).expect("sibling dir"); - init_git_repo_at(&sibling); - } - // `/.pinvou3/sessions//workspace`: not a repository, with the - // sibling repositories four hops up — home is exactly the fourth (last) - // probed ancestor, and the Tried-list assertion below fails if a fixture - // change pushes it out of the probe window and turns this test vacuous. - let workspace = home - .path() - .join(".pinvou3") - .join("sessions") - .join("sid") - .join("workspace"); - std::fs::create_dir_all(&workspace).expect("workspace"); - let worktree_home = tempdir().expect("worktree home"); - let request = SubAgentWorktreeRequest { - branch: Some("codex/agent-session".to_string()), - path: Some(worktree_home.path().join("isolated")), - base_ref: None, - }; - - let message = create_isolated_worktree(&workspace, &request, None, &FleetRole::Worker) - .expect_err("a workspace outside any repository must not borrow an ancestor's sibling") - .to_string(); - assert!( - message.contains("requires a git repository"), - "expected the no-repository diagnostic, got: {message}" - ); - assert!( - !message.contains("Multiple git repositories") - && !message.contains("flutter") - && !message.contains("Spec2017-iOS"), - "ancestor siblings must not be scanned or named: {message}" - ); - assert!( - message.contains(home.path().to_string_lossy().as_ref()), - "the walk must have actually probed home (last ancestor in the window): {message}" - ); - - // A single sibling must not be adopted silently either. - std::fs::remove_dir_all(home.path().join("Spec2017-iOS")).expect("drop one sibling"); - let message = create_isolated_worktree(&workspace, &request, None, &FleetRole::Worker) - .expect_err("one sibling repository is still not the workspace's repository") - .to_string(); - assert!( - message.contains("requires a git repository"), - "expected the no-repository diagnostic, got: {message}" - ); -} - #[test] fn build_subagent_system_prompt_appends_role_when_set() { let assignment = SubAgentAssignment::new("p".to_string(), Some("worker".to_string())); @@ -12351,21 +11898,18 @@ fn build_subagent_system_prompt_appends_role_when_set() { // The shared background-worker / caller framing follows the role line. assert!(prompt.contains("background sub-agent")); } - #[test] fn build_subagent_system_prompt_skips_role_when_none() { let assignment = SubAgentAssignment::new("p".to_string(), None); let prompt = build_subagent_system_prompt(&FleetRole::Worker, &assignment); assert!(!prompt.contains("You are operating in the role of")); } - #[test] fn build_subagent_system_prompt_skips_role_when_blank() { let assignment = SubAgentAssignment::new("p".to_string(), Some(" ".to_string())); let prompt = build_subagent_system_prompt(&FleetRole::Worker, &assignment); assert!(!prompt.contains("You are operating in the role of")); } - #[test] fn fresh_forked_and_nested_subagents_share_authority_bound_skill_catalogs() { let _env = crate::test_support::lock_test_env(); @@ -12479,7 +12023,6 @@ fn fresh_forked_and_nested_subagents_share_authority_bound_skill_catalogs() { "workspace plugin authority must not leak into another worktree: {isolated_system}" ); } - #[test] fn subagent_done_sentinel_format_is_well_formed() { let res = make_snapshot(SubAgentStatus::Completed); @@ -12506,7 +12049,6 @@ fn subagent_done_sentinel_format_is_well_formed() { assert!(parsed.get("duration_ms").is_none()); assert!(parsed.get("steps").is_none()); } - #[test] fn subagent_done_sentinel_keeps_large_result_out_of_metadata() { let mut res = make_snapshot(SubAgentStatus::Completed); @@ -12527,7 +12069,6 @@ fn subagent_done_sentinel_keeps_large_result_out_of_metadata() { "sentinel should not duplicate large result text" ); } - #[test] fn subagent_done_sentinel_marks_truncated_summaries() { // issue #2652: when the child summary was length-gated, the sentinel must @@ -12540,7 +12081,6 @@ fn subagent_done_sentinel_marks_truncated_summaries() { let parsed: serde_json::Value = serde_json::from_str(inner).expect("inner JSON parses"); assert_eq!(parsed["summary_kind"], "truncated"); } - #[test] fn failed_subagent_completion_is_high_priority_and_retrievable() { let mut res = make_snapshot(SubAgentStatus::Failed( @@ -12574,7 +12114,6 @@ fn failed_subagent_completion_is_high_priority_and_retrievable() { let completed = subagent_completion_from_result(&make_snapshot(SubAgentStatus::Completed)); assert!(!completed.is_high_priority_failure()); } - #[test] fn budget_exhaustion_is_a_high_priority_failure_event() { let completion = @@ -12592,7 +12131,6 @@ fn budget_exhaustion_is_a_high_priority_failure_event() { .contains(r#""failure_class":"token_budget""#) ); } - #[test] fn stamp_subagent_summary_appends_note_when_short() { // issue #2652: a short (complete) summary gets the soft self-report note @@ -12609,7 +12147,6 @@ fn stamp_subagent_summary_appends_note_when_short() { "short summary must not get the truncation footer" ); } - #[test] fn forkguard_subagent_report_artifact_keeps_exact_bytes() { let home = tempdir().expect("artifact home"); @@ -12651,7 +12188,6 @@ fn forkguard_subagent_report_artifact_keeps_exact_bytes() { assert!(spill_subagent_final_report(blocked_session, &result).is_none()); }); } - #[test] fn stamp_subagent_summary_truncates_when_over_budget() { // issue #2652: a summary exceeding the budget is head+tail truncated using @@ -12686,7 +12222,6 @@ fn stamp_subagent_summary_truncates_when_over_budget() { assert!(saved.contains("activate it via tool_search first")); assert!(saved.contains("if neither route is available")); } - #[test] fn subagent_failed_sentinel_format_is_well_formed() { let mut result = make_snapshot(SubAgentStatus::Failed("boom".to_string())); @@ -12712,7 +12247,6 @@ fn subagent_failed_sentinel_format_is_well_formed() { // Stays lean — the error text lives on the previous line, not the sentinel. assert!(parsed.get("error").is_none()); } - #[test] fn annotated_failure_message_composes_class_tag_and_model_hint() { // #3884: the failure recorder composes subagent_failure_message (adds the @@ -12755,7 +12289,6 @@ fn annotated_failure_message_composes_class_tag_and_model_hint() { assert!(annotated.contains("route:"), "{annotated}"); assert!(annotated.contains("explicit model id"), "{annotated}"); } - #[test] fn subagent_failure_message_preserves_error_chain() { // #3884: `to_string()` on an anyhow error prints only the outermost @@ -12796,7 +12329,6 @@ fn subagent_failure_message_preserves_error_chain() { let message = subagent_failure_message(&err); assert_eq!(message, "outer: boom"); } - #[test] fn forkguard_quota_exhausted_skips_rate_limit_governor_and_stays_fatal() { // PR#43 follow-up: only LlmError::RateLimited feeds the rate-limit @@ -12830,7 +12362,6 @@ fn forkguard_quota_exhausted_skips_rate_limit_governor_and_stays_fatal() { }); assert!(is_governor_reported_rate_limit(&rate_limited_err)); } - #[test] fn annotate_child_model_error_adds_actionable_hint() { // #2653: a bare provider 403 becomes actionable by naming the model and the @@ -12876,7 +12407,6 @@ fn annotate_child_model_error_adds_actionable_hint() { "OpenAI-style rejection gets the hint: {openai_style}" ); } - #[test] fn child_launch_error_names_provider_model_and_route_source() { // #4049: a model-not-found child launch failure must name the provider @@ -12920,13 +12450,11 @@ fn child_launch_error_names_provider_model_and_route_source() { "inherit route source: {inherited}" ); } - #[test] fn subagent_runtime_default_max_depth_is_three() { // Sanity-check the constant — bumping it without a test means stale docs. assert_eq!(DEFAULT_MAX_SPAWN_DEPTH, 3); } - #[test] fn would_exceed_depth_at_boundary() { // depth=2, max=3 → next spawn (depth 3) is allowed (allow-equal). @@ -12948,7 +12476,6 @@ fn would_exceed_depth_at_boundary() { "depth 2 + max 3 → next is 3, allowed" ); } - #[test] fn clamp_child_max_spawn_depth_enforces_absolute_ceiling() { let ceiling = codewhale_config::MAX_SPAWN_DEPTH_CEILING; @@ -12972,7 +12499,6 @@ fn clamp_child_max_spawn_depth_enforces_absolute_ceiling() { "at the ceiling, a further spawn must be blocked regardless of max_depth" ); } - #[tokio::test] #[allow(clippy::await_holding_lock)] async fn rate_limit_pause_blocks_subagent_spawn() { @@ -13007,7 +12533,6 @@ async fn rate_limit_pause_blocks_subagent_spawn() { "refused spawn must not register or launch a worker" ); } - #[test] fn child_runtime_increments_depth_and_preserves_auto_approve() { let mut parent = stub_runtime(); @@ -13038,7 +12563,6 @@ fn child_runtime_increments_depth_and_preserves_auto_approve() { // `WorkRuntime::matches_todos` keys on `Arc::ptr_eq`, drove the parent's work // graph). These tests pin the invariant: every spawned agent owns its list, // and no agent can reach a parent's or a sibling's. - /// Run `work_update` against `todos` with `runtime`'s own tool context, the /// way a live agent's registry would. async fn write_todos_as(runtime: &SubAgentRuntime, contents: &[&str]) { @@ -13051,7 +12575,6 @@ async fn write_todos_as(runtime: &SubAgentRuntime, contents: &[&str]) { .await .expect("work_update must succeed against the agent's own list"); } - async fn todo_contents(todos: &crate::tools::todo::SharedTodoList) -> Vec { todos .lock() @@ -13062,7 +12585,6 @@ async fn todo_contents(todos: &crate::tools::todo::SharedTodoList) -> Vec crate::todo_snapshot::TodoSource { crate::todo_snapshot::TodoSource::new( runtime.context.runtime.work.clone(), runtime.todos.clone(), ) } - /// A child sends its stored messages and nothing else. Its To-do state is /// already in those messages, as the result of its own `work_update` call — /// no step appends a synthetic copy of the list. @@ -13295,7 +12810,6 @@ async fn child_request_messages_are_exactly_its_stored_messages() { assert!(!text.contains("child step one"), "{text}"); } } - /// Sibling and parent isolation: each agent's snapshot states only its own /// list. This is what the fork handoff and the agent card both read. #[tokio::test] @@ -13343,7 +12857,6 @@ async fn child_todo_snapshot_never_leaks_across_siblings_or_to_the_parent() { } } } - #[test] fn child_and_background_runtimes_preserve_step_api_timeout() { let timeout = Duration::from_secs(7); @@ -13360,7 +12873,6 @@ fn child_and_background_runtimes_preserve_step_api_timeout() { assert_eq!(background.step_api_timeout, timeout); assert_eq!(background.api_timeout_retry_base_backoff, backoff); } - #[tokio::test] async fn subagent_registry_blocks_approval_tools_without_parent_auto_approve() { let mut runtime = stub_runtime(); @@ -13387,7 +12899,6 @@ async fn subagent_registry_blocks_approval_tools_without_parent_auto_approve() { "unexpected error: {err}" ); } - #[tokio::test] async fn prompt_only_general_cannot_mutate_under_parent_auto_approve() { let tmp = tempdir().expect("tempdir"); @@ -13435,7 +12946,6 @@ async fn prompt_only_general_cannot_mutate_under_parent_auto_approve() { } const MCP_ACTION_TOOL: &str = "mcp_github_create_pull_request"; - fn subagent_registry_with_mcp_action(auto_approve: bool) -> SubAgentToolRegistry { let mut runtime = stub_runtime(); runtime.context.auto_approve = auto_approve; @@ -13453,7 +12963,6 @@ fn subagent_registry_with_mcp_action(auto_approve: bool) -> SubAgentToolRegistry )); registry } - #[tokio::test] async fn child_write_tool_fails_closed_outside_registered_scope() { let _env_lock = crate::test_support::lock_test_env(); @@ -13655,7 +13164,6 @@ async fn child_write_tool_fails_closed_outside_registered_scope() { ); assert!(!tmp.path().join("docs/run-escape.txt").exists()); } - /// A lone writer in the shared checkout keeps its shell. /// /// The gate above exists so concurrent children cannot overwrite each other. @@ -13720,7 +13228,6 @@ async fn lone_shared_writer_keeps_unbounded_shell() { ); } } - #[test] fn shared_claim_shell_gate_normalizes_only_the_run_action() { assert!(is_unbounded_shell_run( @@ -13739,7 +13246,6 @@ fn shared_claim_shell_gate_normalizes_only_the_run_action() { ); } } - /// Build one registry per contended child, both bound to the same shared /// manager and workspace — the two-builder shared-checkout topology. fn contended_builder_registry( @@ -13762,7 +13268,6 @@ fn contended_builder_registry( Arc::new(Mutex::new(PlanState::default())), ) } - /// Two live write-capable children with disjoint `exact_files` claims in one /// shared checkout: a bash call the read-only classifier proves mutation-free /// must still run on both. The contention gate used to re-test @@ -13809,7 +13314,6 @@ async fn contended_shared_writers_keep_proven_readonly_shell() { }); } } - /// The same contention still refuses a shell call the classifier cannot prove /// read-only — and the refusal names the blocking peer and its remediation so /// the child can recover instead of retrying blindly. @@ -13854,252 +13358,22 @@ async fn contended_shared_writer_refusal_names_blocking_peer_and_remediation() { .expect_err("mutating shell under contention must stay refused") .to_string(); assert!( - err.contains("cannot prove a bounded file target"), - "refusal kind: {err}" - ); - assert!( - err.contains("agent_b"), - "refusal must name the blocking peer: {err}" - ); - assert!( - err.contains("agents/coordinate action=release") && err.contains("worktree isolation"), - "refusal must state concrete remediation: {err}" - ); - assert!( - !tmp.path().join("src/a2.txt").exists(), - "the refused command must not have run" - ); -} -/// The isolated lane carries the same contention premise as the shared one: -/// the same-worktree admission chain lets several unrelated writers share ONE -/// checkout on disjoint claims (overlapping same-root scopes are refused at -/// admission, so a live peer here always holds a disjoint share), and an -/// unbounded shell command cannot prove it stays inside the owner's share — -/// so a live same-worktree peer blocks it, naming that peer. The old -/// assumption "isolated means sole writer of the worktree" no longer holds -/// once a worktree parent can fan children out into its own checkout. -#[tokio::test] -async fn same_worktree_disjoint_sibling_blocks_unbounded_shell() { - let tmp = tempdir().expect("tempdir"); - let worktree = tmp.path().join("wt-blocked"); - std::fs::create_dir_all(worktree.join("a-dir")).expect("worktree layout"); - let canonical_worktree = worktree.canonicalize().expect("canonical worktree"); - let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 4); - { - let mut guard = manager.write().await; - assert_eq!(guard.insert_test_running_agent("a", &worktree), "agent_a"); - assert_eq!(guard.insert_test_running_agent("b", &worktree), "agent_b"); - let live: HashSet = ["agent_a".to_string(), "agent_b".to_string()] - .into_iter() - .collect(); - for (owner, root) in [("agent_a", "a-dir"), ("agent_b", "b-dir")] { - guard - .coordination - .register_claim( - WriteScopeClaim { - owner: owner.into(), - roots: vec![root.into()], - exact_files: vec![], - contracts: vec![], - }, - true, - Some(canonical_worktree.to_string_lossy().into_owned()), - &[], - |candidate| live.contains(candidate), - ) - .expect("disjoint same-worktree claims admit both live writers"); - } - } - let registry = contended_builder_registry(&manager, &worktree, "agent_a"); - let err = registry - .execute( - "agent_a", - "Bash", - json!({"action": "run", "command": "touch a-dir/unbounded.txt"}), - ) - .await - .expect_err("unbounded shell against a live same-worktree peer must be refused") - .to_string(); - assert!( - err.contains("cannot prove a bounded file target"), - "refusal kind: {err}" - ); - assert!( - err.contains("this same worktree") && err.contains("agent_b"), - "refusal must name the same-worktree blocking peer: {err}" - ); - assert!( - !worktree.join("a-dir/unbounded.txt").exists(), - "the refused command must not have run" - ); -} - -/// The ancestor chain keeps its unbounded shell inside the shared worktree: -/// admission deliberately admits a descendant overlapping its ancestors (one -/// delegated work unit), so the parent's live claim must not gate the child -/// the way an unrelated sibling does. Dropping the exemption turns this into -/// a contention refusal and a delegation chain could never run ordinary -/// shell work at all. -#[tokio::test] -async fn same_worktree_ancestor_chain_keeps_unbounded_shell() { - let tmp = tempdir().expect("tempdir"); - let coordination_root = tmp.path().join("workspace"); - let worktree = tmp - .path() - .join(".codewhale-worktrees") - .join("workspace") - .join("codex-agent-parent"); - std::fs::create_dir_all(worktree.join("c-dir")).expect("worktree layout"); - let manager = new_shared_subagent_manager(coordination_root, 4); - let claim = |root: &str| WriteScopeClaim { - owner: String::new(), - roots: vec![root.into()], - exact_files: vec![], - contracts: vec![], - }; - - let mut guard = manager.write().await; - let mut parent_runtime = stub_runtime(); - parent_runtime.manager = Arc::clone(&manager); - parent_runtime.context = ToolContext::new(&worktree); - let parent = guard - .spawn_background_with_assignment_options( - Arc::clone(&manager), - parent_runtime, - FleetRole::Builder, - "work in the worktree".into(), - make_assignment(), - Some(vec![]), - SubAgentSpawnOptions { - name: Some("shell-worktree-parent".into()), - write_claim: Some(claim(".")), - isolated_worktree: true, - ..Default::default() - }, - ) - .expect("worktree parent admitted"); - // Live parent: otherwise the peer never exists and the test passes with - // the exemption removed (the round-4 mutation lesson). - if let Some(agent) = guard.agents.get_mut(&parent.agent_id) { - agent.status = SubAgentStatus::Running; - } - let child = { - let mut child_runtime = stub_runtime(); - child_runtime.manager = Arc::clone(&manager); - child_runtime.context = ToolContext::new(&worktree); - child_runtime.parent_agent_id = Some(parent.agent_id.clone()); - guard - .spawn_background_with_assignment_options( - Arc::clone(&manager), - child_runtime, - FleetRole::Builder, - "run shell in the delegated share".into(), - make_assignment(), - Some(vec![]), - SubAgentSpawnOptions { - name: Some("shell-child".into()), - write_claim: Some(claim("c-dir")), - ..Default::default() - }, - ) - .expect("a disjoint descendant of the live worktree parent is admitted") - }; - drop(guard); - - let registry = contended_builder_registry(&manager, &worktree, &child.agent_id); - registry - .execute( - &child.agent_id, - "Bash", - json!({"action": "run", "command": "touch c-dir/chain.txt"}), - ) - .await - .unwrap_or_else(|err| { - panic!( - "the parent's live claim must not gate the delegated child's unbounded shell: {err}" - ) - }); - assert!( - worktree.join("c-dir/chain.txt").exists(), - "the admitted command must actually have run" - ); -} - -/// Liveness and the worktree boundary stay load-bearing in the isolated gate: -/// a settled sibling's claim cannot block (a terminal owner cannot write), and -/// a peer keyed on a DIFFERENT worktree never shared this checkout. -#[tokio::test] -async fn same_worktree_gate_ignores_settled_and_foreign_worktree_peers() { - let tmp = tempdir().expect("tempdir"); - let worktree = tmp.path().join("wt-live"); - let other_worktree = tmp.path().join("wt-other"); - std::fs::create_dir_all(worktree.join("a-dir")).expect("worktree layout"); - std::fs::create_dir_all(&other_worktree).expect("other worktree layout"); - let canonical_worktree = worktree.canonicalize().expect("canonical worktree"); - let canonical_other = other_worktree.canonicalize().expect("canonical other"); - let manager = new_shared_subagent_manager(tmp.path().to_path_buf(), 4); - { - let mut guard = manager.write().await; - assert_eq!(guard.insert_test_running_agent("a", &worktree), "agent_a"); - assert_eq!(guard.insert_test_running_agent("b", &worktree), "agent_b"); - assert_eq!(guard.insert_test_running_agent("c", &worktree), "agent_c"); - let live: HashSet = ["agent_a".to_string(), "agent_b".to_string()] - .into_iter() - .collect(); - for (owner, scope, root) in [ - ( - "agent_a", - "a-dir", - canonical_worktree.to_string_lossy().into_owned(), - ), - ( - "agent_b", - "b-dir", - canonical_worktree.to_string_lossy().into_owned(), - ), - ( - "agent_c", - ".", - canonical_other.to_string_lossy().into_owned(), - ), - ] { - guard - .coordination - .register_claim( - WriteScopeClaim { - owner: owner.into(), - roots: vec![scope.into()], - exact_files: vec![], - contracts: vec![], - }, - true, - Some(root), - &[], - |candidate| live.contains(candidate), - ) - .expect("same-worktree disjoint-root claims and a foreign-worktree claim admit"); - } - if let Some(agent) = guard.agents.get_mut("agent_b") { - agent.status = SubAgentStatus::Completed; - } - } - let registry = contended_builder_registry(&manager, &worktree, "agent_a"); - registry - .execute( - "agent_a", - "Bash", - json!({"action": "run", "command": "touch a-dir/kept.txt"}), - ) - .await - .unwrap_or_else(|err| { - panic!("a settled sibling and a foreign-worktree peer must not gate the shell: {err}") - }); + err.contains("cannot prove a bounded file target"), + "refusal kind: {err}" + ); + assert!( + err.contains("agent_b"), + "refusal must name the blocking peer: {err}" + ); + assert!( + err.contains("agents/coordinate action=release") && err.contains("worktree isolation"), + "refusal must state concrete remediation: {err}" + ); assert!( - worktree.join("a-dir/kept.txt").exists(), - "the admitted command must actually have run" + !tmp.path().join("src/a2.txt").exists(), + "the refused command must not have run" ); } - /// A child whose task body panics must still reach a terminal state. /// `spawn_supervised` catches the panic to keep the parent alive; without the /// guard committing a crash result the child stayed `Running` and its write @@ -14163,7 +13437,6 @@ async fn panicked_child_task_terminalizes_and_stops_gating_peers() { "a panicked child must not keep gating peers as a live writer" ); } - /// A headless fleet worker (a worker record with no paired agent entry) that /// reaches `WaitingForUser` can never be answered — nothing will resume or /// finalize it — so it must not count as a live coordination owner, and the @@ -15097,7 +14370,6 @@ async fn claim_less_worktree_child_still_confers_isolation_through_its_manifest( "the manifest fallback still puts the claim on the isolated footing" ); } - #[tokio::test] async fn subagent_blocks_mcp_action_without_parent_auto_approve() { let registry = subagent_registry_with_mcp_action(false); @@ -15114,7 +14386,6 @@ async fn subagent_blocks_mcp_action_without_parent_auto_approve() { "unexpected MCP approval error: {err}" ); } - #[tokio::test] async fn auto_approved_subagent_passes_mcp_action_approval_gate() { let registry = subagent_registry_with_mcp_action(true); @@ -15133,7 +14404,6 @@ async fn auto_approved_subagent_passes_mcp_action_approval_gate() { "auto-approved MCP actions must not stop at the approval gate: {message}" ); } - #[tokio::test] async fn implementer_delegation_allows_suggest_write_without_parent_auto_approve() { // Issue #1828: implementer agents could not write files even when their @@ -15171,7 +14441,6 @@ async fn implementer_delegation_allows_suggest_write_without_parent_auto_approve "successful write should not look like an approval error: {result}" ); } - #[tokio::test] async fn workflow_accept_edits_allows_general_file_write_without_parent_auto_approve() { // Workflow-spawned children accept Suggest-level file edits for write-capable @@ -15220,7 +14489,6 @@ async fn workflow_accept_edits_allows_general_file_write_without_parent_auto_app "unexpected: {err}" ); } - #[tokio::test] async fn general_delegation_still_blocks_suggest_write_without_parent_auto_approve() { let tmp = tempdir().expect("tempdir"); @@ -15255,7 +14523,6 @@ async fn general_delegation_still_blocks_suggest_write_without_parent_auto_appro "general write must not land without parent auto-approve" ); } - #[tokio::test] async fn explore_role_still_blocks_suggest_writes_without_parent_auto_approve() { // Read-only stances (explore, plan, review, verifier) must not gain @@ -15295,7 +14562,6 @@ async fn explore_role_still_blocks_suggest_writes_without_parent_auto_approve() "file must not have been written" ); } - #[tokio::test] async fn explore_role_blocks_writes_even_under_parent_auto_approve() { // #3217: the authoritative per-role posture closes the auto-approve bypass — @@ -15341,7 +14607,6 @@ async fn explore_role_blocks_writes_even_under_parent_auto_approve() { "file must not have been written under auto-approve" ); } - #[tokio::test] async fn delegated_write_role_still_blocks_required_tools() { // Required-level tools such as Bash remain gated behind parent @@ -15373,7 +14638,6 @@ async fn delegated_write_role_still_blocks_required_tools() { "expected Required-level approval message, got: {err}" ); } - #[test] fn read_only_role_starts_do_not_require_approval() { // #5186: an explicit canonical read-only role spawns without a modal in @@ -15414,7 +14678,6 @@ fn read_only_role_starts_do_not_require_approval() { ApprovalRequirement::Auto ); } - #[test] fn write_capable_or_unproven_starts_keep_the_approval_gate() { // #5186: anything the parser cannot prove read-only keeps the modal. @@ -15454,7 +14717,6 @@ fn write_capable_or_unproven_starts_keep_the_approval_gate() { ApprovalRequirement::Auto ); } - #[tokio::test] async fn worker_child_inherits_workspace_write_carve_out_without_parent_auto_approve() { // #5186: a write-posture child that is NOT an explicit write-delegated @@ -15505,7 +14767,6 @@ async fn worker_child_inherits_workspace_write_carve_out_without_parent_auto_app ); } } - #[tokio::test] async fn worker_child_write_carve_out_requires_a_git_work_tree() { // #5186: without a `.git` marker the child keeps the old delegation @@ -15535,7 +14796,6 @@ async fn worker_child_write_carve_out_requires_a_git_work_tree() { "unexpected error: {err}" ); } - #[tokio::test] async fn builder_child_runs_bounded_verification_but_not_shell_without_parent_auto_approve() { // #5186: the bounded built-in verification surface is delegated to @@ -15594,7 +14854,6 @@ async fn builder_child_runs_bounded_verification_but_not_shell_without_parent_au "unexpected error: {argv_err}" ); } - #[tokio::test] async fn auto_approved_parent_runs_required_tools_in_subagent() { // Baseline: when the parent runtime IS auto-approved, every approval @@ -15623,7 +14882,6 @@ async fn auto_approved_parent_runs_required_tools_in_subagent() { .await .expect("auto-approved parent should allow writes"); } - #[test] fn subagent_request_budget_inherits_the_resolved_route_allowance() { assert_eq!( @@ -15632,7 +14890,6 @@ fn subagent_request_budget_inherits_the_resolved_route_allowance() { "sub-agents must not impose a smaller internal output ceiling" ); } - #[test] fn incomplete_response_failure_keeps_the_provider_stop_cause() { let response = MessageResponse { @@ -15652,7 +14909,6 @@ fn incomplete_response_failure_keeps_the_provider_stop_cause() { assert!(failure.contains("`content_filter`"), "{failure}"); assert!(!failure.contains("budget exhausted"), "{failure}"); } - #[test] fn child_cancellation_cascades_from_parent() { let parent = stub_runtime(); @@ -15664,7 +14920,6 @@ fn child_cancellation_cascades_from_parent() { "parent cancel() must propagate to child via child_token()" ); } - #[test] fn detached_background_children_survive_parent_cancellation() { let parent = stub_runtime(); @@ -15678,7 +14933,6 @@ fn detached_background_children_survive_parent_cancellation() { "parent stop must leave every detached child running until explicitly cancelled" ); } - #[test] fn agent_start_is_turn_owned_unless_detached_is_explicit() { let owned = parse_spawn_request(&json!({"prompt": "inspect the workspace"})) @@ -15698,7 +14952,6 @@ fn agent_start_is_turn_owned_unless_detached_is_explicit() { "only an explicit detached=true may outlive the turn" ); } - #[test] fn agent_start_marks_only_explicit_detachment_as_durable_work() { let runtime = stub_runtime(); @@ -15716,7 +14969,6 @@ fn agent_start_marks_only_explicit_detachment_as_durable_work() { "only detached=true may opt into durable background scheduling" ); } - #[tokio::test] async fn foreground_turn_cancellation_joins_direct_children_once_and_excludes_detached() { let registry = Arc::new(ForegroundChildRegistry::new()); @@ -15773,7 +15025,6 @@ async fn foreground_turn_cancellation_joins_direct_children_once_and_excludes_de assert!(late_token.is_cancelled()); assert_eq!(registry.active_count(), 0); } - #[tokio::test] async fn foreground_registration_releases_when_the_child_future_returns_or_unwinds() { let registry = Arc::new(ForegroundChildRegistry::new()); @@ -15804,7 +15055,6 @@ async fn foreground_registration_releases_when_the_child_future_returns_or_unwin .await .expect("return and panic-unwind both release foreground ownership"); } - #[tokio::test] async fn turn_owned_descendants_join_and_park_with_the_same_foreground_registry() { let registry = Arc::new(ForegroundChildRegistry::new()); @@ -15874,7 +15124,6 @@ async fn turn_owned_descendants_join_and_park_with_the_same_foreground_registry( ); assert!(!detached.cancel_token.is_cancelled()); } - #[test] fn derived_child_cancellation_reaches_tool_context_while_detached_stays_independent() { let root = stub_runtime(); @@ -15915,7 +15164,6 @@ fn derived_child_cancellation_reaches_tool_context_while_detached_stays_independ .is_some_and(CancellationToken::is_cancelled) ); } - #[tokio::test] async fn turn_end_parking_preserves_a_step_zero_resumable_checkpoint() { let tmp = tempdir().expect("tempdir"); @@ -15988,7 +15236,6 @@ async fn turn_end_parking_preserves_a_step_zero_resumable_checkpoint() { assert!(explicit_needs_input.is_none()); assert_eq!(explicit_worker_status, AgentWorkerStatus::Cancelled); } - #[test] fn turn_end_parking_wins_over_the_final_child_step_limit() { let agent_id = "agent_turn_end_final_step"; @@ -16015,7 +15262,6 @@ fn turn_end_parking_wins_over_the_final_child_step_limit() { assert!(needs_input.is_some()); assert_eq!(worker_status, AgentWorkerStatus::Interrupted); } - #[test] fn mailbox_propagates_through_child_runtime_chain() { use crate::tools::subagent::mailbox::Mailbox; @@ -16035,7 +15281,6 @@ fn mailbox_propagates_through_child_runtime_chain() { "grandchild inherits via the cloned Arc inside Mailbox" ); } - #[test] fn subagent_rejects_interactive_shell_terminal_takeover() { let err = reject_subagent_terminal_takeover( @@ -16078,7 +15323,6 @@ fn subagent_rejects_interactive_shell_terminal_takeover() { ) .expect("background shell remains allowed"); } - #[tokio::test] async fn mailbox_close_as_cancel_propagates_to_grandchild_runtime() { use crate::tools::subagent::mailbox::Mailbox; @@ -16103,7 +15347,6 @@ async fn mailbox_close_as_cancel_propagates_to_grandchild_runtime() { "close-as-cancel must propagate across max_spawn_depth=3" ); } - #[tokio::test] async fn mailbox_orders_messages_from_parent_and_child_runtimes() { use crate::tools::subagent::mailbox::{Mailbox, MailboxMessage}; @@ -16155,7 +15398,6 @@ async fn mailbox_orders_messages_from_parent_and_child_runtimes() { other => panic!("unexpected message order: {other:?}"), } } - #[test] fn persisted_empty_allowed_tools_loads_as_full_inheritance() { // Backward-compat: a v0.6.5 session that persisted with an empty Vec @@ -16188,7 +15430,6 @@ fn persisted_empty_allowed_tools_loads_as_full_inheritance() { "empty Vec on disk → None (full inheritance)" ); } - #[test] fn persisted_non_empty_allowed_tools_loads_as_narrow() { // Backward-compat the other way: a v0.6.5 session that persisted with @@ -16221,7 +15462,6 @@ fn persisted_non_empty_allowed_tools_loads_as_narrow() { "non-empty Vec → Some(list), narrow scope preserved" ); } - #[test] fn persisted_advisory_assignment_roles_replay_and_repersist_as_consultant() { for alias in ["oracle", "advisor"] { @@ -16269,7 +15509,6 @@ fn persisted_advisory_assignment_roles_replay_and_repersist_as_consultant() { assert_eq!(repersisted["agents"][0]["agent_type"], json!("advisor")); } } - /// Build a minimal `SubAgentRuntime` for tests that exercise pure runtime /// helpers (depth, cancellation, child_runtime). Doesn't construct a real /// HTTP client — calls that hit `runtime.client` would fail, but the @@ -16335,7 +15574,6 @@ pub(crate) fn stub_runtime() -> SubAgentRuntime { ask_on_guardrail: false, } } - #[test] fn root_operate_dispatch_delegates_file_edits_without_bypassing_required_tools() { let mut runtime = stub_runtime(); @@ -16353,7 +15591,6 @@ fn root_operate_dispatch_delegates_file_edits_without_bypassing_required_tools() "Operate dispatch must not silently grant Required tools such as shell" ); } - #[tokio::test] async fn root_operate_dispatch_delegates_builtin_verification_but_not_shell() { let tmp = tempdir().expect("tempdir"); @@ -16431,7 +15668,6 @@ async fn root_operate_dispatch_delegates_builtin_verification_but_not_shell() { "Operate verification delegation must not propagate past the direct worker" ); } - #[test] fn worker_lifecycle_records_direct_operate_approval_without_delegating_authority() { let todos = crate::tools::todo::new_shared_todo_list(); @@ -16530,7 +15766,6 @@ fn worker_lifecycle_records_direct_operate_approval_without_delegating_authority "nested workers must not inherit Operate approval authority" ); } - /// A minimal stub client. Test helpers below only ever check struct fields /// (depth, cancel_token, context); they don't call the network. We need a /// *some* `DeepSeekClient` because `SubAgentRuntime.client` isn't @@ -16541,7 +15776,6 @@ fn stub_runtime_for_provider(provider: &str) -> SubAgentRuntime { runtime.client = stub_client_for_provider(provider); runtime } - fn stub_client_for_provider(provider: &str) -> DeepSeekClient { let _ = rustls::crypto::ring::default_provider().install_default(); let mut providers = crate::config::ProvidersConfig::default(); @@ -16590,7 +15824,6 @@ fn stub_client_for_provider(provider: &str) -> DeepSeekClient { }; DeepSeekClient::new(&config).expect("stub client should construct") } - fn stub_client() -> DeepSeekClient { let _ = rustls::crypto::ring::default_provider().install_default(); let config = crate::config::Config { @@ -16605,7 +15838,6 @@ fn stub_client() -> DeepSeekClient { // There are no provider pins outside the durable Fleet runs: every child runs // on the parent's provider. Child and background runtimes clone the session // client and config unchanged — no cross-provider build, no misroute. - /// A session runtime on `deepseek` with the cross-provider `Config` threaded in, /// exactly as the engine wires it via `with_api_config`. fn cross_provider_runtime() -> SubAgentRuntime { @@ -16633,7 +15865,6 @@ fn cross_provider_runtime() -> SubAgentRuntime { runtime.client = client; runtime } - #[test] fn spawn_child_runtime_inherits_session_client_and_config() { let runtime = cross_provider_runtime(); @@ -16676,7 +15907,6 @@ fn spawn_child_runtime_inherits_session_client_and_config() { // carry the prior boot id and are classified as `from_prior_session`. // Listings default to current-session only; `include_archived=true` surfaces // the prior-session records with the flag set. - fn insert_prior_session_agent( manager: &mut SubAgentManager, id: &str, @@ -16722,7 +15952,6 @@ fn insert_prior_session_agent( } manager.agents.insert(id.to_string(), agent); } - fn assign_test_session_owner( manager: &mut SubAgentManager, agent_id: &str, @@ -16737,14 +15966,12 @@ fn assign_test_session_owner( record.owner_session_id = owner_session_id.to_string(); } } - #[test] fn session_boot_ids_are_unique_per_manager() { let a = SubAgentManager::new(PathBuf::from("."), 1); let b = SubAgentManager::new(PathBuf::from("."), 1); assert_ne!(a.session_boot_id(), b.session_boot_id()); } - #[test] fn terminal_synthesis_is_scoped_to_owner_and_keeps_delivery_history() { let mut manager = SubAgentManager::new(PathBuf::from("."), 5); @@ -16787,7 +16014,6 @@ fn terminal_synthesis_is_scoped_to_owner_and_keeps_delivery_history() { "returning A -> B -> A must not re-synthesize an already delivered terminal result" ); } - #[test] fn active_session_owns_every_roster_and_control_resolution() { let workspace = tempdir().expect("tempdir"); @@ -16903,7 +16129,6 @@ fn active_session_owns_every_roster_and_control_resolution() { "A -> B -> A restores A's archived control row" ); } - #[test] fn forkguard_cancel_all_running_is_session_scoped_and_idempotent() { let workspace = tempdir().expect("tempdir"); @@ -16961,7 +16186,6 @@ fn forkguard_cancel_all_running_is_session_scoped_and_idempotent() { "idempotent cleanup may not append another terminal event" ); } - #[test] fn ownerless_legacy_rows_fail_closed_and_scoped_close_preserves_foreign_workers() { let workspace = tempdir().expect("tempdir"); @@ -17013,7 +16237,6 @@ fn ownerless_legacy_rows_fail_closed_and_scoped_close_preserves_foreign_workers( .is_terminal() ); } - #[test] fn scoped_handle_eviction_drain_preserves_foreign_pending_ids() { let mut manager = SubAgentManager::new(PathBuf::from("."), 2); @@ -17031,7 +16254,6 @@ fn scoped_handle_eviction_drain_preserves_foreign_pending_ids() { vec!["agent-b".to_string()] ); } - #[test] fn coordination_records_are_visible_and_mutable_only_to_their_owner_session() { let mut manager = SubAgentManager::new(PathBuf::from("."), 2); @@ -17062,7 +16284,6 @@ fn coordination_records_are_visible_and_mutable_only_to_their_owner_session() { "B must not gain mutation authority over A's decision" ); } - #[test] fn list_filtered_drops_prior_session_terminals_by_default() { let mut manager = SubAgentManager::new(PathBuf::from("."), 5); @@ -17109,7 +16330,6 @@ fn list_filtered_drops_prior_session_terminals_by_default() { .unwrap(); assert!(!current.from_prior_session); } - #[test] fn list_snapshots_refresh_git_branch_from_agent_workspace() { let repo = init_subagent_git_repo(); @@ -17141,7 +16361,6 @@ fn list_snapshots_refresh_git_branch_from_agent_workspace() { .expect("current agent should still be listed"); assert_eq!(agent.git_branch.as_deref(), Some("feature/agent-new")); } - #[test] fn list_filtered_with_include_archived_returns_everything() { let mut manager = SubAgentManager::new(PathBuf::from("."), 5); @@ -17175,7 +16394,6 @@ fn list_filtered_with_include_archived_returns_everything() { .unwrap(); assert!(!current.from_prior_session); } - #[test] fn agents_with_empty_boot_id_classify_as_prior_session() { // Records persisted before #405 land with an empty `session_boot_id` @@ -17197,7 +16415,6 @@ fn agents_with_empty_boot_id_classify_as_prior_session() { .unwrap(); assert!(legacy.from_prior_session); } - #[test] fn persist_round_trip_preserves_session_and_boot_ownership() { let dir = tempdir().expect("tempdir"); @@ -17279,7 +16496,6 @@ fn persist_round_trip_preserves_session_and_boot_ownership() { // loop drains that channel; for nested agents the running parent sub-agent // owns a local receiver and injects the completion into its own transcript. // These tests cover the routing logic and no-channel safety. - fn runtime_with_depth( spawn_depth: u32, parent_completion_tx: Option>, @@ -17289,7 +16505,6 @@ fn runtime_with_depth( rt.parent_completion_tx = parent_completion_tx; rt } - #[test] fn emit_parent_completion_fires_for_direct_child() { let (tx, mut rx) = mpsc::unbounded_channel::(); @@ -17304,7 +16519,6 @@ fn emit_parent_completion_fires_for_direct_child() { assert_eq!(received.payload, "summary line\n"); assert!(rx.try_recv().is_err(), "should be exactly one message"); } - #[test] fn child_runtime_inherits_speech_output_dir() { let output_dir = PathBuf::from("configured-speech-output"); @@ -17318,7 +16532,6 @@ fn child_runtime_inherits_speech_output_dir() { Some(PathBuf::from("configured-speech-output")) ); } - #[test] fn emit_parent_completion_fires_for_nested_child() { let (tx, mut rx) = mpsc::unbounded_channel::(); @@ -17332,7 +16545,6 @@ fn emit_parent_completion_fires_for_nested_child() { assert_eq!(received.agent_id, "agent_grandchild"); assert_eq!(received.payload, "nested summary"); } - #[test] fn emit_parent_completion_skips_engine_self() { // depth 0 is the engine itself — the engine never spawns a task at @@ -17348,7 +16560,6 @@ fn emit_parent_completion_skips_engine_self() { ); assert!(rx.try_recv().is_err()); } - #[test] fn emit_parent_completion_no_channel_is_noop() { let runtime = runtime_with_depth(1, None); @@ -17360,7 +16571,6 @@ fn emit_parent_completion_no_channel_is_noop() { "missing channel should be a silent no-op, not a panic" ); } - #[test] fn emit_parent_completion_dropped_receiver_does_not_panic() { let (tx, rx) = mpsc::unbounded_channel::(); @@ -17377,7 +16587,6 @@ fn emit_parent_completion_dropped_receiver_does_not_panic() { "we still attempt the send; the engine being gone is not our problem" ); } - #[test] fn terminal_results_excluding_returns_only_current_root_undelivered_agents() { let tmp = tempdir().expect("tempdir"); @@ -17452,7 +16661,6 @@ fn terminal_results_excluding_returns_only_current_root_undelivered_agents() { let delivered = HashSet::from(["agent_root_done".to_string()]); assert!(manager.terminal_results_excluding(&delivered).is_empty()); } - #[tokio::test] async fn run_subagent_task_claims_before_delivery_and_then_finalizes() { let manager = Arc::new(RwLock::new(SubAgentManager::new(PathBuf::from("."), 2))); @@ -17530,7 +16738,6 @@ async fn run_subagent_task_claims_before_delivery_and_then_finalizes() { snapshot.status ); } - #[tokio::test] async fn cancellation_wins_task_race_but_still_fans_in_exactly_once() { use tokio_util::sync::CancellationToken; @@ -17652,7 +16859,6 @@ async fn cancellation_wins_task_race_but_still_fans_in_exactly_once() { if id == &snapshot.agent_id && result.contains(r#""status":"cancelled""#) )); } - /// Call 1 answers with a tool call (so the child banks a real step); /// every later call fails with a non-retryable 400. async fn tool_call_then_invalid_request_chat_client() -> (DeepSeekClient, Arc) { @@ -17731,7 +16937,6 @@ async fn tool_call_then_invalid_request_chat_client() -> (DeepSeekClient, Arc(); @@ -18087,7 +17287,6 @@ fn nested_tool_runtime_routes_child_completions_to_local_inbox() { "root engine must not receive nested child completion directly" ); } - #[test] fn subagent_completion_from_result_surfaces_step_limit_not_silent_success() { let snap = make_snapshot(SubAgentStatus::Failed( @@ -18101,7 +17300,6 @@ fn subagent_completion_from_result_surfaces_step_limit_not_silent_success() { assert!(completion.payload.contains("max_steps"), "{completion:?}"); assert!(!completion.payload.contains("Completed (no output)")); } - #[test] fn subagent_completion_from_result_preserves_missing_final_summary_diagnostic() { let snap = make_snapshot(SubAgentStatus::Completed); @@ -18111,7 +17309,6 @@ fn subagent_completion_from_result_preserves_missing_final_summary_diagnostic() "{completion:?}" ); } - #[test] fn subagent_budget_exhaustion_completion_carries_budget_exhausted_sentinel() { let mut snap = make_snapshot(SubAgentStatus::BudgetExhausted); @@ -18134,7 +17331,6 @@ fn subagent_budget_exhaustion_completion_carries_budget_exhausted_sentinel() { assert_eq!(parsed["failure_class"], "token_budget"); assert_eq!(parsed["error_location"], "previous_line"); } - #[test] fn subagent_completion_inlines_evidence_before_sentinel() { let mut snap = make_snapshot(SubAgentStatus::Completed); @@ -18156,7 +17352,6 @@ fn subagent_completion_inlines_evidence_before_sentinel() { "summary before evidence" ); } - #[test] fn subagent_completion_skips_empty_evidence_on_failed_child() { let mut snap = make_snapshot(SubAgentStatus::Failed("boom".to_string())); @@ -18164,7 +17359,6 @@ fn subagent_completion_skips_empty_evidence_on_failed_child() { let completion = subagent_completion_from_result(&snap); assert!(!completion.payload.contains("### EVIDENCE")); } - #[test] fn child_completion_runtime_message_preserves_agent_and_provenance_guidance() { let message = child_completion_runtime_message(&[SubAgentCompletion { @@ -18182,7 +17376,6 @@ fn child_completion_runtime_message_preserves_agent_and_provenance_guidance() { assert!(text.contains("cite the child agent_id and the EVIDENCE lines")); assert!(text.contains("src/lib.rs:1-3")); } - #[test] fn subagent_runtime_default_step_api_timeout_matches_config_default() { // The runtime default is derived from the config default so call sites @@ -18199,13 +17392,11 @@ fn subagent_runtime_default_step_api_timeout_matches_config_default() { std::time::Duration::from_secs(600) ); } - #[test] fn with_step_api_timeout_overrides_runtime_field() { let runtime = stub_runtime().with_step_api_timeout(std::time::Duration::from_secs(900)); assert_eq!(runtime.step_api_timeout.as_secs(), 900); } - #[test] fn tool_timeout_defaults_to_generous_budget_and_survives_spawn() { // Track A raised the per-tool timeout from the old 30s (which killed long @@ -18220,7 +17411,6 @@ fn tool_timeout_defaults_to_generous_budget_and_survives_spawn() { assert_eq!(parent.child_runtime().tool_timeout, expected); assert_eq!(parent.background_runtime().tool_timeout, expected); } - #[test] fn child_runtime_preserves_step_api_timeout() { // Real sub-agents spawn through `child_runtime()` / `background_runtime()`; @@ -18241,7 +17431,6 @@ fn child_runtime_preserves_step_api_timeout() { "background_runtime (detached) must also preserve the parent's timeout" ); } - #[test] fn subagent_completion_payload_carries_existing_sentinel_format() { // The payload format is the same one already documented in @@ -18277,7 +17466,6 @@ fn subagent_completion_payload_carries_existing_sentinel_format() { "sentinel should not duplicate the human summary line" ); } - /// #2683 — Verify the model-facing tool catalog only advertises canonical /// subagent tools and never exposes legacy superseded names. #[test] @@ -18309,7 +17497,6 @@ fn model_catalog_only_advertises_canonical_subagent_tools() { } // ── #3018: provider-aware auto routing and model validation ───────────────── - #[tokio::test] async fn faster_route_on_provider_without_known_sibling_stays_on_parent_model() { // AC: Ollama must never build a request with a DeepSeek id; even when the @@ -18335,7 +17522,6 @@ async fn faster_route_on_provider_without_known_sibling_stays_on_parent_model() ); } } - #[test] fn faster_route_uses_known_deepseek_and_glm_family_siblings() { let mut deepseek = stub_runtime(); @@ -18375,7 +17561,6 @@ fn faster_route_uses_known_deepseek_and_glm_family_siblings() { assert_eq!(route.model, "z-ai/glm-5-turbo"); assert_ne!(route.model, "z-ai/glm-5.1"); } - #[test] fn inherit_route_remaps_stale_deepseek_model_for_sakana_provider() { let mut runtime = stub_runtime_for_provider("sakana"); @@ -18398,7 +17583,6 @@ fn inherit_route_remaps_stale_deepseek_model_for_sakana_provider() { "Sakana inherit must not keep DeepSeek ids: {validated}" ); } - #[test] fn faster_route_remaps_stale_deepseek_model_for_sakana_provider() { let mut runtime = stub_runtime_for_provider("sakana"); @@ -18415,7 +17599,6 @@ fn faster_route_remaps_stale_deepseek_model_for_sakana_provider() { .expect("faster should remap to operator route"); assert_eq!(validated, crate::config::DEFAULT_SAKANA_MODEL); } - #[test] fn fixed_route_rejects_deepseek_model_for_sakana_provider() { let runtime = stub_runtime_for_provider("sakana"); @@ -18430,7 +17613,6 @@ fn fixed_route_rejects_deepseek_model_for_sakana_provider() { "error should name the model: {err}" ); } - #[test] fn normalize_requested_subagent_model_rejects_cross_namespace_for_sakana() { let err = normalize_requested_subagent_model( @@ -18444,7 +17626,6 @@ fn normalize_requested_subagent_model_rejects_cross_namespace_for_sakana() { "error should name the model: {err}" ); } - #[test] fn gpt55_faster_route_stays_on_gpt55_with_low_reasoning() { // AC: a faster/explore child of a GPT-5.5 (OpenAI Codex) parent must stay @@ -18488,7 +17669,6 @@ fn gpt55_faster_route_stays_on_gpt55_with_low_reasoning() { assert_eq!(route.reasoning_effort.as_deref(), Some("low")); assert_ne!(route.reasoning_effort.as_deref(), Some("off")); } - #[test] fn role_model_validation_accepts_provider_native_ids() { // AC: [subagents] worker_model = "kimi-k2.5" on Moonshot must not fail @@ -18502,7 +17682,6 @@ fn role_model_validation_accepts_provider_native_ids() { .expect("provider-native id is accepted"); assert_eq!(model.as_deref(), Some("kimi-k2.5")); } - #[test] fn consultant_reads_released_advisory_role_model_override_keys() { for legacy_key in ["oracle", "consultant"] { @@ -18516,7 +17695,6 @@ fn consultant_reads_released_advisory_role_model_override_keys() { assert_eq!(model.as_deref(), Some("deepseek-v4-flash"), "{legacy_key}"); } } - #[test] fn canonical_consultant_model_override_precedes_compatibility_keys() { let mut runtime = stub_runtime(); @@ -18534,7 +17712,6 @@ fn canonical_consultant_model_override_precedes_compatibility_keys() { .expect("canonical consultant override should resolve"); assert_eq!(model.as_deref(), Some("deepseek-v4-pro")); } - #[test] fn raw_advisory_role_prefers_canonical_consultant_model_override() { for alias in ["oracle", "consultant"] { @@ -18552,7 +17729,6 @@ fn raw_advisory_role_prefers_canonical_consultant_model_override() { assert_eq!(model.as_deref(), Some("deepseek-v4-pro"), "alias={alias}"); } } - #[test] fn role_model_validation_stays_strict_on_official_deepseek() { let mut runtime = stub_runtime(); @@ -18569,7 +17745,6 @@ fn role_model_validation_stays_strict_on_official_deepseek() { "lists accepted ids from model_completion_names_for_provider: {msg}" ); } - #[test] fn operator_model_for_subagent_enumerates_from_catalog_facade() { // #4116: the operator-route fallback must source its model from the @@ -18614,7 +17789,6 @@ fn operator_model_for_subagent_enumerates_from_catalog_facade() { ); } } - #[test] fn normalize_requested_subagent_model_is_provider_aware() { assert_eq!( @@ -18647,18 +17821,15 @@ fn normalize_requested_subagent_model_is_provider_aware() { } // ── #3030: step-counter formatting ────────────────────────────────────────── - #[test] fn format_step_counter_hides_unbounded_sentinel() { assert_eq!(format_step_counter(16, 0), "step 16"); } - #[test] fn format_step_counter_keeps_concrete_budgets() { assert_eq!(format_step_counter(3, 25), "step 3/25"); assert_eq!(format_step_counter(0, 1), "step 0/1"); } - #[test] fn child_step_override_wins_and_clamps_to_hard_ceiling() { assert_eq!(resolve_max_steps(FleetRole::Scout, None, None), 0); @@ -18678,7 +17849,6 @@ fn child_step_override_wins_and_clamps_to_hard_ceiling() { MAX_SUBAGENT_STEPS ); } - #[test] fn child_wall_timeout_reason_is_typed_and_actionable() { let reason = child_wall_time_exhausted_reason(Duration::from_millis(1)); @@ -18690,7 +17860,6 @@ fn child_wall_timeout_reason_is_typed_and_actionable() { } // ── #3095: sub-agent launch gate ───────────────────────────────────────────── - #[test] fn launch_gate_defaults_to_launch_concurrency_capped_by_max_agents() { let tmp = tempdir().expect("tempdir"); @@ -18711,7 +17880,6 @@ fn launch_gate_defaults_to_launch_concurrency_capped_by_max_agents() { "clamps down to max_agents" ); } - #[tokio::test] async fn launch_gate_queues_extra_direct_children() { use tokio_util::sync::CancellationToken; @@ -18859,7 +18027,6 @@ async fn launch_gate_queues_extra_direct_children() { "queued child must start only after queuing, then complete: {messages:?}" ); } - #[tokio::test] async fn queued_turn_owned_child_parks_without_a_false_start_transition() { use tokio_util::sync::CancellationToken; @@ -19012,7 +18179,6 @@ async fn queued_turn_owned_child_parks_without_a_false_start_transition() { drop(manager); drop(held_launch_permit); } - #[tokio::test] async fn launch_gate_wait_counts_against_child_wall_timeout() { use tokio_util::sync::CancellationToken; @@ -19138,7 +18304,6 @@ async fn launch_gate_wait_counts_against_child_wall_timeout() { drop(manager); drop(held_launch_permit); } - /// Stub chat server that always replies with a final assistant text whose /// `usage` reports the given token counts. Returns the client plus a call /// counter so tests can assert how many model turns ran before a budget cap @@ -19199,7 +18364,6 @@ async fn token_heavy_chat_client( let client = DeepSeekClient::new(&config).expect("fake chat client"); (client, calls) } - /// First response carries partial text plus a tool call and the requested /// incomplete/output-limit reason. A second request, when the runtime is /// allowed to retry, completes normally. @@ -19275,7 +18439,6 @@ async fn incomplete_then_complete_chat_client( let client = DeepSeekClient::new(&config).expect("fake incomplete-response client"); (client, calls) } - async fn run_incomplete_response_worker( workspace: &Path, stop_reason: &str, @@ -19359,7 +18522,6 @@ async fn run_incomplete_response_worker( }; (result, calls, mailbox_messages, total_tokens) } - fn assert_partial_tool_was_not_executed(messages: &[MailboxMessage]) { assert!( !messages @@ -19368,7 +18530,6 @@ fn assert_partial_tool_was_not_executed(messages: &[MailboxMessage]) { "an incomplete response must never dispatch its partial tool call: {messages:?}" ); } - #[tokio::test] async fn output_limit_on_last_step_preserves_partial_text_and_exact_cause() { let tmp = tempdir().expect("tempdir"); @@ -19403,7 +18564,6 @@ async fn output_limit_on_last_step_preserves_partial_text_and_exact_cause() { "the partial assistant text must remain in the transcript checkpoint" ); } - #[tokio::test] async fn output_limit_cause_wins_over_generic_token_budget() { let tmp = tempdir().expect("tempdir"); @@ -19427,7 +18587,6 @@ async fn output_limit_cause_wins_over_generic_token_budget() { assert_eq!(total_tokens, Some(15), "usage must still be recorded"); assert_partial_tool_was_not_executed(&mailbox); } - #[tokio::test] async fn non_output_incomplete_cause_wins_over_generic_token_budget() { let tmp = tempdir().expect("tempdir"); @@ -19448,7 +18607,6 @@ async fn non_output_incomplete_cause_wins_over_generic_token_budget() { assert_eq!(total_tokens, Some(15), "usage must still be recorded"); assert_partial_tool_was_not_executed(&mailbox); } - #[tokio::test] async fn output_limit_never_retries_or_executes_partial_tools() { let tmp = tempdir().expect("tempdir"); @@ -19468,7 +18626,6 @@ async fn output_limit_never_retries_or_executes_partial_tools() { assert_eq!(total_tokens, Some(15)); assert_partial_tool_was_not_executed(&mailbox); } - /// Shared scaffolding for the per-worker token-budget runtime tests: spins up /// a general worker against `token_heavy_chat_client` with the given cap and /// returns the manager, agent id, call counter, and spawned task handle. @@ -19536,7 +18693,6 @@ async fn spawn_budget_capped_worker( let task_handle = tokio::spawn(run_subagent_task(task)); (manager, agent_id, calls, task_handle) } - #[tokio::test] async fn worker_stops_with_typed_wall_time_reason() { let tmp = tempdir().expect("tempdir"); @@ -19562,7 +18718,6 @@ async fn worker_stops_with_typed_wall_time_reason() { other => panic!("expected typed wall-time failure, got {other:?}"), } } - #[tokio::test] async fn worker_stops_when_per_worker_token_budget_exceeded() { let tmp = tempdir().expect("tempdir"); @@ -19593,7 +18748,6 @@ async fn worker_stops_when_per_worker_token_budget_exceeded() { result.status ); } - #[tokio::test] async fn worker_without_per_worker_token_budget_runs_to_completion() { let tmp = tempdir().expect("tempdir"); @@ -19619,7 +18773,6 @@ async fn worker_without_per_worker_token_budget_runs_to_completion() { result.status ); } - #[tokio::test] async fn per_worker_token_budget_does_not_double_count_scope_accounting() { let tmp = tempdir().expect("tempdir"); @@ -19657,7 +18810,6 @@ async fn per_worker_token_budget_does_not_double_count_scope_accounting() { worker_record.usage ); } - /// Variant of [`spawn_budget_capped_worker`] that attaches the worker to a /// shared workflow budget scope before its first model turn (no per-worker /// cap), returning the manager, agent id, call counter, and task handle. @@ -19723,7 +18875,6 @@ async fn spawn_scope_budgeted_worker( let task_handle = tokio::spawn(run_subagent_task(task)); (calls, task_handle) } - #[tokio::test] async fn shared_scope_budget_stops_admitted_children_mid_run() { // A workflow run's token_budget must be a collective ceiling for the @@ -19809,7 +18960,6 @@ async fn shared_scope_budget_stops_admitted_children_mid_run() { "collective spend is accounted once per child" ); } - /// Clears the process-wide rate-limit window on drop so a panicking test /// body cannot leak a live pause into concurrently running tests. struct ClearRateLimitOnDrop; @@ -19819,7 +18969,6 @@ impl Drop for ClearRateLimitOnDrop { crate::retry_status::clear_rate_limit(); } } - #[tokio::test] #[allow(clippy::await_holding_lock)] async fn worker_is_not_stranded_by_transient_global_rate_limit_window() { @@ -19860,7 +19009,6 @@ async fn worker_is_not_stranded_by_transient_global_rate_limit_window() { result.status ); } - /// #4217: terminal worker records must age out of the persisted ledger so /// long-lived sessions do not rewrite multi-MB `subagents.v1.json` forever. #[test] @@ -19942,7 +19090,6 @@ fn cleanup_evicts_stale_terminal_worker_records_and_keeps_live_ones() { assert!(reloaded.get_worker_record("agent_recent_done").is_some()); assert!(reloaded.get_worker_record("agent_still_running").is_some()); } - #[test] fn cleanup_removes_complete_transcript_after_worker_retention_expires() { let tmp = tempdir().expect("tempdir"); @@ -19971,7 +19118,6 @@ fn cleanup_removes_complete_transcript_after_worker_retention_expires() { "artifact must share the terminal worker retention lifecycle" ); } - #[test] fn cleanup_due_gates_write_locked_cleanup_to_a_bounded_cadence() { // #3803: a fresh manager is always due (never cleaned); right after a @@ -19998,7 +19144,6 @@ fn cleanup_due_gates_write_locked_cleanup_to_a_bounded_cadence() { } // ── #3882: bounded sub-agent output under Fleet fanout ───────────────────── - /// Serialize-and-restore guard for the shared spillover test root, mirroring /// the pattern in `tools::truncate::tests`. fn with_spillover_root(root: &std::path::Path, f: F) { @@ -20021,7 +19166,6 @@ fn with_spillover_root(root: &std::path::Path, f: F) { let _restore = Restore(prior, prior_artifacts); f(); } - #[test] fn bounded_tail_messages_keeps_recent_within_budget_and_counts_omitted() { let messages: Vec = (0..10) @@ -20045,7 +19189,6 @@ fn bounded_tail_messages_keeps_recent_within_budget_and_counts_omitted() { "kept tail exceeds budget by more than one message: {total}" ); } - #[test] fn bounded_tail_messages_always_keeps_the_final_message() { let messages = vec![ @@ -20063,7 +19206,6 @@ fn bounded_tail_messages_always_keeps_the_final_message() { assert_eq!(omitted, 1); assert!(message_text(&kept[0]).starts_with('b')); } - #[tokio::test] async fn complete_transcript_artifact_survives_resident_handle_compaction() { let tmp = tempdir().expect("tempdir"); @@ -20135,7 +19277,6 @@ async fn complete_transcript_artifact_survives_resident_handle_compaction() { ); } } - #[test] fn malformed_transcript_artifact_fails_closed_instead_of_showing_partial_chat() { let tmp = tempdir().expect("tempdir"); @@ -20157,7 +19298,6 @@ fn malformed_transcript_artifact_fails_closed_instead_of_showing_partial_chat() .expect_err("a malformed stream must not masquerade as a complete chat"); assert!(error.to_string().contains("line"), "{error:#}"); } - #[cfg(unix)] #[test] fn transcript_artifact_reader_rejects_symlink_replacement() { @@ -20180,7 +19320,6 @@ fn transcript_artifact_reader_rejects_symlink_replacement() { .expect_err("transcript reader must reject symlink replacement"); assert!(error.to_string().contains("must not traverse symlinks")); } - #[test] fn checkpoints_are_byte_bounded_under_fanout_scale_output() { // Simulates the #3882 report shape: a worker whose tool results are @@ -20211,7 +19350,6 @@ fn checkpoints_are_byte_bounded_under_fanout_scale_output() { serialized.len() ); } - #[test] fn checkpoint_without_omitted_field_still_deserializes() { // Records persisted before v0.8.67 carry no omitted_messages key. @@ -20229,7 +19367,6 @@ fn checkpoint_without_omitted_field_still_deserializes() { serde_json::from_str(legacy).expect("legacy checkpoint should load"); assert_eq!(checkpoint.omitted_messages, 0); } - #[test] fn forkguard_subagent_classic_hint_names_the_actual_storage_call() { use crate::tools::large_output_router::WorkshopConfig; @@ -20396,7 +19533,6 @@ fn subagent_tool_results_spill_to_disk_and_stay_bounded_inline() { ); }); } - #[test] fn fanout_of_workers_with_huge_outputs_keeps_resident_state_bounded() { // Acceptance shape for #3882: multiple workers, each emitting multi-MB @@ -20445,7 +19581,6 @@ fn fanout_of_workers_with_huge_outputs_keeps_resident_state_bounded() { ); }); } - #[test] fn write_json_atomic_survives_concurrent_writers() { use std::sync::Arc; @@ -20486,7 +19621,6 @@ fn write_json_atomic_survives_concurrent_writers() { .collect(); assert!(leftover.is_empty(), "temp files leaked: {leftover:?}"); } - #[test] fn coordination_process_lock_rejects_second_process() { const ROLE_ENV: &str = "CODEWHALE_TEST_COORDINATION_LOCK_ROLE"; @@ -20579,7 +19713,6 @@ fn coordination_process_lock_rejects_second_process() { } // === agent(action="wait") + peek throttling (#4097) === - fn insert_running_agent(inner: &mut SubAgentManager, name: &str) -> String { let current_boot = inner.session_boot_id().to_string(); let (input_tx, _input_rx) = mpsc::unbounded_channel(); @@ -20603,7 +19736,6 @@ fn insert_running_agent(inner: &mut SubAgentManager, name: &str) -> String { inner.agents.insert(agent_id.clone(), agent); agent_id } - #[tokio::test] async fn agent_wait_returns_immediately_with_no_children() { let manager = Arc::new(RwLock::new(SubAgentManager::new(PathBuf::from("."), 1))); @@ -20621,7 +19753,6 @@ async fn agent_wait_returns_immediately_with_no_children() { .is_empty() ); } - #[tokio::test] async fn agent_wait_wakes_when_child_settles() { let mut inner = SubAgentManager::new(PathBuf::from("."), 1); @@ -20659,7 +19790,6 @@ async fn agent_wait_wakes_when_child_settles() { assert_eq!(settled[0]["status"], json!("completed")); assert_eq!(payload["timed_out"], json!(false)); } - #[tokio::test] async fn agent_wait_times_out_and_reports_running_child() { let mut inner = SubAgentManager::new(PathBuf::from("."), 1); @@ -20685,7 +19815,6 @@ async fn agent_wait_times_out_and_reports_running_child() { .is_empty() ); } - #[tokio::test] async fn agent_wait_rejects_unknown_agent_ref() { let manager = Arc::new(RwLock::new(SubAgentManager::new(PathBuf::from("."), 1))); @@ -20699,7 +19828,6 @@ async fn agent_wait_rejects_unknown_agent_ref() { .expect_err("unknown agent ref must fail fast instead of blocking"); assert!(matches!(err, ToolError::InvalidInput { .. })); } - #[tokio::test] async fn agent_peek_unchanged_within_window_returns_compact_nudge() { let mut inner = SubAgentManager::new(PathBuf::from("."), 1); @@ -20734,7 +19862,6 @@ async fn agent_peek_unchanged_within_window_returns_compact_nudge() { "nudge should point at agent(action=wait)" ); } - #[test] fn agent_action_parses_wait_aliases() { for alias in ["wait", "join", "await", "block"] { @@ -20759,7 +19886,6 @@ fn agent_action_parses_wait_aliases() { // Deny always wins over allow. Wildcards (`prefix*`) and case-insensitive // matching mirror the session-side `command_denies_tool()`. // =========================================================================== - /// Build a stub runtime with the parent's `disallowed_tools` set on the /// `WorkerRuntimeProfile`. The registry reads deny lists from the profile at /// construction, and `child_runtime()` clones the profile so the list @@ -20769,7 +19895,6 @@ fn stub_runtime_with_disallowed(disallowed: Vec) -> SubAgentRuntime { rt.worker_profile.denied_tools = disallowed; rt } - /// Build a `SubAgentToolRegistry` wired with `disallowed_tools`. Passes the /// runtime through `SubAgentToolRegistry::new()` so the constructor picks up /// `worker_profile.denied_tools`. `allowed_tools` is forwarded directly. @@ -20785,7 +19910,6 @@ fn new_registry_with_disallowed( Arc::new(Mutex::new(PlanState::default())), ) } - #[test] fn test_disallowed_tools_inheritance_denies_tool() { let tmp = tempdir().expect("tempdir"); @@ -20825,7 +19949,6 @@ fn test_disallowed_tools_inheritance_denies_tool() { "the lowercase edit primitive survives: only write_file was denied" ); } - #[test] fn test_disallowed_tools_deny_wins_over_allow() { let tmp = tempdir().expect("tempdir"); @@ -20858,7 +19981,6 @@ fn test_disallowed_tools_deny_wins_over_allow() { ); assert!(!names.contains("bash"), "denied shell alias removes bash"); } - #[test] fn test_disallowed_tools_wildcard_matching() { let tmp = tempdir().expect("tempdir"); @@ -20879,7 +20001,6 @@ fn test_disallowed_tools_wildcard_matching() { "non-MCP tools are unaffected by mcp_* deny" ); } - #[test] fn test_disallowed_tools_case_insensitive_match() { let tmp = tempdir().expect("tempdir"); @@ -20900,7 +20021,6 @@ fn test_disallowed_tools_case_insensitive_match() { "unrelated tool unaffected" ); } - #[test] fn test_disallowed_tools_specific_server_wildcard() { let tmp = tempdir().expect("tempdir"); @@ -20917,7 +20037,6 @@ fn test_disallowed_tools_specific_server_wildcard() { "different server prefix is not denied" ); } - #[test] fn test_disallowed_tools_tools_for_model_excludes_denied() { let tmp = tempdir().expect("tempdir"); @@ -20948,7 +20067,6 @@ fn test_disallowed_tools_tools_for_model_excludes_denied() { "the lowercase edit primitive survives: only write_file was denied" ); } - #[tokio::test] async fn test_disallowed_tools_execute_rejects_denied_tool() { let tmp = tempdir().expect("tempdir"); @@ -20972,7 +20090,6 @@ async fn test_disallowed_tools_execute_rejects_denied_tool() { } // === deny-list propagation through runtime cloning === - #[test] fn test_disallowed_tools_propagates_through_child_runtime() { let runtime = stub_runtime_with_disallowed(vec!["exec_shell".to_string()]); @@ -20983,7 +20100,6 @@ fn test_disallowed_tools_propagates_through_child_runtime() { "child_runtime() must preserve parent's denied_tools" ); } - #[test] fn test_disallowed_tools_propagates_through_background_runtime() { let runtime = stub_runtime_with_disallowed(vec!["write_file".to_string()]); @@ -20994,7 +20110,6 @@ fn test_disallowed_tools_propagates_through_background_runtime() { "background_runtime() must preserve parent's denied_tools" ); } - #[test] fn test_disallowed_tools_across_two_generations() { let tmp = tempdir().expect("tempdir"); @@ -21022,7 +20137,6 @@ fn test_disallowed_tools_across_two_generations() { } // === spawn-path opt-out simulation === - #[test] fn test_disallowed_tools_opt_out_clears_inherited_denies() { // Simulate the spawn-path merge: parent runtime has denies, child sets @@ -21051,7 +20165,6 @@ fn test_disallowed_tools_opt_out_clears_inherited_denies() { ); assert!(registry.is_tool_allowed("read_file")); } - #[test] fn test_disallowed_tools_opt_out_keeps_explicit_caller_deny() { // Opt-out clears inherited denies, but explicit caller disallowed_tools @@ -21085,7 +20198,6 @@ fn test_disallowed_tools_opt_out_keeps_explicit_caller_deny() { } // === parse_spawn_request disallowed_tools === - #[test] fn test_parse_spawn_request_reads_disallowed_tools() { let input = json!({ @@ -21098,7 +20210,6 @@ fn test_parse_spawn_request_reads_disallowed_tools() { Some(vec!["exec_shell".to_string(), "write_file".to_string()]) ); } - #[test] fn test_parse_spawn_request_disallowed_tools_dedupes_and_trims() { let input = json!({ @@ -21112,7 +20223,6 @@ fn test_parse_spawn_request_disallowed_tools_dedupes_and_trims() { "blanks and duplicates are dropped" ); } - #[test] fn test_parse_spawn_request_disallowed_tools_defaults_to_none() { let input = json!({"prompt": "do something"}); @@ -21122,7 +20232,6 @@ fn test_parse_spawn_request_disallowed_tools_defaults_to_none() { "disallowed_tools should be None when not provided" ); } - #[test] fn test_parse_spawn_request_inherit_disallowed_tools_defaults_true() { let input = json!({"prompt": "do something"}); @@ -21132,7 +20241,6 @@ fn test_parse_spawn_request_inherit_disallowed_tools_defaults_true() { "inherit_disallowed_tools should default to true" ); } - #[test] fn test_parse_spawn_request_inherit_disallowed_tools_explicit_false() { let input = json!({ @@ -21145,7 +20253,6 @@ fn test_parse_spawn_request_inherit_disallowed_tools_explicit_false() { "inherit_disallowed_tools should parse an explicit false" ); } - /// #3874 acceptance: the no-progress heartbeat must not kill a worker whose /// only pending work is a tracked *running* background shell task. /// @@ -21264,7 +20371,6 @@ async fn tracked_running_background_shell_keeps_its_owner_off_the_heartbeat_reap .expect("live task handle") .abort(); } - /// #4810: a child publishes its *own* list, only when it actually changes. #[tokio::test] async fn child_work_state_publishes_only_real_changes_from_its_own_list() { @@ -21336,7 +20442,6 @@ async fn child_work_state_publishes_only_real_changes_from_its_own_list() { // These assert on the registry the child actually runs with, not on the // `ChildAuthority` value that produced it. A ceiling that is only a label on a // receipt is not a ceiling. - /// `tools = false` must leave the child with **zero** model tools. The empty /// allowlist is the mechanism; this is the proof it lands. #[test] @@ -21372,7 +20477,6 @@ fn an_exact_member_with_tools_false_gets_no_model_tools_at_all() { ); } } - /// `network_tool = false` removes every model-visible network, browser, and /// remote-MCP surface — even though `tools = true` — while keeping exactly the /// `Web` family's two read-only actions (`search`/`fetch`), and refusing a @@ -21504,7 +20608,6 @@ async fn an_exact_member_without_a_network_tool_really_loses_the_network_surface "the standalone browse tool stays denied by name" ); } - /// A Runtime scout under a network-denied parent gets exactly the bounded web /// surface — `Web` with /// `search`/`fetch` — while every reaching spelling (`web.run`, `fetch_url`, @@ -21619,7 +20722,6 @@ async fn a_read_only_inspection_member_gets_only_bounded_web_search() { "full member keeps the whole Web enum" ); } - /// The unified `rlm` tool routes to the same code as the legacy aliases through /// an `action` parameter. A deny list that stops at the alias names leaves /// `rlm{action:"open", url:...}` callable — the whole reach by another spelling. @@ -21676,7 +20778,6 @@ fn the_unified_rlm_action_cannot_bypass_a_denied_alias() { "the compatibility RLM family must stay hidden from new child turns" ); } - /// The generic layer: a network-denied child that reaches a remote address /// through *any* tool's URL-bearing field is refused at the execution seam, even /// when the tool itself is local and permitted. This is what catches the next @@ -21756,7 +20857,6 @@ fn a_network_denied_child_cannot_address_a_remote_location_through_any_tool() { ); } } - /// A read-only member keeps `Run.verifiers` so it can do its job, but the /// `commands` array spawns arbitrary programs — `bash -lc 'rm -rf src'` is the /// raw shell the deny list just removed, re-entered through the door left open @@ -21811,7 +20911,6 @@ fn a_read_only_member_cannot_smuggle_commands_through_the_verifier_surface() { ); } } - /// The shipped `verifier` role is `write = false, shell = "full"`, and running /// the suite is its documented job. A test *selection* must survive at the same /// guard that refuses a command line — otherwise the indirect-execution fix has @@ -21872,7 +20971,6 @@ fn a_shell_capable_read_only_member_keeps_test_selection_arguments() { ); } } - /// The parent posture wins. A saved `full` member inside a read-only, /// no-network session runs with the session's ceiling, in the real registry. #[tokio::test] @@ -21946,7 +21044,6 @@ async fn a_parent_read_only_session_narrows_a_full_exact_member_in_the_child_reg "the URL-input guard stays deny-closed inside a read-only session" ); } - /// The session ceiling is read off the live parent runtime, so a Fleet can /// never widen what the operator is currently allowed to do. #[test] @@ -21989,9 +21086,17 @@ fn the_session_ceiling_reflects_the_live_parent_posture() { // half-contract: visibility without dispatch means a model that guesses the // name still wins, and dispatch without visibility means the model is offered a // capability it will be refused for using. - /// The child registry a Runtime verifier under a full parent actually runs with. fn read_only_with_shell_registry() -> (tempfile::TempDir, SubAgentToolRegistry) { + read_only_with_shell_registry_auto_approving(false) +} + +/// The carve-out fixture with approvals auto-accepted, for tests that drive +/// the real spawn path (a write-capable `agent` start is otherwise held for +/// approval and never reaches `run_subagent`). +fn read_only_with_shell_registry_auto_approving( + auto_approve: bool, +) -> (tempfile::TempDir, SubAgentToolRegistry) { let tmp = tempdir().expect("tempdir"); let authority = crate::fleet::role::ChildAuthority::from_runtime_role( "verifier", @@ -22010,6 +21115,7 @@ fn read_only_with_shell_registry() -> (tempfile::TempDir, SubAgentToolRegistry) let mut runtime = stub_runtime().with_agent_tool_surface_options(enabled_agent_surface_options()); runtime.context = ToolContext::new(tmp.path().to_path_buf()); + runtime.context.auto_approve = auto_approve; runtime.allow_shell = true; // The posture reaches the child through the Runtime authority envelope. runtime.worker_profile = WorkerRuntimeProfile::for_role(FleetRole::Verifier); @@ -22024,7 +21130,6 @@ fn read_only_with_shell_registry() -> (tempfile::TempDir, SubAgentToolRegistry) ); (tmp, registry) } - /// Adversarial payloads — raw mutation, deletion, network reach, and scheduled /// execution — refused at the real dispatch guard. #[test] @@ -22056,7 +21161,6 @@ fn indirect_execution_payloads_are_refused_at_dispatch() { ); } } - /// The verifier's job, in the registry it actually runs with: a bounded test /// selection passes the real dispatch guard, and everything that could name a /// program or touch the workspace does not. @@ -22109,7 +21213,6 @@ fn a_verifier_runs_bounded_test_selections_and_nothing_else_at_dispatch() { ); } } - /// Catalog and dispatch must agree: the verification surface the guard admits /// is offered, and the legacy raw-shell spellings it refuses are absent (the /// verifier's shell surface is the bounded canonical `bash` tool pinned by @@ -22131,7 +21234,6 @@ fn the_verifier_catalog_offers_run_without_the_legacy_raw_shell_spellings() { ); } } - /// The refusal reaches the *real* `execute` path, not only the classifier: the /// envelope check sits behind the allowlist, posture, and approval gates, and a /// guard that is never reached is not a guard. @@ -22156,7 +21258,6 @@ async fn a_verifier_is_refused_arbitrary_execution_at_the_real_execute_boundary( ); } } - /// The same payloads must never be *offered*. A model that can see a tool will /// try it, and a refusal is a worse experience than an absent capability. #[test] @@ -22210,7 +21311,6 @@ fn indirect_execution_actions_are_pruned_from_the_model_catalog() { ); } } - /// The bounded positives. This is the half that makes the contract honest: the /// point of `shell = "full"` on a read-only member is that it can still run the /// checks, and durable-task bookkeeping is exactly what such a member is for. @@ -22253,7 +21353,6 @@ fn bounded_read_only_and_verification_paths_survive_the_ceiling() { "durable-task bookkeeping must stay visible; got {actions:?}" ); } - /// A write-capable Runtime builder is unaffected. The guard narrows authority; /// it is not a new global restriction. #[test] @@ -22293,7 +21392,6 @@ fn a_write_capable_member_keeps_every_execution_gate() { ); } } - /// The unified-family bypass, for the durable-work families this time: a deny /// list naming `task_gate_run` has to reach `tasks{action:"gate_run"}`, or the /// canonical name is simply a second spelling that nothing checks. @@ -22331,7 +21429,6 @@ fn the_durable_work_families_resolve_their_actions_through_the_policy_seam() { } // ── A child may never widen its parent's envelope ─────────────────────────── - /// `inherit_disallowed_tools: false` is an escape hatch for *preference*, not /// for a ceiling. A Fleet member clamped to `network_tool = false` that spawns a /// grandchild asking for a clean surface must not hand it the network back. @@ -22382,7 +21479,6 @@ fn posture_denials_survive_a_child_that_declines_to_inherit() { } // ── The launch authority must reach the runtime, or the spawn fails ───────── - /// The fingerprint is the value the spawn boundary checks. It has to change /// whenever anything the child is constructed from changes, or checking it /// proves nothing. @@ -22420,7 +21516,6 @@ fn the_authority_fingerprint_distinguishes_every_envelope_it_names() { ); assert_ne!(narrow.fingerprint(), fingerprint("builder")); } - /// The spawn boundary itself. A missing, unparseable, or mismatched fingerprint /// must refuse the launch — a Runtime envelope that does not reach the child /// is not enforced authority. @@ -22482,7 +21577,6 @@ fn the_spawn_boundary_fails_closed_on_a_missing_or_mismatched_authority() { ); } } - /// End to end for the value that used to be computed and never read: the /// authority a launch resolves is the one whose fingerprint rides the receipt, /// and that fingerprint is what the spawn boundary accepts. @@ -22518,7 +21612,6 @@ fn the_launched_authority_is_the_one_the_spawn_boundary_accepts() { "one member's receipt must not authorize another member's child" ); } - /// R6 injection-size regressions (finish-operator 2026-08-02): build — never /// send — the real assembled payloads and pin them to measured-current +10%. /// Growth past the ceiling must be a deliberate, reviewed act, not drift. @@ -22532,7 +21625,6 @@ const READ_ONLY_CHILD_ENVELOPE_BYTE_CEILING: usize = 89_000; /// prompt-only start, review/tasks/verify/web/search/github/goal/terminal/ /// git/ocr/extract truthful-limit disclosures); +10%. const PARENT_SURFACE_BYTE_CEILING: usize = 90_000; - #[tokio::test] async fn read_only_child_envelope_stays_within_measured_ceiling() { let tmp = tempdir().expect("tempdir"); @@ -22566,7 +21658,6 @@ async fn read_only_child_envelope_stays_within_measured_ceiling() { "read-only child envelope grew past its reviewed ceiling: {envelope_bytes}B > {READ_ONLY_CHILD_ENVELOPE_BYTE_CEILING}B. If deliberate, re-measure and raise the ceiling in the same commit." ); } - #[tokio::test] async fn parent_agent_surface_stays_within_measured_ceiling() { let tmp = tempdir().expect("tempdir"); @@ -22597,7 +21688,6 @@ async fn parent_agent_surface_stays_within_measured_ceiling() { "parent prompt+catalog surface grew past its reviewed ceiling: {surface_bytes}B > {PARENT_SURFACE_BYTE_CEILING}B. If deliberate, re-measure and raise the ceiling in the same commit." ); } - fn init_claim_repo(root: &Path) { let run = |args: &[&str]| { let output = Command::new("git") @@ -22627,7 +21717,6 @@ fn init_claim_repo(root: &Path) { .expect("git available"); assert!(output.status.success(), "git commit: {output:?}"); } - #[test] fn completed_claim_of_untouched_file_taints_verification() { // R7 (finish-operator 2026-08-02): the morning report caught a child @@ -22658,7 +21747,6 @@ fn completed_claim_of_untouched_file_taints_verification() { } // ─── resume_from continuation-chain tests (#425) ───────────────────────────── - #[test] fn parse_spawn_request_accepts_resume_from() { let input = json!({ @@ -22668,7 +21756,6 @@ fn parse_spawn_request_accepts_resume_from() { let parsed = parse_spawn_request(&input).expect("spawn request should parse"); assert_eq!(parsed.resume_from.as_deref(), Some("agent_abc123")); } - #[test] fn parse_spawn_request_accepts_resume_from_camel_case() { let input = json!({ @@ -22678,14 +21765,12 @@ fn parse_spawn_request_accepts_resume_from_camel_case() { let parsed = parse_spawn_request(&input).expect("camelCase resumeFrom should parse"); assert_eq!(parsed.resume_from.as_deref(), Some("my-session")); } - #[test] fn parse_spawn_request_resume_from_absent_is_none() { let input = json!({ "prompt": "fresh start" }); let parsed = parse_spawn_request(&input).expect("prompt-only request should parse"); assert!(parsed.resume_from.is_none()); } - #[test] fn parse_spawn_request_resume_from_empty_string_is_none() { let input = json!({ @@ -22696,7 +21781,6 @@ fn parse_spawn_request_resume_from_empty_string_is_none() { .expect("whitespace-only resume_from should be treated as absent"); assert!(parsed.resume_from.is_none()); } - /// Spawning with resume_from + fork_context=false is contradictory and must /// be rejected at the spawn seam (not at parse time, since that is too early /// to know whether fork_context was explicit). @@ -22715,7 +21799,6 @@ fn parse_spawn_request_resume_from_with_fork_context_false_is_parseable() { assert_eq!(parsed.resume_from.as_deref(), Some("agent_abc123")); assert_eq!(parsed.fork_context, Some(false)); } - #[test] fn resume_from_rejects_running_source() { let tmp = tempdir().expect("tempdir"); @@ -22729,7 +21812,6 @@ fn resume_from_rejects_running_source() { "error should mention running status: {err}" ); } - #[test] fn completed_claim_matching_workspace_state_stays_untainted() { let tmp = tempdir().expect("tempdir"); @@ -22787,7 +21869,6 @@ fn completed_claim_matching_workspace_state_stays_untainted() { record.verification.summary ); } - #[tokio::test] async fn spawn_receipt_compacts_and_verbose_restores_the_archive() { // Morning-report issue #4 (W6 rest): every spawn returned ~12KB because @@ -22869,7 +21950,6 @@ async fn spawn_receipt_compacts_and_verbose_restores_the_archive() { assert!(verbose.get("compact").is_none()); assert_eq!(verbose, full, "verbose: true must restore the old shape"); } - fn spawn_route_metadata(provider: &str, model: &str, source: &str) -> WorkflowTaskSpawnMetadata { let child_route = ChildRouteReceipt { requested_type: "explore".to_string(), @@ -22903,11 +21983,9 @@ fn spawn_route_metadata(provider: &str, model: &str, source: &str) -> WorkflowTa resume_from_agent_id: None, } } - fn spawn_child_route_projection(metadata: &WorkflowTaskSpawnMetadata) -> ChildRouteReceipt { metadata.child_route.clone() } - #[test] fn spawn_receipt_route_names_the_provider_and_model_the_child_actually_got() { // #5305: a Fleet profile can route a child onto a provider the parent @@ -22941,7 +22019,6 @@ fn spawn_receipt_route_names_the_provider_and_model_the_child_actually_got() { assert_eq!(route["model_id"], json!("deepseek-v4-pro")); assert_eq!(route["route_source"], json!("task.model")); } - #[tokio::test] async fn spawn_receipt_route_survives_compaction_for_a_type_only_spawn() { // A type-only spawn resolves no roster member: no profile, no pins. The @@ -22995,7 +22072,6 @@ async fn spawn_receipt_route_survives_compaction_for_a_type_only_spawn() { "a type-only spawn resolves no profile: {receipt}" ); } - #[tokio::test] async fn unscoped_status_compacts_running_children_and_keeps_terminal_full() { // Morning-report issue #4: one unscoped status poll returned 203KB @@ -23067,7 +22143,6 @@ async fn unscoped_status_compacts_running_children_and_keeps_terminal_full() { assert!(verbose_row.get("snapshot").is_some(), "{verbose_row}"); assert!(verbose_row.get("compact").is_none(), "{verbose_row}"); } - #[test] fn resume_from_rejects_missing_source() { let tmp = tempdir().expect("tempdir"); @@ -23080,7 +22155,6 @@ fn resume_from_rejects_missing_source() { "error should mention not found: {err}" ); } - #[test] fn resume_from_accepts_completed_source() { let tmp = tempdir().expect("tempdir"); @@ -23094,7 +22168,6 @@ fn resume_from_accepts_completed_source() { validate_resume_from_source(&manager, &completed_id, tmp.path()) .expect("completed agent must be accepted as a resume source"); } - #[test] fn resume_from_accepts_interrupted_source() { let tmp = tempdir().expect("tempdir"); @@ -23111,7 +22184,6 @@ fn resume_from_accepts_interrupted_source() { validate_resume_from_source(&manager, &interrupted_id, tmp.path()) .expect("interrupted agent must be accepted as a resume source"); } - #[test] fn resume_from_accepts_failed_source() { let tmp = tempdir().expect("tempdir"); @@ -23124,7 +22196,6 @@ fn resume_from_accepts_failed_source() { validate_resume_from_source(&manager, &failed_id, tmp.path()) .expect("failed agent may be used as a resume source"); } - #[test] fn resume_from_accepts_cancelled_source() { let tmp = tempdir().expect("tempdir"); @@ -23137,7 +22208,6 @@ fn resume_from_accepts_cancelled_source() { validate_resume_from_source(&manager, &cancelled_id, tmp.path()) .expect("cancelled agent may be used as a resume source"); } - #[test] fn resume_from_rejects_cross_workspace_source() { let tmp_parent = tempdir().expect("parent tempdir"); @@ -23157,7 +22227,6 @@ fn resume_from_rejects_cross_workspace_source() { "error should mention workspace mismatch: {err}" ); } - #[test] fn resume_from_loads_transcript_artifact_when_available() { let tmp = tempdir().expect("tempdir"); @@ -23183,7 +22252,6 @@ fn resume_from_loads_transcript_artifact_when_available() { "resume transcript reads must not fall back to the execution workspace" ); } - #[test] fn resume_from_falls_back_to_checkpoint_when_artifact_missing() { let tmp = tempdir().expect("tempdir"); @@ -23213,7 +22281,6 @@ fn resume_from_falls_back_to_checkpoint_when_artifact_missing() { assert_eq!(fallback_messages.len(), 2); assert_eq!(message_text(&fallback_messages[0]), "checkpoint task"); } - #[test] fn resume_from_session_name_resolves_to_agent_id() { let tmp = tempdir().expect("tempdir"); @@ -23232,7 +22299,6 @@ fn resume_from_session_name_resolves_to_agent_id() { validate_resume_from_source(&manager, "named-source", tmp.path()) .expect("resolution by session name must work for resume_from"); } - #[test] fn child_launch_manifest_carries_resume_from_agent_id() { let tmp = tempdir().expect("tempdir"); @@ -23252,7 +22318,6 @@ fn child_launch_manifest_carries_resume_from_agent_id() { } // ── validation helper exposed for the tests above ──────────────────────────── - /// Extracted validation logic mirroring what `spawn_subagent_from_input` does. /// Returns `Ok(agent_id)` when the source is acceptable, `Err(message)` when /// it must be rejected. @@ -23290,7 +22355,6 @@ fn validate_resume_from_source( Ok(source_id) } - /// Owner report 2026-08-04: a model/provider switch spawns the new engine's /// manager while the old engine still holds the workspace coordination flock /// in this same process. That losing acquisition must classify itself as a @@ -23324,7 +22388,6 @@ fn coordination_lock_loss_to_own_process_reads_as_handover_and_self_heals() { } // -- resume-from-checkpoint tests (checkpoint-based continuation) -- - #[tokio::test] async fn resume_from_checkpoint_spawns_seeded_agent_with_checkpoint_context() { let tmp = tempdir().unwrap(); @@ -23385,7 +22448,6 @@ async fn resume_from_checkpoint_spawns_seeded_agent_with_checkpoint_context() { let prior = guard.agents.get(&agent_id).expect("prior record"); assert!(matches!(prior.status, SubAgentStatus::Interrupted(_))); } - #[tokio::test] async fn resume_from_checkpoint_is_idempotent_across_repeated_followups() { let tmp = tempdir().unwrap(); @@ -23435,7 +22497,6 @@ async fn resume_from_checkpoint_is_idempotent_across_repeated_followups() { "a repeated followup must reach the resumed target" ); } - #[tokio::test] async fn resume_from_checkpoint_rejects_non_interrupted_agents() { let tmp = tempdir().unwrap(); @@ -23457,7 +22518,6 @@ async fn resume_from_checkpoint_rejects_non_interrupted_agents() { "{err}" ); } - #[tokio::test] async fn resume_from_checkpoint_rejects_missing_continuable_checkpoint() { let tmp = tempdir().unwrap(); @@ -23490,7 +22550,6 @@ async fn resume_from_checkpoint_rejects_missing_continuable_checkpoint() { "{err}" ); } - /// The resume lane must honor the same isolation rule as a fresh worktree /// spawn: a worktree child's boundary is the worktree alone, so a multi-root /// parent's attached roots must not re-widen the resumed child. Both @@ -23597,7 +22656,6 @@ async fn resume_of_isolated_worktree_child_keeps_the_worktree_as_its_only_root() root set — the parent's attached root must not stay writable" ); } - /// Isolation must not key on the write claim's existence: a claim can be /// released or never created for a worktree child. The worker record's /// launch manifest durably pins the worktree spawn, so the claim-less resume @@ -23698,7 +22756,6 @@ async fn claim_less_resume_of_a_recorded_worktree_child_stays_isolated() { "a claim-less worktree child must not inherit the caller's writable roots" ); } - /// The rebuild idiom shared by both worktree clear sites: after the roots set /// is cleared, the WorkspaceWrite face of the cloned policy re-derives from /// the child's `(workspace, workspace_roots)` pair — the worktree alone — and @@ -23747,7 +22804,6 @@ fn rederive_sandbox_policy_roots_confines_the_exec_lane_to_the_cleared_set() { assert_eq!(context.elevated_sandbox_policy, Some(policy)); } } - #[test] fn user_follow_up_to_running_child_counts_queued_until_the_loop_takes_it() { let tmp = tempdir().expect("tempdir"); @@ -23794,7 +22850,6 @@ fn user_follow_up_to_running_child_counts_queued_until_the_loop_takes_it() { "nothing queued once the loop took both" ); } - #[test] fn user_follow_up_to_cancelled_child_fails_closed_with_the_reason() { let tmp = tempdir().expect("tempdir"); @@ -23811,7 +22866,6 @@ fn user_follow_up_to_cancelled_child_fails_closed_with_the_reason() { assert!(err.to_string().contains("cancelled"), "{err}"); assert!(manager.queued_follow_up_counts().is_empty()); } - #[test] fn user_follow_up_to_completed_child_requires_a_runtime_to_resume() { let tmp = tempdir().expect("tempdir"); @@ -24565,7 +23619,6 @@ mod child_permission_gate { } // ── #5462: `agent` is the only model-facing sub-agent surface ──────────────── - /// The six narrow tools that used to sit beside `agent` in the model catalog. /// Kept as one list so a test cannot silently check five of them. const RETIRED_AGENTS_TOOLS: &[&str] = &[ @@ -24576,7 +23629,6 @@ const RETIRED_AGENTS_TOOLS: &[&str] = &[ "agents/coordinate", "agents/wait", ]; - fn subagent_registry_for_catalog(tmp: &std::path::Path) -> crate::tools::ToolRegistry { let runtime = stub_runtime(); let manager = runtime.manager.clone(); @@ -24584,7 +23636,6 @@ fn subagent_registry_for_catalog(tmp: &std::path::Path) -> crate::tools::ToolReg .with_subagent_tools(manager, runtime) .build(crate::tools::spec::ToolContext::new(tmp.to_path_buf())) } - /// The narrow tools must vanish from the advertised catalog while staying /// registered: a persisted transcript that replays `agents/followup` has to /// keep dispatching to the same implementation, exactly as `rlm` and @@ -24621,7 +23672,6 @@ fn retired_agents_tools_stay_registered_but_leave_the_model_catalog() { ); } } - /// Hiding a tool from the initial catalog is worthless if `tool_search` can /// hand it back. Both matching paths read the same catalog, so both are /// exercised — with queries chosen to hit the retired tools' own names and @@ -24674,7 +23724,6 @@ fn tool_search_cannot_return_a_retired_agents_tool() { } } } - /// A name the model can read is a name the model will try to call. The `agent` /// description and schema must not advertise a tool that is no longer in the /// catalog — the failure mode that motivated this change was the description @@ -24706,7 +23755,6 @@ fn the_agent_surface_never_names_a_retired_tool() { "the replacement action must be advertised" ); } - /// The claim case, end to end, through the `agent` surface. /// /// This is the test the audit demanded: the coordinate wire key is `roots` @@ -24808,7 +23856,6 @@ async fn agent_claim_expands_the_callers_write_scope() { .expect("the expanded scope admits the previously refused write"); assert!(tmp.path().join("docs/note.txt").exists()); } - /// `expand_write_claim` returns the unchanged claim with `Ok` when every list /// is empty, so an empty claim would read as "granted". Refuse it at the seam. #[test] @@ -24835,7 +23882,6 @@ fn agent_claim_refuses_a_scopeless_call() { "the coordinate wire key is `roots`: {translated}" ); } - /// Folding six tools into one multi-action tool must not hand a read-only role /// an authority its catalog previously withheld. `agents/coordinate` was kept /// off an inspection role's surface by the execution envelope; `claim` has to diff --git a/crates/tui/src/worker_profile.rs b/crates/tui/src/worker_profile.rs index 9a3b8ab203..0e322ef10e 100644 --- a/crates/tui/src/worker_profile.rs +++ b/crates/tui/src/worker_profile.rs @@ -205,10 +205,11 @@ impl WorkerRuntimeProfile { // the network or from shell for no role reason cannot do its job. let (permissions, shell) = match role { // Read-only investigators: no workspace writes, but network reach - // and the bounded verification surface so a scout/reviewer lane - // can run git/gh/web inspection. Raw shell stays denied by the - // registry clamp (read-only classifier), so this widens capability - // without widening mutation authority. + // so a scout/reviewer lane can run git/gh/web inspection. Raw + // shell stays denied by the registry clamp (read-only + // classifier), so this widens capability without widening + // mutation authority; the bounded verification surface is the + // Verifier's own arm below, not a scout/reviewer tool. FleetRole::Scout | FleetRole::Reviewer => { (PermissionSet::read_only_with_network(), ShellPolicy::Full) } diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index 49d0963e3d..96b7ca006a 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -100,7 +100,7 @@ segment must individually be admitted (e.g. `wc -c f; wc -l f`, `cd sub && wc -c notes.md`, `cat x | od -c`) — and a workspace-confined `cd ` in any sequence position its connector always runs (joined by `;`, or first — a `cd` after `&&`/`||` may never execute, so it refuses). Read-only is transitive through any delegation chain: -the clamp (`ChildAuthority::clamp` in `fleet/exact.rs`) intersects every +the clamp (`ChildAuthority::clamp` in `fleet/role.rs`) intersects every field with the narrower side, the deny-list union means a descendant can never drop an ancestor's restriction, and `inherit_disallowed_tools: false` cannot drop a posture denial (`is_posture_denial`). This is pinned by diff --git a/docs/zh_hans/FLEET.md b/docs/zh_hans/FLEET.md index 0ae5afa5e6..32b10f3214 100644 --- a/docs/zh_hans/FLEET.md +++ b/docs/zh_hans/FLEET.md @@ -121,7 +121,7 @@ call_reasrning = "lrw" 在运行时,它只能为已经冻结的 worker route 选择 reasoning 级别。它不能改变 worker、provider、model、role、tools 或 permissions。Router 调用本身被限制在 `off` 或 `low`;更贵的值会被拒绝。手动选择的 worker reasoning 级别不会产生 Router 调用。Route 与 reasoning 回执会指名 worker model,并在使用 Router 时给出 Router 的确切 provider/model,让操作者看到哪个 model 干了哪份活。如果相同的裸 Router 或 Fleet 名字在两个根里都存在,请用 `workspace/` 或 `codewhale_home/` 限定它,而不是依赖遮蔽(shadowing)。 -每个成员的 `permissions` 预设是一个**上限**,绝不是授权:它会与会话的实时姿态取交集,因此只读会话里的 `read_write` 成员以只读方式运行。这个交集成为子任务真实的工具包——`permissions = "none"` 让它一个工具都没有,而没有网络工具的成员会失去每一个可触达的网络表面——`web.run`、`fetch_url`、`web_search`、`github`、`mcp*` 系列以及可触达的 `rlm` 动作——而不是仅仅在调用时被拒绝。唯一刻意的例外是规范 `Web` 家族的 `search`/`fetch` 动作:它们是只读成员有权使用的只读 web 表面(与普通 scout 对等),因此网络被禁的成员恰好保留 `Web {search, fetch}`。它在事实上保持只读,而不仅仅是名义上:按 URL 寻址的 `fetch` 在派发时被拒绝,表面上没有任何其他东西被授予。不能写入的成员会失去可变的文件工具,并且——当它保留了 `shell = "full"` 以便运行检查时——也会失去原始 shell,只保留它为之而存在的有界验证工具(`run_tests`、`run_verifiers`;经 `agent` 工具嵌套的 verifier 额外持有分类器门控的只读取证探针通道,但 Fleet 成员信封把有界验证与 Bash 权限定义为互斥,因此不带该探针通道):任意 shell 命令和 `write_file` 一样确定无疑地改变工作区,所以保留它会令 `write = false` 不成立。那个验证表面只在它的*默认*形式下有界,因此无界参数会随 shell 一起消失:被拒绝写入的成员可以运行内置 gate,但不能用显式 `commands` 数组运行 `run_verifiers`,也不能用原始 `args` 字符串运行 `run_tests`——这两者都会启动操作者提供的程序,是换了名字的原始 shell。 +每个成员的 `permissions` 预设是一个**上限**,绝不是授权:它会与会话的实时姿态取交集,因此只读会话里的 `read_write` 成员以只读方式运行。这个交集成为子任务真实的工具包——`permissions = "none"` 让它一个工具都没有,而没有网络工具的成员会失去每一个可触达的网络表面——`web.run`、`fetch_url`、`web_search`、`github`、`mcp*` 系列以及可触达的 `rlm` 动作——而不是仅仅在调用时被拒绝。唯一刻意的例外是规范 `Web` 家族的 `search`/`fetch` 动作:它们是只读成员有权使用的只读 web 表面(与普通 scout 对等),因此网络被禁的成员恰好保留 `Web {search, fetch}`。它在事实上保持只读,而不仅仅是名义上:按 URL 寻址的 `fetch` 在派发时被拒绝,表面上没有任何其他东西被授予。不能写入的成员会失去可变的文件工具,并且——当它保留了 `shell = "full"` 以便运行检查时——也会失去原始 shell,只保留它为之而存在的有界验证工具(`run_tests`、`run_verifiers`;经 `agent` 工具嵌套的 verifier 在授予该通道的会话内委派上额外持有分类器门控的只读取证探针通道,但 Fleet 成员信封把有界验证与 Bash 权限定义为互斥,因此不带该探针通道):任意 shell 命令和 `write_file` 一样确定无疑地改变工作区,所以保留它会令 `write = false` 不成立。那个验证表面只在它的*默认*形式下有界,因此无界参数会随 shell 一起消失:被拒绝写入的成员可以运行内置 gate,但不能用显式 `commands` 数组运行 `run_verifiers`,也不能用原始 `args` 字符串运行 `run_tests`——这两者都会启动操作者提供的程序,是换了名字的原始 shell。 这些拒绝中有两项比工具名更窄,因为有两个工具触及了它们名字之外的地方。`rlm` 通过在进程*内部*、以它自己的名字调用 fetch 工具来加载 `url`,而 `rlm` 的 `eval` 动作针对活内核运行 Python——套接字和文件系统都触及。因此该家族**按动作**被拒绝,而不是整体拒绝: diff --git a/docs/zh_hans/SUBAGENTS.md b/docs/zh_hans/SUBAGENTS.md index 83b01da271..f4b4584e51 100644 --- a/docs/zh_hans/SUBAGENTS.md +++ b/docs/zh_hans/SUBAGENTS.md @@ -37,7 +37,7 @@ Fleet 角色是面向用户的委派工作词汇:父代理通过 `agent` 启 角色的默认值就是该角色*想要*的姿态,而父代理的有效姿态永远是天花板(子代理绝不会扩得比父代理更宽)。只读角色按意图扣留**工作区写入**;默认不拿走任何其他东西——每个角色都保留网络读取,`custom` 继承父代理的写入/网络/shell 姿态,并且只被它的显式工具列表或发起调用收窄。被聚焦的 worker 的头部会依据运行时自身的权限快照声明有效姿态(`scout · read-only · network · read-only shell`)。 -**委派移动的是工作,绝不是权限**(#5426 的遏制答案)。只读角色委派给可写角色(scout → builder)是*工作容量*受支持的逃生舱——子代理自带其模型、路由和步骤预算——但子代理的权限被钳制在委派父代理的实时姿态上,而不是操作者的姿态上:scout 的 builder 子代理以只读落地,raw shell 和可变工具被拒绝,规范的 `Bash` 对它也被拒绝(只有有界检查角色与 verifier 保留分类的只读 shell)。因此通过委派来获得 shell 在机制上是无用的——scout 自带的受限 shell(`git -C … log`、`find … | head`、`npm view …`,分类器门控)是只读父代理唯一的 shell 路径。该有界语法还允许把这些已准入的探针组合成复合序列——以 `;`、`&&`、`||` 或 `|` 连接的段,且每个段都必须单独准入(例如 `wc -c f; wc -l f`、`cd sub && wc -c notes.md`、`cat x | od -c`)——以及工作区受限的 `cd <单个相对路径>`(可单独使用,或出现在连接符必定执行它的序列位置——以 `;` 连接或位于序列首位;`&&`/`||` 之后的 `cd` 可能根本不会执行,因此会被拒绝)。只读通过任何委派链都是传递的:钳制(`fleet/exact.rs` 中的 `ChildAuthority::clamp`)把每个字段与更窄的一侧求交,拒绝列表的并集意味着后代永远无法去掉祖先的限制,而 `inherit_disallowed_tools: false` 无法去掉姿态拒绝(`is_posture_denial`)。这一点由 `crates/tui/src/fleet/exact.rs` 测试中的 `a_read_only_parents_delegation_never_widens_authority` 钉死。 +**委派移动的是工作,绝不是权限**(#5426 的遏制答案)。只读角色委派给可写角色(scout → builder)是*工作容量*受支持的逃生舱——子代理自带其模型、路由和步骤预算——但子代理的权限被钳制在委派父代理的实时姿态上,而不是操作者的姿态上:scout 的 builder 子代理以只读落地,raw shell 和可变工具被拒绝,规范的 `Bash` 对它也被拒绝(只有有界检查角色与 verifier 保留分类的只读 shell)。因此通过委派来获得 shell 在机制上是无用的——scout 自带的受限 shell(`git -C … log`、`find … | head`、`npm view …`,分类器门控)是只读父代理唯一的 shell 路径。该有界语法还允许把这些已准入的探针组合成复合序列——以 `;`、`&&`、`||` 或 `|` 连接的段,且每个段都必须单独准入(例如 `wc -c f; wc -l f`、`cd sub && wc -c notes.md`、`cat x | od -c`)——以及工作区受限的 `cd <单个相对路径>`(可单独使用,或出现在连接符必定执行它的序列位置——以 `;` 连接或位于序列首位;`&&`/`||` 之后的 `cd` 可能根本不会执行,因此会被拒绝)。只读通过任何委派链都是传递的:钳制(`fleet/role.rs` 中的 `ChildAuthority::clamp`)把每个字段与更窄的一侧求交,拒绝列表的并集意味着后代永远无法去掉祖先的限制,而 `inherit_disallowed_tools: false` 无法去掉姿态拒绝(`is_posture_denial`)。这一点由 `crates/tui/src/fleet/exact.rs` 测试中的 `a_read_only_parents_delegation_never_widens_authority` 钉死。 会话的**权限姿态**在每个子代理内部的应用方式与父代理回合完全一致:在 Auto-Review 下,同一个确定性底线和一次性模型守护者决定 worker 的被扣留调用(绝不是提示词;守护者不可用时拒绝,fail closed);在 Ask 下,角色无法委派的被扣留调用会作为审批提示在父代理的 UI 中弹出,worker 可见地等待(`waiting for user`),或者在无法提示的主机上带着原因被拒绝;Full Access 仍然在不可绕过的安全底线上 fail closed。每一次没有人被提示的决策都是该 worker 转录中的一行备注(聚焦时可见)和一条审计日志记录。参见 `docs/MODES.md`。 @@ -122,7 +122,7 @@ OUTPUT: VERDICT、EVIDENCE、GAPS、NEXT。 - **`planner`** —— 当父代理有目标但没有可执行的分解。planner 写工件(`todo_write` 条目、响应体里的策略),但不执行它们。 - **`reviewer`** —— 当已经有一个变更,父代理想要它被评分。reviewer 不打补丁——他们在发现里描述修复方案,这样如果判定是"修它",父代理可以派一个 builder。 - **`builder`** —— 当变更已经被明确指定、只需要落地。builder 保持严格的范围:最小改动,不做顺手重构,交回前跑一次快速验证。 -- **`verifier`** —— 当父代理需要测试套件或其他验证上的权威通过/失败结论。verifier 不修失败;他们记录失败的断言 + 栈,把修复候选放在 RISKS 下。verifier 姿态从不写入;其 shell 是有界内置验证面——对会话内子代理,只有当只读 shell 父代理钳制了其权限时才会被拒绝,被钳制的子代理保留探针通道、失去验证门禁——加上,在授予该通道的会话内委派上,与检查角色相同的分类器门控只读探针通道(`wc`、`ls`、`cat`、`grep` 管道)用于取证;未授予该通道的 envelope 会省略它,shell 尝试一律拒绝。Fleet 成员的 envelope 更严格:在那里有界验证与 Bash 权限互斥,因此 fleet verifier 只持有门禁、不带探针通道。写入上限是只读,无界 shell 形式被拒绝(#5186),自定义命令也被拒绝——请改用具备写能力的角色。 +- **`verifier`** —— 当父代理需要测试套件或其他验证上的权威通过/失败结论。verifier 不修失败;他们记录失败的断言 + 栈,把修复候选放在 RISKS 下。verifier 姿态从不写入;其 shell 是有界内置验证面——对会话内子代理,只有当只读 shell 父代理钳制了其权限时才会被拒绝,被钳制的子代理保留探针通道、失去验证门禁——加上,在授予该通道的会话内委派上,与检查角色相同的分类器门控只读探针通道(`wc`、`ls`、`cat`、`grep` 管道)用于取证;未授予该通道的信封会省略它,shell 尝试一律拒绝。Fleet 成员的信封更严格:在那里有界验证与 Bash 权限互斥,因此 fleet verifier 只持有门禁、不带探针通道。写入上限是只读,无界 shell 形式被拒绝(#5186),自定义命令也被拒绝——请改用具备写能力的角色。 - **`consultant`** —— 当操作者想在更便宜的执行继续之前得到一个高杠杆的第二意见。consultant 读足够的材料来支撑一条建议,但不能写,也不能运行 shell 命令。`oracle` 和 `advisor` 只在加载更老的请求或持久化记录时被接受;新的提示词、回执和 UI 使用 `consultant`。 - **`custom`** —— 只有当父代理需要显式约束工具集时。通过 legacy/internal 子代理记录上的 `allowed_tools` 字段传 allowlist;面向模型的 `agent` 工具刻意保持公共 schema 很小。 From 2fd4de382acba35e52d07f9e163b68fc9e2323fd Mon Sep 17 00:00:00 2001 From: asto18089 Date: Thu, 12 Nov 2026 13:37:09 +0800 Subject: [PATCH 10/11] fix(fleet): round-3 review fixes - pin the fleet roster blurb, complete the guide rows, finish the blank-line strip Signed-off-by: asto18089 --- crates/tui/src/tools/subagent/tests.rs | 44 +++++++++++++++++++------- docs/GUIDE.md | 2 +- docs/zh_hans/GUIDE.md | 2 +- 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 3120be654f..c37e4311aa 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -5478,7 +5478,6 @@ fn subagent_tool_schemas_advertise_real_type_and_role_vocabulary() { "worktree description should teach isolated parallel edits: {worktree}" ); } - // The wait surfaces block the turn, so the model-facing text must disclose // the bounded block (default 30s, max 120s) and the timed_out receipt shape; // an undisclosed finite block reads as a hang when nothing settles. @@ -5540,7 +5539,6 @@ fn wait_schema_text_discloses_timeout_bound_and_timed_out_receipt() { the timed_out receipt:\n{wait_description}" ); } - // The two wait faces advertise from two independent constant sets, so each // face's own pin can stay green while the surfaces drift numerically apart; // this cross-assertion closes that gap. @@ -7556,7 +7554,6 @@ fn synthetic_deferred_tool(name: &str, description_bytes: usize) -> Tool { cache_control: None, } } - // Pinvou fork: the toolbox size bounds are disabled (see the constant docs in // core/session.rs). Upstream tests that construct near-budget activations to // fire bounds eviction or oversize rejection are not carried here — those @@ -8101,11 +8098,43 @@ fn verifier_roster_description_states_the_bounded_contract() { description.contains("unless a read-only-shell parent clamped its shell authority"), "roster description must condition the gates on shell authority — a clamped child keeps probes and loses gates: {description}" ); - // The Agent-tool `type` description carries the same truth, briefly. + // The Agent-tool `type` description carries the same truth, briefly — + // including the two clauses the model needs before picking: the gates + // clamp under a read-only-shell parent, and custom commands are refused. assert!( SUBAGENT_TYPE_DESCRIPTION.contains("test: runs the built-in verification gates"), "type description must match the roster: {SUBAGENT_TYPE_DESCRIPTION}" ); + assert!( + SUBAGENT_TYPE_DESCRIPTION.contains("custom commands are refused"), + "type description must refuse custom commands: {SUBAGENT_TYPE_DESCRIPTION}" + ); + assert!( + SUBAGENT_TYPE_DESCRIPTION + .contains("a read-only-shell parent clamps the gates (probes remain)"), + "type description must condition the gates on the parent shell: {SUBAGENT_TYPE_DESCRIPTION}" + ); + // The fleet roster slot blurb is the third roster surface a model reads + // (built-in members feed `AgentProfile.description`); it must not + // advertise builds or a shell the fleet envelope never grants. + let fleet_blurb = FleetRoster::built_in_members() + .into_iter() + .find(|member| member.id == "verifier") + .unwrap_or_else(|| panic!("built-in roster must carry the verifier slot")) + .description + .unwrap_or_else(|| panic!("built-in verifier slot must carry a description")); + assert!( + fleet_blurb.contains("built-in verification gates"), + "fleet roster blurb must name the built-in gates: {fleet_blurb}" + ); + assert!( + fleet_blurb.contains("holds no shell"), + "fleet roster blurb must disclaim the shell the fleet envelope refuses: {fleet_blurb}" + ); + assert!( + !fleet_blurb.contains("builds and tests"), + "fleet roster blurb must not re-overclaim builds: {fleet_blurb}" + ); } /// Acceptance for the verifier lane, sibling of /// `read_only_inspection_roles_execute_pwd_and_absolute_git_log`: a live @@ -12944,7 +12973,6 @@ async fn prompt_only_general_cannot_mutate_under_parent_auto_approve() { assert!(!tmp.path().join("forbidden.txt").exists()); assert!(!tmp.path().join("shell.txt").exists()); } - const MCP_ACTION_TOOL: &str = "mcp_github_create_pull_request"; fn subagent_registry_with_mcp_action(auto_approve: bool) -> SubAgentToolRegistry { let mut runtime = stub_runtime(); @@ -18963,7 +18991,6 @@ async fn shared_scope_budget_stops_admitted_children_mid_run() { /// Clears the process-wide rate-limit window on drop so a panicking test /// body cannot leak a live pause into concurrently running tests. struct ClearRateLimitOnDrop; - impl Drop for ClearRateLimitOnDrop { fn drop(&mut self) { crate::retry_status::clear_rate_limit(); @@ -22887,7 +22914,6 @@ fn user_follow_up_to_completed_child_requires_a_runtime_to_resume() { } // === child permission gate: the session posture applied to a worker's calls === - mod child_permission_gate { use super::*; use crate::approval_log::{ApprovalOutcome, ApprovalReceipt, ApprovalReceiptStore}; @@ -23959,7 +23985,6 @@ async fn agent_claim_is_withheld_from_a_role_with_no_write_authority() { .to_string(); assert!(refusal.contains("no write authority to widen"), "{refusal}"); } - // Regression (stopship scout repair): the workflow's read-only scout // activates `grep_files` with one `tool_search` call before searching. That // two-step path only works while the scout surface keeps a first-turn-active @@ -24012,7 +24037,6 @@ async fn forkguard_scout_surface_keeps_tool_search_grep_files_activation_path() "the hidden File alias must stay out of scout catalogs" ); } - // Regression (stopship scout repair, Pinvou #490 phantom-tool class): fleet // workflow briefs are model-facing text too. The scout brief must name tools // the child catalog can actually see (`tool_search`, `grep_files`) and never @@ -24054,7 +24078,6 @@ fn forkguard_workflow_briefs_name_catalog_visible_tools() { "workflow briefs must not command calls a catalog can never return: {cited:?}" ); } - // Behavioral counterpart to the two composition guards above: on the scout's // live surface, one `tool_search` call must make the deferred `grep_files` // dispatchable through the same gate the child step loop uses, while the @@ -24183,7 +24206,6 @@ fn forkguard_subagent_skill_catalog_uses_tool_search_discovery() { ); assert!(!catalog.contains("try `load_skill` directly anyway")); } - // Worker records, takeover targets, and transcript artifact descriptions all // point the parent at `handle_read`, which is deferred on stock hosts; every // prose site that names it must carry the `tool_search` activation hint so diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 977a403970..cf8b470e6f 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -471,7 +471,7 @@ Useful roles include: | `plan` | Design and migration planning | | `review` | Bug-focused review of an existing change | | `implementer` | A tightly specified edit | -| `verifier` | Running checks and reporting pass/fail evidence | +| `verifier` | Running the built-in verification gates and reporting pass/fail evidence | Sub-agents are most useful when work can be separated cleanly. Do not use them for tiny edits, and do not ask multiple agents to write the same files at the diff --git a/docs/zh_hans/GUIDE.md b/docs/zh_hans/GUIDE.md index f6ad23ae18..0d2d9634f2 100644 --- a/docs/zh_hans/GUIDE.md +++ b/docs/zh_hans/GUIDE.md @@ -325,7 +325,7 @@ Codewhale 的工具是结构化操作。模型不只是产出文字,还能调 | `plan` | 设计与迁移规划 | | `review` | 对已有改动的 bug 聚焦审查 | | `implementer` | 规格明确的编辑 | -| `verifier` | 运行检查并报告通过/失败证据 | +| `verifier` | 运行内置验证门禁并报告通过/失败证据 | 子智能体在可以干净切分工作的时候最有用。不要为微小编辑使用它们,也不要让多个智能体同时写入相同文件。 From a49fa47ab65946d0d09ed89041cd997119e7a9c9 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Thu, 12 Nov 2026 13:37:31 +0800 Subject: [PATCH 11/11] fix(subagent): keep explicit tool denials authoritative over role carve-outs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bounded read-only bash lane overrode the ENTIRE deny list for canonical bash — including a session operator's --disallowed-tools rule or a spawn caller's explicit disallowed_tools entry — because the carve-out cannot tell the clamp's automatic Bash rule from an identically-spelled operator rule. A test/explore/reviewer child explicitly denied bash still saw the tool on the wire and could dispatch classifier-admitted probes. Carry the operator/caller provenance explicitly: WorkerRuntimeProfile gains explicit_denied_tools (unioned by derive_child, cleared only by inherit_disallowed_tools: false), the spawn merge books caller denies into it, the session seeds stamp it, and the registry lane closes whenever any operator rule matches bash. Catalog admission and dispatch are both held by the same predicate, with negative coverage for exact, wildcard, and prefix rule spellings plus a control pin proving the automatic clamp is still overridden. Requested by review on #140 (P2: explicit delegated-tool restriction not preserved at catalog admission and dispatch). Signed-off-by: asto18089 --- crates/tui/src/core/engine.rs | 4 + crates/tui/src/core/engine/tool_catalog.rs | 37 ++-- crates/tui/src/tools/shell/tests.rs | 195 +-------------------- crates/tui/src/tools/subagent/mod.rs | 41 ++++- crates/tui/src/tools/subagent/tests.rs | 81 +++++++++ crates/tui/src/worker_profile.rs | 52 ++++++ 6 files changed, 199 insertions(+), 211 deletions(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index f9d0c55661..dd96bed078 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -5648,6 +5648,8 @@ impl Engine { // (plan-mode role override above is intentionally before this). rt.worker_profile.denied_tools = self.config.disallowed_tools.clone().unwrap_or_default(); + rt.worker_profile.explicit_denied_tools = + self.config.disallowed_tools.clone().unwrap_or_default(); if let Some(context) = fork_context_for_runtime.clone() { rt = rt.with_fork_context(context); } @@ -7364,6 +7366,8 @@ impl Engine { rt.worker_profile = WorkerRuntimeProfile::for_role(FleetRole::Planner); } rt.worker_profile.denied_tools = self.config.disallowed_tools.clone().unwrap_or_default(); + rt.worker_profile.explicit_denied_tools = + self.config.disallowed_tools.clone().unwrap_or_default(); Some(rt) } diff --git a/crates/tui/src/core/engine/tool_catalog.rs b/crates/tui/src/core/engine/tool_catalog.rs index 10dd8298eb..0ddf526693 100644 --- a/crates/tui/src/core/engine/tool_catalog.rs +++ b/crates/tui/src/core/engine/tool_catalog.rs @@ -805,22 +805,29 @@ pub(super) fn tool_denied(disallowed_tools: Option<&[String]>, tool_name: &str) } pub(crate) fn tool_matches_any_rule(rules: &[String], tool_name: &str) -> bool { + rules.iter().any(|rule| rule_matches_tool(rule, tool_name)) +} + +/// Whether one deny/allow rule matches `tool_name`, with the same +/// canonicalization [`tool_matches_any_rule`] applies across a list. Split +/// out so callers that must tell rules apart by provenance (an operator's +/// explicit `bash` rule vs the role clamp's automatic `Bash` rule — the +/// strings are identical) can classify each entry. +pub(crate) fn rule_matches_tool(rule: &str, tool_name: &str) -> bool { let tool_name = tool_name.to_ascii_lowercase(); - rules.iter().any(|rule| { - let rule = rule.to_ascii_lowercase(); - let (rule_body, is_prefix) = rule - .strip_suffix('*') - .map_or((rule.as_str(), false), |prefix| (prefix, true)); - std::iter::once(tool_name.as_str()) - .chain(policy_tool_aliases(&tool_name).iter().copied()) - .any(|candidate| { - if is_prefix { - candidate.starts_with(rule_body) - } else { - candidate == rule_body - } - }) - }) + let rule = rule.to_ascii_lowercase(); + let (rule_body, is_prefix) = rule + .strip_suffix('*') + .map_or((rule.as_str(), false), |prefix| (prefix, true)); + std::iter::once(tool_name.as_str()) + .chain(policy_tool_aliases(&tool_name).iter().copied()) + .any(|candidate| { + if is_prefix { + candidate.starts_with(rule_body) + } else { + candidate == rule_body + } + }) } /// Whether an explicit tool allowlist is provably limited to the native file diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index b9c6e76ad1..320c9ef2b1 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -5778,7 +5778,7 @@ async fn readonly_github_calls_refuse_at_the_policy_inside_the_tool_entry() { fn guidance_states_the_foreground_stdin_contract() { let description = super::guidance::description(); assert!( - description.contains("Foreground commands without `interactive` or `combined_output` run without a terminal stdin"), + description.contains("Foreground commands without `interactive`, `tty`, or `combined_output` run without a terminal stdin"), "guidance must state the bounded foreground stdin: {description}" ); assert!( @@ -10112,151 +10112,6 @@ fn spawned_child_does_not_inherit_the_pty_writer_dup() { drop(reader); } -/// The bounded stdin write: a command that never consumes its pipe fills -/// it, and past [`STDIN_WRITE_DEADLINE`] the write errors and the task's -/// stdin is marked stalled instead of holding the shell-manager lock (and -/// with it every other shell in the session) forever. The stall flag then -/// refuses further writes without another wait — two writers could -/// interleave on one pipe. -// Unix-only: the bounded write (worker + deadline) the test exercises is -// `#[cfg(unix)]`, as are the raw-mode tty semantics it pins; the -// Windows/ohos owned path is bounded by the same deadline through -// `bounded_stdin_write_owned` and covered by its own helper test. -#[cfg(unix)] -#[test] -fn write_stdin_to_a_child_that_stops_consuming_errors_instead_of_wedging() { - let verdict = run_stdin_scenario_with_hang_guard( - "the bounded-write pin", - || -> Result<(), String> { - let tmp = tempdir().map_err(|error| error.to_string())?; - let mut manager = ShellManager::new(tmp.path().to_path_buf()); - let result = manager - .execute_with_options_env( - &sleep_command(60), - None, - 60_000, - true, - None, - false, - None, - HashMap::new(), - ) - .map_err(|error| error.to_string())?; - let task_id = result - .task_id - .ok_or_else(|| "background execution should return task_id".to_string())?; - // Far past any pipe buffer on any platform: the write cannot - // complete unless the child reads, and it never does. - let payload = "x".repeat(200 * 1024); - let started = std::time::Instant::now(); - let error = manager - .write_stdin(&task_id, &payload, false) - .err() - .ok_or_else(|| "the stalled write must error at the deadline".to_string())? - .to_string(); - if !error.contains("did not complete within") { - return Err(error); - } - let elapsed = started.elapsed(); - if elapsed < STDIN_WRITE_DEADLINE { - return Err(format!( - "the write returned before the deadline: {elapsed:?}" - )); - } - // No upper bound on the elapsed time: the load-bearing assertions - // are that the deadline FIRED (elapsed ≥ deadline) and further - // writes are refused. A fixed upper bound is load-fragile at any - // multiple — under a saturated full-suite parallel run the - // deadline worker was observed starving past 30 s — while a - // broken-mechanism mutant (deadline removed) never returns and - // trips the outer hang guard instead. - // Further writes are refused by the stall flag without waiting. - let started = std::time::Instant::now(); - let again = manager.write_stdin(&task_id, "y", false); - if again.is_ok() { - return Err("the stalled task must refuse further writes".to_string()); - } - if started.elapsed() > std::time::Duration::from_millis(500) { - return Err("the stall refusal must not wait on another write".to_string()); - } - // A close after the stall must not claim the end-of-file the - // abandoned writer never delivered: the post-close refusal must - // still name the stall (the `stdin_closed_at_eof` stall conjunct - // and the stall-first check order together keep the EOF claim - // off; a reorder or conjunct drop that surfaced the EOF message - // reddens here). - let _closed = manager.write_stdin(&task_id, "", true); - let after_close = manager - .write_stdin(&task_id, "z", false) - .err() - .ok_or_else(|| "the post-stall close must leave writes refused".to_string())? - .to_string(); - if !after_close.contains("stalled") || after_close.contains("end-of-file") { - return Err(format!( - "a close after a stall must not claim an EOF the abandoned writer has not delivered: {after_close}" - )); - } - manager.kill(&task_id).map_err(|error| error.to_string())?; - Ok(()) - }, - ); - verdict.expect("bounded-write assertions"); -} -/// The `interact` payload carries the same per-call bound as the inline -/// spawn payload: one oversized call is refused with a pointer at chunking -/// before any write is attempted. -#[tokio::test] -async fn interact_payload_over_the_cap_refuses() { - let workspace = tempdir().expect("workspace"); - let context = ToolContext::new(workspace.path().to_path_buf()); - let oversized = "x".repeat(super::MAX_INLINE_STDIN_BYTES + 1); - let output = BashTool::new("Bash") - .execute( - json!({"action": "interact", "command": "pwd", "task_id": "no-such-task", "stdin": oversized}), - &context, - ) - .await - .expect("the refusal is a result, not an error"); - assert!(!output.success, "{}", output.content); - assert!( - output.content.contains("per interact call"), - "the refusal must name the per-call bound: {}", - output.content - ); - // The advertised bound stays in sync with the constant (the copy - // interpolates it; this asserts the value actually reaches the model). - assert!( - output - .content - .contains(&super::MAX_INLINE_STDIN_BYTES.to_string()), - "the refusal must carry the exact bound value: {}", - output.content - ); -} -/// The tool description states the foreground stdin contract; the sentence is -/// the model-facing contract for why stdin readers exit, so it is pinned. -#[test] -fn guidance_states_the_foreground_stdin_contract() { - let description = super::guidance::description(); - assert!( - description.contains("Foreground commands without `interactive`, `tty`, or `combined_output` run without a terminal stdin"), - "guidance must state the bounded foreground stdin: {description}" - ); - assert!( - description.contains("`tty` always selects a background terminal task"), - "guidance must state where tty commands actually run — the run path forces them background: {description}" - ); - assert!( - description.contains( - "a terminal lane that nothing writes to unless a session member interacts with the task" - ), - "guidance must state the combined_output foreground stdin contract accurately: {description}" - ); - assert!( - description.contains("action=\"interact\""), - "guidance must point at background interact for input-driven work: {description}" - ); -} /// The lowercase tool's own description carries the same foreground stdin /// contract: its legacy translation accepts only command/timeout/sandbox /// fields, so that sentence is the whole stdin story on that surface. @@ -10272,45 +10127,6 @@ fn lowercase_bash_description_states_the_foreground_stdin_contract() { "the bare-shell probe must be named as exiting on EOF: {description}" ); } -/// A foreground command that reads stdin with NO stdin data used to wedge on -/// a pipe whose write end the tool kept open: the live repro was `$SHELL` -/// holding the turn until the foreground watchdog killed it at the full -/// timeout (`cat` behaves identically). The foreground lane must deliver EOF -/// at spawn instead. Background keeps the pipe open for deferred -/// `interact` streaming — that contract is pinned by -/// `test_write_stdin_streams_output` and must not regress. -#[tokio::test] -async fn foreground_without_stdin_data_gives_stdin_readers_eof() { - let workspace = tempdir().expect("workspace"); - let context = ToolContext::new(workspace.path().to_path_buf()); - // `cat` on Unix, `more` on cmd, `[Console]::In.ReadToEnd()` on PowerShell — - // each blocks until stdin reaches EOF. - let echo_stdin = echo_stdin_command(); - - let started = Instant::now(); - let result = BashTool::new("Bash") - .execute(json!({"command": echo_stdin, "timeout_ms": 3000}), &context) - .await - .expect("foreground stdin-reader must spawn"); - let elapsed = started.elapsed(); - - assert!( - result.success, - "a stdin-reading foreground command with no stdin data must see EOF \ - and exit promptly, not run to the timeout; took {elapsed:?}: {}", - result.content - ); - // The elapsed bound is a unix pin: on Windows a cold PowerShell startup - // can legitimately approach the 3000 ms watchdog under parallel load, - // while `success` above already fails if the watchdog killed the run. - #[cfg(unix)] - assert!( - elapsed < Duration::from_millis(2900), - "EOF must be delivered right after spawn, not discovered at the \ - timeout; took {elapsed:?}: {}", - result.content - ); -} /// When the inline payload cannot be delivered at spawn, the run must abort /// with the run-never-started context: no task id exists yet, so the child /// is killed and the error must say the mentioned task id was never @@ -10370,15 +10186,6 @@ fn spawn_aborts_with_run_never_started_context_when_the_payload_stalls() { ); } #[cfg(unix)] -const SYNC_STDIN_EOF_HELPER_ENV: &str = "CODEWHALE_SYNC_STDIN_EOF_HELPER"; -#[cfg(unix)] -const SYNC_STDIN_EOF_RESULT_FILE_ENV: &str = "CODEWHALE_SYNC_STDIN_EOF_RESULT_FILE"; -/// Helper half of -/// `sync_lane_without_stdin_data_gets_eof_not_inherited_stdin`: runs one -/// synchronous (background=false) stdin-reading command from a process whose -/// own stdin is a pipe the driver deliberately holds open, and records the -/// resulting status for the driver to judge. -#[cfg(unix)] #[test] fn readonly_operand_gate_refuses_cd_operands_the_shell_reinterprets() { // zsh's EQUALS expansion turns `cd =ls` into `cd /bin/ls`: the shell's diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 881bad346f..67c33376a9 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -36,8 +36,8 @@ use crate::core::engine::tool_catalog::{ TOOL_SEARCH_NAME, active_tools_for_request, apply_native_tool_deferral, bounded_toolbox_eviction_note, ensure_advanced_tooling, execute_tool_search_with_cache, initial_active_tools, is_tool_search_tool, park_toolbox_eviction, remove_toolbox_drops, - render_toolbox_eviction_notes, tool_matches_any_rule, toolbox_oversize_reason, - touch_cached_tool_after_execution, + render_toolbox_eviction_notes, rule_matches_tool, tool_matches_any_rule, + toolbox_oversize_reason, touch_cached_tool_after_execution, }; use crate::core::events::{AgentProgressEventMeta, Event}; use crate::core::session::{PendingToolboxEviction, ToolActivationCache, ToolboxEvictionTrigger}; @@ -10622,6 +10622,11 @@ async fn spawn_subagent_from_input( .worker_profile .denied_tools .retain(|rule| is_posture_denial(rule)); + // The dropped preferences were the inherited operator rules; their + // explicit-bookkeeping twins go with them, so the child's explicit + // set holds exactly the caller's list below. An automatic clamp rule + // keeps its enforcement regardless. + child_runtime.worker_profile.explicit_denied_tools.clear(); } if let Some(ref caller_deny) = spawn_request.disallowed_tools { for tool in caller_deny { @@ -10633,6 +10638,21 @@ async fn spawn_subagent_from_input( { child_runtime.worker_profile.denied_tools.push(tool.clone()); } + // Bookkeep the caller's provenance even when the rule string was + // already present (e.g. the role clamp installed the same + // `Bash` rule): the carve-outs override the clamp's automatic + // denial, never the caller's. + if !child_runtime + .worker_profile + .explicit_denied_tools + .iter() + .any(|existing| existing == tool) + { + child_runtime + .worker_profile + .explicit_denied_tools + .push(tool.clone()); + } } } apply_spawn_write_authority(&mut child_runtime, &spawn_request); @@ -15632,6 +15652,12 @@ struct SubAgentToolRegistry { // parent/child scope. Deny rules always win, including canonical prefixes. allowed_tools: Option>, disallowed_tools: Vec, + // The operator/caller subset of `disallowed_tools` (session + // `--disallowed-tools` preferences plus the spawn request's explicit + // `disallowed_tools`). The strings overlap the role clamp's automatic + // rules, so provenance is carried: the bounded read-only bash lane + // overrides the clamp's automatic denial, never one of these. + operator_denied_tools: Vec, // Approval posture is separate from authority. Auto approval can remove a // prompt, but cannot restore a tool removed by role, scope, or envelope. auto_approve: bool, @@ -15768,6 +15794,7 @@ impl SubAgentToolRegistry { Self { allowed_tools, disallowed_tools: effective_profile.denied_tools.clone(), + operator_denied_tools: effective_profile.explicit_denied_tools.clone(), auto_approve: runtime.context.auto_approve, accept_edits: runtime.accept_edits, agent_type, @@ -16441,10 +16468,20 @@ impl SubAgentToolRegistry { /// case-insensitive compare would hand these roles back the raw shell this /// carve-out exists to withhold. Only the canonical lowercase tool the /// first-turn catalog actually offers is bounded by the classifier. + /// + /// The lane overrides exactly ONE denial source: the role clamp's + /// automatic raw-shell rules. An operator or caller who denied `bash` (or + /// any rule matching it — `exec_shell*`, a wildcard) by name keeps that + /// restriction authoritative at catalog admission and dispatch alike, so + /// any operator/caller rule matching `bash` disables the lane outright. fn allows_bounded_readonly_bash(&self, name: &str) -> bool { name == "bash" && crate::fleet::role::role_requires_read_only_shell(&self.agent_type) && self.runtime_profile.shell != crate::worker_profile::ShellPolicy::None + && !self + .operator_denied_tools + .iter() + .any(|rule| rule_matches_tool(rule, name)) } fn legacy_action_alias(family: &str, action: &str) -> Option<&'static str> { diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index c37e4311aa..f9bcff5ef7 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -8254,6 +8254,87 @@ async fn verifier_child_still_refuses_raw_shell_custom_commands_and_writes() { "workspace-write refusal did not identify the denial: {write}" ); } +/// The bounded read-only bash lane overrides exactly ONE denial source: the +/// role clamp's AUTOMATIC raw-shell rules. An operator (session +/// `--disallowed-tools`) or spawn caller who denies `bash` — by exact name +/// or by a rule that matches it (`ba*`, `exec_shell*`) — keeps that +/// restriction authoritative at BOTH boundaries the lane serves: the tool +/// leaves the model-visible catalog, and a direct dispatch is refused by the +/// name gate instead of being judged by the lane's read-only classifier. +/// The strings of an operator rule and the clamp's rule are identical +/// (`Bash`), so the provenance must be carried (`explicit_denied_tools`), +/// not re-derived — this pin holds the registry side of that contract. +#[tokio::test] +async fn verifier_explicit_bash_deny_rules_close_the_lane_at_admission_and_dispatch() { + for rule in ["bash", "ba*", "exec_shell*"] { + let tmp = tempdir().expect("tempdir"); + let authority = crate::fleet::role::ChildAuthority::from_runtime_role( + "verifier", + codewhale_workflow::PermissionCeiling::preset("full").expect("preset"), + ); + let mut runtime = + stub_runtime().with_agent_tool_surface_options(enabled_agent_surface_options()); + runtime.context = ToolContext::new(tmp.path().to_path_buf()); + runtime.allow_shell = true; + runtime.worker_profile = WorkerRuntimeProfile::for_role(FleetRole::Verifier); + runtime.worker_profile.denied_tools = authority.disallowed_tools.clone(); + // What the spawn merge does with the caller's `disallowed_tools` + // entry: installs the rule AND books its operator/caller provenance. + runtime.worker_profile.denied_tools.push(rule.to_string()); + runtime + .worker_profile + .explicit_denied_tools + .push(rule.to_string()); + let registry = SubAgentToolRegistry::new( + runtime, + FleetRole::Verifier, + authority.allowed_tools.clone(), + Arc::new(Mutex::new(TodoList::new())), + Arc::new(Mutex::new(PlanState::default())), + ); + + // Catalog admission: the model must not even see the tool. + let names: Vec = registry + .tools_for_model(&FleetRole::Verifier) + .iter() + .map(|tool| tool.name.clone()) + .collect(); + assert!( + !names.iter().any(|name| name == "bash"), + "explicit operator rule `{rule}` must keep bash out of the model-visible catalog: {names:?}" + ); + + // Dispatch: a hand-written call is refused before the lane's + // classifier could ever judge the command. With the lane closed, + // canonical bash is unhardened for this role, so the unhardened- + // process wall speaks first; the name gate is its backstop. Either + // refusal proves the operator rule outranks the lane's classifier. + let refused = registry + .execute( + "agent_verifier_explicit_deny", + "bash", + json!({"command": "wc -c src/main.rs"}), + ) + .await + .expect_err("explicit operator rule `{rule}` must refuse bash dispatch"); + let refused = refused.to_string(); + assert!( + refused.contains("not available to this read-only worker") + || refused.contains("not allowed for this sub-agent"), + "explicit operator rule `{rule}` refusal must come from a dispatch wall: {refused}" + ); + } + + // Control: the lane still exists to override the clamp's AUTOMATIC + // raw-shell denial — with no operator rule matching bash, the canonical + // tool is admitted (the probe execution itself is pinned by + // `verifier_child_executes_a_bounded_readonly_bash_probe_for_evidence`). + let (_tmp, control) = read_only_with_shell_registry(); + assert!( + control.is_tool_allowed("bash"), + "the bounded lane must keep overriding the automatic raw-shell denial when no operator rule matches bash" + ); +} /// A registry for a write-capable role whose parent handed it a read-only /// shell and no writes — what `derive_child` produces for a Builder, Worker, /// or Custom child of a Scout or Planner on the plain `agent` nesting path, diff --git a/crates/tui/src/worker_profile.rs b/crates/tui/src/worker_profile.rs index 0e322ef10e..2bf9f28f75 100644 --- a/crates/tui/src/worker_profile.rs +++ b/crates/tui/src/worker_profile.rs @@ -162,6 +162,19 @@ pub struct WorkerRuntimeProfile { /// which clears the cloned runtime's list before the registry reads it. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub denied_tools: Vec, + /// The subset of `denied_tools` that an **operator or spawn caller asked + /// for by name** (the session's `--disallowed-tools` preferences and the + /// spawn request's explicit `disallowed_tools`), as opposed to the rules a + /// role clamp installs automatically (the raw-shell, network, and + /// verification-surface lists). The strings of the two sources overlap, so + /// the distinction must be carried, not re-derived: the bounded read-only + /// bash lane exists to override the clamp's automatic denial, and must + /// never override one of these. + /// + /// A child can only ever *add* entries — `derive_child()` unions this list + /// the same way it unions `denied_tools`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub explicit_denied_tools: Vec, /// Remaining nested-delegation budget. A worker may spawn children while /// `max_spawn_depth > 0`; each level decrements it. Clamped to the workspace /// ceiling. @@ -250,6 +263,7 @@ impl WorkerRuntimeProfile { // wins via `derive_child`, same as every other role. reasoning_effort: matches!(role, FleetRole::Consultant).then(|| "high".to_string()), denied_tools: Vec::new(), + explicit_denied_tools: Vec::new(), max_spawn_depth: codewhale_config::DEFAULT_SPAWN_DEPTH, max_steps: Self::default_max_steps(role.clone()), background: true, @@ -282,6 +296,15 @@ impl WorkerRuntimeProfile { denied_tools.push(rule.clone()); } } + // Same union for the operator/caller subset: a descendant cannot drop + // an explicit restriction an ancestor carries, and only + // `inherit_disallowed_tools: false` may clear the inherited half. + let mut explicit_denied_tools = self.explicit_denied_tools.clone(); + for rule in &requested.explicit_denied_tools { + if !explicit_denied_tools.contains(rule) { + explicit_denied_tools.push(rule.clone()); + } + } let tools = match (&self.tools, &requested.tools) { // Parent restricts to a set → the child can only narrow within it. (ToolScope::Explicit(parent), ToolScope::Explicit(child)) => ToolScope::Explicit( @@ -315,6 +338,7 @@ impl WorkerRuntimeProfile { .clone() .or_else(|| self.reasoning_effort.clone()), denied_tools, + explicit_denied_tools, max_spawn_depth, max_steps: requested.max_steps, background: requested.background, @@ -615,6 +639,34 @@ mod tests { assert!(child.denied_tools.contains(&"write_file".to_string())); } + /// The operator/caller subset of the deny list unions the same way: a + /// descendant cannot drop an explicit restriction an ancestor carries. + /// The registry reads this list to keep role carve-outs (the bounded + /// read-only bash lane) from overriding operator rules whose strings are + /// identical to the clamp's own. + #[test] + fn child_explicit_denied_tools_union_never_drops_parent_rule() { + let mut parent = WorkerRuntimeProfile::for_role(FleetRole::Worker); + parent.explicit_denied_tools = vec!["bash".into()]; + let requested = WorkerRuntimeProfile::for_role(FleetRole::Builder); + + let child = parent.derive_child(&requested); + assert_eq!(child.explicit_denied_tools, vec!["bash".to_string()]); + + let mut parent_with_both = parent.clone(); + parent_with_both.explicit_denied_tools = vec!["bash".into(), "mcp_*".into()]; + let mut requesting_child = WorkerRuntimeProfile::for_role(FleetRole::Builder); + requesting_child.explicit_denied_tools = vec!["write_file".into()]; + let merged = parent_with_both.derive_child(&requesting_child); + for rule in ["bash", "mcp_*", "write_file"] { + assert!( + merged.explicit_denied_tools.contains(&rule.to_string()), + "explicit operator rule `{rule}` must survive derive_child: {:?}", + merged.explicit_denied_tools + ); + } + } + /// Every built-in role keeps network reads by default: a worker cut off /// from the network for no role reason cannot do its job. Only workspace /// mutation is a role intent.