Compare commits

...

4 Commits

Author SHA1 Message Date
Abhinav Vedmala
9ecd72ff13 Fix hook env file lifecycle 2026-05-26 13:15:24 -07:00
Abhinav Vedmala
28dff23b2f Refine env file snapshot restore 2026-05-26 11:26:33 -07:00
Abhinav Vedmala
16190659f5 Fix env file hook CI failures 2026-05-26 10:33:31 -07:00
Abhinav Vedmala
5bccb54bdf Add hook env file persistence 2026-05-25 12:09:46 -07:00
19 changed files with 865 additions and 18 deletions

View File

@@ -0,0 +1,333 @@
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 without touching the filesystem.
///
/// The hook runtime creates the parent directory only when it exposes this path to a
/// SessionStart hook. Later tool executions can still call `apply_to_env` safely when the
/// file was never created.
pub(crate) fn new(codex_home: &AbsolutePathBuf, thread_id: ThreadId) -> Self {
Self {
path: codex_home
.join(HOOK_ENV_DIR)
.join(format!("{thread_id}.sh")),
}
}
/// Returns the path exposed to hook commands via CODEX_ENV_FILE and CLAUDE_ENV_FILE.
pub(crate) fn path(&self) -> &AbsolutePathBuf {
&self.path
}
/// Clears any env updates from an earlier SessionStart boundary.
///
/// Each SessionStart event gets a fresh file. Hooks that append updates rebuild the overlay
/// for the current boundary, while removed hooks leave no stale file behind.
pub(crate) fn reset(&self) {
match fs::remove_file(self.path.as_path()) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => {
warn!(
path = %self.path.display(),
"failed to reset hook env file: {err}"
);
}
}
}
/// Applies persisted hook env updates to later local tool environments.
///
/// The returned names are the variables touched by the file. Shell-like tools use that list
/// to keep a shell snapshot from undoing hook updates such as `PATH` prepends.
pub(crate) fn apply_to_env(&self, env: &mut HashMap<String, String>) -> Vec<String> {
match fs::read_to_string(self.path.as_path()) {
Ok(contents) => {
let mut applied_names = Vec::new();
for line in contents.lines() {
if let Some(name) = apply_env_file_line(env, line)
&& !applied_names.contains(&name)
{
applied_names.push(name);
}
}
applied_names
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(err) => {
warn!(
path = %self.path.display(),
"failed to read hook env file: {err}"
);
Vec::new()
}
}
}
}
/// 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<String, String>, line: &str) -> Option<String> {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return None;
}
let assignment = line
.strip_prefix("export ")
.or_else(|| line.strip_prefix("declare -x "))?;
let assignment = assignment.trim_start();
let (name, value) = assignment.split_once('=')?;
let name = name.trim();
if !is_env_name(name) {
return None;
}
let value = parse_env_value(value.trim_start(), env);
env.insert(name.to_string(), value);
Some(name.to_string())
}
/// Parses the value side of an env assignment, preserving single-quoted values literally.
fn parse_env_value(value: &str, env: &HashMap<String, String>) -> 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_double_quoted_env_value(value, env);
}
expand_env_vars(value.trim_end(), env)
}
/// Parses double-quoted values, expanding unescaped env vars while preserving escaped dollars.
fn expand_double_quoted_env_value(value: &str, env: &HashMap<String, String>) -> String {
let mut out = String::with_capacity(value.len());
let mut chars = value.chars().peekable();
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;
}
if ch == '$' {
push_expanded_env_var_or_literal(&mut chars, env, &mut out);
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, String>) -> 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;
}
push_expanded_env_var_or_literal(&mut chars, env, &mut out);
}
out
}
/// Appends the expansion for a `$` already consumed from `chars`.
fn push_expanded_env_var_or_literal(
chars: &mut std::iter::Peekable<std::str::Chars<'_>>,
env: &HashMap<String, String>,
out: &mut String,
) {
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('}');
}
return;
}
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);
}
}
/// 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<String, String>, contents: &str) -> Vec<String> {
let mut applied_names = Vec::new();
for line in contents.lines() {
if let Some(name) = apply_env_file_line(env, line) {
applied_names.push(name);
}
}
applied_names
}
#[test]
fn env_file_applies_exports_and_expands_path() {
let mut env = HashMap::from([("PATH".to_string(), "/usr/bin".to_string())]);
let applied_names = 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()),
])
);
assert_eq!(
applied_names,
vec!["FOO".to_string(), "PATH".to_string(), "LITERAL".to_string()]
);
}
#[test]
fn env_file_supports_braced_references_and_declare_exports() {
let mut env = HashMap::from([("BASE".to_string(), "base".to_string())]);
let applied_names = 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()),
])
);
assert_eq!(
applied_names,
vec!["FROM_DECLARE".to_string(), "FROM_BRACES".to_string()]
);
}
#[test]
fn env_file_preserves_escaped_dollars_in_double_quotes() {
let mut env = HashMap::from([
("HOME".to_string(), "/home/codex".to_string()),
("PATH".to_string(), "/usr/bin".to_string()),
]);
apply_env_file_lines(
&mut env,
r#"
export TEMPLATE="\$HOME/bin"
export MIXED="\$HOME:$PATH"
"#,
);
assert_eq!(
env,
HashMap::from([
("HOME".to_string(), "/home/codex".to_string()),
("MIXED".to_string(), "$HOME:/usr/bin".to_string()),
("PATH".to_string(), "/usr/bin".to_string()),
("TEMPLATE".to_string(), "$HOME/bin".to_string()),
])
);
}
#[test]
fn reset_removes_existing_env_file_and_ignores_missing_file() {
let dir = tempfile::tempdir().expect("create temp dir");
let path =
AbsolutePathBuf::try_from(dir.path().join("env.sh")).expect("absolute env file path");
let env_file = HookEnvFile { path };
fs::write(
env_file.path().as_path(),
"export PATH=\"/old/bin:$PATH\"\n",
)
.expect("write env file");
env_file.reset();
assert!(!env_file.path().as_path().exists());
env_file.reset();
}
}

