Repository navigation
Description
Summary
Codex rewrites every shell tool call into bash -lc "". On macOS (and any system where /etc/profile invokes path_helper), that login shell rebuilds PATH and prepends system entries, so the user’s PATH ordering is lost before their command runs. The mutation breaks toolchains that rely on PATH priority—e.g., Nix-provided cargo shims fall behind Apple’s toolchain, yielding missing libxml2 when Codex replays cargo clippy while the same command succeeds in the user’s terminal.
Impact
- Nix-managed toolchains, language shims, or custom PATH prefixes stop working inside Codex even though they work locally.
- PATH order is globally scrambled, so any expectation about “my bin directory wins” fails, independent of macOS.
- Commands that implicitly depend on PATH (dynamic linker lookup, plugin discovery, wrapper scripts) exhibit inconsistent behavior between Codex and a real terminal.
Environment
- macOS 14.6.1 (Apple Silicon)
- Shared Nix shell
- User login shell: /bin/zsh
- Codex CLI default shell translation logic (Shell::Bash, use_profile = true)
Steps to Reproduce
- In a regular terminal:
export PATH="/tmp/test-bin:/usr/bin"
echo "Before: $PATH"
/bin/bash -lc 'echo "bash -lc: $PATH"'
bin/bash -c 'echo "bash -c: $PATH"'
/bin/zsh -lc 'echo "zsh -lc: $PATH"'
- Observed output on 2025-09-25:
Before: /tmp/test-bin:/usr/bin
bash -lc: /usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/Applications/Little Snitch.app/Contents/Components:/tmp/test-bin
bash -c: /tmp/test-bin:/usr/bin
zsh -lc: /tmp/test-bin:/usr/bin
- Trigger the same command through Codex’s shell tool; the PATH matches the bash -lc case above.
Observed vs. Expected
- Observed: Codex’s login bash shell prepends Apple system paths in front of the caller’s PATH, demoting Nix and user prefixes.
- Expected: Codex should preserve the exact PATH order supplied in ExecParams.env, matching the caller’s environment.
Root Cause
- Shell::format_default_shell_invocation always produces [shell_path, "-lc", <script>], forcing a login shell even when the model already generated bash -lc. (core/src/shell.rs) core/src/shell.rs#L33-L135 ()
codex/codex-rs/core/src/shell.rs
Lines 33 to 135 in e857426
pub fn format_default_shell_invocation(&self, command: Vec<String>) -> Option<Vec<String>> { match self { Shell::Zsh(zsh) => format_shell_invocation_with_rc( command.as_slice(), &zsh.shell_path, &zsh.zshrc_path, ), Shell::Bash(bash) => format_shell_invocation_with_rc( command.as_slice(), &bash.shell_path, &bash.bashrc_path, ), Shell::PowerShell(ps) => { // If model generated a bash command, prefer a detected bash fallback if let Some(script) = strip_bash_lc(command.as_slice()) { return match &ps.bash_exe_fallback { Some(bash) => Some(vec![ bash.to_string_lossy().to_string(), "-lc".to_string(), script, ]), // No bash fallback → run the script under PowerShell. // It will likely fail (except for some simple commands), but the error // should give a clue to the model to fix upon retry that it's running under PowerShell. None => Some(vec![ ps.exe.clone(), "-NoProfile".to_string(), "-Command".to_string(), script, ]), }; } // Not a bash command. If model did not generate a PowerShell command, // turn it into a PowerShell command. let first = command.first().map(String::as_str); if first != Some(ps.exe.as_str()) { // TODO (CODEX_2900): Handle escaping newlines. if command.iter().any(|a| a.contains('\n') || a.contains('\r')) { return Some(command); } let joined = shlex::try_join(command.iter().map(String::as_str)).ok(); return joined.map(|arg| { vec![ ps.exe.clone(), "-NoProfile".to_string(), "-Command".to_string(), arg, ] }); } // Model generated a PowerShell command. Run it. Some(command) } Shell::Unknown => None, } } pub fn name(&self) -> Option<String> { match self { Shell::Zsh(zsh) => std::path::Path::new(&zsh.shell_path) .file_name() .map(|s| s.to_string_lossy().to_string()), Shell::Bash(bash) => std::path::Path::new(&bash.shell_path) .file_name() .map(|s| s.to_string_lossy().to_string()), Shell::PowerShell(ps) => Some(ps.exe.clone()), Shell::Unknown => None, } } } fn format_shell_invocation_with_rc( command: &[String], shell_path: &str, rc_path: &str, ) -> Option<Vec<String>> { let joined = strip_bash_lc(command) .or_else(|| shlex::try_join(command.iter().map(String::as_str)).ok())?; let rc_command = if std::path::Path::new(rc_path).exists() { format!("source {rc_path} && ({joined})") } else { joined }; Some(vec![shell_path.to_string(), "-lc".to_string(), rc_command]) } fn strip_bash_lc(command: &[String]) -> Option<String> { match command { // exactly three items [first, second, third] // first two must be "bash", "-lc" if first == "bash" && second == "-lc" => { Some(third.clone()) } _ => None, } - maybe_translate_shell_command swaps in that wrapper whenever ShellEnvironmentPolicy.use_profile is true (default on macOS). (core/src/codex.rs) core/src/codex.rs#L2602-L2687 ()
codex/codex-rs/core/src/codex.rs
Lines 2602 to 2687 in e857426
fn to_exec_params(params: ShellToolCallParams, turn_context: &TurnContext) -> ExecParams { ExecParams { command: params.command, cwd: turn_context.resolve_path(params.workdir.clone()), timeout_ms: params.timeout_ms, env: create_env(&turn_context.shell_environment_policy), with_escalated_permissions: params.with_escalated_permissions, justification: params.justification, } } fn parse_container_exec_arguments( arguments: String, turn_context: &TurnContext, _call_id: &str, ) -> Result<ExecParams, FunctionCallError> { serde_json::from_str::<ShellToolCallParams>(&arguments) .map(|p| to_exec_params(p, turn_context)) .map_err(|e| { FunctionCallError::RespondToModel(format!("failed to parse function arguments: {e:?}")) }) } pub struct ExecInvokeArgs<'a> { pub params: ExecParams, pub sandbox_type: SandboxType, pub sandbox_policy: &'a SandboxPolicy, pub sandbox_cwd: &'a Path, pub codex_linux_sandbox_exe: &'a Option<PathBuf>, pub stdout_stream: Option<StdoutStream>, } fn maybe_translate_shell_command( params: ExecParams, sess: &Session, turn_context: &TurnContext, ) -> ExecParams { let should_translate = matches!(sess.user_shell, crate::shell::Shell::PowerShell(_)) || turn_context.shell_environment_policy.use_profile; if should_translate && let Some(command) = sess .user_shell .format_default_shell_invocation(params.command.clone()) { return ExecParams { command, ..params }; } params } async fn handle_container_exec_with_params( params: ExecParams, sess: &Session, turn_context: &TurnContext, turn_diff_tracker: &mut TurnDiffTracker, sub_id: String, call_id: String, ) -> Result<String, FunctionCallError> { if params.with_escalated_permissions.unwrap_or(false) && !matches!(turn_context.approval_policy, AskForApproval::OnRequest) { return Err(FunctionCallError::RespondToModel(format!( "approval policy is {policy:?}; reject command — you should not ask for escalated permissions if the approval policy is {policy:?}", policy = turn_context.approval_policy ))); } // check if this was a patch, and apply it if so let apply_patch_exec = match maybe_parse_apply_patch_verified(¶ms.command, ¶ms.cwd) { MaybeApplyPatchVerified::Body(changes) => { match apply_patch::apply_patch(sess, turn_context, &sub_id, &call_id, changes).await { InternalApplyPatchInvocation::Output(item) => return item, InternalApplyPatchInvocation::DelegateToExec(apply_patch_exec) => { Some(apply_patch_exec) } } } MaybeApplyPatchVerified::CorrectnessError(parse_error) => { // It looks like an invocation of `apply_patch`, but we // could not resolve it into a patch that would apply // cleanly. Return to model for resample. return Err(FunctionCallError::RespondToModel(format!( "error: {parse_error:#?}" ))); } MaybeApplyPatchVerified::ShellParseError(error) => { - The child process is spawned with a clean environment that Codex repopulates, so PATH enters exactly as provided before bash login hooks run. (core/src/spawn.rs, core/src/exec_env.rs) core/src/spawn.rs#L38-L107 () core/src/exec_env.rs#L14-L68 (
codex/codex-rs/core/src/spawn.rs
Lines 38 to 107 in e857426
pub(crate) async fn spawn_child_async( program: PathBuf, args: Vec<String>, #[cfg_attr(not(unix), allow(unused_variables))] arg0: Option<&str>, cwd: PathBuf, sandbox_policy: &SandboxPolicy, stdio_policy: StdioPolicy, env: HashMap<String, String>, ) -> std::io::Result<Child> { trace!( "spawn_child_async: {program:?} {args:?} {arg0:?} {cwd:?} {sandbox_policy:?} {stdio_policy:?} {env:?}" ); let mut cmd = Command::new(&program); #[cfg(unix)] cmd.arg0(arg0.map_or_else(|| program.to_string_lossy().to_string(), String::from)); cmd.args(args); cmd.current_dir(cwd); cmd.env_clear(); cmd.envs(env); if !sandbox_policy.has_full_network_access() { cmd.env(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR, "1"); } // If this Codex process dies (including being killed via SIGKILL), we want // any child processes that were spawned as part of a `"shell"` tool call // to also be terminated. // This relies on prctl(2), so it only works on Linux. #[cfg(target_os = "linux")] unsafe { cmd.pre_exec(|| { // This prctl call effectively requests, "deliver SIGTERM when my // current parent dies." if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGTERM) == -1 { return Err(std::io::Error::last_os_error()); } // Though if there was a race condition and this pre_exec() block is // run _after_ the parent (i.e., the Codex process) has already // exited, then the parent is the _init_ process (which will never // die), so we should just terminate the child process now. if libc::getppid() == 1 { libc::raise(libc::SIGTERM); } Ok(()) }); } match stdio_policy { StdioPolicy::RedirectForShellTool => { // Do not create a file descriptor for stdin because otherwise some // commands may hang forever waiting for input. For example, ripgrep has // a heuristic where it may try to read from stdin as explained here: // https://github.com/BurntSushi/ripgrep/blob/e2362d4d5185d02fa857bf381e7bd52e66fafc73/crates/core/flags/hiargs.rs#L1101-L1103 cmd.stdin(Stdio::null()); cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); } StdioPolicy::Inherit => { // Inherit stdin, stdout, and stderr from the parent process. cmd.stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()); } } cmd.kill_on_drop(true).spawn() } )codex/codex-rs/core/src/exec_env.rs
Lines 14 to 68 in e857426
pub fn create_env(policy: &ShellEnvironmentPolicy) -> HashMap<String, String> { populate_env(std::env::vars(), policy) } fn populate_env<I>(vars: I, policy: &ShellEnvironmentPolicy) -> HashMap<String, String> where I: IntoIterator<Item = (String, String)>, { // Step 1 – determine the starting set of variables based on the // `inherit` strategy. let mut env_map: HashMap<String, String> = match policy.inherit { ShellEnvironmentPolicyInherit::All => vars.into_iter().collect(), ShellEnvironmentPolicyInherit::None => HashMap::new(), ShellEnvironmentPolicyInherit::Core => { const CORE_VARS: &[&str] = &[ "HOME", "LOGNAME", "PATH", "SHELL", "USER", "USERNAME", "TMPDIR", "TEMP", "TMP", ]; let allow: HashSet<&str> = CORE_VARS.iter().copied().collect(); vars.into_iter() .filter(|(k, _)| allow.contains(k.as_str())) .collect() } }; // Internal helper – does `name` match **any** pattern in `patterns`? let matches_any = |name: &str, patterns: &[EnvironmentVariablePattern]| -> bool { patterns.iter().any(|pattern| pattern.matches(name)) }; // Step 2 – Apply the default exclude if not disabled. if !policy.ignore_default_excludes { let default_excludes = vec![ EnvironmentVariablePattern::new_case_insensitive("*KEY*"), EnvironmentVariablePattern::new_case_insensitive("*SECRET*"), EnvironmentVariablePattern::new_case_insensitive("*TOKEN*"), ]; env_map.retain(|k, _| !matches_any(k, &default_excludes)); } // Step 3 – Apply custom excludes. if !policy.exclude.is_empty() { env_map.retain(|k, _| !matches_any(k, &policy.exclude)); } // Step 4 – Apply user-provided overrides. for (key, val) in &policy.r#set { env_map.insert(key.clone(), val.clone()); } // Step 5 – If include_only is non-empty, keep *only* the matching vars. if !policy.include_only.is_empty() { env_map.retain(|k, _| matches_any(k, &policy.include_only)); } env_map - GNU Bash treats -l / --login as a login shell and executes /etc/profile before the user command. GNU Bash manual (https://www.gnu.org/software/bash/manual/html_node/Bash-Startup-Files.html)
- On macOS, /etc/profile runs /usr/libexec/path_helper -s, which rebuilds PATH from /etc/paths and /etc/paths.d, then appends the previous PATH value—earlier entries get pushed behind the system defaults. path_helper man page (https://www.unix.com/man-page/osx/8/path_helper/) path_helper behavior write-up (https://scriptingosx.com/2017/05/path_helper-and-launchctl-setenv/)
Suggested Fixes
- Preferred: Stop forcing login shells by default. Use bash -c unless the user explicitly asked for profiles (e.g., use_profile). That preserves PATH while respecting existing behavior for users who opt in.
- Fallback: If login shells must stay, restore the original PATH ordering immediately before executing the user script (e.g., inject export PATH='$ORIGINAL' into the generated command). Risky and brittle.
- Regression Test: Add an integration test that runs through the Codex shell path with a sentinel PATH (/tmp/a:/tmp/b), captures echo $PATH inside the spawned process, and asserts exact equality.
Supporting Docs
- GNU Bash manual (login shells read /etc/profile).
- macOS path_helper man page (path_helper rebuilds PATH from /etc/paths*).
- External analysis of path_helper ordering behavior.
Activity
jessfraz commentedon Sep 25, 2025
also if you tell me what you want to do here, i will do it, but I don't know the history of "why login shells" so i don't feel comfortable scorching them from the earth without knowing if there was a good reason, even tho i can't personally think of one, i don't have all the context.
gcp commentedon Sep 30, 2025
Similar to #2542.
maruel commentedon Sep 30, 2025
Even on linux in a docker container purely using bash, PATH is modified in surprising way:
In my .bashrc, I have:
yet it's not there.
gcp commentedon Sep 30, 2025
A login shell will never read
.bashrcazarenkovgd commentedon Oct 9, 2025
Are there any estimates when this bug will be fixed? It severely affects my workflow and forces to use some messy workarounds.
Maybe there are some official recommendations what to do in this case until the bug is fixed.
ianb commentedon Oct 21, 2025
Note this breaks something as simple as nvm, which modifies the $PATH to set the node interpreter.
nvm usealso doesn't work within Codex (presumably because environment changes aren't persisted between commands in codex). You can work around this by telling codex to use the absolute path to whatever command or interpreter you want (e.g., (e.g.,/Users/username/.nvm/versions/node/v25.0.0/bin/npm), but it's awkward and codex tends to forget to do this.nachoal commentedon Nov 22, 2025
This is such a basic thing to get right, is there some context on why you use login shells? Extremely disruptive to workflow, part of the reason why claude code is amazing at tooling an cli work (they get bash execution right)
imajes commentedon Nov 25, 2025
Thanks for writing this up @jessfraz -- really does feel like there's not a clear sense of whether or not a user's shell + env is particularly respected.
@etraut-openai: I promise I won't keep tagging you in, but you have been doing Sisyphean work in keeping up with PRs/issues. This one matters though. it seems impossible now to say, "i use zsh, please don't create a new shell every time to run your commands?"
On top of that- it's now
cd ...into the folder it's already in every time. is it not keeping track ofcwd?JaysonGeng commentedon Mar 31, 2026
I dug through the current code path and put together a targeted fix on my fork:
Ready branch:
https://github.com/JaysonGeng/codex/tree/fix/shell-path-order-preservation
Compare:
JaysonGeng/codex@main...fix/shell-path-order-preservation
Commit:
JaysonGeng@c5524e5
What it changes:
shell_command/exec_commandto non-login shells whenloginis omittedallow_login_shell = trueas an explicit opt-in gate forlogin = trueuser_shell) from hardcoding login-shell executionmemories/usagealigned with the new command shapebash -coverbash -lcValidation:
cargo test -p codex-toolspassedjust fix -p codex-corepassedjust fix -p codex-toolspassedjust fmtpassedcargo test/check -p codex-coreunder a 60s hard-timeout repeatedly reached thecodex-coretest/check target without surfacing compile or test failures, but did not fully complete within the timeout on this machinejust argument-comment-lintcould not run here because the local environment is missing Bazel andtools/argument-comment-lint/*If maintainers are interested, I can rebase or split this further, but this should already be reviewable as-is.
JaysonGeng commentedon Mar 31, 2026
Per the current contributing policy in
docs/contributing.md, I am intentionally not opening an unsolicited upstream PR for this.The branch and commit are already in a maintainer-ready state:
If this approach matches the intended direction, I can open the PR immediately once a maintainer invites it; otherwise feel free to cherry-pick
c5524e5directly or ask me to rebase/split it further.