1use 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
29pub const SHELL_PREFIX_ENV: &str = "CLAUDE_CODE_SHELL_PREFIX";
31pub const SESSION_ID_ENV: &str = "CLAUDE_CODE_SESSION_ID";
34const CALLS_DIR_OVERRIDE_ENV: &str = "AGENTMUX_BASHWRAP_CALLS_DIR";
36const RECORD_MAX_AGE: Duration = Duration::from_secs(3600);
39const SUBCOMMANDS: &[&str] = &["exec", "hook", "precompact", "sessionstart", "help"];
41
42#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub description: Option<String>,
54}
55
56const MAX_DESCRIPTION_CHARS: usize = 200;
58
59pub 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
77pub 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
92pub fn extract_command(script: &str) -> Option<(String, Range<usize>)> {
97 let at = script.find("&& eval '")? + "&& eval ".len();
99 let (command, end) = read_shell_word(script, at)?;
100 Some((command, at..end))
101}
102
103fn 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
151pub fn single_quote(s: &str) -> String {
153 format!("'{}'", s.replace('\'', "'\"'\"'"))
154}
155
156pub 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
178pub 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 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
201fn 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 let _ = std::fs::remove_dir(&path);
215 }
216 }
217}
218
219pub 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 let _ = std::fs::remove_dir(&dir);
252 claimed_record
253}
254
255fn 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
279pub async fn run(script: String) -> Result<i32> {
287 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 let script = match crate::hook::tee_redirect_rewrite(&command) {
304 Some(teed) => replace_command(&script, range, &teed),
305 None => script,
306 };
307 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 let _ = tokio::time::timeout(Duration::from_secs(6), task).await;
319 }
320 result
321}
322
323async 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
337fn 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#[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#[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 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
386fn 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 #[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
399pub 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 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 #[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 #[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 #[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 #[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; }
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 assert_eq!(claim("s1", "ls ").unwrap().tool_use_id, "toolu_c");
565 });
566 }
567
568 #[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 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 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 std::fs::create_dir_all(session_dir("empty").unwrap()).unwrap();
619 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}