From 02a023767b368b6c12519915bf8696a88d957c83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20H=C3=B6fer?= Date: Thu, 8 Oct 2026 13:27:44 +0200 Subject: [PATCH 1/4] fix(agent): forward terminal identity into the Docker sandbox Agents in a Docker-sandboxed run sent no desktop notifications when a turn finished or they needed input: only TERM and COLORTERM reached the container, and Claude Code and Codex choose OSC 9/777/99 or the bell from TERM_PROGRAM. TERM_PROGRAM, TERM_PROGRAM_VERSION and LC_TERMINAL are now forwarded too, so the terminal or multiplexer wrapping the pane gets the same signals as outside the sandbox. Profile env vars still win. --- src/handlers/run/docker_sandbox.rs | 89 +++++++++++++++++++++++++----- 1 file changed, 76 insertions(+), 13 deletions(-) diff --git a/src/handlers/run/docker_sandbox.rs b/src/handlers/run/docker_sandbox.rs index 521ccfc1..131e0bd6 100644 --- a/src/handlers/run/docker_sandbox.rs +++ b/src/handlers/run/docker_sandbox.rs @@ -938,19 +938,9 @@ pub(crate) fn docker_run_command( super::subprocess::force_color_level() )); - // TERM/COLORTERM drive terminal-capability detection (truecolor - // support, theme selection) in TUIs like Codex's — FORCE_COLOR alone - // only covers basic on/off color, not that. Forwarded from the host - // since the container has no controlling terminal of its own to - // detect these from; caller-provided env vars still win. - for key in ["TERM", "COLORTERM"] { - if !env_vars.contains_key(key) { - if let Ok(value) = std::env::var(key) { - args.push("-e".to_owned()); - args.push(format!("{key}={value}")); - } - } - } + args.extend(terminal_identity_env_args(env_vars, |key| { + std::env::var(key).ok() + })); args.push(agent_image.to_owned()); args.push(command.to_owned()); @@ -1435,6 +1425,45 @@ fn host_git_config(key: &str) -> Option { /// committing. Returns an empty map if the host has neither configured — /// this is a convenience, not a requirement, and the container works /// fine without it (git commands that don't need an identity still run). +/// Host env vars that tell a TUI which terminal it is running in. +/// +/// `TERM`/`COLORTERM` drive capability detection (truecolor support, theme +/// selection) in TUIs like Codex's — FORCE_COLOR alone only covers basic +/// on/off color, not that. `TERM_PROGRAM`, `TERM_PROGRAM_VERSION` and +/// `LC_TERMINAL` are how agents like Claude Code and Codex pick a desktop +/// notification method (OSC 9/777/99 or the bell) when a turn finishes or +/// they need input. Those escape sequences pass through `docker run -t` +/// untouched, so any terminal or multiplexer wrapping the pane (Ghostty, +/// iTerm2, kitty, herdr, tmux, ...) receives them just as it would outside +/// the sandbox — but only if the agent knows which terminal it is in. +const FORWARDED_TERMINAL_ENV_VARS: [&str; 5] = [ + "TERM", + "COLORTERM", + "TERM_PROGRAM", + "TERM_PROGRAM_VERSION", + "LC_TERMINAL", +]; + +/// `-e` args forwarding [`FORWARDED_TERMINAL_ENV_VARS`] from the host, +/// since the container has no controlling terminal of its own to detect +/// these from. Caller-provided env vars still win. +fn terminal_identity_env_args( + env_vars: &std::collections::HashMap, + host_env: impl Fn(&str) -> Option, +) -> Vec { + let mut args = Vec::new(); + for key in FORWARDED_TERMINAL_ENV_VARS { + if env_vars.contains_key(key) { + continue; + } + if let Some(value) = host_env(key) { + args.push("-e".to_owned()); + args.push(format!("{key}={value}")); + } + } + args +} + fn host_git_identity_env_vars() -> std::collections::HashMap { let mut vars = std::collections::HashMap::new(); if let Some(name) = host_git_config("user.name") { @@ -2488,6 +2517,40 @@ mod tests { assert!(!args.contains(&"-t".to_owned())); } + #[test] + fn terminal_identity_env_args_forwards_terminal_vars_set_on_host() { + let host_env = |key: &str| match key { + "TERM" => Some("xterm-256color".to_owned()), + "TERM_PROGRAM" => Some("ghostty".to_owned()), + "LC_TERMINAL" => Some("iTerm2".to_owned()), + _ => None, + }; + let args = terminal_identity_env_args(&std::collections::HashMap::new(), host_env); + assert_eq!( + args, + vec![ + "-e", + "TERM=xterm-256color", + "-e", + "TERM_PROGRAM=ghostty", + "-e", + "LC_TERMINAL=iTerm2", + ] + ); + } + + #[test] + fn terminal_identity_env_args_lets_caller_env_vars_win() { + let host_env = |_: &str| Some("host-value".to_owned()); + let env_vars = std::collections::HashMap::from([( + "TERM_PROGRAM".to_owned(), + "profile-value".to_owned(), + )]); + let args = terminal_identity_env_args(&env_vars, host_env); + assert!(!args.iter().any(|arg| arg.starts_with("TERM_PROGRAM="))); + assert!(args.contains(&"TERM_PROGRAM_VERSION=host-value".to_owned())); + } + #[test] fn docker_run_command_adds_pty_flag_only_when_stdin_is_a_terminal() { let network = DockerRunNetwork { From 685e96e30f8f7a285fbfc4e514069d0bc681eb9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20H=C3=B6fer?= Date: Thu, 8 Oct 2026 13:27:44 +0200 Subject: [PATCH 2/4] fix(agent): let Herdr detect an agent running in the Docker sandbox Herdr identifies an agent by the pane's foreground process, which for a Docker-sandboxed run is the host docker CLI, so it showed no agent state. Inside a Herdr pane the docker process now gets HERDR_AGENT=, Herdr's documented hint for wrappers. It is set only on the host process, never in the container, and a HERDR_AGENT the user exported is kept. --- src/handlers/run/subprocess.rs | 57 +++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/src/handlers/run/subprocess.rs b/src/handlers/run/subprocess.rs index 23468a38..ea1f837c 100644 --- a/src/handlers/run/subprocess.rs +++ b/src/handlers/run/subprocess.rs @@ -181,6 +181,14 @@ pub async fn run_command_with_filesystem_policy_and_network( sandbox_isolated_paths, ) .map_err(|error| anyhow::anyhow!("failed to build Docker sandbox invocation: {error}"))?; + // Added only after `docker_run_command` has built its `-e` args, so + // the hint lands on the host-side `docker` process (the pane's + // foreground process, which Herdr inspects) and never reaches the + // container. + let mut env_vars = env_vars; + if let Some(agent) = herdr_agent_hint(command, |key| env::var(key).ok()) { + env_vars.insert("HERDR_AGENT".to_owned(), agent); + } return run_built_command( program, launcher_args, @@ -544,6 +552,28 @@ fn codex_boundary_for_mode(mode: &str) -> CodexSandboxBoundary { /// new user/mount namespace, which `--cap-drop ALL` blocks outright). /// Codex's approval policy is left untouched; only its own filesystem /// sandboxing is disabled. +/// The `HERDR_AGENT` value for a Docker-sandboxed agent run inside a Herdr +/// pane, or `None` when there is nothing to add. +/// +/// Herdr identifies an agent by the pane's foreground process. For the +/// Docker backend that is the host's `docker` CLI — the agent itself runs +/// inside the Docker VM, invisible to the host process tree — so Herdr +/// can't tell an agent is there and shows no idle/working/blocked state. +/// Herdr's documented fix for wrappers is `HERDR_AGENT=` set on the +/// host-side wrapper process, which selects that agent's screen-detection +/// rules for the pane. A `HERDR_AGENT` the user already exported is +/// inherited by `docker` as-is, so it is left alone. +fn herdr_agent_hint(command: &str, host_env: impl Fn(&str) -> Option) -> Option { + if host_env("HERDR_ENV").as_deref() != Some("1") || host_env("HERDR_AGENT").is_some() { + return None; + } + let agent = PathBuf::from(command) + .file_stem()? + .to_string_lossy() + .to_ascii_lowercase(); + (!agent.is_empty()).then_some(agent) +} + fn codex_args_forcing_full_access(command: &str, mut args: Vec) -> Vec { let is_codex = PathBuf::from(command) .file_stem() @@ -1202,7 +1232,8 @@ pub(crate) fn filesystem_enforcement_error() -> Option { mod tests { use super::{ codex_args_forcing_full_access, filesystem_backend_for_policy, filesystem_denial_from_line, - run_command, run_marking_launch, sandbox_command, should_inherit_terminal_streams, + herdr_agent_hint, run_command, run_marking_launch, sandbox_command, + should_inherit_terminal_streams, }; #[cfg(target_os = "macos")] use super::{ @@ -1218,6 +1249,30 @@ mod tests { LOCK.get_or_init(|| Mutex::new(())) } + #[test] + fn herdr_agent_hint_names_the_agent_inside_a_herdr_pane() { + let host_env = |key: &str| (key == "HERDR_ENV").then(|| "1".to_owned()); + assert_eq!( + herdr_agent_hint("/usr/local/bin/Claude", host_env), + Some("claude".to_owned()) + ); + } + + #[test] + fn herdr_agent_hint_is_none_outside_herdr() { + assert_eq!(herdr_agent_hint("claude", |_| None), None); + } + + #[test] + fn herdr_agent_hint_keeps_a_user_exported_value() { + let host_env = |key: &str| match key { + "HERDR_ENV" => Some("1".to_owned()), + "HERDR_AGENT" => Some("codex".to_owned()), + _ => None, + }; + assert_eq!(herdr_agent_hint("claude", host_env), None); + } + #[test] fn codex_args_forcing_full_access_leaves_non_codex_commands_untouched() { let args = vec!["--sandbox".to_owned(), "workspace-write".to_owned()]; From 92616845bbba89eaabb9ed4a021d8c1ebc8bed1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20H=C3=B6fer?= Date: Thu, 8 Oct 2026 13:36:38 +0200 Subject: [PATCH 3/4] fix(agent): keep an explicit HERDR_AGENT on the docker process A HERDR_AGENT set by the profile (an env var or binding) is applied to the host docker process, but the inferred hint replaced it, so Herdr could use the wrong agent's detection rules. The hint is now skipped when env_vars already has HERDR_AGENT, as it already was for one the user exported. Also moves herdr_agent_hint above the doc comment of codex_args_forcing_full_access, which it had split off. --- src/handlers/run/subprocess.rs | 57 +++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 21 deletions(-) diff --git a/src/handlers/run/subprocess.rs b/src/handlers/run/subprocess.rs index ea1f837c..29642799 100644 --- a/src/handlers/run/subprocess.rs +++ b/src/handlers/run/subprocess.rs @@ -186,7 +186,7 @@ pub async fn run_command_with_filesystem_policy_and_network( // foreground process, which Herdr inspects) and never reaches the // container. let mut env_vars = env_vars; - if let Some(agent) = herdr_agent_hint(command, |key| env::var(key).ok()) { + if let Some(agent) = herdr_agent_hint(command, &env_vars, |key| env::var(key).ok()) { env_vars.insert("HERDR_AGENT".to_owned(), agent); } return run_built_command( @@ -539,19 +539,6 @@ fn codex_boundary_for_mode(mode: &str) -> CodexSandboxBoundary { } } -/// Rewrites Codex's own `--sandbox ` argument to `danger-full-access` -/// (or injects it if absent), the same way `codex_args_with_outer_sandbox` -/// does for the native macOS backend when an outer Seatbelt profile is -/// already active. Unlike that macOS-only helper, this one runs -/// unconditionally for the Docker backend on every host platform: the -/// Docker container is *always* an outer sandbox once selected, and -/// Codex's own inner sandbox (bubblewrap on Linux, Seatbelt on macOS) is -/// both redundant — the container is already the enforcement boundary — -/// and, for bubblewrap specifically, non-functional inside a container -/// that has already dropped all capabilities (`bwrap` needs to create a -/// new user/mount namespace, which `--cap-drop ALL` blocks outright). -/// Codex's approval policy is left untouched; only its own filesystem -/// sandboxing is disabled. /// The `HERDR_AGENT` value for a Docker-sandboxed agent run inside a Herdr /// pane, or `None` when there is nothing to add. /// @@ -561,10 +548,18 @@ fn codex_boundary_for_mode(mode: &str) -> CodexSandboxBoundary { /// can't tell an agent is there and shows no idle/working/blocked state. /// Herdr's documented fix for wrappers is `HERDR_AGENT=` set on the /// host-side wrapper process, which selects that agent's screen-detection -/// rules for the pane. A `HERDR_AGENT` the user already exported is -/// inherited by `docker` as-is, so it is left alone. -fn herdr_agent_hint(command: &str, host_env: impl Fn(&str) -> Option) -> Option { - if host_env("HERDR_ENV").as_deref() != Some("1") || host_env("HERDR_AGENT").is_some() { +/// rules for the pane. An explicit `HERDR_AGENT` always wins: one in +/// `env_vars` (a profile env var or binding) is already applied to the +/// `docker` process, and one the user exported is inherited by it. +fn herdr_agent_hint( + command: &str, + env_vars: &HashMap, + host_env: impl Fn(&str) -> Option, +) -> Option { + if host_env("HERDR_ENV").as_deref() != Some("1") + || env_vars.contains_key("HERDR_AGENT") + || host_env("HERDR_AGENT").is_some() + { return None; } let agent = PathBuf::from(command) @@ -574,6 +569,19 @@ fn herdr_agent_hint(command: &str, host_env: impl Fn(&str) -> Option) -> (!agent.is_empty()).then_some(agent) } +/// Rewrites Codex's own `--sandbox ` argument to `danger-full-access` +/// (or injects it if absent), the same way `codex_args_with_outer_sandbox` +/// does for the native macOS backend when an outer Seatbelt profile is +/// already active. Unlike that macOS-only helper, this one runs +/// unconditionally for the Docker backend on every host platform: the +/// Docker container is *always* an outer sandbox once selected, and +/// Codex's own inner sandbox (bubblewrap on Linux, Seatbelt on macOS) is +/// both redundant — the container is already the enforcement boundary — +/// and, for bubblewrap specifically, non-functional inside a container +/// that has already dropped all capabilities (`bwrap` needs to create a +/// new user/mount namespace, which `--cap-drop ALL` blocks outright). +/// Codex's approval policy is left untouched; only its own filesystem +/// sandboxing is disabled. fn codex_args_forcing_full_access(command: &str, mut args: Vec) -> Vec { let is_codex = PathBuf::from(command) .file_stem() @@ -1253,14 +1261,14 @@ mod tests { fn herdr_agent_hint_names_the_agent_inside_a_herdr_pane() { let host_env = |key: &str| (key == "HERDR_ENV").then(|| "1".to_owned()); assert_eq!( - herdr_agent_hint("/usr/local/bin/Claude", host_env), + herdr_agent_hint("/usr/local/bin/Claude", &HashMap::new(), host_env), Some("claude".to_owned()) ); } #[test] fn herdr_agent_hint_is_none_outside_herdr() { - assert_eq!(herdr_agent_hint("claude", |_| None), None); + assert_eq!(herdr_agent_hint("claude", &HashMap::new(), |_| None), None); } #[test] @@ -1270,7 +1278,14 @@ mod tests { "HERDR_AGENT" => Some("codex".to_owned()), _ => None, }; - assert_eq!(herdr_agent_hint("claude", host_env), None); + assert_eq!(herdr_agent_hint("claude", &HashMap::new(), host_env), None); + } + + #[test] + fn herdr_agent_hint_keeps_an_explicit_profile_value() { + let host_env = |key: &str| (key == "HERDR_ENV").then(|| "1".to_owned()); + let env_vars = HashMap::from([("HERDR_AGENT".to_owned(), "codex".to_owned())]); + assert_eq!(herdr_agent_hint("claude", &env_vars, host_env), None); } #[test] From 3efca9dc6192442f75355ed838056cfc7dfd3b81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20H=C3=B6fer?= Date: Thu, 8 Oct 2026 13:41:32 +0200 Subject: [PATCH 4/4] docs: note that agent notifications and herdr state work in the Docker sandbox The README's Docker section and a new docs/sandboxing.md section say that cmux, herdr and other terminals get agent notifications in the sandbox with no setup, and that herdr also shows the agent's state. The section lists the forwarded terminal vars, the HERDR_AGENT override, and why the apps' own hook integrations don't run in the container. --- README.md | 4 +++- docs/sandboxing.md | 8 ++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e4f2dd20..871c3284 100644 --- a/README.md +++ b/README.md @@ -264,7 +264,9 @@ Or override the profile's choice for one invocation without editing the file: `- Claude Code and Codex are pre-installed in the default sandbox image; a profile can also run its own image or Dockerfile instead (`[sandbox] image`/`dockerfile`) to add other tools, without loosening any of the sandbox constraints themselves. -See **[docs/sandboxing.md](docs/sandboxing.md)** for the full picture: how the network firewall is enforced, custom images, git identity forwarding, login persistence across images, Codex/Claude Code OAuth quirks, and current limitations. +Agent notifications keep working inside the sandbox, with no setup: [cmux](https://cmux.com), [herdr](https://herdr.dev), Ghostty, iTerm2, kitty, tmux and other terminals get Claude Code's and Codex's "turn finished" and "needs input" notifications as usual, and herdr also shows the sandboxed agent's idle/working/blocked state. + +See **[docs/sandboxing.md](docs/sandboxing.md)** for the full picture: how the network firewall is enforced, custom images, git identity forwarding, [notifications and herdr/cmux](docs/sandboxing.md#notifications-herdr-and-cmux), login persistence across images, Codex/Claude Code OAuth quirks, and current limitations. ### Parallel Agents in Git Worktrees diff --git a/docs/sandboxing.md b/docs/sandboxing.md index f204cc8d..5a594bdd 100644 --- a/docs/sandboxing.md +++ b/docs/sandboxing.md @@ -111,6 +111,14 @@ stashbase agent run --profile coding --docker-image node:22-alpine -- claude Your global `git config user.name` and `user.email` (if configured on the host) are forwarded into the container as `GIT_AUTHOR_NAME`, `GIT_AUTHOR_EMAIL`, `GIT_COMMITTER_NAME`, and `GIT_COMMITTER_EMAIL`. This is the one piece of host configuration deliberately forwarded despite the filesystem allow-list, since it's authorship metadata, not a credential — without it, `git commit` inside the sandbox fails with no identity configured. It does not grant push access: `git push` (or any other authenticated git operation) still needs a real credential, wired through `[secrets]` like `GITHUB_TOKEN`, or run from outside the sandbox. Raw SSH keys are never forwarded. A profile that explicitly sets one of these four env vars itself takes precedence over the forwarded host value. +### Notifications, herdr and cmux + +Agent notifications work in the sandbox with no setup. Your terminal's identity (`TERM`, `COLORTERM`, `TERM_PROGRAM`, `TERM_PROGRAM_VERSION`, `LC_TERMINAL`) is forwarded into the container, so Claude Code and Codex send the same notifications they would outside it (OSC 9/777/99 or the bell) when a turn finishes or they need input. Ghostty, iTerm2, kitty, [cmux](https://cmux.com), [herdr](https://herdr.dev), tmux and other terminals and multiplexers pick them up as usual. Only the terminal's name and version are forwarded; nothing else about your terminal or session reaches the container. + +In a herdr pane, herdr also shows the sandboxed agent's state (idle, working, blocked). herdr identifies an agent by the pane's foreground process, which for a Docker run is the host `docker` CLI, so Stashbase sets `HERDR_AGENT=` (e.g. `claude`, `codex`) on that host process, never inside the container. If the agent command's name isn't the herdr agent name, set it yourself: `HERDR_AGENT=codex stashbase agent run --profile coding -- my-codex-wrapper`. An exported `HERDR_AGENT` is always kept. + +None of this needs the apps' own hook integrations (`herdr integration install`, cmux's Claude Code hooks), and those hooks don't run in the sandbox: they need the host's agent config and the app's local socket, which Stashbase deliberately doesn't expose to the container, since it would let the agent control your other panes. cmux still gets notifications from the escape sequences above, and herdr's state comes from its screen detection. + ### Login persistence Agent login/config state (e.g. Claude Code's `~/.claude`, Codex's `~/.codex`) is kept in a Docker-managed named volume, not a bind mount of your real home directory, so it survives across `agent run` invocations without exposing anything else on the host. This volume is shared across every profile, project, *and image* using the Docker backend on this machine — logging in once covers all of them, even after switching to a completely different custom image or Dockerfile, since the volume is mounted at the same container path (`/home/agent`) regardless of which image runs.