Simplify session env file exports

This commit is contained in:
Abhinav Vedmala
2026-05-28 21:22:15 -07:00
parent 4753f76d38
commit 8cb24dcd1f
13 changed files with 388 additions and 297 deletions

View File

@@ -40,6 +40,7 @@ use serde_json::Value;
use crate::context::ContextualUserFragment;
use crate::context::HookAdditionalContext;
use crate::event_mapping::parse_turn_item;
use crate::exec_env::create_env;
use crate::session::TurnInput;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
@@ -124,6 +125,7 @@ pub(crate) async fn run_pending_session_start_hooks(
source: session_start_source,
},
};
let captures_shell_env = matches!(target, StartHookTarget::SessionStart { .. });
let request = codex_hooks::SessionStartRequest {
session_id: sess.session_id().into(),
#[allow(deprecated)]
@@ -135,15 +137,30 @@ pub(crate) async fn run_pending_session_start_hooks(
};
let hooks = sess.hooks();
let preview_runs = hooks.preview_session_start(&request);
if run_context_injecting_hook(
sess,
turn_context,
preview_runs,
hooks.run_session_start(request, Some(turn_context.sub_id.clone())),
)
.await
.record_additional_contexts(sess, turn_context)
.await
emit_hook_started_events(sess, turn_context, preview_runs).await;
let outcome = hooks
.run_session_start(request, Some(turn_context.sub_id.clone()))
.await;
if captures_shell_env {
let base_env = create_env(
&turn_context.shell_environment_policy,
Some(sess.conversation_id),
);
let cwd = turn_context
.environments
.primary()
.map(|environment| environment.cwd.as_path())
.unwrap_or_else(|| turn_context.config.cwd.as_path());
if let Err(error) = sess.capture_shell_env_exports(cwd, &base_env).await {
tracing::warn!("failed to capture SessionStart shell environment: {error:#}");
}
}
let outcome: ContextInjectingHookOutcome = outcome.into();
emit_hook_completed_events(sess, turn_context, outcome.hook_events).await;
if outcome
.outcome
.record_additional_contexts(sess, turn_context)
.await
{
return true;
}

View File

@@ -3223,16 +3223,27 @@ 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) async fn capture_shell_env_exports(
&self,
cwd: &Path,
base_env: &HashMap<String, String>,
) -> anyhow::Result<()> {
if let Some(shell_env_file) = self.services.shell_env_file.as_ref() {
let shell = self.user_shell();
shell_env_file
.capture_exports(shell.as_ref(), cwd, base_env)
.await?;
}
Ok(())
}
pub(crate) fn insert_shell_env_file(&self, env: &mut HashMap<String, String>) {
pub(crate) fn apply_shell_env_exports(
&self,
env: &mut HashMap<String, String>,
explicit_env_overrides: &HashMap<String, String>,
) {
if let Some(shell_env_file) = self.services.shell_env_file.as_ref() {
shell_env_file.insert_into_env(env);
shell_env_file.apply_exports(env, explicit_env_overrides);
}
}
@@ -3317,7 +3328,7 @@ async fn build_hooks_for_config(
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);
shell_env_file.insert_path_into_env(&mut command_env);
}
Hooks::new(HooksConfig {
legacy_notify_argv: config.notify.clone(),

View File

@@ -1,19 +1,28 @@
use std::collections::HashMap;
use std::path::Path;
use std::sync::Mutex;
#[cfg(not(windows))]
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use anyhow::bail;
use codex_protocol::ThreadId;
use codex_protocol::shell_environment::CODEX_ENV_FILE_ENV_VAR;
use codex_protocol::shell_environment::CODEX_THREAD_ID_ENV_VAR;
use tempfile::TempPath;
use tokio::process::Command;
use crate::shell::Shell;
use crate::shell::ShellType;
/// 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.
/// Only lifecycle hooks receive the writable file path. After SessionStart
/// hooks finish, Codex captures supported exported variables and passes those
/// values to later commands without exposing the writable path.
pub(crate) struct ShellEnvFile {
path: TempPath,
exports: Mutex<HashMap<String, Option<String>>>,
}
impl ShellEnvFile {
@@ -40,6 +49,7 @@ impl ShellEnvFile {
.context("failed to create temporary shell env file")?;
Ok(Self {
path: file.into_temp_path(),
exports: Mutex::new(HashMap::new()),
})
}
@@ -47,12 +57,184 @@ impl ShellEnvFile {
self.path.as_ref()
}
pub(crate) fn insert_into_env(&self, env: &mut HashMap<String, String>) {
pub(crate) fn insert_path_into_env(&self, env: &mut HashMap<String, String>) {
env.insert(
CODEX_ENV_FILE_ENV_VAR.to_string(),
self.path().to_string_lossy().to_string(),
);
}
/// Sources the hook-writable env file once and stores the resulting
/// environment diff.
///
/// The temp file remains an input channel for SessionStart hooks, but later
/// commands receive only captured variable changes. Running both a baseline
/// environment dump and a sourced dump lets shell syntax such as command
/// substitution behave naturally without keeping `CODEX_ENV_FILE` available
/// after hook execution.
pub(crate) async fn capture_exports(
&self,
shell: &Shell,
cwd: &Path,
base_env: &HashMap<String, String>,
) -> Result<()> {
if !matches!(
shell.shell_type,
ShellType::Zsh | ShellType::Bash | ShellType::Sh
) {
return Ok(());
}
let mut capture_env = base_env.clone();
capture_env.remove(CODEX_ENV_FILE_ENV_VAR);
self.insert_path_into_env(&mut capture_env);
let baseline = capture_env_from_shell(shell, cwd, DUMP_ENV_SCRIPT, &capture_env).await?;
let captured = capture_env_from_shell(
shell,
cwd,
SOURCE_ENV_FILE_AND_DUMP_ENV_SCRIPT,
&capture_env,
)
.await?;
let exports = diff_env(&baseline, &captured);
*self
.exports
.lock()
.map_err(|_| anyhow!("shell env exports lock poisoned"))? = exports;
Ok(())
}
/// Applies captured SessionStart environment changes to a command
/// environment without exposing the writable env-file path.
///
/// Explicit shell-environment policy values are layered back on top so
/// configured overrides keep precedence, and runtime-owned values such as
/// the Codex thread id are preserved rather than accepting hook-written
/// replacements.
pub(crate) fn apply_exports(
&self,
env: &mut HashMap<String, String>,
explicit_env_overrides: &HashMap<String, String>,
) {
let thread_id = env.get(CODEX_THREAD_ID_ENV_VAR).cloned();
let exports = self
.exports
.lock()
.map(|exports| exports.clone())
.unwrap_or_default();
for (key, value) in exports {
if ignored_capture_key(&key) {
continue;
}
match value {
Some(value) => {
env.insert(key, value);
}
None => {
env.remove(&key);
}
}
}
for (key, value) in explicit_env_overrides {
env.insert(key.clone(), value.clone());
}
if let Some(thread_id) = thread_id {
env.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id);
}
env.remove(CODEX_ENV_FILE_ENV_VAR);
}
}
const DUMP_ENV_SCRIPT: &str = r#"if [ -x /usr/bin/env ]; then
/usr/bin/env -0
else
env -0
fi"#;
const SOURCE_ENV_FILE_AND_DUMP_ENV_SCRIPT: &str = r#"if [ -n "${CODEX_ENV_FILE:-}" ] && [ -f "$CODEX_ENV_FILE" ]; then
if . "$CODEX_ENV_FILE" >/dev/null 2>&1; then
:
fi
fi
if [ -x /usr/bin/env ]; then
/usr/bin/env -0
else
env -0
fi"#;
async fn capture_env_from_shell(
shell: &Shell,
cwd: &Path,
script: &str,
env: &HashMap<String, String>,
) -> Result<HashMap<String, String>> {
let output = Command::new(&shell.shell_path)
.current_dir(cwd)
.arg("-c")
.arg(script)
.env_clear()
.envs(env)
.output()
.await
.with_context(|| format!("failed to run {}", shell.shell_path.display()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
bail!(
"failed to capture shell environment with {}: {stderr}",
shell.shell_path.display()
);
}
parse_env_output(&output.stdout)
}
fn parse_env_output(output: &[u8]) -> Result<HashMap<String, String>> {
let mut env = HashMap::new();
for entry in output.split(|byte| *byte == 0) {
if entry.is_empty() {
continue;
}
let Some(separator) = entry.iter().position(|byte| *byte == b'=') else {
continue;
};
let key = String::from_utf8(entry[..separator].to_vec())
.context("captured shell environment key was not UTF-8")?;
let value = String::from_utf8(entry[separator + 1..].to_vec())
.context("captured shell environment value was not UTF-8")?;
env.insert(key, value);
}
Ok(env)
}
fn diff_env(
baseline: &HashMap<String, String>,
captured: &HashMap<String, String>,
) -> HashMap<String, Option<String>> {
let mut exports = HashMap::new();
for (key, value) in captured {
if ignored_capture_key(key) {
continue;
}
if baseline.get(key) != Some(value) {
exports.insert(key.clone(), Some(value.clone()));
}
}
for key in baseline.keys() {
if ignored_capture_key(key) {
continue;
}
if !captured.contains_key(key) {
exports.insert(key.clone(), None);
}
}
exports
}
fn ignored_capture_key(key: &str) -> bool {
matches!(
key,
CODEX_ENV_FILE_ENV_VAR | CODEX_THREAD_ID_ENV_VAR | "PWD" | "OLDPWD" | "SHLVL" | "_"
)
}
#[cfg(all(test, not(windows)))]

View File

@@ -1,7 +1,13 @@
use anyhow::Result;
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::path::PathBuf;
use super::*;
use crate::shell::Shell;
use crate::shell::ShellType;
use crate::shell::empty_shell_snapshot_receiver;
#[cfg(not(windows))]
#[test]
@@ -15,3 +21,103 @@ fn shell_env_file_is_removed_when_session_owner_drops() -> Result<()> {
Ok(())
}
#[cfg(not(windows))]
#[tokio::test]
async fn shell_env_file_applies_exports_without_exposing_writable_path() -> Result<()> {
let env_file = ShellEnvFile::new(ThreadId::new())?;
let base_env = HashMap::from([
("PATH".to_string(), "/usr/bin".to_string()),
(
CODEX_THREAD_ID_ENV_VAR.to_string(),
"real-thread".to_string(),
),
]);
std::fs::write(
env_file.path(),
"\
export CODEX_SESSION_START_TEST='from-session-start'
export PATH=\"/plugin/bin:$PATH\"
export CODEX_ENV_FILE='/tmp/poison'
export CODEX_THREAD_ID='poisoned-thread'
export EXPLICIT_OVERRIDE='from-hook'
",
)?;
let cwd = std::env::current_dir()?;
env_file
.capture_exports(&test_shell(), cwd.as_path(), &base_env)
.await?;
let mut env = base_env;
env.insert(
CODEX_ENV_FILE_ENV_VAR.to_string(),
env_file.path().display().to_string(),
);
let explicit_env_overrides =
HashMap::from([("EXPLICIT_OVERRIDE".to_string(), "from-policy".to_string())]);
env_file.apply_exports(&mut env, &explicit_env_overrides);
assert_eq!(
env,
HashMap::from([
("PATH".to_string(), "/plugin/bin:/usr/bin".to_string()),
(
"CODEX_SESSION_START_TEST".to_string(),
"from-session-start".to_string(),
),
(
CODEX_THREAD_ID_ENV_VAR.to_string(),
"real-thread".to_string(),
),
("EXPLICIT_OVERRIDE".to_string(), "from-policy".to_string()),
])
);
Ok(())
}
#[cfg(not(windows))]
#[tokio::test]
async fn shell_env_file_sources_shell_code_once() -> Result<()> {
let env_file = ShellEnvFile::new(ThreadId::new())?;
std::fs::write(
env_file.path(),
"\
echo hidden
export SAFE=value
export COMMAND_SUBSTITUTION=$(printf unsafe)
export FUNCTION_DEF='() { echo unsafe; }'
",
)?;
let cwd = std::env::current_dir()?;
env_file
.capture_exports(&test_shell(), cwd.as_path(), &HashMap::new())
.await?;
let mut env = HashMap::new();
env_file.apply_exports(&mut env, &HashMap::new());
assert_eq!(
env,
HashMap::from([
("SAFE".to_string(), "value".to_string()),
("COMMAND_SUBSTITUTION".to_string(), "unsafe".to_string()),
(
"FUNCTION_DEF".to_string(),
"() { echo unsafe; }".to_string(),
),
])
);
Ok(())
}
#[cfg(not(windows))]
fn test_shell() -> Shell {
Shell {
shell_type: ShellType::Sh,
shell_path: PathBuf::from("/bin/sh"),
shell_snapshot: empty_shell_snapshot_receiver(),
}
}

View File

@@ -130,7 +130,10 @@ 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);
session.apply_shell_env_exports(
&mut exec_env_map,
&turn_context.shell_environment_policy.r#set,
);
if exec_env_map.contains_key(PROXY_ACTIVE_ENV_KEY) {
for key in PROXY_ENV_KEYS {
exec_env_map.remove(*key);
@@ -150,7 +153,6 @@ pub(crate) async fn execute_user_shell_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,
);

View File

@@ -96,7 +96,7 @@ impl ShellCommandHandler {
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);
session.apply_shell_env_exports(&mut env, &turn_context.shell_environment_policy.r#set);
Ok(ExecParams {
command,

View File

@@ -18,10 +18,6 @@ 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_protocol::shell_environment::CODEX_ENV_FILE_ENV_VAR;
use codex_sandboxing::SandboxCommand;
use codex_sandboxing::SandboxType;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -75,29 +71,6 @@ 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
}
#[cfg(unix)]
fn prepend_path_entry(env: &mut HashMap<String, String>, path_entry: &str) -> String {
let updated_path = match env.get("PATH") {
@@ -165,33 +138,26 @@ pub(crate) fn disable_powershell_profile_for_elevated_windows_sandbox(
command
}
/// 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 -c/-lc "<script>"
/// => shell -c/-lc ". ENV_FILE; <script>"
/// POSIX-only wrapper for commands produced by `Shell::derive_exec_args` when a
/// login-shell snapshot must be sourced before the user script:
///
/// shell -lc "<script>" with a matching snapshot
/// => user_shell -c ". SNAPSHOT; . ENV_FILE; exec shell -c <script>"
/// => user_shell -c ". SNAPSHOT; exec shell -c <script>"
///
/// This wrapper script uses POSIX constructs (`if`, `.`, `exec`) so it can
/// 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. Without a snapshot, the original
/// script stays in the original shell so login-shell-local initialization
/// remains visible to it.
/// be run by Bash/Zsh/sh. 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` and `CODEX_ENV_FILE` without
/// pretending they came from the explicit override policy.
/// runtime-only vars like `CODEX_THREAD_ID` 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> {
@@ -205,78 +171,45 @@ pub(crate) fn maybe_wrap_shell_command_with_runtime_env(
}
let flag = command[1].as_str();
if flag != "-c" && flag != "-lc" {
if flag != "-lc" {
return command.to_vec();
}
// 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() {
let Some(snapshot) = session_shell.shell_snapshot().filter(|snapshot| {
snapshot.path.exists()
&& path_utils::paths_match_after_normalization(snapshot.cwd.as_path(), cwd)
}) else {
return command.to_vec();
}
};
let has_snapshot = snapshot.is_some();
let mut override_env = explicit_env_overrides.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());
}
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 (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 execution = if has_snapshot {
let original_shell = shell_single_quote(&command[0]);
let original_script = shell_single_quote(&command[2]);
let trailing_args = command[3..]
.iter()
.map(|arg| format!(" '{}'", shell_single_quote(arg)))
.collect::<String>();
format!("exec '{original_shell}' -c '{original_script}'{trailing_args}")
} else {
command[2].clone()
};
let path = shell_single_quote(&snapshot.path.to_string_lossy());
let source_commands = format!("if . '{path}' >/dev/null 2>&1; then :; fi");
let original_shell = shell_single_quote(&command[0]);
let original_script = shell_single_quote(&command[2]);
let trailing_args = command[3..]
.iter()
.map(|arg| format!(" '{}'", shell_single_quote(arg)))
.collect::<String>();
let execution = format!("exec '{original_shell}' -c '{original_script}'{trailing_args}");
let rewritten_script = if override_exports.is_empty() {
format!("{source_commands}\n\n{execution}")
} else {
format!("{override_captures}\n\n{source_commands}\n\n{override_exports}\n\n{execution}")
};
if has_snapshot {
vec![
session_shell.shell_path.to_string_lossy().to_string(),
"-c".to_string(),
rewritten_script,
]
} else {
let mut rewritten_command = command.to_vec();
rewritten_command[2] = rewritten_script;
rewritten_command
}
}
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))
vec![
session_shell.shell_path.to_string_lossy().to_string(),
"-c".to_string(),
rewritten_script,
]
}
fn build_override_exports(explicit_env_overrides: &HashMap<String, String>) -> (String, String) {

View File

@@ -20,17 +20,12 @@ 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;
use core_test_support::PathBufExt;
use core_test_support::PathExt;
use pretty_assertions::assert_eq;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;
use std::process::Command;
use std::sync::Arc;
@@ -230,7 +225,6 @@ fn maybe_wrap_shell_lc_with_snapshot_bootstraps_in_user_shell() {
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&HashMap::new(),
&HashMap::new(),
);
@@ -242,79 +236,8 @@ fn maybe_wrap_shell_lc_with_snapshot_bootstraps_in_user_shell() {
}
#[test]
fn maybe_wrap_shell_command_sources_session_env_file_without_snapshot() {
fn maybe_wrap_shell_command_skips_non_login_command_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"),
@@ -330,7 +253,6 @@ fn maybe_wrap_shell_command_skips_empty_session_env_file() {
&command,
&session_shell,
&dir.path().abs(),
Some(env_file.as_path()),
&HashMap::new(),
&HashMap::new(),
);
@@ -338,56 +260,6 @@ fn maybe_wrap_shell_command_skips_empty_session_env_file() {
assert_eq!(rewritten, command);
}
#[cfg(unix)]
#[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 shell_path = dir.path().join("login-shell.sh");
std::fs::write(
&shell_path,
"#!/bin/sh\nif [ \"$1\" = \"-lc\" ]; then\n LOGIN_ONLY='from-login'\nfi\nscript=\"$2\"\nshift 2\neval \"$script\"\n",
)
.expect("write login shell");
let mut permissions = std::fs::metadata(&shell_path)
.expect("read login shell permissions")
.permissions();
permissions.set_mode(0o755);
std::fs::set_permissions(&shell_path, permissions).expect("make login shell executable");
let shell_path = shell_path.to_string_lossy().to_string();
let session_shell = Shell {
shell_type: ShellType::Bash,
shell_path: PathBuf::from(&shell_path),
shell_snapshot: crate::shell::empty_shell_snapshot_receiver(),
};
let command = vec![
shell_path,
"-lc".to_string(),
"printf '%s|%s' \"$LOGIN_ONLY\" \"$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(),
);
let output = Command::new(&rewritten[0])
.args(&rewritten[1..])
.output()
.expect("run rewritten command");
assert!(output.status.success(), "command failed: {output:?}");
assert_eq!(rewritten[1], "-lc");
assert_eq!(
String::from_utf8_lossy(&output.stdout),
"from-login|hook-value"
);
}
#[test]
fn maybe_wrap_shell_lc_with_snapshot_escapes_single_quotes() {
let dir = tempdir().expect("create temp dir");
@@ -409,7 +281,6 @@ fn maybe_wrap_shell_lc_with_snapshot_escapes_single_quotes() {
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&HashMap::new(),
&HashMap::new(),
);
@@ -438,7 +309,6 @@ fn maybe_wrap_shell_lc_with_snapshot_uses_bash_bootstrap_shell() {
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&HashMap::new(),
&HashMap::new(),
);
@@ -470,7 +340,6 @@ fn maybe_wrap_shell_lc_with_snapshot_uses_sh_bootstrap_shell() {
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&HashMap::new(),
&HashMap::new(),
);
@@ -504,7 +373,6 @@ fn maybe_wrap_shell_lc_with_snapshot_preserves_trailing_args() {
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&HashMap::new(),
&HashMap::new(),
);
@@ -540,7 +408,6 @@ fn maybe_wrap_shell_lc_with_snapshot_skips_when_cwd_mismatch() {
&command,
&session_shell,
&command_cwd.abs(),
/*env_file*/ None,
&HashMap::new(),
&HashMap::new(),
);
@@ -570,7 +437,6 @@ fn maybe_wrap_shell_lc_with_snapshot_accepts_dot_alias_cwd() {
&command,
&session_shell,
&command_cwd.abs(),
/*env_file*/ None,
&HashMap::new(),
&HashMap::new(),
);
@@ -607,7 +473,6 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_explicit_override_precedence() {
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&explicit_env_overrides,
&HashMap::from([("TEST_ENV_SNAPSHOT".to_string(), "worktree".to_string())]),
);
@@ -648,7 +513,6 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_codex_thread_id_from_env() {
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&HashMap::new(),
&HashMap::from([("CODEX_THREAD_ID".to_string(), "nested-thread".to_string())]),
);
@@ -691,7 +555,6 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_proxy_env_from_process_env() {
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&HashMap::new(),
&HashMap::new(),
);
@@ -749,7 +612,6 @@ fn maybe_wrap_shell_lc_with_snapshot_refreshes_codex_proxy_git_ssh_command() {
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&HashMap::new(),
&HashMap::new(),
);
@@ -795,7 +657,6 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_custom_git_ssh_command() {
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&HashMap::new(),
&HashMap::new(),
);
@@ -842,7 +703,6 @@ fn maybe_wrap_shell_lc_with_snapshot_clears_stale_codex_git_ssh_command_without_
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&HashMap::new(),
&HashMap::new(),
);
@@ -880,7 +740,6 @@ fn maybe_wrap_shell_lc_with_snapshot_keeps_user_proxy_env_when_proxy_inactive()
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&HashMap::new(),
&HashMap::new(),
);
@@ -931,7 +790,6 @@ fn maybe_wrap_shell_lc_with_snapshot_restores_live_env_when_snapshot_proxy_activ
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&HashMap::new(),
&HashMap::from([(
"HTTP_PROXY".to_string(),
@@ -977,7 +835,6 @@ fn maybe_wrap_shell_lc_with_snapshot_keeps_snapshot_path_without_override() {
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&HashMap::new(),
&HashMap::new(),
);
@@ -1015,7 +872,6 @@ fn maybe_wrap_shell_lc_with_snapshot_applies_explicit_path_override() {
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&explicit_env_overrides,
&HashMap::from([("PATH".to_string(), "/worktree/bin".to_string())]),
);
@@ -1064,7 +920,6 @@ fn maybe_wrap_shell_lc_with_snapshot_preserves_zsh_fork_path_prepend() {
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&explicit_env_overrides,
&env,
);
@@ -1109,7 +964,6 @@ fn maybe_wrap_shell_lc_with_snapshot_does_not_embed_override_values_in_argv() {
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&explicit_env_overrides,
&HashMap::from([(
"OPENAI_API_KEY".to_string(),
@@ -1158,7 +1012,6 @@ fn maybe_wrap_shell_lc_with_snapshot_preserves_unset_override_variables() {
&command,
&session_shell,
&dir.path().abs(),
/*env_file*/ None,
&explicit_env_overrides,
&HashMap::new(),
);

View File

@@ -26,7 +26,6 @@ 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_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;
@@ -249,12 +248,10 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
}
(env, explicit_env_overrides)
};
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,
&explicit_env_overrides,
&env,
);
@@ -281,10 +278,7 @@ impl ToolRuntime<ShellRequest, ExecToolCallOutput> for ShellRuntime {
}
}
let additional_permissions = with_shell_env_file_read_permission(
req.additional_permissions.clone(),
shell_env_file_path,
);
let additional_permissions = req.additional_permissions.clone();
let command = build_sandbox_command(&command, &req.cwd, &env, additional_permissions)?;
let mut expiration: crate::exec::ExecExpiration = req.timeout_ms.into();
expiration = expiration.with_cancellation(req.cancellation_token.clone());

View File

@@ -17,7 +17,6 @@ use crate::shell::ShellType;
use crate::tools::runtimes::build_sandbox_command;
use crate::tools::runtimes::exec_env_for_sandbox_permissions;
use crate::tools::runtimes::prepend_zsh_fork_bin_to_path;
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;
@@ -120,10 +119,7 @@ pub(super) async fn try_run_zsh_fork(
let mut env = exec_env_for_sandbox_permissions(&req.env, req.sandbox_permissions);
prepend_zsh_fork_bin_to_path(&mut env, shell_zsh_path);
let additional_permissions = with_shell_env_file_read_permission(
req.additional_permissions.clone(),
ctx.session.shell_env_file_path(),
);
let additional_permissions = req.additional_permissions.clone();
let command = build_sandbox_command(command, &req.cwd, &env, additional_permissions)?;
let options = ExecOptions {
expiration: req.timeout_ms.into(),

View File

@@ -24,7 +24,6 @@ use crate::tools::runtimes::disable_powershell_profile_for_elevated_windows_sand
use crate::tools::runtimes::exec_env_for_sandbox_permissions;
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;
@@ -277,18 +276,13 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
}
explicit_env_overrides
};
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 {
let command = if req.environment.is_remote() {
base_command.to_vec()
} else {
maybe_wrap_shell_command_with_runtime_env(
base_command,
session_shell.as_ref(),
&req.cwd,
shell_env_file_path,
&explicit_env_overrides,
&env,
)
@@ -304,10 +298,7 @@ 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,
);
let additional_permissions = req.additional_permissions.clone();
if let UnifiedExecShellMode::ZshFork(zsh_fork_config) = &self.shell_mode {
let command =

View File

@@ -1005,7 +1005,9 @@ impl UnifiedExecProcessManager {
context.session.conversation_id.to_string(),
);
if !request.environment.is_remote() {
context.session.insert_shell_env_file(&mut env);
context
.session
.apply_shell_env_exports(&mut env, &context.turn.shell_environment_policy.r#set);
}
let env = apply_unified_exec_env(env);
let exec_server_env_config = ExecServerEnvConfig {

View File

@@ -2717,7 +2717,7 @@ async fn assert_session_start_env_file_reaches_bash_surface(
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 command = "printf '%s|%s' \"$CODEX_SESSION_START_TEST\" \"${CODEX_ENV_FILE:+configured}\"";
let tool_call = match surface {
BashRewriteSurface::ExecCommand => ev_function_call(
&call_id,
@@ -2772,6 +2772,10 @@ async fn assert_session_start_env_file_reaches_bash_surface(
output.contains("from-session-start"),
"expected hook-exported value in {surface:?} output: {output}"
);
assert!(
output.contains("from-session-start|"),
"expected CODEX_ENV_FILE to stay hidden from {surface:?} command: {output}"
);
assert!(
!output.contains("leaked-to-pre-tool-use"),
"PreToolUse should not receive CODEX_ENV_FILE: {output}"
@@ -2781,17 +2785,17 @@ async fn assert_session_start_env_file_reaches_bash_surface(
}
#[tokio::test]
async fn session_start_env_file_is_sourced_by_shell_command() -> Result<()> {
async fn session_start_env_file_exports_reach_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<()> {
async fn session_start_env_file_exports_reach_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<()> {
async fn shell_command_hides_codex_env_file_when_hooks_are_disabled() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_windows!(Ok(()));
@@ -2837,7 +2841,7 @@ async fn shell_command_exposes_codex_env_file_when_hooks_are_disabled() -> Resul
let requests = responses.requests();
assert_eq!(requests.len(), 2);
assert!(
requests[1]
!requests[1]
.function_call_output(call_id)
.to_string()
.contains("configured")