View File

@@ -124,6 +124,9 @@ pub(crate) async fn run_pending_session_start_hooks(
source: session_start_source,
},
};
if matches!(target, StartHookTarget::SessionStart { .. }) {
sess.reset_hook_env_file();
}
let request = codex_hooks::SessionStartRequest {
session_id: sess.session_id().into(),
#[allow(deprecated)]

View File

@@ -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;

View File

@@ -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,14 @@ impl Session {
self.services.hooks.load_full()
}
pub(crate) fn apply_hook_env_file(&self, env: &mut HashMap<String, String>) -> Vec<String> {
self.services.hook_env_file.apply_to_env(env)
}
pub(crate) fn reset_hook_env_file(&self) {
self.services.hook_env_file.reset();
}
pub(crate) fn user_shell(&self) -> Arc<shell::Shell> {
Arc::clone(&self.services.user_shell)
}
@@ -3315,6 +3324,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 +3342,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()),
})
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<PathBuf>,
pub(crate) analytics_events_client: AnalyticsEventsClient,
pub(crate) hooks: ArcSwap<Hooks>,
pub(crate) hook_env_file: HookEnvFile,
pub(crate) rollout_thread_trace: ThreadTraceContext,
pub(crate) user_shell: Arc<crate::shell::Shell>,
pub(crate) shell_snapshot_tx: watch::Sender<Option<Arc<crate::shell_snapshot::ShellSnapshot>>>,

View File

@@ -152,6 +152,7 @@ pub(crate) async fn execute_user_shell_command(
#[allow(deprecated)]
&turn_context.cwd,
&turn_context.shell_environment_policy.r#set,
&[],
&exec_env_map,
);

View File

