From 67dfba0daf09384a6bdaa0ae9f8f4f9aa19272ec Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:38:02 -0700 Subject: [PATCH 1/6] test(cmux-tui): cover Codex hook context for shared daemons --- cmux-tui/crates/cmux-tui/src/agent_hook_install.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/cmux-tui/crates/cmux-tui/src/agent_hook_install.rs b/cmux-tui/crates/cmux-tui/src/agent_hook_install.rs index 652f75f0de51..70c1ee7e87b4 100644 --- a/cmux-tui/crates/cmux-tui/src/agent_hook_install.rs +++ b/cmux-tui/crates/cmux-tui/src/agent_hook_install.rs @@ -3773,6 +3773,20 @@ esac assert_eq!(fs::read_to_string(&capture).unwrap(), "codex Stop\n"); } + #[cfg(unix)] + #[test] + fn codex_contextual_hook_command_survives_a_shared_daemon() { + let command = codex_contextual_hook_command( + "Stop", + Path::new("/run/cmux-tui.sock"), + "terminal-1", + Path::new("/home/user/.local/share/cmux-tui/bin/cmux-tui-hook"), + ); + assert!(command.contains("CMUX_TUI_SOCKET='/run/cmux-tui.sock'"), "{command}"); + assert!(command.contains("CMUX_TUI_TERMINAL_ID='terminal-1'"), "{command}"); + assert!(command.contains("cmux-tui-hook 'codex' 'Stop'"), "{command}"); + } + #[test] fn every_command_hook_fits_in_two_hundred_bytes() { for provider in PROVIDERS { From ae742e9569aec95b915740dabb37f46b20900aac Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:02:35 -0700 Subject: [PATCH 2/6] fix(cmux-tui): preserve Codex hook context across daemons --- .../crates/cmux-tui/src/agent_hook_install.rs | 39 ++- cmux-tui/crates/cmux-tui/src/codex_wrapper.rs | 266 ++++++++++++++++++ cmux-tui/crates/cmux-tui/src/main.rs | 11 + 3 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 cmux-tui/crates/cmux-tui/src/codex_wrapper.rs diff --git a/cmux-tui/crates/cmux-tui/src/agent_hook_install.rs b/cmux-tui/crates/cmux-tui/src/agent_hook_install.rs index 70c1ee7e87b4..7b1b2ef19170 100644 --- a/cmux-tui/crates/cmux-tui/src/agent_hook_install.rs +++ b/cmux-tui/crates/cmux-tui/src/agent_hook_install.rs @@ -91,7 +91,7 @@ fn hermes_reaper_spawn_should_fail() -> bool { false } -const CODEX_EVENTS: &[&str] = &[ +pub(crate) const CODEX_EVENTS: &[&str] = &[ "SessionStart", "UserPromptSubmit", "Stop", @@ -1917,6 +1917,43 @@ fn hook_command(provider: &str, event: &str) -> String { ) } +/// The Codex wrapper's per-launch hook command. Codex's shared app-server can +/// outlive the terminal that started it and does not preserve that terminal's +/// environment when it starts hook processes, so the routing context must be +/// part of the command itself. +#[cfg(unix)] +pub(crate) fn codex_contextual_hook_command( + event: &str, + socket: &Path, + terminal: &str, + helper: &Path, +) -> String { + format!( + "CMUX_TUI_SOCKET={} CMUX_TUI_TERMINAL_ID={} CMUX_TUI_HOOK={} {} {} {} 2>/dev/null||:;echo {{}};#{COMMAND_MARKER}", + shell_quote(&socket.to_string_lossy()), + shell_quote(terminal), + shell_quote(&helper.to_string_lossy()), + shell_quote(&helper.to_string_lossy()), + shell_quote("codex"), + shell_quote(event), + ) +} + +/// Encodes one contextual cmux hook as a Codex `-c` value. +#[cfg(unix)] +pub(crate) fn codex_contextual_hook_setting( + event: &str, + socket: &Path, + terminal: &str, + helper: &Path, +) -> String { + let command = codex_contextual_hook_command(event, socket, terminal, helper); + format!( + "hooks.{event}=[{{hooks=[{{type=\"command\",command='''{command}''',timeout={}}}]}}]", + codex_hook_timeout(event) + ) +} + /// The command shape before the tmux fallback. Its codex trust hashes stay /// cmux-owned so an upgrade replaces them instead of leaving them behind. fn legacy_hook_command(provider: &str, event: &str) -> String { diff --git a/cmux-tui/crates/cmux-tui/src/codex_wrapper.rs b/cmux-tui/crates/cmux-tui/src/codex_wrapper.rs new file mode 100644 index 000000000000..6d399cb89e2e --- /dev/null +++ b/cmux-tui/crates/cmux-tui/src/codex_wrapper.rs @@ -0,0 +1,266 @@ +//! `cmux-tui agent codex-wrapper [codex args...]`: starts Codex with +//! per-invocation cmux hook context. +//! +//! Codex may execute hooks from a shared app-server daemon. That daemon does +//! not retain the terminal environment that started it, so persistent hooks +//! which rely on `CMUX_TUI_SOCKET` cannot identify their terminal. The wrapper +//! puts the socket, terminal id, and helper path directly in each hook command +//! passed to Codex for this launch. + +use std::ffi::{OsStr, OsString}; +use std::fs; +use std::os::unix::fs::PermissionsExt as _; +use std::os::unix::process::CommandExt as _; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::Context as _; + +use crate::agent_hook_install; + +const VERB: &str = "codex-wrapper"; +const SHIM_MARKER: &str = "# cmux-tui-codex-shim"; +const HOOKS_DISABLED_ENV: &str = "CMUX_TUI_CODEX_HOOKS_DISABLED"; + +/// Returns the wrapper's arguments when argv (without the program name) +/// selects `agent codex-wrapper`. +pub(crate) fn invocation(args: &[OsString]) -> Option<&[OsString]> { + match args { + [scope, verb, rest @ ..] if scope == "agent" && verb == VERB => Some(rest), + _ => None, + } +} + +/// Execs the real Codex. If the launch is not a session or the terminal has +/// no live cmux socket, the original argv is passed through unchanged. +pub(crate) fn run(args: &[OsString]) -> i32 { + let path = std::env::var_os("PATH").unwrap_or_default(); + let shim_dir = shim_directory(); + let Some(codex) = find_real_codex(&path, shim_dir.as_deref()) else { + eprintln!("codex: command not found"); + return 127; + }; + + let mut command = Command::new(&codex); + command.env("PATH", path_without_shims(&path, shim_dir.as_deref())); + let mut launch_args = args.to_vec(); + if should_inject(args, |name| std::env::var_os(name)) { + if let Some((injected, helper)) = prepare_hooks(args) { + launch_args = injected; + command.env("CMUX_TUI_CODEX_WRAPPER_ACTIVE", "1"); + command.env("CMUX_TUI_HOOK", helper); + } + } + let _error = command.args(&launch_args).exec(); + eprintln!("codex: failed to start"); + 126 +} + +/// PATH for pane processes: the Codex shim directory first, then the server's +/// PATH. The Claude shim uses the same directory, so both are available from +/// one environment entry. +pub(crate) fn pane_path() -> Option { + let dir = shim_directory()?; + let executable = current_executable().ok()?; + install_shim(&dir, &executable).ok()?; + path_with_shim_first(&std::env::var_os("PATH").unwrap_or_default(), &dir)?.into_string().ok() +} + +fn shim_directory() -> Option { + agent_hook_install::runtime_cmux_tui_data_home().map(|home| home.join("shims")) +} + +fn current_executable() -> anyhow::Result { + let executable = std::env::current_exe().context("resolve the cmux-tui executable")?; + Ok(executable.canonicalize().unwrap_or(executable)) +} + +fn hook_helper(executable: &Path) -> Option { + agent_hook_install::runtime_helper_path() + .filter(|path| agent_hook_install::is_executable_file(path)) + .or_else(|| agent_hook_install::locate_helper_source(Some(executable))) + .filter(|path| path.is_absolute()) +} + +fn prepare_hooks(args: &[OsString]) -> Option<(Vec, PathBuf)> { + let executable = current_executable().ok()?; + let helper = hook_helper(&executable)?; + let socket = std::env::var_os("CMUX_TUI_SOCKET").filter(|value| !value.is_empty())?; + let terminal = std::env::var("CMUX_TUI_TERMINAL_ID").ok().filter(|value| !value.is_empty())?; + let socket = PathBuf::from(socket); + let mut launch = vec![OsString::from("--enable"), OsString::from("hooks")]; + launch.push(OsString::from("--dangerously-bypass-hook-trust")); + for event in agent_hook_install::CODEX_EVENTS { + launch.push(OsString::from("-c")); + launch.push(OsString::from(agent_hook_install::codex_contextual_hook_setting( + event, &socket, &terminal, &helper, + ))); + } + launch.extend_from_slice(args); + Some((launch, helper)) +} + +fn should_inject(args: &[OsString], getenv: impl Fn(&str) -> Option) -> bool { + let disabled = [HOOKS_DISABLED_ENV, "CMUX_CODEX_HOOKS_DISABLED"] + .into_iter() + .any(|name| getenv(name).is_some_and(|value| value == "1")); + if disabled || getenv("CMUX_TUI_CODEX_WRAPPER_ACTIVE").is_some_and(|value| value == "1") { + return false; + } + let Some(socket) = getenv("CMUX_TUI_SOCKET").filter(|value| !value.is_empty()) else { + return false; + }; + if !fs::metadata(socket).is_ok_and(|metadata| metadata.file_type().is_socket()) { + return false; + } + if getenv("CMUX_TUI_TERMINAL_ID").is_none_or(|value| value.is_empty()) { + return false; + } + !launch_classification::is_non_launch( + &args.iter().map(|arg| arg.to_string_lossy().into_owned()).collect::>(), + ) +} + +fn find_real_codex(path: &OsStr, shim_dir: Option<&Path>) -> Option { + std::env::split_paths(path) + .filter(|dir| !dir.as_os_str().is_empty() && !is_shim_directory(dir, shim_dir)) + .map(|dir| dir.join("codex")) + .find(|candidate| { + candidate.metadata().is_ok_and(|metadata| metadata.is_file()) + && current_process_can_execute(candidate) + && !is_codex_shim(candidate) + }) +} + +fn current_process_can_execute(path: &Path) -> bool { + let Ok(path) = std::ffi::CString::new(path.as_os_str().as_encoded_bytes()) else { + return false; + }; + // SAFETY: `path` is a live NUL-terminated CString for this call. + unsafe { libc::access(path.as_ptr(), libc::X_OK) == 0 } +} + +fn path_without_shims(path: &OsStr, shim_dir: Option<&Path>) -> OsString { + let kept = std::env::split_paths(path).filter(|dir| { + !dir.as_os_str().is_empty() + && !is_shim_directory(dir, shim_dir) + && !is_codex_shim(&dir.join("codex")) + }); + std::env::join_paths(kept).unwrap_or_else(|_| path.to_owned()) +} + +fn path_with_shim_first(path: &OsStr, shim_dir: &Path) -> Option { + let inherited = std::env::split_paths(path).filter(|dir| dir != shim_dir); + std::env::join_paths(std::iter::once(shim_dir.to_path_buf()).chain(inherited)).ok() +} + +fn is_shim_directory(dir: &Path, shim_dir: Option<&Path>) -> bool { + let Some(shim_dir) = shim_dir else { return false }; + dir == shim_dir + || matches!((dir.canonicalize(), shim_dir.canonicalize()), (Ok(dir), Ok(shim_dir)) if dir == shim_dir) +} + +fn is_codex_shim(path: &Path) -> bool { + fs::read(path) + .ok() + .is_some_and(|bytes| bytes.windows(SHIM_MARKER.len()).any(|window| window == SHIM_MARKER.as_bytes())) +} + +fn install_shim(dir: &Path, executable: &Path) -> anyhow::Result { + fs::create_dir_all(dir).context("create the shim directory")?; + fs::set_permissions(dir, fs::Permissions::from_mode(0o700))?; + let path = dir.join("codex"); + let script = shim_script(executable, dir)?; + let current = fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.is_file()) + && fs::read(&path).is_ok_and(|existing| existing == script.as_bytes()); + if current { + fs::set_permissions(&path, fs::Permissions::from_mode(0o700))?; + } else { + agent_hook_install::atomic_write(&path, script.as_bytes(), Some(0o700))?; + } + Ok(path) +} + +fn shim_script(executable: &Path, dir: &Path) -> anyhow::Result { + let executable = agent_hook_install::shell_quote(executable.to_str().context("cmux-tui path is not UTF-8")?); + let dir = agent_hook_install::shell_quote(dir.to_str().context("shim directory is not UTF-8")?); + Ok(format!( + "#!/bin/sh\n{SHIM_MARKER}\nif [ -x {executable} ]; then\n exec {executable} agent {VERB} \"$@\"\nfi\n\nset -f\nIFS=:\nkept=\nfor entry in $PATH; do\n [ \"$entry\" = {dir} ] || kept=\"${{kept:+$kept:}}$entry\"\ndone\nunset IFS\nset +f\nPATH=$kept\nexport PATH\nexec codex \"$@\"\n" + )) +} + +mod launch_classification { + const INFORMATIONAL: &[&str] = &["--help", "-h", "--version", "-V"]; + const MANAGEMENT: &[&str] = &[ + "apply", "app", "app-server", "archive", "completion", "debug", "delete", "doctor", + "exec-server", "features", "help", "login", "logout", "mcp", "mcp-server", "plugin", + "remote-control", "review", "sandbox", "unarchive", "update", + ]; + + pub(super) fn is_non_launch(args: &[String]) -> bool { + let mut expects_value = false; + for argument in args { + if expects_value { + expects_value = false; + continue; + } + if argument == "--" { + return false; + } + if argument.starts_with('-') { + if INFORMATIONAL.contains(&argument.as_str()) { + return true; + } + if matches!(argument.as_str(), "-c" | "--config" | "-m" | "--model" | "-p" | "--profile" | "-C" | "--cd" | "-s" | "--sandbox" | "--enable" | "--disable") && !argument.contains('=') { + expects_value = true; + } + continue; + } + return MANAGEMENT.contains(&argument.as_str()) && !matches!(argument.as_str(), "exec" | "resume" | "fork"); + } + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::net::UnixListener; + + fn os(args: &[&str]) -> Vec { + args.iter().map(OsString::from).collect() + } + + #[test] + fn codex_wrapper_injects_session_entrypoints_only() { + let root = tempfile::tempdir().unwrap(); + let socket = root.path().join("mux.sock"); + let _listener = UnixListener::bind(&socket).unwrap(); + let env = |args: &[(&str, &str)]| { + let socket = socket.clone(); + move |name: &str| -> Option { + if let Some((_, value)) = args.iter().find(|(key, _)| *key == name) { + return Some((*value).into()); + } + match name { + "CMUX_TUI_SOCKET" => Some(socket.clone().into_os_string()), + "CMUX_TUI_TERMINAL_ID" => Some("term_1".into()), + _ => None, + } + } + }; + assert!(!should_inject(&os(&["--version"]), env(&[]))); + assert!(!should_inject(&os(&["mcp", "list"]), env(&[]))); + assert!(should_inject(&os(&[]), env(&[]))); + assert!(should_inject(&os(&["exec", "hello"]), env(&[]))); + assert!(should_inject(&os(&["resume", "--last"]), env(&[]))); + assert!(!should_inject(&os(&[]), env(&[(HOOKS_DISABLED_ENV, "1")])), "opt-out"); + } + + #[test] + fn codex_wrapper_shim_selects_only_the_hidden_verb() { + let args = os(&["agent", "codex-wrapper", "exec"]); + assert_eq!(invocation(&args), Some(&args[2..])); + assert_eq!(invocation(&os(&["agent", "list"])), None); + } +} diff --git a/cmux-tui/crates/cmux-tui/src/main.rs b/cmux-tui/crates/cmux-tui/src/main.rs index 5f1ca53a2bdd..a917fc16f85c 100644 --- a/cmux-tui/crates/cmux-tui/src/main.rs +++ b/cmux-tui/crates/cmux-tui/src/main.rs @@ -13,6 +13,8 @@ mod app; mod browser_input; #[cfg(unix)] mod claude_wrapper; +#[cfg(unix)] +mod codex_wrapper; mod cli; mod client_log; #[cfg(unix)] @@ -1632,6 +1634,9 @@ fn run_main() { if let Some(wrapper_args) = claude_wrapper::invocation(&args) { client_log::exit(claude_wrapper::run(wrapper_args)); } + if let Some(wrapper_args) = codex_wrapper::invocation(&args) { + client_log::exit(codex_wrapper::run(wrapper_args)); + } } // Pin the launch directory before any subsystem can move the process: // new terminals default to it (not $HOME) for the daemon's lifetime. @@ -2157,6 +2162,12 @@ fn run_server( if let Some(path) = claude_wrapper::pane_path() { surface_options.extra_env.push(("PATH".into(), path)); } + #[cfg(unix)] + if let Some(path) = codex_wrapper::pane_path() { + if !surface_options.extra_env.iter().any(|(name, value)| name == "PATH" && value == &path) { + surface_options.extra_env.push(("PATH".into(), path)); + } + } let state_root = if args.ephemeral { None From adf4dd81a1a659c11054496366c192c2016880c1 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:08:05 -0700 Subject: [PATCH 3/6] style(cmux-tui): format Codex wrapper --- cmux-tui/crates/cmux-tui/src/codex_wrapper.rs | 52 +++++++++++++++---- cmux-tui/crates/cmux-tui/src/main.rs | 4 +- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/codex_wrapper.rs b/cmux-tui/crates/cmux-tui/src/codex_wrapper.rs index 6d399cb89e2e..d870f0283431 100644 --- a/cmux-tui/crates/cmux-tui/src/codex_wrapper.rs +++ b/cmux-tui/crates/cmux-tui/src/codex_wrapper.rs @@ -161,9 +161,9 @@ fn is_shim_directory(dir: &Path, shim_dir: Option<&Path>) -> bool { } fn is_codex_shim(path: &Path) -> bool { - fs::read(path) - .ok() - .is_some_and(|bytes| bytes.windows(SHIM_MARKER.len()).any(|window| window == SHIM_MARKER.as_bytes())) + fs::read(path).ok().is_some_and(|bytes| { + bytes.windows(SHIM_MARKER.len()).any(|window| window == SHIM_MARKER.as_bytes()) + }) } fn install_shim(dir: &Path, executable: &Path) -> anyhow::Result { @@ -182,7 +182,8 @@ fn install_shim(dir: &Path, executable: &Path) -> anyhow::Result { } fn shim_script(executable: &Path, dir: &Path) -> anyhow::Result { - let executable = agent_hook_install::shell_quote(executable.to_str().context("cmux-tui path is not UTF-8")?); + let executable = + agent_hook_install::shell_quote(executable.to_str().context("cmux-tui path is not UTF-8")?); let dir = agent_hook_install::shell_quote(dir.to_str().context("shim directory is not UTF-8")?); Ok(format!( "#!/bin/sh\n{SHIM_MARKER}\nif [ -x {executable} ]; then\n exec {executable} agent {VERB} \"$@\"\nfi\n\nset -f\nIFS=:\nkept=\nfor entry in $PATH; do\n [ \"$entry\" = {dir} ] || kept=\"${{kept:+$kept:}}$entry\"\ndone\nunset IFS\nset +f\nPATH=$kept\nexport PATH\nexec codex \"$@\"\n" @@ -192,9 +193,27 @@ fn shim_script(executable: &Path, dir: &Path) -> anyhow::Result { mod launch_classification { const INFORMATIONAL: &[&str] = &["--help", "-h", "--version", "-V"]; const MANAGEMENT: &[&str] = &[ - "apply", "app", "app-server", "archive", "completion", "debug", "delete", "doctor", - "exec-server", "features", "help", "login", "logout", "mcp", "mcp-server", "plugin", - "remote-control", "review", "sandbox", "unarchive", "update", + "apply", + "app", + "app-server", + "archive", + "completion", + "debug", + "delete", + "doctor", + "exec-server", + "features", + "help", + "login", + "logout", + "mcp", + "mcp-server", + "plugin", + "remote-control", + "review", + "sandbox", + "unarchive", + "update", ]; pub(super) fn is_non_launch(args: &[String]) -> bool { @@ -211,12 +230,27 @@ mod launch_classification { if INFORMATIONAL.contains(&argument.as_str()) { return true; } - if matches!(argument.as_str(), "-c" | "--config" | "-m" | "--model" | "-p" | "--profile" | "-C" | "--cd" | "-s" | "--sandbox" | "--enable" | "--disable") && !argument.contains('=') { + if matches!( + argument.as_str(), + "-c" | "--config" + | "-m" + | "--model" + | "-p" + | "--profile" + | "-C" + | "--cd" + | "-s" + | "--sandbox" + | "--enable" + | "--disable" + ) && !argument.contains('=') + { expects_value = true; } continue; } - return MANAGEMENT.contains(&argument.as_str()) && !matches!(argument.as_str(), "exec" | "resume" | "fork"); + return MANAGEMENT.contains(&argument.as_str()) + && !matches!(argument.as_str(), "exec" | "resume" | "fork"); } false } diff --git a/cmux-tui/crates/cmux-tui/src/main.rs b/cmux-tui/crates/cmux-tui/src/main.rs index a917fc16f85c..97e4d2b50d81 100644 --- a/cmux-tui/crates/cmux-tui/src/main.rs +++ b/cmux-tui/crates/cmux-tui/src/main.rs @@ -13,12 +13,12 @@ mod app; mod browser_input; #[cfg(unix)] mod claude_wrapper; -#[cfg(unix)] -mod codex_wrapper; mod cli; mod client_log; #[cfg(unix)] mod coderouter_usage; +#[cfg(unix)] +mod codex_wrapper; mod config; // The agent hook helper, also built as the standalone `cmux-tui-hook`. #[path = "bin/cmux-tui-hook.rs"] From 4f2d04e4bfa2a1577b09828d711049f0b5e15226 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:39:51 -0700 Subject: [PATCH 4/6] fix(cmux-tui): bind Codex hooks to the launching terminal --- .../crates/cmux-tui/src/agent_hook_install.rs | 83 ++++--- .../crates/cmux-tui/src/bin/cmux-tui-hook.rs | 2 +- cmux-tui/crates/cmux-tui/src/codex_wrapper.rs | 230 ++++++++++++------ .../crates/cmux-tui/tests/codex_wrapper.rs | 221 +++++++++++++++++ cmux-tui/docs/agent-hooks.md | 20 ++ 5 files changed, 451 insertions(+), 105 deletions(-) create mode 100644 cmux-tui/crates/cmux-tui/tests/codex_wrapper.rs diff --git a/cmux-tui/crates/cmux-tui/src/agent_hook_install.rs b/cmux-tui/crates/cmux-tui/src/agent_hook_install.rs index 7b1b2ef19170..2b1cafbe40fe 100644 --- a/cmux-tui/crates/cmux-tui/src/agent_hook_install.rs +++ b/cmux-tui/crates/cmux-tui/src/agent_hook_install.rs @@ -91,7 +91,7 @@ fn hermes_reaper_spawn_should_fail() -> bool { false } -pub(crate) const CODEX_EVENTS: &[&str] = &[ +const CODEX_EVENTS: &[&str] = &[ "SessionStart", "UserPromptSubmit", "Stop", @@ -1917,41 +1917,68 @@ fn hook_command(provider: &str, event: &str) -> String { ) } -/// The Codex wrapper's per-launch hook command. Codex's shared app-server can -/// outlive the terminal that started it and does not preserve that terminal's -/// environment when it starts hook processes, so the routing context must be -/// part of the command itself. +/// Codex's shared app-server may run hooks without the launching terminal's +/// environment. Carry that route in the command, using the embedded helper +/// so hosts deployed with only the cmux-tui binary work too. #[cfg(unix)] -pub(crate) fn codex_contextual_hook_command( +fn codex_contextual_hook_command( event: &str, socket: &Path, terminal: &str, - helper: &Path, + launcher: &Path, ) -> String { format!( - "CMUX_TUI_SOCKET={} CMUX_TUI_TERMINAL_ID={} CMUX_TUI_HOOK={} {} {} {} 2>/dev/null||:;echo {{}};#{COMMAND_MARKER}", + "CMUX_TUI_SOCKET={} CMUX_TUI_TERMINAL_ID={} {} {HOOK_MODE_ARG} codex {} 2>/dev/null||:;echo {{}};#{COMMAND_MARKER}", shell_quote(&socket.to_string_lossy()), shell_quote(terminal), - shell_quote(&helper.to_string_lossy()), - shell_quote(&helper.to_string_lossy()), - shell_quote("codex"), + shell_quote(&launcher.to_string_lossy()), shell_quote(event), ) } -/// Encodes one contextual cmux hook as a Codex `-c` value. +/// Session flags have their own config layer in Codex 0.162. Trust only the +/// commands generated here, leaving all other hooks' trust decisions intact. +/// Codex appends hooks from lower layers, so disable the installed cmux copies +/// for this invocation instead of sending each event twice. #[cfg(unix)] -pub(crate) fn codex_contextual_hook_setting( - event: &str, +pub(crate) fn codex_session_hook_settings( socket: &Path, terminal: &str, - helper: &Path, -) -> String { - let command = codex_contextual_hook_command(event, socket, terminal, helper); - format!( - "hooks.{event}=[{{hooks=[{{type=\"command\",command='''{command}''',timeout={}}}]}}]", - codex_hook_timeout(event) - ) + launcher: &Path, +) -> anyhow::Result> { + let context = Context::runtime()?; + codex_session_hook_settings_with_context(socket, terminal, launcher, &context) +} + +#[cfg(unix)] +fn codex_session_hook_settings_with_context( + socket: &Path, + terminal: &str, + launcher: &Path, + context: &Context, +) -> anyhow::Result> { + let mut settings = Vec::new(); + let provider = PROVIDERS.iter().copied().find(|provider| provider.id == "codex").unwrap(); + let path = context.provider_path(provider); + let root = read_json_object(&path)?; + let key_path = + codex_state_key_hooks_path(&path, context.provider_path_from_env_override(provider)); + for key in codex_owned_entry_keys(&key_path, &root) { + settings.push(format!("hooks.state.{}.enabled=false", toml_edit::Key::new(key))); + } + for event in CODEX_EVENTS { + let command = codex_contextual_hook_command(event, socket, terminal, launcher); + let timeout = codex_hook_timeout(event); + let label = codex_event_state_label(event)?; + let hash = codex_trust_hash(label, &command, timeout); + settings.push(format!( + "hooks.{event}=[{{hooks=[{{type=\"command\",command={},timeout={timeout}}}]}}]", + toml_edit::Value::from(command), + )); + let key = toml_edit::Key::new(format!("//config.toml:{label}:0:0")); + settings.push(format!("hooks.state.{key}.trusted_hash={}", toml_edit::Value::from(hash))); + } + Ok(settings) } /// The command shape before the tmux fallback. Its codex trust hashes stay @@ -3810,20 +3837,6 @@ esac assert_eq!(fs::read_to_string(&capture).unwrap(), "codex Stop\n"); } - #[cfg(unix)] - #[test] - fn codex_contextual_hook_command_survives_a_shared_daemon() { - let command = codex_contextual_hook_command( - "Stop", - Path::new("/run/cmux-tui.sock"), - "terminal-1", - Path::new("/home/user/.local/share/cmux-tui/bin/cmux-tui-hook"), - ); - assert!(command.contains("CMUX_TUI_SOCKET='/run/cmux-tui.sock'"), "{command}"); - assert!(command.contains("CMUX_TUI_TERMINAL_ID='terminal-1'"), "{command}"); - assert!(command.contains("cmux-tui-hook 'codex' 'Stop'"), "{command}"); - } - #[test] fn every_command_hook_fits_in_two_hundred_bytes() { for provider in PROVIDERS { diff --git a/cmux-tui/crates/cmux-tui/src/bin/cmux-tui-hook.rs b/cmux-tui/crates/cmux-tui/src/bin/cmux-tui-hook.rs index 5c81e85b62ff..e563a32101c4 100644 --- a/cmux-tui/crates/cmux-tui/src/bin/cmux-tui-hook.rs +++ b/cmux-tui/crates/cmux-tui/src/bin/cmux-tui-hook.rs @@ -114,7 +114,7 @@ fn run(args: Args, exe_prefix: &[&str]) -> anyhow::Result<()> { /// own `CMUX_TUI_*` values, or, for an agent in a tmux session started /// outside cmux-tui (which has none), the cmux-tui terminal attached to the /// pane's tmux session. -fn session_route() -> Option<(PathBuf, Option)> { +pub(crate) fn session_route() -> Option<(PathBuf, Option)> { if let Some(socket) = env::var_os("CMUX_TUI_SOCKET").filter(|value| !value.is_empty()) { let terminal = env::var("CMUX_TUI_TERMINAL_ID").ok().filter(|value| !value.is_empty()); return Some((PathBuf::from(socket), terminal)); diff --git a/cmux-tui/crates/cmux-tui/src/codex_wrapper.rs b/cmux-tui/crates/cmux-tui/src/codex_wrapper.rs index d870f0283431..b4c2c693d11d 100644 --- a/cmux-tui/crates/cmux-tui/src/codex_wrapper.rs +++ b/cmux-tui/crates/cmux-tui/src/codex_wrapper.rs @@ -9,7 +9,8 @@ use std::ffi::{OsStr, OsString}; use std::fs; -use std::os::unix::fs::PermissionsExt as _; +use std::io::Read as _; +use std::os::unix::fs::{FileTypeExt as _, PermissionsExt as _}; use std::os::unix::process::CommandExt as _; use std::path::{Path, PathBuf}; use std::process::Command; @@ -34,10 +35,11 @@ pub(crate) fn invocation(args: &[OsString]) -> Option<&[OsString]> { /// Execs the real Codex. If the launch is not a session or the terminal has /// no live cmux socket, the original argv is passed through unchanged. pub(crate) fn run(args: &[OsString]) -> i32 { + let messages = &crate::localization::catalog().agent_wrapper; let path = std::env::var_os("PATH").unwrap_or_default(); let shim_dir = shim_directory(); let Some(codex) = find_real_codex(&path, shim_dir.as_deref()) else { - eprintln!("codex: command not found"); + eprintln!("{}", messages.agent_not_found); return 127; }; @@ -45,14 +47,17 @@ pub(crate) fn run(args: &[OsString]) -> i32 { command.env("PATH", path_without_shims(&path, shim_dir.as_deref())); let mut launch_args = args.to_vec(); if should_inject(args, |name| std::env::var_os(name)) { - if let Some((injected, helper)) = prepare_hooks(args) { - launch_args = injected; - command.env("CMUX_TUI_CODEX_WRAPPER_ACTIVE", "1"); - command.env("CMUX_TUI_HOOK", helper); + match prepare_hooks(args) { + Ok(Some(injected)) => { + launch_args = injected; + command.env("CMUX_TUI_CODEX_WRAPPER_ACTIVE", "1"); + } + Ok(None) => {} + Err(_) => eprintln!("{}", messages.hooks_unavailable), } } let _error = command.args(&launch_args).exec(); - eprintln!("codex: failed to start"); + eprintln!("{}", messages.agent_start_failed); 126 } @@ -75,29 +80,22 @@ fn current_executable() -> anyhow::Result { Ok(executable.canonicalize().unwrap_or(executable)) } -fn hook_helper(executable: &Path) -> Option { - agent_hook_install::runtime_helper_path() - .filter(|path| agent_hook_install::is_executable_file(path)) - .or_else(|| agent_hook_install::locate_helper_source(Some(executable))) - .filter(|path| path.is_absolute()) -} - -fn prepare_hooks(args: &[OsString]) -> Option<(Vec, PathBuf)> { - let executable = current_executable().ok()?; - let helper = hook_helper(&executable)?; - let socket = std::env::var_os("CMUX_TUI_SOCKET").filter(|value| !value.is_empty())?; - let terminal = std::env::var("CMUX_TUI_TERMINAL_ID").ok().filter(|value| !value.is_empty())?; - let socket = PathBuf::from(socket); +fn prepare_hooks(args: &[OsString]) -> anyhow::Result>> { + let Some((socket, Some(terminal))) = crate::hook_helper::session_route() else { + return Ok(None); + }; + if !fs::metadata(&socket).is_ok_and(|metadata| metadata.file_type().is_socket()) { + return Ok(None); + } + let executable = current_executable()?; let mut launch = vec![OsString::from("--enable"), OsString::from("hooks")]; - launch.push(OsString::from("--dangerously-bypass-hook-trust")); - for event in agent_hook_install::CODEX_EVENTS { + for setting in agent_hook_install::codex_session_hook_settings(&socket, &terminal, &executable)? + { launch.push(OsString::from("-c")); - launch.push(OsString::from(agent_hook_install::codex_contextual_hook_setting( - event, &socket, &terminal, &helper, - ))); + launch.push(OsString::from(setting)); } - launch.extend_from_slice(args); - Some((launch, helper)) + launch.extend(hoist_session_global_args(args)); + Ok(Some(launch)) } fn should_inject(args: &[OsString], getenv: impl Fn(&str) -> Option) -> bool { @@ -107,15 +105,6 @@ fn should_inject(args: &[OsString], getenv: impl Fn(&str) -> Option) - if disabled || getenv("CMUX_TUI_CODEX_WRAPPER_ACTIVE").is_some_and(|value| value == "1") { return false; } - let Some(socket) = getenv("CMUX_TUI_SOCKET").filter(|value| !value.is_empty()) else { - return false; - }; - if !fs::metadata(socket).is_ok_and(|metadata| metadata.file_type().is_socket()) { - return false; - } - if getenv("CMUX_TUI_TERMINAL_ID").is_none_or(|value| value.is_empty()) { - return false; - } !launch_classification::is_non_launch( &args.iter().map(|arg| arg.to_string_lossy().into_owned()).collect::>(), ) @@ -161,9 +150,15 @@ fn is_shim_directory(dir: &Path, shim_dir: Option<&Path>) -> bool { } fn is_codex_shim(path: &Path) -> bool { - fs::read(path).ok().is_some_and(|bytes| { - bytes.windows(SHIM_MARKER.len()).any(|window| window == SHIM_MARKER.as_bytes()) - }) + fs::File::open(path) + .ok() + .and_then(|file| { + let mut bytes = Vec::new(); + file.take(4096).read_to_end(&mut bytes).ok().map(|_| bytes) + }) + .is_some_and(|bytes| { + bytes.windows(SHIM_MARKER.len()).any(|window| window == SHIM_MARKER.as_bytes()) + }) } fn install_shim(dir: &Path, executable: &Path) -> anyhow::Result { @@ -190,13 +185,69 @@ fn shim_script(executable: &Path, dir: &Path) -> anyhow::Result { )) } +/// Codex's clap globals replace the root list when repeated after a +/// subcommand. Move session -c/--config and feature flags into the root list +/// so the wrapper's hooks survive `codex exec -c ...` and resume/fork. +fn hoist_session_global_args(args: &[OsString]) -> Vec { + let mut index = 0; + while index < args.len() { + let arg = args[index].to_string_lossy(); + if arg == "--" { + return args.to_vec(); + } + if arg.starts_with('-') { + index += if launch_classification::consumes_value(&arg) { 2 } else { 1 }; + } else if matches!(arg.as_ref(), "exec" | "e" | "resume" | "fork") { + break; + } else { + return args.to_vec(); + } + } + if index >= args.len() { + return args.to_vec(); + } + let mut head = args[..index].to_vec(); + let mut tail = vec![args[index].clone()]; + index += 1; + while index < args.len() { + let arg = args[index].to_string_lossy(); + if arg == "--" { + tail.extend_from_slice(&args[index..]); + break; + } + let separate = matches!(arg.as_ref(), "-c" | "--config" | "--enable" | "--disable"); + let inline = + ["--config=", "--enable=", "--disable="].iter().any(|prefix| arg.starts_with(prefix)) + || (arg.starts_with("-c") && arg.len() > 2); + if separate && index + 1 < args.len() { + head.extend_from_slice(&args[index..index + 2]); + index += 2; + } else if inline { + head.push(args[index].clone()); + index += 1; + } else { + let count = if launch_classification::consumes_value(&arg) && index + 1 < args.len() { + 2 + } else { + 1 + }; + tail.extend_from_slice(&args[index..index + count]); + index += count; + } + } + head.extend(tail); + head +} + mod launch_classification { const INFORMATIONAL: &[&str] = &["--help", "-h", "--version", "-V"]; const MANAGEMENT: &[&str] = &[ + "a", "apply", "app", "app-server", "archive", + "cloud", "completion", "debug", "delete", @@ -216,6 +267,34 @@ mod launch_classification { "update", ]; + pub(super) fn consumes_value(argument: &str) -> bool { + matches!( + argument, + "-c" | "--config" + | "-m" + | "--model" + | "-p" + | "--profile" + | "-C" + | "--cd" + | "--remote" + | "-a" + | "--ask-for-approval" + | "-s" + | "--sandbox" + | "--output-last-message" + | "--enable" + | "--disable" + | "--add-dir" + | "-i" + | "--image" + | "--local-provider" + | "--remote-auth-token-env" + | "--output-schema" + | "--color" + ) + } + pub(super) fn is_non_launch(args: &[String]) -> bool { let mut expects_value = false; for argument in args { @@ -230,21 +309,7 @@ mod launch_classification { if INFORMATIONAL.contains(&argument.as_str()) { return true; } - if matches!( - argument.as_str(), - "-c" | "--config" - | "-m" - | "--model" - | "-p" - | "--profile" - | "-C" - | "--cd" - | "-s" - | "--sandbox" - | "--enable" - | "--disable" - ) && !argument.contains('=') - { + if consumes_value(argument) { expects_value = true; } continue; @@ -259,7 +324,6 @@ mod launch_classification { #[cfg(test)] mod tests { use super::*; - use std::os::unix::net::UnixListener; fn os(args: &[&str]) -> Vec { args.iter().map(OsString::from).collect() @@ -267,20 +331,9 @@ mod tests { #[test] fn codex_wrapper_injects_session_entrypoints_only() { - let root = tempfile::tempdir().unwrap(); - let socket = root.path().join("mux.sock"); - let _listener = UnixListener::bind(&socket).unwrap(); - let env = |args: &[(&str, &str)]| { - let socket = socket.clone(); - move |name: &str| -> Option { - if let Some((_, value)) = args.iter().find(|(key, _)| *key == name) { - return Some((*value).into()); - } - match name { - "CMUX_TUI_SOCKET" => Some(socket.clone().into_os_string()), - "CMUX_TUI_TERMINAL_ID" => Some("term_1".into()), - _ => None, - } + let env = |overrides: &'static [(&'static str, &'static str)]| { + move |name: &str| { + overrides.iter().find(|(key, _)| *key == name).map(|(_, value)| (*value).into()) } }; assert!(!should_inject(&os(&["--version"]), env(&[]))); @@ -291,6 +344,45 @@ mod tests { assert!(!should_inject(&os(&[]), env(&[(HOOKS_DISABLED_ENV, "1")])), "opt-out"); } + #[test] + fn codex_wrapper_preserves_session_config_and_prompt_arguments() { + assert_eq!( + hoist_session_global_args(&os(&[ + "-c", + "model=\"root\"", + "exec", + "--image", + "--config=picture", + "--enable", + "foo", + "-cmodel=\"child\"", + "--", + "-c", + "prompt" + ])), + os(&[ + "-c", + "model=\"root\"", + "--enable", + "foo", + "-cmodel=\"child\"", + "exec", + "--image", + "--config=picture", + "--", + "-c", + "prompt" + ]), + ); + for args in [ + os(&["a", "diff"]), + os(&["--", "exec", "-c", "prompt"]), + os(&["a prompt", "--config=x"]), + ] { + assert_eq!(hoist_session_global_args(&args), args); + } + } + #[test] fn codex_wrapper_shim_selects_only_the_hidden_verb() { let args = os(&["agent", "codex-wrapper", "exec"]); diff --git a/cmux-tui/crates/cmux-tui/tests/codex_wrapper.rs b/cmux-tui/crates/cmux-tui/tests/codex_wrapper.rs new file mode 100644 index 000000000000..07fa1d932b0e --- /dev/null +++ b/cmux-tui/crates/cmux-tui/tests/codex_wrapper.rs @@ -0,0 +1,221 @@ +//! Exercise the SSH launch boundary without depending on Codex credentials: +//! capture the real wrapper's argv, then run its hooks as a shared daemon would. +#![cfg(unix)] + +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::fs::PermissionsExt as _; +use std::os::unix::net::UnixListener; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +const TERMINAL_ONE: &str = "term_00000000000000000000000000000001"; +const TERMINAL_TWO: &str = "term_00000000000000000000000000000002"; + +struct Launch { + root: tempfile::TempDir, + socket: PathBuf, + listener: UnixListener, +} + +impl Launch { + fn new() -> Self { + // macOS's socket path limit is shorter than its default temp directory. + let root = tempfile::tempdir_in("/tmp").unwrap(); + let socket = root.path().join("mux '''.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + listener.set_nonblocking(true).unwrap(); + let bin = root.path().join("bin"); + fs::create_dir(&bin).unwrap(); + fs::write(bin.join("codex"), "#!/bin/sh\nprintf '%s\\0' \"$@\"\n").unwrap(); + fs::set_permissions(bin.join("codex"), fs::Permissions::from_mode(0o700)).unwrap(); + Self { root, socket, listener } + } + + fn command(&self) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_cmux-tui")); + command.args(["agent", "codex-wrapper"]).env_clear(); + command.env("HOME", self.root.path()); + command.env("XDG_DATA_HOME", self.root.path().join("data")); + command.env("PATH", format!("{}:/usr/bin:/bin", self.root.path().join("bin").display())); + command.env("CMUX_TUI_SOCKET", &self.socket); + command.env("CMUX_TUI_TERMINAL_ID", TERMINAL_ONE); + command + } + + fn argv(&self, command: &mut Command) -> Vec { + let output = command.output().unwrap(); + assert!(output.status.success(), "wrapper failed: {output:?}"); + assert!(output.stderr.is_empty(), "{}", String::from_utf8_lossy(&output.stderr)); + output + .stdout + .strip_suffix(&[0]) + .unwrap_or(&output.stdout) + .split(|byte| *byte == 0) + .map(|part| String::from_utf8(part.to_vec()).unwrap()) + .collect() + } + + fn event(&self) -> serde_json::Value { + let deadline = Instant::now() + Duration::from_secs(5); + let mut stream = loop { + match self.listener.accept() { + Ok((stream, _)) => break stream, + Err(error) + if error.kind() == std::io::ErrorKind::WouldBlock + && Instant::now() < deadline => + { + std::thread::sleep(Duration::from_millis(10)); + } + result => panic!("hook must reach this session: {result:?}"), + } + }; + stream.set_read_timeout(Some(Duration::from_secs(3))).unwrap(); + let mut line = String::new(); + BufReader::new(&stream).read_line(&mut line).unwrap(); + let request: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["operation"], "session.journal.append"); + let response = serde_json::json!({ + "protocol":"cmux.protocol/2", "type":"response", "id":request["id"], + "ok":true, "result":{"value":{"sequence":"1"}} + }); + writeln!(stream, "{response}").unwrap(); + request["params"]["event"].clone() + } +} + +fn settings(argv: &[String]) -> toml::Value { + let text = argv + .windows(2) + .filter(|pair| pair[0] == "-c") + .map(|pair| pair[1].as_str()) + .collect::>() + .join("\n"); + toml::from_str(&text).unwrap() +} + +fn hook_command(config: &toml::Value, event: &str) -> String { + config + .get("hooks") + .and_then(|hooks| hooks.get(event)) + .and_then(|groups| groups.get(0)) + .and_then(|group| group.get("hooks")) + .and_then(|handlers| handlers.get(0)) + .and_then(|handler| handler.get("command")) + .and_then(toml::Value::as_str) + .unwrap_or_else(|| panic!("launch must supply a contextual {event} hook: {config}")) + .to_owned() +} + +fn run_hook(command: &str, stale_socket: Option<&Path>, session: &str) { + let mut process = Command::new("/bin/sh"); + process.args(["-c", command]).env_clear(); + if let Some(socket) = stale_socket { + process + .env("CMUX_TUI_SOCKET", socket) + .env("CMUX_TUI_TERMINAL_ID", "term_wrong") + .env("CMUX_TUI_HOOK", "/missing/old-helper"); + } + let mut child = process + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + writeln!(child.stdin.take().unwrap(), "{{\"session_id\":\"{session}\"}}").unwrap(); + let output = child.wait_with_output().unwrap(); + assert!(output.status.success(), "{output:?}"); + assert_eq!(output.stdout, b"{}\n"); +} + +#[test] +fn codex_wrapper_remote_hooks_keep_each_terminal_under_a_shared_daemon_environment() { + let launch = Launch::new(); + let stale = Launch::new(); + for (terminal, stale_socket) in + [(TERMINAL_ONE, None), (TERMINAL_TWO, Some(stale.socket.as_path()))] + { + let argv = launch + .argv(launch.command().env("CMUX_TUI_TERMINAL_ID", terminal).args(["exec", "hello"])); + let config = settings(&argv); + assert!(!argv.iter().any(|arg| arg == "--dangerously-bypass-hook-trust")); + for event in ["SessionStart", "UserPromptSubmit", "Stop", "SessionEnd"] { + run_hook(&hook_command(&config, event), stale_socket, terminal); + let received = launch.event(); + assert_eq!(received["payload"]["native_event"], event); + assert!( + received["subjects"] + .as_array() + .unwrap() + .iter() + .any(|subject| { subject["kind"] == "terminal" && subject["id"] == terminal }), + "{received}" + ); + } + } + assert_eq!(stale.listener.accept().unwrap_err().kind(), std::io::ErrorKind::WouldBlock); +} + +#[test] +fn codex_wrapper_passes_through_management_opt_out_and_missing_routes() { + let launch = Launch::new(); + for args in [["--version", ""], ["app-server", "--help"]] { + let args: Vec<_> = args.into_iter().filter(|arg| !arg.is_empty()).collect(); + assert_eq!(launch.argv(launch.command().args(&args)), args); + } + for env in [ + "CMUX_TUI_CODEX_HOOKS_DISABLED", + "CMUX_CODEX_HOOKS_DISABLED", + "CMUX_TUI_CODEX_WRAPPER_ACTIVE", + ] { + assert_eq!( + launch.argv(launch.command().env(env, "1").args(["exec", "hello"])), + ["exec", "hello"] + ); + } + assert_eq!( + launch.argv(launch.command().env_remove("CMUX_TUI_SOCKET").args(["exec", "hello"])), + ["exec", "hello"] + ); + assert_eq!( + launch.argv( + launch.command().env("CMUX_TUI_SOCKET", "/missing/socket").args(["exec", "hello"]) + ), + ["exec", "hello"] + ); +} + +#[test] +fn codex_wrapper_scopes_trust_and_suppresses_only_installed_cmux_hooks() { + let launch = Launch::new(); + let home = launch.root.path().join("codex ''' home"); + fs::create_dir(&home).unwrap(); + let hooks_path = home.join("hooks.json"); + let contents = serde_json::json!({"hooks":{"Stop":[{"hooks":[ + {"type":"command","command":"echo user-hook"}, + {"type":"command","command":"echo {};#cmux-tui-journal-hook"} + ]}]}}) + .to_string(); + fs::write(&hooks_path, &contents).unwrap(); + let argv = launch.argv(launch.command().env("CODEX_HOME", &home).args([ + "resume", + "--last", + "-c", + "model=\"test\"", + ])); + let config = settings(&argv); + let state = config["hooks"]["state"].as_table().unwrap(); + let installed_key = format!("{}:stop:0:1", hooks_path.canonicalize().unwrap().display()); + assert_eq!(state[&installed_key]["enabled"].as_bool(), Some(false)); + assert!( + !state.contains_key(&format!("{}:stop:0:0", hooks_path.canonicalize().unwrap().display())) + ); + let trust = &state["//config.toml:stop:0:0"]["trusted_hash"]; + assert!(trust.as_str().unwrap().starts_with("sha256:")); + assert_eq!(config["model"].as_str(), Some("test")); + let resume = argv.iter().position(|arg| arg == "resume").unwrap(); + assert_eq!(&argv[resume..], ["resume", "--last"]); + assert_eq!(fs::read_to_string(hooks_path).unwrap(), contents); + assert!(!home.join("config.toml").exists(), "per-launch trust must not edit persistent config"); +} diff --git a/cmux-tui/docs/agent-hooks.md b/cmux-tui/docs/agent-hooks.md index fbc837b86f04..b84a4cc151ed 100644 --- a/cmux-tui/docs/agent-hooks.md +++ b/cmux-tui/docs/agent-hooks.md @@ -78,6 +78,26 @@ beside the cmux-tui binary), its hooks call `cmux-tui agent hook emit` instead. If `CMUX_TUI_HOOK` still names a working helper in that case, the installed hooks run as well and each event is delivered twice. +## Codex without an install + +Codex started through the `codex` shim gets the same journal hooks without a +Codex config edit. The shim runs `cmux-tui agent codex-wrapper`, which passes +the socket and terminal id inside every hook command. This is required when +Codex reuses an app-server: the daemon can start a hook after the original +shell has gone away, so it cannot inherit that shell's `CMUX_TUI_SOCKET`. +The launch flags trust only these generated session hooks and disable the +installed cmux copies for that invocation, leaving user hooks and their trust +settings unchanged. `exec`, `resume`, and `fork` sessions are covered; +management commands and informational flags pass through. + +The wrapper also works in an SSH workspace where the host received the +standalone cmux-tui binary. If Codex is launched in a tmux pane without cmux +variables, the installed hook helper keeps using the existing Linux tmux +client route described above. `ssh-tmux` mirrored workspaces use tmux's +control transport rather than the cmux-tui journal and currently have no +agent-status subscription, so this wrapper does not add badges to those +mirrors. + ### Shell startup files can bypass the shim The shim only works while its directory stays ahead of the real `claude` on From 46ae642874a92f2d23ccfe3a0c70a2a47b5d650f Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:52:06 -0700 Subject: [PATCH 5/6] fix(cmux-tui): satisfy Codex wrapper lint --- cmux-tui/crates/cmux-tui/src/codex_wrapper.rs | 1 + cmux-tui/crates/cmux-tui/src/main.rs | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/codex_wrapper.rs b/cmux-tui/crates/cmux-tui/src/codex_wrapper.rs index b4c2c693d11d..cf9718717976 100644 --- a/cmux-tui/crates/cmux-tui/src/codex_wrapper.rs +++ b/cmux-tui/crates/cmux-tui/src/codex_wrapper.rs @@ -10,6 +10,7 @@ use std::ffi::{OsStr, OsString}; use std::fs; use std::io::Read as _; +use std::os::unix::ffi::OsStrExt as _; use std::os::unix::fs::{FileTypeExt as _, PermissionsExt as _}; use std::os::unix::process::CommandExt as _; use std::path::{Path, PathBuf}; diff --git a/cmux-tui/crates/cmux-tui/src/main.rs b/cmux-tui/crates/cmux-tui/src/main.rs index 97e4d2b50d81..63e5bbe585cd 100644 --- a/cmux-tui/crates/cmux-tui/src/main.rs +++ b/cmux-tui/crates/cmux-tui/src/main.rs @@ -2163,10 +2163,10 @@ fn run_server( surface_options.extra_env.push(("PATH".into(), path)); } #[cfg(unix)] - if let Some(path) = codex_wrapper::pane_path() { - if !surface_options.extra_env.iter().any(|(name, value)| name == "PATH" && value == &path) { - surface_options.extra_env.push(("PATH".into(), path)); - } + if let Some(path) = codex_wrapper::pane_path() + && !surface_options.extra_env.iter().any(|(name, value)| name == "PATH" && value == &path) + { + surface_options.extra_env.push(("PATH".into(), path)); } let state_root = if args.ephemeral { From 48bf4b4cbd272051314c5d7d42b46b054b822615 Mon Sep 17 00:00:00 2001 From: Austin Wang <38676809+austinywang@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:56:33 -0700 Subject: [PATCH 6/6] docs(cmux-tui): describe Codex and tmux status boundaries --- cmux-tui/crates/cmux-tui/src/codex_wrapper.rs | 4 +--- cmux-tui/docs/agent-hooks.md | 23 ++++++++++++------- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/cmux-tui/crates/cmux-tui/src/codex_wrapper.rs b/cmux-tui/crates/cmux-tui/src/codex_wrapper.rs index cf9718717976..5ec6564d9d55 100644 --- a/cmux-tui/crates/cmux-tui/src/codex_wrapper.rs +++ b/cmux-tui/crates/cmux-tui/src/codex_wrapper.rs @@ -10,7 +10,6 @@ use std::ffi::{OsStr, OsString}; use std::fs; use std::io::Read as _; -use std::os::unix::ffi::OsStrExt as _; use std::os::unix::fs::{FileTypeExt as _, PermissionsExt as _}; use std::os::unix::process::CommandExt as _; use std::path::{Path, PathBuf}; @@ -315,8 +314,7 @@ mod launch_classification { } continue; } - return MANAGEMENT.contains(&argument.as_str()) - && !matches!(argument.as_str(), "exec" | "resume" | "fork"); + return MANAGEMENT.contains(&argument.as_str()); } false } diff --git a/cmux-tui/docs/agent-hooks.md b/cmux-tui/docs/agent-hooks.md index b84a4cc151ed..95c2e72edd54 100644 --- a/cmux-tui/docs/agent-hooks.md +++ b/cmux-tui/docs/agent-hooks.md @@ -91,14 +91,21 @@ settings unchanged. `exec`, `resume`, and `fork` sessions are covered; management commands and informational flags pass through. The wrapper also works in an SSH workspace where the host received the -standalone cmux-tui binary. If Codex is launched in a tmux pane without cmux -variables, the installed hook helper keeps using the existing Linux tmux -client route described above. `ssh-tmux` mirrored workspaces use tmux's -control transport rather than the cmux-tui journal and currently have no -agent-status subscription, so this wrapper does not add badges to those -mirrors. - -### Shell startup files can bypass the shim +standalone cmux-tui binary. If the wrapper is launched in a tmux pane without cmux +variables, it resolves the attached Linux tmux client using the same route as +the installed helper, then binds hooks to that terminal for the launch. An +already-running tmux server may not have the shim on PATH; start Codex through +`cmux-tui agent codex-wrapper` in that case. Restart Codex after moving its +session to a different cmux-tui terminal. Set +`CMUX_TUI_CODEX_HOOKS_DISABLED=1` to skip injection. Codex 0.162 can select +embedded mode when given launch-time config; this does not repair a shared +daemon's authorization for direct `cmux notify` calls. + +`ssh-tmux` mirrored workspaces use tmux's control transport rather than the +cmux-tui journal and currently have no agent-status subscription, so this +wrapper does not add badges to those mirrors. + +## Shell startup files can bypass the shim The shim only works while its directory stays ahead of the real `claude` on `PATH`. A login or interactive shell startup file that prepends a directory