Add CODEX_ENV_FILE for SessionStart hooks

This commit is contained in:
Abhinav Vedmala
2026-05-27 11:31:25 -07:00
parent 66ff8b0f54
commit 39a311cb8d
20 changed files with 628 additions and 69 deletions
+1
View File
@@ -132,6 +132,7 @@ mod rollout;
pub(crate) mod safety;
mod session_rollout_init_error;
pub mod shell;
mod shell_env_file;
pub(crate) mod shell_snapshot;
pub mod spawn;
pub(crate) mod state_db_bridge;
+20
View File
@@ -1488,6 +1488,7 @@ impl Session {
config.as_ref(),
self.services.plugins_manager.as_ref(),
self.services.user_shell.as_ref(),
self.services.shell_env_file.as_ref(),
)
.await;
@@ -3269,6 +3270,19 @@ impl Session {
Arc::clone(&self.services.user_shell)
}
pub(crate) fn shell_env_file_path(&self) -> Option<&Path> {
self.services
.shell_env_file
.as_ref()
.map(crate::shell_env_file::ShellEnvFile::path)
}
pub(crate) fn insert_shell_env_file(&self, env: &mut HashMap<String, String>) {
if let Some(shell_env_file) = self.services.shell_env_file.as_ref() {
shell_env_file.insert_into_env(env);
}
}
pub(crate) async fn current_rollout_path(&self) -> anyhow::Result<Option<PathBuf>> {
let Some(live_thread) = self.live_thread() else {
return Ok(None);
@@ -3337,6 +3351,7 @@ async fn build_hooks_for_config(
config: &Config,
plugins_manager: &PluginsManager,
user_shell: &crate::shell::Shell,
shell_env_file: Option<&crate::shell_env_file::ShellEnvFile>,
) -> Hooks {
let mut hook_shell_argv = user_shell.derive_exec_args("", /*use_login_shell*/ false);
let hook_shell_program = hook_shell_argv.remove(0);
@@ -3345,6 +3360,10 @@ async fn build_hooks_for_config(
let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await;
let plugin_hook_sources = plugin_outcome.effective_plugin_hook_sources();
let plugin_hook_load_warnings = plugin_outcome.effective_plugin_hook_warnings();
let mut command_env = HashMap::new();
if let Some(shell_env_file) = shell_env_file {
shell_env_file.insert_into_env(&mut command_env);
}
Hooks::new(HooksConfig {
legacy_notify_argv: config.notify.clone(),
feature_enabled: config.features.enabled(Feature::CodexHooks),
@@ -3354,6 +3373,7 @@ async fn build_hooks_for_config(
plugin_hook_load_warnings,
shell_program: Some(hook_shell_program),
shell_args: hook_shell_argv,
command_env,
})
}
+10 -2
View File
@@ -865,6 +865,8 @@ impl Session {
default_shell.shell_snapshot = rx;
tx
};
let shell_env_file =
crate::shell_env_file::ShellEnvFile::for_session(&config.codex_home, thread_id)?;
let thread_name =
thread_title_from_thread_store(live_thread_init.as_ref(), &thread_store, thread_id)
.instrument(info_span!(
@@ -935,8 +937,13 @@ impl Session {
(None, None)
};
let hooks =
build_hooks_for_config(&config, plugins_manager.as_ref(), &default_shell).await;
let hooks = build_hooks_for_config(
&config,
plugins_manager.as_ref(),
&default_shell,
shell_env_file.as_ref(),
)
.await;
for warning in hooks.startup_warnings() {
post_session_configured_events.push(Event {
id: INITIAL_SUBMIT_ID.to_owned(),
@@ -996,6 +1003,7 @@ impl Session {
hooks: arc_swap::ArcSwap::from_pointee(hooks),
rollout_thread_trace,
user_shell: Arc::new(default_shell),
shell_env_file,
shell_snapshot_tx,
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
exec_policy,
+2
View File
@@ -4546,6 +4546,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
})),
rollout_thread_trace: codex_rollout_trace::ThreadTraceContext::disabled(),
user_shell: Arc::new(default_user_shell()),
shell_env_file: None,
shell_snapshot_tx: watch::channel(None).0,
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
exec_policy,
@@ -6381,6 +6382,7 @@ where
})),
rollout_thread_trace: codex_rollout_trace::ThreadTraceContext::disabled(),
user_shell: Arc::new(default_user_shell()),
shell_env_file: None,
shell_snapshot_tx: watch::channel(None).0,
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
exec_policy,
+70
View File
@@ -0,0 +1,70 @@
use std::collections::HashMap;
use std::path::Path;
use anyhow::Context;
use anyhow::Result;
use codex_protocol::ThreadId;
use codex_utils_absolute_path::AbsolutePathBuf;
use tempfile::TempPath;
pub(crate) const CODEX_ENV_FILE_ENV_VAR: &str = "CODEX_ENV_FILE";
#[cfg(not(windows))]
const SHELL_ENV_DIR: &str = "shell_env";
/// Session-owned script that hooks can populate with exported shell state.
///
/// Local shell tool commands source this file before running so lifecycle hook
/// setup remains scoped to the active session rather than persistent config.
pub(crate) struct ShellEnvFile {
path: TempPath,
}
impl ShellEnvFile {
pub(crate) fn for_session(
codex_home: &AbsolutePathBuf,
thread_id: ThreadId,
) -> Result<Option<Self>> {
#[cfg(windows)]
{
let _ = (codex_home, thread_id);
// TODO: Support a Windows shell environment persistence contract,
// likely with PowerShell- and cmd-compatible formats.
Ok(None)
}
#[cfg(not(windows))]
{
Self::new(codex_home, thread_id).map(Some)
}
}
#[cfg(not(windows))]
fn new(codex_home: &AbsolutePathBuf, thread_id: ThreadId) -> Result<Self> {
let dir = codex_home.join(SHELL_ENV_DIR);
std::fs::create_dir_all(dir.as_path())
.with_context(|| format!("failed to create shell env dir {}", dir.display()))?;
let file = tempfile::Builder::new()
.prefix(&format!("{thread_id}."))
.suffix(".sh")
.tempfile_in(dir.as_path())
.with_context(|| format!("failed to create shell env file in {}", dir.display()))?;
Ok(Self {
path: file.into_temp_path(),
})
}
pub(crate) fn path(&self) -> &Path {
self.path.as_ref()
}
pub(crate) fn insert_into_env(&self, env: &mut HashMap<String, String>) {
env.insert(
CODEX_ENV_FILE_ENV_VAR.to_string(),
self.path().to_string_lossy().to_string(),
);
}
}
#[cfg(test)]
#[path = "shell_env_file_tests.rs"]
mod tests;
+21
View File
@@ -0,0 +1,21 @@
use anyhow::Result;
use codex_protocol::ThreadId;
use codex_utils_absolute_path::AbsolutePathBuf;
use tempfile::tempdir;
use super::*;
#[cfg(not(windows))]
#[test]
fn shell_env_file_is_removed_when_session_owner_drops() -> Result<()> {
let dir = tempdir()?;
let codex_home = AbsolutePathBuf::from_absolute_path(dir.path())?;
let env_file = ShellEnvFile::new(&codex_home, ThreadId::new())?;
let path = env_file.path().to_path_buf();
assert!(path.exists());
drop(env_file);
assert!(!path.exists());
Ok(())
}
+1
View File
@@ -50,6 +50,7 @@ pub(crate) struct SessionServices {
pub(crate) hooks: ArcSwap<Hooks>,
pub(crate) rollout_thread_trace: ThreadTraceContext,
pub(crate) user_shell: Arc<crate::shell::Shell>,
pub(crate) shell_env_file: Option<crate::shell_env_file::ShellEnvFile>,
pub(crate) shell_snapshot_tx: watch::Sender<Option<Arc<crate::shell_snapshot::ShellSnapshot>>>,
pub(crate) show_raw_agent_reasoning: bool,
pub(crate) exec_policy: Arc<ExecPolicyManager>,
+4 -2
View File
@@ -20,7 +20,7 @@ use crate::session::TurnInput;
use crate::session::turn_context::TurnContext;
use crate::state::TaskKind;
use crate::tools::format_exec_output_str;
use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot;
use crate::tools::runtimes::maybe_wrap_shell_command_with_runtime_env;
use crate::turn_timing::now_unix_timestamp_ms;
use crate::user_shell_command::user_shell_command_record_item;
use codex_protocol::exec_output::ExecToolCallOutput;
@@ -132,6 +132,7 @@ pub(crate) async fn execute_user_shell_command(
&turn_context.shell_environment_policy,
Some(session.conversation_id),
);
session.insert_shell_env_file(&mut exec_env_map);
if exec_env_map.contains_key(PROXY_ACTIVE_ENV_KEY) {
for key in PROXY_ENV_KEYS {
exec_env_map.remove(*key);
@@ -146,11 +147,12 @@ pub(crate) async fn execute_user_shell_command(
exec_env_map.remove(PROXY_GIT_SSH_COMMAND_ENV_KEY);
}
}
let exec_command = maybe_wrap_shell_lc_with_snapshot(
let exec_command = maybe_wrap_shell_command_with_runtime_env(
&display_command,
session_shell.as_ref(),
#[allow(deprecated)]
&turn_context.cwd,
session.shell_env_file_path(),
&turn_context.shell_environment_policy.r#set,
&exec_env_map,
);
@@ -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.insert_shell_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,
+83 -32
View File
@@ -9,6 +9,7 @@ use crate::path_utils;
use crate::sandboxing::SandboxPermissions;
use crate::shell::Shell;
use crate::shell::ShellType;
use crate::shell_env_file::CODEX_ENV_FILE_ENV_VAR;
use crate::tools::sandboxing::ToolError;
#[cfg(target_os = "macos")]
use codex_network_proxy::CODEX_PROXY_GIT_SSH_COMMAND_MARKER;
@@ -18,10 +19,14 @@ use codex_network_proxy::PROXY_ENV_KEYS;
use codex_network_proxy::PROXY_GIT_SSH_COMMAND_ENV_KEY;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::AdditionalPermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_sandboxing::SandboxCommand;
use codex_sandboxing::SandboxType;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::HashMap;
use std::path::Path;
pub(crate) mod apply_patch;
pub(crate) mod shell;
@@ -70,6 +75,29 @@ pub(crate) fn exec_env_for_sandbox_permissions(
env
}
/// Adds the read required by a shell wrapper that sources `CODEX_ENV_FILE`.
///
/// Callers apply this only while preparing execution, after requested
/// permissions have already been considered for approval.
pub(crate) fn with_shell_env_file_read_permission(
mut additional_permissions: Option<AdditionalPermissionProfile>,
env_file: Option<&Path>,
) -> Option<AdditionalPermissionProfile> {
let Some(path) = non_empty_shell_env_file_path(env_file) else {
return additional_permissions;
};
let Ok(path) = AbsolutePathBuf::from_absolute_path(path) else {
return additional_permissions;
};
let profile = additional_permissions.get_or_insert_with(Default::default);
let file_system = profile.file_system.get_or_insert_with(Default::default);
file_system.entries.push(FileSystemSandboxEntry {
path: FileSystemPath::Path { path },
access: FileSystemAccessMode::Read,
});
additional_permissions
}
pub(crate) fn disable_powershell_profile_for_elevated_windows_sandbox(
command: &[String],
shell_type: Option<&ShellType>,
@@ -99,45 +127,33 @@ pub(crate) fn disable_powershell_profile_for_elevated_windows_sandbox(
command
}
/// POSIX-only helper: for commands produced by `Shell::derive_exec_args`
/// for Bash/Zsh/sh of the form `[shell_path, "-lc", "<script>"]`, and
/// when a snapshot is configured on the session shell, rewrite the argv
/// to a single non-login shell that sources the snapshot before running
/// the original script:
/// POSIX-only wrapper for commands produced by `Shell::derive_exec_args` for
/// Bash/Zsh/sh when shell setup must be sourced before the user script:
///
/// shell -lc "<script>"
/// => user_shell -c ". SNAPSHOT (best effort); exec shell -c <script>"
/// => user_shell -c ". SNAPSHOT; . ENV_FILE; exec shell -c <script>"
///
/// This wrapper script uses POSIX constructs (`if`, `.`, `exec`) so it can
/// be run by Bash/Zsh/sh. On non-matching commands, or when command cwd does
/// not match the snapshot cwd, this is a no-op.
/// be run by Bash/Zsh/sh. The env file is sourced for either `-c` or `-lc`
/// commands and is independent of cwd. A snapshot remains restricted to
/// login commands in its matching cwd.
///
/// `explicit_env_overrides` and `env` are intentionally separate inputs.
/// `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.
pub(crate) fn maybe_wrap_shell_lc_with_snapshot(
/// runtime-only vars like `CODEX_THREAD_ID` and `CODEX_ENV_FILE` without
/// pretending they came from the explicit override policy.
pub(crate) fn maybe_wrap_shell_command_with_runtime_env(
command: &[String],
session_shell: &Shell,
cwd: &AbsolutePathBuf,
env_file: Option<&Path>,
explicit_env_overrides: &HashMap<String, String>,
env: &HashMap<String, String>,
) -> Vec<String> {
if cfg!(windows) {
return command.to_vec();
}
let Some(snapshot) = session_shell.shell_snapshot() else {
return command.to_vec();
};
if !snapshot.path.exists() {
return command.to_vec();
}
if !path_utils::paths_match_after_normalization(snapshot.cwd.as_path(), cwd) {
// TODO: Support a Windows shell environment persistence contract.
return command.to_vec();
}
@@ -146,38 +162,73 @@ pub(crate) fn maybe_wrap_shell_lc_with_snapshot(
}
let flag = command[1].as_str();
if flag != "-lc" {
if flag != "-c" && flag != "-lc" {
return command.to_vec();
}
let snapshot_path = snapshot.path.to_string_lossy();
let shell_path = session_shell.shell_path.to_string_lossy();
// Login-shell snapshots and hook-written exports have separate lifetimes
// and eligibility rules. When both apply, restore login state first, then
// layer the session hook exports over it.
let snapshot = (flag == "-lc")
.then(|| session_shell.shell_snapshot())
.flatten()
.filter(|snapshot| {
snapshot.path.exists()
&& path_utils::paths_match_after_normalization(snapshot.cwd.as_path(), cwd)
});
let env_file = non_empty_shell_env_file_path(env_file);
if snapshot.is_none() && env_file.is_none() {
return command.to_vec();
}
let has_snapshot = snapshot.is_some();
let shell_path = if has_snapshot {
session_shell.shell_path.to_string_lossy().to_string()
} else {
command[0].clone()
};
let shell_flag = if has_snapshot { "-c" } else { flag };
let original_shell = shell_single_quote(&command[0]);
let original_script = shell_single_quote(&command[2]);
let snapshot_path = shell_single_quote(snapshot_path.as_ref());
let trailing_args = command[3..]
.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());
for key in [CODEX_THREAD_ID_ENV_VAR, CODEX_ENV_FILE_ENV_VAR] {
if let Some(value) = env.get(key) {
override_env.insert(key.to_string(), value.clone());
}
}
let (override_captures, override_exports) = build_override_exports(&override_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]);
let mut source_commands = Vec::new();
if let Some(snapshot) = snapshot {
let path = shell_single_quote(&snapshot.path.to_string_lossy());
source_commands.push(format!("if . '{path}' >/dev/null 2>&1; then :; fi"));
}
if let Some(env_file) = env_file {
let path = shell_single_quote(&env_file.to_string_lossy());
source_commands.push(format!("if . '{path}' >/dev/null 2>&1; then :; fi"));
}
let source_commands = join_shell_blocks(source_commands);
let rewritten_script = if override_exports.is_empty() {
format!(
"if . '{snapshot_path}' >/dev/null 2>&1; then :; fi\n\nexec '{original_shell}' -c '{original_script}'{trailing_args}"
"{source_commands}\n\nexec '{original_shell}' -c '{original_script}'{trailing_args}"
)
} else {
format!(
"{override_captures}\n\nif . '{snapshot_path}' >/dev/null 2>&1; then :; fi\n\n{override_exports}\n\nexec '{original_shell}' -c '{original_script}'{trailing_args}"
"{override_captures}\n\n{source_commands}\n\n{override_exports}\n\nexec '{original_shell}' -c '{original_script}'{trailing_args}"
)
};
vec![shell_path.to_string(), "-c".to_string(), rewritten_script]
vec![shell_path, shell_flag.to_string(), rewritten_script]
}
fn non_empty_shell_env_file_path(env_file: Option<&Path>) -> Option<&Path> {
env_file.filter(|path| std::fs::metadata(path).is_ok_and(|metadata| metadata.len() > 0))
}
fn build_override_exports(explicit_env_overrides: &HashMap<String, String>) -> (String, String) {
+168 -19
View File
@@ -20,6 +20,9 @@ use codex_network_proxy::PROXY_ENV_KEYS;
use codex_network_proxy::PROXY_GIT_SSH_COMMAND_ENV_KEY;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::FileSystemAccessMode;
use codex_protocol::permissions::FileSystemPath;
use codex_protocol::permissions::FileSystemSandboxEntry;
use codex_sandboxing::SandboxManager;
use codex_sandboxing::SandboxType;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -179,10 +182,11 @@ fn maybe_wrap_shell_lc_with_snapshot_bootstraps_in_user_shell() {
"echo hello".to_string(),
];
let rewritten = maybe_wrap_shell_lc_with_snapshot(
let rewritten = maybe_wrap_shell_command_with_runtime_env(
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&HashMap::new(),
&HashMap::new(),
);
@@ -193,6 +197,133 @@ fn maybe_wrap_shell_lc_with_snapshot_bootstraps_in_user_shell() {
assert!(rewritten[2].contains("exec '/bin/bash' -c 'echo hello'"));
}
#[test]
fn maybe_wrap_shell_command_sources_session_env_file_without_snapshot() {
let dir = tempdir().expect("create temp dir");
let env_file = dir.path().join("session-env.sh");
std::fs::write(
&env_file,
"export FROM_SESSION_START='hook-value'\nexport CODEX_ENV_FILE='/overwritten'\n",
)
.expect("write env file");
let session_shell = Shell {
shell_type: ShellType::Bash,
shell_path: PathBuf::from("/bin/bash"),
shell_snapshot: crate::shell::empty_shell_snapshot_receiver(),
};
let command = vec![
"/bin/bash".to_string(),
"-c".to_string(),
"printf '%s|%s' \"$FROM_SESSION_START\" \"$CODEX_ENV_FILE\"".to_string(),
];
let env_file_value = env_file.to_string_lossy().to_string();
let env = HashMap::from([(CODEX_ENV_FILE_ENV_VAR.to_string(), env_file_value.clone())]);
let rewritten = maybe_wrap_shell_command_with_runtime_env(
&command,
&session_shell,
&dir.path().abs(),
Some(env_file.as_path()),
&HashMap::new(),
&env,
);
let output = Command::new(&rewritten[0])
.args(&rewritten[1..])
.env(CODEX_ENV_FILE_ENV_VAR, &env_file_value)
.output()
.expect("run rewritten command");
assert!(output.status.success(), "command failed: {output:?}");
assert_eq!(
String::from_utf8_lossy(&output.stdout),
format!("hook-value|{env_file_value}")
);
}
#[test]
fn populated_session_env_file_adds_runtime_read_permission() {
let dir = tempdir().expect("create temp dir");
let env_file = dir.path().join("session-env.sh");
std::fs::write(&env_file, "export FROM_SESSION_START='hook-value'\n").expect("write env file");
let additional_permissions = with_shell_env_file_read_permission(
/*additional_permissions*/ None,
Some(env_file.as_path()),
)
.expect("session env file should add a permission");
assert_eq!(
additional_permissions.file_system,
Some(codex_protocol::models::FileSystemPermissions {
entries: vec![FileSystemSandboxEntry {
path: FileSystemPath::Path {
path: env_file.abs()
},
access: FileSystemAccessMode::Read,
}],
..Default::default()
})
);
}
#[test]
fn maybe_wrap_shell_command_skips_empty_session_env_file() {
let dir = tempdir().expect("create temp dir");
let env_file = dir.path().join("session-env.sh");
std::fs::write(&env_file, "").expect("write env file");
let session_shell = Shell {
shell_type: ShellType::Bash,
shell_path: PathBuf::from("/bin/bash"),
shell_snapshot: crate::shell::empty_shell_snapshot_receiver(),
};
let command = vec![
"/bin/bash".to_string(),
"-c".to_string(),
"printf '%s' \"$FROM_SESSION_START\"".to_string(),
];
let rewritten = maybe_wrap_shell_command_with_runtime_env(
&command,
&session_shell,
&dir.path().abs(),
Some(env_file.as_path()),
&HashMap::new(),
&HashMap::new(),
);
assert_eq!(rewritten, command);
}
#[test]
fn maybe_wrap_shell_command_keeps_login_initialization_before_session_env_file() {
let dir = tempdir().expect("create temp dir");
let env_file = dir.path().join("session-env.sh");
std::fs::write(&env_file, "export FROM_SESSION_START='hook-value'\n").expect("write env file");
let session_shell = Shell {
shell_type: ShellType::Bash,
shell_path: PathBuf::from("/bin/bash"),
shell_snapshot: crate::shell::empty_shell_snapshot_receiver(),
};
let command = vec![
"/bin/bash".to_string(),
"-lc".to_string(),
"printf '%s' \"$FROM_SESSION_START\"".to_string(),
];
let rewritten = maybe_wrap_shell_command_with_runtime_env(
&command,
&session_shell,
&dir.path().abs(),
Some(env_file.as_path()),
&HashMap::new(),
&HashMap::new(),
);
assert_eq!(rewritten[0], "/bin/bash");
assert_eq!(rewritten[1], "-lc");
assert!(rewritten[2].contains("session-env.sh"));
}
#[test]
fn maybe_wrap_shell_lc_with_snapshot_escapes_single_quotes() {
let dir = tempdir().expect("create temp dir");
@@ -210,10 +341,11 @@ fn maybe_wrap_shell_lc_with_snapshot_escapes_single_quotes() {
"echo 'hello'".to_string(),
];
let rewritten = maybe_wrap_shell_lc_with_snapshot(
let rewritten = maybe_wrap_shell_command_with_runtime_env(
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&HashMap::new(),
&HashMap::new(),
);
@@ -238,10 +370,11 @@ fn maybe_wrap_shell_lc_with_snapshot_uses_bash_bootstrap_shell() {
"echo hello".to_string(),
];
let rewritten = maybe_wrap_shell_lc_with_snapshot(
let rewritten = maybe_wrap_shell_command_with_runtime_env(
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&HashMap::new(),
&HashMap::new(),
);
@@ -269,10 +402,11 @@ fn maybe_wrap_shell_lc_with_snapshot_uses_sh_bootstrap_shell() {
"echo hello".to_string(),
];
let rewritten = maybe_wrap_shell_lc_with_snapshot(
let rewritten = maybe_wrap_shell_command_with_runtime_env(
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&HashMap::new(),
&HashMap::new(),
);
@@ -302,10 +436,11 @@ fn maybe_wrap_shell_lc_with_snapshot_preserves_trailing_args() {
"arg1".to_string(),
];
let rewritten = maybe_wrap_shell_lc_with_snapshot(
let rewritten = maybe_wrap_shell_command_with_runtime_env(
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&HashMap::new(),
&HashMap::new(),
);
@@ -337,10 +472,11 @@ fn maybe_wrap_shell_lc_with_snapshot_skips_when_cwd_mismatch() {
"echo hello".to_string(),
];
let rewritten = maybe_wrap_shell_lc_with_snapshot(
let rewritten = maybe_wrap_shell_command_with_runtime_env(
&command,
&session_shell,
&command_cwd.abs(),
/*env_file*/ None,
&HashMap::new(),
&HashMap::new(),
);
@@ -366,10 +502,11 @@ fn maybe_wrap_shell_lc_with_snapshot_accepts_dot_alias_cwd() {
];
let command_cwd = dir.path().join(".");
let rewritten = maybe_wrap_shell_lc_with_snapshot(
let rewritten = maybe_wrap_shell_command_with_runtime_env(
&command,
&session_shell,
&command_cwd.abs(),
/*env_file*/ None,
&HashMap::new(),
&HashMap::new(),
);
@@ -402,10 +539,11 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_explicit_override_precedence() {
];
let explicit_env_overrides =
HashMap::from([("TEST_ENV_SNAPSHOT".to_string(), "worktree".to_string())]);
let rewritten = maybe_wrap_shell_lc_with_snapshot(
let rewritten = maybe_wrap_shell_command_with_runtime_env(
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&explicit_env_overrides,
&HashMap::from([("TEST_ENV_SNAPSHOT".to_string(), "worktree".to_string())]),
);
@@ -442,10 +580,11 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_codex_thread_id_from_env() {
"-lc".to_string(),
"printf '%s' \"$CODEX_THREAD_ID\"".to_string(),
];
let rewritten = maybe_wrap_shell_lc_with_snapshot(
let rewritten = maybe_wrap_shell_command_with_runtime_env(
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&HashMap::new(),
&HashMap::from([("CODEX_THREAD_ID".to_string(), "nested-thread".to_string())]),
);
@@ -484,10 +623,11 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_proxy_env_from_process_env() {
"printf '%s\\n%s\\n%s\\n%s' \"$PIP_PROXY\" \"$HTTP_PROXY\" \"$http_proxy\" \"$GIT_SSH_COMMAND\""
.to_string(),
];
let rewritten = maybe_wrap_shell_lc_with_snapshot(
let rewritten = maybe_wrap_shell_command_with_runtime_env(
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&HashMap::new(),
&HashMap::new(),
);
@@ -541,10 +681,11 @@ fn maybe_wrap_shell_lc_with_snapshot_refreshes_codex_proxy_git_ssh_command() {
"-lc".to_string(),
format!("printf '%s' \"${PROXY_GIT_SSH_COMMAND_ENV_KEY}\""),
];
let rewritten = maybe_wrap_shell_lc_with_snapshot(
let rewritten = maybe_wrap_shell_command_with_runtime_env(
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&HashMap::new(),
&HashMap::new(),
);
@@ -586,10 +727,11 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_custom_git_ssh_command() {
"-lc".to_string(),
format!("printf '%s' \"${PROXY_GIT_SSH_COMMAND_ENV_KEY}\""),
];
let rewritten = maybe_wrap_shell_lc_with_snapshot(
let rewritten = maybe_wrap_shell_command_with_runtime_env(
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&HashMap::new(),
&HashMap::new(),
);
@@ -632,10 +774,11 @@ fn maybe_wrap_shell_lc_with_snapshot_clears_stale_codex_git_ssh_command_without_
"if [ \"${{{PROXY_GIT_SSH_COMMAND_ENV_KEY}+x}}\" = x ]; then printf 'set'; else printf 'unset'; fi"
),
];
let rewritten = maybe_wrap_shell_lc_with_snapshot(
let rewritten = maybe_wrap_shell_command_with_runtime_env(
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&HashMap::new(),
&HashMap::new(),
);
@@ -669,10 +812,11 @@ fn maybe_wrap_shell_lc_with_snapshot_keeps_user_proxy_env_when_proxy_inactive()
"-lc".to_string(),
"printf '%s' \"$HTTP_PROXY\"".to_string(),
];
let rewritten = maybe_wrap_shell_lc_with_snapshot(
let rewritten = maybe_wrap_shell_command_with_runtime_env(
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&HashMap::new(),
&HashMap::new(),
);
@@ -719,10 +863,11 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_live_env_when_snapshot_proxy_activ
if [ \"${{{PROXY_ACTIVE_ENV_KEY}+x}}\" = x ]; then printf 'active:%s' \"${PROXY_ACTIVE_ENV_KEY}\"; else printf 'active:unset'; fi"
),
];
let rewritten = maybe_wrap_shell_lc_with_snapshot(
let rewritten = maybe_wrap_shell_command_with_runtime_env(
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&HashMap::new(),
&HashMap::from([(
"HTTP_PROXY".to_string(),
@@ -764,10 +909,11 @@ fn maybe_wrap_shell_lc_with_snapshot_keeps_snapshot_path_without_override() {
"-lc".to_string(),
"printf '%s' \"$PATH\"".to_string(),
];
let rewritten = maybe_wrap_shell_lc_with_snapshot(
let rewritten = maybe_wrap_shell_command_with_runtime_env(
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&HashMap::new(),
&HashMap::new(),
);
@@ -801,10 +947,11 @@ fn maybe_wrap_shell_lc_with_snapshot_applies_explicit_path_override() {
"printf '%s' \"$PATH\"".to_string(),
];
let explicit_env_overrides = HashMap::from([("PATH".to_string(), "/worktree/bin".to_string())]);
let rewritten = maybe_wrap_shell_lc_with_snapshot(
let rewritten = maybe_wrap_shell_command_with_runtime_env(
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&explicit_env_overrides,
&HashMap::from([("PATH".to_string(), "/worktree/bin".to_string())]),
);
@@ -842,10 +989,11 @@ fn maybe_wrap_shell_lc_with_snapshot_does_not_embed_override_values_in_argv() {
"OPENAI_API_KEY".to_string(),
"super-secret-value".to_string(),
)]);
let rewritten = maybe_wrap_shell_lc_with_snapshot(
let rewritten = maybe_wrap_shell_command_with_runtime_env(
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&explicit_env_overrides,
&HashMap::from([(
"OPENAI_API_KEY".to_string(),
@@ -890,10 +1038,11 @@ fn maybe_wrap_shell_lc_with_snapshot_preserves_unset_override_variables() {
"CODEX_TEST_UNSET_OVERRIDE".to_string(),
"worktree-value".to_string(),
)]);
let rewritten = maybe_wrap_shell_lc_with_snapshot(
let rewritten = maybe_wrap_shell_command_with_runtime_env(
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&explicit_env_overrides,
&HashMap::new(),
);
+10 -4
View File
@@ -23,7 +23,8 @@ use crate::tools::network_approval::NetworkApprovalSpec;
use crate::tools::runtimes::build_sandbox_command;
use crate::tools::runtimes::disable_powershell_profile_for_elevated_windows_sandbox;
use crate::tools::runtimes::exec_env_for_sandbox_permissions;
use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot;
use crate::tools::runtimes::maybe_wrap_shell_command_with_runtime_env;
use crate::tools::runtimes::with_shell_env_file_read_permission;
use crate::tools::sandboxing::Approvable;
use crate::tools::sandboxing::ApprovalCtx;
use crate::tools::sandboxing::ExecApprovalRequirement;
@@ -232,10 +233,12 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
let managed_network =
managed_network_for_sandbox_permissions(req.network.as_ref(), req.sandbox_permissions);
let env = exec_env_for_sandbox_permissions(&req.env, req.sandbox_permissions);
let command = maybe_wrap_shell_lc_with_snapshot(
let shell_env_file_path = ctx.session.shell_env_file_path();
let command = maybe_wrap_shell_command_with_runtime_env(
&req.command,
session_shell.as_ref(),
&req.cwd,
shell_env_file_path,
&req.explicit_env_overrides,
&env,
);
@@ -262,8 +265,11 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
}
}
let command =
build_sandbox_command(&command, &req.cwd, &env, req.additional_permissions.clone())?;
let additional_permissions = with_shell_env_file_read_permission(
req.additional_permissions.clone(),
shell_env_file_path,
);
let command = build_sandbox_command(&command, &req.cwd, &env, additional_permissions)?;
let mut expiration: crate::exec::ExecExpiration = req.timeout_ms.into();
if let Some(cancellation) = attempt.network_denial_cancellation_token.clone() {
expiration = expiration.with_cancellation(cancellation);
@@ -16,6 +16,7 @@ use crate::sandboxing::SandboxPermissions;
use crate::shell::ShellType;
use crate::tools::runtimes::build_sandbox_command;
use crate::tools::runtimes::exec_env_for_sandbox_permissions;
use crate::tools::runtimes::with_shell_env_file_read_permission;
use crate::tools::sandboxing::PermissionRequestPayload;
use crate::tools::sandboxing::SandboxAttempt;
use crate::tools::sandboxing::ToolCtx;
@@ -117,8 +118,11 @@ pub(super) async fn try_run_zsh_fork(
}
let env = exec_env_for_sandbox_permissions(&req.env, req.sandbox_permissions);
let command =
build_sandbox_command(command, &req.cwd, &env, req.additional_permissions.clone())?;
let additional_permissions = with_shell_env_file_read_permission(
req.additional_permissions.clone(),
ctx.session.shell_env_file_path(),
);
let command = build_sandbox_command(command, &req.cwd, &env, additional_permissions)?;
let options = ExecOptions {
expiration: req.timeout_ms.into(),
capture_policy: ExecCapturePolicy::ShellTool,
@@ -20,8 +20,9 @@ use crate::tools::network_approval::NetworkApprovalSpec;
use crate::tools::runtimes::build_sandbox_command;
use crate::tools::runtimes::disable_powershell_profile_for_elevated_windows_sandbox;
use crate::tools::runtimes::exec_env_for_sandbox_permissions;
use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot;
use crate::tools::runtimes::maybe_wrap_shell_command_with_runtime_env;
use crate::tools::runtimes::shell::zsh_fork_backend;
use crate::tools::runtimes::with_shell_env_file_read_permission;
use crate::tools::sandboxing::Approvable;
use crate::tools::sandboxing::ApprovalCtx;
use crate::tools::sandboxing::ExecApprovalRequirement;
@@ -262,13 +263,17 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
network.apply_to_env(&mut env);
}
let environment_is_remote = req.environment.is_remote();
let shell_env_file_path = (!environment_is_remote)
.then(|| ctx.session.shell_env_file_path())
.flatten();
let command = if environment_is_remote {
base_command.to_vec()
} else {
maybe_wrap_shell_lc_with_snapshot(
maybe_wrap_shell_command_with_runtime_env(
base_command,
session_shell.as_ref(),
&req.cwd,
shell_env_file_path,
&req.explicit_env_overrides,
&env,
)
@@ -284,10 +289,14 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
} else {
command
};
let additional_permissions = with_shell_env_file_read_permission(
req.additional_permissions.clone(),
shell_env_file_path,
);
if let UnifiedExecShellMode::ZshFork(zsh_fork_config) = &self.shell_mode {
let command =
build_sandbox_command(&command, &req.cwd, &env, req.additional_permissions.clone())
build_sandbox_command(&command, &req.cwd, &env, additional_permissions.clone())
.map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?;
let options = unified_exec_options(attempt.network_denial_cancellation_token.clone());
let mut exec_env = attempt
@@ -337,9 +346,8 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
}
}
}
let command =
build_sandbox_command(&command, &req.cwd, &env, req.additional_permissions.clone())
.map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?;
let command = build_sandbox_command(&command, &req.cwd, &env, additional_permissions)
.map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?;
let options = unified_exec_options(attempt.network_denial_cancellation_token.clone());
let mut exec_env = attempt
.env_for(command, options, managed_network)
@@ -1010,6 +1010,9 @@ impl UnifiedExecProcessManager {
CODEX_THREAD_ID_ENV_VAR.to_string(),
context.session.conversation_id.to_string(),
);
if !request.environment.is_remote() {
context.session.insert_shell_env_file(&mut env);
}
let env = apply_unified_exec_env(env);
let exec_server_env_config = ExecServerEnvConfig {
policy: exec_env_policy_from_shell_policy(&context.turn.shell_environment_policy),
+184 -1
View File
@@ -778,6 +778,52 @@ print(json.dumps({{
Ok(())
}
fn write_session_start_hook_exporting_env(home: &Path) -> Result<()> {
let session_start_script_path = home.join("session_start_env_hook.py");
let session_start_script = r#"import os
from pathlib import Path
env_file = Path(os.environ["CODEX_ENV_FILE"])
with env_file.open("a", encoding="utf-8") as handle:
handle.write("export CODEX_SESSION_START_TEST='from-session-start'\n")
"#;
let pre_tool_use_script_path = home.join("pre_tool_use_env_hook.py");
let pre_tool_use_script = r#"import os
from pathlib import Path
env_file = os.environ.get("CODEX_ENV_FILE")
if env_file:
with Path(env_file).open("a", encoding="utf-8") as handle:
handle.write("export CODEX_SESSION_START_TEST='leaked-to-pre-tool-use'\n")
"#;
let hooks = serde_json::json!({
"hooks": {
"SessionStart": [{
"hooks": [{
"type": "command",
"command": format!("python3 {}", session_start_script_path.display()),
"statusMessage": "exporting session environment",
}]
}],
"PreToolUse": [{
"matcher": "^Bash$",
"hooks": [{
"type": "command",
"command": format!("python3 {}", pre_tool_use_script_path.display()),
"statusMessage": "checking session environment isolation",
}]
}]
}
});
fs::write(&session_start_script_path, session_start_script)
.context("write session start env hook script")?;
fs::write(&pre_tool_use_script_path, pre_tool_use_script)
.context("write pre tool use env hook script")?;
fs::write(home.join("hooks.json"), hooks.to_string()).context("write hooks.json")?;
Ok(())
}
fn write_compact_session_start_hook_with_context(
home: &Path,
additional_context: &str,
@@ -2607,7 +2653,7 @@ async fn blocked_pre_tool_use_records_additional_context_for_shell_command() ->
Ok(())
}
#[derive(Clone, Copy)]
#[derive(Clone, Copy, Debug)]
enum BashRewriteSurface {
ExecCommand,
ShellCommand,
@@ -2663,6 +2709,143 @@ impl BashRewriteSurface {
}
}
async fn assert_session_start_env_file_reaches_bash_surface(
surface: BashRewriteSurface,
) -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_windows!(Ok(()));
let server = start_mock_server().await;
let call_id = format!("session-start-env-file-{}", surface.slug());
let command = "printf '%s' \"$CODEX_SESSION_START_TEST\"";
let tool_call = match surface {
BashRewriteSurface::ExecCommand => ev_function_call(
&call_id,
"exec_command",
&serde_json::to_string(&serde_json::json!({ "cmd": command }))?,
),
BashRewriteSurface::ShellCommand => ev_function_call(
&call_id,
"shell_command",
&serde_json::to_string(&serde_json::json!({
"command": command,
"login": false,
}))?,
),
};
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
tool_call,
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "session environment observed"),
ev_completed("resp-2"),
]),
],
)
.await;
let mut builder = test_codex()
.with_pre_build_hook(|home| {
if let Err(error) = write_session_start_hook_exporting_env(home) {
panic!("failed to write session env hook fixture: {error}");
}
})
.with_config(move |config| surface.configure(config));
let test = builder.build(&server).await?;
test.submit_turn_with_permission_profile(
"read the exported session environment",
PermissionProfile::Disabled,
)
.await?;
let requests = responses.requests();
assert_eq!(requests.len(), 2);
let output = requests[1].function_call_output(&call_id).to_string();
assert!(
output.contains("from-session-start"),
"expected hook-exported value in {surface:?} output: {output}"
);
assert!(
!output.contains("leaked-to-pre-tool-use"),
"PreToolUse should not receive CODEX_ENV_FILE: {output}"
);
Ok(())
}
#[tokio::test]
async fn session_start_env_file_is_sourced_by_shell_command() -> Result<()> {
assert_session_start_env_file_reaches_bash_surface(BashRewriteSurface::ShellCommand).await
}
#[tokio::test]
async fn session_start_env_file_is_sourced_by_exec_command() -> Result<()> {
assert_session_start_env_file_reaches_bash_surface(BashRewriteSurface::ExecCommand).await
}
#[tokio::test]
async fn shell_command_exposes_codex_env_file_when_hooks_are_disabled() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_windows!(Ok(()));
let server = start_mock_server().await;
let call_id = "codex-env-file-without-hooks";
let responses = mount_sse_sequence(
&server,
vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(
call_id,
"shell_command",
&serde_json::to_string(&serde_json::json!({
"command": "printf '%s' \"${CODEX_ENV_FILE:+configured}\"",
"login": false,
}))?,
),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_assistant_message("msg-1", "session env file available"),
ev_completed("resp-2"),
]),
],
)
.await;
let mut builder = test_codex().with_config(|config| {
if let Err(error) = config.features.disable(Feature::CodexHooks) {
panic!("test config should allow feature update: {error}");
}
});
let test = builder.build(&server).await?;
test.submit_turn_with_permission_profile(
"check whether the session environment file is available",
PermissionProfile::Disabled,
)
.await?;
let requests = responses.requests();
assert_eq!(requests.len(), 2);
assert!(
requests[1]
.function_call_output(call_id)
.to_string()
.contains("configured")
);
Ok(())
}
async fn assert_pre_tool_use_rewrites_bash_surface(surface: BashRewriteSurface) -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -7,6 +7,8 @@ use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tokio::time::timeout;
use codex_protocol::protocol::HookEventName;
use super::CommandShell;
use super::ConfiguredHandler;
@@ -113,6 +115,10 @@ fn build_command(shell: &CommandShell, handler: &ConfiguredHandler) -> Command {
command.arg(&handler.command);
}
command.envs(&handler.env);
// CODEX_ENV_FILE is only available for SessionStart hook types
if handler.event_name == HookEventName::SessionStart {
command.envs(&shell.env);
}
command
}
+1
View File
@@ -36,6 +36,7 @@ use std::collections::HashMap;
pub(crate) struct CommandShell {
pub program: String,
pub args: Vec<String>,
pub env: HashMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
+17
View File
@@ -203,6 +203,7 @@ with Path(r"{log_path}").open("a", encoding="utf-8") as handle:
CommandShell {
program: String::new(),
args: Vec::new(),
env: HashMap::new(),
},
);
@@ -218,6 +219,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(),
command_env: HashMap::new(),
});
assert!(listed.hooks[0].is_managed);
let cwd = cwd();
@@ -306,6 +308,7 @@ async fn requirements_managed_hooks_execute_windows_command_override() {
CommandShell {
program: String::new(),
args: Vec::new(),
env: HashMap::new(),
},
);
@@ -385,6 +388,7 @@ fn unknown_requirement_source_hooks_stay_managed() {
CommandShell {
program: String::new(),
args: Vec::new(),
env: HashMap::new(),
},
);
@@ -467,6 +471,7 @@ fn user_disablement_filters_non_managed_hooks_but_not_managed_hooks() {
CommandShell {
program: String::new(),
args: Vec::new(),
env: HashMap::new(),
},
);
@@ -530,6 +535,7 @@ fn user_disablement_does_not_filter_managed_layer_hooks() {
CommandShell {
program: String::new(),
args: Vec::new(),
env: HashMap::new(),
},
);
@@ -691,6 +697,7 @@ fn requirements_managed_hooks_load_when_managed_dir_is_missing() {
CommandShell {
program: String::new(),
args: Vec::new(),
env: HashMap::new(),
},
);
@@ -747,6 +754,7 @@ fn allow_managed_hooks_only_false_keeps_unmanaged_hooks() {
CommandShell {
program: String::new(),
args: Vec::new(),
env: HashMap::new(),
},
);
@@ -801,6 +809,7 @@ fn allow_managed_hooks_only_in_config_toml_does_not_enable_policy() {
CommandShell {
program: String::new(),
args: Vec::new(),
env: HashMap::new(),
},
);
@@ -871,6 +880,7 @@ fn allow_managed_hooks_only_skips_unmanaged_json_and_toml_hooks() {
CommandShell {
program: String::new(),
args: Vec::new(),
env: HashMap::new(),
},
);
@@ -910,6 +920,7 @@ fn allow_managed_hooks_only_skips_unmanaged_plugin_hooks() {
CommandShell {
program: String::new(),
args: Vec::new(),
env: HashMap::new(),
},
);
@@ -982,6 +993,7 @@ fn allow_managed_hooks_only_keeps_managed_requirement_and_config_layer_hooks() {
CommandShell {
program: String::new(),
args: Vec::new(),
env: HashMap::new(),
},
);
@@ -1092,6 +1104,7 @@ fn discovers_hooks_from_json_and_toml_in_the_same_layer() {
CommandShell {
program: String::new(),
args: Vec::new(),
env: HashMap::new(),
},
);
@@ -1185,6 +1198,7 @@ print(json.dumps({
CommandShell {
program: String::new(),
args: Vec::new(),
env: HashMap::new(),
},
);
@@ -1213,6 +1227,7 @@ print(json.dumps({
plugin_hook_load_warnings: Vec::new(),
shell_program: None,
shell_args: Vec::new(),
command_env: HashMap::new(),
});
assert_eq!(
listed.hooks[0].plugin_id.as_deref(),
@@ -1299,6 +1314,7 @@ fn plugin_hook_sources_expand_plugin_placeholders() {
CommandShell {
program: String::new(),
args: Vec::new(),
env: HashMap::new(),
},
);
@@ -1343,6 +1359,7 @@ fn plugin_hook_load_warnings_are_startup_warnings() {
CommandShell {
program: String::new(),
args: Vec::new(),
env: HashMap::new(),
},
);
+3
View File
@@ -1,5 +1,6 @@
use codex_config::ConfigLayerStack;
use codex_plugin::PluginHookSource;
use std::collections::HashMap;
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 command_env: HashMap<String, String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
@@ -73,6 +75,7 @@ impl Hooks {
CommandShell {
program: config.shell_program.unwrap_or_default(),
args: config.shell_args,
env: config.command_env,
},
);
Self {