mirror of
https://github.com/openai/codex.git
synced 2026-06-02 19:31:59 +00:00
Compare commits
4 Commits
etraut/win
...
abhinav/co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ecd72ff13 | ||
|
|
28dff23b2f | ||
|
|
16190659f5 | ||
|
|
5bccb54bdf |
333
codex-rs/core/src/hook_env.rs
Normal file
333
codex-rs/core/src/hook_env.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>>>,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(()));
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()]);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user