@@ -58,7 +58,7 @@ struct RunExecLikeArgs {
async fn run_exec_like(args: RunExecLikeArgs) -> Result<FunctionToolOutput, FunctionCallError> {
let RunExecLikeArgs {
tool_name,
exec_params,
mut exec_params,
hook_command,
shell_type,
additional_permissions,
@@ -77,7 +77,6 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result<FunctionToolOutput, Func
};
let fs = turn_environment.environment.get_filesystem();
let explicit_env_overrides = turn.shell_environment_policy.r#set.clone();
let exec_permission_approvals_enabled =
session.features().enabled(Feature::ExecPermissionApprovals);
let requested_additional_permissions = additional_permissions.clone();
@@ -157,6 +156,7 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result<FunctionToolOutput, Func
);
emitter.begin(event_ctx).await;
let snapshot_restore_env_keys = session.apply_hook_env_file(&mut exec_params.env);
let file_system_sandbox_policy = turn.file_system_sandbox_policy();
let exec_approval_requirement = session
.services
@@ -184,7 +184,8 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result<FunctionToolOutput, Func
cwd: exec_params.cwd.clone(),
timeout_ms: exec_params.expiration.timeout_ms(),
env: exec_params.env.clone(),
explicit_env_overrides,
explicit_env_overrides: turn.shell_environment_policy.r#set.clone(),
snapshot_restore_env_keys,
network: exec_params.network.clone(),
sandbox_permissions: effective_additional_permissions.sandbox_permissions,
additional_permissions: normalized_additional_permissions,

View File

@@ -116,13 +116,17 @@ pub(crate) fn disable_powershell_profile_for_elevated_windows_sandbox(
/// `explicit_env_overrides` contains policy-driven shell env overrides that
/// should win after the snapshot is sourced, while `env` is the full live exec
/// environment. We need access to both so snapshot restore logic can preserve
/// runtime-only vars like `CODEX_THREAD_ID` without pretending they came from
/// the explicit override policy.
/// runtime-only vars like `CODEX_THREAD_ID`.
///
/// `snapshot_restore_env_keys` covers additional live env values, such as
/// `CODEX_ENV_FILE` updates, that should survive snapshot sourcing without
/// pretending they came from the explicit override policy.
pub(crate) fn maybe_wrap_shell_lc_with_snapshot(
command: &[String],
session_shell: &Shell,
cwd: &AbsolutePathBuf,
explicit_env_overrides: &HashMap<String, String>,
snapshot_restore_env_keys: &[String],
env: &HashMap<String, String>,
) -> Vec<String> {
if cfg!(windows) {
@@ -159,11 +163,8 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot(
.iter()
.map(|arg| format!(" '{}'", shell_single_quote(arg)))
.collect::<String>();
let mut override_env = explicit_env_overrides.clone();
if let Some(thread_id) = env.get(CODEX_THREAD_ID_ENV_VAR) {
override_env.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.clone());
}
let (override_captures, override_exports) = build_override_exports(&override_env);
let (override_captures, override_exports) =
build_override_exports(explicit_env_overrides, snapshot_restore_env_keys, env);
let (proxy_captures, proxy_exports) = build_proxy_env_exports();
let override_captures = join_shell_blocks([override_captures, proxy_captures]);
let override_exports = join_shell_blocks([override_exports, proxy_exports]);
@@ -180,13 +181,22 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot(
vec![shell_path.to_string(), "-c".to_string(), rewritten_script]
}
fn build_override_exports(explicit_env_overrides: &HashMap<String, String>) -> (String, String) {
fn build_override_exports(
explicit_env_overrides: &HashMap<String, String>,
snapshot_restore_env_keys: &[String],
env: &HashMap<String, String>,
) -> (String, String) {
let mut keys = explicit_env_overrides
.keys()
.map(String::as_str)
.chain(snapshot_restore_env_keys.iter().map(String::as_str))
.filter(|key| is_valid_shell_variable_name(key))
.collect::<Vec<_>>();
if env.contains_key(CODEX_THREAD_ID_ENV_VAR) {
keys.push(CODEX_THREAD_ID_ENV_VAR);
}
keys.sort_unstable();
keys.dedup();
build_override_exports_for_keys("__CODEX_SNAPSHOT_OVERRIDE", &keys)
}

View File

@@ -184,6 +184,7 @@ fn maybe_wrap_shell_lc_with_snapshot_bootstraps_in_user_shell() {
&session_shell,
&dir.path().abs(),
&HashMap::new(),
&[],
&HashMap::new(),
);
@@ -215,6 +216,7 @@ fn maybe_wrap_shell_lc_with_snapshot_escapes_single_quotes() {
&session_shell,
&dir.path().abs(),
&HashMap::new(),
&[],
&HashMap::new(),
);
@@ -243,6 +245,7 @@ fn maybe_wrap_shell_lc_with_snapshot_uses_bash_bootstrap_shell() {
&session_shell,
&dir.path().abs(),
&HashMap::new(),
&[],
&HashMap::new(),
);
@@ -274,6 +277,7 @@ fn maybe_wrap_shell_lc_with_snapshot_uses_sh_bootstrap_shell() {
&session_shell,
&dir.path().abs(),
&HashMap::new(),
&[],
&HashMap::new(),
);
@@ -307,6 +311,7 @@ fn maybe_wrap_shell_lc_with_snapshot_preserves_trailing_args() {
&session_shell,
&dir.path().abs(),
&HashMap::new(),
&[],
&HashMap::new(),
);
@@ -342,6 +347,7 @@ fn maybe_wrap_shell_lc_with_snapshot_skips_when_cwd_mismatch() {
&session_shell,
&command_cwd.abs(),
&HashMap::new(),
&[],
&HashMap::new(),
);
@@ -371,6 +377,7 @@ fn maybe_wrap_shell_lc_with_snapshot_accepts_dot_alias_cwd() {
&session_shell,
&command_cwd.abs(),
&HashMap::new(),
&[],
&HashMap::new(),
);
@@ -407,6 +414,7 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_explicit_override_precedence() {
&session_shell,
&dir.path().abs(),
&explicit_env_overrides,
&[],
&HashMap::from([("TEST_ENV_SNAPSHOT".to_string(), "worktree".to_string())]),
);
let output = Command::new(&rewritten[0])
@@ -447,6 +455,7 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_codex_thread_id_from_env() {
&session_shell,
&dir.path().abs(),
&HashMap::new(),
&[],
&HashMap::from([("CODEX_THREAD_ID".to_string(), "nested-thread".to_string())]),
);
let output = Command::new(&rewritten[0])
@@ -489,6 +498,7 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_proxy_env_from_process_env() {
&session_shell,
&dir.path().abs(),
&HashMap::new(),
&[],
&HashMap::new(),
);
let output = Command::new(&rewritten[0])
@@ -546,6 +556,7 @@ fn maybe_wrap_shell_lc_with_snapshot_refreshes_codex_proxy_git_ssh_command() {
&session_shell,
&dir.path().abs(),
&HashMap::new(),
&[],
&HashMap::new(),
);
let output = Command::new(&rewritten[0])
@@ -591,6 +602,7 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_custom_git_ssh_command() {
&session_shell,
&dir.path().abs(),
&HashMap::new(),
&[],
&HashMap::new(),
);
let output = Command::new(&rewritten[0])
@@ -637,6 +649,7 @@ fn maybe_wrap_shell_lc_with_snapshot_clears_stale_codex_git_ssh_command_without_
&session_shell,
&dir.path().abs(),
&HashMap::new(),
&[],
&HashMap::new(),
);
let output = Command::new(&rewritten[0])
@@ -674,6 +687,7 @@ fn maybe_wrap_shell_lc_with_snapshot_keeps_user_proxy_env_when_proxy_inactive()
&session_shell,
&dir.path().abs(),
&HashMap::new(),
&[],
&HashMap::new(),
);
let mut command = Command::new(&rewritten[0]);
@@ -724,6 +738,7 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_live_env_when_snapshot_proxy_activ
&session_shell,
&dir.path().abs(),
&HashMap::new(),
&[],
&HashMap::from([(
"HTTP_PROXY".to_string(),
"http://user.proxy:8080".to_string(),
@@ -769,6 +784,7 @@ fn maybe_wrap_shell_lc_with_snapshot_keeps_snapshot_path_without_override() {
&session_shell,
&dir.path().abs(),
&HashMap::new(),
&[],
&HashMap::new(),
);
let output = Command::new(&rewritten[0])
@@ -806,6 +822,46 @@ fn maybe_wrap_shell_lc_with_snapshot_applies_explicit_path_override() {
&session_shell,
&dir.path().abs(),
&explicit_env_overrides,
&[],
&HashMap::from([("PATH".to_string(), "/worktree/bin".to_string())]),
);
let output = Command::new(&rewritten[0])
.args(&rewritten[1..])
.env("PATH", "/worktree/bin")
.output()
.expect("run rewritten command");
assert!(output.status.success(), "command failed: {output:?}");
assert_eq!(String::from_utf8_lossy(&output.stdout), "/worktree/bin");
}
#[test]
fn maybe_wrap_shell_lc_with_snapshot_restores_extra_live_path_key() {
let dir = tempdir().expect("create temp dir");
let snapshot_path = dir.path().join("snapshot.sh");
std::fs::write(
&snapshot_path,
"# Snapshot file\nexport PATH='/snapshot/bin'\n",
)
.expect("write snapshot");
let session_shell = shell_with_snapshot(
ShellType::Bash,
"/bin/bash",
snapshot_path.abs(),
dir.path().abs(),
);
let command = vec![
"/bin/bash".to_string(),
"-lc".to_string(),
"printf '%s' \"$PATH\"".to_string(),
];
let snapshot_restore_env_keys = vec!["PATH".to_string()];
let rewritten = maybe_wrap_shell_lc_with_snapshot(
&command,
&session_shell,
&dir.path().abs(),
&HashMap::new(),
&snapshot_restore_env_keys,
&HashMap::from([("PATH".to_string(), "/worktree/bin".to_string())]),
);
let output = Command::new(&rewritten[0])
@@ -847,6 +903,7 @@ fn maybe_wrap_shell_lc_with_snapshot_does_not_embed_override_values_in_argv() {
&session_shell,
&dir.path().abs(),
&explicit_env_overrides,
&[],
&HashMap::from([(
"OPENAI_API_KEY".to_string(),
"super-secret-value".to_string(),
@@ -895,6 +952,7 @@ fn maybe_wrap_shell_lc_with_snapshot_preserves_unset_override_variables() {
&session_shell,
&dir.path().abs(),
&explicit_env_overrides,
&[],
&HashMap::new(),
);

View File

@@ -54,6 +54,7 @@ pub struct ShellRequest {
pub timeout_ms: Option<u64>,
pub env: HashMap<String, String>,
pub explicit_env_overrides: HashMap<String, String>,
pub snapshot_restore_env_keys: Vec<String>,
pub network: Option<NetworkProxy>,
pub sandbox_permissions: SandboxPermissions,
pub additional_permissions: Option<AdditionalPermissionProfile>,
@@ -237,6 +238,7 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
session_shell.as_ref(),
&req.cwd,
&req.explicit_env_overrides,
&req.snapshot_restore_env_keys,
&env,
);
let command = disable_powershell_profile_for_elevated_windows_sandbox(

View File

@@ -66,6 +66,7 @@ pub struct UnifiedExecRequest {
pub env: HashMap<String, String>,
pub exec_server_env_config: Option<ExecServerEnvConfig>,
pub explicit_env_overrides: HashMap<String, String>,
pub snapshot_restore_env_keys: Vec<String>,
pub network: Option<NetworkProxy>,
pub tty: bool,
pub sandbox_permissions: SandboxPermissions,
@@ -270,6 +271,7 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
session_shell.as_ref(),
&req.cwd,
&req.explicit_env_overrides,
&req.snapshot_restore_env_keys,
&env,
)
};
@@ -418,6 +420,7 @@ mod tests {
env: HashMap::new(),
exec_server_env_config: None,
explicit_env_overrides: HashMap::new(),
snapshot_restore_env_keys: Vec::new(),
network: None,
tty: false,
sandbox_permissions: SandboxPermissions::UseDefault,

View File

@@ -1006,6 +1006,14 @@ impl UnifiedExecProcessManager {
/*thread_id*/ None,
);
let mut env = local_policy_env.clone();
let snapshot_restore_env_keys = if request.environment.is_remote() {
tracing::debug!(
"CODEX_ENV_FILE overlays are local-only; skipping persisted hook env for remote exec",
);
Vec::new()
} else {
context.session.apply_hook_env_file(&mut env)
};
env.insert(
CODEX_THREAD_ID_ENV_VAR.to_string(),
context.session.conversation_id.to_string(),
@@ -1050,6 +1058,7 @@ impl UnifiedExecProcessManager {
env,
exec_server_env_config: Some(exec_server_env_config),
explicit_env_overrides: context.turn.shell_environment_policy.r#set.clone(),
snapshot_restore_env_keys,
network: request.network.clone(),
tty: request.tty,
sandbox_permissions: request.sandbox_permissions,

View File

@@ -104,6 +104,79 @@ fn trust_plugin_hooks(config: &mut Config, plugin_hook_sources: Vec<PluginHookSo
trust_hooks(config, listed.hooks);
}
fn write_path_persisting_plugin(
home: &Path,
) -> Result<(Vec<PluginHookSource>, 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::<codex_config::HooksFile>(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(()));

View File

@@ -112,6 +112,7 @@ impl ClaudeHooksEngine {
plugin_hook_sources: Vec<PluginHookSource>,
plugin_hook_load_warnings: Vec<String>,
shell: CommandShell,
env_file_path: Option<AbsolutePathBuf>,
) -> Self {
if !enabled {
return Self {
@@ -129,8 +130,40 @@ impl ClaudeHooksEngine {
plugin_hook_load_warnings,
bypass_hook_trust,
);
let mut handlers = discovered.handlers;
if let Some(env_file_path) = env_file_path {
let mut env_file_path_for_handlers: Option<String> = None;
for handler in handlers
.iter_mut()
.filter(|handler| handler.event_name == HookEventName::SessionStart)
{
let env_file_path = env_file_path_for_handlers
.get_or_insert_with(|| {
// Hook scripts append to CODEX_ENV_FILE directly, so create the parent
// directory only when the path is actually exposed to a SessionStart hook.
if let Some(parent) = env_file_path.as_path().parent()
&& let Err(err) = std::fs::create_dir_all(parent)
{
tracing::warn!(
path = %parent.display(),
"failed to create hook env file directory: {err}"
);
}
env_file_path.display().to_string()
})
.clone();
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(),

View File

@@ -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()]);

View File

@@ -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 {

View File

@@ -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<String>,
pub shell_program: Option<String>,
pub shell_args: Vec<String>,
pub env_file_path: Option<AbsolutePathBuf>,
}
#[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,