Detect Codex installs managed by pnpm (#31503)

## Why

The Codex JavaScript shim currently distinguishes npm and Bun installs,
but a global pnpm install falls back to npm. That causes the native CLI,
`codex doctor`, and update flows to report or run npm commands even
though pnpm owns the installation. pnpm also allows its global package
and bin directories to differ, so `PNPM_HOME` does not reliably identify
the package owner.

## What changed

- detect pnpm-managed installs by finding pnpm's
`node_modules/.modules.yaml` metadata from the launched JavaScript
entrypoint
- pass a mutually exclusive `CODEX_MANAGED_BY_PNPM` marker into the
native CLI
- represent pnpm in install context, doctor output, version checks, and
TUI update actions without changing the existing public
`InstallContext::from_exe` signature
- recommend `pnpm add -g @openai/codex` for pnpm-managed installs and
snapshot the rendered TUI update notice

Validated with the 9-test install-context suite, the focused pnpm TUI
snapshot test, and Node's syntax check for the launcher.

Closes https://github.com/openai/codex/issues/10294.
This commit is contained in:
Charlie Marsh
2026-07-08 12:17:58 -04:00
committed by GitHub
parent 0bbea86a6a
commit e212cc95b0
12 changed files with 152 additions and 50 deletions
+50 -5
View File
@@ -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}`,
);
@@ -112,11 +115,47 @@ 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'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 (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";
}
}
if (isPnpmOwnedCodexInstall(path.join(filesystemRoot, "node_modules"))) {
return "pnpm";
}
}
const userAgent = process.env.npm_config_user_agent || "";
if (/\bbun\//.test(userAgent)) {
return "bun";
@@ -137,15 +176,21 @@ function detectPackageManager() {
return userAgent ? "npm" : null;
}
const packageManager = detectPackageManager();
const packageManagerEnvVar =
detectPackageManager() === "bun"
packageManager === "bun"
? "CODEX_MANAGED_BY_BUN"
: "CODEX_MANAGED_BY_NPM";
: packageManager === "pnpm"
? "CODEX_MANAGED_BY_PNPM"
: "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",
+8
View File
@@ -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())
}
+1 -1
View File
@@ -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
+4 -1
View File
@@ -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",
],
+1
View File
@@ -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",
}
@@ -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
+9
View File
@@ -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<String, String> {
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,
+44 -41
View File
@@ -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.
@@ -69,15 +71,13 @@ impl InstallContext {
pub fn from_exe(
is_macos: bool,
current_exe: Option<&Path>,
managed_by_npm: bool,
managed_by_bun: bool,
method_override: Option<InstallMethod>,
) -> 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,
method_override,
codex_home.as_deref(),
)
}
@@ -85,15 +85,12 @@ impl InstallContext {
fn from_exe_with_codex_home(
is_macos: bool,
current_exe: Option<&Path>,
managed_by_npm: bool,
managed_by_bun: bool,
method_override: Option<InstallMethod>,
codex_home: Option<&Path>,
) -> Self {
let package_layout = current_exe.and_then(CodexPackageLayout::from_exe);
let method = if managed_by_npm {
InstallMethod::Npm
} else if managed_by_bun {
InstallMethod::Bun
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 {
@@ -109,13 +106,19 @@ 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 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(InstallMethod::Npm)
} else if std::env::var_os("CODEX_MANAGED_BY_BUN").is_some() {
Some(InstallMethod::Bun)
} else {
None
};
Self::from_exe(
cfg!(target_os = "macos"),
current_exe.as_deref(),
managed_by_npm,
managed_by_bun,
method_override,
)
})
}
@@ -308,8 +311,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!(
@@ -343,8 +345,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());
@@ -386,8 +387,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!(
@@ -450,8 +450,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!(
@@ -496,12 +495,10 @@ 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,
/*method_override*/ Some(InstallMethod::Npm),
);
assert_eq!(context.method, InstallMethod::Npm);
assert!(context.package_layout.is_some());
@@ -526,8 +523,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());
@@ -550,8 +546,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());
@@ -560,13 +555,24 @@ mod tests {
}
#[test]
fn npm_and_bun_take_precedence() {
let npm_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")),
/*managed_by_npm*/ true,
/*managed_by_bun*/ false,
/*codex_home*/ None,
/*method_override*/ Some(InstallMethod::Pnpm),
);
assert_eq!(
pnpm_context,
InstallContext {
method: InstallMethod::Pnpm,
package_layout: None,
}
);
let npm_context = InstallContext::from_exe(
/*is_macos*/ false,
/*current_exe*/ Some(Path::new("/tmp/codex")),
/*method_override*/ Some(InstallMethod::Npm),
);
assert_eq!(
npm_context,
@@ -576,12 +582,10 @@ 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,
/*method_override*/ Some(InstallMethod::Bun),
);
assert_eq!(
bun_context,
@@ -597,8 +601,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!(
@@ -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 │
╰─────────────────────────────────────────────────╯
+9
View File
@@ -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);
+11
View File
@@ -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,
+3 -1
View File
@@ -79,7 +79,9 @@ async fn check_for_update(version_file: &Path, action: Option<UpdateAction>) ->
.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)