Skip to main content

agentmux_srv/server/agent_handlers/
bash_prefix.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The Claude agent's shell prefix: `agentmux-bashwrap` as the CLI's
5//! `CLAUDE_CODE_SHELL_PREFIX`, which streams Bash output without the hook
6//! rewriting the call (docs/specs/SPEC_BASH_STREAMING_VIA_SHELL_PREFIX_2026_10_10.md).
7
8/// Set the prefix in an agent's spawn environment
9/// (`input::build_persistent_spawn_env`). Not for a pane in the MCP
10/// self-heal's safe profile: its MCP server failed to start and reconnecting
11/// didn't help, so its CLI runs without the prefix, as with
12/// `AGENTMUX_BASHWRAP_MODE=rewrite` (`backend::mcp_health`).
13pub(crate) fn apply_for_spawn(env_vars: &mut std::collections::HashMap<String, String>, block_id: &str) {
14    let mode = mode_for(block_id);
15    apply_bash_shell_prefix(env_vars, crate::backend::tool_store::bashwrap_path(), mode.as_deref());
16}
17
18/// `rewrite` for a pane in the safe profile, else srv's own setting.
19fn mode_for(block_id: &str) -> Option<String> {
20    if crate::backend::mcp_health::is_safe_profile(block_id) {
21        tracing::info!(block_id, "MCP self-heal safe profile: starting the agent without the shell prefix");
22        return Some("rewrite".to_string());
23    }
24    std::env::var(BASHWRAP_MODE_ENV).ok()
25}
26
27/// srv's own environment variable that turns the shell prefix off:
28/// `rewrite` keeps the hook rewriting commands, as before the prefix.
29const BASHWRAP_MODE_ENV: &str = "AGENTMUX_BASHWRAP_MODE";
30
31/// Claude Code runs every Bash tool command through `CLAUDE_CODE_SHELL_PREFIX`
32/// when it is set: pointed at `agentmux-bashwrap`, that streams the command
33/// without the hook rewriting the call, so the CLI's permission rules and
34/// labels see the model's own command
35/// (docs/specs/SPEC_BASH_STREAMING_VIA_SHELL_PREFIX_2026_10_10.md ยง2.4).
36/// Only Claude Code reads the variable. A prefix the agent's own `cmd:env`
37/// sets is kept (the hook then rewrites, as it does without a prefix); no
38/// prefix is set without the wrapper, or when srv runs with
39/// `AGENTMUX_BASHWRAP_MODE=rewrite`.
40fn apply_bash_shell_prefix(
41    env_vars: &mut std::collections::HashMap<String, String>,
42    bashwrap: Option<std::path::PathBuf>,
43    mode: Option<&str>,
44) {
45    const PREFIX_ENV: &str = "CLAUDE_CODE_SHELL_PREFIX";
46    if env_vars.contains_key(PREFIX_ENV) || mode.is_some_and(|m| m.eq_ignore_ascii_case("rewrite")) {
47        return;
48    }
49    if let Some(path) = bashwrap {
50        env_vars.insert(PREFIX_ENV.to_string(), path.to_string_lossy().into_owned());
51    }
52}
53
54#[cfg(test)]
55mod tests {
56    use super::apply_bash_shell_prefix;
57
58    const PREFIX: &str = "CLAUDE_CODE_SHELL_PREFIX";
59
60    #[test]
61    fn the_shell_prefix_names_the_bundled_wrapper() {
62        let mut env = std::collections::HashMap::new();
63        let wrapper = std::path::PathBuf::from("/opt/agentmux/tools/bin/agentmux-bashwrap");
64        apply_bash_shell_prefix(&mut env, Some(wrapper.clone()), None);
65        assert_eq!(env.get(PREFIX).map(String::as_str), Some(wrapper.to_str().unwrap()));
66    }
67
68    #[test]
69    fn no_shell_prefix_without_the_wrapper_or_when_switched_off() {
70        let mut env = std::collections::HashMap::new();
71        apply_bash_shell_prefix(&mut env, None, None);
72        assert!(!env.contains_key(PREFIX));
73        let wrapper = Some(std::path::PathBuf::from("/x/agentmux-bashwrap"));
74        apply_bash_shell_prefix(&mut env, wrapper.clone(), Some("rewrite"));
75        apply_bash_shell_prefix(&mut env, wrapper, Some("REWRITE"));
76        assert!(!env.contains_key(PREFIX));
77    }
78
79    #[test]
80    fn an_agents_own_shell_prefix_is_kept() {
81        let mut env = std::collections::HashMap::from([(PREFIX.to_string(), "/home/me/log-commands.sh".to_string())]);
82        apply_bash_shell_prefix(&mut env, Some("/x/agentmux-bashwrap".into()), None);
83        assert_eq!(env[PREFIX], "/home/me/log-commands.sh");
84    }
85
86    /// A pane whose MCP server failed and wouldn't reconnect starts without
87    /// the prefix; other panes are unaffected.
88    #[test]
89    fn a_pane_in_the_safe_profile_gets_no_prefix() {
90        use crate::backend::mcp_health::{on_reconnect, on_status, Status};
91        let block = format!("safe-profile-test-{}", uuid::Uuid::new_v4());
92        on_status(&block, "p1", Status::Failed, None, true, 0);
93        on_reconnect(&block, "p1", Err("Connection closed".into()), 1);
94        on_reconnect(&block, "p1", Err("Connection closed".into()), 2);
95        assert_eq!(super::mode_for(&block).as_deref(), Some("rewrite"));
96        if std::env::var("AGENTMUX_BASHWRAP_MODE").is_err() {
97            assert_eq!(super::mode_for("another-pane"), None);
98        }
99    }
100}