mirror of
https://github.com/openai/codex.git
synced 2026-10-21 17:48:44 +00:00
Add CODEX_ENV_FILE for SessionStart hooks
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user