diff --git a/codex-rs/core/src/hook_env.rs b/codex-rs/core/src/hook_env.rs new file mode 100644 index 0000000000..c74112a121 --- /dev/null +++ b/codex-rs/core/src/hook_env.rs @@ -0,0 +1,252 @@ +use std::collections::HashMap; +use std::fs; + +use codex_protocol::ThreadId; +use codex_utils_absolute_path::AbsolutePathBuf; +use tracing::warn; + +const HOOK_ENV_DIR: &str = "env"; + +#[derive(Debug)] +pub(crate) struct HookEnvFile { + path: AbsolutePathBuf, +} + +impl HookEnvFile { + /// Creates the per-thread env file handle and makes its parent directory appendable for hooks. + pub(crate) fn new(codex_home: &AbsolutePathBuf, thread_id: ThreadId) -> Self { + let env_file = Self { + path: codex_home + .join(HOOK_ENV_DIR) + .join(format!("{thread_id}.sh")), + }; + env_file.ensure_parent(); + env_file + } + + /// Returns the path exposed to hook commands via CODEX_ENV_FILE and CLAUDE_ENV_FILE. + pub(crate) fn path(&self) -> &AbsolutePathBuf { + &self.path + } + + /// Best-effort directory setup so a hook can append to the advertised env file path. + fn ensure_parent(&self) { + let Some(parent) = self.path.as_path().parent() else { + return; + }; + if let Err(err) = fs::create_dir_all(parent) { + warn!( + path = %parent.display(), + "failed to create hook env file directory: {err}" + ); + } + } + + /// Applies persisted hook env updates to the command environment used by later local tools. + pub(crate) fn apply_to_env(&self, env: &mut HashMap) { + match fs::read_to_string(self.path.as_path()) { + Ok(contents) => { + for line in contents.lines() { + apply_env_file_line(env, line); + } + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => { + warn!( + path = %self.path.display(), + "failed to read hook env file: {err}" + ); + } + } + } +} + +/// Applies one supported shell-style env update line. +/// +/// We intentionally support the common hook outputs here rather than sourcing +/// the file in a hidden shell wrapper: `export NAME=value`, plus Bash's +/// `declare -x NAME=value` form emitted by `export -p`. +fn apply_env_file_line(env: &mut HashMap, line: &str) { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return; + } + + let Some(assignment) = line + .strip_prefix("export ") + .or_else(|| line.strip_prefix("declare -x ")) + else { + return; + }; + let assignment = assignment.trim_start(); + let Some((name, value)) = assignment.split_once('=') else { + return; + }; + let name = name.trim(); + if !is_env_name(name) { + return; + } + let value = parse_env_value(value.trim_start(), env); + env.insert(name.to_string(), value); +} + +/// Parses the value side of an env assignment, preserving single-quoted values literally. +fn parse_env_value(value: &str, env: &HashMap) -> String { + if let Some(value) = value + .strip_prefix('\'') + .and_then(|value| value.strip_suffix('\'')) + { + return value.to_string(); + } + if let Some(value) = value + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + { + return expand_env_vars(&unescape_double_quoted(value), env); + } + expand_env_vars(value.trim_end(), env) +} + +/// Handles the small set of backslash escapes expected inside double-quoted env values. +fn unescape_double_quoted(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + let mut chars = value.chars(); + while let Some(ch) = chars.next() { + if ch == '\\' + && let Some(next) = chars.next() + { + match next { + '$' | '"' | '\\' => out.push(next), + other => { + out.push(ch); + out.push(other); + } + } + continue; + } + out.push(ch); + } + out +} + +/// Expands `$NAME` and `${NAME}` using the environment accumulated for this command. +fn expand_env_vars(value: &str, env: &HashMap) -> String { + let mut out = String::with_capacity(value.len()); + let mut chars = value.chars().peekable(); + while let Some(ch) = chars.next() { + if ch != '$' { + out.push(ch); + continue; + } + + if chars.peek() == Some(&'{') { + chars.next(); + let mut name = String::new(); + while let Some(&next) = chars.peek() { + chars.next(); + if next == '}' { + break; + } + name.push(next); + } + if is_env_name(&name) { + out.push_str(env.get(&name).map_or("", String::as_str)); + } else { + out.push_str("${"); + out.push_str(&name); + out.push('}'); + } + continue; + } + + let mut name = String::new(); + while let Some(&next) = chars.peek() { + if !is_env_name_char(next) { + break; + } + chars.next(); + name.push(next); + } + if name.is_empty() { + out.push('$'); + } else if is_env_name(&name) { + out.push_str(env.get(&name).map_or("", String::as_str)); + } else { + out.push('$'); + out.push_str(&name); + } + } + out +} + +/// Returns whether a string is a valid shell environment variable name. +fn is_env_name(name: &str) -> bool { + let mut chars = name.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first == '_' || first.is_ascii_alphabetic()) && chars.all(is_env_name_char) +} + +/// Returns whether a character is valid after the first character in an env var name. +fn is_env_name_char(ch: char) -> bool { + ch == '_' || ch.is_ascii_alphanumeric() +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn apply_env_file_lines(env: &mut HashMap, contents: &str) { + for line in contents.lines() { + apply_env_file_line(env, line); + } + } + + #[test] + fn env_file_applies_exports_and_expands_path() { + let mut env = HashMap::from([("PATH".to_string(), "/usr/bin".to_string())]); + + apply_env_file_lines( + &mut env, + r#" +# ignored +export FOO=bar +export PATH="/plugin/bin:$PATH" +export LITERAL='$FOO' +"#, + ); + + assert_eq!( + env, + HashMap::from([ + ("FOO".to_string(), "bar".to_string()), + ("LITERAL".to_string(), "$FOO".to_string()), + ("PATH".to_string(), "/plugin/bin:/usr/bin".to_string()), + ]) + ); + } + + #[test] + fn env_file_supports_braced_references_and_declare_exports() { + let mut env = HashMap::from([("BASE".to_string(), "base".to_string())]); + + apply_env_file_lines( + &mut env, + r#" +declare -x FROM_DECLARE="${BASE}/declare" +export FROM_BRACES=${FROM_DECLARE}/braces +"#, + ); + + assert_eq!( + env, + HashMap::from([ + ("BASE".to_string(), "base".to_string()), + ("FROM_DECLARE".to_string(), "base/declare".to_string()), + ("FROM_BRACES".to_string(), "base/declare/braces".to_string()), + ]) + ); + } +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index d5c1237638..34122521ec 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -40,6 +40,7 @@ mod goals; pub use goals::ExternalGoalPreviousStatus; pub use goals::ExternalGoalSet; mod guardian; +mod hook_env; mod hook_runtime; mod installation_id; pub(crate) mod landlock; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 675e794aee..796e822ae9 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1476,6 +1476,7 @@ impl Session { config.as_ref(), self.services.plugins_manager.as_ref(), self.services.user_shell.as_ref(), + &self.services.hook_env_file, ) .await; @@ -3243,6 +3244,10 @@ impl Session { self.services.hooks.load_full() } + pub(crate) fn apply_hook_env_file(&self, env: &mut HashMap) { + self.services.hook_env_file.apply_to_env(env); + } + pub(crate) fn user_shell(&self) -> Arc { Arc::clone(&self.services.user_shell) } @@ -3315,6 +3320,7 @@ async fn build_hooks_for_config( config: &Config, plugins_manager: &PluginsManager, user_shell: &crate::shell::Shell, + hook_env_file: &crate::hook_env::HookEnvFile, ) -> Hooks { let mut hook_shell_argv = user_shell.derive_exec_args("", /*use_login_shell*/ false); let hook_shell_program = hook_shell_argv.remove(0); @@ -3332,6 +3338,7 @@ async fn build_hooks_for_config( plugin_hook_load_warnings, shell_program: Some(hook_shell_program), shell_args: hook_shell_argv, + env_file_path: Some(hook_env_file.path().clone()), }) } diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index e430202ae1..f3a7089862 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -930,8 +930,14 @@ impl Session { (None, None) }; - let hooks = - build_hooks_for_config(&config, plugins_manager.as_ref(), &default_shell).await; + let hook_env_file = crate::hook_env::HookEnvFile::new(&config.codex_home, thread_id); + let hooks = build_hooks_for_config( + &config, + plugins_manager.as_ref(), + &default_shell, + &hook_env_file, + ) + .await; for warning in hooks.startup_warnings() { post_session_configured_events.push(Event { id: INITIAL_SUBMIT_ID.to_owned(), @@ -988,6 +994,7 @@ impl Session { main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), analytics_events_client, hooks: arc_swap::ArcSwap::from_pointee(hooks), + hook_env_file, rollout_thread_trace, user_shell: Arc::new(default_shell), shell_snapshot_tx, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index ac62a3482c..bcc16926c5 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -4536,6 +4536,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { legacy_notify_argv: config.notify.clone(), ..HooksConfig::default() })), + hook_env_file: crate::hook_env::HookEnvFile::new(&config.codex_home, thread_id), rollout_thread_trace: codex_rollout_trace::ThreadTraceContext::disabled(), user_shell: Arc::new(default_user_shell()), shell_snapshot_tx: watch::channel(None).0, @@ -6365,6 +6366,7 @@ where legacy_notify_argv: config.notify.clone(), ..HooksConfig::default() })), + hook_env_file: crate::hook_env::HookEnvFile::new(&config.codex_home, thread_id), rollout_thread_trace: codex_rollout_trace::ThreadTraceContext::disabled(), user_shell: Arc::new(default_user_shell()), shell_snapshot_tx: watch::channel(None).0, diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 59def9c7ab..8c787706c5 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -10,6 +10,7 @@ use crate::config::StartedNetworkProxy; use crate::exec_policy::ExecPolicyManager; use crate::guardian::GuardianRejection; use crate::guardian::GuardianRejectionCircuitBreaker; +use crate::hook_env::HookEnvFile; use crate::mcp::McpManager; use crate::tools::code_mode::CodeModeService; use crate::tools::network_approval::NetworkApprovalService; @@ -48,6 +49,7 @@ pub(crate) struct SessionServices { pub(crate) main_execve_wrapper_exe: Option, pub(crate) analytics_events_client: AnalyticsEventsClient, pub(crate) hooks: ArcSwap, + pub(crate) hook_env_file: HookEnvFile, pub(crate) rollout_thread_trace: ThreadTraceContext, pub(crate) user_shell: Arc, pub(crate) shell_snapshot_tx: watch::Sender>>, diff --git a/codex-rs/core/src/tools/handlers/shell/shell_command.rs b/codex-rs/core/src/tools/handlers/shell/shell_command.rs index e9ec246f18..8644934052 100644 --- a/codex-rs/core/src/tools/handlers/shell/shell_command.rs +++ b/codex-rs/core/src/tools/handlers/shell/shell_command.rs @@ -95,12 +95,15 @@ impl ShellCommandHandler { #[allow(deprecated)] let cwd = turn_context.resolve_path(params.workdir.clone()); + let mut env = create_env(&turn_context.shell_environment_policy, Some(thread_id)); + session.apply_hook_env_file(&mut env); + Ok(ExecParams { command, cwd, expiration: params.timeout_ms.into(), capture_policy: ExecCapturePolicy::ShellTool, - env: create_env(&turn_context.shell_environment_policy, Some(thread_id)), + env, network: turn_context.network.clone(), sandbox_permissions: params.sandbox_permissions.unwrap_or_default(), windows_sandbox_level: turn_context.windows_sandbox_level, diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index fb99500755..f0f2178d6a 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -1006,6 +1006,13 @@ impl UnifiedExecProcessManager { /*thread_id*/ None, ); let mut env = local_policy_env.clone(); + if request.environment.is_remote() { + tracing::debug!( + "CODEX_ENV_FILE overlays are local-only; skipping persisted hook env for remote exec", + ); + } else { + context.session.apply_hook_env_file(&mut env); + } env.insert( CODEX_THREAD_ID_ENV_VAR.to_string(), context.session.conversation_id.to_string(), diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index 3791746551..b9163ac87b 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -104,6 +104,79 @@ fn trust_plugin_hooks(config: &mut Config, plugin_hook_sources: Vec Result<(Vec, std::path::PathBuf)> { + let plugin_root = home.join("plugins/cache/test/sample/local"); + let hooks_dir = plugin_root.join("hooks"); + let plugin_data = home.join("plugins/data/sample-test"); + fs::create_dir_all(plugin_root.join(".codex-plugin")) + .context("create plugin manifest directory")?; + fs::create_dir_all(&hooks_dir).context("create plugin hooks directory")?; + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ) + .context("write plugin manifest")?; + fs::write( + home.join("config.toml"), + r#"[plugins."sample@test"] +enabled = true +"#, + ) + .context("write plugin config")?; + + let script_path = hooks_dir.join("session_start_hook.py"); + fs::write( + &script_path, + r##"import os +from pathlib import Path + +env_file = os.environ.get("CODEX_ENV_FILE") +if not env_file or env_file != os.environ.get("CLAUDE_ENV_FILE"): + raise SystemExit("missing CODEX_ENV_FILE/CLAUDE_ENV_FILE") + +bin_dir = Path(os.environ["PLUGIN_DATA"]) / "bin" +bin_dir.mkdir(parents=True, exist_ok=True) +shim = bin_dir / "adam-shim" +shim.write_text("#!/bin/sh\nprintf shim-ok\n", encoding="utf-8") +shim.chmod(0o755) + +with open(env_file, "a", encoding="utf-8") as handle: + handle.write(f'export PATH="{bin_dir}:$PATH"\n') +"##, + ) + .context("write plugin session start hook script")?; + let plugin_hooks_json = r#"{ + "hooks": { + "SessionStart": [{ + "matcher": "startup", + "hooks": [{ + "type": "command", + "command": "python3 ${PLUGIN_ROOT}/hooks/session_start_hook.py" + }] + }] + } +}"#; + let plugin_hooks_path = hooks_dir.join("hooks.json"); + fs::write(&plugin_hooks_path, plugin_hooks_json).context("write plugin hooks config")?; + + let plugin_hook_sources = vec![PluginHookSource { + plugin_id: PluginId::parse("sample@test").context("plugin id")?, + plugin_root: AbsolutePathBuf::try_from(plugin_root).context("absolute plugin root")?, + plugin_data_root: AbsolutePathBuf::try_from(plugin_data.clone()) + .context("absolute plugin data root")?, + source_path: AbsolutePathBuf::try_from(plugin_hooks_path) + .context("absolute plugin hooks path")?, + source_relative_path: "hooks/hooks.json".to_string(), + hooks: serde_json::from_str::(plugin_hooks_json) + .context("parse plugin hooks")? + .hooks, + }]; + + Ok((plugin_hook_sources, plugin_data)) +} + fn write_stop_hook(home: &Path, block_prompts: &[&str]) -> Result<()> { let script_path = home.join("stop_hook.py"); let log_path = home.join("stop_hook_log.jsonl"); @@ -2973,6 +3046,99 @@ print(json.dumps({{ Ok(()) } +#[tokio::test] +async fn plugin_session_start_env_file_path_persists_for_shell_and_exec_command() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let shell_call_id = "plugin-env-file-shell-command"; + let exec_call_id = "plugin-env-file-exec-command"; + let shell_args = serde_json::json!({ "command": "adam-shim" }); + let exec_args = serde_json::json!({ "cmd": "adam-shim" }); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + shell_call_id, + "shell_command", + &serde_json::to_string(&shell_args)?, + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_function_call( + exec_call_id, + "exec_command", + &serde_json::to_string(&exec_args)?, + ), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_response_created("resp-3"), + ev_assistant_message("msg-1", "both shim calls worked"), + ev_completed("resp-3"), + ]), + ], + ) + .await; + + let home = Arc::new(TempDir::new()?); + let (plugin_hook_sources, plugin_data) = write_path_persisting_plugin(home.path())?; + let mut builder = test_codex() + .with_home(Arc::clone(&home)) + .with_config(move |config| { + config.use_experimental_unified_exec_tool = true; + config + .features + .enable(Feature::Plugins) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + trust_plugin_hooks(config, plugin_hook_sources); + }); + let test = builder.build(&server).await?; + + test.submit_turn_with_permission_profile( + "run the plugin shim by name with shell and exec", + PermissionProfile::Disabled, + ) + .await?; + + assert!( + plugin_data.join("bin/adam-shim").exists(), + "session start hook should create the shim under plugin data" + ); + let requests = responses.requests(); + assert_eq!(requests.len(), 3); + + let shell_output_item = requests[1].function_call_output(shell_call_id); + let shell_output = shell_output_item + .get("output") + .and_then(Value::as_str) + .expect("shell command output string"); + assert!( + shell_output.contains("shim-ok"), + "shell command should find the shim through CODEX_ENV_FILE PATH" + ); + + let exec_output_item = requests[2].function_call_output(exec_call_id); + let exec_output = exec_output_item + .get("output") + .and_then(Value::as_str) + .expect("exec command output string"); + assert!( + exec_output.contains("shim-ok"), + "exec command should find the shim through CODEX_ENV_FILE PATH" + ); + + Ok(()) +} + #[tokio::test] async fn pre_tool_use_blocks_shell_when_defined_in_config_toml() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index 859fc54069..2be268e0fb 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -112,6 +112,7 @@ impl ClaudeHooksEngine { plugin_hook_sources: Vec, plugin_hook_load_warnings: Vec, shell: CommandShell, + env_file_path: Option, ) -> Self { if !enabled { return Self { @@ -129,8 +130,25 @@ impl ClaudeHooksEngine { plugin_hook_load_warnings, bypass_hook_trust, ); + let mut handlers = discovered.handlers; + if let Some(env_file_path) = env_file_path { + let env_file_path = env_file_path.display().to_string(); + for handler in handlers + .iter_mut() + .filter(|handler| handler.event_name == HookEventName::SessionStart) + { + handler.env.insert( + crate::CODEX_ENV_FILE_ENV_VAR.to_string(), + env_file_path.clone(), + ); + handler.env.insert( + crate::CLAUDE_ENV_FILE_ENV_VAR.to_string(), + env_file_path.clone(), + ); + } + } Self { - handlers: discovered.handlers, + handlers, warnings: discovered.warnings, shell, output_spiller: HookOutputSpiller::new(), diff --git a/codex-rs/hooks/src/engine/mod_tests.rs b/codex-rs/hooks/src/engine/mod_tests.rs index 9b642503a0..21baf3779b 100644 --- a/codex-rs/hooks/src/engine/mod_tests.rs +++ b/codex-rs/hooks/src/engine/mod_tests.rs @@ -31,6 +31,9 @@ use tempfile::tempdir; use super::ClaudeHooksEngine; use super::CommandShell; use crate::events::pre_tool_use::PreToolUseRequest; +use crate::events::session_start::SessionStartRequest; +use crate::events::session_start::SessionStartSource; +use crate::events::session_start::StartHookTarget; fn cwd() -> AbsolutePathBuf { AbsolutePathBuf::current_dir().expect("current dir") @@ -204,6 +207,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: program: String::new(), args: Vec::new(), }, + /*env_file_path*/ None, ); assert!(engine.warnings().is_empty()); @@ -218,6 +222,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle: plugin_hook_load_warnings: Vec::new(), shell_program: None, shell_args: Vec::new(), + env_file_path: None, }); assert!(listed.hooks[0].is_managed); let cwd = cwd(); @@ -307,6 +312,7 @@ async fn requirements_managed_hooks_execute_windows_command_override() { program: String::new(), args: Vec::new(), }, + /*env_file_path*/ None, ); let outcome = engine @@ -386,6 +392,7 @@ fn unknown_requirement_source_hooks_stay_managed() { program: String::new(), args: Vec::new(), }, + /*env_file_path*/ None, ); assert_eq!(engine.handlers.len(), 1); @@ -468,6 +475,7 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() { program: String::new(), args: Vec::new(), }, + /*env_file_path*/ None, ); assert_eq!(engine.handlers.len(), 1); @@ -531,6 +539,7 @@ fn user_disablement_does_not_filter_managed_layer_hooks() { program: String::new(), args: Vec::new(), }, + /*env_file_path*/ None, ); assert_eq!(engine.handlers.len(), 1); @@ -692,6 +701,7 @@ fn requirements_managed_hooks_load_when_managed_dir_is_missing() { program: String::new(), args: Vec::new(), }, + /*env_file_path*/ None, ); assert!(engine.warnings().is_empty()); @@ -748,6 +758,7 @@ fn allow_managed_hooks_only_false_keeps_unmanaged_hooks() { program: String::new(), args: Vec::new(), }, + /*env_file_path*/ None, ); assert!(engine.warnings().is_empty()); @@ -802,6 +813,7 @@ fn allow_managed_hooks_only_in_config_toml_does_not_enable_policy() { program: String::new(), args: Vec::new(), }, + /*env_file_path*/ None, ); assert!(engine.warnings().is_empty()); @@ -872,6 +884,7 @@ fn allow_managed_hooks_only_skips_unmanaged_json_and_toml_hooks() { program: String::new(), args: Vec::new(), }, + /*env_file_path*/ None, ); assert!(engine.handlers.is_empty()); @@ -911,6 +924,7 @@ fn allow_managed_hooks_only_skips_unmanaged_plugin_hooks() { program: String::new(), args: Vec::new(), }, + /*env_file_path*/ None, ); assert!(engine.handlers.is_empty()); @@ -983,6 +997,7 @@ fn allow_managed_hooks_only_keeps_managed_requirement_and_config_layer_hooks() { program: String::new(), args: Vec::new(), }, + /*env_file_path*/ None, ); assert!(engine.warnings().is_empty()); @@ -1093,6 +1108,7 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() { program: String::new(), args: Vec::new(), }, + /*env_file_path*/ None, ); assert!(engine.warnings().iter().any(|warning| { @@ -1186,6 +1202,7 @@ print(json.dumps({ program: String::new(), args: Vec::new(), }, + /*env_file_path*/ None, ); let preview = engine.preview_pre_tool_use(&PreToolUseRequest { @@ -1213,6 +1230,7 @@ print(json.dumps({ plugin_hook_load_warnings: Vec::new(), shell_program: None, shell_args: Vec::new(), + env_file_path: None, }); assert_eq!( listed.hooks[0].plugin_id.as_deref(), @@ -1255,6 +1273,184 @@ print(json.dumps({ ); } +#[tokio::test] +async fn hook_commands_receive_codex_env_file_and_claude_alias() { + let temp = tempdir().expect("create temp dir"); + let script_path = temp.path().join("write_env_file_vars.py"); + fs::write( + &script_path, + r#"import json +import os + +print(json.dumps({ + "systemMessage": json.dumps({ + "codex": os.environ.get("CODEX_ENV_FILE"), + "claude": os.environ.get("CLAUDE_ENV_FILE"), + }) +})) +"#, + ) + .expect("write hook script"); + let config_path = + AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path"); + let env_file_path = + AbsolutePathBuf::try_from(temp.path().join("codex-env.sh")).expect("absolute env path"); + let config = serde_json::from_value(serde_json::json!({ + "hooks": { + "SessionStart": [{ + "matcher": "startup", + "hooks": [{ + "type": "command", + "command": format!("python3 {}", script_path.display()), + }], + }], + }, + })) + .expect("config TOML should deserialize"); + let config_layer_stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + ConfigLayerSource::User { + file: config_path, + profile: None, + }, + config, + )], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("config layer stack"); + let engine = ClaudeHooksEngine::new( + /*enabled*/ true, + /*bypass_hook_trust*/ true, + Some(&config_layer_stack), + Vec::new(), + Vec::new(), + CommandShell { + program: String::new(), + args: Vec::new(), + }, + Some(env_file_path.clone()), + ); + + let outcome = engine + .run_session_start( + SessionStartRequest { + session_id: ThreadId::new(), + cwd: cwd(), + transcript_path: None, + model: "gpt-test".to_string(), + permission_mode: "default".to_string(), + target: StartHookTarget::SessionStart { + source: SessionStartSource::Startup, + }, + }, + Some("turn-1".to_string()), + ) + .await; + + assert_eq!(outcome.hook_events.len(), 1); + assert_eq!(outcome.hook_events[0].run.status, HookRunStatus::Completed); + let logged: serde_json::Value = + serde_json::from_str(&outcome.hook_events[0].run.entries[0].text) + .expect("parse env payload"); + assert_eq!( + logged, + serde_json::json!({ + "codex": env_file_path.display().to_string(), + "claude": env_file_path.display().to_string(), + }) + ); +} + +#[tokio::test] +async fn non_session_start_hooks_do_not_receive_env_file_vars() { + let temp = tempdir().expect("create temp dir"); + let script_path = temp.path().join("write_env_file_vars.py"); + fs::write( + &script_path, + r#"import json +import os + +print(json.dumps({ + "systemMessage": json.dumps({ + "codex": os.environ.get("CODEX_ENV_FILE"), + "claude": os.environ.get("CLAUDE_ENV_FILE"), + }) +})) +"#, + ) + .expect("write hook script"); + let config_path = + AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path"); + let env_file_path = + AbsolutePathBuf::try_from(temp.path().join("codex-env.sh")).expect("absolute env path"); + let config = serde_json::from_value(serde_json::json!({ + "hooks": { + "PreToolUse": [{ + "matcher": "Bash", + "hooks": [{ + "type": "command", + "command": format!("python3 {}", script_path.display()), + }], + }], + }, + })) + .expect("config TOML should deserialize"); + let config_layer_stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + ConfigLayerSource::User { + file: config_path, + profile: None, + }, + config, + )], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("config layer stack"); + let engine = ClaudeHooksEngine::new( + /*enabled*/ true, + /*bypass_hook_trust*/ true, + Some(&config_layer_stack), + Vec::new(), + Vec::new(), + CommandShell { + program: String::new(), + args: Vec::new(), + }, + Some(env_file_path), + ); + + let outcome = engine + .run_pre_tool_use(PreToolUseRequest { + session_id: ThreadId::new(), + turn_id: "turn-1".to_string(), + subagent: None, + cwd: cwd(), + transcript_path: None, + model: "gpt-test".to_string(), + permission_mode: "default".to_string(), + tool_name: "Bash".to_string(), + matcher_aliases: Vec::new(), + tool_use_id: "tool-1".to_string(), + tool_input: serde_json::json!({ "command": "echo hello" }), + }) + .await; + + assert_eq!(outcome.hook_events.len(), 1); + assert_eq!(outcome.hook_events[0].run.status, HookRunStatus::Completed); + let logged: serde_json::Value = + serde_json::from_str(&outcome.hook_events[0].run.entries[0].text) + .expect("parse env payload"); + assert_eq!( + logged, + serde_json::json!({ + "codex": serde_json::Value::Null, + "claude": serde_json::Value::Null, + }) + ); +} + #[test] fn plugin_hook_sources_expand_plugin_placeholders() { let temp = tempdir().expect("create temp dir"); @@ -1300,6 +1496,7 @@ fn plugin_hook_sources_expand_plugin_placeholders() { program: String::new(), args: Vec::new(), }, + /*env_file_path*/ None, ); assert_eq!( @@ -1344,6 +1541,7 @@ fn plugin_hook_load_warnings_are_startup_warnings() { program: String::new(), args: Vec::new(), }, + /*env_file_path*/ None, ); assert_eq!(engine.warnings(), &["failed plugin hook".to_string()]); diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index 11300802d8..f7db783322 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -80,6 +80,9 @@ pub use types::HookPayload; pub use types::HookResponse; pub use types::HookResult; +pub const CODEX_ENV_FILE_ENV_VAR: &str = "CODEX_ENV_FILE"; +pub const CLAUDE_ENV_FILE_ENV_VAR: &str = "CLAUDE_ENV_FILE"; + /// Returns the hook event label used in persisted hook-state keys. pub fn hook_event_key_label(event_name: HookEventName) -> &'static str { match event_name { diff --git a/codex-rs/hooks/src/registry.rs b/codex-rs/hooks/src/registry.rs index 1f4e01aa59..645f6fee69 100644 --- a/codex-rs/hooks/src/registry.rs +++ b/codex-rs/hooks/src/registry.rs @@ -1,5 +1,6 @@ use codex_config::ConfigLayerStack; use codex_plugin::PluginHookSource; +use codex_utils_absolute_path::AbsolutePathBuf; use tokio::process::Command; use crate::engine::ClaudeHooksEngine; @@ -36,6 +37,7 @@ pub struct HooksConfig { pub plugin_hook_load_warnings: Vec, pub shell_program: Option, pub shell_args: Vec, + pub env_file_path: Option, } #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -64,16 +66,18 @@ impl Hooks { .map(crate::notify_hook) .into_iter() .collect(); + let shell = CommandShell { + program: config.shell_program.unwrap_or_default(), + args: config.shell_args, + }; let engine = ClaudeHooksEngine::new( config.feature_enabled, config.bypass_hook_trust, config.config_layer_stack.as_ref(), config.plugin_hook_sources, config.plugin_hook_load_warnings, - CommandShell { - program: config.shell_program.unwrap_or_default(), - args: config.shell_args, - }, + shell, + config.env_file_path, ); Self { after_agent,