cli: add package path from install context (#26189)

## Why

Codex package installs include helper binaries in `codex-path`, such as
the bundled `rg`. Package-layout launches should add that directory
before user commands run, but standalone launches were missing it while
npm launches only worked because `codex.js` had its own legacy `PATH`
rewrite. That made npm and standalone package behavior diverge.

Shell snapshot restoration can also reset `PATH` after runtime setup.
Any package-owned `PATH` prepend has to be recorded as an explicit
runtime override so shells, unified exec, and user-shell commands keep
access to `codex-path` after a snapshot is sourced.

## Repro

Before this change, a curl-installed package could contain `rg` under
`codex-path` but still fail to put it on `PATH`:

```shell
mkdir /tmp/test-codex-curl
curl -fsSL https://chatgpt.com/codex/install.sh \
  | CODEX_HOME=/tmp/test-codex-curl CODEX_NON_INTERACTIVE=1 sh
/tmp/test-codex-curl/packages/standalone/current/bin/codex exec \
  --skip-git-repo-check 'print `which -a rg`'
find /tmp/test-codex-curl -name rg
```

The `which -a rg` output omitted the packaged helper even though `find`
showed it under
`/tmp/test-codex-curl/packages/standalone/releases/.../codex-path/rg`.

The npm install path behaved differently only because
`codex-cli/bin/codex.js` had legacy `PATH` rewriting:

```shell
mkdir /tmp/test-codex-npm
cd /tmp/test-codex-npm
npm install @openai/codex
./node_modules/.bin/codex exec --skip-git-repo-check 'print `which -a rg`'
```

That printed the npm package's `vendor/<target>/codex-path/rg` first.
This PR moves that behavior into Rust-side package launch setup so
curl/standalone and npm/bun launches agree without JS rewriting `PATH`.

## What Changed

- `codex-rs/arg0` now uses
`InstallContext::current().package_layout.path_dir` to prepend the
package helper directory before any threads are created.
- Package helper `PATH` setup is independent from the temporary arg0
alias setup, so `codex-path` is still added even if CODEX_HOME tempdir,
lock, or symlink setup fails.
- `codex-rs/install-context` detects the canonical package layout we
ship: `bin/`, `codex-resources/`, and `codex-path/` next to
`codex-package.json`.
- Shell, local unified exec, and user-shell runtimes now record package
`codex-path` prepends in `explicit_env_overrides`, matching the existing
zsh-fork behavior so shell snapshots cannot restore over the package
helper path.
- Remote unified exec requests do not receive the local app-server
package path overlay.
- `codex-cli/bin/codex.js` no longer computes or overrides `PATH`; it
only locates the native binary in the canonical package layout and
passes npm/bun management metadata.
- Added regression tests for `PATH` ordering, package layout detection,
and shell snapshot preservation of package path prepends.

## Verification

- `node --check codex-cli/bin/codex.js`
- `just test -p codex-install-context -p codex-arg0`
- `just test -p codex-core
user_shell_snapshot_preserves_package_path_prepend`
- `just test -p codex-core tools::runtimes::tests`
- `just bazel-lock-update`
- `just bazel-lock-check`
- `just fix -p codex-install-context -p codex-arg0 -p codex-core`
This commit is contained in:
Michael Bolin
2026-06-03 19:08:19 -07:00
committed by GitHub
parent 80b65e9945
commit 6bcccb0ee6
11 changed files with 674 additions and 127 deletions
+175 -34
View File
@@ -1,3 +1,4 @@
use std::ffi::OsString;
use std::fs::File;
use std::future::Future;
use std::path::Path;
@@ -5,6 +6,7 @@ use std::path::PathBuf;
use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1;
use codex_exec_server::CODEX_FS_HELPER_ARG1;
use codex_install_context::InstallContext;
use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0;
use codex_utils_home_dir::find_codex_home;
#[cfg(unix)]
@@ -138,13 +140,36 @@ pub fn arg0_dispatch() -> Option<Arg0PathEntryGuard> {
// before creating any threads/the Tokio runtime.
load_dotenv();
match prepend_path_entry_for_codex_aliases() {
Ok(path_entry) => Some(path_entry),
let (path_entry_guard, updated_path_env_var) = prepare_path_env_var_with_aliases(
InstallContext::current(),
std::env::var_os("PATH"),
prepare_path_entry_for_codex_aliases,
);
if let Some(updated_path_env_var) = updated_path_env_var {
// It is safe to call set_var() because our process is single-threaded at
// this point in its execution.
unsafe {
std::env::set_var("PATH", updated_path_env_var);
}
}
path_entry_guard
}
fn prepare_path_env_var_with_aliases(
install_context: &InstallContext,
existing_path: Option<OsString>,
prepare_aliases: impl FnOnce(Option<OsString>) -> std::io::Result<(Arg0PathEntryGuard, OsString)>,
) -> (Option<Arg0PathEntryGuard>, Option<OsString>) {
let package_path = path_env_with_package_path_dir(install_context, existing_path.clone());
let path_for_aliases = package_path.clone().or(existing_path);
match prepare_aliases(path_for_aliases) {
Ok((path_entry, updated_path_env_var)) => (Some(path_entry), Some(updated_path_env_var)),
Err(err) => {
// It is possible that Codex will proceed successfully even if
// updating the PATH fails, so warn the user and move on.
eprintln!("WARNING: proceeding, even though we could not update PATH: {err}");
None
// creating helper aliases fails, so warn the user and move on.
eprintln!("WARNING: proceeding, even though we could not create PATH aliases: {err}");
(None, package_path)
}
}
}
@@ -285,15 +310,16 @@ where
/// - WINDOWS: `apply_patch.bat` batch script to invoke the current executable
/// with the hidden `--codex-run-as-apply-patch` flag.
///
/// This temporary directory is prepended to the PATH environment variable so
/// that `apply_patch` can be on the PATH without requiring the user to
/// install a separate `apply_patch` executable, simplifying the deployment of
/// Codex CLI.
/// Returns the temporary directory guard and the PATH value that prepends the
/// temporary directory so `apply_patch` can be on the PATH without requiring the
/// user to install a separate executable, simplifying the deployment of Codex
/// CLI.
/// Note: In debug builds the temp-dir guard is disabled to ease local testing.
///
/// IMPORTANT: This function modifies the PATH environment variable, so it MUST
/// be called before multiple threads are spawned.
pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result<Arg0PathEntryGuard> {
/// IMPORTANT: Callers must update PATH before multiple threads are spawned.
fn prepare_path_entry_for_codex_aliases(
existing_path: Option<OsString>,
) -> std::io::Result<(Arg0PathEntryGuard, OsString)> {
let codex_home = find_codex_home()?;
#[cfg(not(debug_assertions))]
{
@@ -371,27 +397,7 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result<Arg0PathEntryGu
}
}
#[cfg(unix)]
const PATH_SEPARATOR: &str = ":";
#[cfg(windows)]
const PATH_SEPARATOR: &str = ";";
let updated_path_env_var = match std::env::var_os("PATH") {
Some(existing_path) => {
let mut path_env_var =
std::ffi::OsString::with_capacity(path.as_os_str().len() + 1 + existing_path.len());
path_env_var.push(path);
path_env_var.push(PATH_SEPARATOR);
path_env_var.push(existing_path);
path_env_var
}
None => path.as_os_str().to_owned(),
};
unsafe {
std::env::set_var("PATH", updated_path_env_var);
}
let updated_path_env_var = path_env_with_entry(path, existing_path);
let paths = Arg0DispatchPaths {
codex_self_exe: std::env::current_exe().ok(),
@@ -417,7 +423,41 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result<Arg0PathEntryGu
},
};
Ok(Arg0PathEntryGuard::new(temp_dir, lock_file, paths))
Ok((
Arg0PathEntryGuard::new(temp_dir, lock_file, paths),
updated_path_env_var,
))
}
fn path_env_with_package_path_dir(
install_context: &InstallContext,
existing_path: Option<OsString>,
) -> Option<OsString> {
let path_dir = install_context
.package_layout
.as_ref()
.and_then(|package_layout| package_layout.path_dir.as_ref())?;
Some(path_env_with_entry(path_dir.as_path(), existing_path))
}
fn path_env_with_entry(path_entry: &Path, existing_path: Option<OsString>) -> OsString {
#[cfg(unix)]
const PATH_SEPARATOR: &str = ":";
#[cfg(windows)]
const PATH_SEPARATOR: &str = ";";
let capacity = path_entry.as_os_str().len()
+ existing_path
.as_ref()
.map_or(0, |existing_path| 1 + existing_path.len());
let mut path_env_var = OsString::with_capacity(capacity);
path_env_var.push(path_entry);
if let Some(existing_path) = existing_path {
path_env_var.push(PATH_SEPARATOR);
path_env_var.push(existing_path);
}
path_env_var
}
fn janitor_cleanup(temp_root: &Path) -> std::io::Result<()> {
@@ -475,12 +515,25 @@ mod tests {
use super::run_main_with_arg0_guard;
#[cfg(unix)]
use anyhow::ensure;
use codex_install_context::CodexPackageLayout;
use codex_install_context::InstallContext;
use codex_install_context::InstallMethod;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use std::fs;
use std::fs::File;
use std::path::Path;
use std::path::PathBuf;
use tempfile::TempDir;
struct PackagePathTestFixture {
_temp_dir: TempDir,
arg0_dir: PathBuf,
existing_dir: PathBuf,
install_context: InstallContext,
path_dir: AbsolutePathBuf,
}
fn create_lock(dir: &Path) -> std::io::Result<File> {
let lock_path = dir.join(LOCK_FILENAME);
File::options()
@@ -491,6 +544,37 @@ mod tests {
.open(lock_path)
}
fn package_path_test_fixture() -> anyhow::Result<PackagePathTestFixture> {
let temp_dir = TempDir::new()?;
let arg0_dir = temp_dir.path().join("arg0");
let package_dir = temp_dir.path().join("package");
let bin_dir = package_dir.join("bin");
let path_dir = package_dir.join("codex-path");
let existing_dir = temp_dir.path().join("existing-bin");
fs::create_dir_all(&arg0_dir)?;
fs::create_dir_all(&bin_dir)?;
fs::create_dir_all(&path_dir)?;
fs::create_dir_all(&existing_dir)?;
let path_dir = AbsolutePathBuf::from_absolute_path(path_dir.canonicalize()?)?;
let install_context = InstallContext {
method: InstallMethod::Other,
package_layout: Some(CodexPackageLayout {
package_dir: AbsolutePathBuf::from_absolute_path(package_dir.canonicalize()?)?,
bin_dir: AbsolutePathBuf::from_absolute_path(bin_dir.canonicalize()?)?,
resources_dir: None,
path_dir: Some(path_dir.clone()),
}),
};
Ok(PackagePathTestFixture {
_temp_dir: temp_dir,
arg0_dir,
existing_dir,
install_context,
path_dir,
})
}
#[test]
fn linux_sandbox_exe_path_prefers_codex_linux_sandbox_alias() -> std::io::Result<()> {
let temp_dir = TempDir::new()?;
@@ -513,6 +597,63 @@ mod tests {
Ok(())
}
#[test]
fn path_env_can_prepend_package_path_before_arg0_alias_dir() -> anyhow::Result<()> {
let fixture = package_path_test_fixture()?;
let package_path = super::path_env_with_package_path_dir(
&fixture.install_context,
Some(fixture.existing_dir.as_os_str().to_owned()),
)
.expect("package path dir should update PATH");
let updated_path = super::path_env_with_entry(&fixture.arg0_dir, Some(package_path));
assert_eq!(
std::env::split_paths(&updated_path).collect::<Vec<_>>(),
vec![
fixture.arg0_dir,
fixture.path_dir.as_path().to_path_buf(),
fixture.existing_dir
],
);
Ok(())
}
#[test]
fn package_path_survives_arg0_alias_setup_failure() -> anyhow::Result<()> {
let fixture = package_path_test_fixture()?;
let (path_entry_guard, updated_path_env_var) = super::prepare_path_env_var_with_aliases(
&fixture.install_context,
Some(fixture.existing_dir.as_os_str().to_owned()),
|path_for_aliases| {
assert_eq!(
std::env::split_paths(
&path_for_aliases.expect("package PATH should be passed to alias setup")
)
.collect::<Vec<_>>(),
vec![
fixture.path_dir.as_path().to_path_buf(),
fixture.existing_dir.clone()
],
);
Err(std::io::Error::other("alias setup failed"))
},
);
assert!(path_entry_guard.is_none());
let updated_path_env_var =
updated_path_env_var.expect("package PATH should survive alias setup failure");
assert_eq!(
std::env::split_paths(&updated_path_env_var).collect::<Vec<_>>(),
vec![
fixture.path_dir.as_path().to_path_buf(),
fixture.existing_dir
],
);
Ok(())
}
#[cfg(unix)]
#[test]
fn run_main_with_arg0_guard_keeps_aliases_alive_until_main_returns() -> anyhow::Result<()> {