From aadc9b50ce9953729212e4127b4e0e71218e643d Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 7 Jul 2026 20:12:51 -0400 Subject: [PATCH 01/11] Detect Codex installs managed by pnpm --- codex-cli/bin/codex.js | 39 ++++++++++++++++--- codex-rs/cli/src/doctor.rs | 8 ++++ codex-rs/cli/src/doctor/output.rs | 2 +- codex-rs/cli/src/doctor/output/detail.rs | 5 ++- codex-rs/cli/src/doctor/runtime.rs | 1 + ..._doctor_human_report_environment_rows.snap | 2 +- codex-rs/cli/src/doctor/updates.rs | 9 +++++ codex-rs/install-context/src/lib.rs | 39 ++++++++++++++++++- codex-rs/tui/src/update_action.rs | 11 ++++++ codex-rs/tui/src/updates.rs | 4 +- 10 files changed, 108 insertions(+), 12 deletions(-) diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js index 5cc519418300..29b0069b06f6 100755 --- a/codex-cli/bin/codex.js +++ b/codex-cli/bin/codex.js @@ -11,6 +11,7 @@ import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const require = createRequire(import.meta.url); +const codexPackageRoot = realpathSync(path.join(__dirname, "..")); const PLATFORM_PACKAGE_BY_TARGET = { "x86_64-unknown-linux-musl": "@openai/codex-linux-x64", @@ -98,7 +99,9 @@ function findCodexExecutable() { const updateCommand = packageManager === "bun" ? "bun install -g @openai/codex@latest" - : "npm install -g @openai/codex@latest"; + : packageManager === "pnpm" + ? "pnpm add -g @openai/codex@latest" + : "npm install -g @openai/codex@latest"; throw new Error( `Missing optional dependency ${platformPackage}. Reinstall Codex: ${updateCommand}`, ); @@ -117,6 +120,16 @@ const binaryPath = findCodexExecutable(); * in order to give the user a hint about how to update it. */ function detectPackageManager() { + const pnpmHome = process.env.PNPM_HOME; + if (pnpmHome) { + const resolvedPnpmHome = existsSync(pnpmHome) + ? realpathSync(pnpmHome) + : path.resolve(pnpmHome); + if (isPathInside(resolvedPnpmHome, codexPackageRoot)) { + return "pnpm"; + } + } + const userAgent = process.env.npm_config_user_agent || ""; if (/\bbun\//.test(userAgent)) { return "bun"; @@ -137,15 +150,29 @@ function detectPackageManager() { return userAgent ? "npm" : null; } +function isPathInside(parent, child) { + const relative = path.relative(parent, child); + return ( + relative === "" || + (relative !== ".." && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative)) + ); +} + const packageManagerEnvVar = - detectPackageManager() === "bun" - ? "CODEX_MANAGED_BY_BUN" - : "CODEX_MANAGED_BY_NPM"; + { + "bun": "CODEX_MANAGED_BY_BUN", + "pnpm": "CODEX_MANAGED_BY_PNPM", + }[detectPackageManager()] ?? "CODEX_MANAGED_BY_NPM"; const env = { ...process.env, - [packageManagerEnvVar]: "1", - CODEX_MANAGED_PACKAGE_ROOT: realpathSync(path.join(__dirname, "..")), + CODEX_MANAGED_PACKAGE_ROOT: codexPackageRoot, }; +delete env.CODEX_MANAGED_BY_NPM; +delete env.CODEX_MANAGED_BY_BUN; +delete env.CODEX_MANAGED_BY_PNPM; +env[packageManagerEnvVar] = "1"; const child = spawn(binaryPath, process.argv.slice(2), { stdio: "inherit", diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index cfb9316e91d2..2e9c0094c4e8 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -797,6 +797,10 @@ fn installation_check(show_details: bool) -> DoctorCheck { "managed by bun: {}", env::var_os("CODEX_MANAGED_BY_BUN").is_some() )); + details.push(format!( + "managed by pnpm: {}", + env::var_os("CODEX_MANAGED_BY_PNPM").is_some() + )); push_env_path_detail( &mut details, "managed package root", @@ -885,6 +889,7 @@ fn doctor_managed_by_npm(current_exe: Option<&Path>) -> bool { fn inherited_managed_env_for_cargo_binary(current_exe: Option<&Path>) -> bool { if env::var_os("CODEX_MANAGED_BY_NPM").is_none() && env::var_os("CODEX_MANAGED_BY_BUN").is_none() + && env::var_os("CODEX_MANAGED_BY_PNPM").is_none() { return false; } @@ -937,6 +942,9 @@ fn describe_install_context(context: &InstallContext) -> String { InstallMethod::Bun => { describe_method_with_package_layout("bun", context.package_layout.as_ref()) } + InstallMethod::Pnpm => { + describe_method_with_package_layout("pnpm", context.package_layout.as_ref()) + } InstallMethod::Brew => { describe_method_with_package_layout("brew", context.package_layout.as_ref()) } diff --git a/codex-rs/cli/src/doctor/output.rs b/codex-rs/cli/src/doctor/output.rs index 81f92314821f..4f0380a58316 100644 --- a/codex-rs/cli/src/doctor/output.rs +++ b/codex-rs/cli/src/doctor/output.rs @@ -1260,7 +1260,7 @@ Environment LESS -FRX ✓ runtime running local build on darwin-arm64 ✓ install consistent - managed by npm: no · bun: no · package root — + managed by npm: no · bun: no · pnpm: no · package root — ✓ search search is OK (bundled) ✓ git git version 2.54.0 selected git /usr/bin/git diff --git a/codex-rs/cli/src/doctor/output/detail.rs b/codex-rs/cli/src/doctor/output/detail.rs index 05e7af404e7c..0ee5add05e7e 100644 --- a/codex-rs/cli/src/doctor/output/detail.rs +++ b/codex-rs/cli/src/doctor/output/detail.rs @@ -211,13 +211,15 @@ fn install_details(parsed: &[ParsedDetail], options: HumanOutputOptions) -> Vec< let managed_by_npm = value(parsed, "managed by npm").unwrap_or("false"); let managed_by_bun = value(parsed, "managed by bun").unwrap_or("false"); + let managed_by_pnpm = value(parsed, "managed by pnpm").unwrap_or("false"); let package_root = value(parsed, "managed package root").unwrap_or("not set"); out.push(HumanDetail::Row { label: "managed by".to_string(), value: format!( - "npm: {} · bun: {} · package root {}", + "npm: {} · bun: {} · pnpm: {} · package root {}", yes_no(managed_by_npm), yes_no(managed_by_bun), + yes_no(managed_by_pnpm), if is_falsy(package_root) { "—".to_string() } else { @@ -263,6 +265,7 @@ fn install_details(parsed: &[ParsedDetail], options: HumanOutputOptions) -> Vec< "install context", "managed by npm", "managed by bun", + "managed by pnpm", "managed package root", "PATH codex entries", ], diff --git a/codex-rs/cli/src/doctor/runtime.rs b/codex-rs/cli/src/doctor/runtime.rs index 14afa70e4c53..d805c7c6b22e 100644 --- a/codex-rs/cli/src/doctor/runtime.rs +++ b/codex-rs/cli/src/doctor/runtime.rs @@ -121,6 +121,7 @@ fn install_method_name(context: &InstallContext) -> &'static str { InstallMethod::Standalone { .. } => "standalone", InstallMethod::Npm => "npm", InstallMethod::Bun => "bun", + InstallMethod::Pnpm => "pnpm", InstallMethod::Brew => "brew", InstallMethod::Other => "local build", } diff --git a/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap b/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap index b3c8d1ee78ed..2944c6c659ca 100644 --- a/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap +++ b/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap @@ -21,7 +21,7 @@ Environment LESS -FRX ✓ runtime running local build on darwin-arm64 ✓ install consistent - managed by npm: no · bun: no · package root — + managed by npm: no · bun: no · pnpm: no · package root — ✓ search search is OK (bundled) ✓ git git version 2.54.0 selected git /usr/bin/git diff --git a/codex-rs/cli/src/doctor/updates.rs b/codex-rs/cli/src/doctor/updates.rs index 246eac2b39fa..b13de06d14ce 100644 --- a/codex-rs/cli/src/doctor/updates.rs +++ b/codex-rs/cli/src/doctor/updates.rs @@ -133,6 +133,7 @@ fn update_action_label(context: &InstallContext) -> &'static str { match &context.method { InstallMethod::Npm => "npm install -g @openai/codex", InstallMethod::Bun => "bun install -g @openai/codex", + InstallMethod::Pnpm => "pnpm add -g @openai/codex", InstallMethod::Brew => "brew upgrade --cask codex", InstallMethod::Standalone { .. } => "standalone installer", InstallMethod::Other => "manual or unknown", @@ -144,6 +145,7 @@ fn fetch_latest_version(context: &InstallContext) -> Result { InstallMethod::Brew => fetch_homebrew_cask_version(), InstallMethod::Npm | InstallMethod::Bun + | InstallMethod::Pnpm | InstallMethod::Standalone { .. } | InstallMethod::Other => fetch_latest_github_release_version(), } @@ -223,6 +225,13 @@ mod tests { }), "npm install -g @openai/codex" ); + assert_eq!( + update_action_label(&InstallContext { + method: InstallMethod::Pnpm, + package_layout: None, + }), + "pnpm add -g @openai/codex" + ); assert_eq!( update_action_label(&InstallContext { method: InstallMethod::Other, diff --git a/codex-rs/install-context/src/lib.rs b/codex-rs/install-context/src/lib.rs index 63694c5d6259..e7cebbcb7e01 100644 --- a/codex-rs/install-context/src/lib.rs +++ b/codex-rs/install-context/src/lib.rs @@ -56,6 +56,8 @@ pub enum InstallMethod { Npm, /// A Codex binary launched through the bun-managed `codex.js` shim. Bun, + /// A Codex binary launched through the pnpm-managed `codex.js` shim. + Pnpm, /// A Codex binary that appears to come from a Homebrew install prefix. Brew, /// Any other execution environment. @@ -71,6 +73,7 @@ impl InstallContext { current_exe: Option<&Path>, managed_by_npm: bool, managed_by_bun: bool, + managed_by_pnpm: bool, ) -> Self { let codex_home = codex_utils_home_dir::find_codex_home().ok(); Self::from_exe_with_codex_home( @@ -78,6 +81,7 @@ impl InstallContext { current_exe, managed_by_npm, managed_by_bun, + managed_by_pnpm, codex_home.as_deref(), ) } @@ -87,10 +91,13 @@ impl InstallContext { current_exe: Option<&Path>, managed_by_npm: bool, managed_by_bun: bool, + managed_by_pnpm: bool, codex_home: Option<&Path>, ) -> Self { let package_layout = current_exe.and_then(CodexPackageLayout::from_exe); - let method = if managed_by_npm { + let method = if managed_by_pnpm { + InstallMethod::Pnpm + } else if managed_by_npm { InstallMethod::Npm } else if managed_by_bun { InstallMethod::Bun @@ -111,11 +118,13 @@ impl InstallContext { let current_exe = std::env::current_exe().ok(); let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some(); let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some(); + let managed_by_pnpm = std::env::var_os("CODEX_MANAGED_BY_PNPM").is_some(); Self::from_exe( cfg!(target_os = "macos"), current_exe.as_deref(), managed_by_npm, managed_by_bun, + managed_by_pnpm, ) }) } @@ -310,6 +319,7 @@ mod tests { /*current_exe*/ Some(&exe_path), /*managed_by_npm*/ false, /*managed_by_bun*/ false, + /*managed_by_pnpm*/ false, /*codex_home*/ Some(codex_home.path()), ); assert_eq!( @@ -345,6 +355,7 @@ mod tests { /*current_exe*/ Some(&exe_path), /*managed_by_npm*/ false, /*managed_by_bun*/ false, + /*managed_by_pnpm*/ false, /*codex_home*/ Some(codex_home.path()), ); assert_eq!(context.rg_command(), default_rg_command()); @@ -388,6 +399,7 @@ mod tests { /*current_exe*/ Some(&exe_path), /*managed_by_npm*/ false, /*managed_by_bun*/ false, + /*managed_by_pnpm*/ false, /*codex_home*/ None, ); assert_eq!( @@ -452,6 +464,7 @@ mod tests { /*current_exe*/ Some(&exe_path), /*managed_by_npm*/ false, /*managed_by_bun*/ false, + /*managed_by_pnpm*/ false, /*codex_home*/ Some(codex_home.path()), ); assert_eq!( @@ -501,6 +514,7 @@ mod tests { /*current_exe*/ Some(&exe_path), /*managed_by_npm*/ true, /*managed_by_bun*/ false, + /*managed_by_pnpm*/ false, /*codex_home*/ None, ); assert_eq!(context.method, InstallMethod::Npm); @@ -528,6 +542,7 @@ mod tests { /*current_exe*/ Some(&exe_path), /*managed_by_npm*/ false, /*managed_by_bun*/ false, + /*managed_by_pnpm*/ false, /*codex_home*/ None, ); assert_eq!(context.rg_command(), default_rg_command()); @@ -552,6 +567,7 @@ mod tests { /*current_exe*/ Some(&exe_path), /*managed_by_npm*/ false, /*managed_by_bun*/ false, + /*managed_by_pnpm*/ false, /*codex_home*/ None, ); assert_eq!(context.rg_command(), default_rg_command()); @@ -560,12 +576,29 @@ mod tests { } #[test] - fn npm_and_bun_take_precedence() { + fn package_manager_markers_take_precedence() { + let pnpm_context = InstallContext::from_exe_with_codex_home( + /*is_macos*/ false, + /*current_exe*/ Some(Path::new("/tmp/codex")), + /*managed_by_npm*/ false, + /*managed_by_bun*/ false, + /*managed_by_pnpm*/ true, + /*codex_home*/ None, + ); + assert_eq!( + pnpm_context, + InstallContext { + method: InstallMethod::Pnpm, + package_layout: None, + } + ); + let npm_context = InstallContext::from_exe_with_codex_home( /*is_macos*/ false, /*current_exe*/ Some(Path::new("/tmp/codex")), /*managed_by_npm*/ true, /*managed_by_bun*/ false, + /*managed_by_pnpm*/ false, /*codex_home*/ None, ); assert_eq!( @@ -581,6 +614,7 @@ mod tests { /*current_exe*/ Some(Path::new("/tmp/codex")), /*managed_by_npm*/ false, /*managed_by_bun*/ true, + /*managed_by_pnpm*/ false, /*codex_home*/ None, ); assert_eq!( @@ -599,6 +633,7 @@ mod tests { /*current_exe*/ Some(Path::new("/opt/homebrew/bin/codex")), /*managed_by_npm*/ false, /*managed_by_bun*/ false, + /*managed_by_pnpm*/ false, /*codex_home*/ None, ); assert_eq!( diff --git a/codex-rs/tui/src/update_action.rs b/codex-rs/tui/src/update_action.rs index 420562f13b68..0fe4408444de 100644 --- a/codex-rs/tui/src/update_action.rs +++ b/codex-rs/tui/src/update_action.rs @@ -12,6 +12,8 @@ pub enum UpdateAction { NpmGlobalLatest, /// Update via `bun install -g @openai/codex@latest`. BunGlobalLatest, + /// Update via `pnpm add -g @openai/codex@latest`. + PnpmGlobalLatest, /// Update via `brew upgrade codex`. BrewUpgrade, /// Update via `curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh`. @@ -26,6 +28,7 @@ impl UpdateAction { match &context.method { InstallMethod::Npm => Some(UpdateAction::NpmGlobalLatest), InstallMethod::Bun => Some(UpdateAction::BunGlobalLatest), + InstallMethod::Pnpm => Some(UpdateAction::PnpmGlobalLatest), InstallMethod::Brew => Some(UpdateAction::BrewUpgrade), InstallMethod::Standalone { platform, .. } => Some(match platform { StandalonePlatform::Unix => UpdateAction::StandaloneUnix, @@ -40,6 +43,7 @@ impl UpdateAction { match self { UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex"]), UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex"]), + UpdateAction::PnpmGlobalLatest => ("pnpm", &["add", "-g", "@openai/codex"]), UpdateAction::BrewUpgrade => ("brew", &["upgrade", "--cask", "codex"]), UpdateAction::StandaloneUnix => ( "sh", @@ -106,6 +110,13 @@ mod tests { }), Some(UpdateAction::BunGlobalLatest) ); + assert_eq!( + UpdateAction::from_install_context(&InstallContext { + method: InstallMethod::Pnpm, + package_layout: None, + }), + Some(UpdateAction::PnpmGlobalLatest) + ); assert_eq!( UpdateAction::from_install_context(&InstallContext { method: InstallMethod::Brew, diff --git a/codex-rs/tui/src/updates.rs b/codex-rs/tui/src/updates.rs index 65a2317560ab..64e7f76344f9 100644 --- a/codex-rs/tui/src/updates.rs +++ b/codex-rs/tui/src/updates.rs @@ -79,7 +79,9 @@ async fn check_for_update(version_file: &Path, action: Option) -> .await?; version } - Some(UpdateAction::NpmGlobalLatest) | Some(UpdateAction::BunGlobalLatest) => { + Some(UpdateAction::NpmGlobalLatest) + | Some(UpdateAction::BunGlobalLatest) + | Some(UpdateAction::PnpmGlobalLatest) => { let latest_version = fetch_latest_github_release_version().await?; let package_info = create_client() .get(npm_registry::PACKAGE_URL) From 62a32b366bb17b133ebdf674d72a40962508ef60 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 7 Jul 2026 20:30:06 -0400 Subject: [PATCH 02/11] Clarify path containment check --- codex-cli/bin/codex.js | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js index 29b0069b06f6..31cea8c623f1 100755 --- a/codex-cli/bin/codex.js +++ b/codex-cli/bin/codex.js @@ -152,12 +152,11 @@ function detectPackageManager() { function isPathInside(parent, child) { const relative = path.relative(parent, child); - return ( - relative === "" || - (relative !== ".." && - !relative.startsWith(`..${path.sep}`) && - !path.isAbsolute(relative)) - ); + const isOutside = + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative); + return !isOutside; } const packageManagerEnvVar = From a73a812d047b3a42e0232adcca60bb8f71fa3a3b Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 7 Jul 2026 20:56:31 -0400 Subject: [PATCH 03/11] Detect pnpm from installation metadata --- codex-cli/bin/codex.js | 26 +++---- codex-rs/install-context/src/lib.rs | 78 ++++++++++++------- ...pdate_available_history_cell_snapshot.snap | 11 +++ codex-rs/tui/src/history_cell/tests.rs | 9 +++ 4 files changed, 79 insertions(+), 45 deletions(-) create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__pnpm_update_available_history_cell_snapshot.snap diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js index 31cea8c623f1..eb842a8e5f2d 100755 --- a/codex-cli/bin/codex.js +++ b/codex-cli/bin/codex.js @@ -120,14 +120,19 @@ const binaryPath = findCodexExecutable(); * in order to give the user a hint about how to update it. */ function detectPackageManager() { - const pnpmHome = process.env.PNPM_HOME; - if (pnpmHome) { - const resolvedPnpmHome = existsSync(pnpmHome) - ? realpathSync(pnpmHome) - : path.resolve(pnpmHome); - if (isPathInside(resolvedPnpmHome, codexPackageRoot)) { + // pnpm records installation metadata in node_modules/.modules.yaml. Search + // from the entry point because its global package and bin dirs can differ. + let currentDir = path.dirname(path.resolve(process.argv[1])); + while (true) { + if (existsSync(path.join(currentDir, "node_modules", ".modules.yaml"))) { return "pnpm"; } + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + break; + } + currentDir = parentDir; } const userAgent = process.env.npm_config_user_agent || ""; @@ -150,15 +155,6 @@ function detectPackageManager() { return userAgent ? "npm" : null; } -function isPathInside(parent, child) { - const relative = path.relative(parent, child); - const isOutside = - relative === ".." || - relative.startsWith(`..${path.sep}`) || - path.isAbsolute(relative); - return !isOutside; -} - const packageManagerEnvVar = { "bun": "CODEX_MANAGED_BY_BUN", diff --git a/codex-rs/install-context/src/lib.rs b/codex-rs/install-context/src/lib.rs index e7cebbcb7e01..f99ee8bfb8cd 100644 --- a/codex-rs/install-context/src/lib.rs +++ b/codex-rs/install-context/src/lib.rs @@ -67,13 +67,18 @@ pub enum InstallMethod { Other, } +enum ManagedPackageManager { + Npm, + Bun, + Pnpm, +} + impl InstallContext { pub fn from_exe( is_macos: bool, current_exe: Option<&Path>, managed_by_npm: bool, managed_by_bun: bool, - managed_by_pnpm: bool, ) -> Self { let codex_home = codex_utils_home_dir::find_codex_home().ok(); Self::from_exe_with_codex_home( @@ -81,7 +86,6 @@ impl InstallContext { current_exe, managed_by_npm, managed_by_bun, - managed_by_pnpm, codex_home.as_deref(), ) } @@ -91,16 +95,36 @@ impl InstallContext { current_exe: Option<&Path>, managed_by_npm: bool, managed_by_bun: bool, - managed_by_pnpm: bool, codex_home: Option<&Path>, ) -> Self { - let package_layout = current_exe.and_then(CodexPackageLayout::from_exe); - let method = if managed_by_pnpm { - InstallMethod::Pnpm - } else if managed_by_npm { - InstallMethod::Npm + let managed_package_manager = if managed_by_npm { + Some(ManagedPackageManager::Npm) } else if managed_by_bun { - InstallMethod::Bun + Some(ManagedPackageManager::Bun) + } else { + None + }; + Self::from_exe_with_package_manager_and_codex_home( + is_macos, + current_exe, + managed_package_manager, + codex_home, + ) + } + + fn from_exe_with_package_manager_and_codex_home( + is_macos: bool, + current_exe: Option<&Path>, + managed_package_manager: Option, + codex_home: Option<&Path>, + ) -> Self { + let package_layout = current_exe.and_then(CodexPackageLayout::from_exe); + let method = if let Some(managed_package_manager) = managed_package_manager { + match managed_package_manager { + ManagedPackageManager::Npm => InstallMethod::Npm, + ManagedPackageManager::Bun => InstallMethod::Bun, + ManagedPackageManager::Pnpm => InstallMethod::Pnpm, + } } else if let Some(exe_path) = current_exe { install_method_from_exe(exe_path, codex_home, package_layout.as_ref(), is_macos) } else { @@ -116,15 +140,21 @@ impl InstallContext { pub fn current() -> &'static Self { INSTALL_CONTEXT.get_or_init(|| { let current_exe = std::env::current_exe().ok(); - let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some(); - let managed_by_bun = std::env::var_os("CODEX_MANAGED_BY_BUN").is_some(); - let managed_by_pnpm = std::env::var_os("CODEX_MANAGED_BY_PNPM").is_some(); - Self::from_exe( + let managed_package_manager = if std::env::var_os("CODEX_MANAGED_BY_PNPM").is_some() { + Some(ManagedPackageManager::Pnpm) + } else if std::env::var_os("CODEX_MANAGED_BY_NPM").is_some() { + Some(ManagedPackageManager::Npm) + } else if std::env::var_os("CODEX_MANAGED_BY_BUN").is_some() { + Some(ManagedPackageManager::Bun) + } else { + None + }; + let codex_home = codex_utils_home_dir::find_codex_home().ok(); + Self::from_exe_with_package_manager_and_codex_home( cfg!(target_os = "macos"), current_exe.as_deref(), - managed_by_npm, - managed_by_bun, - managed_by_pnpm, + managed_package_manager, + codex_home.as_deref(), ) }) } @@ -319,7 +349,6 @@ mod tests { /*current_exe*/ Some(&exe_path), /*managed_by_npm*/ false, /*managed_by_bun*/ false, - /*managed_by_pnpm*/ false, /*codex_home*/ Some(codex_home.path()), ); assert_eq!( @@ -355,7 +384,6 @@ mod tests { /*current_exe*/ Some(&exe_path), /*managed_by_npm*/ false, /*managed_by_bun*/ false, - /*managed_by_pnpm*/ false, /*codex_home*/ Some(codex_home.path()), ); assert_eq!(context.rg_command(), default_rg_command()); @@ -399,7 +427,6 @@ mod tests { /*current_exe*/ Some(&exe_path), /*managed_by_npm*/ false, /*managed_by_bun*/ false, - /*managed_by_pnpm*/ false, /*codex_home*/ None, ); assert_eq!( @@ -464,7 +491,6 @@ mod tests { /*current_exe*/ Some(&exe_path), /*managed_by_npm*/ false, /*managed_by_bun*/ false, - /*managed_by_pnpm*/ false, /*codex_home*/ Some(codex_home.path()), ); assert_eq!( @@ -514,7 +540,6 @@ mod tests { /*current_exe*/ Some(&exe_path), /*managed_by_npm*/ true, /*managed_by_bun*/ false, - /*managed_by_pnpm*/ false, /*codex_home*/ None, ); assert_eq!(context.method, InstallMethod::Npm); @@ -542,7 +567,6 @@ mod tests { /*current_exe*/ Some(&exe_path), /*managed_by_npm*/ false, /*managed_by_bun*/ false, - /*managed_by_pnpm*/ false, /*codex_home*/ None, ); assert_eq!(context.rg_command(), default_rg_command()); @@ -567,7 +591,6 @@ mod tests { /*current_exe*/ Some(&exe_path), /*managed_by_npm*/ false, /*managed_by_bun*/ false, - /*managed_by_pnpm*/ false, /*codex_home*/ None, ); assert_eq!(context.rg_command(), default_rg_command()); @@ -577,12 +600,10 @@ mod tests { #[test] fn package_manager_markers_take_precedence() { - let pnpm_context = InstallContext::from_exe_with_codex_home( + let pnpm_context = InstallContext::from_exe_with_package_manager_and_codex_home( /*is_macos*/ false, /*current_exe*/ Some(Path::new("/tmp/codex")), - /*managed_by_npm*/ false, - /*managed_by_bun*/ false, - /*managed_by_pnpm*/ true, + /*managed_package_manager*/ Some(ManagedPackageManager::Pnpm), /*codex_home*/ None, ); assert_eq!( @@ -598,7 +619,6 @@ mod tests { /*current_exe*/ Some(Path::new("/tmp/codex")), /*managed_by_npm*/ true, /*managed_by_bun*/ false, - /*managed_by_pnpm*/ false, /*codex_home*/ None, ); assert_eq!( @@ -614,7 +634,6 @@ mod tests { /*current_exe*/ Some(Path::new("/tmp/codex")), /*managed_by_npm*/ false, /*managed_by_bun*/ true, - /*managed_by_pnpm*/ false, /*codex_home*/ None, ); assert_eq!( @@ -633,7 +652,6 @@ mod tests { /*current_exe*/ Some(Path::new("/opt/homebrew/bin/codex")), /*managed_by_npm*/ false, /*managed_by_bun*/ false, - /*managed_by_pnpm*/ false, /*codex_home*/ None, ); assert_eq!( diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__pnpm_update_available_history_cell_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__pnpm_update_available_history_cell_snapshot.snap new file mode 100644 index 000000000000..a9dd9cac784b --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__pnpm_update_available_history_cell_snapshot.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/history_cell/tests.rs +expression: rendered +--- +╭─────────────────────────────────────────────────╮ +│ ✨ Update available! 0.0.0 -> 9.9.9 │ +│ Run pnpm add -g @openai/codex to update. │ +│ │ +│ See full release notes: │ +│ https://github.com/openai/codex/releases/latest │ +╰─────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index 42dadb8535bc..f5a3a2efa237 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -1137,6 +1137,15 @@ fn standalone_windows_update_available_history_cell_snapshot() { insta::assert_snapshot!(rendered); } +#[test] +fn pnpm_update_available_history_cell_snapshot() { + let cell = + UpdateAvailableHistoryCell::new("9.9.9".to_string(), Some(UpdateAction::PnpmGlobalLatest)); + let rendered = render_lines(&cell.display_lines(/*width*/ 110)).join("\n"); + + insta::assert_snapshot!(rendered); +} + #[test] fn web_search_history_cell_without_detail_snapshot() { let cell = new_web_search_call("call-1".to_string(), String::new(), WebSearchAction::Other); From b6ae469dc757dc283b6dd5c594d3bfa17110eedc Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 7 Jul 2026 21:08:45 -0400 Subject: [PATCH 04/11] Detect pnpm through symlinked global bins --- codex-cli/bin/codex.js | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js index eb842a8e5f2d..003be748fa25 100755 --- a/codex-cli/bin/codex.js +++ b/codex-cli/bin/codex.js @@ -121,18 +121,21 @@ const binaryPath = findCodexExecutable(); */ function detectPackageManager() { // pnpm records installation metadata in node_modules/.modules.yaml. Search - // from the entry point because its global package and bin dirs can differ. - let currentDir = path.dirname(path.resolve(process.argv[1])); - while (true) { - if (existsSync(path.join(currentDir, "node_modules", ".modules.yaml"))) { - return "pnpm"; - } - - const parentDir = path.dirname(currentDir); - if (parentDir === currentDir) { - break; + // the canonical package root and lexical entrypoint because either can be + // linked by pnpm. + const entrypointDir = path.dirname(path.resolve(process.argv[1])); + for (let currentDir of new Set([codexPackageRoot, entrypointDir])) { + while (true) { + if (existsSync(path.join(currentDir, "node_modules", ".modules.yaml"))) { + return "pnpm"; + } + + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) { + break; + } + currentDir = parentDir; } - currentDir = parentDir; } const userAgent = process.env.npm_config_user_agent || ""; From a98aba548aad377ff2ed190dbfa3c927c9daabf1 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 7 Jul 2026 21:24:50 -0400 Subject: [PATCH 05/11] Constrain pnpm detection to the owning install --- codex-cli/bin/codex.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js index 003be748fa25..cd2d03dcfddf 100755 --- a/codex-cli/bin/codex.js +++ b/codex-cli/bin/codex.js @@ -126,7 +126,7 @@ function detectPackageManager() { const entrypointDir = path.dirname(path.resolve(process.argv[1])); for (let currentDir of new Set([codexPackageRoot, entrypointDir])) { while (true) { - if (existsSync(path.join(currentDir, "node_modules", ".modules.yaml"))) { + if (existsSync(path.join(currentDir, ".modules.yaml"))) { return "pnpm"; } From 74276dc77ee24d7e54c12936ed83f5524fa4475d Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 7 Jul 2026 21:45:41 -0400 Subject: [PATCH 06/11] Verify pnpm installation ownership --- codex-cli/bin/codex.js | 14 +- scripts/codex_package/test_npm_launcher.py | 180 +++++++++++++++++++++ 2 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 scripts/codex_package/test_npm_launcher.py diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js index cd2d03dcfddf..934f17af5bc7 100755 --- a/codex-cli/bin/codex.js +++ b/codex-cli/bin/codex.js @@ -126,8 +126,18 @@ function detectPackageManager() { const entrypointDir = path.dirname(path.resolve(process.argv[1])); for (let currentDir of new Set([codexPackageRoot, entrypointDir])) { while (true) { - if (existsSync(path.join(currentDir, ".modules.yaml"))) { - return "pnpm"; + const nodeModulesDir = path.join(currentDir, "node_modules"); + if (existsSync(path.join(nodeModulesDir, ".modules.yaml"))) { + try { + if ( + realpathSync(path.join(nodeModulesDir, "@openai", "codex")) === + codexPackageRoot + ) { + return "pnpm"; + } + } catch { + // Keep looking if this installation does not contain Codex. + } } const parentDir = path.dirname(currentDir); diff --git a/scripts/codex_package/test_npm_launcher.py b/scripts/codex_package/test_npm_launcher.py new file mode 100644 index 000000000000..d2d6a33e2e44 --- /dev/null +++ b/scripts/codex_package/test_npm_launcher.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 + +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + + +REPO_ROOT = Path(__file__).resolve().parents[2] +CODEX_CLI_ROOT = REPO_ROOT / "codex-cli" +NODE = shutil.which("node") + +TARGET_BY_PLATFORM_ARCH = { + "darwin/arm64": "aarch64-apple-darwin", + "darwin/x64": "x86_64-apple-darwin", + "linux/arm64": "aarch64-unknown-linux-musl", + "linux/x64": "x86_64-unknown-linux-musl", +} + + +@unittest.skipIf(os.name == "nt", "launcher fixtures require Unix symlinks") +@unittest.skipIf(NODE is None, "node is required") +class NpmLauncherPackageManagerTest(unittest.TestCase): + def setUp(self) -> None: + assert NODE is not None + self.node = Path(NODE).resolve() + platform_arch = subprocess.check_output( + [str(self.node), "-p", "`${process.platform}/${process.arch}`"], + text=True, + ).strip() + if platform_arch not in TARGET_BY_PLATFORM_ARCH: + self.skipTest(f"unsupported Node platform: {platform_arch}") + self.target = TARGET_BY_PLATFORM_ARCH[platform_arch] + + self.temp_dir = tempfile.TemporaryDirectory(prefix="codex-npm-launcher-") + self.addCleanup(self.temp_dir.cleanup) + self.root = Path(self.temp_dir.name) + self.probe = self.root / "probe.mjs" + self.probe.write_text( + """ +console.log(JSON.stringify({ + npm: process.env.CODEX_MANAGED_BY_NPM ?? null, + bun: process.env.CODEX_MANAGED_BY_BUN ?? null, + pnpm: process.env.CODEX_MANAGED_BY_PNPM ?? null, + packageRoot: process.env.CODEX_MANAGED_PACKAGE_ROOT ?? null, +})); +""".strip() + + "\n", + encoding="utf-8", + ) + + def test_detects_pnpm_virtual_store_global_install(self) -> None: + layout_dir = self.root / "custom-global" / "5" + package_root = ( + layout_dir + / ".pnpm" + / "@openai+codex@1.0.0" + / "node_modules" + / "@openai" + / "codex" + ) + entrypoint = self.create_codex_package(package_root) + + node_modules = layout_dir / "node_modules" + self.create_pnpm_marker(node_modules) + self.link_directory(package_root, node_modules / "@openai" / "codex") + global_bin = self.root / "pnpm-home" / "codex" + self.link_file(entrypoint, global_bin) + + self.assert_manager(global_bin, package_root, "pnpm") + + def test_detects_hoisted_pnpm_global_install(self) -> None: + node_modules = self.root / "custom-global" / "5" / "node_modules" + package_root = node_modules / "@openai" / "codex" + entrypoint = self.create_codex_package(package_root) + self.create_pnpm_marker(node_modules) + global_bin = self.root / "pnpm-home" / "codex" + self.link_file(entrypoint, global_bin) + + self.assert_manager(global_bin, package_root, "pnpm") + + def test_detects_pnpm_v11_linked_install_group(self) -> None: + package_root = ( + self.root + / "pnpm-home" + / "store" + / "v10" + / "links" + / "@openai+codex" + / "node_modules" + / "@openai" + / "codex" + ) + self.create_codex_package(package_root) + + node_modules = ( + self.root / "pnpm-home" / "global" / "v11" / "123-456" / "node_modules" + ) + self.create_pnpm_marker(node_modules) + public_package = node_modules / "@openai" / "codex" + self.link_directory(package_root, public_package) + + self.assert_manager(public_package / "bin" / "codex.js", package_root, "pnpm") + + def test_rejects_unrelated_ancestor_pnpm_metadata(self) -> None: + home = self.root / "home" + package_root = ( + home / ".npm-global" / "lib" / "node_modules" / "@openai" / "codex" + ) + entrypoint = self.create_codex_package(package_root) + npm_bin = home / ".npm-global" / "bin" / "codex" + self.link_file(entrypoint, npm_bin) + + unrelated_node_modules = home / "node_modules" + self.create_pnpm_marker(unrelated_node_modules) + (unrelated_node_modules / "@openai" / "codex").mkdir(parents=True) + + self.assert_manager(npm_bin, package_root, "npm") + + def create_codex_package(self, package_root: Path) -> Path: + bin_dir = package_root / "bin" + bin_dir.mkdir(parents=True) + entrypoint = bin_dir / "codex.js" + shutil.copy2(CODEX_CLI_ROOT / "bin" / "codex.js", entrypoint) + shutil.copy2(CODEX_CLI_ROOT / "package.json", package_root / "package.json") + + vendor_bin = package_root / "vendor" / self.target / "bin" / "codex" + vendor_bin.parent.mkdir(parents=True) + vendor_bin.symlink_to(self.node) + return entrypoint + + def create_pnpm_marker(self, node_modules: Path) -> None: + node_modules.mkdir(parents=True, exist_ok=True) + (node_modules / ".modules.yaml").write_text( + "packageManager: pnpm@10.0.0\n", + encoding="utf-8", + ) + + def link_directory(self, target: Path, link: Path) -> None: + link.parent.mkdir(parents=True, exist_ok=True) + link.symlink_to(target, target_is_directory=True) + + def link_file(self, target: Path, link: Path) -> None: + link.parent.mkdir(parents=True, exist_ok=True) + link.symlink_to(target) + + def assert_manager( + self, + entrypoint: Path, + package_root: Path, + expected_manager: str, + ) -> None: + result = subprocess.run( + [str(self.node), str(entrypoint), str(self.probe)], + cwd=self.root, + env={"PATH": os.environ.get("PATH", "")}, + text=True, + capture_output=True, + timeout=10, + ) + self.assertEqual( + result.returncode, + 0, + msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}", + ) + actual = json.loads(result.stdout) + expected = { + "npm": "1" if expected_manager == "npm" else None, + "bun": "1" if expected_manager == "bun" else None, + "pnpm": "1" if expected_manager == "pnpm" else None, + "packageRoot": str(package_root.resolve()), + } + self.assertEqual(actual, expected) + + +if __name__ == "__main__": + unittest.main() From abfa6d214518c32b0ece28df2f5b63a4f3593dbd Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 7 Jul 2026 22:04:27 -0400 Subject: [PATCH 07/11] Remove pnpm launcher test harness --- scripts/codex_package/test_npm_launcher.py | 180 --------------------- 1 file changed, 180 deletions(-) delete mode 100644 scripts/codex_package/test_npm_launcher.py diff --git a/scripts/codex_package/test_npm_launcher.py b/scripts/codex_package/test_npm_launcher.py deleted file mode 100644 index d2d6a33e2e44..000000000000 --- a/scripts/codex_package/test_npm_launcher.py +++ /dev/null @@ -1,180 +0,0 @@ -#!/usr/bin/env python3 - -import json -import os -from pathlib import Path -import shutil -import subprocess -import tempfile -import unittest - - -REPO_ROOT = Path(__file__).resolve().parents[2] -CODEX_CLI_ROOT = REPO_ROOT / "codex-cli" -NODE = shutil.which("node") - -TARGET_BY_PLATFORM_ARCH = { - "darwin/arm64": "aarch64-apple-darwin", - "darwin/x64": "x86_64-apple-darwin", - "linux/arm64": "aarch64-unknown-linux-musl", - "linux/x64": "x86_64-unknown-linux-musl", -} - - -@unittest.skipIf(os.name == "nt", "launcher fixtures require Unix symlinks") -@unittest.skipIf(NODE is None, "node is required") -class NpmLauncherPackageManagerTest(unittest.TestCase): - def setUp(self) -> None: - assert NODE is not None - self.node = Path(NODE).resolve() - platform_arch = subprocess.check_output( - [str(self.node), "-p", "`${process.platform}/${process.arch}`"], - text=True, - ).strip() - if platform_arch not in TARGET_BY_PLATFORM_ARCH: - self.skipTest(f"unsupported Node platform: {platform_arch}") - self.target = TARGET_BY_PLATFORM_ARCH[platform_arch] - - self.temp_dir = tempfile.TemporaryDirectory(prefix="codex-npm-launcher-") - self.addCleanup(self.temp_dir.cleanup) - self.root = Path(self.temp_dir.name) - self.probe = self.root / "probe.mjs" - self.probe.write_text( - """ -console.log(JSON.stringify({ - npm: process.env.CODEX_MANAGED_BY_NPM ?? null, - bun: process.env.CODEX_MANAGED_BY_BUN ?? null, - pnpm: process.env.CODEX_MANAGED_BY_PNPM ?? null, - packageRoot: process.env.CODEX_MANAGED_PACKAGE_ROOT ?? null, -})); -""".strip() - + "\n", - encoding="utf-8", - ) - - def test_detects_pnpm_virtual_store_global_install(self) -> None: - layout_dir = self.root / "custom-global" / "5" - package_root = ( - layout_dir - / ".pnpm" - / "@openai+codex@1.0.0" - / "node_modules" - / "@openai" - / "codex" - ) - entrypoint = self.create_codex_package(package_root) - - node_modules = layout_dir / "node_modules" - self.create_pnpm_marker(node_modules) - self.link_directory(package_root, node_modules / "@openai" / "codex") - global_bin = self.root / "pnpm-home" / "codex" - self.link_file(entrypoint, global_bin) - - self.assert_manager(global_bin, package_root, "pnpm") - - def test_detects_hoisted_pnpm_global_install(self) -> None: - node_modules = self.root / "custom-global" / "5" / "node_modules" - package_root = node_modules / "@openai" / "codex" - entrypoint = self.create_codex_package(package_root) - self.create_pnpm_marker(node_modules) - global_bin = self.root / "pnpm-home" / "codex" - self.link_file(entrypoint, global_bin) - - self.assert_manager(global_bin, package_root, "pnpm") - - def test_detects_pnpm_v11_linked_install_group(self) -> None: - package_root = ( - self.root - / "pnpm-home" - / "store" - / "v10" - / "links" - / "@openai+codex" - / "node_modules" - / "@openai" - / "codex" - ) - self.create_codex_package(package_root) - - node_modules = ( - self.root / "pnpm-home" / "global" / "v11" / "123-456" / "node_modules" - ) - self.create_pnpm_marker(node_modules) - public_package = node_modules / "@openai" / "codex" - self.link_directory(package_root, public_package) - - self.assert_manager(public_package / "bin" / "codex.js", package_root, "pnpm") - - def test_rejects_unrelated_ancestor_pnpm_metadata(self) -> None: - home = self.root / "home" - package_root = ( - home / ".npm-global" / "lib" / "node_modules" / "@openai" / "codex" - ) - entrypoint = self.create_codex_package(package_root) - npm_bin = home / ".npm-global" / "bin" / "codex" - self.link_file(entrypoint, npm_bin) - - unrelated_node_modules = home / "node_modules" - self.create_pnpm_marker(unrelated_node_modules) - (unrelated_node_modules / "@openai" / "codex").mkdir(parents=True) - - self.assert_manager(npm_bin, package_root, "npm") - - def create_codex_package(self, package_root: Path) -> Path: - bin_dir = package_root / "bin" - bin_dir.mkdir(parents=True) - entrypoint = bin_dir / "codex.js" - shutil.copy2(CODEX_CLI_ROOT / "bin" / "codex.js", entrypoint) - shutil.copy2(CODEX_CLI_ROOT / "package.json", package_root / "package.json") - - vendor_bin = package_root / "vendor" / self.target / "bin" / "codex" - vendor_bin.parent.mkdir(parents=True) - vendor_bin.symlink_to(self.node) - return entrypoint - - def create_pnpm_marker(self, node_modules: Path) -> None: - node_modules.mkdir(parents=True, exist_ok=True) - (node_modules / ".modules.yaml").write_text( - "packageManager: pnpm@10.0.0\n", - encoding="utf-8", - ) - - def link_directory(self, target: Path, link: Path) -> None: - link.parent.mkdir(parents=True, exist_ok=True) - link.symlink_to(target, target_is_directory=True) - - def link_file(self, target: Path, link: Path) -> None: - link.parent.mkdir(parents=True, exist_ok=True) - link.symlink_to(target) - - def assert_manager( - self, - entrypoint: Path, - package_root: Path, - expected_manager: str, - ) -> None: - result = subprocess.run( - [str(self.node), str(entrypoint), str(self.probe)], - cwd=self.root, - env={"PATH": os.environ.get("PATH", "")}, - text=True, - capture_output=True, - timeout=10, - ) - self.assertEqual( - result.returncode, - 0, - msg=f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}", - ) - actual = json.loads(result.stdout) - expected = { - "npm": "1" if expected_manager == "npm" else None, - "bun": "1" if expected_manager == "bun" else None, - "pnpm": "1" if expected_manager == "pnpm" else None, - "packageRoot": str(package_root.resolve()), - } - self.assertEqual(actual, expected) - - -if __name__ == "__main__": - unittest.main() From c218b5e9cb0c11f8a3d52bcae1c85720b6a611b3 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 7 Jul 2026 22:14:08 -0400 Subject: [PATCH 08/11] Simplify package manager marker selection --- codex-cli/bin/codex.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js index 934f17af5bc7..a43104a3bf3c 100755 --- a/codex-cli/bin/codex.js +++ b/codex-cli/bin/codex.js @@ -168,11 +168,13 @@ function detectPackageManager() { return userAgent ? "npm" : null; } +const packageManager = detectPackageManager(); const packageManagerEnvVar = - { - "bun": "CODEX_MANAGED_BY_BUN", - "pnpm": "CODEX_MANAGED_BY_PNPM", - }[detectPackageManager()] ?? "CODEX_MANAGED_BY_NPM"; + packageManager === "bun" + ? "CODEX_MANAGED_BY_BUN" + : packageManager === "pnpm" + ? "CODEX_MANAGED_BY_PNPM" + : "CODEX_MANAGED_BY_NPM"; const env = { ...process.env, CODEX_MANAGED_PACKAGE_ROOT: codexPackageRoot, From 73ed1f741381f059770e26d52dedc445da4196db Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 7 Jul 2026 22:26:57 -0400 Subject: [PATCH 09/11] Bound pnpm ownership scan --- codex-cli/bin/codex.js | 52 ++++++++++++++++++++++++------------------ 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js index a43104a3bf3c..a307e3861023 100755 --- a/codex-cli/bin/codex.js +++ b/codex-cli/bin/codex.js @@ -115,36 +115,44 @@ const binaryPath = findCodexExecutable(); // and guarantees that when either the child terminates or the parent // receives a fatal signal, both processes exit in a predictable manner. +function isPnpmOwnedCodexInstall(nodeModulesDir) { + if (!existsSync(path.join(nodeModulesDir, ".modules.yaml"))) { + return false; + } + + try { + return ( + realpathSync(path.join(nodeModulesDir, "@openai", "codex")) === + codexPackageRoot + ); + } catch { + return false; + } +} + /** * Use heuristics to detect the package manager that was used to install Codex * in order to give the user a hint about how to update it. */ function detectPackageManager() { - // pnpm records installation metadata in node_modules/.modules.yaml. Search - // the canonical package root and lexical entrypoint because either can be - // linked by pnpm. + // pnpm's owning node_modules directory can be several parents above the + // package in isolated global layouts. Search ancestors of both the canonical + // package root and lexical entrypoint because pnpm may link either path. const entrypointDir = path.dirname(path.resolve(process.argv[1])); - for (let currentDir of new Set([codexPackageRoot, entrypointDir])) { - while (true) { - const nodeModulesDir = path.join(currentDir, "node_modules"); - if (existsSync(path.join(nodeModulesDir, ".modules.yaml"))) { - try { - if ( - realpathSync(path.join(nodeModulesDir, "@openai", "codex")) === - codexPackageRoot - ) { - return "pnpm"; - } - } catch { - // Keep looking if this installation does not contain Codex. - } + for (const startDir of new Set([codexPackageRoot, entrypointDir])) { + const filesystemRoot = path.parse(startDir).root; + for ( + let currentDir = startDir; + currentDir !== filesystemRoot; + currentDir = path.dirname(currentDir) + ) { + if (isPnpmOwnedCodexInstall(path.join(currentDir, "node_modules"))) { + return "pnpm"; } + } - const parentDir = path.dirname(currentDir); - if (parentDir === currentDir) { - break; - } - currentDir = parentDir; + if (isPnpmOwnedCodexInstall(path.join(filesystemRoot, "node_modules"))) { + return "pnpm"; } } From 7d9fc030bda561760fea2eac8e5c45c8df988cbd Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 8 Jul 2026 07:06:41 -0400 Subject: [PATCH 10/11] Simplify managed install method handling --- codex-rs/install-context/src/lib.rs | 94 +++++++++-------------------- 1 file changed, 29 insertions(+), 65 deletions(-) diff --git a/codex-rs/install-context/src/lib.rs b/codex-rs/install-context/src/lib.rs index f99ee8bfb8cd..1f9f1a174359 100644 --- a/codex-rs/install-context/src/lib.rs +++ b/codex-rs/install-context/src/lib.rs @@ -67,12 +67,6 @@ pub enum InstallMethod { Other, } -enum ManagedPackageManager { - Npm, - Bun, - Pnpm, -} - impl InstallContext { pub fn from_exe( is_macos: bool, @@ -80,51 +74,31 @@ impl InstallContext { managed_by_npm: bool, managed_by_bun: bool, ) -> Self { - let codex_home = codex_utils_home_dir::find_codex_home().ok(); - Self::from_exe_with_codex_home( - is_macos, - current_exe, - managed_by_npm, - managed_by_bun, - codex_home.as_deref(), - ) - } - - fn from_exe_with_codex_home( - is_macos: bool, - current_exe: Option<&Path>, - managed_by_npm: bool, - managed_by_bun: bool, - codex_home: Option<&Path>, - ) -> Self { - let managed_package_manager = if managed_by_npm { - Some(ManagedPackageManager::Npm) + let method_override = if managed_by_npm { + Some(InstallMethod::Npm) } else if managed_by_bun { - Some(ManagedPackageManager::Bun) + Some(InstallMethod::Bun) } else { None }; - Self::from_exe_with_package_manager_and_codex_home( + let codex_home = codex_utils_home_dir::find_codex_home().ok(); + Self::from_exe_with_codex_home( is_macos, current_exe, - managed_package_manager, - codex_home, + method_override, + codex_home.as_deref(), ) } - fn from_exe_with_package_manager_and_codex_home( + fn from_exe_with_codex_home( is_macos: bool, current_exe: Option<&Path>, - managed_package_manager: Option, + method_override: Option, codex_home: Option<&Path>, ) -> Self { let package_layout = current_exe.and_then(CodexPackageLayout::from_exe); - let method = if let Some(managed_package_manager) = managed_package_manager { - match managed_package_manager { - ManagedPackageManager::Npm => InstallMethod::Npm, - ManagedPackageManager::Bun => InstallMethod::Bun, - ManagedPackageManager::Pnpm => InstallMethod::Pnpm, - } + let method = if let Some(method) = method_override { + method } else if let Some(exe_path) = current_exe { install_method_from_exe(exe_path, codex_home, package_layout.as_ref(), is_macos) } else { @@ -140,20 +114,20 @@ impl InstallContext { pub fn current() -> &'static Self { INSTALL_CONTEXT.get_or_init(|| { let current_exe = std::env::current_exe().ok(); - let managed_package_manager = if std::env::var_os("CODEX_MANAGED_BY_PNPM").is_some() { - Some(ManagedPackageManager::Pnpm) + let method_override = if std::env::var_os("CODEX_MANAGED_BY_PNPM").is_some() { + Some(InstallMethod::Pnpm) } else if std::env::var_os("CODEX_MANAGED_BY_NPM").is_some() { - Some(ManagedPackageManager::Npm) + Some(InstallMethod::Npm) } else if std::env::var_os("CODEX_MANAGED_BY_BUN").is_some() { - Some(ManagedPackageManager::Bun) + Some(InstallMethod::Bun) } else { None }; let codex_home = codex_utils_home_dir::find_codex_home().ok(); - Self::from_exe_with_package_manager_and_codex_home( + Self::from_exe_with_codex_home( cfg!(target_os = "macos"), current_exe.as_deref(), - managed_package_manager, + method_override, codex_home.as_deref(), ) }) @@ -347,8 +321,7 @@ mod tests { let context = InstallContext::from_exe_with_codex_home( /*is_macos*/ false, /*current_exe*/ Some(&exe_path), - /*managed_by_npm*/ false, - /*managed_by_bun*/ false, + /*method_override*/ None, /*codex_home*/ Some(codex_home.path()), ); assert_eq!( @@ -382,8 +355,7 @@ mod tests { let context = InstallContext::from_exe_with_codex_home( /*is_macos*/ false, /*current_exe*/ Some(&exe_path), - /*managed_by_npm*/ false, - /*managed_by_bun*/ false, + /*method_override*/ None, /*codex_home*/ Some(codex_home.path()), ); assert_eq!(context.rg_command(), default_rg_command()); @@ -425,8 +397,7 @@ mod tests { let context = InstallContext::from_exe_with_codex_home( /*is_macos*/ false, /*current_exe*/ Some(&exe_path), - /*managed_by_npm*/ false, - /*managed_by_bun*/ false, + /*method_override*/ None, /*codex_home*/ None, ); assert_eq!( @@ -489,8 +460,7 @@ mod tests { let context = InstallContext::from_exe_with_codex_home( /*is_macos*/ false, /*current_exe*/ Some(&exe_path), - /*managed_by_npm*/ false, - /*managed_by_bun*/ false, + /*method_override*/ None, /*codex_home*/ Some(codex_home.path()), ); assert_eq!( @@ -535,12 +505,11 @@ mod tests { fs::write(path_dir.join(default_rg_command()), "")?; let canonical_path_dir = AbsolutePathBuf::from_absolute_path(path_dir.canonicalize()?)?; - let context = InstallContext::from_exe_with_codex_home( + let context = InstallContext::from_exe( /*is_macos*/ false, /*current_exe*/ Some(&exe_path), /*managed_by_npm*/ true, /*managed_by_bun*/ false, - /*codex_home*/ None, ); assert_eq!(context.method, InstallMethod::Npm); assert!(context.package_layout.is_some()); @@ -565,8 +534,7 @@ mod tests { let context = InstallContext::from_exe_with_codex_home( /*is_macos*/ false, /*current_exe*/ Some(&exe_path), - /*managed_by_npm*/ false, - /*managed_by_bun*/ false, + /*method_override*/ None, /*codex_home*/ None, ); assert_eq!(context.rg_command(), default_rg_command()); @@ -589,8 +557,7 @@ mod tests { let context = InstallContext::from_exe_with_codex_home( /*is_macos*/ false, /*current_exe*/ Some(&exe_path), - /*managed_by_npm*/ false, - /*managed_by_bun*/ false, + /*method_override*/ None, /*codex_home*/ None, ); assert_eq!(context.rg_command(), default_rg_command()); @@ -600,10 +567,10 @@ mod tests { #[test] fn package_manager_markers_take_precedence() { - let pnpm_context = InstallContext::from_exe_with_package_manager_and_codex_home( + let pnpm_context = InstallContext::from_exe_with_codex_home( /*is_macos*/ false, /*current_exe*/ Some(Path::new("/tmp/codex")), - /*managed_package_manager*/ Some(ManagedPackageManager::Pnpm), + /*method_override*/ Some(InstallMethod::Pnpm), /*codex_home*/ None, ); assert_eq!( @@ -614,12 +581,11 @@ mod tests { } ); - let npm_context = InstallContext::from_exe_with_codex_home( + let npm_context = InstallContext::from_exe( /*is_macos*/ false, /*current_exe*/ Some(Path::new("/tmp/codex")), /*managed_by_npm*/ true, /*managed_by_bun*/ false, - /*codex_home*/ None, ); assert_eq!( npm_context, @@ -629,12 +595,11 @@ mod tests { } ); - let bun_context = InstallContext::from_exe_with_codex_home( + let bun_context = InstallContext::from_exe( /*is_macos*/ false, /*current_exe*/ Some(Path::new("/tmp/codex")), /*managed_by_npm*/ false, /*managed_by_bun*/ true, - /*codex_home*/ None, ); assert_eq!( bun_context, @@ -650,8 +615,7 @@ mod tests { let context = InstallContext::from_exe_with_codex_home( /*is_macos*/ true, /*current_exe*/ Some(Path::new("/opt/homebrew/bin/codex")), - /*managed_by_npm*/ false, - /*managed_by_bun*/ false, + /*method_override*/ None, /*codex_home*/ None, ); assert_eq!( From 23aa44de587db1f8e7e58a39710284571e916e0f Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Wed, 8 Jul 2026 07:17:14 -0400 Subject: [PATCH 11/11] Accept install method override in from_exe --- codex-rs/install-context/src/lib.rs | 28 +++++++--------------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/codex-rs/install-context/src/lib.rs b/codex-rs/install-context/src/lib.rs index 1f9f1a174359..61dabb0e0290 100644 --- a/codex-rs/install-context/src/lib.rs +++ b/codex-rs/install-context/src/lib.rs @@ -71,16 +71,8 @@ impl InstallContext { pub fn from_exe( is_macos: bool, current_exe: Option<&Path>, - managed_by_npm: bool, - managed_by_bun: bool, + method_override: Option, ) -> Self { - let method_override = if managed_by_npm { - Some(InstallMethod::Npm) - } else if managed_by_bun { - Some(InstallMethod::Bun) - } else { - None - }; let codex_home = codex_utils_home_dir::find_codex_home().ok(); Self::from_exe_with_codex_home( is_macos, @@ -123,12 +115,10 @@ impl InstallContext { } else { None }; - let codex_home = codex_utils_home_dir::find_codex_home().ok(); - Self::from_exe_with_codex_home( + Self::from_exe( cfg!(target_os = "macos"), current_exe.as_deref(), method_override, - codex_home.as_deref(), ) }) } @@ -508,8 +498,7 @@ mod tests { let context = InstallContext::from_exe( /*is_macos*/ false, /*current_exe*/ Some(&exe_path), - /*managed_by_npm*/ true, - /*managed_by_bun*/ false, + /*method_override*/ Some(InstallMethod::Npm), ); assert_eq!(context.method, InstallMethod::Npm); assert!(context.package_layout.is_some()); @@ -566,12 +555,11 @@ mod tests { } #[test] - fn package_manager_markers_take_precedence() { - let pnpm_context = InstallContext::from_exe_with_codex_home( + fn package_manager_method_overrides_take_precedence() { + let pnpm_context = InstallContext::from_exe( /*is_macos*/ false, /*current_exe*/ Some(Path::new("/tmp/codex")), /*method_override*/ Some(InstallMethod::Pnpm), - /*codex_home*/ None, ); assert_eq!( pnpm_context, @@ -584,8 +572,7 @@ mod tests { let npm_context = InstallContext::from_exe( /*is_macos*/ false, /*current_exe*/ Some(Path::new("/tmp/codex")), - /*managed_by_npm*/ true, - /*managed_by_bun*/ false, + /*method_override*/ Some(InstallMethod::Npm), ); assert_eq!( npm_context, @@ -598,8 +585,7 @@ mod tests { let bun_context = InstallContext::from_exe( /*is_macos*/ false, /*current_exe*/ Some(Path::new("/tmp/codex")), - /*managed_by_npm*/ false, - /*managed_by_bun*/ true, + /*method_override*/ Some(InstallMethod::Bun), ); assert_eq!( bun_context,