Skip to main content

agentmux_bashwrap/
prefix.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Prefix mode: `agentmux-bashwrap '<script>'` as Claude Code's
5//! `CLAUDE_CODE_SHELL_PREFIX`.
6//!
7//! The CLI runs every Bash tool command as `<prefix> '<its script>'` when the
8//! variable is set, and leaves the tool call itself alone: its permission
9//! rules, labels and checks see the model's own command. The `PreToolUse`
10//! hook records each call here (`register`) instead of rewriting it; the
11//! prefix process takes the model's command back out of the CLI's script,
12//! claims the matching record for the call's `tool_use_id`, and runs the
13//! whole script the way `exec` runs a command (bash_wrap.rs).
14//!
15//! docs/specs/SPEC_BASH_STREAMING_VIA_SHELL_PREFIX_2026_10_10.md §2.
16
17use std::ffi::OsString;
18use std::ops::Range;
19use std::path::{Path, PathBuf};
20use std::time::Duration;
21
22#[cfg(windows)]
23use agentmux_common::win32::NoWindow;
24use agentmux_common::time::now_ms_u64 as now_ms;
25use anyhow::{Context, Result};
26
27use serde::{Deserialize, Serialize};
28
29/// The CLI's environment variable naming the prefix.
30pub const SHELL_PREFIX_ENV: &str = "CLAUDE_CODE_SHELL_PREFIX";
31/// The CLI's session id, as the prefix process sees it. Equal to the
32/// `session_id` in the hook's payload.
33pub const SESSION_ID_ENV: &str = "CLAUDE_CODE_SESSION_ID";
34/// Overrides where records are kept (tests).
35const CALLS_DIR_OVERRIDE_ENV: &str = "AGENTMUX_BASHWRAP_CALLS_DIR";
36/// Records older than this are deleted on each claim: a call the CLI never
37/// ran (denied, interrupted) leaves its record behind.
38const RECORD_MAX_AGE: Duration = Duration::from_secs(3600);
39/// The subcommands, which a lone argument must not be mistaken for.
40const SUBCOMMANDS: &[&str] = &["exec", "hook", "precompact", "sessionstart", "help"];
41
42/// One Bash call, as the hook saw it.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct CallRecord {
45    pub tool_use_id: String,
46    pub command: String,
47    #[serde(default)]
48    pub run_in_background: bool,
49    pub created_ms: u64,
50    /// What the model said the call does: Tower labels the process that ran
51    /// it with this (`report_call`).
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub description: Option<String>,
54}
55
56/// The longest description reported for a call.
57const MAX_DESCRIPTION_CHARS: usize = 200;
58
59/// The script, when this process was started as the CLI's shell prefix:
60/// exactly one argument, which is neither a subcommand nor a flag.
61///
62/// The CLI puts the prefix in front of more than Bash calls: hook commands,
63/// and every stdio MCP server, whose command and arguments it joins into
64/// that one argument. So a lone word is a prefix call too: the `agentmux`
65/// MCP server arrives as just `agentmux-mcp`, and refusing it as an unknown
66/// subcommand left agents without their AgentMux tools
67/// (docs/reports/REPORT_AGENT_MCP_DOWN_THROUGH_SHELL_PREFIX_2026_10_11.md).
68pub fn prefix_script(args: &[OsString]) -> Option<String> {
69    if args.len() != 2 {
70        return None;
71    }
72    let script = args[1].to_str()?;
73    let looks_like_cli_arg = script.starts_with('-') || SUBCOMMANDS.contains(&script);
74    (!looks_like_cli_arg && !script.trim().is_empty()).then(|| script.to_string())
75}
76
77/// Whether this process runs under a CLI whose shell prefix is AgentMux's
78/// wrapper, so the hook records calls instead of rewriting them.
79pub fn prefix_active() -> bool {
80    std::env::var_os(SHELL_PREFIX_ENV)
81        .map(|v| is_bashwrap_path(Path::new(&v)))
82        .unwrap_or(false)
83}
84
85fn is_bashwrap_path(path: &Path) -> bool {
86    path.file_stem()
87        .and_then(|s| s.to_str())
88        .map(|s| s.eq_ignore_ascii_case("agentmux-bashwrap"))
89        .unwrap_or(false)
90}
91
92/// The model's command inside the CLI's script, and where its quoted form
93/// sits in the script: the shell word after the CLI's `eval `, which the CLI
94/// quotes with single quotes (an embedded `'` becomes `'"'"'`). `None` when
95/// the script doesn't have one, e.g. after a change to the CLI's format.
96pub fn extract_command(script: &str) -> Option<(String, Range<usize>)> {
97    // The CLI's own `eval` comes before the command, which is inside it.
98    let at = script.find("&& eval '")? + "&& eval ".len();
99    let (command, end) = read_shell_word(script, at)?;
100    Some((command, at..end))
101}
102
103/// Read one shell word starting at `start`: single-quoted, double-quoted and
104/// backslash-escaped parts, up to unquoted whitespace or the end. Returns the
105/// word's value and the byte offset after it.
106fn read_shell_word(s: &str, start: usize) -> Option<(String, usize)> {
107    let bytes = s.as_bytes();
108    let mut out = String::new();
109    let mut i = start;
110    while i < bytes.len() {
111        match bytes[i] {
112            b'\'' => {
113                let close = s[i + 1..].find('\'')? + i + 1;
114                out.push_str(&s[i + 1..close]);
115                i = close + 1;
116            }
117            b'"' => {
118                i += 1;
119                loop {
120                    let c = *bytes.get(i)?;
121                    if c == b'"' {
122                        i += 1;
123                        break;
124                    }
125                    if c == b'\\' && matches!(bytes.get(i + 1), Some(b'"' | b'\\' | b'$' | b'`')) {
126                        out.push(bytes[i + 1] as char);
127                        i += 2;
128                        continue;
129                    }
130                    let ch = s[i..].chars().next()?;
131                    out.push(ch);
132                    i += ch.len_utf8();
133                }
134            }
135            b'\\' => {
136                let ch = s[i + 1..].chars().next()?;
137                out.push(ch);
138                i += 1 + ch.len_utf8();
139            }
140            c if c.is_ascii_whitespace() => break,
141            _ => {
142                let ch = s[i..].chars().next()?;
143                out.push(ch);
144                i += ch.len_utf8();
145            }
146        }
147    }
148    (i > start).then_some((out, i))
149}
150
151/// `s` as one single-quoted shell word, the way the CLI quotes it.
152pub fn single_quote(s: &str) -> String {
153    format!("'{}'", s.replace('\'', "'\"'\"'"))
154}
155
156/// The script with the command at `range` replaced by `command`.
157pub fn replace_command(script: &str, range: Range<usize>, command: &str) -> String {
158    format!("{}{}{}", &script[..range.start], single_quote(command), &script[range.end..])
159}
160
161fn calls_root() -> Option<PathBuf> {
162    if let Some(over) = std::env::var_os(CALLS_DIR_OVERRIDE_ENV).filter(|v| !v.is_empty()) {
163        return Some(PathBuf::from(over));
164    }
165    Some(dirs::home_dir()?.join(".agentmux").join("state").join("bashwrap-calls"))
166}
167
168fn session_dir(session_id: &str) -> Option<PathBuf> {
169    Some(calls_root()?.join(sanitize(session_id)))
170}
171
172fn sanitize(raw: &str) -> String {
173    raw.chars()
174        .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
175        .collect()
176}
177
178/// Record a call for the prefix process to claim. Written to a temporary
179/// name and renamed, so a claim never reads half a record. Then other
180/// sessions' leftovers are swept (`sweep_other_sessions`).
181pub fn register(session_id: &str, record: &CallRecord) -> std::io::Result<()> {
182    let dir = session_dir(session_id)
183        .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no home directory"))?;
184    let name = format!("{}.json", sanitize(&record.tool_use_id));
185    let tmp = dir.join(format!(".{name}.{}", std::process::id()));
186    let bytes = serde_json::to_vec(record)?;
187    let write = || -> std::io::Result<()> {
188        std::fs::create_dir_all(&dir)?;
189        std::fs::write(&tmp, &bytes)?;
190        std::fs::rename(&tmp, dir.join(&name))
191    };
192    // A claim or a sweep removes a directory it finds empty, which can land
193    // between creating it and writing into it: then once more.
194    write().or_else(|e| if e.kind() == std::io::ErrorKind::NotFound { write() } else { Err(e) })?;
195    if let Some(root) = calls_root() {
196        sweep_other_sessions(&root, &dir);
197    }
198    Ok(())
199}
200
201/// Every other session's directory: its stale records deleted, and the
202/// directory itself removed once nothing is left in it. Sessions end
203/// without telling anyone, and a call the CLI never ran (denied) leaves its
204/// record; without this both would stay forever.
205fn sweep_other_sessions(root: &Path, keep: &Path) {
206    let Ok(entries) = std::fs::read_dir(root) else { return };
207    for entry in entries.flatten() {
208        let path = entry.path();
209        if path == keep || !path.is_dir() {
210            continue;
211        }
212        if read_records(&path).is_empty() {
213            // Fails (and is left) if a record or a temporary file is in it.
214            let _ = std::fs::remove_dir(&path);
215        }
216    }
217}
218
219/// Claim the record of the call this script runs: the newest one for the
220/// session whose command is `command`; failing that, the session's only
221/// record. Newest, because the hook records a call just before the CLI runs
222/// it, while a call the CLI never ran (a deny rule, a refused prompt) leaves
223/// an older record behind that a retry of the same command must not take. A claim renames the record away, so two
224/// prefix processes never claim the same call. `None` when there is nothing
225/// to claim: the script then runs unlinked. Only a Bash call's script, one
226/// with a command in its `eval`, ever claims (`run`).
227pub fn claim(session_id: &str, command: &str) -> Option<CallRecord> {
228    let dir = session_dir(session_id)?;
229    let mut records = read_records(&dir);
230    records.sort_by_key(|(_, r)| std::cmp::Reverse(r.created_ms));
231    let matching: Vec<&(PathBuf, CallRecord)> =
232        records.iter().filter(|(_, r)| r.command == command).collect();
233    let candidates: Vec<&(PathBuf, CallRecord)> = if !matching.is_empty() {
234        matching
235    } else if records.len() == 1 {
236        records.iter().collect()
237    } else {
238        Vec::new()
239    };
240    let mut claimed_record = None;
241    for (path, record) in candidates {
242        let claimed = path.with_extension(format!("claimed.{}", std::process::id()));
243        if std::fs::rename(path, &claimed).is_ok() {
244            let _ = std::fs::remove_file(&claimed);
245            claimed_record = Some(record.clone());
246            break;
247        }
248    }
249    // The session's last waiting call: its directory goes too (it is made
250    // again for the next one). Fails, and stays, while anything is in it.
251    let _ = std::fs::remove_dir(&dir);
252    claimed_record
253}
254
255/// The session's unclaimed records, deleting any past their age.
256fn read_records(dir: &Path) -> Vec<(PathBuf, CallRecord)> {
257    let Ok(entries) = std::fs::read_dir(dir) else {
258        return Vec::new();
259    };
260    let cutoff = now_ms().saturating_sub(RECORD_MAX_AGE.as_millis() as u64);
261    let mut out = Vec::new();
262    for entry in entries.flatten() {
263        let path = entry.path();
264        let is_record = path.extension().and_then(|e| e.to_str()) == Some("json")
265            && !path.file_name().and_then(|n| n.to_str()).unwrap_or("").starts_with('.');
266        if !is_record {
267            continue;
268        }
269        match std::fs::read(&path).ok().and_then(|b| serde_json::from_slice::<CallRecord>(&b).ok()) {
270            Some(record) if record.created_ms >= cutoff => out.push((path, record)),
271            _ => {
272                let _ = std::fs::remove_file(&path);
273            }
274        }
275    }
276    out
277}
278
279/// Run the CLI's whole `script` for the Bash call it belongs to, streaming it
280/// exactly as `exec` streams a command. The call is the record the hook left
281/// for this session's command. A script that can't be linked to a call (a
282/// `claude` run by hand or nested inside an agent, whose session has no
283/// records, or the CLI's own shell calls) runs as plain bash, as if there
284/// were no prefix: no live output, nothing else changed.
285/// docs/specs/SPEC_BASH_STREAMING_VIA_SHELL_PREFIX_2026_10_10.md §2.2, §2.3.
286pub async fn run(script: String) -> Result<i32> {
287    // Only a Bash call's script has the CLI's `eval`. Anything else is a hook
288    // or an MCP server, which runs as it is and never takes a Bash call's
289    // record: in the PTY, a hook's answer or a server's JSON-RPC would be
290    // garbled, and the call it took would lose its live output.
291    let Some((command, range)) = extract_command(&script) else {
292        tracing::info!(target: "bashwrap", "prefix: not a Bash call; running it as it is");
293        return run_passthrough(&script);
294    };
295    let session = std::env::var(SESSION_ID_ENV).ok().filter(|s| !s.is_empty());
296    let record = session.as_deref().and_then(|s| claim(s, &command));
297    let Some(record) = record else {
298        tracing::info!(target: "bashwrap", session = ?session, "prefix: no call to link; running the script as plain bash");
299        return run_plain(&script);
300    };
301    // The tee rewrite works on the model's command, so it is applied to the
302    // command inside the script's `eval` and the script rebuilt around it.
303    let script = match crate::hook::tee_redirect_rewrite(&command) {
304        Some(teed) => replace_command(&script, range, &teed),
305        None => script,
306    };
307    // Not awaited before the command starts: a slow srv must not hold it up.
308    let report = record.description.clone().map(|d| tokio::spawn(report_call(record.tool_use_id.clone(), d)));
309    let args = crate::bash_wrap::Args {
310        tool_id: record.tool_use_id,
311        b64_cmd: String::new(),
312        block_id: None,
313        declared_background: record.run_in_background,
314    };
315    let result = crate::bash_wrap::run_command(args, script).await;
316    if let Some(task) = report {
317        // Bounded past the publish's own timeout; main exits right after.
318        let _ = tokio::time::timeout(Duration::from_secs(6), task).await;
319    }
320    result
321}
322
323/// Tell srv which call this process runs, once per linked call, so srv can say which tool call
324/// a process tree is (Tower's "started by"): this wrapper's pid, the call and what it does. srv
325/// keeps it and does not forward it to the pane, which would take an unknown op for output.
326/// SPEC_TOWER_AGENT_CENTRIC_VIEWS_2026_10_08.md §5.3. Best effort.
327async fn report_call(tool_id: String, description: String) {
328    let Some(client) = crate::mps_client::WpsClient::from_env() else { return };
329    let block_id = std::env::var("AGENTMUX_BLOCKID").ok().filter(|b| !b.is_empty());
330    let description: String = description.chars().take(MAX_DESCRIPTION_CHARS).collect();
331    let msg = agentmux_common::client_events::ToolChunk::Call { tool_id: tool_id.clone(), pid: std::process::id(), description, timestamp: now_ms() };
332    if let Err(e) = client.publish_chunk(block_id.as_deref(), &msg).await {
333        tracing::warn!(target: "bashwrap", tool_id, error = %e, "call report failed");
334    }
335}
336
337/// Run a hook command or an MCP server's command line on this process's own
338/// stdio, untouched: an MCP server speaks JSON-RPC on it, so one added byte
339/// breaks it. A lone word is a program, started directly (no shell to
340/// mangle a Windows path, nothing to locate first); anything else is a
341/// command line, which the CLI joined unquoted, so bash runs it. Except on
342/// Windows when the program is a Windows path: bash would strip its
343/// backslashes, and only an MCP server's line can have one (the CLI makes a
344/// hook's paths POSIX), so it is split back into words, as the CLI joined it.
345fn run_passthrough(script: &str) -> Result<i32> {
346    let script = script.trim();
347    let mut words = script.split_whitespace();
348    let program = words.next().unwrap_or_default();
349    if !script.contains(char::is_whitespace) || (cfg!(windows) && program.contains('\\')) {
350        return run_program(program, &words.collect::<Vec<_>>());
351    }
352    run_plain(script)
353}
354
355/// Become the program, so the CLI's signals and pid reach it. Returns only
356/// when it can't be started.
357#[cfg(unix)]
358fn run_program(program: &str, args: &[&str]) -> Result<i32> {
359    use std::os::unix::process::CommandExt;
360    let err = std::process::Command::new(program).args(args).exec();
361    eprintln!("agentmux-bashwrap: cannot run {program}: {err}");
362    Ok(127)
363}
364
365/// Run the program and wait for it. The block's Job Object ends it with the
366/// agent, and it sees stdin close when the CLI closes its end. Found through
367/// `PATHEXT` the way the CLI would find it without a prefix, so a `.cmd`
368/// shim (what a global npm install puts on `PATH`) starts too: std looks
369/// only for `.exe`, but runs a `.cmd` given its full path.
370#[cfg(windows)]
371fn run_program(program: &str, args: &[&str]) -> Result<i32> {
372    let resolved = which::which(program).unwrap_or_else(|_| PathBuf::from(program));
373    let mut cmd = std::process::Command::new(resolved);
374    cmd.args(args);
375    // A console program, like bash.exe in `run_plain`.
376    cmd.no_window();
377    match cmd.status() {
378        Ok(status) => Ok(status.code().unwrap_or(1)),
379        Err(e) => {
380            eprintln!("agentmux-bashwrap: cannot run {program}: {e}");
381            Ok(127)
382        }
383    }
384}
385
386/// Run `script` with bash on this process's own stdio, for its exit code.
387fn run_plain(script: &str) -> Result<i32> {
388    let bash = crate::bash_wrap::locate_bash()?;
389    let mut cmd = std::process::Command::new(&bash);
390    cmd.arg("-c").arg(script);
391    // Windows only: bash.exe is a console program; without this it opens a
392    // console window (SPEC_ELIMINATE_BASHWRAP_CONSOLE_WINDOWS_2026_06_20).
393    #[cfg(windows)]
394    cmd.no_window();
395    let status = cmd.status().with_context(|| format!("running {}", bash.display()))?;
396    Ok(status.code().unwrap_or(1))
397}
398
399/// A record for a call the hook just saw.
400pub fn new_record(tool_use_id: &str, command: &str, run_in_background: bool) -> CallRecord {
401    CallRecord {
402        tool_use_id: tool_use_id.to_string(),
403        command: command.to_string(),
404        run_in_background,
405        created_ms: now_ms(),
406        description: None,
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    /// The script Claude Code 2.1.288 passed to the prefix, recorded for
415    /// `echo 'it''s' "q" && echo two > out.txt; cat out.txt`.
416    const CLI_SCRIPT: &str = r#"source /c/Users/u/AppData/Local/Temp/config/shell-snapshots/snapshot-bash-1.sh 2>/dev/null || true && export TEMP='C:\Users\u\AppData\Local\Temp' TMP='C:\Users\u\AppData\Local\Temp' && { shopt -u extglob || setopt NO_EXTENDED_GLOB NO_BARE_GLOB_QUAL; } >/dev/null 2>&1 || true && { \builtin unalias -- 'unsetenv'; \builtin unset -f -- 'unsetenv'; } >/dev/null 2>&1 || true && eval 'echo '"'"'it'"'"''"'"'s'"'"' "q" && echo two > out.txt; cat out.txt' < /dev/null && pwd -P >| /c/Users/u/AppData/Local/Temp/claude-941e-cwd"#;
417
418    fn os(args: &[&str]) -> Vec<OsString> {
419        args.iter().map(OsString::from).collect()
420    }
421
422    #[test]
423    fn a_lone_script_argument_is_a_prefix_call() {
424        assert_eq!(prefix_script(&os(&["bw", CLI_SCRIPT])).as_deref(), Some(CLI_SCRIPT));
425    }
426
427    #[test]
428    fn subcommands_flags_and_other_shapes_are_not() {
429        for args in [
430            vec!["bw", "hook"],
431            vec!["bw", "exec"],
432            vec!["bw", "--version"],
433            vec!["bw", "--help"],
434            vec!["bw"],
435            vec!["bw", "exec", "--tool-id=x"],
436            vec!["bw", "  "],
437        ] {
438            assert_eq!(prefix_script(&os(&args)), None, "{args:?}");
439        }
440    }
441
442    /// The CLI also prefixes hooks and stdio MCP servers, joining a server's
443    /// command and arguments into one argument; `agentmux-mcp` arrives alone.
444    #[test]
445    fn hooks_and_mcp_servers_are_prefix_calls_too() {
446        for script in ["agentmux-mcp", r"C:\tools\server.exe", "npx -y @scope/server", "agentmux-bashwrap hook"] {
447            assert_eq!(prefix_script(&os(&["bw", script])).as_deref(), Some(script));
448        }
449    }
450
451    /// A hook or an MCP server started while a Bash call waits must not take
452    /// that call's record: it would run in the PTY, and the call would lose
453    /// its live output.
454    #[test]
455    fn a_script_without_the_clis_eval_never_claims_a_call() {
456        with_calls_dir(|| {
457            register("s", &new_record("toolu_1", "cargo build", false)).unwrap();
458            std::env::set_var(SESSION_ID_ENV, "s");
459            let rt = tokio::runtime::Runtime::new().unwrap();
460            let code = rt.block_on(run("agentmux-no-such-program-4f1c".to_string())).unwrap();
461            std::env::remove_var(SESSION_ID_ENV);
462            assert_eq!(code, 127, "a program that can't be started exits like a shell's");
463            assert_eq!(claim("s", "cargo build").unwrap().tool_use_id, "toolu_1", "the call is still waiting");
464        });
465    }
466
467    /// A global npm install puts a `.cmd` shim on `PATH`; named alone, as an
468    /// MCP server's command, it must start.
469    #[cfg(windows)]
470    #[test]
471    fn a_cmd_shim_on_path_starts_as_a_lone_word() {
472        let _guard = crate::test_env_lock::ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
473        let dir = std::env::temp_dir().join(format!("bashwrap-shim-test-{}-{}", std::process::id(), now_ms()));
474        std::fs::create_dir_all(&dir).unwrap();
475        std::fs::write(dir.join("bw-shim-test.cmd"), "@exit /b 9\r\n").unwrap();
476        let old_path = std::env::var_os("PATH").unwrap_or_default();
477        let mut paths = vec![dir.clone()];
478        paths.extend(std::env::split_paths(&old_path));
479        std::env::set_var("PATH", std::env::join_paths(paths).unwrap());
480        let code = run_program("bw-shim-test", &[]);
481        std::env::set_var("PATH", &old_path);
482        let _ = std::fs::remove_dir_all(&dir);
483        assert_eq!(code.unwrap(), 9);
484    }
485
486    /// `C:\tools\server.exe --port 1`, an MCP server's joined line: started
487    /// directly with its arguments, not through bash, which would eat the
488    /// backslashes.
489    #[cfg(windows)]
490    #[test]
491    fn a_windows_path_with_arguments_starts_without_bash() {
492        let dir = std::env::temp_dir().join(format!("bashwrap-winpath-test-{}-{}", std::process::id(), now_ms()));
493        if dir.to_string_lossy().contains(char::is_whitespace) {
494            return; // A path with a space is a command line the CLI made ambiguous.
495        }
496        std::fs::create_dir_all(&dir).unwrap();
497        let script = dir.join("server.cmd");
498        std::fs::write(&script, "@exit /b %1\r\n").unwrap();
499        let line = format!("{} 5", script.display());
500        let code = run_passthrough(&line);
501        let _ = std::fs::remove_dir_all(&dir);
502        assert_eq!(code.unwrap(), 5, "its argument reached it");
503    }
504
505    #[test]
506    fn takes_the_command_out_of_the_clis_script() {
507        let (command, range) = extract_command(CLI_SCRIPT).unwrap();
508        assert_eq!(command, r#"echo 'it''s' "q" && echo two > out.txt; cat out.txt"#);
509        assert!(CLI_SCRIPT[range.end..].starts_with(" < /dev/null && pwd -P"));
510        assert!(CLI_SCRIPT[..range.start].ends_with("&& eval "));
511    }
512
513    #[test]
514    fn putting_the_command_back_round_trips() {
515        let (command, range) = extract_command(CLI_SCRIPT).unwrap();
516        assert_eq!(replace_command(CLI_SCRIPT, range.clone(), &command), CLI_SCRIPT);
517        let rewritten = replace_command(CLI_SCRIPT, range, "echo 'a' | tee out.txt");
518        assert_eq!(extract_command(&rewritten).unwrap().0, "echo 'a' | tee out.txt");
519    }
520
521    #[test]
522    fn multi_line_and_unicode_commands_survive() {
523        let cmd = "for f in *; do\n  echo \"$f\" — ✓ 'x'\ndone";
524        let script = format!("source s.sh || true && eval {} < /dev/null && pwd -P >| f", single_quote(cmd));
525        assert_eq!(extract_command(&script).unwrap().0, cmd);
526    }
527
528    #[test]
529    fn a_script_without_the_clis_eval_has_no_command() {
530        assert_eq!(extract_command("echo hi && ls"), None);
531        assert_eq!(extract_command("x && eval 'unterminated"), None);
532    }
533
534    #[test]
535    fn only_a_path_to_agentmux_bashwrap_turns_the_prefix_on() {
536        assert!(is_bashwrap_path(Path::new("C:/Program Files/AgentMux/tools/bin/agentmux-bashwrap.exe")));
537        assert!(is_bashwrap_path(Path::new("/usr/local/bin/agentmux-bashwrap")));
538        assert!(!is_bashwrap_path(Path::new("/usr/local/bin/logger.sh")));
539        assert!(!is_bashwrap_path(Path::new("")));
540    }
541
542    fn with_calls_dir<T>(f: impl FnOnce() -> T) -> T {
543        let _guard = crate::test_env_lock::ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
544        let dir = std::env::temp_dir().join(format!("bashwrap-calls-test-{}-{}", std::process::id(), now_ms()));
545        std::env::set_var(CALLS_DIR_OVERRIDE_ENV, &dir);
546        let out = f();
547        std::env::remove_var(CALLS_DIR_OVERRIDE_ENV);
548        let _ = std::fs::remove_dir_all(&dir);
549        out
550    }
551
552    #[test]
553    fn a_claim_takes_the_matching_record_once() {
554        with_calls_dir(|| {
555            register("s1", &new_record("toolu_a", "cargo build", false)).unwrap();
556            register("s1", &new_record("toolu_b", "npm test", true)).unwrap();
557            register("s1", &new_record("toolu_c", "ls", false)).unwrap();
558            let got = claim("s1", "npm test").unwrap();
559            assert_eq!((got.tool_use_id.as_str(), got.run_in_background), ("toolu_b", true));
560            assert_eq!(claim("s1", "npm test"), None, "claimed once: two others left, neither matches");
561            assert_eq!(claim("s1", "cargo build").unwrap().tool_use_id, "toolu_a");
562            // One record left: a command that doesn't match exactly (the CLI
563            // may tidy it) still gets the session's only waiting call.
564            assert_eq!(claim("s1", "ls ").unwrap().tool_use_id, "toolu_c");
565        });
566    }
567
568    /// A call the CLI never ran (denied) leaves its record; the retry of the
569    /// same command, recorded later, is the one its run claims.
570    #[test]
571    fn the_newest_record_for_a_command_is_claimed_first() {
572        with_calls_dir(|| {
573            let mut denied = new_record("toolu_denied", "make", false);
574            denied.created_ms -= 10_000;
575            register("s", &denied).unwrap();
576            register("s", &new_record("toolu_retry", "make", true)).unwrap();
577            let got = claim("s", "make").unwrap();
578            assert_eq!((got.tool_use_id.as_str(), got.run_in_background), ("toolu_retry", true));
579        });
580    }
581
582    #[test]
583    fn without_a_match_only_a_lone_record_is_claimed() {
584        with_calls_dir(|| {
585            register("s", &new_record("toolu_1", "a", false)).unwrap();
586            assert_eq!(claim("s", "no match").unwrap().tool_use_id, "toolu_1");
587            register("s", &new_record("toolu_2", "b", false)).unwrap();
588            register("s", &new_record("toolu_3", "c", false)).unwrap();
589            assert_eq!(claim("s", "zzz"), None, "two candidates: ambiguous");
590            assert_eq!(claim("other-session", "b"), None, "another session's records are never claimed");
591        });
592    }
593
594    #[test]
595    fn a_session_directory_goes_once_its_last_call_is_claimed() {
596        with_calls_dir(|| {
597            register("s", &new_record("toolu_1", "a", false)).unwrap();
598            register("s", &new_record("toolu_2", "b", false)).unwrap();
599            let dir = session_dir("s").unwrap();
600            claim("s", "a").unwrap();
601            assert!(dir.is_dir(), "a call still waits in it");
602            claim("s", "b").unwrap();
603            assert!(!dir.exists());
604            // The next call makes it again.
605            register("s", &new_record("toolu_3", "c", false)).unwrap();
606            assert_eq!(claim("s", "c").unwrap().tool_use_id, "toolu_3");
607        });
608    }
609
610    #[test]
611    fn a_new_record_sweeps_other_sessions_left_behind() {
612        with_calls_dir(|| {
613            // A session whose only call was denied: its record is past its age.
614            let mut denied = new_record("toolu_old", "x", false);
615            denied.created_ms -= RECORD_MAX_AGE.as_millis() as u64 + 1;
616            register("ended", &denied).unwrap();
617            // A session that ended with nothing waiting: an empty directory.
618            std::fs::create_dir_all(session_dir("empty").unwrap()).unwrap();
619            // A live session with a call waiting.
620            register("live", &new_record("toolu_live", "y", false)).unwrap();
621            register("current", &new_record("toolu_now", "z", false)).unwrap();
622            assert!(!session_dir("ended").unwrap().exists());
623            assert!(!session_dir("empty").unwrap().exists());
624            assert_eq!(claim("live", "y").unwrap().tool_use_id, "toolu_live");
625            assert_eq!(claim("current", "z").unwrap().tool_use_id, "toolu_now");
626        });
627    }
628
629    #[test]
630    fn stale_records_are_dropped() {
631        with_calls_dir(|| {
632            let mut old = new_record("toolu_old", "x", false);
633            old.created_ms -= RECORD_MAX_AGE.as_millis() as u64 + 1;
634            register("s", &old).unwrap();
635            assert_eq!(claim("s", "x"), None);
636            assert!(read_records(&session_dir("s").unwrap()).is_empty());
637        });
638    }
639}