agentmux_bashwrap/bash_wrap.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `exec` subcommand — runs the user's bash command and streams chunks.
5//!
6//! Invariants:
7//! - The user's command is decoded from `--b64-cmd` so quoting /
8//! multi-line / shell-metachar content survives the rewrite.
9//! - The inner command runs under `bash -c` inside a PTY by default
10//! (`run_via_pty`), with a pipe-stdio fallback (`run_via_pipes`)
11//! if PTY allocation fails. The PTY path keeps glibc's stdout
12//! line-buffered so partial chunks reach the overlay in real time;
13//! ConPTY's startup DSR (`\x1b[6n`, Windows only) is satisfied by
14//! pre-loading the master writer with `\x1b[1;1R`; the writer is held alive until
15//! after child.wait() (dropping it earlier sends CTRL_C_EVENT on
16//! Windows — ConPTY CONIN lifetime invariant, same as pair.master).
17//! The command is wrapped in a brace group redirected from /dev/null
18//! (`{ <cmd>; } </dev/null`) so stdin-reading children see EOF instead
19//! of blocking on the live PTY slave. We must NOT use `exec </dev/null`
20//! for this: running it inside bash closes the child's ConPTY console
21//! input from the child side, which ConPTY reports as a CTRL_C_EVENT,
22//! killing every command with exit 130 before it ran. The group
23//! redirect points only the group's fd 0 at /dev/null while bash's own
24//! console fd stays open, so children get EOF and ConPTY never fires.
25//! The pipe path remains as a safety net and is the only path that
26//! preserves the stdout/stderr split — PTY collapses both onto
27//! one stream. See `docs/specs/SPEC_LIVE_LOG_PTY_REWORK_2026_05_16.md`.
28//! - Bash is located via `$AGENTMUX_BASH` → `$BASH` → PATH search →
29//! well-known Windows locations (Git Bash). Fails loud if missing.
30//! - Each line is published as its own chunk with `kind` set to
31//! `"stdout"` (PTY path always; pipe path for stdout reads) or
32//! `"stderr"` (pipe path only — PTY merges both onto one stream).
33//! Aggregated output (with stderr lines prefixed `[stderr] ` in the
34//! pipe path) is captured into a buffer; on exit, a formatted summary
35//! lands on this process's stdout for Claude's native Bash tool to
36//! harvest as the `tool_result` content. Truncated head/tail at 50KB
37//! each per `docs/specs/SPEC_STREAMING_BASH_RUNNER_2026_05_11.md` §4.5.
38//! - If MPS publish fails (missing env, sidecar down, etc.), the
39//! command still runs to completion — output just doesn't stream.
40//! The model-visible blob is unaffected. A `kind: "system"` chunk
41//! announces the degradation at startup.
42
43#[cfg(windows)]
44use agentmux_common::win32::NoWindow;
45use anyhow::{Context, Result, anyhow};
46use clap::Parser;
47use portable_pty::{CommandBuilder, PtySize, native_pty_system};
48use std::path::{Path, PathBuf};
49use std::process::Stdio;
50use std::sync::Arc;
51use std::time::Duration;
52use tokio::io::AsyncReadExt;
53use tokio::process::Command;
54use tokio::sync::{Mutex, mpsc, oneshot};
55
56use crate::mps_client::WpsClient;
57
58/// CLI args for `exec`. `command` carried as base64 to sidestep every
59/// quoting concern in the shell that invokes us.
60#[derive(Parser, Debug)]
61pub struct Args {
62 /// Tool-use id from Claude's `tool_use` event; threaded back as
63 /// the MPS subject suffix so the frontend correlates chunks with
64 /// the matching ToolNode.
65 #[arg(long)]
66 pub tool_id: String,
67
68 /// URL-safe base64 (no padding) of the original command string.
69 #[arg(long)]
70 pub b64_cmd: String,
71
72 /// Optional block id — used as the `block:<id>` MPS scope so
73 /// chunks only reach subscribers watching this block. When
74 /// omitted, chunks publish unscoped.
75 #[arg(long)]
76 pub block_id: Option<String>,
77
78 /// True when the CLI's own Bash tool call was made with
79 /// `run_in_background: true` (threaded through by
80 /// `hook.rs::build_response` from the PreToolUse payload's
81 /// `tool_input.run_in_background`). A declared-background task may
82 /// legitimately produce zero output forever (a GUI window, a dev
83 /// server once its logs go quiet) — the idle-kill heuristic exists for
84 /// commands that SHOULD exit and are stuck, not for this class. See
85 /// `effective_idle_timeout` below, issue #2491, and
86 /// docs/retro/RETRO_TASK_DEV_IDLE_KILL_FALSE_POSITIVE_2026_07_31.md.
87 #[arg(long, default_value_t = false)]
88 pub declared_background: bool,
89}
90
91// ─────────────────────────────────────────────────────────────────────────────
92// Cwd persistence across one-shot `exec` invocations
93//
94// Every Bash tool call the PreToolUse hook rewrites spawns a brand-new,
95// disposable `agentmux-bashwrap exec` process (see main.rs's module doc —
96// there is no daemon). Historically the inner bash's starting directory was
97// seeded from `std::env::current_dir()`, i.e. THIS process's own inherited
98// cwd, which is always the agent's fixed home directory (nothing in this
99// process tree ever chdirs it). A `cd` run by the wrapped command only ever
100// affected that one throwaway inner bash; the moment it exited, the change
101// was gone, and the NEXT call started over at the same fixed directory again
102// — every time, not intermittently. Claude Code's own Bash tool expects `cd`
103// continuity across calls within a session and (at least on Windows, where
104// this rewrite always applies) surfaces the mismatch as a
105// "Shell cwd was reset to ..." notice on effectively every call. See
106// docs/retro/RETRO_BASH_CWD_RESET_NOTICE_WINDOWS_2026_08_02.md for the full
107// investigation.
108//
109// The fix: persist the shell's ending `$PWD` to a small per-agent state file
110// after each call, and restore it as the starting directory of the next
111// call. This doesn't change what Claude Code itself believes (that's opaque,
112// out of process) but it does make the ACTUAL directory a `cd` in one Bash
113// call leaves the agent in survive to the next call, which is the part that
114// was silently broken.
115// ─────────────────────────────────────────────────────────────────────────────
116
117/// Env var bashwrap sets on the *child* bash process (never interpolated
118/// into the script text, so no path-quoting concerns) pointing at the state
119/// file `append_cwd_capture`'s appended script writes the ending `$PWD` to.
120const CWD_STATE_ENV: &str = "AGENTMUX_BASHWRAP_CWD_STATE";
121
122/// Optional override of the state file's own path, read from bashwrap's own
123/// process env (distinct from `CWD_STATE_ENV`, which bashwrap sets rather
124/// than reads). Exists so tests can point persistence at a tempdir instead
125/// of a real agent's `~/.agentmux` state, and as an escape hatch for manual
126/// debugging.
127const CWD_STATE_FILE_OVERRIDE_ENV: &str = "AGENTMUX_BASHWRAP_CWD_STATE_FILE";
128
129/// Resolves the cwd-persistence file location and any previously-restored
130/// starting directory once per `exec` invocation.
131struct CwdState {
132 /// Where the ending `$PWD` gets written after this command runs, for the
133 /// *next* `exec` invocation to pick up. `None` if we couldn't resolve a
134 /// location or create its parent dir — persistence is then simply
135 /// skipped for this call (never a hard failure; falls back to today's
136 /// pre-fix behavior).
137 path: Option<PathBuf>,
138 /// The directory to actually start this command's shell in: the
139 /// previously-persisted directory if one was found and still exists,
140 /// otherwise this process's own (fixed, inherited) cwd — the same
141 /// default used before this fix existed.
142 start_dir: Option<PathBuf>,
143}
144
145impl CwdState {
146 fn load() -> Self {
147 let mut path = cwd_state_path();
148 if let Some(dir) = path.as_deref().and_then(Path::parent) {
149 if let Err(e) = std::fs::create_dir_all(dir) {
150 tracing::warn!(
151 target: "bashwrap",
152 error = %e,
153 dir = %dir.display(),
154 "failed to create cwd-state dir; cwd persistence disabled for this call",
155 );
156 // reagentx P2 (re-review): the log line above claims
157 // persistence is disabled, but `path` used to stay `Some`
158 // here regardless — restore_cwd below would still attempt
159 // (and silently fail) a read, and the child would still get
160 // CWD_STATE_ENV pointed at a directory that doesn't exist,
161 // so append_cwd_capture's write would silently fail on
162 // every single call forever instead of being skipped once,
163 // as documented. Actually clear it so both sides honor the
164 // "disabled" contract `path`'s own doc comment promises.
165 path = None;
166 }
167 }
168 let start_dir = path
169 .as_deref()
170 .and_then(restore_cwd)
171 .or_else(|| std::env::current_dir().ok());
172 Self { path, start_dir }
173 }
174}
175
176/// Per-*instance* state file path. Keyed by `AGENTMUX_INSTANCE_SLUG` — set by
177/// `agent-model.ts` to a per-launch slug that also seeds that instance's own
178/// unique working directory (`${agentmuxHome()}/agents/${instanceSlug}`) —
179/// NOT `AGENTMUX_AGENT_ID`, which is the definition-wide slug shared by every
180/// named instance launched from the same agent definition (codex P1 on this
181/// PR: keying by `AGENTMUX_AGENT_ID` made two concurrently-running instances
182/// of the same definition silently share one cwd file, so one instance's
183/// `cd` could redirect the other's next command into the wrong worktree).
184/// Falls back to `AGENTMUX_AGENT_ID` for older callers that only set that,
185/// then to a sanitized form of this process's own cwd (always the instance's
186/// fixed home directory either way) for the rare case neither is set, e.g. a
187/// manual invocation outside AgentMux.
188fn cwd_state_path() -> Option<PathBuf> {
189 if let Ok(over) = std::env::var(CWD_STATE_FILE_OVERRIDE_ENV) {
190 if !over.is_empty() {
191 return Some(PathBuf::from(over));
192 }
193 }
194 let dir = dirs::home_dir()?
195 .join(".agentmux")
196 .join("state")
197 .join("bashwrap-cwd");
198 let non_empty = |s: String| (!s.is_empty()).then_some(s);
199 let key = std::env::var("AGENTMUX_INSTANCE_SLUG")
200 .ok()
201 .and_then(non_empty)
202 .or_else(|| std::env::var("AGENTMUX_AGENT_ID").ok().and_then(non_empty))
203 .or_else(|| {
204 std::env::current_dir()
205 .ok()
206 .map(|p| p.to_string_lossy().into_owned())
207 })?;
208 Some(dir.join(format!("{}.cwd", sanitize_state_key(&key))))
209}
210
211/// Filesystem-safe form of an arbitrary key for use as a filename component.
212fn sanitize_state_key(raw: &str) -> String {
213 raw.chars()
214 .map(|c| {
215 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
216 c
217 } else {
218 '_'
219 }
220 })
221 .collect()
222}
223
224/// Read back a previously-persisted cwd, if the file exists, is non-empty,
225/// and still names a real directory (it may not — the directory could have
226/// been deleted since, or this is the first call for this agent).
227fn restore_cwd(state_path: &Path) -> Option<PathBuf> {
228 let contents = std::fs::read_to_string(state_path).ok()?;
229 let trimmed = contents.trim();
230 if trimmed.is_empty() {
231 return None;
232 }
233 let path = PathBuf::from(trimmed);
234 path.is_dir().then_some(path)
235}
236
237/// Appends bookkeeping to `command_block` that (1) captures its real exit
238/// code before running anything else, (2) persists the shell's ending
239/// `$PWD` to `$AGENTMUX_BASHWRAP_CWD_STATE` if that env var is set (skipped
240/// entirely otherwise — e.g. when `CwdState::path` was `None`), written via
241/// a temp-file-then-rename so a mid-write crash can't leave a half-written
242/// state file, and (3) re-exits with the captured code so this bookkeeping
243/// never changes the command's real exit status. `command_block` must be a
244/// brace group or bare command, not something that already ends in its own
245/// `exit`.
246///
247/// The temp file is suffixed with `$$` (this bash's own PID), not a fixed
248/// name — reagentx P2 on this PR: two Bash tool calls for the same
249/// agent/instance can run concurrently (Claude issues parallel tool calls),
250/// and a shared fixed temp name meant two concurrent writers could
251/// interleave writes to the *same* temp file before either renamed it,
252/// corrupting the persisted cwd. Per-PID temp names make each writer's
253/// temp-then-rename independent; the only remaining race is which
254/// completed rename lands last, which is an inherent, expected ambiguity
255/// for genuinely concurrent `cd`s (no different from two parallel shells
256/// racing to `cd` a shared session) — not data corruption.
257fn append_cwd_capture(command_block: &str) -> String {
258 // `pwd` alone prints MSYS-style paths under Git Bash (`/c/Users/...`),
259 // which Rust's `std::fs`/`Path::is_dir()` on native Windows can't
260 // resolve (it looks for a literal `\c\Users\...` under the current
261 // drive root). `-W` is Git Bash's own flag for "print the Windows-
262 // native form instead" (`C:/Users/...`), which both `restore_cwd`'s
263 // `is_dir()` check and `cmd.cwd()`/`cmd.current_dir()` understand
264 // correctly. Not a POSIX `pwd` flag — Unix has no such mismatch to
265 // work around, so plain `pwd` is correct there.
266 let pwd_cmd = if cfg!(windows) { "pwd -W" } else { "pwd" };
267 format!(
268 "{command_block}\n\
269__agentmux_bashwrap_rc=$?\n\
270if [ -n \"${CWD_STATE_ENV}\" ]; then\n\
271 __agentmux_bashwrap_cwd_tmp=\"${CWD_STATE_ENV}.tmp.$$\"\n\
272 {pwd_cmd} > \"$__agentmux_bashwrap_cwd_tmp\" 2>/dev/null && mv -f \"$__agentmux_bashwrap_cwd_tmp\" \"${CWD_STATE_ENV}\" 2>/dev/null\n\
273fi\n\
274exit $__agentmux_bashwrap_rc"
275 )
276}
277
278/// Cap the model-visible head/tail sections of the aggregated blob.
279const MODEL_BLOB_HEAD_BYTES: usize = 50_000;
280const MODEL_BLOB_TAIL_BYTES: usize = 50_000;
281
282/// Flush pending bytes to the publisher when buffered without a
283/// newline AND the buffer reaches this size — so long lines and
284/// newline-free output (minified JSON, CR-only progress bars) still
285/// stream live instead of accumulating in memory unbounded.
286const FLUSH_BYTES: usize = 4096;
287
288/// Size of the PTY commands run in. Wider than the classic 80 so that
289/// width-aware programs (tables, `--help`, `ls` columns) don't fold their
290/// output with hard line breaks; previews scroll long lines instead. Bash
291/// derives `COLUMNS` / `LINES` from it.
292const PTY_COLS: u16 = 200;
293const PTY_ROWS: u16 = 50;
294
295/// Flush pending bytes after this much idle time even if no newline /
296/// size threshold has been hit — keeps slow-trickle output visible
297/// without waiting for the next byte that would have triggered a
298/// natural flush.
299const FLUSH_QUIET_WINDOW: Duration = Duration::from_millis(50);
300
301/// If a wrapped command's PTY produces zero bytes of output for this long,
302/// assume it's blocked waiting for interactive input that will never come
303/// (e.g. `less`/`more` invoked as a pager, since the PTY deliberately makes
304/// the child see `isatty(stdout) == true` and this wrapper never writes to
305/// the PTY again after the startup DSR response) and forcibly kill it
306/// rather than leak this whole process forever. See
307/// docs/retro/RETRO_BASHWRAP_PAGER_HANG_LEAK_2026_07_14.md.
308///
309/// This is an IDLE timeout, not a total-runtime timeout: a command that's
310/// silent for under this long but runs far longer overall (e.g. a build
311/// with continuous compiler output over several minutes) is unaffected —
312/// only a command producing literally zero bytes for the full window trips
313/// it. Overridable via `AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS`.
314const DEFAULT_IDLE_KILL_TIMEOUT: Duration = Duration::from_secs(600);
315
316fn idle_kill_timeout() -> Duration {
317 std::env::var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS")
318 .ok()
319 .and_then(|s| s.parse::<u64>().ok())
320 .map(Duration::from_secs)
321 .unwrap_or(DEFAULT_IDLE_KILL_TIMEOUT)
322}
323
324/// The idle-kill timeout to actually apply for this invocation. A
325/// declared-background call (`args.declared_background`, threaded from the
326/// CLI's own `run_in_background: true`) is exempt from the idle-kill safety
327/// net entirely — it may legitimately go silent forever (a GUI window, a dev
328/// server) without that being a stuck/pager-hung command. Everything else
329/// keeps the ordinary `idle_kill_timeout()`. See issue #2491.
330fn effective_idle_timeout(args: &Args) -> Duration {
331 if args.declared_background {
332 Duration::from_secs(u64::MAX)
333 } else {
334 idle_kill_timeout()
335 }
336}
337
338/// For a `--declared-background` invocation, detach this process into a
339/// brand-new session (`setsid()`) before doing anything else, so it has no
340/// controlling terminal at all.
341///
342/// Without this, this process (and anything it spawns — the wrapped
343/// command's own PTY-hosted child, e.g. `task dev`) stays in the SAME
344/// session as the `claude` CLI process that invoked it (an ordinary child
345/// inherits its parent's session/process group unless it explicitly
346/// detaches). Two independent POSIX mechanisms then both threaten it the
347/// moment that session's leader (`claude`) is killed for ANY reason,
348/// including a clean single-PID kill that never touches this process
349/// directly:
350/// 1. If `claude`'s own PTY master is closed (agentmux-srv's normal
351/// stop/replace path drops its input channel, which drops the PTY
352/// writer+master — see `blockcontroller/shell/lifecycle.rs`), the
353/// kernel hangs up the terminal, sending SIGHUP to the whole foreground
354/// process group of that terminal.
355/// 2. Independently of (1): POSIX also sends SIGHUP to a session's
356/// foreground process group when the session LEADER terminates — this
357/// fires purely from `claude` exiting, with no PTY-close step required
358/// at all.
359/// Either one, by default, terminates this process (SIGHUP's default
360/// disposition) — and if this process is what's directly holding open the
361/// wrapped command's own PTY master (the common case, via `run_via_pty`),
362/// this process dying closes that PTY too, cascading the exact same
363/// hangup one level deeper to the wrapped command itself. `setsid()`
364/// makes this process (and, transitively, anything it PTY-spawns
365/// afterward, which becomes associated with THIS new session rather than
366/// `claude`'s) immune to both — a session with no controlling terminal at
367/// all has no foreground process group for the kernel to signal. See
368/// docs/specs/SPEC_BACKGROUND_TASK_TEARDOWN_SURVIVAL_2026_08_20.md and the
369/// Codex finding on PR #2683 this was written to address.
370///
371/// A plain child (not already a process group leader — always true here,
372/// since this process was just `exec`'d as an ordinary child of `claude`)
373/// can always call `setsid()` successfully; a failure would be a genuine
374/// platform anomaly, logged but not fatal — worst case this invocation
375/// keeps the pre-existing (already-buggy, already being fixed elsewhere in
376/// this same PR) exposure, rather than losing the command's actual output.
377/// No-op on Windows (no session/controlling-terminal concept to detach
378/// from) and for an ordinary, non-backgrounded invocation.
379#[cfg(unix)]
380fn detach_declared_background_session(args: &Args) {
381 if !args.declared_background {
382 return;
383 }
384 // SAFETY: setsid() is a well-defined POSIX syscall; this process is
385 // not a process group leader (an ordinary freshly-exec'd child never
386 // is), so EPERM (the only failure mode) cannot occur in practice.
387 let rc = unsafe { libc::setsid() };
388 if rc == -1 {
389 tracing::warn!(
390 target: "bashwrap",
391 tool_id = %args.tool_id,
392 error = %std::io::Error::last_os_error(),
393 "setsid() failed for a declared-background invocation — this task remains exposed to SIGHUP if the owning agent session is torn down",
394 );
395 }
396}
397
398#[cfg(not(unix))]
399fn detach_declared_background_session(_args: &Args) {}
400
401/// Kill `pid` AND every descendant it spawned, not just the one process.
402/// Supplements `ChildKiller::kill()` (portable-pty's Windows impl is a bare
403/// `TerminateProcess` on a single handle, no job object — see the caller's
404/// comment) — without this, a wrapped command whose direct child forks
405/// further (a pipeline, or `git` spawning `less` as a child) can leave an
406/// orphaned grandchild running and still attached to the PTY slave after
407/// the "kill" (reagent P1, PR #2156).
408///
409/// `agentmux_common::process::force_kill_tree` does the killing, by PID only:
410///
411/// Windows: `taskkill /F /T /PID <pid>` (console flash suppressed) walks the
412/// OS-level parent-PID tree, independent of shell job control, and
413/// force-kills every process in it.
414///
415/// Unix: SIGKILL to the child's process group. portable-pty makes the PTY
416/// child a session and group leader (`setsid`), so its pgid is its pid and
417/// the group holds the command, its pipeline and what it backgrounded with
418/// `&`. portable-pty's own `ChildKiller` only SIGHUPs the one pid, which a
419/// `nohup`'d child ignores. A child that started its own session is out of
420/// reach here; the agent's process tracker ends it with the agent.
421fn kill_process_tree(pid: u32) {
422 match agentmux_common::process::force_kill_tree(pid) {
423 Ok(()) => tracing::info!(target: "bashwrap", pid, "kill_process_tree: tree killed"),
424 // Non-fatal: the direct-handle kill above may have already won the
425 // race (the tree is then "not found"), which is fine — best-effort
426 // supplemental cleanup either way.
427 Err(e) => tracing::warn!(target: "bashwrap", pid, error = %e, "kill_process_tree: kill did not report success"),
428 }
429}
430
431/// Internal channel payload: a single line tagged with its source.
432/// Bytes are raw — UTF-8 conversion is deferred to the publish /
433/// aggregation site so non-UTF-8 output (binary `cat`, Windows
434/// legacy-encoded tools) doesn't abort the reader.
435#[derive(Debug)]
436struct LineEvent {
437 kind: ToolChunkKind, // Stdout or Stderr
438 /// Raw bytes including the trailing `\n` if present. May be the
439 /// final fragment before EOF without a newline, in which case it
440 /// has no trailing delimiter.
441 bytes: Vec<u8>,
442}
443
444/// Returns the inner command's exit code so main.rs can mirror it as
445/// the wrapper's own process exit. Without this, Claude's native Bash
446/// tool would see success for every wrapped command regardless of the
447/// actual outcome.
448pub async fn run(args: Args) -> Result<i32> {
449 let command = decode_command(&args.b64_cmd)?;
450 run_command(args, command).await
451}
452
453pub(crate) async fn run_command(mut args: Args, command: String) -> Result<i32> {
454 detach_declared_background_session(&args);
455 log_relevant_env();
456
457 // `block_id` controls the MPS publish scope. Prefer the explicit
458 // CLI arg, but fall back to AGENTMUX_BLOCKID env (set by
459 // agentmux-srv when spawning Claude) so the hook doesn't have to
460 // pass it explicitly. Without a scope, the frontend's per-block
461 // subscription won't receive the events.
462 if args.block_id.as_deref().filter(|s| !s.is_empty()).is_none() {
463 if let Ok(v) = std::env::var("AGENTMUX_BLOCKID") {
464 if !v.is_empty() {
465 args.block_id = Some(v);
466 }
467 }
468 }
469
470 let mps = WpsClient::from_env();
471 let degraded = mps.is_none();
472
473 let buffered = Arc::new(Mutex::new(Vec::<u8>::with_capacity(64 * 1024)));
474
475 tracing::info!(
476 target: "bashwrap",
477 tool_id = %args.tool_id,
478 block_id = %args.block_id.as_deref().unwrap_or(""),
479 degraded,
480 command_len = command.len(),
481 "exec start"
482 );
483
484 // Join handle for the fire-and-forget pid publish below, if spawned.
485 // `main.rs` calls `std::process::exit()` immediately once `run()`
486 // returns — which tears down every still-in-flight spawned task with
487 // no Drop glue, silently. Every OTHER spawned task in this file
488 // (`idle_watcher`, `publisher_handle`, `wait_task`, the
489 // `kill_process_tree` spawn_blocking) is explicitly joined with a
490 // bounded timeout before `run()` returns for exactly this reason; this
491 // one must be too (reagentx P1, PR #2681) — joined below, after
492 // `run_proc` completes, so it still doesn't block a genuinely
493 // long-running declared-background command's own startup (by the time
494 // a long-running command's `run_proc` finally returns, this task has
495 // long since finished on its own — the join below is only ever a real
496 // wait in the narrow case `run_proc` returns quickly, e.g. the wrapped
497 // command fails fast).
498 let mut pid_publish: Option<tokio::task::JoinHandle<()>> = None;
499
500 if degraded {
501 // Surface degradation as a real chunk on stdout (we'll prefix
502 // it on the model side too) so the user sees a clear "no
503 // streaming this turn" message in the overlay. MPS publish
504 // would no-op, so we skip it.
505 let warn = b"[bashwrap] warning: streaming disabled (auth/url env missing); command output will only appear on completion\n";
506 buffered.lock().await.extend_from_slice(warn);
507 } else if let Some(client) = mps.as_ref() {
508 match publish_system(
509 client,
510 &args.tool_id,
511 args.block_id.as_deref(),
512 &format!("[bashwrap] starting: {} chars", command.len()),
513 )
514 .await
515 {
516 Ok(()) => tracing::info!(target: "bashwrap", tool_id = %args.tool_id, "initial publish ok"),
517 Err(e) => tracing::warn!(target: "bashwrap", tool_id = %args.tool_id, error = %e, "initial publish failed"),
518 }
519 if args.declared_background {
520 // Fire-and-forget, NOT awaited inline: publish_pid's own doc
521 // comment promises best-effort semantics, and awaiting it
522 // right here (as an earlier version of this code did) put its
523 // up-to-~10s worst case (two publish_chunk attempts, each up
524 // to the 5s WpsClient::from_env default, plus the retry's own
525 // 250ms sleep) directly on this declared-background command's
526 // startup path — measurably delaying the actual work (e.g.
527 // `task dev`) behind a degraded MPS endpoint, exactly what
528 // "best-effort" is supposed to avoid (reagentx P1, PR #2681).
529 // Still joined (bounded) below before `run()` returns, so a
530 // fast-exiting wrapped command can't silently abandon it.
531 let client = client.clone();
532 let tool_id = args.tool_id.clone();
533 let block_id = args.block_id.clone();
534 pid_publish = Some(tokio::spawn(async move {
535 publish_pid(&client, &tool_id, block_id.as_deref(), std::process::id()).await;
536 }));
537 }
538 }
539
540 let start = std::time::Instant::now();
541 // NOT `?` here — captured instead of propagated immediately. `run_proc`
542 // can fail fast (e.g. `locate_bash()`/PTY-or-pipe spawn failure) and an
543 // early `?`-return would skip the pid_publish join below entirely,
544 // reproducing the exact abandonment bug the join exists to prevent, just
545 // via the Err path instead of a fast Ok exit (reagentx P1, round 3 on
546 // this PR — the round-2 fix only covered the Ok(status) fast-exit case).
547 let run_result = run_proc(&args, &command, mps.as_ref(), buffered.clone()).await;
548 let elapsed = start.elapsed();
549
550 if let Some(handle) = pid_publish {
551 // Bound slightly above publish_pid's own worst case (~10.25s: two
552 // publish attempts at up to 5s each + the 250ms retry sleep) so
553 // this only ever cuts it short in a genuinely wedged case, not a
554 // normal slow-but-still-within-its-own-bounds one.
555 match tokio::time::timeout(Duration::from_secs(11), handle).await {
556 Ok(Ok(())) => {}
557 Ok(Err(join_err)) => {
558 tracing::warn!(target: "bashwrap", tool_id = %args.tool_id, error = %join_err, "pid publish task panicked");
559 }
560 Err(_elapsed) => {
561 tracing::warn!(target: "bashwrap", tool_id = %args.tool_id, "pid publish task still running after its own bound — abandoning rather than delaying exit further");
562 }
563 }
564 }
565
566 let status = run_result?;
567
568 if let Some(client) = mps.as_ref() {
569 let _ = client
570 .publish_chunk(
571 args.block_id.as_deref(),
572 &ToolChunk::Terminal {
573 tool_id: args.tool_id.clone(),
574 exit_code: status,
575 timestamp: now_ms(),
576 },
577 )
578 .await;
579 }
580
581 let buf = buffered.lock().await;
582 let mut model_blob = format_model_blob(&buf, status, elapsed);
583 if let Some(hint) = access_denied_hint(&buf, status) {
584 model_blob.push_str(hint);
585 }
586 print!("{}", model_blob);
587 Ok(status)
588}
589
590fn decode_command(b64: &str) -> Result<String> {
591 use base64::Engine as _;
592 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
593 let bytes = URL_SAFE_NO_PAD
594 .decode(b64.as_bytes())
595 .context("decoding --b64-cmd")?;
596 Ok(String::from_utf8(bytes).context("--b64-cmd is not valid UTF-8")?)
597}
598
599use agentmux_common::client_events::{ToolChunk, ToolChunkKind};
600use agentmux_common::time::now_ms_u64 as now_ms;
601
602/// Log which streaming-relevant env vars the wrapper actually received,
603/// so the sidecar log tells us at a glance whether the env-propagation
604/// chain from agentmux-srv → claude → bash → hook → here is intact.
605/// Values are not logged (auth key is sensitive); presence/absence is
606/// the only signal we need to triage the propagation gap.
607fn log_relevant_env() {
608 let has = |k: &str| std::env::var(k).is_ok();
609 tracing::info!(
610 target: "bashwrap",
611 auth_key = has("AGENTMUX_AUTH_KEY"),
612 local_url = has("AGENTMUX_LOCAL_URL"),
613 agent_id = has("AGENTMUX_AGENT_ID"),
614 block_id = has("AGENTMUX_BLOCKID"),
615 bash = std::env::var("BASH").ok(),
616 agentmux_bash = std::env::var("AGENTMUX_BASH").ok(),
617 "bashwrap exec env snapshot"
618 );
619}
620
621async fn publish_system(
622 client: &WpsClient,
623 tool_id: &str,
624 block_id: Option<&str>,
625 msg: &str,
626) -> Result<()> {
627 client
628 .publish_chunk(
629 block_id,
630 &ToolChunk::Chunk {
631 tool_id: tool_id.to_string(),
632 kind: ToolChunkKind::System,
633 content: msg.to_string(),
634 timestamp: now_ms(),
635 },
636 )
637 .await
638}
639
640/// Publish this process's own pid, once, for a declared-background
641/// invocation. Called via `tokio::spawn` (never awaited inline) — see the
642/// call site's comment for why blocking startup on this was a real
643/// latency bug (reagentx P1, PR #2681).
644///
645/// The single retry here is insurance against a transient MPS/HTTP
646/// publish failure ONLY — it does not, and cannot, wait for
647/// `db_background_tasks`'s row to exist first (this function has no
648/// visibility into srv's database state). This publish routinely arrives
649/// at srv before that row exists at all (this call fires at process
650/// start, before the frontend has even seen an accepted-background tool
651/// result to create the row from) — that ordering race is handled
652/// entirely server-side, by stashing an early pid and applying it once
653/// the row is created (`pending_background_pids.rs`,
654/// `websocket.rs`'s `COMMAND_BACKGROUND_TASK_PID`/`COMMAND_DOCK_NODE_STATUS`
655/// handlers). An earlier version of this comment claimed this retry
656/// covered that race — it never did (Codex P1, reagentx P2, PR #2681).
657async fn publish_pid(client: &WpsClient, tool_id: &str, block_id: Option<&str>, pid: u32) {
658 let msg = ToolChunk::Pid {
659 tool_id: tool_id.to_string(),
660 pid,
661 timestamp: now_ms(),
662 };
663 if client.publish_chunk(block_id, &msg).await.is_ok() {
664 return;
665 }
666 tokio::time::sleep(Duration::from_millis(250)).await;
667 if let Err(e) = client.publish_chunk(block_id, &msg).await {
668 tracing::warn!(target: "bashwrap", tool_id, pid, error = %e, "pid publish failed after retry");
669 }
670}
671
672async fn publish_line(
673 client: &WpsClient,
674 tool_id: &str,
675 block_id: Option<&str>,
676 kind: ToolChunkKind,
677 line: &str,
678) -> Result<()> {
679 client
680 .publish_chunk(
681 block_id,
682 &ToolChunk::Chunk {
683 tool_id: tool_id.to_string(),
684 kind,
685 content: line.to_string(),
686 timestamp: now_ms(),
687 },
688 )
689 .await
690}
691
692/// Locate bash. Order:
693/// 1. `$AGENTMUX_BASH` — explicit override from agentmux-srv config.
694/// 2. `$BASH` — set by bash itself when it spawns a child; reliable
695/// inside Claude Code's hook context because Claude shells out to
696/// bash to run the hook.
697/// 3. `which bash` on PATH.
698/// 4. Well-known Windows paths (Git Bash, msys2). Skipped on Unix.
699///
700/// Fail-loud if not found — pass-through fallback would hide the issue.
701pub(crate) fn locate_bash() -> Result<PathBuf> {
702 if let Ok(p) = std::env::var("AGENTMUX_BASH") {
703 if !p.is_empty() {
704 return Ok(PathBuf::from(p));
705 }
706 }
707 if let Ok(p) = std::env::var("BASH") {
708 if !p.is_empty() && PathBuf::from(&p).exists() {
709 return Ok(PathBuf::from(p));
710 }
711 }
712 if let Ok(p) = which::which("bash") {
713 return Ok(p);
714 }
715 #[cfg(windows)]
716 {
717 for candidate in [
718 r"C:\Program Files\Git\bin\bash.exe",
719 r"C:\Program Files\Git\usr\bin\bash.exe",
720 r"C:\Program Files (x86)\Git\bin\bash.exe",
721 r"C:\msys64\usr\bin\bash.exe",
722 r"C:\msys2\usr\bin\bash.exe",
723 ] {
724 let p = PathBuf::from(candidate);
725 if p.exists() {
726 return Ok(p);
727 }
728 }
729 }
730 Err(anyhow!(
731 "bash not found — set AGENTMUX_BASH, install Git Bash, or add bash to PATH"
732 ))
733}
734
735/// Read raw bytes from a child stdio handle and forward chunks to
736/// the publisher with three flush triggers:
737///
738/// 1. **Newline.** As soon as a `\n` is in the buffer, drain everything
739/// up to and including that newline as one chunk. Loop, since a
740/// single read can contain multiple complete lines.
741/// 2. **Size threshold** (`FLUSH_BYTES`). If the buffer accumulates
742/// without a newline (minified JSON output, long progress lines,
743/// `printf` without `\n`), flush as soon as it exceeds the
744/// threshold. Prevents unbounded memory growth and keeps streaming
745/// live for newline-free output.
746/// 3. **Quiet-window** (`FLUSH_QUIET_WINDOW`). After this much idle
747/// time with non-empty pending bytes, flush. Surfaces slow-trickle
748/// output (one byte at a time) that would otherwise wait for the
749/// next byte to trigger a natural flush.
750async fn stream_reader<R>(
751 mut reader: R,
752 kind: ToolChunkKind,
753 tx: mpsc::Sender<LineEvent>,
754 last_activity: Arc<std::sync::Mutex<std::time::Instant>>,
755)
756where
757 R: tokio::io::AsyncRead + Unpin,
758{
759 let mut pending: Vec<u8> = Vec::with_capacity(8192);
760 let mut buf = [0u8; 8192];
761 // P1b: CR override slot — when the quiet window expires and `pending`
762 // starts with `\r`, hold it here rather than flushing immediately.
763 // The next read prepends it so collapse_cr can overwrite it with the
764 // new frame, collapsing throttled spinner frames (>50 ms apart) before
765 // they become separate LineEvents.
766 let mut pending_cr_override: Option<Vec<u8>> = None;
767 // A1: speculative one-tick hold for a pending line with no leading OR
768 // trailing `\r` yet. Mirrors the same slot in `pty_reader_loop`. See
769 // docs/specs/SPEC_TOOL_LOG_UNIVERSAL_ANIMATION_COLLAPSE_2026_07_27.md §A1.
770 let mut deferred_pending: Option<Vec<u8>> = None;
771 loop {
772 match tokio::time::timeout(FLUSH_QUIET_WINDOW, reader.read(&mut buf)).await {
773 Ok(Ok(0)) => {
774 // EOF: flush any held CR override or A1 deferred line, then
775 // drain remainder, stripping a dangling lone \r (stream
776 // ended mid-spinner or mid-CRLF pair).
777 if let Some(mut held) = pending_cr_override.take() {
778 if held.last() == Some(&b'\r') {
779 held.pop();
780 }
781 if !held.is_empty() {
782 let _ = tx.send(LineEvent { kind, bytes: held }).await;
783 }
784 }
785 if let Some(deferred) = deferred_pending.take() {
786 let _ = tx.send(LineEvent { kind, bytes: deferred }).await;
787 }
788 if !pending.is_empty() {
789 if pending.last() == Some(&b'\r') {
790 pending.pop();
791 }
792 if !pending.is_empty() {
793 let _ = tx
794 .send(LineEvent { kind, bytes: std::mem::take(&mut pending) })
795 .await;
796 }
797 }
798 return;
799 }
800 Ok(Ok(n)) => {
801 // Idle-kill tracking (mirrors the PTY path's pty_reader_loop):
802 // reset on ANY bytes read, regardless of whether they become
803 // a published LineEvent — see run_via_pipes's idle watcher.
804 *last_activity.lock().unwrap_or_else(|e| e.into_inner()) = std::time::Instant::now();
805 // P1b: prepend any held CR override so collapse_cr can
806 // overwrite it with the new frame content.
807 if let Some(held) = pending_cr_override.take() {
808 let mut combined = held;
809 combined.extend_from_slice(&buf[..n]);
810 pending.splice(0..0, combined);
811 } else if let Some(deferred) = deferred_pending.take() {
812 // A1: resolve last tick's speculative hold. A leading
813 // `\r` means this is the overwrite we were hoping for;
814 // otherwise the deferred line was genuinely final and
815 // unrelated — flush it on its own first.
816 if buf[..n].first() == Some(&b'\r') {
817 let mut combined = deferred;
818 combined.extend_from_slice(&buf[..n]);
819 pending.splice(0..0, combined);
820 } else {
821 if tx.send(LineEvent { kind, bytes: deferred }).await.is_err() {
822 return;
823 }
824 pending.extend_from_slice(&buf[..n]);
825 }
826 } else {
827 pending.extend_from_slice(&buf[..n]);
828 }
829 collapse_cr(&mut pending);
830 // Drain every complete line.
831 while let Some(nl_pos) = pending.iter().position(|&b| b == b'\n') {
832 let line: Vec<u8> = pending.drain(..=nl_pos).collect();
833 if tx.send(LineEvent { kind, bytes: line }).await.is_err() {
834 return;
835 }
836 }
837 // P2a: newline-free residue past the size threshold — flush
838 // unconditionally to keep memory bounded. Prevents unbounded
839 // accumulation from a trailing-\r spinner that never emits \n.
840 if pending.len() >= FLUSH_BYTES {
841 if tx
842 .send(LineEvent { kind, bytes: std::mem::take(&mut pending) })
843 .await
844 .is_err()
845 {
846 return;
847 }
848 }
849 }
850 Ok(Err(e)) => {
851 tracing::warn!(target: "bashwrap", error = %e, ?kind, "read error");
852 return;
853 }
854 Err(_elapsed) => {
855 // P1b/A1: quiet-window expiry.
856 //
857 // If a line was already speculatively deferred last tick
858 // (A1) and nothing new arrived since — deferred_pending
859 // being Some implies pending is empty, since any new data
860 // would have resolved it in the Ok(Ok(n)) branch above —
861 // give up waiting for a `\r`-prefixed overwrite and flush
862 // it now.
863 //
864 // If `pending` starts with `\r`, it is a leading-\r spinner
865 // frame. Stash it in the CR override slot so the next read
866 // prepends it and collapse_cr can overwrite it with the new
867 // frame.
868 //
869 // If `pending` ends with `\r` (but not starts), hold it —
870 // a following `\n` will form a complete CRLF.
871 //
872 // Otherwise (non-`\r` partial output, e.g. `printf
873 // 'Building...'`): first quiet window with this content —
874 // defer it one tick (A1) instead of flushing immediately,
875 // in case the very next read starts with `\r`. See
876 // docs/specs/SPEC_TOOL_LOG_UNIVERSAL_ANIMATION_COLLAPSE_2026_07_27.md §A1.
877 //
878 // Note: pending_cr_override / deferred_pending are always
879 // None here at this point (after the take() above). Each is
880 // set only by take(&mut pending), which empties pending; the
881 // only way to reach this branch with non-empty pending is
882 // after an Ok(Ok(n)) that already consumed and cleared both.
883 if let Some(deferred) = deferred_pending.take() {
884 if tx.send(LineEvent { kind, bytes: deferred }).await.is_err() {
885 return;
886 }
887 }
888 if !pending.is_empty() {
889 if pending.first() == Some(&b'\r') {
890 pending_cr_override = Some(std::mem::take(&mut pending));
891 } else if pending.last() != Some(&b'\r') {
892 deferred_pending = Some(std::mem::take(&mut pending));
893 }
894 // else: trailing-\r hold — do nothing, next read resolves CRLF.
895 }
896 }
897 }
898 }
899}
900
901/// Spawn the bash child and stream its lines. PTY by default with a
902/// pipe fallback if PTY allocation fails (G6 in
903/// docs/specs/SPEC_LIVE_LOG_PTY_REWORK_2026_05_16.md).
904///
905/// Why PTY: when the child sees its stdout as a pipe, glibc switches
906/// libc stdio from line-buffered to block-buffered (~4 KB). External
907/// programs (grep, npm, cargo, python, ...) accumulate output in the
908/// stdio buffer until flush, defeating the live-log overlay's premise.
909/// PTY makes `isatty(STDOUT_FILENO) == 1` so glibc stays line-buffered.
910///
911/// CRITICAL ConPTY lifetime contract (Windows): `pair.master` MUST
912/// stay alive across `child.wait()`. Dropping master during child
913/// startup tears down the pseudoconsole anchor and produces
914/// `STATUS_DLL_INIT_FAILED` (the β.A wedge). See retro §4.2.
915///
916/// HEADLESS PTY contract (Windows): ConPTY queries the terminal at
917/// startup with `\x1b[6n` (DSR — request cursor position) and blocks
918/// waiting for a `\x1b[r;cR` response. A headless PTY (no real terminal
919/// behind it) never answers — the child never proceeds — so
920/// `child.wait()` would never return. We write a synthetic `\x1b[1;1R`
921/// response via the master writer, and the reader strips the query from
922/// the output. This is what xterm.js does for VS Code's agent-mode
923/// terminal; here we do the minimum subset for non-interactive `bash -c`.
924/// Verified via agentmux-pty-repro V2 before this landed.
925///
926/// Unix PTYs never ask (checked with `bash -c` 3.2 and 5.x on macOS: no
927/// `\x1b[6n` in the output, and the command exits without a reply). There
928/// the reply would only be echoed back by the line discipline as the
929/// printable text `^[[1;1R` at the start of every result, which no escape
930/// stripping can recognise, and left in the child's stdin. So it is sent on
931/// Windows only.
932///
933/// The PTY is [`PTY_COLS`] wide, not the classic 80: width-aware programs
934/// (tables, help text, column listings) fold their output at the terminal
935/// width with real line breaks, which no preview can undo.
936async fn run_proc(
937 args: &Args,
938 command: &str,
939 mps: Option<&WpsClient>,
940 buffered: Arc<Mutex<Vec<u8>>>,
941) -> Result<i32> {
942 let bash = locate_bash()?;
943 let pty_system = native_pty_system();
944 match pty_system.openpty(PtySize {
945 rows: PTY_ROWS,
946 cols: PTY_COLS,
947 pixel_width: 0,
948 pixel_height: 0,
949 }) {
950 Ok(pair) => {
951 tracing::info!(
952 target: "bashwrap",
953 bash = %bash.display(),
954 "spawning bash -c via PTY (live-buffered, headless DSR responder active)",
955 );
956 run_via_pty(args, command, mps, buffered, &bash, pair).await
957 }
958 Err(e) => {
959 tracing::warn!(
960 target: "bashwrap",
961 bash = %bash.display(),
962 error = %e,
963 "PTY allocation failed — falling back to pipes (output may buffer at child level)",
964 );
965 run_via_pipes(args, command, mps, buffered, &bash).await
966 }
967 }
968}
969
970/// PTY-backed run path. Holds master alive across child.wait() and
971/// answers DSR queries from bash at startup.
972async fn run_via_pty(
973 args: &Args,
974 command: &str,
975 mps: Option<&WpsClient>,
976 buffered: Arc<Mutex<Vec<u8>>>,
977 bash: &std::path::Path,
978 pair: portable_pty::PtyPair,
979) -> Result<i32> {
980 let mut cmd = CommandBuilder::new(bash.as_os_str());
981 // -c <cmd> for non-interactive run, WITHOUT -l. Login shell
982 // startup costs ~1 second (sources /etc/profile, /etc/bashrc,
983 // ~/.bash_profile, etc.) which the user sees as a "Running..."
984 // delay before any chunk arrives. We pre-prepend the MSYS2 bin
985 // dirs to PATH manually (see below) so external commands like
986 // `date`, `grep`, `awk` resolve without needing rc-script setup.
987 cmd.arg("-c");
988 // Give stdin-reading children (`cat`, `read`, prompted scripts) EOF
989 // instead of leaving them blocked on the live PTY slave, but do NOT use
990 // `exec </dev/null` — running that inside bash closes the child's ConPTY
991 // console-input handle from the child side, which ConPTY reports as a
992 // CTRL_C_EVENT and kills the command with exit 130 before it runs (the
993 // bug this wrapper exists to fix). A brace-group redirect points the
994 // group's fd 0 at /dev/null for the command's duration while bash's own
995 // fd 0 (the console) stays open, so children see EOF and ConPTY never
996 // fires ctrl-c. The leading newline terminates the group list cleanly
997 // regardless of how `command` ends (trailing comment, no newline, etc.).
998 let cwd_state = CwdState::load();
999 // Wrapped a second time by `append_cwd_capture` so the ending `$PWD`
1000 // survives this disposable process — see the cwd-persistence block
1001 // above `Args` for why that's necessary.
1002 cmd.arg(append_cwd_capture(&format!("{{\n{}\n}} </dev/null", command)));
1003
1004 // Disable pagers. The PTY above deliberately makes the child see
1005 // `isatty(stdout) == true` so external tools stay line-buffered (see
1006 // the module doc comment) — but that's exactly what `git`
1007 // (diff/log/show/branch/...) uses to decide whether to auto-invoke
1008 // `core.pager` (`less` by default). We never write to the PTY again
1009 // after the startup DSR response below, so a pager waiting for a
1010 // keystroke blocks forever and leaks this whole process. `cat` passes
1011 // content straight through with no paging. See
1012 // docs/retro/RETRO_BASHWRAP_PAGER_HANG_LEAK_2026_07_14.md.
1013 cmd.env("GIT_PAGER", "cat");
1014 cmd.env("PAGER", "cat");
1015
1016 // PATH fix-up: bashwrap is a Windows exe, so when MSYS2/Git Bash
1017 // spawned us it converted PATH to Windows form, which has
1018 // /c/Windows/system32, /c/Program Files/nodejs, etc. but NOT
1019 // /usr/bin, /usr/local/bin, /mingw64/bin where coreutils lives.
1020 // Without -l (above), the child bash inherits the Windows PATH
1021 // and every external command becomes "command not found".
1022 //
1023 // Derive MSYS2 bin dirs from bash's own location — typically
1024 // bash sits at `C:\Program Files\Git\usr\bin\bash.exe`, so the
1025 // three dirs we need are reachable as siblings/cousins of that.
1026 // Pre-pend them to the inherited PATH; the existing Windows
1027 // entries stay so platform-specific tools (cmd, powershell, gh,
1028 // ...) still resolve.
1029 if let Some(usr_bin) = bash.parent() {
1030 let mut prefix_dirs: Vec<std::ffi::OsString> = vec![usr_bin.to_path_buf().into()];
1031 if let Some(usr) = usr_bin.parent() {
1032 // /usr/local/bin sibling of /usr/bin.
1033 let local_bin = usr.join("local").join("bin");
1034 if local_bin.exists() {
1035 prefix_dirs.push(local_bin.into());
1036 }
1037 // /mingw64/bin sibling of /usr (Git for Windows layout).
1038 if let Some(git_root) = usr.parent() {
1039 let mingw64_bin = git_root.join("mingw64").join("bin");
1040 if mingw64_bin.exists() {
1041 prefix_dirs.push(mingw64_bin.into());
1042 }
1043 }
1044 }
1045 let existing_path = std::env::var_os("PATH").unwrap_or_default();
1046 let mut all_dirs: Vec<std::path::PathBuf> =
1047 prefix_dirs.iter().map(std::path::PathBuf::from).collect();
1048 if !existing_path.is_empty() {
1049 for p in std::env::split_paths(&existing_path) {
1050 all_dirs.push(p);
1051 }
1052 }
1053 let new_path = std::env::join_paths(&all_dirs)
1054 .context("joining PATH entries for child bash")?;
1055 cmd.env("PATH", &new_path);
1056 tracing::info!(
1057 target: "bashwrap",
1058 prepended_dirs = ?prefix_dirs,
1059 "PATH fix-up for child bash (no login-shell startup needed)",
1060 );
1061 }
1062 if let Some(dir) = &cwd_state.start_dir {
1063 cmd.cwd(dir);
1064 }
1065 if let Some(state_path) = &cwd_state.path {
1066 cmd.env(CWD_STATE_ENV, state_path);
1067 }
1068
1069 let child = pair
1070 .slave
1071 .spawn_command(cmd)
1072 .with_context(|| format!("PTY spawn of bash at {}", bash.display()))?;
1073
1074 // Split out a killer before `child` moves into the wait task below, so
1075 // an idle-timeout detected on the reader side (a different task) can
1076 // still terminate it. See `ChildKiller::clone_killer`'s doc comment —
1077 // this is exactly the "send it signals independently from a thread
1078 // that may be blocked in `.wait`" case it exists for.
1079 //
1080 // `killer.kill()` alone only terminates THIS ONE process (portable-pty's
1081 // Windows impl is a bare `TerminateProcess` on the direct handle, no
1082 // job object) — it does not reach descendants bash forked (a pipeline,
1083 // or `git` spawning `less` as a child). An orphaned grandchild can
1084 // survive the kill, stay attached to the PTY slave, and keep blocking
1085 // forever — reproducing this exact leak one process removed instead of
1086 // eliminating it (reagent P1, PR #2156). `child_pid` + `kill_process_tree`
1087 // below is the supplemental fix: a full tree-kill by PID.
1088 let mut killer = child.clone_killer();
1089 let child_pid = child.process_id();
1090
1091 let reader = pair.master.try_clone_reader().context("PTY try_clone_reader")?;
1092
1093 // Write the DSR response into the master writer (Windows), then hold
1094 // the writer alive until after child.wait(). On Windows, closing the
1095 // CONIN pipe write-end while the pseudoconsole is still attached
1096 // sends CTRL_C_EVENT to the child (exit 130 / SIGINT). This is
1097 // the same ConPTY-lifetime invariant as pair.master itself — both
1098 // must outlive child.wait(). After the DSR response we never write
1099 // again; the handle is kept open only to hold CONIN alive.
1100 #[allow(unused_mut)]
1101 let mut writer = pair.master.take_writer().context("PTY take_writer")?;
1102 // ConPTY only: a Unix PTY never asks, and would echo this back into the
1103 // output as `^[[1;1R` (see run_proc's doc comment).
1104 #[cfg(windows)]
1105 {
1106 use std::io::Write as _;
1107 let _ = writer.write_all(b"\x1b[1;1R");
1108 let _ = writer.flush();
1109 }
1110
1111 let (tx, rx) = mpsc::channel::<LineEvent>(1024);
1112 let tx_reader = tx.clone();
1113 let (idle_tx, idle_rx) = oneshot::channel::<()>();
1114 let pty_idle_timeout = effective_idle_timeout(args);
1115 tokio::task::spawn_blocking(move || {
1116 pty_reader_loop(reader, tx_reader, idle_tx, pty_idle_timeout);
1117 });
1118 drop(tx);
1119
1120 let publisher_handle = spawn_publisher_loop(args, mps.cloned(), buffered.clone(), rx);
1121
1122 // Move pair AND writer into the wait task — both must outlive
1123 // child.wait() to satisfy the ConPTY lifetime contract on Windows.
1124 let mut wait_task = tokio::task::spawn_blocking(move || -> Result<i32> {
1125 let mut child = child;
1126 let status = child.wait().context("PTY child wait")?;
1127 let code = status.exit_code() as i32;
1128 tracing::info!(target: "bashwrap", exit_code = code, "PTY child exited");
1129 drop(writer);
1130 drop(pair);
1131 Ok(code)
1132 });
1133
1134 // `idle_rx` resolves for TWO distinct reasons, and they must not be
1135 // conflated (reagent P1, PR #2156): either `pty_reader_loop` really
1136 // did send the idle-timeout signal (`Ok(())`), OR its `idle_tx` was
1137 // simply DROPPED WITHOUT SENDING because the reader hit a normal EOF
1138 // and returned — which is exactly what happens on ordinary, fast,
1139 // successful command completion. A oneshot receiver's `.await`
1140 // resolves (with `Err`) in that drop case too, and `wait_task`
1141 // finishing is an independent race on a separate spawn_blocking
1142 // thread — under scheduling variance (or blocking-pool contention from
1143 // concurrent bashwrap invocations, common per this PR's own retro doc)
1144 // the reader's drop can resolve first even for a command that ran to
1145 // completion normally. Treating ANY `idle_rx` resolution as "idle
1146 // timeout fired" would misclassify and kill perfectly successful fast
1147 // commands. Only `Ok(())` is a real signal; `Err` just means "not an
1148 // idle timeout, keep waiting on wait_task directly."
1149 let mut idle_killed = false;
1150 let exit_code = tokio::select! {
1151 res = &mut wait_task => {
1152 res.context("PTY wait task join")??
1153 }
1154 idle_signal = idle_rx => {
1155 if idle_signal.is_err() {
1156 // idle_tx dropped without sending — not a real timeout.
1157 // idle_rx is now consumed either way, so just await the
1158 // real completion directly instead of re-entering select.
1159 wait_task.await.context("PTY wait task join")??
1160 } else {
1161 idle_killed = true;
1162 tracing::warn!(
1163 target: "bashwrap",
1164 tool_id = %args.tool_id,
1165 idle_secs = idle_kill_timeout().as_secs(),
1166 "child produced no PTY output for the idle timeout — likely \
1167 blocked waiting for interactive input (e.g. a pager); killing",
1168 );
1169 // Tree-kill FIRST, `killer.kill()` second — order matters.
1170 // `taskkill /T` walks descendants by their recorded parent PID,
1171 // which only works while the root (bash) is still enumerable;
1172 // killing bash first (via killer.kill()) and THEN tree-killing
1173 // races taskkill against an already-dead root and can leave
1174 // grandchildren behind (verified empirically: an earlier version
1175 // of this fix that called killer.kill() first left 1 of 2
1176 // backgrounded test children alive — the test below is what
1177 // caught it). killer.kill() still runs afterward as a cheap,
1178 // harmless belt-and-suspenders in case kill_process_tree itself
1179 // didn't fully land (e.g. taskkill unavailable).
1180 if let Some(pid) = child_pid {
1181 // spawn_blocking: kill_process_tree shells out synchronously
1182 // (std::process::Command::output()) — running it inline here
1183 // would block this tokio worker thread for the taskkill
1184 // round-trip. Bounded (reagent P1, PR #2156): `taskkill /T`
1185 // itself can hang (a documented Windows failure mode —
1186 // unresponsive process enumeration) — without this timeout,
1187 // an unresponsive taskkill reintroduces the exact unbounded
1188 // hang this whole fix exists to eliminate.
1189 let _ = tokio::time::timeout(
1190 Duration::from_secs(5),
1191 tokio::task::spawn_blocking(move || kill_process_tree(pid)),
1192 )
1193 .await;
1194 }
1195 let _ = killer.kill();
1196 // Bounded grace period for the kill to actually land and for
1197 // the wait task to finish (which drops writer/pair, releasing
1198 // the PTY). If it still doesn't, abandon the wait rather than
1199 // block forever — main()'s process::exit() tears down every
1200 // thread in this process regardless, once run() returns.
1201 match tokio::time::timeout(Duration::from_secs(5), &mut wait_task).await {
1202 Ok(Ok(Ok(code))) => code,
1203 _ => 124, // matches the conventional `timeout` command's exit code
1204 }
1205 }
1206 }
1207 };
1208
1209 // `buffered` (read by the caller for the model blob) is populated
1210 // only by the publisher loop, so drain it BEFORE appending the
1211 // idle-kill diagnostic (reagent P2, PR #2156) — otherwise genuine
1212 // pre-kill output still queued in the channel could flush into
1213 // `buffered` after the diagnostic note, interleaving it ahead of
1214 // trailing real output instead of strictly after it. Bounded for the
1215 // same reason as the wait task above: if a surviving grandchild still
1216 // holds the PTY slave open after the kill, the reader thread's
1217 // blocking read may never see EOF.
1218 if tokio::time::timeout(Duration::from_secs(5), publisher_handle)
1219 .await
1220 .is_err()
1221 {
1222 tracing::warn!(target: "bashwrap", tool_id = %args.tool_id, "publisher drain timed out — proceeding without it");
1223 }
1224
1225 if idle_killed {
1226 buffered.lock().await.extend_from_slice(
1227 b"\n[bashwrap] command produced no output for the idle timeout and was \
1228terminated automatically (likely blocked on a pager or other interactive \
1229prompt this wrapper can never answer, e.g. `git diff`/`log`/`show` auto- \
1230paging output that doesn't fit one screen). Try `git --no-pager <cmd>` or \
1231`| cat` on future invocations.\n",
1232 );
1233 }
1234
1235 Ok(exit_code)
1236}
1237
1238/// Pipe-backed run path. Safety net for environments where PTY
1239/// allocation fails (CI, sandboxes). Same byte semantics as the old
1240/// pipe-only wrapper.
1241async fn run_via_pipes(
1242 args: &Args,
1243 command: &str,
1244 mps: Option<&WpsClient>,
1245 buffered: Arc<Mutex<Vec<u8>>>,
1246 bash: &std::path::Path,
1247) -> Result<i32> {
1248 // Same cwd-persistence rationale as run_via_pty — see the block above
1249 // `Args`.
1250 let cwd_state = CwdState::load();
1251 let mut cmd = Command::new(bash);
1252 cmd.arg("-c")
1253 .arg(append_cwd_capture(command))
1254 .stdin(Stdio::null())
1255 .stdout(Stdio::piped())
1256 .stderr(Stdio::piped())
1257 .kill_on_drop(true)
1258 // Defense-in-depth, matching run_via_pty: stdout is a plain pipe
1259 // here (isatty == false), so git shouldn't auto-page in this path
1260 // at all — but set it anyway in case some tool pages on a
1261 // different heuristic. See
1262 // docs/retro/RETRO_BASHWRAP_PAGER_HANG_LEAK_2026_07_14.md.
1263 .env("GIT_PAGER", "cat")
1264 .env("PAGER", "cat");
1265 if let Some(dir) = &cwd_state.start_dir {
1266 cmd.current_dir(dir);
1267 }
1268 if let Some(state_path) = &cwd_state.path {
1269 cmd.env(CWD_STATE_ENV, state_path);
1270 }
1271 // Windows only: bash.exe is a console-subsystem binary, so spawning it
1272 // without CREATE_NO_WINDOW pops a new visible (and orphaned-looking)
1273 // console window for every fallback exec — this path only runs when
1274 // PTY allocation fails, which is why it slipped past
1275 // SPEC_ELIMINATE_BASHWRAP_CONSOLE_WINDOWS_2026_06_20's audit (that pass
1276 // covered bashwrap's own process subsystem and shell_node.rs, not this
1277 // rarely-exercised inner spawn). Same fix as shell_node.rs.
1278 #[cfg(windows)]
1279 {
1280 // `cmd` is `tokio::process::Command`; `NoWindow` covers both it and
1281 // `std::process::Command`, so no `CommandExt` import is needed.
1282 cmd.no_window();
1283 }
1284 let mut child = cmd
1285 .spawn()
1286 .with_context(|| format!("pipe spawn of bash at {}", bash.display()))?;
1287 // Captured before anything else can consume/reap the child — see the
1288 // idle-kill branch below for why this matters (reagent P1, PR #2156).
1289 let child_pid = child.id();
1290
1291 let stdout = child.stdout.take().ok_or_else(|| anyhow!("no stdout"))?;
1292 let stderr = child.stderr.take().ok_or_else(|| anyhow!("no stderr"))?;
1293
1294 // Idle-kill safety net (reagent P1, PR #2156): this path has no PTY, so
1295 // it can't hit the pager-hang mechanism specifically (isatty(stdout) ==
1296 // false here, the whole reason git wouldn't auto-page) — but nothing
1297 // else bounded child.wait() either, so ANY other hang cause still
1298 // leaked the wrapper forever, reproducing the root bug this PR exists
1299 // to fix. Mirrors run_via_pty's idle (not total-runtime) timeout: a
1300 // shared last-activity clock updated by both stdout/stderr readers on
1301 // every byte read, watched by a lightweight polling task.
1302 let last_activity = Arc::new(std::sync::Mutex::new(std::time::Instant::now()));
1303
1304 let (tx, rx) = mpsc::channel::<LineEvent>(1024);
1305 let stdout_reader = tokio::spawn(stream_reader(stdout, ToolChunkKind::Stdout, tx.clone(), last_activity.clone()));
1306 let stderr_reader = tokio::spawn(stream_reader(stderr, ToolChunkKind::Stderr, tx.clone(), last_activity.clone()));
1307 drop(tx);
1308
1309 let publisher_handle = spawn_publisher_loop(args, mps.cloned(), buffered.clone(), rx);
1310
1311 let (idle_tx, idle_rx) = oneshot::channel::<()>();
1312 let idle_timeout = effective_idle_timeout(args);
1313 let watcher_activity = last_activity.clone();
1314 let idle_watcher = tokio::spawn(async move {
1315 let mut idle_tx = Some(idle_tx);
1316 loop {
1317 tokio::time::sleep(Duration::from_millis(200)).await;
1318 let elapsed = watcher_activity
1319 .lock()
1320 .unwrap_or_else(|e| e.into_inner())
1321 .elapsed();
1322 if elapsed >= idle_timeout {
1323 if let Some(tx) = idle_tx.take() {
1324 let _ = tx.send(());
1325 }
1326 return;
1327 }
1328 }
1329 });
1330
1331 // Same Ok/Err distinction as run_via_pty's select (reagent P1 follow-up):
1332 // idle_rx resolving with Err just means pty_reader_loop-equivalent
1333 // cleanup dropped it without signaling (not applicable on this path
1334 // directly, but kept for structural symmetry and defensiveness) — not
1335 // a real signal either way.
1336 let mut idle_killed = false;
1337 let exit_code: i32 = tokio::select! {
1338 res = child.wait() => {
1339 idle_watcher.abort();
1340 res.context("waiting for bash child")?.code().unwrap_or(-1)
1341 }
1342 idle_signal = idle_rx => {
1343 if idle_signal.is_err() {
1344 child.wait().await.context("waiting for bash child")?.code().unwrap_or(-1)
1345 } else {
1346 idle_killed = true;
1347 tracing::warn!(
1348 target: "bashwrap",
1349 tool_id = %args.tool_id,
1350 idle_secs = idle_kill_timeout().as_secs(),
1351 "child produced no output for the idle timeout (pipe path) — killing",
1352 );
1353 // Same tree-kill-first ordering and reasoning as run_via_pty
1354 // (reagent P1, PR #2156): child.start_kill() alone only
1355 // terminates the direct bash process, not descendants it
1356 // forked (a backgrounded `&` child, or a pipeline segment) —
1357 // those would otherwise survive, still attached to the
1358 // stdout/stderr pipes, leaving the reader tasks waiting for
1359 // an EOF that never comes. Bounded: taskkill /T can itself
1360 // hang (a documented Windows failure mode).
1361 if let Some(pid) = child_pid {
1362 let _ = tokio::time::timeout(
1363 Duration::from_secs(5),
1364 tokio::task::spawn_blocking(move || kill_process_tree(pid)),
1365 )
1366 .await;
1367 }
1368 let _ = child.start_kill();
1369 match tokio::time::timeout(Duration::from_secs(5), child.wait()).await {
1370 Ok(Ok(status)) => status.code().unwrap_or(-1),
1371 _ => 124, // matches the conventional `timeout` command's exit code
1372 }
1373 }
1374 }
1375 };
1376
1377 // Reader tasks: bound-JOIN, never `.abort()`. Empirically, aborting a
1378 // task that's inside `tokio::time::timeout(_, AsyncRead::read(..))` on
1379 // a just-killed child's Windows pipe leaves something in a state that
1380 // hangs this whole test/runtime's shutdown — reproduced in isolation
1381 // while building this fix (a bare `.read().await` with no per-call
1382 // timeout wrapper was fine to abort; adding the 50ms quiet-window
1383 // timeout wrapper, matching `stream_reader`'s real structure, is what
1384 // triggered it). A `JoinHandle` that's simply *dropped* without abort,
1385 // by contrast, does not cancel the task — it keeps running fully
1386 // detached and harmlessly finishes reading to EOF on its own once the
1387 // pipe closes, which is exactly what timing out on the join and
1388 // moving on (rather than aborting) achieves here.
1389 if tokio::time::timeout(
1390 Duration::from_secs(5),
1391 async { let _ = tokio::join!(stdout_reader, stderr_reader); },
1392 )
1393 .await
1394 .is_err()
1395 {
1396 tracing::warn!(target: "bashwrap", tool_id = %args.tool_id, "reader tasks did not finish within the grace period — leaving them to finish in the background rather than aborting (see run_via_pipes's comment)");
1397 }
1398
1399 // Bounded for the same reason as run_via_pty: proceed rather than block
1400 // forever if a hung descendant somehow keeps a pipe end open.
1401 if tokio::time::timeout(Duration::from_secs(5), publisher_handle)
1402 .await
1403 .is_err()
1404 {
1405 tracing::warn!(target: "bashwrap", tool_id = %args.tool_id, "publisher drain timed out — proceeding without it");
1406 }
1407
1408 if idle_killed {
1409 buffered.lock().await.extend_from_slice(
1410 b"\n[bashwrap] command produced no output for the idle timeout and was \
1411terminated automatically.\n",
1412 );
1413 }
1414
1415 Ok(exit_code)
1416}
1417
1418/// PTY reader loop: drains bytes from the master, strips DSR / ANSI
1419/// control sequences, and forwards line-split chunks to the publisher.
1420///
1421/// Uses a thread-bridge + `recv_timeout` to add a quiet-window flush
1422/// identical to `stream_reader`'s `tokio::time::timeout` approach, but
1423/// for the blocking sync I/O that the PTY master reader requires.
1424///
1425/// **P1a — `collapse_cr`:** called on the `pending` accumulator after
1426/// each PTY read (after `strip_ansi`), collapsing mid-buffer lone-`\r`
1427/// overwrites (trailing-`\r` convention, e.g. `"frame\r"`) and embedded
1428/// multi-frame chunks (e.g. `printf 'f1\rf2\rfinal\n'`). Previously the
1429/// PTY path passed `\r` bytes intact and relied on `spawn_publisher_loop`'s
1430/// `pending_cr_line` slot; that slot only fires per-LineEvent so embedded
1431/// `\r`s in a single write survived uncollapsed.
1432///
1433/// **P1b — leading-`\r` spinner collapse:** throttled spinners (npm,
1434/// cargo, ora, tqdm at ~80-100 ms/frame, above the 50 ms quiet window)
1435/// arrive as separate reads. When the quiet window expires and `pending`
1436/// starts with `\r`, the frame is stashed in a `pending_cr_override` slot
1437/// rather than flushed. The next read prepends it so `collapse_cr`
1438/// overwrites it with the new frame. The slot is flushed when a
1439/// `\n`-terminated line arrives or at EOF.
1440///
1441/// **P2a — `FLUSH_BYTES` guard:** flushed unconditionally when accumulated
1442/// size exceeds `FLUSH_BYTES`, preventing unbounded accumulation from a
1443/// trailing-`\r` spinner that never emits `\n`.
1444fn pty_reader_loop(
1445 reader: Box<dyn std::io::Read + Send>,
1446 tx: mpsc::Sender<LineEvent>,
1447 idle_tx: oneshot::Sender<()>,
1448 idle_timeout: Duration,
1449) {
1450 use std::sync::mpsc as std_mpsc;
1451 // PTY collapses stdout + stderr onto one stream — the slave's
1452 // terminal device is a single FD shared by both. Chunks are
1453 // labelled "stdout" because there is no way to recover the
1454 // original FD distinction from the master read. The pipe path
1455 // (`run_via_pipes`) preserves the split.
1456 let kind = ToolChunkKind::Stdout;
1457
1458 // Offload the blocking read to a dedicated thread so the main
1459 // loop can use recv_timeout for the quiet-window flush. Bound the
1460 // channel (64 slots ≈ 512 KiB headroom) so a flooding child can't
1461 // grow it unbounded — the SyncSender blocks when full, propagating
1462 // backpressure to the PTY buffer and ultimately to the child process.
1463 let (data_tx, data_rx) = std_mpsc::sync_channel::<Option<Vec<u8>>>(64);
1464 std::thread::spawn(move || {
1465 use std::io::Read;
1466 let mut reader = reader;
1467 let mut buf = [0u8; 8192];
1468 loop {
1469 match reader.read(&mut buf) {
1470 Ok(0) => {
1471 let _ = data_tx.send(None); // EOF sentinel
1472 return;
1473 }
1474 Ok(n) => {
1475 if data_tx.send(Some(buf[..n].to_vec())).is_err() {
1476 return; // consumer gone
1477 }
1478 }
1479 Err(e) => {
1480 tracing::warn!(target: "bashwrap", error = %e, "PTY read error");
1481 return;
1482 }
1483 }
1484 }
1485 });
1486
1487 let mut pending: Vec<u8> = Vec::with_capacity(8192);
1488 // P1b: CR override slot — mirrors the same slot in stream_reader.
1489 // When the quiet window expires and `pending` starts with `\r`, hold
1490 // it here so throttled spinner frames collapse before becoming separate
1491 // LineEvents.
1492 let mut pending_cr_override: Option<Vec<u8>> = None;
1493 // A1: speculative one-tick hold for a pending line that has NO leading
1494 // OR trailing `\r` yet (the overwhelmingly common real pattern: print a
1495 // static label once, then every subsequent update is `\r`-prefixed).
1496 // Distinct from `pending_cr_override` above — that slot only ever holds
1497 // content that already starts with `\r`. See
1498 // docs/specs/SPEC_TOOL_LOG_UNIVERSAL_ANIMATION_COLLAPSE_2026_07_27.md §A1.
1499 let mut deferred_pending: Option<Vec<u8>> = None;
1500 // A2: tracks whether this tool call's output has scrolled past its
1501 // first visual line yet — see `normalize_csi_overwrites`'s doc comment
1502 // for why CUP-to-home (`\x1b[H`) is only a `\r`-equivalent while this
1503 // is still true.
1504 let mut still_on_first_line = true;
1505 // A2: tracks whether the cursor is currently at column 0, across PTY
1506 // reads — see `normalize_csi_overwrites`'s doc comment for why a
1507 // standalone EL is only safe to treat as `\r` while this is true, and
1508 // why it must persist across calls rather than reset per-chunk.
1509 let mut at_col0 = true;
1510 // Idle-kill tracking: `last_activity` resets on every byte read from
1511 // the PTY (regardless of whether it becomes a published LineEvent —
1512 // even control-sequence-only output means the child is still doing
1513 // something). `idle_tx` fires exactly once, on the first quiet-window
1514 // timeout after `idle_kill_timeout()` has elapsed with zero activity.
1515 // See the constant's doc comment for why this is idle-based rather
1516 // than a total-runtime cap. `idle_timeout` is passed in by the caller
1517 // (`effective_idle_timeout`) rather than read here, so a declared-
1518 // background call's exemption applies without this loop needing its
1519 // own copy of `Args`.
1520 let mut last_activity = std::time::Instant::now();
1521 let mut idle_tx = Some(idle_tx);
1522 loop {
1523 match data_rx.recv_timeout(FLUSH_QUIET_WINDOW) {
1524 Ok(Some(mut chunk)) => {
1525 last_activity = std::time::Instant::now();
1526 let raw_n = chunk.len();
1527 strip_dsr(&mut chunk);
1528 // Phase γ (partial): convert the CHA(col-1)/EL cursor-
1529 // repositioning idioms into `\r` bytes BEFORE strip_ansi
1530 // deletes them outright, so collapse_cr (below) can treat
1531 // CSI-based progress-bar redraws as overwrites the same way
1532 // it already does for bare `\r`. See
1533 // docs/specs/SPEC_TOOL_LOG_UNIVERSAL_ANIMATION_COLLAPSE_2026_07_27.md §A2.
1534 normalize_csi_overwrites(&mut chunk, &mut still_on_first_line, &mut at_col0);
1535 // Phase β: strip remaining ANSI control sequences so
1536 // the plain-text chunk list doesn't render them as
1537 // garbled literal characters. Bash emits terminal-
1538 // init bytes (`\x1b[m`, `\x1b]0;<title>\x07`,
1539 // `\x1b[?25h`) on every `-c` invocation through a
1540 // PTY; without this strip every chunk list starts
1541 // with garbage.
1542 strip_ansi(&mut chunk);
1543 tracing::debug!(target: "bashwrap", raw_bytes = raw_n, post_strip = chunk.len(), "PTY read");
1544 if chunk.is_empty() {
1545 continue;
1546 }
1547 // P1b: prepend any held CR override so collapse_cr can
1548 // overwrite it with the new frame content.
1549 if let Some(held) = pending_cr_override.take() {
1550 let mut combined = held;
1551 combined.extend_from_slice(&chunk);
1552 pending.splice(0..0, combined);
1553 } else if let Some(deferred) = deferred_pending.take() {
1554 // A1: the new chunk resolves last tick's speculative
1555 // hold. A leading `\r` means it's the overwrite we were
1556 // hoping for — merge and let collapse_cr do the rest,
1557 // same as the pending_cr_override case above. Anything
1558 // else means the deferred line was genuinely final and
1559 // unrelated to this new content — flush it as its own
1560 // line first so the two are never wrongly concatenated.
1561 if chunk.first() == Some(&b'\r') {
1562 let mut combined = deferred;
1563 combined.extend_from_slice(&chunk);
1564 pending.splice(0..0, combined);
1565 } else {
1566 if tx.blocking_send(LineEvent { kind, bytes: deferred }).is_err() {
1567 return;
1568 }
1569 pending.extend_from_slice(&chunk);
1570 }
1571 } else {
1572 pending.extend_from_slice(&chunk);
1573 }
1574 // P1a: collapse lone \r in the accumulated pending buffer,
1575 // normalising mid-buffer overwrites and embedded multi-frame
1576 // chunks identically to the pipe path.
1577 collapse_cr(&mut pending);
1578 // Any held CR override was already consumed and prepended into
1579 // `pending` above, so it never survives to this drain loop —
1580 // mirrors the pipe path in `stream_reader` which omits a
1581 // take() here for the same reason.
1582 while let Some(nl_pos) = pending.iter().position(|&b| b == b'\n') {
1583 let line: Vec<u8> = pending.drain(..=nl_pos).collect();
1584 if tx.blocking_send(LineEvent { kind, bytes: line }).is_err() {
1585 return;
1586 }
1587 }
1588 // P2a: size threshold — flush unconditionally to keep memory
1589 // bounded. Prevents unbounded accumulation from a trailing-\r
1590 // spinner that never emits \n.
1591 if pending.len() >= FLUSH_BYTES {
1592 if tx.blocking_send(LineEvent {
1593 kind,
1594 bytes: std::mem::take(&mut pending),
1595 }).is_err() {
1596 return;
1597 }
1598 }
1599 }
1600 Ok(None) => {
1601 // EOF: flush any held CR override or A1 deferred line first,
1602 // then drain remainder, stripping a dangling lone \r.
1603 if let Some(mut held) = pending_cr_override.take() {
1604 if held.last() == Some(&b'\r') {
1605 held.pop();
1606 }
1607 if !held.is_empty() {
1608 let _ = tx.blocking_send(LineEvent { kind, bytes: held });
1609 }
1610 }
1611 if let Some(deferred) = deferred_pending.take() {
1612 let _ = tx.blocking_send(LineEvent { kind, bytes: deferred });
1613 }
1614 if !pending.is_empty() {
1615 if pending.last() == Some(&b'\r') {
1616 pending.pop();
1617 }
1618 if !pending.is_empty() {
1619 let _ = tx.blocking_send(LineEvent {
1620 kind,
1621 bytes: std::mem::take(&mut pending),
1622 });
1623 }
1624 }
1625 return;
1626 }
1627 Err(std_mpsc::RecvTimeoutError::Timeout) => {
1628 if idle_tx.is_some() && last_activity.elapsed() >= idle_timeout {
1629 // Fires once (idle_tx.take() leaves None behind), even
1630 // though this branch keeps running every quiet-window
1631 // tick after that — the async side kills the child on
1632 // receipt, which will eventually produce real EOF/error
1633 // here and end the loop normally.
1634 if let Some(sender) = idle_tx.take() {
1635 let _ = sender.send(());
1636 }
1637 }
1638 // P1b/A1: quiet-window expiry.
1639 //
1640 // If a line was ALREADY speculatively deferred last tick
1641 // (A1) and nothing new has arrived since — deferred_pending
1642 // being Some implies pending is empty, since any new data
1643 // would have resolved it in the Ok(Some) branch above — give
1644 // up waiting for a `\r`-prefixed overwrite and flush it now.
1645 //
1646 // If `pending` starts with `\r`, it is a leading-\r spinner
1647 // frame. Stash it in the CR override slot so the next read
1648 // prepends it and collapse_cr overwrites it with the new frame.
1649 //
1650 // If `pending` ends with `\r` (but not starts), hold it —
1651 // a following `\n` can form a complete CRLF.
1652 //
1653 // Otherwise (non-`\r` partial output, e.g. `printf
1654 // 'Building...'`): this is the FIRST quiet window with this
1655 // content, so defer it one tick (A1) rather than flushing
1656 // immediately — in case the very next read starts with `\r`
1657 // and this was actually the first frame of an overwrite
1658 // sequence (the overwhelmingly common real pattern: print a
1659 // static label once, then every subsequent update is
1660 // `\r`-prefixed). See
1661 // docs/specs/SPEC_TOOL_LOG_UNIVERSAL_ANIMATION_COLLAPSE_2026_07_27.md §A1.
1662 if let Some(deferred) = deferred_pending.take() {
1663 if tx.blocking_send(LineEvent { kind, bytes: deferred }).is_err() {
1664 return;
1665 }
1666 }
1667 if !pending.is_empty() {
1668 if pending.first() == Some(&b'\r') {
1669 // Leading-\r spinner frame: stash in the override slot so
1670 // the next Ok(Some) prepends it and collapse_cr can overwrite
1671 // it with the incoming frame.
1672 //
1673 // Note: pending_cr_override is always None here. The override
1674 // is set only by take(&mut pending), which empties pending.
1675 // The only way to reach this branch with non-empty pending is
1676 // after an Ok(Some) that already consumed and cleared the
1677 // override. No flush-prior is needed or reachable.
1678 pending_cr_override = Some(std::mem::take(&mut pending));
1679 } else if pending.last() != Some(&b'\r') {
1680 // First quiet window with this non-\r content: defer
1681 // instead of flushing (A1). deferred_pending is
1682 // always None here for the same reason as above.
1683 deferred_pending = Some(std::mem::take(&mut pending));
1684 }
1685 // else: trailing-\r hold — do nothing, next read resolves CRLF.
1686 }
1687 }
1688 Err(std_mpsc::RecvTimeoutError::Disconnected) => {
1689 // Reader thread died without EOF — flush CR override / A1
1690 // deferred line, then drain.
1691 if let Some(mut held) = pending_cr_override.take() {
1692 if held.last() == Some(&b'\r') {
1693 held.pop();
1694 }
1695 if !held.is_empty() {
1696 let _ = tx.blocking_send(LineEvent { kind, bytes: held });
1697 }
1698 }
1699 if let Some(deferred) = deferred_pending.take() {
1700 let _ = tx.blocking_send(LineEvent { kind, bytes: deferred });
1701 }
1702 if !pending.is_empty() {
1703 if pending.last() == Some(&b'\r') {
1704 pending.pop();
1705 }
1706 if !pending.is_empty() {
1707 let _ = tx.blocking_send(LineEvent {
1708 kind,
1709 bytes: std::mem::take(&mut pending),
1710 });
1711 }
1712 }
1713 return;
1714 }
1715 }
1716 }
1717}
1718
1719/// Normalize CSI cursor-repositioning idioms that map cleanly onto the
1720/// existing `\r`-based overwrite model, converting them to a literal `\r`
1721/// byte so `collapse_cr` (which already understands `\r`) collapses them
1722/// too, with no new cases needed there. Runs on the raw `chunk` BEFORE
1723/// `strip_ansi`, which otherwise deletes CSI sequences unconditionally with
1724/// no overwrite-awareness — the "Phase γ" gap `strip_ansi`'s own doc
1725/// comment flags ("Cursor positioning escapes within the same line").
1726/// See docs/specs/SPEC_TOOL_LOG_UNIVERSAL_ANIMATION_COLLAPSE_2026_07_27.md §A2.
1727///
1728/// `still_on_first_line` and `at_col0` are both caller-owned state that
1729/// persists across calls for the lifetime of one reader loop — this
1730/// function is invoked once per PTY read, and a chunk boundary is not a
1731/// terminal state boundary: a read ending mid-line (no trailing `\n`/`\r`)
1732/// must carry "cursor is NOT at column 0" into the next call, otherwise a
1733/// standalone EL split across two reads (`"prefix"` in one chunk,
1734/// `"\x1b[2Ksuffix"` in the next) would see a freshly-reset `at_col0 ==
1735/// true` and wrongly convert that EL to `\r`, reproducing the exact
1736/// mid-line truncation bug this function exists to prevent, just moved
1737/// from a mid-chunk split to a cross-chunk-read split (reagent P1, PR
1738/// #2330, caught after the first, chunk-internal-only version of this fix).
1739///
1740/// `still_on_first_line`: `true` until the first `\n` is
1741/// seen in this tool call's output, `false` forever after. Needed because
1742/// **Windows ConPTY re-serializes a literal `\r` as CUP-to-home
1743/// (`\x1b[H`)** rather than passing it through unchanged — verified
1744/// empirically via `a1_e2e_static_label_then_delayed_cr_overwrite_collapses_to_one_line`
1745/// capturing the raw PTY bytes: a child process writing `\rdone` produces
1746/// `\x1b[?25l\x1b[Hdone...\x1b[?25h` on the wire, not a literal `\r`. CUP
1747/// "home" (row 1, col 1) is unambiguous evidence of an overwrite ONLY when
1748/// nothing has scrolled the visual line off row 1 yet; once real multi-line
1749/// output exists, `\x1b[H` genuinely means "go back to the first of several
1750/// lines" (a multi-line redraw, spec §A3, deliberately out of scope) and
1751/// must NOT be treated as "restart the current line".
1752///
1753/// Cases handled, chosen because each means exactly "move to the start of
1754/// the (only) line so far" without needing true per-column tracking:
1755/// - CUP to row 1 col 1 (`\x1b[H`, `\x1b[1H`, `\x1b[1;1H`, and the
1756/// omitted-field variants of each), gated on `still_on_first_line` per
1757/// above — the ConPTY-observed idiom.
1758/// - CHA to column 1 (`\x1b[1G` / `\x1b[G`, "cursor to column 1") is
1759/// literally "move to start of line" — identical to `\r`. Unix PTYs
1760/// (and any tool not going through ConPTY's re-serialization) may use
1761/// this form directly.
1762/// - EL (`\x1b[2K` erase whole line, `\x1b[K`/`\x1b[0K` erase to end of
1763/// line) is treated as "reset to start of line" too, but ONLY when the
1764/// cursor is already known to be at column 0 (`*at_col0`) — i.e. the
1765/// dominant real-world `\r` + EL idiom (`\r\x1b[2K<text>`), where it's
1766/// genuinely redundant with the `\r` already just emitted. A
1767/// STANDALONE EL, mid-line (cursor NOT at column 0 — e.g.
1768/// `prefix\x1b[Ksuffix`), is a real, different case: EL only erases
1769/// from the cursor onward and never moves it, so collapsing it to `\r`
1770/// there would make `collapse_cr` truncate `prefix` even though EL
1771/// never asked for that. That standalone case is left unconverted
1772/// (falls through to the "unrecognized" branch below, so `strip_ansi`
1773/// removes the escape byte-for-byte without touching `prefix`) — a
1774/// bug reagent caught on this exact line (PR #2330): the prior,
1775/// unconditional version silently discarded `prefix` in
1776/// `prefix\x1b[Ksuffix\n`.
1777///
1778/// CUB (`\x1b[<n>D`, "cursor back n columns") is intentionally NOT handled
1779/// here — a faithful implementation needs true per-column tracking (a raw
1780/// byte position isn't a faithful proxy once `n` varies per frame), which
1781/// `collapse_cr`'s line-offset model doesn't have. Left as a documented
1782/// follow-up alongside CUU/multi-line redraw (spec §A3) rather than
1783/// guessed at with an approximation that could truncate the wrong amount.
1784fn normalize_csi_overwrites(chunk: &mut Vec<u8>, still_on_first_line: &mut bool, at_col0: &mut bool) {
1785 let mut out: Vec<u8> = Vec::with_capacity(chunk.len());
1786 let mut i = 0;
1787 while i < chunk.len() {
1788 let b = chunk[i];
1789 if b == b'\n' {
1790 *still_on_first_line = false;
1791 }
1792 if b == 0x1b && i + 1 < chunk.len() && chunk[i + 1] == b'[' {
1793 let mut j = i + 2;
1794 while j < chunk.len() && (0x30..=0x3F).contains(&chunk[j]) {
1795 j += 1;
1796 }
1797 if j < chunk.len() && (0x40..=0x7E).contains(&chunk[j]) {
1798 let params = &chunk[i + 2..j];
1799 let final_byte = chunk[j];
1800 let is_cha_col1 = final_byte == b'G' && (params.is_empty() || params == b"1");
1801 let is_el = final_byte == b'K' && *at_col0;
1802 let is_cup_home = *still_on_first_line
1803 && final_byte == b'H'
1804 && is_cup_row1_col1(params);
1805 if is_cha_col1 || is_el || is_cup_home {
1806 out.push(b'\r');
1807 *at_col0 = true;
1808 i = j + 1;
1809 continue;
1810 }
1811 // Unrecognized/CUB/mid-line-EL/out-of-scope CSI sequence:
1812 // fall through untouched, byte by byte, so strip_ansi
1813 // (which runs next) still recognizes and removes it as a
1814 // whole — this function only ever substitutes the matched
1815 // idioms above. Each of those bytes then runs through the
1816 // ordinary `*at_col0 = b == b'\n' || b == b'\r'` update
1817 // below like any other byte, which forces `at_col0` to
1818 // `false` (none of an escape sequence's bytes are `\n`/
1819 // `\r`) — conservative and safe (errs toward NOT
1820 // collapsing a subsequent EL), not "left as-is".
1821 }
1822 }
1823 out.push(b);
1824 *at_col0 = b == b'\n' || b == b'\r';
1825 i += 1;
1826 }
1827 *chunk = out;
1828}
1829
1830/// Does a CUP (`\x1b[<params>H`) params string address row 1, column 1
1831/// ("home")? Each field defaults to 1 when empty, per ECMA-48 — so `""`,
1832/// `"1"`, `"1;1"`, `";1"`, `"1;"`, and `";"` are all row-1-col-1.
1833fn is_cup_row1_col1(params: &[u8]) -> bool {
1834 let field_is_one_or_empty = |f: &[u8]| f.is_empty() || f == b"1";
1835 match params.iter().position(|&b| b == b';') {
1836 None => field_is_one_or_empty(params),
1837 Some(idx) => {
1838 field_is_one_or_empty(¶ms[..idx]) && field_is_one_or_empty(¶ms[idx + 1..])
1839 }
1840 }
1841}
1842
1843/// Scan `chunk` for DSR `\x1b[6n` sequences and remove them in place
1844/// so they don't leak into the model-visible blob or overlay log.
1845fn strip_dsr(chunk: &mut Vec<u8>) {
1846 const DSR: &[u8] = b"\x1b[6n";
1847 let mut i = 0;
1848 while i + DSR.len() <= chunk.len() {
1849 if &chunk[i..i + DSR.len()] == DSR {
1850 chunk.drain(i..i + DSR.len());
1851 } else {
1852 i += 1;
1853 }
1854 }
1855}
1856
1857/// Phase β (lite): strip ANSI control sequences from a PTY chunk so
1858/// the overlay's plain-text ChunkList doesn't render them as garbled
1859/// literal characters.
1860///
1861/// Three families to handle (everything bash emits during init and
1862/// most programs emit during normal output):
1863///
1864/// 1. **CSI sequences** — `ESC [ <params> <final>` where params are
1865/// `0x30..=0x3F` (digits, `;`, `?`, etc.) and final is
1866/// `0x40..=0x7E` (letters + punctuation). Covers `\x1b[m` (reset),
1867/// `\x1b[?25h/l` (cursor visibility), color codes, cursor moves.
1868///
1869/// 2. **OSC sequences** — `ESC ] <text> BEL` (or `ESC ] <text> ESC \`).
1870/// Covers `\x1b]0;<title>\x07` (set window title — emitted by
1871/// Git Bash on every command).
1872///
1873/// 3. **Lone control chars** — `\r` (carriage return without
1874/// newline), `\x07` (bell), `\x08` (backspace). CRLF → LF for
1875/// the chunk list. Lone `\r` passes through so `collapse_cr` can
1876/// operate on the `pending` buffer across PTY reads (see below).
1877///
1878/// Things we DON'T handle yet (Phase γ territory):
1879/// - Alt-screen apps (`\x1b[?1049h`) — left in the stream.
1880/// - Cursor positioning escapes within the same line.
1881/// - OSC 633 (shell integration markers).
1882fn strip_ansi(chunk: &mut Vec<u8>) {
1883 let mut out: Vec<u8> = Vec::with_capacity(chunk.len());
1884 let mut i = 0;
1885 while i < chunk.len() {
1886 let b = chunk[i];
1887 if b == 0x1b && i + 1 < chunk.len() {
1888 match chunk[i + 1] {
1889 b'[' => {
1890 // CSI: scan params (0x30..=0x3F) then final (0x40..=0x7E).
1891 let mut j = i + 2;
1892 while j < chunk.len() && (0x30..=0x3F).contains(&chunk[j]) {
1893 j += 1;
1894 }
1895 // Intermediate bytes (rare for our case).
1896 while j < chunk.len() && (0x20..=0x2F).contains(&chunk[j]) {
1897 j += 1;
1898 }
1899 if j < chunk.len() && (0x40..=0x7E).contains(&chunk[j]) {
1900 // Consume the whole CSI sequence.
1901 i = j + 1;
1902 continue;
1903 }
1904 // Malformed — keep ESC, skip past.
1905 out.push(b);
1906 i += 1;
1907 continue;
1908 }
1909 b']' => {
1910 // OSC: scan until BEL (0x07) or ST (`ESC \`).
1911 let mut j = i + 2;
1912 while j < chunk.len() {
1913 if chunk[j] == 0x07 {
1914 i = j + 1;
1915 break;
1916 }
1917 if chunk[j] == 0x1b && j + 1 < chunk.len() && chunk[j + 1] == b'\\' {
1918 i = j + 2;
1919 break;
1920 }
1921 j += 1;
1922 }
1923 if i <= j {
1924 // Reached end of chunk without terminator —
1925 // drop the rest as a partial OSC. (Next read
1926 // will resume with the trailing bytes; if it
1927 // doesn't start with the terminator we'll
1928 // mis-render once, but that's better than
1929 // emitting garbage.)
1930 i = chunk.len();
1931 }
1932 continue;
1933 }
1934 _ => {
1935 // ESC followed by some other byte (e.g. `ESC c` =
1936 // reset). Consume both.
1937 i += 2;
1938 continue;
1939 }
1940 }
1941 }
1942 if b == 0x07 {
1943 // Bare BEL — drop.
1944 i += 1;
1945 continue;
1946 }
1947 if b == b'\r' && i + 1 < chunk.len() && chunk[i + 1] == b'\n' {
1948 // CRLF → LF: drop the \r, keep the \n on the next pass.
1949 i += 1;
1950 continue;
1951 }
1952 out.push(b);
1953 i += 1;
1954 }
1955 *chunk = out;
1956}
1957
1958/// Collapse lone carriage-returns in the `pending` byte buffer, simulating
1959/// terminal overwrite: each `\r` NOT followed by `\n` discards the current
1960/// visual line back to column 0.
1961///
1962/// **Why here and not in `strip_ansi`:** real spinner animations emit each
1963/// frame as a separate `write()` syscall, so the preceding frame content and
1964/// the overwriting `\r` arrive in different PTY reads. Running collapse on
1965/// the `pending` accumulator (which spans reads for the current visual line)
1966/// catches those cross-read overwrites.
1967///
1968/// **Leading `\r` is preserved — including through mid-buffer overwrites.**
1969/// When a lone `\r` appears at the very start of a visual line, it is emitted
1970/// as-is rather than discarded. When a subsequent lone `\r` overwrites a line
1971/// that *itself* started with `\r` (e.g. `"\rframe1\rframe2"`), the leading
1972/// `\r` is preserved in the result (`"\rframe2"`, not `"frame2"`). This lets
1973/// the caller's `pending_cr_override` slot chain-collapse throttled spinner
1974/// frames on successive reads: the collapsed result still starts with `\r`, so
1975/// the slot engages again on the next quiet-window expiry.
1976///
1977/// **Trailing `\r` is left in place.** A `\r` at the very end of `pending`
1978/// might be the first byte of a CRLF pair split across reads. Collapsing it
1979/// immediately would discard the line's content before the `\n` arrives,
1980/// causing silent data loss. The caller suppresses the eager partial-line
1981/// flush when `pending` ends with `\r` so the next read can disambiguate.
1982fn collapse_cr(pending: &mut Vec<u8>) {
1983 if !pending.contains(&b'\r') {
1984 return; // fast-path: no CR at all
1985 }
1986 let mut out: Vec<u8> = Vec::with_capacity(pending.len());
1987 let mut line_start = 0usize;
1988 let mut i = 0;
1989 while i < pending.len() {
1990 let b = pending[i];
1991 if b == b'\n' {
1992 out.push(b);
1993 line_start = out.len();
1994 i += 1;
1995 } else if b == b'\r' {
1996 if i + 1 < pending.len() {
1997 if pending[i + 1] == b'\n' {
1998 // CRLF → LF (strip_ansi already handles this for
1999 // within-chunk pairs, but CRLF can straddle reads).
2000 out.push(b'\n');
2001 line_start = out.len();
2002 i += 2;
2003 } else if out.len() == line_start {
2004 // Leading \r: nothing to overwrite on this line.
2005 // Preserve it so the caller's pending_cr_override slot
2006 // can recognise and hold leading-\r spinner frames
2007 // ("\rframe", npm/ora/tqdm style).
2008 out.push(b);
2009 i += 1;
2010 } else {
2011 // Lone \r mid-buffer: discard current line back to column 0.
2012 // If the line opened with a leading \r (spinner convention),
2013 // preserve that leading \r so the pending_cr_override slot in
2014 // the reader can still recognise the result as a spinner frame
2015 // after collapsing "\rframe1\rframe2" → "\rframe2".
2016 let keep =
2017 usize::from(out.len() > line_start && out[line_start] == b'\r');
2018 out.truncate(line_start + keep);
2019 i += 1;
2020 }
2021 } else {
2022 // Trailing \r — can't disambiguate yet; leave for next read.
2023 out.push(b);
2024 i += 1;
2025 }
2026 } else {
2027 out.push(b);
2028 i += 1;
2029 }
2030 }
2031 *pending = out;
2032}
2033
2034/// Shared publisher loop — drains the LineEvent channel, aggregates
2035/// into the model-visible buffer, and publishes each line via MPS.
2036///
2037/// **Leading-`\r` spinner handling:** throttled spinner frames (npm, cargo,
2038/// ora, tqdm at >50 ms/frame) are collapsed upstream — in the
2039/// `pending_cr_override` slot inside `stream_reader` (pipe path) and
2040/// `pty_reader_loop` (PTY path) — before reaching this loop. The
2041/// `pending_cr_line` slot here remains as a secondary layer: when a frame
2042/// arrives with a leading `\r`, the prior stored frame is published first
2043/// (so no frame is silently dropped), then the new frame is stored and
2044/// flushed at the next non-`\r` line or EOF. Consecutive spinner frames
2045/// therefore appear as sequential published chunks in the live log.
2046///
2047/// The model-visible buffer (`buffered`) receives the post-collapse LineEvent
2048/// bytes. Intermediate spinner frames collapsed by `pending_cr_override` never
2049/// become LineEvents and are not present in `buffered`; the model sees only the
2050/// surviving final frame for each overwrite sequence.
2051fn spawn_publisher_loop(
2052 args: &Args,
2053 mps: Option<WpsClient>,
2054 buffered: Arc<Mutex<Vec<u8>>>,
2055 mut rx: mpsc::Receiver<LineEvent>,
2056) -> tokio::task::JoinHandle<()> {
2057 let tool_id = args.tool_id.clone();
2058 let block_id = args.block_id.clone();
2059 tokio::spawn(async move {
2060 let mut chunks_published = 0u64;
2061 let mut chunks_failed = 0u64;
2062
2063 // Pending leading-\r spinner frame: holds (content_after_cr, kind).
2064 // Flushed on the next non-\r line or at EOF.
2065 let mut pending_cr_line: Option<(String, ToolChunkKind)> = None;
2066
2067 while let Some(event) = rx.recv().await {
2068 {
2069 let mut buf = buffered.lock().await;
2070 if event.kind == ToolChunkKind::Stderr {
2071 buf.extend_from_slice(b"[stderr] ");
2072 }
2073 // Strip a single leading `\r` carried by a collapsed leading-\r
2074 // spinner frame (e.g. a final "\r✔ done\n"), matching the MPS
2075 // live path's `strip_prefix('\r')` below, so the model-visible
2076 // blob and the live log agree byte-for-byte. The trailing `\n`
2077 // is preserved here — it separates lines in the blob.
2078 let body: &[u8] = match event.bytes.first() {
2079 Some(&b'\r') => &event.bytes[1..],
2080 _ => &event.bytes,
2081 };
2082 buf.extend_from_slice(body);
2083 }
2084 if let Some(client) = mps.as_ref() {
2085 let mut line_bytes: &[u8] = &event.bytes;
2086 if line_bytes.last() == Some(&b'\n') {
2087 line_bytes = &line_bytes[..line_bytes.len() - 1];
2088 }
2089 let line_str = String::from_utf8_lossy(line_bytes);
2090
2091 if let Some(stripped) = line_str.strip_prefix('\r') {
2092 // Leading-\r spinner frame: publish any prior pending frame
2093 // before storing the new one, so consecutive \r-prefixed
2094 // lines are not silently dropped from the live MPS log.
2095 // (This pending slot only affects the live MPS log; the
2096 // model-visible blob already received this frame's bytes
2097 // above, with the leading \r stripped to match.)
2098 if let Some((prior_text, prior_kind)) = pending_cr_line.take() {
2099 match publish_line(
2100 client,
2101 &tool_id,
2102 block_id.as_deref(),
2103 prior_kind,
2104 &prior_text,
2105 )
2106 .await
2107 {
2108 Ok(()) => chunks_published += 1,
2109 Err(e) => {
2110 chunks_failed += 1;
2111 tracing::warn!(
2112 target: "bashwrap",
2113 tool_id = %tool_id,
2114 error = %e,
2115 "MPS publish failed (prior pending_cr_line)"
2116 );
2117 }
2118 }
2119 }
2120 pending_cr_line = Some((stripped.to_owned(), event.kind));
2121 } else {
2122 // Non-\r line: flush any pending spinner frame first.
2123 if let Some((cr_text, cr_kind)) = pending_cr_line.take() {
2124 match publish_line(
2125 client,
2126 &tool_id,
2127 block_id.as_deref(),
2128 cr_kind,
2129 &cr_text,
2130 )
2131 .await
2132 {
2133 Ok(()) => chunks_published += 1,
2134 Err(e) => {
2135 chunks_failed += 1;
2136 tracing::warn!(
2137 target: "bashwrap",
2138 tool_id = %tool_id,
2139 error = %e,
2140 "MPS publish failed"
2141 );
2142 }
2143 }
2144 }
2145 match publish_line(
2146 client,
2147 &tool_id,
2148 block_id.as_deref(),
2149 event.kind,
2150 &line_str,
2151 )
2152 .await
2153 {
2154 Ok(()) => chunks_published += 1,
2155 Err(e) => {
2156 chunks_failed += 1;
2157 tracing::warn!(
2158 target: "bashwrap",
2159 tool_id = %tool_id,
2160 error = %e,
2161 "MPS publish failed"
2162 );
2163 }
2164 }
2165 }
2166 }
2167 }
2168
2169 // EOF: flush any remaining pending spinner frame.
2170 if let Some((cr_text, cr_kind)) = pending_cr_line.take() {
2171 if let Some(client) = mps.as_ref() {
2172 match publish_line(client, &tool_id, block_id.as_deref(), cr_kind, &cr_text).await
2173 {
2174 Ok(()) => chunks_published += 1,
2175 Err(e) => {
2176 chunks_failed += 1;
2177 tracing::warn!(
2178 target: "bashwrap",
2179 tool_id = %tool_id,
2180 error = %e,
2181 "MPS publish failed (EOF flush)"
2182 );
2183 }
2184 }
2185 }
2186 }
2187
2188 tracing::info!(
2189 target: "bashwrap",
2190 tool_id = %tool_id,
2191 chunks_published,
2192 chunks_failed,
2193 "publisher done"
2194 );
2195 })
2196}
2197
2198/// Snap `idx` down to the nearest UTF-8 character boundary (or 0).
2199/// A byte is a UTF-8 char boundary iff it's either ASCII (< 0x80) or
2200/// a leading byte (>= 0xC0). Continuation bytes are in 0x80..=0xBF.
2201/// `idx == buf.len()` is always a valid boundary (one past the end).
2202fn snap_to_char_boundary_floor(buf: &[u8], idx: usize) -> usize {
2203 let mut i = idx.min(buf.len());
2204 while i > 0 && i < buf.len() && (buf[i] & 0xC0) == 0x80 {
2205 i -= 1;
2206 }
2207 i
2208}
2209
2210/// Snap `idx` up to the nearest UTF-8 character boundary (or buf.len()).
2211fn snap_to_char_boundary_ceil(buf: &[u8], idx: usize) -> usize {
2212 let mut i = idx.min(buf.len());
2213 while i < buf.len() && (buf[i] & 0xC0) == 0x80 {
2214 i += 1;
2215 }
2216 i
2217}
2218
2219/// Build the aggregated, model-visible blob from the captured bytes.
2220/// Truncates with head/tail markers if it exceeds 100KB.
2221///
2222/// **UTF-8 safety**: head/tail slice boundaries are snapped to the
2223/// nearest UTF-8 character boundary so non-ASCII output (emoji,
2224/// accented characters, CJK) doesn't get corrupted into `�`
2225/// replacement characters at the cut point — naive fixed-byte slicing
2226/// splits multi-byte sequences and the lossy decode emits replacement
2227/// chars.
2228pub(crate) fn format_model_blob(
2229 buf: &[u8],
2230 exit_code: i32,
2231 elapsed: std::time::Duration,
2232) -> String {
2233 let body = if buf.len() <= MODEL_BLOB_HEAD_BYTES + MODEL_BLOB_TAIL_BYTES {
2234 String::from_utf8_lossy(buf).into_owned()
2235 } else {
2236 let head_end = snap_to_char_boundary_floor(buf, MODEL_BLOB_HEAD_BYTES);
2237 let tail_start = snap_to_char_boundary_ceil(buf, buf.len() - MODEL_BLOB_TAIL_BYTES);
2238 let head = String::from_utf8_lossy(&buf[..head_end]);
2239 let tail = String::from_utf8_lossy(&buf[tail_start..]);
2240 let elided = tail_start - head_end;
2241 format!(
2242 "{}\n... [{} bytes elided — see streaming log for full output] ...\n{}",
2243 head, elided, tail
2244 )
2245 };
2246 format!(
2247 "<exited {} in {:.2}s>\n{}",
2248 exit_code,
2249 elapsed.as_secs_f64(),
2250 body
2251 )
2252}
2253
2254/// Output fragments that mean an API refused the caller rather than the
2255/// command being wrong: AWS (`AccessDenied`, `AccessDeniedException`,
2256/// `... is not authorized to perform: ...`, EC2's `UnauthorizedOperation`)
2257/// and the GitHub API as an App (`Resource not accessible by integration`).
2258const ACCESS_DENIED_MARKERS: &[&str] = &[
2259 "AccessDenied",
2260 "is not authorized to perform",
2261 "UnauthorizedOperation",
2262 "Resource not accessible by integration",
2263];
2264
2265/// Appended after a failed command whose output shows an access denial.
2266/// Generic on purpose: how to escalate differs per deployment. Conditional
2267/// on purpose: in a compound command or a test suite the denial text may
2268/// come from a step that isn't the one that failed.
2269pub(crate) const ACCESS_DENIED_HINT: &str = "\n[agentmux] The output above includes an access denial. If that denial \
2270is why the command failed, don't work around it with another credential or by skipping the step: use this \
2271environment's documented escalation path (check your Global Memory and CLAUDE.md) with a stated reason, or ask the \
2272human operator if there is none.\n";
2273
2274/// The hint for the model, if the command failed and its output shows an
2275/// access denial. The whole buffer is searched, including any part the
2276/// model blob elides. A successful command never gets the hint, so a `grep`
2277/// or a log that merely mentions `AccessDenied` stays untouched.
2278pub(crate) fn access_denied_hint(buf: &[u8], exit_code: i32) -> Option<&'static str> {
2279 if exit_code == 0 {
2280 return None;
2281 }
2282 let denied = ACCESS_DENIED_MARKERS.iter().any(|marker| {
2283 let needle = marker.as_bytes();
2284 buf.windows(needle.len()).any(|window| window == needle)
2285 });
2286 denied.then_some(ACCESS_DENIED_HINT)
2287}
2288
2289#[cfg(test)]
2290mod tests {
2291 use super::*;
2292 use std::sync::Mutex;
2293
2294 // `std::env::set_var` / `remove_var` mutate process-global state;
2295 // cargo test runs tests in parallel by default, so without a
2296 // serial lock tests that touch the env race each other. Mirrors
2297 // the same pattern in `mps_client::tests`.
2298 static ENV_LOCK: Mutex<()> = Mutex::new(());
2299
2300 #[test]
2301 fn decodes_b64_command() {
2302 // base64("echo hi") = ZWNobyBoaQ (no padding, url-safe)
2303 let s = decode_command("ZWNobyBoaQ").unwrap();
2304 assert_eq!(s, "echo hi");
2305 }
2306
2307 #[test]
2308 fn decodes_multi_line_with_quotes() {
2309 use base64::Engine as _;
2310 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
2311 let original = "echo \"line one\"\necho 'line two'";
2312 let b64 = URL_SAFE_NO_PAD.encode(original.as_bytes());
2313 let s = decode_command(&b64).unwrap();
2314 assert_eq!(s, original);
2315 }
2316
2317 #[test]
2318 fn rejects_malformed_b64() {
2319 assert!(decode_command("not-valid-base64===").is_err());
2320 }
2321
2322 // ── cwd persistence tests ───────────────────────────────────────────────
2323 // See docs/retro/RETRO_BASH_CWD_RESET_NOTICE_WINDOWS_2026_08_02.md for
2324 // the bug this exists to fix: each `exec` invocation used to always seed
2325 // its inner bash's cwd from this process's own (fixed) cwd, silently
2326 // discarding whatever directory a previous call's `cd` left the agent
2327 // in.
2328
2329 /// Unique-per-call scratch directory under the OS temp dir, so parallel
2330 /// test runs (cargo's default) never share state. Mirrors the marker-tag
2331 /// pattern `run_via_pty_kills_idle_child_and_returns_promptly` already
2332 /// uses for the same reason.
2333 fn unique_temp_dir(tag: &str) -> PathBuf {
2334 use std::sync::atomic::{AtomicU64, Ordering};
2335 static COUNTER: AtomicU64 = AtomicU64::new(0);
2336 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
2337 let dir = std::env::temp_dir().join(format!(
2338 "agentmux-bashwrap-test-{tag}-{}-{n}",
2339 std::process::id()
2340 ));
2341 std::fs::create_dir_all(&dir).expect("create unique temp dir");
2342 dir
2343 }
2344
2345 #[test]
2346 fn sanitize_state_key_replaces_unsafe_chars() {
2347 assert_eq!(sanitize_state_key("agent3-0630k"), "agent3-0630k");
2348 assert_eq!(sanitize_state_key("Agent With Spaces"), "Agent_With_Spaces");
2349 assert_eq!(sanitize_state_key(r"C:\Users\user"), "C__Users_user");
2350 }
2351
2352 #[test]
2353 fn cwd_state_path_honors_explicit_override() {
2354 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2355 let scratch = unique_temp_dir("override");
2356 let target = scratch.join("explicit.cwd");
2357 unsafe {
2358 std::env::set_var(CWD_STATE_FILE_OVERRIDE_ENV, &target);
2359 }
2360 let resolved = cwd_state_path();
2361 unsafe {
2362 std::env::remove_var(CWD_STATE_FILE_OVERRIDE_ENV);
2363 }
2364 let _ = std::fs::remove_dir_all(&scratch);
2365 assert_eq!(resolved, Some(target));
2366 }
2367
2368 /// Save-and-restore guard for an env var, so tests can freely
2369 /// set/remove it and always leave the ambient environment exactly as
2370 /// they found it, even on an early return or panic mid-test — every
2371 /// cwd-state-key test needs this for at least `AGENTMUX_AGENT_ID` and
2372 /// `AGENTMUX_INSTANCE_SLUG` simultaneously, so a bespoke prev/restore
2373 /// dance per test (the pattern used elsewhere in this file for a single
2374 /// var) would otherwise multiply combinatorially.
2375 struct EnvVarGuard {
2376 key: &'static str,
2377 prev: Option<String>,
2378 }
2379
2380 impl EnvVarGuard {
2381 fn set(key: &'static str, value: &str) -> Self {
2382 let prev = std::env::var(key).ok();
2383 unsafe {
2384 std::env::set_var(key, value);
2385 }
2386 Self { key, prev }
2387 }
2388
2389 fn unset(key: &'static str) -> Self {
2390 let prev = std::env::var(key).ok();
2391 unsafe {
2392 std::env::remove_var(key);
2393 }
2394 Self { key, prev }
2395 }
2396 }
2397
2398 impl Drop for EnvVarGuard {
2399 fn drop(&mut self) {
2400 unsafe {
2401 match &self.prev {
2402 Some(v) => std::env::set_var(self.key, v),
2403 None => std::env::remove_var(self.key),
2404 }
2405 }
2406 }
2407 }
2408
2409 #[test]
2410 fn cwd_state_path_derives_from_agent_id_when_no_override_or_instance_slug() {
2411 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2412 let _override = EnvVarGuard::unset(CWD_STATE_FILE_OVERRIDE_ENV);
2413 let _instance = EnvVarGuard::unset("AGENTMUX_INSTANCE_SLUG");
2414 let _agent = EnvVarGuard::set("AGENTMUX_AGENT_ID", "Test-Agent-42");
2415
2416 let resolved = cwd_state_path().expect("home dir should resolve in any test environment");
2417 assert!(resolved.ends_with("Test-Agent-42.cwd"), "{resolved:?}");
2418 assert!(resolved.to_string_lossy().contains(".agentmux"));
2419 }
2420
2421 /// codex P1 on this PR: keying solely by `AGENTMUX_AGENT_ID` (the
2422 /// definition-wide slug) made two concurrently-running *instances* of
2423 /// the same agent definition share one cwd file — a `cd` in instance A
2424 /// would redirect instance B's next command into A's directory, even
2425 /// though each instance has its own distinct working directory. The
2426 /// fix: prefer `AGENTMUX_INSTANCE_SLUG` (per-launch, unique per running
2427 /// instance — see `agent-model.ts`) whenever it's set.
2428 #[test]
2429 fn cwd_state_path_prefers_instance_slug_over_agent_id() {
2430 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2431 let _override = EnvVarGuard::unset(CWD_STATE_FILE_OVERRIDE_ENV);
2432 let _agent = EnvVarGuard::set("AGENTMUX_AGENT_ID", "shared-definition-slug");
2433 let _instance_a = EnvVarGuard::set("AGENTMUX_INSTANCE_SLUG", "shared-definition-slug-20260101-000000");
2434
2435 let resolved_a =
2436 cwd_state_path().expect("home dir should resolve in any test environment");
2437 assert!(
2438 resolved_a.ends_with("shared-definition-slug-20260101-000000.cwd"),
2439 "{resolved_a:?}"
2440 );
2441
2442 // A second, concurrently-running instance of the SAME definition
2443 // (same AGENTMUX_AGENT_ID, different AGENTMUX_INSTANCE_SLUG) must
2444 // resolve to a DIFFERENT file.
2445 let _instance_b = EnvVarGuard::set("AGENTMUX_INSTANCE_SLUG", "shared-definition-slug-20260101-000001");
2446 let resolved_b =
2447 cwd_state_path().expect("home dir should resolve in any test environment");
2448 assert_ne!(resolved_a, resolved_b);
2449 assert!(
2450 resolved_b.ends_with("shared-definition-slug-20260101-000001.cwd"),
2451 "{resolved_b:?}"
2452 );
2453 }
2454
2455 /// reagentx re-review finding: when the state dir can't be created,
2456 /// `CwdState::load()` used to leave `path` as `Some` anyway, so
2457 /// `restore_cwd` and the child's write would both silently keep failing
2458 /// against a directory that doesn't exist on every subsequent call,
2459 /// instead of cleanly disabling persistence once as the doc comment
2460 /// promises. Forces the failure by pointing the override at a path
2461 /// whose *parent* is a plain file, not a directory — `create_dir_all`
2462 /// cannot create a directory there.
2463 #[test]
2464 fn cwd_state_load_clears_path_when_state_dir_cannot_be_created() {
2465 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2466 let scratch = unique_temp_dir("uncreatable-parent");
2467 let blocking_file = scratch.join("not-a-directory");
2468 std::fs::write(&blocking_file, b"blocks create_dir_all").unwrap();
2469 let unreachable_state_file = blocking_file.join("state.cwd");
2470
2471 let _override = EnvVarGuard::set(
2472 CWD_STATE_FILE_OVERRIDE_ENV,
2473 unreachable_state_file.to_str().unwrap(),
2474 );
2475 let state = CwdState::load();
2476 let _ = std::fs::remove_dir_all(&scratch);
2477
2478 assert_eq!(
2479 state.path, None,
2480 "path must be cleared (not just logged-as-disabled) when its parent dir can't be created"
2481 );
2482 assert!(state.start_dir.is_some(), "must still fall back to a start dir");
2483 }
2484
2485 #[test]
2486 fn restore_cwd_none_when_file_missing() {
2487 let scratch = unique_temp_dir("missing");
2488 let missing = scratch.join("does-not-exist.cwd");
2489 assert_eq!(restore_cwd(&missing), None);
2490 let _ = std::fs::remove_dir_all(&scratch);
2491 }
2492
2493 #[test]
2494 fn restore_cwd_none_when_persisted_dir_no_longer_exists() {
2495 let scratch = unique_temp_dir("stale");
2496 let state_file = scratch.join("state.cwd");
2497 let gone = scratch.join("deleted-dir");
2498 std::fs::create_dir_all(&gone).unwrap();
2499 std::fs::write(&state_file, gone.to_string_lossy().as_bytes()).unwrap();
2500 std::fs::remove_dir_all(&gone).unwrap();
2501 assert_eq!(restore_cwd(&state_file), None);
2502 let _ = std::fs::remove_dir_all(&scratch);
2503 }
2504
2505 #[test]
2506 fn restore_cwd_some_when_persisted_dir_exists() {
2507 let scratch = unique_temp_dir("valid");
2508 let state_file = scratch.join("state.cwd");
2509 std::fs::write(&state_file, scratch.to_string_lossy().as_bytes()).unwrap();
2510 assert_eq!(restore_cwd(&state_file), Some(scratch.clone()));
2511 let _ = std::fs::remove_dir_all(&scratch);
2512 }
2513
2514 #[test]
2515 fn restore_cwd_ignores_blank_file() {
2516 let scratch = unique_temp_dir("blank");
2517 let state_file = scratch.join("state.cwd");
2518 std::fs::write(&state_file, b" \n").unwrap();
2519 assert_eq!(restore_cwd(&state_file), None);
2520 let _ = std::fs::remove_dir_all(&scratch);
2521 }
2522
2523 #[test]
2524 fn append_cwd_capture_preserves_exit_code_and_gates_on_env() {
2525 let script = append_cwd_capture("false");
2526 // The real command's exit code must survive the appended
2527 // bookkeeping regardless of whether the state env var is set.
2528 assert!(script.contains("__agentmux_bashwrap_rc=$?"));
2529 assert!(script.trim_end().ends_with("exit $__agentmux_bashwrap_rc"));
2530 // The write is gated on the env var being non-empty, and uses a
2531 // per-PID temp-then-rename so a crash mid-write can't corrupt the
2532 // file, and two concurrent invocations never write the same temp
2533 // file (reagentx P2 on this PR).
2534 assert!(script.contains(&format!("-n \"${CWD_STATE_ENV}\"")));
2535 assert!(script.contains(&format!("${CWD_STATE_ENV}.tmp.$$")));
2536 assert!(script.contains("mv -f"));
2537 }
2538
2539 /// reagentx P2 on this PR: a fixed (non-unique) temp filename meant two
2540 /// concurrent Bash tool calls for the same instance could both write to
2541 /// the *same* temp file before either renamed it, corrupting the
2542 /// persisted cwd with interleaved bytes. Proves two real, genuinely
2543 /// concurrent writers (via `$$`, each process's own PID) never
2544 /// interleave — the file always ends up as one writer's complete,
2545 /// valid output, never a mix of both.
2546 #[tokio::test]
2547 async fn run_via_pipes_concurrent_writers_never_corrupt_state_file() {
2548 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2549 let scratch = unique_temp_dir("e2e-concurrent");
2550 let state_file = scratch.join("state.cwd");
2551 let dir_a = scratch.join("dir-a");
2552 let dir_b = scratch.join("dir-b");
2553 std::fs::create_dir_all(&dir_a).unwrap();
2554 std::fs::create_dir_all(&dir_b).unwrap();
2555
2556 let _override =
2557 EnvVarGuard::set(CWD_STATE_FILE_OVERRIDE_ENV, state_file.to_str().unwrap());
2558
2559 let bash = locate_bash().expect("locate_bash for test — same dependency the whole binary needs");
2560 let args_a = Args {
2561 tool_id: "test-concurrent-a".to_string(),
2562 b64_cmd: String::new(),
2563 block_id: None,
2564 declared_background: false,
2565 };
2566 let args_b = Args {
2567 tool_id: "test-concurrent-b".to_string(),
2568 b64_cmd: String::new(),
2569 block_id: None,
2570 declared_background: false,
2571 };
2572 let cd_a = format!("cd \"{}\"", dir_a.display());
2573 let cd_b = format!("cd \"{}\"", dir_b.display());
2574 let buffered_a = Arc::new(tokio::sync::Mutex::new(Vec::<u8>::new()));
2575 let buffered_b = Arc::new(tokio::sync::Mutex::new(Vec::<u8>::new()));
2576
2577 let (status_a, status_b) = tokio::join!(
2578 run_via_pipes(&args_a, &cd_a, None, buffered_a, &bash),
2579 run_via_pipes(&args_b, &cd_b, None, buffered_b, &bash),
2580 );
2581 assert_eq!(status_a.unwrap(), 0);
2582 assert_eq!(status_b.unwrap(), 0);
2583
2584 // Whichever writer's rename landed last, the file must contain
2585 // exactly ONE valid, complete directory path — never a truncated
2586 // or interleaved mix of both writers' output.
2587 let final_contents = std::fs::read_to_string(&state_file).unwrap();
2588 let trimmed = final_contents.trim();
2589 let a_str = dir_a.to_string_lossy();
2590 let b_str = dir_b.to_string_lossy();
2591 assert!(
2592 trimmed.ends_with(dir_a.file_name().unwrap().to_str().unwrap())
2593 || trimmed.ends_with(dir_b.file_name().unwrap().to_str().unwrap()),
2594 "expected a clean write of either {a_str:?} or {b_str:?}, got {trimmed:?}"
2595 );
2596 assert!(
2597 !trimmed.contains('\n'),
2598 "a corrupted/interleaved write would likely contain embedded newlines: {trimmed:?}"
2599 );
2600
2601 let _ = std::fs::remove_dir_all(&scratch);
2602 }
2603
2604 /// End-to-end proof of the actual fix: running a `cd` in one `exec`
2605 /// invocation must change the starting directory of the *next*
2606 /// invocation, via the persisted state file — not just leave a
2607 /// same-process illusion of persistence.
2608 #[tokio::test]
2609 async fn run_via_pipes_persists_cwd_across_invocations() {
2610 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2611 let scratch = unique_temp_dir("e2e-pipes");
2612 let state_file = scratch.join("state.cwd");
2613 let target_dir = scratch.join("moved-here");
2614 std::fs::create_dir_all(&target_dir).unwrap();
2615
2616 let prev = std::env::var(CWD_STATE_FILE_OVERRIDE_ENV).ok();
2617 unsafe {
2618 std::env::set_var(CWD_STATE_FILE_OVERRIDE_ENV, &state_file);
2619 }
2620
2621 let bash = locate_bash().expect("locate_bash for test — same dependency the whole binary needs");
2622 let args = Args {
2623 tool_id: "test-cwd-persist".to_string(),
2624 b64_cmd: String::new(),
2625 block_id: None,
2626 declared_background: false,
2627 };
2628
2629 // Call 1: cd into target_dir. Nothing about this process's own env
2630 // changes — the fix must work purely through the state file.
2631 let cd_cmd = format!("cd \"{}\"", target_dir.display());
2632 let buffered1 = Arc::new(tokio::sync::Mutex::new(Vec::<u8>::new()));
2633 let status1 = run_via_pipes(&args, &cd_cmd, None, buffered1, &bash)
2634 .await
2635 .expect("call 1 should succeed");
2636 assert_eq!(status1, 0, "cd should succeed");
2637
2638 // Call 2: a fresh, unrelated invocation with no cd of its own —
2639 // `pwd` should report target_dir, proving the restored starting
2640 // directory came from call 1's persisted state, not from this
2641 // process's own (unchanged) cwd.
2642 let buffered2 = Arc::new(tokio::sync::Mutex::new(Vec::<u8>::new()));
2643 let status2 = run_via_pipes(&args, "pwd", None, buffered2.clone(), &bash)
2644 .await
2645 .expect("call 2 should succeed");
2646 assert_eq!(status2, 0);
2647
2648 unsafe {
2649 match prev {
2650 Some(v) => std::env::set_var(CWD_STATE_FILE_OVERRIDE_ENV, v),
2651 None => std::env::remove_var(CWD_STATE_FILE_OVERRIDE_ENV),
2652 }
2653 }
2654
2655 let pwd_output = String::from_utf8_lossy(&buffered2.lock().await).trim().to_string();
2656 let expected_name = target_dir
2657 .file_name()
2658 .and_then(|n| n.to_str())
2659 .unwrap()
2660 .to_string();
2661 let _ = std::fs::remove_dir_all(&scratch);
2662
2663 // Compare by trailing path component only — Windows `pwd` under Git
2664 // Bash reports an MSYS-style path (`/c/...`), not the Windows-style
2665 // path this test constructed the `cd` from.
2666 assert!(
2667 pwd_output.ends_with(&expected_name),
2668 "expected pwd output to end in {expected_name:?}, got {pwd_output:?}"
2669 );
2670 }
2671
2672 #[test]
2673 fn format_model_blob_under_cap_passes_body_through() {
2674 let body = b"hello world\n";
2675 let out = format_model_blob(body, 0, std::time::Duration::from_millis(123));
2676 assert!(out.starts_with("<exited 0 in 0.12s>\n"));
2677 assert!(out.contains("hello world"));
2678 }
2679
2680 #[test]
2681 fn access_denied_hint_on_failed_denials() {
2682 // Real outputs: the AWS CLI, CDK, EC2 and the GitHub API as an App.
2683 for output in [
2684 "An error occurred (AccessDeniedException) when calling the GetCostAndUsage operation: User: arn:aws:sts::1:assumed-role/R/s is not authorized to perform: ce:GetCostAndUsage",
2685 "User: arn:aws:sts::1:assumed-role/R/s is not authorized to perform: cloudformation:DescribeStacks on resource: arn:aws:cloudformation:us-east-1:1:stack/x/y",
2686 "An error occurred (UnauthorizedOperation) when calling the DescribeInstances operation",
2687 "An error occurred (AccessDenied) when calling the ListBuckets operation: Access Denied",
2688 "gh: Resource not accessible by integration (HTTP 403)",
2689 ] {
2690 assert_eq!(access_denied_hint(output.as_bytes(), 254), Some(ACCESS_DENIED_HINT), "{output}");
2691 }
2692 }
2693
2694 #[test]
2695 fn access_denied_hint_not_on_success_or_other_failures() {
2696 // Exit 0: a grep or a log that only mentions a denial.
2697 assert_eq!(access_denied_hint(b"docs/x.md:12: AccessDenied means escalate", 0), None);
2698 // Failed, but not a denial.
2699 assert_eq!(access_denied_hint(b"error: could not compile `x`", 101), None);
2700 assert_eq!(access_denied_hint(b"", 1), None);
2701 }
2702
2703 #[test]
2704 fn access_denied_hint_searches_output_the_blob_elides() {
2705 // The denial sits in the middle of output over the model blob's cap.
2706 let mut buf = vec![b'x'; MODEL_BLOB_HEAD_BYTES + 10];
2707 buf.extend_from_slice(b" is not authorized to perform: s3:PutObject ");
2708 buf.extend(vec![b'y'; MODEL_BLOB_TAIL_BYTES + 10]);
2709 let blob = format_model_blob(&buf, 1, std::time::Duration::from_millis(1));
2710 assert!(!blob.contains("is not authorized"), "precondition: the blob elides the denial");
2711 assert_eq!(access_denied_hint(&buf, 1), Some(ACCESS_DENIED_HINT));
2712 }
2713
2714 #[test]
2715 fn access_denied_hint_is_generic_and_says_not_to_work_around() {
2716 // AgentMux ships to every deployment: no deployment's own commands.
2717 for specific in ["secrets escalate", "gh-agent", "a5af"] {
2718 assert!(!ACCESS_DENIED_HINT.contains(specific), "{specific}");
2719 }
2720 assert!(ACCESS_DENIED_HINT.contains("don't work around it"));
2721 assert!(ACCESS_DENIED_HINT.contains("ask the human operator"));
2722 // The denial may come from an earlier step than the one that failed.
2723 assert!(ACCESS_DENIED_HINT.contains("If that denial is why the command failed"));
2724 }
2725
2726 #[test]
2727 fn format_model_blob_truncates_over_cap_at_char_boundary() {
2728 // 200KB of '🌟' (4-byte emoji) > 100KB cap → truncates.
2729 let s = "🌟".repeat(50_000);
2730 let bytes = s.as_bytes();
2731 let out = format_model_blob(bytes, 0, std::time::Duration::from_secs(1));
2732 // No replacement char `�` (U+FFFD) — boundary snap must keep
2733 // multi-byte sequences whole at the cut point.
2734 assert!(
2735 !out.contains('\u{FFFD}'),
2736 "found replacement char (lossy decode at boundary)"
2737 );
2738 assert!(out.contains("bytes elided"));
2739 }
2740
2741 #[test]
2742 fn snap_floor_is_idempotent_at_ascii() {
2743 let buf = b"abcdef";
2744 assert_eq!(snap_to_char_boundary_floor(buf, 3), 3);
2745 assert_eq!(snap_to_char_boundary_floor(buf, 0), 0);
2746 assert_eq!(snap_to_char_boundary_floor(buf, 6), 6);
2747 }
2748
2749 #[test]
2750 fn snap_floor_steps_back_off_continuation_bytes() {
2751 // "é" = [0xC3, 0xA9]; index 1 is a continuation byte.
2752 let buf = b"\xc3\xa9x";
2753 assert_eq!(snap_to_char_boundary_floor(buf, 1), 0);
2754 }
2755
2756 // ── collapse_cr tests ────────────────────────────────────────────────────
2757
2758 fn cr(input: &[u8]) -> Vec<u8> {
2759 let mut v = input.to_vec();
2760 collapse_cr(&mut v);
2761 v
2762 }
2763
2764 #[test]
2765 fn collapse_cr_no_cr_is_noop() {
2766 assert_eq!(cr(b"hello\nworld\n"), b"hello\nworld\n");
2767 }
2768
2769 #[test]
2770 fn collapse_cr_trailing_cr_preserved() {
2771 // Trailing \r must not be collapsed — may be first byte of CRLF split
2772 // across reads. The caller suppresses the partial flush in this case.
2773 assert_eq!(cr(b"frame1\r"), b"frame1\r");
2774 }
2775
2776 #[test]
2777 fn collapse_cr_crlf_within_buffer_becomes_lf() {
2778 assert_eq!(cr(b"line\r\n"), b"line\n");
2779 }
2780
2781 #[test]
2782 fn collapse_cr_crlf_straddle_resolved_on_second_call() {
2783 // Simulate two reads: first leaves trailing \r, second sees \n next.
2784 // collapse_cr is called after each extend.
2785 let mut pending = b"line1\r".to_vec();
2786 collapse_cr(&mut pending);
2787 assert_eq!(pending, b"line1\r"); // trailing \r preserved
2788 pending.extend_from_slice(b"\nmore");
2789 collapse_cr(&mut pending);
2790 assert_eq!(pending, b"line1\nmore"); // CRLF resolved, "more" accumulated
2791 }
2792
2793 #[test]
2794 fn collapse_cr_trailing_spinner_frames_collapse() {
2795 // Trailing-\r convention: "frame\r" per write, accumulated before \n.
2796 let mut pending = b"frame1\r".to_vec();
2797 collapse_cr(&mut pending);
2798 // trailing \r kept, frame1 still present
2799 assert_eq!(pending, b"frame1\r");
2800 pending.extend_from_slice(b"frame2\r");
2801 collapse_cr(&mut pending);
2802 // \r mid-buffer (pos 6, followed by 'f'): frame1 collapsed, trailing \r kept
2803 assert_eq!(pending, b"frame2\r");
2804 pending.extend_from_slice(b"done\n");
2805 collapse_cr(&mut pending);
2806 // \r mid-buffer (followed by 'd'): frame2 collapsed, done\n remains
2807 assert_eq!(pending, b"done\n");
2808 }
2809
2810 #[test]
2811 fn collapse_cr_leading_spinner_frames_collapse() {
2812 // Leading-\r convention: "\rframe" per write (npm/ora/gh style).
2813 // Case 1: non-\r line overwritten by \r-prefixed content (normal path).
2814 let mut pending = b"frame1".to_vec();
2815 collapse_cr(&mut pending);
2816 assert_eq!(pending, b"frame1"); // no \r, unchanged
2817 pending.extend_from_slice(b"\rframe2");
2818 collapse_cr(&mut pending);
2819 // "frame1\rframe2": \r at pos 6 overwrites non-\r line → "frame2"
2820 assert_eq!(pending, b"frame2");
2821 pending.extend_from_slice(b"\rdone\n");
2822 collapse_cr(&mut pending);
2823 // "frame2\rdone\n": \r at pos 6 overwrites non-\r line, done\n remains
2824 assert_eq!(pending, b"done\n");
2825 }
2826
2827 #[test]
2828 fn collapse_cr_leading_cr_multi_frame_preserves_leading_cr() {
2829 // Case 2: \r-prefixed line overwritten by another \r-prefixed line.
2830 // "\rframe1\rframe2" must collapse to "\rframe2", NOT "frame2".
2831 // Without the leading-\r preservation, the mid-buffer \r at pos 7
2832 // would truncate to line_start=0, discarding the leading \r, and
2833 // pending_cr_override can no longer detect the result as a spinner.
2834 assert_eq!(cr(b"\rframe1\rframe2"), b"\rframe2");
2835 // Three frames: only last survives, leading \r preserved.
2836 assert_eq!(cr(b"\rframe1\rframe2\rframe3"), b"\rframe3");
2837 // Normal (non-leading-\r) line overwritten: no leading \r in result.
2838 assert_eq!(cr(b"frame1\rframe2"), b"frame2");
2839 }
2840
2841 #[test]
2842 fn collapse_cr_preserves_prior_lines() {
2843 // \r only rewinds to the start of the current visual line, not past \n.
2844 assert_eq!(cr(b"line1\npartial\rreplace\n"), b"line1\nreplace\n");
2845 }
2846
2847 // ── normalize_csi_overwrites tests (spec §A2) ──────────────────────────
2848
2849 fn csi(input: &[u8]) -> Vec<u8> {
2850 let mut v = input.to_vec();
2851 let mut still_on_first_line = true;
2852 let mut at_col0 = true;
2853 normalize_csi_overwrites(&mut v, &mut still_on_first_line, &mut at_col0);
2854 v
2855 }
2856
2857 #[test]
2858 fn normalize_csi_cha_col1_becomes_cr() {
2859 assert_eq!(csi(b"progress\x1b[1Gdone"), b"progress\rdone");
2860 // Bare \x1b[G (no param) means column 1 too, per ECMA-48 default.
2861 assert_eq!(csi(b"progress\x1b[Gdone"), b"progress\rdone");
2862 }
2863
2864 #[test]
2865 fn normalize_csi_cha_other_column_left_untouched() {
2866 // Only column 1 maps cleanly onto "\r" (start of line); other
2867 // columns are intentionally out of scope (need true column
2868 // tracking) and must survive unmangled for strip_ansi to remove.
2869 let out = csi(b"progress\x1b[10Gdone");
2870 assert!(!out.contains(&b'\r'), "unrelated CHA column must not synthesize a \\r");
2871 assert_eq!(out, b"progress\x1b[10Gdone");
2872 }
2873
2874 #[test]
2875 fn normalize_csi_el_at_line_start_becomes_cr() {
2876 // EL at the very start of a chunk (nothing written on this line
2877 // yet) is safe: there's nothing to lose by treating it as \r.
2878 assert_eq!(csi(b"\x1b[2Kfresh"), b"\rfresh");
2879 assert_eq!(csi(b"\x1b[Kfresh"), b"\rfresh");
2880 assert_eq!(csi(b"\x1b[0Kfresh"), b"\rfresh");
2881 // Same, after a real newline -- also column 0.
2882 assert_eq!(csi(b"line1\n\x1b[2Kfresh"), b"line1\n\rfresh");
2883 }
2884
2885 #[test]
2886 fn normalize_csi_standalone_mid_line_el_is_left_untouched_not_synthesized_to_cr() {
2887 // reagent P1, PR #2330: EL only erases from the cursor onward and
2888 // never moves it -- it is NOT equivalent to \r when real content
2889 // ("stale") already precedes it on the line. The previous,
2890 // unconditional version turned this into "stale\rfresh", which
2891 // collapse_cr then truncated to just "fresh", silently discarding
2892 // "stale" even though EL never asked for that. Leaving it
2893 // unconverted here means strip_ansi removes only the escape bytes
2894 // afterward, preserving "stale" + "fresh" concatenated.
2895 let out = csi(b"stale\x1b[2Kfresh");
2896 assert!(!out.contains(&b'\r'), "mid-line EL must not synthesize a \\r: {out:?}");
2897 assert_eq!(out, b"stale\x1b[2Kfresh");
2898
2899 let out_k = csi(b"stale\x1b[Kfresh");
2900 assert!(!out_k.contains(&b'\r'));
2901 assert_eq!(out_k, b"stale\x1b[Kfresh");
2902 }
2903
2904 #[test]
2905 fn normalize_csi_at_col0_persists_across_calls_mid_line_el_split_across_reads_stays_untouched() {
2906 // reagent P1, PR #2330 (round 2): normalize_csi_overwrites is called
2907 // once per PTY read in the real reader loop, with at_col0 threaded
2908 // in by &mut reference across calls -- NOT reset per call. A prior
2909 // version of this fix declared at_col0 as a local reset to `true`
2910 // on every call, so a standalone EL split across two PTY reads
2911 // ("prefix" in one read, "\x1b[2Ksuffix" in the next) would see a
2912 // freshly-true at_col0 on the second call and wrongly convert the
2913 // EL to \r, reproducing the exact mid-line truncation bug the first
2914 // round of this fix addressed -- just moved from a mid-chunk split
2915 // to a cross-chunk-read split.
2916 let mut still_on_first_line = true;
2917 let mut at_col0 = true;
2918
2919 let mut first_read = b"prefix".to_vec();
2920 normalize_csi_overwrites(&mut first_read, &mut still_on_first_line, &mut at_col0);
2921 assert_eq!(first_read, b"prefix");
2922 assert!(!at_col0, "cursor is mid-line after a read with no trailing \\n/\\r");
2923
2924 let mut second_read = b"\x1b[2Ksuffix".to_vec();
2925 normalize_csi_overwrites(&mut second_read, &mut still_on_first_line, &mut at_col0);
2926 assert!(
2927 !second_read.contains(&b'\r'),
2928 "EL split across a PTY-read boundary, still mid-line, must not synthesize a \\r: {second_read:?}"
2929 );
2930 assert_eq!(second_read, b"\x1b[2Ksuffix");
2931
2932 // Simulate the reader loop's actual buffering: both reads accumulate
2933 // into one pending buffer before collapse_cr/strip_ansi run on it.
2934 let mut pending = first_read;
2935 pending.extend_from_slice(&second_read);
2936 assert_eq!(pending, b"prefix\x1b[2Ksuffix");
2937 }
2938
2939 #[test]
2940 fn normalize_csi_at_col0_persists_across_calls_leading_cr_read_then_bare_el_read_becomes_cr() {
2941 // Complement of the above: a read ending in \r (cursor genuinely at
2942 // column 0) followed by a read that STARTS with EL must still
2943 // convert -- persisted state must correctly stay `true` too, not
2944 // just correctly go false.
2945 let mut still_on_first_line = true;
2946 let mut at_col0 = true;
2947
2948 let mut first_read = b"frame1\r".to_vec();
2949 normalize_csi_overwrites(&mut first_read, &mut still_on_first_line, &mut at_col0);
2950 assert!(at_col0, "trailing \\r puts the cursor back at column 0");
2951
2952 let mut second_read = b"\x1b[2Kframe2".to_vec();
2953 normalize_csi_overwrites(&mut second_read, &mut still_on_first_line, &mut at_col0);
2954 assert_eq!(second_read, b"\rframe2");
2955 }
2956
2957 #[test]
2958 fn normalize_csi_el_immediately_after_real_cr_still_becomes_cr() {
2959 // The dominant real-world idiom (\r\x1b[2K<text>): the \r already
2960 // puts the cursor at column 0, so a following EL is genuinely
2961 // redundant and safe to also represent as \r.
2962 assert_eq!(csi(b"\r\x1b[2Kfresh"), b"\r\rfresh");
2963 }
2964
2965 #[test]
2966 fn normalize_csi_cup_home_becomes_cr_on_first_line() {
2967 // Windows ConPTY re-serializes a literal \r as CUP-to-home rather
2968 // than passing it through -- verified empirically via the e2e test
2969 // below. All the omitted-field variants mean row 1, col 1.
2970 assert_eq!(csi(b"progress\x1b[Hdone"), b"progress\rdone");
2971 assert_eq!(csi(b"progress\x1b[1Hdone"), b"progress\rdone");
2972 assert_eq!(csi(b"progress\x1b[1;1Hdone"), b"progress\rdone");
2973 assert_eq!(csi(b"progress\x1b[;1Hdone"), b"progress\rdone");
2974 assert_eq!(csi(b"progress\x1b[1;Hdone"), b"progress\rdone");
2975 }
2976
2977 #[test]
2978 fn normalize_csi_cup_other_position_left_untouched() {
2979 // Row/col other than 1,1 isn't "start of line" and must survive
2980 // unmangled.
2981 let out = csi(b"progress\x1b[2;1Hdone");
2982 assert!(!out.contains(&b'\r'));
2983 assert_eq!(out, b"progress\x1b[2;1Hdone");
2984 }
2985
2986 #[test]
2987 fn normalize_csi_cup_home_ignored_once_output_has_scrolled_past_first_line() {
2988 // Once real multi-line output exists, \x1b[H means "go back to the
2989 // FIRST of several lines" (a multi-line redraw, spec §A3,
2990 // deliberately out of scope) -- must NOT be treated as "restart
2991 // the current line", which would corrupt the multi-line content.
2992 let mut v = b"line1\nline2\n\x1b[Hline1-updated".to_vec();
2993 let mut still_on_first_line = true;
2994 let mut at_col0 = true;
2995 normalize_csi_overwrites(&mut v, &mut still_on_first_line, &mut at_col0);
2996 assert!(!still_on_first_line, "must flip false after the first \\n");
2997 assert!(
2998 !v.contains(&b'\r'),
2999 "CUP-home after multi-line output must not synthesize a \\r: {:?}",
3000 String::from_utf8_lossy(&v)
3001 );
3002 assert_eq!(v, b"line1\nline2\n\x1b[Hline1-updated");
3003 }
3004
3005 #[test]
3006 fn normalize_csi_cub_left_untouched() {
3007 // CUB (cursor-back N) is documented out-of-scope for this pass
3008 // (spec §A2) -- needs true per-column tracking. Must survive
3009 // unmangled here so strip_ansi still strips it as a whole sequence
3010 // afterward (not left as garbage).
3011 let out = csi(b"progress\x1b[5Ddone");
3012 assert!(!out.contains(&b'\r'));
3013 assert_eq!(out, b"progress\x1b[5Ddone");
3014 }
3015
3016 #[test]
3017 fn normalize_csi_dominant_cr_plus_el_idiom_is_redundant_but_correct() {
3018 // The overwhelmingly common real-world idiom: \r\x1b[2K<text>. The
3019 // \r is already present; normalize_csi_overwrites additionally
3020 // turning \x1b[2K into a second \r is harmless -- collapse_cr
3021 // treats consecutive \r as idempotent resets to the same line
3022 // start.
3023 let normalized = csi(b"\r\x1b[2Knew frame");
3024 let mut pending = normalized;
3025 collapse_cr(&mut pending);
3026 assert_eq!(pending, b"\rnew frame");
3027 }
3028
3029 #[test]
3030 fn normalize_csi_composes_with_collapse_cr_for_a_full_overwrite() {
3031 // End-to-end of the two functions together, as the reader loop
3032 // actually calls them: a progress line first written plainly, then
3033 // rewritten via CHA(1) on the next PTY read, must collapse exactly
3034 // like a \r-based rewrite would.
3035 let mut pending = b"Downloading 10%".to_vec();
3036 collapse_cr(&mut pending); // no-op, no \r yet
3037
3038 let mut next_chunk = b"\x1b[1GDownloading 45%".to_vec();
3039 let mut still_on_first_line = true;
3040 let mut at_col0 = true;
3041 normalize_csi_overwrites(&mut next_chunk, &mut still_on_first_line, &mut at_col0);
3042 pending.extend_from_slice(&next_chunk);
3043 collapse_cr(&mut pending);
3044
3045 assert_eq!(pending, b"Downloading 45%");
3046 }
3047
3048 /// Verify that the quiet-window semantics are: hold when pending ends
3049 /// with \r; flush when it doesn't. These are unit tests of the policy
3050 /// (not of the reader loop directly) — they encode the contract so a
3051 /// future refactor can't accidentally revert to pop-and-flush.
3052 #[test]
3053 fn quiet_window_holds_when_trailing_cr() {
3054 // A spinner frame "⠋ Loading...\r" should NOT be flushed by the
3055 // quiet-window — the \r signals that the next frame will overwrite.
3056 let pending = b"Loading...\r".to_vec();
3057 assert!(
3058 pending.last() == Some(&b'\r'),
3059 "quiet-window must hold pending ending with \\r"
3060 );
3061 // Simulate what collapse_cr does when the next frame arrives.
3062 let mut combined = pending.clone();
3063 combined.extend_from_slice(b"Done!\n");
3064 collapse_cr(&mut combined);
3065 assert_eq!(combined, b"Done!\n");
3066 }
3067
3068 #[test]
3069 fn quiet_window_defers_once_when_no_leading_or_trailing_cr() {
3070 // "Building..." (no \r at either end) is a candidate for A1's
3071 // one-tick speculative defer, not an immediate flush — see
3072 // docs/specs/SPEC_TOOL_LOG_UNIVERSAL_ANIMATION_COLLAPSE_2026_07_27.md §A1.
3073 let pending = b"Building...".to_vec();
3074 assert!(
3075 pending.first() != Some(&b'\r') && pending.last() != Some(&b'\r'),
3076 "quiet-window must defer (not flush) pending with no leading/trailing \\r"
3077 );
3078 }
3079
3080 /// A1: the exact real-world bug this closes — a static label is
3081 /// printed once with no `\r`, THEN (after the label has already been
3082 /// speculatively deferred) a `\r`-prefixed overwrite arrives. Before
3083 /// A1, the label would already have been flushed as its own permanent
3084 /// LineEvent by the time the overwrite showed up, producing two lines
3085 /// instead of one settled line.
3086 #[test]
3087 fn a1_deferred_line_merges_with_a_following_cr_prefixed_overwrite() {
3088 // What deferred_pending holds after tick 1 (no \r either end).
3089 let deferred = b"Installing deps...".to_vec();
3090 // What arrives on the next read: a \r-prefixed overwrite.
3091 let next_chunk = b"\rInstalling deps... done\n".to_vec();
3092 // Mirrors the reader's merge step: prepend deferred, let
3093 // collapse_cr do the rest.
3094 let mut combined = deferred;
3095 combined.extend_from_slice(&next_chunk);
3096 collapse_cr(&mut combined);
3097 assert_eq!(
3098 combined, b"Installing deps... done\n",
3099 "deferred label + \\r-overwrite must collapse to ONE line, not two"
3100 );
3101 }
3102
3103 /// A1: the deferred line must NOT be merged with unrelated content
3104 /// that arrives without a leading `\r` — that's two genuinely
3105 /// different lines and must stay two LineEvents.
3106 #[test]
3107 fn a1_deferred_line_is_not_merged_with_unrelated_non_cr_content() {
3108 let deferred = b"Installing deps...".to_vec();
3109 let next_chunk = b"Running tests...\n".to_vec();
3110 // Mirrors the reader's policy: no leading \r on the new chunk means
3111 // flush `deferred` as its own event, then start fresh with the new
3112 // chunk (never concatenate the two).
3113 assert_ne!(next_chunk.first(), Some(&b'\r'));
3114 let mut fresh = Vec::new();
3115 fresh.extend_from_slice(&next_chunk);
3116 collapse_cr(&mut fresh);
3117 assert_eq!(fresh, b"Running tests...\n");
3118 // The deferred line, flushed separately, is untouched by the above.
3119 assert_eq!(deferred, b"Installing deps...");
3120 }
3121
3122 #[test]
3123 fn eof_strips_trailing_cr() {
3124 // At EOF, a dangling \r (stream ended mid-spinner or mid-CRLF) is
3125 // stripped before the final flush so the rendered chunk is clean.
3126 let mut pending = b"partial\r".to_vec();
3127 if pending.last() == Some(&b'\r') {
3128 pending.pop();
3129 }
3130 assert_eq!(pending, b"partial");
3131 }
3132
3133 #[test]
3134 fn locate_bash_via_explicit_env() {
3135 // Use the binary that runs the test as a stand-in for "bash"
3136 // — we only assert that the override is honored, not that the
3137 // path is a real bash. ENV_LOCK serializes against any future
3138 // env-touching test (and against `BASH` reads in the same fn).
3139 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3140 let exe = std::env::current_exe().expect("current_exe");
3141 let prev_agentmux_bash = std::env::var("AGENTMUX_BASH").ok();
3142 let prev_bash = std::env::var("BASH").ok();
3143 // Clear BASH so the override path is the one being asserted.
3144 unsafe {
3145 std::env::set_var("AGENTMUX_BASH", &exe);
3146 std::env::remove_var("BASH");
3147 }
3148 let found = locate_bash().expect("locate_bash with override");
3149 assert_eq!(found, exe);
3150 unsafe {
3151 match prev_agentmux_bash {
3152 Some(v) => std::env::set_var("AGENTMUX_BASH", v),
3153 None => std::env::remove_var("AGENTMUX_BASH"),
3154 }
3155 if let Some(v) = prev_bash {
3156 std::env::set_var("BASH", v);
3157 }
3158 }
3159 }
3160
3161 // ── idle_kill_timeout tests ─────────────────────────────────────────────
3162 // RETRO_BASHWRAP_PAGER_HANG_LEAK_2026_07_14.md: these cover the env-var
3163 // override for the idle-kill timeout. The kill/PTY behavior itself isn't
3164 // unit-testable without a real PTY (see the retro's verification section
3165 // for the manual repro this was checked against instead).
3166
3167 #[test]
3168 fn idle_kill_timeout_defaults_when_unset() {
3169 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3170 let prev = std::env::var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS").ok();
3171 unsafe {
3172 std::env::remove_var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS");
3173 }
3174 assert_eq!(idle_kill_timeout(), DEFAULT_IDLE_KILL_TIMEOUT);
3175 unsafe {
3176 if let Some(v) = prev {
3177 std::env::set_var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS", v);
3178 }
3179 }
3180 }
3181
3182 #[test]
3183 fn idle_kill_timeout_honors_env_override() {
3184 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3185 let prev = std::env::var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS").ok();
3186 unsafe {
3187 std::env::set_var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS", "5");
3188 }
3189 assert_eq!(idle_kill_timeout(), Duration::from_secs(5));
3190 unsafe {
3191 match prev {
3192 Some(v) => std::env::set_var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS", v),
3193 None => std::env::remove_var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS"),
3194 }
3195 }
3196 }
3197
3198 #[test]
3199 fn idle_kill_timeout_falls_back_on_unparseable_value() {
3200 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3201 let prev = std::env::var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS").ok();
3202 unsafe {
3203 std::env::set_var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS", "not-a-number");
3204 }
3205 assert_eq!(idle_kill_timeout(), DEFAULT_IDLE_KILL_TIMEOUT);
3206 unsafe {
3207 match prev {
3208 Some(v) => std::env::set_var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS", v),
3209 None => std::env::remove_var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS"),
3210 }
3211 }
3212 }
3213
3214 /// End-to-end proof that the idle-kill mechanism actually fires AND
3215 /// reaches the whole process tree, not just the direct PTY child.
3216 ///
3217 /// Uses two backgrounded `sleep` grandchildren (`bash -c '{ sleep
3218 /// <marker>1 & sleep <marker>2 & wait; } </dev/null'`) rather than a
3219 /// single-process command: killing only the direct `bash` child (what
3220 /// `ChildKiller::kill()` alone does — see `kill_process_tree`'s doc
3221 /// comment) would leave these two running as orphans, exactly
3222 /// reproducing the leak one process removed instead of fixing it
3223 /// (reagent P1, PR #2156). The marker values are unique per test run
3224 /// (derived from the current PID) so a leftover orphan from a
3225 /// *previous* failed run of this same test, or an unrelated `sleep`
3226 /// elsewhere on a shared dev machine, can't produce a false pass.
3227 ///
3228 /// The `sleep <marker>` argument doubles as the zero-PTY-output
3229 /// condition this exists to catch (a clean, portable stand-in for "any
3230 /// command silently blocked forever," the same shape as the pager hang
3231 /// — see docs/retro/RETRO_BASHWRAP_PAGER_HANG_LEAK_2026_07_14.md).
3232 /// Without the fix, this test would hang until the outer
3233 /// `tokio::time::timeout` fires and fails it.
3234 #[tokio::test]
3235 async fn run_via_pty_kills_idle_child_and_returns_promptly() {
3236 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3237 let prev = std::env::var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS").ok();
3238 unsafe {
3239 std::env::set_var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS", "1");
3240 }
3241
3242 let bash = locate_bash().expect("locate_bash for test — same dependency the whole binary needs");
3243 let pty_system = native_pty_system();
3244 let pair = match pty_system.openpty(PtySize {
3245 rows: 24,
3246 cols: 80,
3247 pixel_width: 0,
3248 pixel_height: 0,
3249 }) {
3250 Ok(p) => p,
3251 Err(e) => {
3252 unsafe {
3253 match prev {
3254 Some(v) => std::env::set_var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS", v),
3255 None => std::env::remove_var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS"),
3256 }
3257 }
3258 eprintln!("skipping: PTY unavailable in this environment: {e}");
3259 return;
3260 }
3261 };
3262
3263 let args = Args {
3264 tool_id: "test-idle-kill".to_string(),
3265 b64_cmd: String::new(),
3266 block_id: None,
3267 declared_background: false,
3268 };
3269 // `Mutex` in this module scope resolves to `std::sync::Mutex`
3270 // (shadowed for `ENV_LOCK` above) — `run_via_pty` needs the async
3271 // `tokio::sync::Mutex` its `buffered: Arc<Mutex<Vec<u8>>>` param
3272 // expects, so spell it out.
3273 let buffered = Arc::new(tokio::sync::Mutex::new(Vec::<u8>::new()));
3274
3275 // Unique-per-run, but still a valid numeric duration for `sleep`
3276 // (GNU coreutils sleep accepts fractional seconds and arbitrarily
3277 // large values). A non-numeric marker string would make `sleep`
3278 // error out instantly instead of blocking — caught by this test's
3279 // own first run failing with exit code 0 (~0.1s elapsed) instead
3280 // of the expected kill.
3281 //
3282 // The `100` tag (vs. `bashwrap_binary_idle_kill_cleans_up_full_process_tree`'s
3283 // `300`) is NOT cosmetic: `std::process::id()` is the SAME across
3284 // every test in this binary (they're threads in one process, not
3285 // separate processes), and Rust's test harness runs tests in
3286 // parallel by default — so a bare `pid.to_string()` marker would
3287 // let this test's sleep processes get miscounted as "survivors" by
3288 // that other test's WMI substring search (and vice versa) if both
3289 // happen to overlap in time. Found this the hard way: the other
3290 // test failed with "3 survivors" once this test's marker collided
3291 // with its own. The tag makes the two tests' marker strings
3292 // non-overlapping substrings of each other.
3293 let pid = std::process::id();
3294 let marker = format!("{pid}100");
3295 let command = format!("sleep {marker}.001 & sleep {marker}.002 & wait");
3296
3297 let start = std::time::Instant::now();
3298 let result = tokio::time::timeout(
3299 Duration::from_secs(20),
3300 run_via_pty(&args, &command, None, buffered.clone(), &bash, pair),
3301 )
3302 .await
3303 .expect("run_via_pty must not hang past the outer test timeout — idle-kill should have fired well before this")
3304 .expect("run_via_pty should return Ok even when it had to kill the child");
3305
3306 // The exact code is platform/kill-mechanism-specific (portable-pty's
3307 // Windows ChildKiller calls TerminateProcess with code 127, but the
3308 // wrapping `bash -c` process's own reported code after being killed
3309 // is what we actually observe here, not necessarily 127 — verified
3310 // empirically as 1 on this Windows dev machine). `124` is only the
3311 // fallback sentinel used if the wait task doesn't resolve within
3312 // the post-kill grace period at all. What actually matters: it's
3313 // never the clean-success `0`, and (checked below) it returns
3314 // promptly instead of hanging.
3315 assert_ne!(
3316 result, 0,
3317 "an idle-killed child must not report a clean success exit code"
3318 );
3319 assert!(
3320 start.elapsed() < Duration::from_secs(15),
3321 "should return well within the 1s idle timeout + grace periods, not the 20s outer bound — got {:?}",
3322 start.elapsed()
3323 );
3324
3325 let blob = buffered.lock().await;
3326 assert!(
3327 String::from_utf8_lossy(&blob).contains("terminated automatically"),
3328 "model-visible blob should explain why the command was cut short, got: {:?}",
3329 String::from_utf8_lossy(&blob)
3330 );
3331 drop(blob);
3332
3333 // NOTE on what this test does NOT prove: calling `run_via_pty`
3334 // in-process (as a library function inside `cargo test`'s own
3335 // process) returning does not, by itself, guarantee every
3336 // descendant is gone yet — see
3337 // `bashwrap_binary_idle_kill_cleans_up_full_process_tree` below for
3338 // why, and for the test that actually proves full-tree cleanup.
3339 let _ = ▮ // used by the binary-level test below, not here
3340
3341 unsafe {
3342 match prev {
3343 Some(v) => std::env::set_var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS", v),
3344 None => std::env::remove_var("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS"),
3345 }
3346 }
3347 }
3348
3349 /// Reagent P1 follow-up, PR #2156: `idle_rx` resolves (with `Err`) not
3350 /// only when `pty_reader_loop` really signals an idle timeout, but also
3351 /// whenever it returns normally (EOF on a fast, successful command) and
3352 /// drops the still-unused `idle_tx`. The original `tokio::select! { _ =
3353 /// idle_rx => {...} }` treated BOTH cases identically, so a fast
3354 /// command completing could race `wait_task` and get spuriously
3355 /// killed + tagged "terminated automatically" even though it ran to
3356 /// completion normally. Fixed by branching on `idle_signal.is_err()`
3357 /// inside that arm instead of ignoring the value.
3358 ///
3359 /// A single run of a fast command isn't a reliable regression test for
3360 /// a race in general, so this runs `echo` 30 times with a very short
3361 /// idle timeout to widen the chance of catching it.
3362 ///
3363 /// Honesty check performed while writing this test: temporarily forced
3364 /// the OLD (buggy) unconditional-kill behavior back in and re-ran this
3365 /// exact test — it still passed 30/30. On this machine, `wait_task`
3366 /// (`child.wait()` resolving) appears to reliably win the race against
3367 /// `pty_reader_loop`'s EOF-then-drop for a trivially fast command like
3368 /// `echo hello`, so this test does NOT reliably fail on the unfixed
3369 /// code and can't be trusted as sole proof the bug is gone. muxreview's
3370 /// code-level analysis of the race is still correct — a oneshot
3371 /// receiver genuinely does resolve identically for "sent" and
3372 /// "dropped without sending," and the fix (branching on
3373 /// `idle_signal.is_err()`) is the structurally correct response
3374 /// regardless of whether this specific test can force the window open.
3375 /// The scenario muxreview named as the realistic trigger — blocking
3376 /// thread-pool contention from concurrent bashwrap invocations —
3377 /// wasn't reproduced here; doing so reliably would need deliberately
3378 /// saturating tokio's blocking pool, not attempted given time spent on
3379 /// this investigation already. This test is kept as basic coverage of
3380 /// the fast-success path (asserts real output, exit 0, no spurious
3381 /// diagnostic) — a real regression guard for *some* bugs in this area,
3382 /// just not proven to be one for this exact race.
3383 #[tokio::test]
3384 async fn run_via_pty_does_not_misclassify_fast_success_as_idle_timeout() {
3385 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3386 // reagentx P2 on PR #2569 (re-review): a manual save-now/restore-
3387 // at-the-end dance doesn't run on an early exit — the `panic!`s in
3388 // the loop below (via `unwrap_or_else`) and the `assert_eq!`/
3389 // `assert!` calls can all unwind straight past the restore code
3390 // that used to sit at the bottom of this function, leaking this env
3391 // var's override to every subsequent test in the process. Same bug
3392 // class already fixed via `EnvVarGuard` (defined above, used
3393 // elsewhere in this file for exactly this reason) for the
3394 // neighboring `a1_e2e` test — applied here too.
3395 //
3396 // 1s was tight enough that a loaded CI runner's PTY-reader-thread
3397 // scheduling delay alone could exceed it before `echo hello` ever
3398 // produced output — a genuine idle-kill by the test's own
3399 // (deliberately short) timeout, not the idle_rx/wait_task
3400 // misclassification race this test exists to catch. 3s keeps the
3401 // race window this test targets while giving scheduling enough
3402 // slack not to trip the timeout on its own.
3403 let _idle_timeout_guard = EnvVarGuard::set("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS", "3");
3404
3405 let bash = locate_bash().expect("locate_bash for test — same dependency the whole binary needs");
3406
3407 for i in 0..30 {
3408 let pty_system = native_pty_system();
3409 let pair = match pty_system.openpty(PtySize {
3410 rows: 24,
3411 cols: 80,
3412 pixel_width: 0,
3413 pixel_height: 0,
3414 }) {
3415 Ok(p) => p,
3416 Err(e) => {
3417 eprintln!("skipping: PTY unavailable in this environment: {e}");
3418 break;
3419 }
3420 };
3421 let args = Args {
3422 tool_id: format!("test-fast-success-{i}"),
3423 b64_cmd: String::new(),
3424 block_id: None,
3425 declared_background: false,
3426 };
3427 let buffered = Arc::new(tokio::sync::Mutex::new(Vec::<u8>::new()));
3428
3429 let result = tokio::time::timeout(
3430 Duration::from_secs(10),
3431 run_via_pty(&args, "echo hello", None, buffered.clone(), &bash, pair),
3432 )
3433 .await
3434 .unwrap_or_else(|_| panic!("iteration {i}: run_via_pty must not hang on a trivial fast command"))
3435 .unwrap_or_else(|e| panic!("iteration {i}: run_via_pty errored: {e}"));
3436
3437 assert_eq!(
3438 result, 0,
3439 "iteration {i}: a fast, genuinely successful `echo` must report clean exit 0, \
3440 not get spuriously classified as idle-killed by the idle_rx/wait_task race"
3441 );
3442 let blob = String::from_utf8_lossy(&buffered.lock().await).into_owned();
3443 assert!(
3444 !blob.contains("terminated automatically"),
3445 "iteration {i}: fast successful command must not carry the idle-kill \
3446 diagnostic — got blob: {blob:?}"
3447 );
3448 assert!(
3449 blob.contains("hello"),
3450 "iteration {i}: expected the command's real output in the blob, got: {blob:?}"
3451 );
3452 }
3453 }
3454
3455 /// Issue #2491: a declared-background call (`declared_background:
3456 /// true`, threaded from the CLI's own `run_in_background: true`) must
3457 /// be exempt from the idle-kill safety net — it may legitimately go
3458 /// silent past the timeout window (a GUI window, a dev server) without
3459 /// that meaning it's stuck. Uses a 1s idle timeout and a command silent
3460 /// for 3s (3x the timeout) so a non-exempt call would provably be
3461 /// killed well before this test's own bound — proves the exemption is
3462 /// doing something, not merely that idle-kill didn't happen to fire.
3463 #[tokio::test]
3464 async fn run_via_pty_skips_idle_kill_when_declared_background() {
3465 let _g = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3466 let _idle_timeout_guard = EnvVarGuard::set("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS", "1");
3467
3468 let bash = locate_bash().expect("locate_bash for test — same dependency the whole binary needs");
3469 let pty_system = native_pty_system();
3470 let pair = match pty_system.openpty(PtySize {
3471 rows: 24,
3472 cols: 80,
3473 pixel_width: 0,
3474 pixel_height: 0,
3475 }) {
3476 Ok(p) => p,
3477 Err(e) => {
3478 eprintln!("skipping: PTY unavailable in this environment: {e}");
3479 return;
3480 }
3481 };
3482 let args = Args {
3483 tool_id: "test-declared-background".to_string(),
3484 b64_cmd: String::new(),
3485 block_id: None,
3486 declared_background: true,
3487 };
3488 let buffered = Arc::new(tokio::sync::Mutex::new(Vec::<u8>::new()));
3489
3490 let result = tokio::time::timeout(
3491 Duration::from_secs(10),
3492 run_via_pty(&args, "sleep 3 && echo done", None, buffered.clone(), &bash, pair),
3493 )
3494 .await
3495 .expect("a declared-background call must not hang past the outer test timeout")
3496 .expect("run_via_pty should return Ok");
3497
3498 assert_eq!(
3499 result, 0,
3500 "a declared-background command silent past the idle-timeout window must still run \
3501 to completion, not get idle-killed — exit code was {result}"
3502 );
3503 let blob = String::from_utf8_lossy(&buffered.lock().await).into_owned();
3504 assert!(
3505 blob.contains("done"),
3506 "expected the command's real output after surviving the idle window, got: {blob:?}"
3507 );
3508 assert!(
3509 !blob.contains("terminated automatically"),
3510 "declared-background call must never carry the idle-kill diagnostic — got: {blob:?}"
3511 );
3512 }
3513
3514 /// End-to-end proof of A1 against a REAL PTY + bash, reproducing the
3515 /// exact bug SPEC_TOOL_LOG_UNIVERSAL_ANIMATION_COLLAPSE_2026_07_27.md
3516 /// closes: a static label printed with no `\r`, a pause past the
3517 /// quiet-window, then a `\r`-prefixed overwrite. Before A1, the label
3518 /// was already flushed as its own permanent LineEvent by the time the
3519 /// overwrite arrived, so the model-visible blob contained the label
3520 /// TWICE ("Installing deps..." followed by "Installing deps... done").
3521 ///
3522 /// The pause is deliberately tuned to land INSIDE A1's one-extra-tick
3523 /// window: greater than one `FLUSH_QUIET_WINDOW` (50ms, so the label is
3524 /// actually deferred rather than merged inline on the same read) but
3525 /// less than two (100ms, the point at which A1 gives up waiting and
3526 /// flushes the deferred line anyway per the spec's own "no added
3527 /// latency beyond one extra tick" scope). 70ms centers comfortably
3528 /// inside that ~50ms window in isolation, but real OS thread scheduling
3529 /// still occasionally pushes either boundary under load (observed: this
3530 /// test flaked ~1 run in 6 in the full-suite parallel run, though never
3531 /// in isolation) — `FLUSH_QUIET_WINDOW` is a hardcoded const with no
3532 /// test-only override, so retrying the wall-clock scenario a few times
3533 /// (same "widen the chance" philosophy already used by
3534 /// `run_via_pty_does_not_misclassify_fast_success_as_idle_timeout`
3535 /// above, just applied to reliably demonstrate a real behavior instead
3536 /// of reliably catching a rare regression) is more honest than either a
3537 /// single flaky assertion or silently loosening what's being proven.
3538 #[tokio::test]
3539 async fn a1_e2e_static_label_then_delayed_cr_overwrite_collapses_to_one_line() {
3540 let bash = locate_bash().expect("locate_bash for test — same dependency the whole binary needs");
3541 let command = "printf 'Installing deps...'; sleep 0.07; printf '\\rInstalling deps... done\\n'";
3542
3543 // reagentx P2 on PR #2569: a hung/slow attempt used to be retried by
3544 // wrapping `run_via_pty` in an EXTERNAL `tokio::time::timeout` and
3545 // `continue`ing past the deadline. That leaked a process: dropping
3546 // the outer future does NOT abort `wait_task`/`pty_reader_loop`
3547 // (independent `spawn_blocking` tasks already running by the time
3548 // the timeout could preempt anything), so the underlying bash/PTY
3549 // child from the timed-out attempt kept running detached — up to
3550 // MAX_ATTEMPTS - 1 of them per flaky run, none ever cleaned up.
3551 //
3552 // `run_via_pty` already has a CORRECT internal kill path for exactly
3553 // "this attempt is stuck": its idle-timeout branch tree-kills the
3554 // child (via `kill_process_tree` + `clone_killer`) and returns exit
3555 // code 124 BEFORE returning at all. Rather than fight that with a
3556 // second, incomplete external timeout, lower its threshold for this
3557 // test (well above what this ~70ms command legitimately needs, but
3558 // short enough to bound a genuinely stuck attempt) and let
3559 // `run_via_pty` clean up after itself before this test ever sees
3560 // control back — no external timeout, no orphan.
3561 let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
3562 // reagentx P2 on PR #2569 (re-review): a manual save-now/restore-
3563 // at-the-end dance doesn't run on an early exit — `Err(e) =>
3564 // panic!(...)` and the `assert_eq!` below can both unwind straight
3565 // out of the loop, skipping the restore and leaving this env var
3566 // permanently set to "15" for the rest of the test binary process.
3567 // `EnvVarGuard` (used elsewhere in this file for exactly this
3568 // reason) restores it in `Drop`, which runs on every exit path
3569 // including a panic unwind.
3570 let _idle_timeout_guard = EnvVarGuard::set("AGENTMUX_BASHWRAP_IDLE_TIMEOUT_SECS", "15");
3571
3572 const MAX_ATTEMPTS: u32 = 5;
3573 // reagentx P2 on PR #2559 (re-review): must be updated on EVERY
3574 // retry path, not just the occurrence-mismatch one — the idle-kill
3575 // branch below used to leave this at its initial empty value, so if
3576 // every attempt got idle-killed the final panic misleadingly
3577 // reported an empty blob instead of indicating a timeout occurred.
3578 let mut last_outcome = String::new();
3579 let mut collapsed = false;
3580 let mut skipped = false;
3581 for attempt in 1..=MAX_ATTEMPTS {
3582 let pty_system = native_pty_system();
3583 let pair = match pty_system.openpty(PtySize {
3584 rows: 24,
3585 cols: 80,
3586 pixel_width: 0,
3587 pixel_height: 0,
3588 }) {
3589 Ok(p) => p,
3590 Err(e) => {
3591 eprintln!("skipping: PTY unavailable in this environment: {e}");
3592 skipped = true;
3593 break;
3594 }
3595 };
3596 let args = Args {
3597 tool_id: format!("test-a1-e2e-{attempt}"),
3598 b64_cmd: String::new(),
3599 block_id: None,
3600 declared_background: false,
3601 };
3602 let buffered = Arc::new(tokio::sync::Mutex::new(Vec::<u8>::new()));
3603
3604 let result = match run_via_pty(&args, command, None, buffered.clone(), &bash, pair).await {
3605 Ok(r) => r,
3606 Err(e) => panic!("run_via_pty should return Ok, got: {e}"),
3607 };
3608 if result == 124 {
3609 // Idle-killed by run_via_pty's own internal mechanism — the
3610 // process tree is already cleaned up by the time this
3611 // returns, nothing left behind to leak.
3612 last_outcome = format!("attempt {attempt}/{MAX_ATTEMPTS}: idle-killed by run_via_pty \
3613 (likely a loaded CI runner delaying process spawn/scheduling), no output captured");
3614 eprintln!(
3615 "{last_outcome} — retrying, already cleaned up"
3616 );
3617 continue;
3618 }
3619 if result != 0 {
3620 // Retried for the same reason 124 is, immediately above: on a
3621 // loaded runner bash can fail to start cleanly, and from here
3622 // that is indistinguishable from being idle-killed.
3623 //
3624 // This was `assert_eq!(result, 0, "command should exit cleanly")`,
3625 // which failed the whole test on attempt 1 and bypassed
3626 // MAX_ATTEMPTS entirely. The retry loop existed but covered only
3627 // two of the three flaky outcomes, and the one it missed is the
3628 // one that fired — exit 1, failing a docs-only PR that touched
3629 // no Rust at all (issue #2916). A retry loop that does not cover
3630 // the case that actually happens is decoration.
3631 //
3632 // A genuinely broken build still fails: every one of
3633 // MAX_ATTEMPTS must exit nonzero, and the panic below reports
3634 // the last outcome, which a bare assert_eq! never did.
3635 last_outcome = format!(
3636 "attempt {attempt}/{MAX_ATTEMPTS}: command exited {result}, expected 0 \
3637 (likely a loaded CI runner failing to spawn bash cleanly)"
3638 );
3639 eprintln!("{last_outcome} — retrying");
3640 continue;
3641 }
3642
3643 let blob = String::from_utf8_lossy(&buffered.lock().await).into_owned();
3644 let occurrences = blob.matches("Installing deps").count();
3645 if occurrences == 1 && blob.contains("Installing deps... done") {
3646 collapsed = true; // demonstrated: the collapse happened as designed
3647 break;
3648 }
3649 last_outcome = format!(
3650 "attempt {attempt}/{MAX_ATTEMPTS}: expected 1 occurrence + the settled frame, \
3651 got {occurrences} occurrence(s) — blob: {blob:?}"
3652 );
3653 eprintln!(
3654 "{last_outcome} (retrying: real OS scheduling can occasionally push the 70ms \
3655 pause outside A1's ~50-100ms defer window)"
3656 );
3657 }
3658
3659 if skipped || collapsed {
3660 return;
3661 }
3662 panic!(
3663 "the static label + its delayed \\r-overwrite must collapse to ONE occurrence across \
3664 {MAX_ATTEMPTS} attempts — last outcome: {last_outcome}"
3665 );
3666 }
3667}