From 1e5abe5afdc8e50f4f0c1980d18297f4a5a5caf3 Mon Sep 17 00:00:00 2001 From: Edward Frazer Date: Sun, 8 Mar 2026 16:59:22 +0000 Subject: [PATCH 01/23] feat: significantly improve native installer Co-authored-by: Codex --- codex-rs/Cargo.lock | 11 + codex-rs/Cargo.toml | 2 + codex-rs/arg0/src/lib.rs | 12 +- codex-rs/cli/src/main.rs | 2 +- codex-rs/install-context/Cargo.toml | 20 ++ codex-rs/install-context/src/lib.rs | 197 +++++++++++++++++ codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/update_action.rs | 106 ++++++--- docs/install.md | 16 +- scripts/install/install.ps1 | 224 +++++++++++++++---- scripts/install/install.sh | 322 ++++++++++++++++++++++------ 11 files changed, 780 insertions(+), 133 deletions(-) create mode 100644 codex-rs/install-context/Cargo.toml create mode 100644 codex-rs/install-context/src/lib.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a05a5af3676d..faed568bcd09 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2198,6 +2198,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "codex-install-context" +version = "0.0.0" +dependencies = [ + "pretty_assertions", + "serde", + "tempfile", + "toml 0.9.11+spec-1.1.0", +] + [[package]] name = "codex-instructions" version = "0.0.0" @@ -2799,6 +2809,7 @@ dependencies = [ "codex-feedback", "codex-file-search", "codex-git-utils", + "codex-install-context", "codex-login", "codex-mcp", "codex-model-provider-info", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 16329276cd63..b072d4af0579 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -13,6 +13,7 @@ members = [ "arg0", "feedback", "features", + "install-context", "codex-backend-openapi-models", "code-mode", "cloud-requirements", @@ -131,6 +132,7 @@ codex-execpolicy = { path = "execpolicy" } codex-experimental-api-macros = { path = "codex-experimental-api-macros" } codex-features = { path = "features" } codex-feedback = { path = "feedback" } +codex-install-context = { path = "install-context" } codex-file-search = { path = "file-search" } codex-git-utils = { path = "git-utils" } codex-hooks = { path = "hooks" } diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index efad6c2481e9..80b176e3c0b8 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -17,6 +17,12 @@ const EXECVE_WRAPPER_ARG0: &str = "codex-execve-wrapper"; const LOCK_FILENAME: &str = ".lock"; const TOKIO_WORKER_STACK_SIZE_BYTES: usize = 16 * 1024 * 1024; +#[cfg(unix)] +const PATH_SEPARATOR: &str = ":"; + +#[cfg(windows)] +const PATH_SEPARATOR: &str = ";"; + #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct Arg0DispatchPaths { /// Stable path to the current Codex executable for child re-execs. @@ -320,12 +326,6 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result { let mut path_env_var = diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 24dd558ea700..61781f975163 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -498,7 +498,7 @@ fn run_update_action(action: UpdateAction) -> anyhow::Result<()> { #[cfg(not(windows))] { let (cmd, args) = action.command_args(); - let command_path = crate::wsl_paths::normalize_for_wsl(cmd); + let command_path = crate::wsl_paths::normalize_for_wsl(&cmd); let normalized_args: Vec = args .iter() .map(crate::wsl_paths::normalize_for_wsl) diff --git a/codex-rs/install-context/Cargo.toml b/codex-rs/install-context/Cargo.toml new file mode 100644 index 000000000000..6df75c26107a --- /dev/null +++ b/codex-rs/install-context/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "codex-install-context" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_install_context" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true, features = ["derive"] } +toml = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/install-context/src/lib.rs b/codex-rs/install-context/src/lib.rs new file mode 100644 index 000000000000..9e8e115e87e3 --- /dev/null +++ b/codex-rs/install-context/src/lib.rs @@ -0,0 +1,197 @@ +use std::path::Path; +use std::path::PathBuf; +use std::sync::OnceLock; + +use serde::Deserialize; + +const METADATA_FILENAME: &str = "metadata.toml"; +static INSTALL_CONTEXT: OnceLock = OnceLock::new(); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InstallManager { + Native, + Npm, + Bun, + Brew, + Unknown, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InstallContext { + pub manager: InstallManager, + pub current_exe: Option, + pub release_dir: Option, + pub version: Option, + pub target: Option, + pub rg_command: String, +} + +impl InstallContext { + pub fn from_exe( + is_macos: bool, + current_exe: Option<&Path>, + managed_by_npm: bool, + managed_by_bun: bool, + ) -> Self { + if managed_by_npm { + return Self::unknown_with_manager(InstallManager::Npm, current_exe); + } + + if managed_by_bun { + return Self::unknown_with_manager(InstallManager::Bun, current_exe); + } + + if let Some(exe_path) = current_exe + && let Some(native_context) = native_install_context(exe_path) + { + return native_context; + } + + if is_macos + && let Some(exe_path) = current_exe + && (exe_path.starts_with("/opt/homebrew") || exe_path.starts_with("/usr/local")) + { + return Self::unknown_with_manager(InstallManager::Brew, Some(exe_path)); + } + + Self::unknown_with_manager(InstallManager::Unknown, current_exe) + } + + 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(); + Self::from_exe( + cfg!(target_os = "macos"), + current_exe.as_deref(), + managed_by_npm, + managed_by_bun, + ) + }) + } + + fn unknown_with_manager(manager: InstallManager, current_exe: Option<&Path>) -> Self { + Self { + manager, + current_exe: current_exe.map(Path::to_path_buf), + release_dir: None, + version: None, + target: None, + rg_command: default_rg_command(), + } + } +} + +#[derive(Debug, Deserialize, Eq, PartialEq)] +struct NativeInstallMetadata { + install_method: String, + version: String, + target: String, +} + +fn native_install_context(exe_path: &Path) -> Option { + let canonical_exe = std::fs::canonicalize(exe_path).ok()?; + let release_dir = canonical_exe.parent()?.to_path_buf(); + let metadata = parse_native_install_metadata(&release_dir.join(METADATA_FILENAME))?; + + let rg_name = if cfg!(windows) { "rg.exe" } else { "rg" }; + let rg_command = release_dir.join(rg_name).display().to_string(); + + Some(InstallContext { + manager: InstallManager::Native, + current_exe: Some(canonical_exe), + release_dir: Some(release_dir), + version: Some(metadata.version), + target: Some(metadata.target), + rg_command, + }) +} + +fn parse_native_install_metadata(path: &Path) -> Option { + let contents = std::fs::read_to_string(path).ok()?; + let metadata: NativeInstallMetadata = toml::from_str(&contents).ok()?; + if metadata.install_method != "native" { + return None; + } + Some(metadata) +} + +fn default_rg_command() -> String { + if cfg!(windows) { + "rg.exe".to_string() + } else { + "rg".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::fs; + + #[test] + fn detects_native_install_from_adjacent_metadata() -> std::io::Result<()> { + let root = tempfile::tempdir()?; + let release_dir = root.path().join("1.2.3-x86_64-unknown-linux-musl"); + fs::create_dir(&release_dir)?; + fs::write( + release_dir.join("metadata.toml"), + "install_method = \"native\"\nversion = \"1.2.3\"\ntarget = \"x86_64-unknown-linux-musl\"\n", + )?; + let exe_name = if cfg!(windows) { "codex.exe" } else { "codex" }; + let rg_name = if cfg!(windows) { "rg.exe" } else { "rg" }; + let exe_path = release_dir.join(exe_name); + fs::write(&exe_path, "")?; + fs::write(release_dir.join(rg_name), "")?; + + let context = InstallContext::from_exe(false, Some(&exe_path), false, false); + assert_eq!(context.manager, InstallManager::Native); + assert_eq!(context.release_dir, Some(release_dir.canonicalize()?)); + assert_eq!(context.version.as_deref(), Some("1.2.3")); + assert_eq!(context.target.as_deref(), Some("x86_64-unknown-linux-musl")); + assert!(context.rg_command.ends_with(rg_name)); + Ok(()) + } + + #[test] + fn native_metadata_rejects_non_native_install_method() -> std::io::Result<()> { + let root = tempfile::tempdir()?; + let release_dir = root.path().join("bad-release"); + fs::create_dir(&release_dir)?; + fs::write( + release_dir.join("metadata.toml"), + "install_method = \"npm\"\nversion = \"1.2.3\"\ntarget = \"x86_64-unknown-linux-musl\"\n", + )?; + let exe_path = release_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + fs::write(&exe_path, "")?; + + let context = InstallContext::from_exe(false, Some(&exe_path), false, false); + assert_eq!(context.manager, InstallManager::Unknown); + assert_eq!(context.version, None); + Ok(()) + } + + #[test] + fn npm_and_bun_take_precedence() { + let npm_context = + InstallContext::from_exe(false, Some(Path::new("/tmp/codex")), true, false); + assert_eq!(npm_context.manager, InstallManager::Npm); + + let bun_context = + InstallContext::from_exe(false, Some(Path::new("/tmp/codex")), false, true); + assert_eq!(bun_context.manager, InstallManager::Bun); + } + + #[test] + fn brew_is_detected_on_macos_prefixes() { + let context = InstallContext::from_exe( + true, + Some(Path::new("/opt/homebrew/bin/codex")), + false, + false, + ); + assert_eq!(context.manager, InstallManager::Brew); + } +} diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 713a5b38ae78..cc46b30725b6 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -29,6 +29,7 @@ codex-ansi-escape = { workspace = true } codex-app-server-client = { workspace = true } codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } +codex-install-context = { workspace = true } codex-chatgpt = { workspace = true } codex-cloud-requirements = { workspace = true } codex-config = { workspace = true } diff --git a/codex-rs/tui/src/update_action.rs b/codex-rs/tui/src/update_action.rs index baee1662fdf8..dba798ac3f57 100644 --- a/codex-rs/tui/src/update_action.rs +++ b/codex-rs/tui/src/update_action.rs @@ -1,3 +1,8 @@ +#[cfg(any(not(debug_assertions), test))] +use codex_install_context::InstallContext; +#[cfg(any(not(debug_assertions), test))] +use codex_install_context::InstallManager; + /// Update action the CLI should perform after the TUI exits. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum UpdateAction { @@ -5,40 +10,67 @@ pub enum UpdateAction { NpmGlobalLatest, /// Update via `bun install -g @openai/codex@latest`. BunGlobalLatest, + /// Update via the native installer script. + NativeUpgrade, /// Update via `brew upgrade codex`. BrewUpgrade, } impl UpdateAction { /// Returns the list of command-line arguments for invoking the update. - pub fn command_args(self) -> (&'static str, &'static [&'static str]) { + pub fn command_args(self) -> (String, Vec) { match self { - UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex"]), - UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex"]), - UpdateAction::BrewUpgrade => ("brew", &["upgrade", "--cask", "codex"]), + UpdateAction::NpmGlobalLatest => ( + "npm".to_string(), + vec!["install".into(), "-g".into(), "@openai/codex".into()], + ), + UpdateAction::BunGlobalLatest => ( + "bun".to_string(), + vec!["install".into(), "-g".into(), "@openai/codex".into()], + ), + UpdateAction::NativeUpgrade => { + #[cfg(windows)] + { + ( + "powershell".to_string(), + vec![ + "-NoProfile".into(), + "-ExecutionPolicy".into(), + "Bypass".into(), + "-Command".into(), + "$tmp = New-TemporaryFile; Invoke-WebRequest -Uri 'https://chatgpt.com/codex/install.ps1' -OutFile $tmp; & $tmp; Remove-Item $tmp".into(), + ], + ) + } + #[cfg(not(windows))] + { + ( + "sh".to_string(), + vec![ + "-c".into(), + "tmp=\"$(mktemp)\" && if command -v curl >/dev/null 2>&1; then curl -fsSL 'https://chatgpt.com/codex/install.sh' -o \"$tmp\"; elif command -v wget >/dev/null 2>&1; then wget -q -O \"$tmp\" 'https://chatgpt.com/codex/install.sh'; else echo 'curl or wget is required to update Codex.' >&2; rm -f \"$tmp\"; exit 1; fi && sh \"$tmp\"; status=$?; rm -f \"$tmp\"; exit $status".into(), + ], + ) + } + } + UpdateAction::BrewUpgrade => ( + "brew".to_string(), + vec!["upgrade".into(), "--cask".into(), "codex".into()], + ), } } /// Returns string representation of the command-line arguments for invoking the update. pub fn command_str(self) -> String { let (command, args) = self.command_args(); - shlex::try_join(std::iter::once(command).chain(args.iter().copied())) + shlex::try_join(std::iter::once(command.as_str()).chain(args.iter().map(String::as_str))) .unwrap_or_else(|_| format!("{command} {}", args.join(" "))) } } #[cfg(not(debug_assertions))] pub(crate) fn get_update_action() -> Option { - let exe = std::env::current_exe().unwrap_or_default(); - 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(); - - detect_update_action( - cfg!(target_os = "macos"), - &exe, - managed_by_npm, - managed_by_bun, - ) + update_action_for_context(InstallContext::current()) } #[cfg(any(not(debug_assertions), test))] @@ -48,22 +80,27 @@ fn detect_update_action( managed_by_npm: bool, managed_by_bun: bool, ) -> Option { - if managed_by_npm { - Some(UpdateAction::NpmGlobalLatest) - } else if managed_by_bun { - Some(UpdateAction::BunGlobalLatest) - } else if is_macos - && (current_exe.starts_with("/opt/homebrew") || current_exe.starts_with("/usr/local")) - { - Some(UpdateAction::BrewUpgrade) - } else { - None + let context = + InstallContext::from_exe(is_macos, Some(current_exe), managed_by_npm, managed_by_bun); + update_action_for_context(&context) +} + +#[cfg(any(not(debug_assertions), test))] +fn update_action_for_context(context: &InstallContext) -> Option { + match context.manager { + InstallManager::Npm => Some(UpdateAction::NpmGlobalLatest), + InstallManager::Bun => Some(UpdateAction::BunGlobalLatest), + InstallManager::Native => Some(UpdateAction::NativeUpgrade), + InstallManager::Brew => Some(UpdateAction::BrewUpgrade), + InstallManager::Unknown => None, } } #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; + use std::fs; #[test] fn detects_update_action_without_env_mutation() { @@ -113,4 +150,23 @@ mod tests { Some(UpdateAction::BrewUpgrade) ); } + + #[test] + fn detects_native_update_action_from_metadata() -> std::io::Result<()> { + let root = tempfile::tempdir()?; + let release_dir = root.path().join("1.2.3-x86_64-unknown-linux-musl"); + fs::create_dir(&release_dir)?; + fs::write( + release_dir.join("metadata.toml"), + "install_method = \"native\"\nversion = \"1.2.3\"\ntarget = \"x86_64-unknown-linux-musl\"\n", + )?; + let exe_path = release_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + fs::write(&exe_path, "")?; + + assert_eq!( + detect_update_action(false, &exe_path, false, false), + Some(UpdateAction::NativeUpgrade) + ); + Ok(()) + } } diff --git a/docs/install.md b/docs/install.md index b7d4f0711a60..bdd9fac22fbf 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,10 +1,24 @@ ## Installing & building +### Native installer + +Use the native installer when you want a standalone Codex binary plus bundled native helpers without depending on Node: + +```bash +curl -fsSL https://chatgpt.com/codex/install.sh | sh +``` + +On Windows PowerShell: + +```powershell +irm https://chatgpt.com/codex/install.ps1 | iex +``` + ### System requirements | Requirement | Details | | --------------------------- | --------------------------------------------------------------- | -| Operating systems | macOS 12+, Ubuntu 20.04+/Debian 10+, or Windows 11 **via WSL2** | +| Operating systems | macOS 12+, Ubuntu 20.04+/Debian 10+, or Windows 11 | | Git (optional, recommended) | 2.23+ for built-in PR helpers | | RAM | 4-GB minimum (8-GB recommended) | diff --git a/scripts/install/install.ps1 b/scripts/install/install.ps1 index 40328db7006e..b6e2d9e220af 100644 --- a/scripts/install/install.ps1 +++ b/scripts/install/install.ps1 @@ -1,6 +1,5 @@ param( - [Parameter(Position=0)] - [string]$Version = "latest" + [string]$Release = "latest" ) Set-StrictMode -Version Latest @@ -15,6 +14,14 @@ function Write-Step { Write-Host "==> $Message" } +function Write-WarningStep { + param( + [string]$Message + ) + + Write-Warning $Message +} + function Normalize-Version { param( [string]$RawVersion @@ -65,7 +72,7 @@ function Path-Contains { } function Resolve-Version { - $normalizedVersion = Normalize-Version -RawVersion $Version + $normalizedVersion = Normalize-Version -RawVersion $Release if ($normalizedVersion -ne "latest") { return $normalizedVersion } @@ -79,6 +86,111 @@ function Resolve-Version { return (Normalize-Version -RawVersion $release.tag_name) } +function Read-MetadataValue { + param( + [string]$MetadataPath, + [string]$Key + ) + + if (-not (Test-Path $MetadataPath)) { + return $null + } + + foreach ($line in Get-Content $MetadataPath) { + if ($line -match "^\s*$Key\s*=\s*""([^""]+)""") { + return $matches[1] + } + } + + return $null +} + +function Ensure-Junction { + param( + [string]$LinkPath, + [string]$TargetPath + ) + + if (Test-Path $LinkPath) { + $item = Get-Item -LiteralPath $LinkPath -Force + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + Remove-Item -LiteralPath $LinkPath -Force + } else { + Remove-Item -LiteralPath $LinkPath -Recurse -Force + } + } + + New-Item -ItemType Junction -Path $LinkPath -Target $TargetPath | Out-Null +} + +function Get-ExistingCodexCommand { + $existing = Get-Command codex -ErrorAction SilentlyContinue + if ($null -eq $existing) { + return $null + } + + return $existing.Source +} + +function Get-ExistingCodexManager { + param( + [string]$ExistingPath, + [string]$VisibleBinDir + ) + + if ([string]::IsNullOrWhiteSpace($ExistingPath)) { + return $null + } + + if ($ExistingPath.StartsWith($VisibleBinDir, [System.StringComparison]::OrdinalIgnoreCase)) { + return $null + } + + if ($ExistingPath -match "\\.bun\\") { + return "bun" + } + + if ($ExistingPath -match "node_modules" -or $ExistingPath -match "\\npm\\") { + return "npm" + } + + return $null +} + +function Maybe-HandleConflictingInstall { + param( + [string]$VisibleBinDir + ) + + $existingPath = Get-ExistingCodexCommand + $manager = Get-ExistingCodexManager -ExistingPath $existingPath -VisibleBinDir $VisibleBinDir + if ($null -eq $manager) { + return + } + + Write-Step "Detected existing $manager-managed Codex at $existingPath" + Write-WarningStep "Multiple managed Codex installs can be ambiguous because PATH order decides which one runs." + + $uninstallArgs = if ($manager -eq "bun") { + @("remove", "-g", "@openai/codex") + } else { + @("uninstall", "-g", "@openai/codex") + } + $uninstallCommand = if ($manager -eq "bun") { "bun" } else { "npm" } + + $choice = Read-Host "Uninstall the existing $manager-managed Codex now? [y/N]" + if ($choice -match "^(?i:y(?:es)?)$") { + Write-Step "Running: $uninstallCommand $($uninstallArgs -join ' ')" + try { + & $uninstallCommand @uninstallArgs + } catch { + Write-WarningStep "Failed to uninstall the existing $manager-managed Codex. Continuing with the native install." + } + } else { + Write-WarningStep "Leaving the existing $manager-managed Codex installed. PATH order will determine which codex runs." + } +} + if ($env:OS -ne "Windows_NT") { Write-Error "install.ps1 supports Windows only. Use install.sh on macOS or Linux." exit 1 @@ -110,84 +222,116 @@ switch ($architecture) { } } +$codexHome = if ([string]::IsNullOrWhiteSpace($env:CODEX_HOME)) { + Join-Path $env:USERPROFILE ".codex" +} else { + $env:CODEX_HOME +} +$nativeRoot = Join-Path $codexHome "packages\native" +$releasesDir = Join-Path $nativeRoot "releases" +$currentDir = Join-Path $nativeRoot "current" + if ([string]::IsNullOrWhiteSpace($env:CODEX_INSTALL_DIR)) { - $installDir = Join-Path $env:LOCALAPPDATA "Programs\OpenAI\Codex\bin" + $visibleBinDir = Join-Path $env:LOCALAPPDATA "Programs\OpenAI\Codex\bin" } else { - $installDir = $env:CODEX_INSTALL_DIR + $visibleBinDir = $env:CODEX_INSTALL_DIR } -$codexPath = Join-Path $installDir "codex.exe" -$installMode = if (Test-Path $codexPath) { "Updating" } else { "Installing" } +$currentVersion = Read-MetadataValue -MetadataPath (Join-Path $currentDir "metadata.toml") -Key "version" +$resolvedVersion = Resolve-Version +$releaseName = "$resolvedVersion-$target" +$releaseDir = Join-Path $releasesDir $releaseName -Write-Step "$installMode Codex CLI" +if (-not [string]::IsNullOrWhiteSpace($currentVersion) -and $currentVersion -ne $resolvedVersion) { + Write-Step "Updating Codex CLI from $currentVersion to $resolvedVersion" +} elseif (-not [string]::IsNullOrWhiteSpace($currentVersion)) { + Write-Step "Updating Codex CLI" +} else { + Write-Step "Installing Codex CLI" +} Write-Step "Detected platform: $platformLabel" +Write-Step "Resolved version: $resolvedVersion" -New-Item -ItemType Directory -Force -Path $installDir | Out-Null +Maybe-HandleConflictingInstall -VisibleBinDir $visibleBinDir -$resolvedVersion = Resolve-Version -Write-Step "Resolved version: $resolvedVersion" $packageAsset = "codex-npm-$npmTag-$resolvedVersion.tgz" - $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("codex-install-" + [System.Guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Force -Path $tempDir | Out-Null try { - $archivePath = Join-Path $tempDir $packageAsset - $extractDir = Join-Path $tempDir "extract" - $url = Get-ReleaseUrl -AssetName $packageAsset -ResolvedVersion $resolvedVersion + if (-not (Test-Path $releaseDir)) { + $archivePath = Join-Path $tempDir $packageAsset + $extractDir = Join-Path $tempDir "extract" + $stagingDir = Join-Path $tempDir "release" + $url = Get-ReleaseUrl -AssetName $packageAsset -ResolvedVersion $resolvedVersion + + Write-Step "Downloading Codex CLI" + Invoke-WebRequest -Uri $url -OutFile $archivePath + + New-Item -ItemType Directory -Force -Path $extractDir | Out-Null + New-Item -ItemType Directory -Force -Path $stagingDir | Out-Null + tar -xzf $archivePath -C $extractDir + + $vendorRoot = Join-Path $extractDir "package/vendor/$target" + $copyMap = @{ + "codex/codex.exe" = "codex.exe" + "codex/codex-command-runner.exe" = "codex-command-runner.exe" + "codex/codex-windows-sandbox-setup.exe" = "codex-windows-sandbox-setup.exe" + "path/rg.exe" = "rg.exe" + } - Write-Step "Downloading Codex CLI" - Invoke-WebRequest -Uri $url -OutFile $archivePath + foreach ($relativeSource in $copyMap.Keys) { + Copy-Item -LiteralPath (Join-Path $vendorRoot $relativeSource) -Destination (Join-Path $stagingDir $copyMap[$relativeSource]) + } - New-Item -ItemType Directory -Force -Path $extractDir | Out-Null - tar -xzf $archivePath -C $extractDir + @" +install_method = "native" +version = "$resolvedVersion" +target = "$target" +"@ | Set-Content -LiteralPath (Join-Path $stagingDir "metadata.toml") -NoNewline - $vendorRoot = Join-Path $extractDir "package/vendor/$target" - Write-Step "Installing to $installDir" - $copyMap = @{ - "codex/codex.exe" = "codex.exe" - "codex/codex-command-runner.exe" = "codex-command-runner.exe" - "codex/codex-windows-sandbox-setup.exe" = "codex-windows-sandbox-setup.exe" - "path/rg.exe" = "rg.exe" + New-Item -ItemType Directory -Force -Path $releasesDir | Out-Null + Move-Item -LiteralPath $stagingDir -Destination $releaseDir } - foreach ($relativeSource in $copyMap.Keys) { - $sourcePath = Join-Path $vendorRoot $relativeSource - $destinationPath = Join-Path $installDir $copyMap[$relativeSource] - Move-Item -Force $sourcePath $destinationPath - } + New-Item -ItemType Directory -Force -Path $nativeRoot | Out-Null + Ensure-Junction -LinkPath $currentDir -TargetPath $releaseDir + + $visibleParent = Split-Path -Parent $visibleBinDir + New-Item -ItemType Directory -Force -Path $visibleParent | Out-Null + Ensure-Junction -LinkPath $visibleBinDir -TargetPath $currentDir } finally { Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue } $userPath = [Environment]::GetEnvironmentVariable("Path", "User") $pathNeedsNewShell = $false -if (-not (Path-Contains -PathValue $userPath -Entry $installDir)) { +if (-not (Path-Contains -PathValue $userPath -Entry $visibleBinDir)) { if ([string]::IsNullOrWhiteSpace($userPath)) { - $newUserPath = $installDir + $newUserPath = $visibleBinDir } else { - $newUserPath = "$installDir;$userPath" + $newUserPath = "$visibleBinDir;$userPath" } [Environment]::SetEnvironmentVariable("Path", $newUserPath, "User") - if (-not (Path-Contains -PathValue $env:Path -Entry $installDir)) { + if (-not (Path-Contains -PathValue $env:Path -Entry $visibleBinDir)) { if ([string]::IsNullOrWhiteSpace($env:Path)) { - $env:Path = $installDir + $env:Path = $visibleBinDir } else { - $env:Path = "$installDir;$env:Path" + $env:Path = "$visibleBinDir;$env:Path" } } Write-Step "PATH updated for future PowerShell sessions." $pathNeedsNewShell = $true -} elseif (Path-Contains -PathValue $env:Path -Entry $installDir) { - Write-Step "$installDir is already on PATH." +} elseif (Path-Contains -PathValue $env:Path -Entry $visibleBinDir) { + Write-Step "$visibleBinDir is already on PATH." } else { Write-Step "PATH is already configured for future PowerShell sessions." $pathNeedsNewShell = $true } if ($pathNeedsNewShell) { - Write-Step ('Run now: $env:Path = "{0};$env:Path"; codex' -f $installDir) + Write-Step ('Run now: $env:Path = "{0};$env:Path"; codex' -f $visibleBinDir) Write-Step "Or open a new PowerShell window and run: codex" } else { Write-Step "Run: codex" diff --git a/scripts/install/install.sh b/scripts/install/install.sh index 86e2940d5530..06e876bee8c4 100755 --- a/scripts/install/install.sh +++ b/scripts/install/install.sh @@ -2,8 +2,15 @@ set -eu -VERSION="${1:-latest}" -INSTALL_DIR="${CODEX_INSTALL_DIR:-$HOME/.local/bin}" +RELEASE="latest" + +BIN_DIR="${CODEX_INSTALL_DIR:-$HOME/.local/bin}" +BIN_PATH="$BIN_DIR/codex" +CODEX_HOME_DIR="${CODEX_HOME:-$HOME/.codex}" +NATIVE_ROOT="$CODEX_HOME_DIR/packages/native" +RELEASES_DIR="$NATIVE_ROOT/releases" +CURRENT_LINK="$NATIVE_ROOT/current" + path_action="already" path_profile="" @@ -11,6 +18,10 @@ step() { printf '==> %s\n' "$1" } +warn() { + printf 'WARNING: %s\n' "$1" >&2 +} + normalize_version() { case "$1" in "" | latest) @@ -28,6 +39,32 @@ normalize_version() { esac } +parse_args() { + while [ "$#" -gt 0 ]; do + case "$1" in + --release) + if [ "$#" -lt 2 ]; then + echo "--release requires a value." >&2 + exit 1 + fi + RELEASE="$2" + shift + ;; + --help | -h) + cat <&2 + exit 1 + ;; + esac + shift + done +} + download_file() { url="$1" output="$2" @@ -63,76 +100,235 @@ download_text() { exit 1 } +release_url_for_asset() { + asset="$1" + resolved_version="$2" + + printf 'https://github.com/openai/codex/releases/download/rust-v%s/%s\n' "$resolved_version" "$asset" +} + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "$1 is required to install Codex." >&2 + exit 1 + fi +} + +resolve_version() { + normalized_version="$(normalize_version "$RELEASE")" + + if [ "$normalized_version" != "latest" ]; then + printf '%s\n' "$normalized_version" + return + fi + + release_json="$(download_text "https://api.github.com/repos/openai/codex/releases/latest")" + resolved="$(printf '%s\n' "$release_json" | sed -n 's/.*"tag_name":[[:space:]]*"rust-v\([^"]*\)".*/\1/p' | head -n 1)" + + if [ -z "$resolved" ]; then + echo "Failed to resolve the latest Codex release version." >&2 + exit 1 + fi + + printf '%s\n' "$resolved" +} + +pick_profile() { + # Use the same shell-specific split Homebrew documents because there is no + # universal startup file across macOS/Linux login and interactive shells. + case "$os:${SHELL:-}" in + darwin:*/zsh) + printf '%s\n' "$HOME/.zprofile" + ;; + darwin:*/bash) + printf '%s\n' "$HOME/.bash_profile" + ;; + linux:*/zsh) + printf '%s\n' "$HOME/.zshrc" + ;; + linux:*/bash) + printf '%s\n' "$HOME/.bashrc" + ;; + *) + printf '%s\n' "$HOME/.profile" + ;; + esac +} + add_to_path() { path_action="already" path_profile="" case ":$PATH:" in - *":$INSTALL_DIR:"*) + *":$BIN_DIR:"*) return ;; esac - profile="$HOME/.profile" - case "${SHELL:-}" in - */zsh) - profile="$HOME/.zshrc" - ;; - */bash) - profile="$HOME/.bashrc" - ;; - esac - + profile="$(pick_profile)" path_profile="$profile" - path_line="export PATH=\"$INSTALL_DIR:\$PATH\"" - if [ -f "$profile" ] && grep -F "$path_line" "$profile" >/dev/null 2>&1; then + begin_marker="# >>> Codex installer >>>" + end_marker="# <<< Codex installer <<<" + path_line="export PATH=\"$BIN_DIR:\$PATH\"" + + if [ -f "$profile" ] && grep -F "$begin_marker" "$profile" >/dev/null 2>&1; then path_action="configured" return fi { - printf '\n# Added by Codex installer\n' + printf '\n%s\n' "$begin_marker" printf '%s\n' "$path_line" + printf '%s\n' "$end_marker" } >>"$profile" path_action="added" } -release_url_for_asset() { - asset="$1" - resolved_version="$2" +read_metadata_value() { + metadata_path="$1" + key="$2" - printf 'https://github.com/openai/codex/releases/download/rust-v%s/%s\n' "$resolved_version" "$asset" + if [ ! -f "$metadata_path" ]; then + return 1 + fi + + sed -n "s/^${key}[[:space:]]*=[[:space:]]*\"\([^\"]*\)\"/\1/p" "$metadata_path" | head -n 1 } -require_command() { - if ! command -v "$1" >/dev/null 2>&1; then - echo "$1 is required to install Codex." >&2 - exit 1 +resolve_existing_codex() { + command -v codex 2>/dev/null || true +} + +classify_existing_codex() { + existing_path="$1" + + if [ -z "$existing_path" ] || [ "$existing_path" = "$BIN_PATH" ]; then + return 1 + fi + + case "$existing_path" in + /opt/homebrew/* | /usr/local/*) + if [ "$os" = "darwin" ]; then + printf 'brew\n' + return 0 + fi + ;; + esac + + if [ -f "$existing_path" ] && grep -F "#!/usr/bin/env node" "$existing_path" >/dev/null 2>&1; then + case "$existing_path" in + *".bun"*) + printf 'bun\n' + ;; + *) + printf 'npm\n' + ;; + esac + return 0 fi + + return 1 } -require_command mktemp -require_command tar +prompt_yes_no() { + prompt="$1" -resolve_version() { - normalized_version="$(normalize_version "$VERSION")" + if [ ! -t 0 ]; then + return 1 + fi - if [ "$normalized_version" != "latest" ]; then - printf '%s\n' "$normalized_version" + printf '%s [y/N] ' "$prompt" + read -r answer + case "$answer" in + y | Y | yes | YES) + return 0 + ;; + *) + return 1 + ;; + esac +} + +handle_conflicting_install() { + existing_path="$(resolve_existing_codex)" + manager="$(classify_existing_codex "$existing_path" || true)" + + if [ -z "$manager" ]; then return fi - release_json="$(download_text "https://api.github.com/repos/openai/codex/releases/latest")" - resolved="$(printf '%s\n' "$release_json" | sed -n 's/.*"tag_name":[[:space:]]*"rust-v\([^"]*\)".*/\1/p' | head -n 1)" + step "Detected existing $manager-managed Codex at $existing_path" + warn "Multiple managed Codex installs can be ambiguous because PATH order decides which one runs." - if [ -z "$resolved" ]; then - echo "Failed to resolve the latest Codex release version." >&2 - exit 1 + case "$manager" in + brew) + uninstall_cmd="brew uninstall --cask codex" + ;; + bun) + uninstall_cmd="bun remove -g @openai/codex" + ;; + *) + uninstall_cmd="npm uninstall -g @openai/codex" + ;; + esac + + if prompt_yes_no "Uninstall the existing $manager-managed Codex now?"; then + step "Running: $uninstall_cmd" + if ! sh -c "$uninstall_cmd"; then + warn "Failed to uninstall the existing $manager-managed Codex. Continuing with the native install." + fi + else + warn "Leaving the existing $manager-managed Codex installed. PATH order will determine which codex runs." fi +} - printf '%s\n' "$resolved" +install_release() { + release_dir="$1" + vendor_root="$2" + + if [ -d "$release_dir" ]; then + return + fi + + stage_release="$tmp_dir/release" + mkdir -p "$stage_release" + cp "$vendor_root/codex/codex" "$stage_release/codex" + cp "$vendor_root/path/rg" "$stage_release/rg" + chmod 0755 "$stage_release/codex" + chmod 0755 "$stage_release/rg" + cat >"$stage_release/metadata.toml" < Date: Sun, 8 Mar 2026 17:30:00 +0000 Subject: [PATCH 02/23] fix: tighten native install context model Co-authored-by: Codex --- codex-rs/arg0/src/lib.rs | 12 +-- codex-rs/cli/src/main.rs | 6 +- codex-rs/install-context/src/lib.rs | 112 ++++++++++++++-------- codex-rs/tui/src/history_cell.rs | 3 +- codex-rs/tui/src/update_action.rs | 143 ++++++++++++---------------- codex-rs/tui/src/update_prompt.rs | 5 +- codex-rs/tui/src/updates.rs | 2 +- docs/install.md | 16 +--- 8 files changed, 148 insertions(+), 151 deletions(-) diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index 80b176e3c0b8..efad6c2481e9 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -17,12 +17,6 @@ const EXECVE_WRAPPER_ARG0: &str = "codex-execve-wrapper"; const LOCK_FILENAME: &str = ".lock"; const TOKIO_WORKER_STACK_SIZE_BYTES: usize = 16 * 1024 * 1024; -#[cfg(unix)] -const PATH_SEPARATOR: &str = ":"; - -#[cfg(windows)] -const PATH_SEPARATOR: &str = ";"; - #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct Arg0DispatchPaths { /// Stable path to the current Codex executable for child re-execs. @@ -326,6 +320,12 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result { let mut path_env_var = diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 61781f975163..57a655efeee5 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -484,7 +484,7 @@ fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> { /// Run the update action and print the result. fn run_update_action(action: UpdateAction) -> anyhow::Result<()> { println!(); - let cmd_str = action.command_str(); + let cmd_str = command_str(action); println!("Updating Codex via `{cmd_str}`..."); let status = { @@ -497,8 +497,8 @@ fn run_update_action(action: UpdateAction) -> anyhow::Result<()> { } #[cfg(not(windows))] { - let (cmd, args) = action.command_args(); - let command_path = crate::wsl_paths::normalize_for_wsl(&cmd); + let (cmd, args) = command_args(action); + let command_path = crate::wsl_paths::normalize_for_wsl(cmd); let normalized_args: Vec = args .iter() .map(crate::wsl_paths::normalize_for_wsl) diff --git a/codex-rs/install-context/src/lib.rs b/codex-rs/install-context/src/lib.rs index 9e8e115e87e3..eb71ed104947 100644 --- a/codex-rs/install-context/src/lib.rs +++ b/codex-rs/install-context/src/lib.rs @@ -13,17 +13,40 @@ pub enum InstallManager { Npm, Bun, Brew, - Unknown, + /// Any other execution environment. + /// + /// This commonly covers `cargo run`, app-bundled Codex binaries, custom + /// internal launchers, and tests that execute Codex from an arbitrary path. + Other, } #[derive(Clone, Debug, Eq, PartialEq)] -pub struct InstallContext { - pub manager: InstallManager, - pub current_exe: Option, - pub release_dir: Option, - pub version: Option, - pub target: Option, - pub rg_command: String, +pub enum InstallContext { + Native { + /// The native release directory that contains `codex`, `rg`, and + /// `metadata.toml`, for example + /// `~/.codex/packages/native/releases/0.111.0-x86_64-unknown-linux-musl`. + release_dir: PathBuf, + /// The installed native Codex version, for example `0.111.0`. + version: String, + /// The target triple recorded in native metadata, for example + /// `x86_64-unknown-linux-musl` or `aarch64-apple-darwin`. + target: String, + /// The bundled ripgrep binary for this native release, for example + /// `~/.codex/packages/native/releases/.../rg`. + rg_command: PathBuf, + }, + /// A Codex binary launched through the npm-managed `codex.js` shim. + Npm, + /// A Codex binary launched through the bun-managed `codex.js` shim. + Bun, + /// A Codex binary that appears to come from a Homebrew install prefix. + Brew, + /// Any other execution environment. + /// + /// This commonly covers `cargo run`, app-bundled Codex binaries, custom + /// internal launchers, and tests that execute Codex from an arbitrary path. + Other, } impl InstallContext { @@ -34,11 +57,11 @@ impl InstallContext { managed_by_bun: bool, ) -> Self { if managed_by_npm { - return Self::unknown_with_manager(InstallManager::Npm, current_exe); + return Self::Npm; } if managed_by_bun { - return Self::unknown_with_manager(InstallManager::Bun, current_exe); + return Self::Bun; } if let Some(exe_path) = current_exe @@ -51,10 +74,10 @@ impl InstallContext { && let Some(exe_path) = current_exe && (exe_path.starts_with("/opt/homebrew") || exe_path.starts_with("/usr/local")) { - return Self::unknown_with_manager(InstallManager::Brew, Some(exe_path)); + return Self::Brew; } - Self::unknown_with_manager(InstallManager::Unknown, current_exe) + Self::Other } pub fn current() -> &'static Self { @@ -71,14 +94,20 @@ impl InstallContext { }) } - fn unknown_with_manager(manager: InstallManager, current_exe: Option<&Path>) -> Self { - Self { - manager, - current_exe: current_exe.map(Path::to_path_buf), - release_dir: None, - version: None, - target: None, - rg_command: default_rg_command(), + pub fn manager(&self) -> InstallManager { + match self { + Self::Native { .. } => InstallManager::Native, + Self::Npm => InstallManager::Npm, + Self::Bun => InstallManager::Bun, + Self::Brew => InstallManager::Brew, + Self::Other => InstallManager::Other, + } + } + + pub fn rg_command(&self) -> PathBuf { + match self { + Self::Native { rg_command, .. } => rg_command.clone(), + Self::Npm | Self::Bun | Self::Brew | Self::Other => default_rg_command(), } } } @@ -96,14 +125,12 @@ fn native_install_context(exe_path: &Path) -> Option { let metadata = parse_native_install_metadata(&release_dir.join(METADATA_FILENAME))?; let rg_name = if cfg!(windows) { "rg.exe" } else { "rg" }; - let rg_command = release_dir.join(rg_name).display().to_string(); - - Some(InstallContext { - manager: InstallManager::Native, - current_exe: Some(canonical_exe), - release_dir: Some(release_dir), - version: Some(metadata.version), - target: Some(metadata.target), + let rg_command = release_dir.join(rg_name); + + Some(InstallContext::Native { + release_dir, + version: metadata.version, + target: metadata.target, rg_command, }) } @@ -117,11 +144,11 @@ fn parse_native_install_metadata(path: &Path) -> Option { Some(metadata) } -fn default_rg_command() -> String { +fn default_rg_command() -> PathBuf { if cfg!(windows) { - "rg.exe".to_string() + PathBuf::from("rg.exe") } else { - "rg".to_string() + PathBuf::from("rg") } } @@ -147,11 +174,15 @@ mod tests { fs::write(release_dir.join(rg_name), "")?; let context = InstallContext::from_exe(false, Some(&exe_path), false, false); - assert_eq!(context.manager, InstallManager::Native); - assert_eq!(context.release_dir, Some(release_dir.canonicalize()?)); - assert_eq!(context.version.as_deref(), Some("1.2.3")); - assert_eq!(context.target.as_deref(), Some("x86_64-unknown-linux-musl")); - assert!(context.rg_command.ends_with(rg_name)); + assert_eq!( + context, + InstallContext::Native { + release_dir: release_dir.canonicalize()?, + version: "1.2.3".to_string(), + target: "x86_64-unknown-linux-musl".to_string(), + rg_command: release_dir.join(rg_name), + } + ); Ok(()) } @@ -168,8 +199,7 @@ mod tests { fs::write(&exe_path, "")?; let context = InstallContext::from_exe(false, Some(&exe_path), false, false); - assert_eq!(context.manager, InstallManager::Unknown); - assert_eq!(context.version, None); + assert_eq!(context, InstallContext::Other); Ok(()) } @@ -177,11 +207,11 @@ mod tests { fn npm_and_bun_take_precedence() { let npm_context = InstallContext::from_exe(false, Some(Path::new("/tmp/codex")), true, false); - assert_eq!(npm_context.manager, InstallManager::Npm); + assert_eq!(npm_context, InstallContext::Npm); let bun_context = InstallContext::from_exe(false, Some(Path::new("/tmp/codex")), false, true); - assert_eq!(bun_context.manager, InstallManager::Bun); + assert_eq!(bun_context, InstallContext::Bun); } #[test] @@ -192,6 +222,6 @@ mod tests { false, false, ); - assert_eq!(context.manager, InstallManager::Brew); + assert_eq!(context, InstallContext::Brew); } } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 67c7e9f98b57..64cac956b2e9 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -34,6 +34,7 @@ use crate::text_formatting::truncate_text; use crate::tooltips; use crate::ui_consts::LIVE_PREFIX_COLS; use crate::update_action::UpdateAction; +use crate::update_action::command_str; use crate::version::CODEX_CLI_VERSION; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_line; @@ -520,7 +521,7 @@ impl HistoryCell for UpdateAvailableHistoryCell { use ratatui_macros::line; use ratatui_macros::text; let update_instruction = if let Some(update_action) = self.update_action { - line!["Run ", update_action.command_str().cyan(), " to update."] + line!["Run ", command_str(update_action).cyan(), " to update."] } else { line![ "See ", diff --git a/codex-rs/tui/src/update_action.rs b/codex-rs/tui/src/update_action.rs index dba798ac3f57..a02da12fba50 100644 --- a/codex-rs/tui/src/update_action.rs +++ b/codex-rs/tui/src/update_action.rs @@ -1,71 +1,60 @@ #[cfg(any(not(debug_assertions), test))] use codex_install_context::InstallContext; -#[cfg(any(not(debug_assertions), test))] use codex_install_context::InstallManager; -/// Update action the CLI should perform after the TUI exits. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum UpdateAction { - /// Update via `npm install -g @openai/codex@latest`. - NpmGlobalLatest, - /// Update via `bun install -g @openai/codex@latest`. - BunGlobalLatest, - /// Update via the native installer script. - NativeUpgrade, - /// Update via `brew upgrade codex`. - BrewUpgrade, -} +pub type UpdateAction = InstallManager; -impl UpdateAction { - /// Returns the list of command-line arguments for invoking the update. - pub fn command_args(self) -> (String, Vec) { - match self { - UpdateAction::NpmGlobalLatest => ( - "npm".to_string(), - vec!["install".into(), "-g".into(), "@openai/codex".into()], - ), - UpdateAction::BunGlobalLatest => ( - "bun".to_string(), - vec!["install".into(), "-g".into(), "@openai/codex".into()], - ), - UpdateAction::NativeUpgrade => { - #[cfg(windows)] - { - ( - "powershell".to_string(), - vec![ - "-NoProfile".into(), - "-ExecutionPolicy".into(), - "Bypass".into(), - "-Command".into(), - "$tmp = New-TemporaryFile; Invoke-WebRequest -Uri 'https://chatgpt.com/codex/install.ps1' -OutFile $tmp; & $tmp; Remove-Item $tmp".into(), - ], - ) - } - #[cfg(not(windows))] - { - ( - "sh".to_string(), - vec![ - "-c".into(), - "tmp=\"$(mktemp)\" && if command -v curl >/dev/null 2>&1; then curl -fsSL 'https://chatgpt.com/codex/install.sh' -o \"$tmp\"; elif command -v wget >/dev/null 2>&1; then wget -q -O \"$tmp\" 'https://chatgpt.com/codex/install.sh'; else echo 'curl or wget is required to update Codex.' >&2; rm -f \"$tmp\"; exit 1; fi && sh \"$tmp\"; status=$?; rm -f \"$tmp\"; exit $status".into(), - ], - ) - } +/// Returns the list of command-line arguments for invoking the update. +pub fn command_args(action: UpdateAction) -> (String, Vec) { + match action { + InstallManager::Npm => ( + "npm".to_string(), + vec!["install".into(), "-g".into(), "@openai/codex".into()], + ), + InstallManager::Bun => ( + "bun".to_string(), + vec!["install".into(), "-g".into(), "@openai/codex".into()], + ), + InstallManager::Native => { + #[cfg(windows)] + { + ( + "powershell".to_string(), + vec![ + "-NoProfile".into(), + "-ExecutionPolicy".into(), + "Bypass".into(), + "-Command".into(), + "$tmp = New-TemporaryFile; Invoke-WebRequest -Uri 'https://chatgpt.com/codex/install.ps1' -OutFile $tmp; & $tmp; Remove-Item $tmp".into(), + ], + ) } - UpdateAction::BrewUpgrade => ( - "brew".to_string(), - vec!["upgrade".into(), "--cask".into(), "codex".into()], - ), + #[cfg(not(windows))] + { + ( + "sh".to_string(), + vec![ + "-c".into(), + "tmp=\"$(mktemp)\" && if command -v curl >/dev/null 2>&1; then curl -fsSL 'https://chatgpt.com/codex/install.sh' -o \"$tmp\"; elif command -v wget >/dev/null 2>&1; then wget -q -O \"$tmp\" 'https://chatgpt.com/codex/install.sh'; else echo 'curl or wget is required to update Codex.' >&2; rm -f \"$tmp\"; exit 1; fi && sh \"$tmp\"; status=$?; rm -f \"$tmp\"; exit $status".into(), + ], + ) + } + } + InstallManager::Brew => ( + "brew".to_string(), + vec!["upgrade".into(), "--cask".into(), "codex".into()], + ), + InstallManager::Other => { + unreachable!("non-updatable installs should not reach command_args") } } +} - /// Returns string representation of the command-line arguments for invoking the update. - pub fn command_str(self) -> String { - let (command, args) = self.command_args(); - shlex::try_join(std::iter::once(command.as_str()).chain(args.iter().map(String::as_str))) - .unwrap_or_else(|_| format!("{command} {}", args.join(" "))) - } +/// Returns string representation of the command-line arguments for invoking the update. +pub fn command_str(action: UpdateAction) -> String { + let (command, args) = command_args(action); + shlex::try_join(std::iter::once(command.as_str()).chain(args.iter().map(String::as_str))) + .unwrap_or_else(|_| format!("{command} {}", args.join(" "))) } #[cfg(not(debug_assertions))] @@ -87,12 +76,12 @@ fn detect_update_action( #[cfg(any(not(debug_assertions), test))] fn update_action_for_context(context: &InstallContext) -> Option { - match context.manager { - InstallManager::Npm => Some(UpdateAction::NpmGlobalLatest), - InstallManager::Bun => Some(UpdateAction::BunGlobalLatest), - InstallManager::Native => Some(UpdateAction::NativeUpgrade), - InstallManager::Brew => Some(UpdateAction::BrewUpgrade), - InstallManager::Unknown => None, + match context.manager() { + InstallManager::Npm => Some(InstallManager::Npm), + InstallManager::Bun => Some(InstallManager::Bun), + InstallManager::Native => Some(InstallManager::Native), + InstallManager::Brew => Some(InstallManager::Brew), + InstallManager::Other => None, } } @@ -114,22 +103,12 @@ mod tests { None ); assert_eq!( - detect_update_action( - /*is_macos*/ false, - std::path::Path::new("/any/path"), - /*managed_by_npm*/ true, - /*managed_by_bun*/ false - ), - Some(UpdateAction::NpmGlobalLatest) + detect_update_action(false, std::path::Path::new("/any/path"), true, false), + Some(InstallManager::Npm) ); assert_eq!( - detect_update_action( - /*is_macos*/ false, - std::path::Path::new("/any/path"), - /*managed_by_npm*/ false, - /*managed_by_bun*/ true - ), - Some(UpdateAction::BunGlobalLatest) + detect_update_action(false, std::path::Path::new("/any/path"), false, true), + Some(InstallManager::Bun) ); assert_eq!( detect_update_action( @@ -138,7 +117,7 @@ mod tests { /*managed_by_npm*/ false, /*managed_by_bun*/ false ), - Some(UpdateAction::BrewUpgrade) + Some(InstallManager::Brew) ); assert_eq!( detect_update_action( @@ -147,7 +126,7 @@ mod tests { /*managed_by_npm*/ false, /*managed_by_bun*/ false ), - Some(UpdateAction::BrewUpgrade) + Some(InstallManager::Brew) ); } @@ -165,7 +144,7 @@ mod tests { assert_eq!( detect_update_action(false, &exe_path, false, false), - Some(UpdateAction::NativeUpgrade) + Some(InstallManager::Native) ); Ok(()) } diff --git a/codex-rs/tui/src/update_prompt.rs b/codex-rs/tui/src/update_prompt.rs index 43ee0dbd4004..89a8740d0a68 100644 --- a/codex-rs/tui/src/update_prompt.rs +++ b/codex-rs/tui/src/update_prompt.rs @@ -11,6 +11,7 @@ use crate::tui::FrameRequester; use crate::tui::Tui; use crate::tui::TuiEvent; use crate::update_action::UpdateAction; +use crate::update_action::command_str; use crate::updates; use codex_core::config::Config; use color_eyre::Result; @@ -186,7 +187,7 @@ impl WidgetRef for &UpdatePromptScreen { Clear.render(area, buf); let mut column = ColumnRenderable::new(); - let update_command = self.update_action.command_str(); + let update_command = command_str(self.update_action); column.push(""); column.push(Line::from(vec![ @@ -253,7 +254,7 @@ mod tests { UpdatePromptScreen::new( FrameRequester::test_dummy(), "9.9.9".into(), - UpdateAction::NpmGlobalLatest, + UpdateAction::Npm, ) } diff --git a/codex-rs/tui/src/updates.rs b/codex-rs/tui/src/updates.rs index 1edd871d0b63..0a5302a3ff70 100644 --- a/codex-rs/tui/src/updates.rs +++ b/codex-rs/tui/src/updates.rs @@ -80,7 +80,7 @@ fn read_version_info(version_file: &Path) -> anyhow::Result { async fn check_for_update(version_file: &Path) -> anyhow::Result<()> { let latest_version = match update_action::get_update_action() { - Some(UpdateAction::BrewUpgrade) => { + Some(UpdateAction::Brew) => { let HomebrewCaskInfo { version } = create_client() .get(HOMEBREW_CASK_API_URL) .send() diff --git a/docs/install.md b/docs/install.md index bdd9fac22fbf..b7d4f0711a60 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,24 +1,10 @@ ## Installing & building -### Native installer - -Use the native installer when you want a standalone Codex binary plus bundled native helpers without depending on Node: - -```bash -curl -fsSL https://chatgpt.com/codex/install.sh | sh -``` - -On Windows PowerShell: - -```powershell -irm https://chatgpt.com/codex/install.ps1 | iex -``` - ### System requirements | Requirement | Details | | --------------------------- | --------------------------------------------------------------- | -| Operating systems | macOS 12+, Ubuntu 20.04+/Debian 10+, or Windows 11 | +| Operating systems | macOS 12+, Ubuntu 20.04+/Debian 10+, or Windows 11 **via WSL2** | | Git (optional, recommended) | 2.23+ for built-in PR helpers | | RAM | 4-GB minimum (8-GB recommended) | From 02249b455086774345c2828ac0dc6ff09773a569 Mon Sep 17 00:00:00 2001 From: Edward Frazer Date: Sun, 8 Mar 2026 18:14:06 +0000 Subject: [PATCH 03/23] Cleaning up UpdateAction --- codex-rs/cli/src/main.rs | 4 +- codex-rs/install-context/src/lib.rs | 69 +++++++--- codex-rs/tui/src/history_cell.rs | 3 +- codex-rs/tui/src/update_action.rs | 202 ++++++++++++---------------- codex-rs/tui/src/update_prompt.rs | 3 +- 5 files changed, 137 insertions(+), 144 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 57a655efeee5..24dd558ea700 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -484,7 +484,7 @@ fn handle_app_exit(exit_info: AppExitInfo) -> anyhow::Result<()> { /// Run the update action and print the result. fn run_update_action(action: UpdateAction) -> anyhow::Result<()> { println!(); - let cmd_str = command_str(action); + let cmd_str = action.command_str(); println!("Updating Codex via `{cmd_str}`..."); let status = { @@ -497,7 +497,7 @@ fn run_update_action(action: UpdateAction) -> anyhow::Result<()> { } #[cfg(not(windows))] { - let (cmd, args) = command_args(action); + let (cmd, args) = action.command_args(); let command_path = crate::wsl_paths::normalize_for_wsl(cmd); let normalized_args: Vec = args .iter() diff --git a/codex-rs/install-context/src/lib.rs b/codex-rs/install-context/src/lib.rs index eb71ed104947..8c508dd4da3b 100644 --- a/codex-rs/install-context/src/lib.rs +++ b/codex-rs/install-context/src/lib.rs @@ -8,16 +8,9 @@ const METADATA_FILENAME: &str = "metadata.toml"; static INSTALL_CONTEXT: OnceLock = OnceLock::new(); #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum InstallManager { - Native, - Npm, - Bun, - Brew, - /// Any other execution environment. - /// - /// This commonly covers `cargo run`, app-bundled Codex binaries, custom - /// internal launchers, and tests that execute Codex from an arbitrary path. - Other, +pub enum NativePlatform { + Unix, + Windows, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -35,6 +28,8 @@ pub enum InstallContext { /// The bundled ripgrep binary for this native release, for example /// `~/.codex/packages/native/releases/.../rg`. rg_command: PathBuf, + /// The platform of the native release, either `Unix` or `Windows`. + platform: NativePlatform, }, /// A Codex binary launched through the npm-managed `codex.js` shim. Npm, @@ -94,16 +89,6 @@ impl InstallContext { }) } - pub fn manager(&self) -> InstallManager { - match self { - Self::Native { .. } => InstallManager::Native, - Self::Npm => InstallManager::Npm, - Self::Bun => InstallManager::Bun, - Self::Brew => InstallManager::Brew, - Self::Other => InstallManager::Other, - } - } - pub fn rg_command(&self) -> PathBuf { match self { Self::Native { rg_command, .. } => rg_command.clone(), @@ -123,11 +108,15 @@ fn native_install_context(exe_path: &Path) -> Option { let canonical_exe = std::fs::canonicalize(exe_path).ok()?; let release_dir = canonical_exe.parent()?.to_path_buf(); let metadata = parse_native_install_metadata(&release_dir.join(METADATA_FILENAME))?; - - let rg_name = if cfg!(windows) { "rg.exe" } else { "rg" }; + let platform = native_platform_from_target(&metadata.target); + let rg_name = match platform { + NativePlatform::Unix => "rg", + NativePlatform::Windows => "rg.exe", + }; let rg_command = release_dir.join(rg_name); Some(InstallContext::Native { + platform, release_dir, version: metadata.version, target: metadata.target, @@ -144,6 +133,14 @@ fn parse_native_install_metadata(path: &Path) -> Option { Some(metadata) } +fn native_platform_from_target(target: &str) -> NativePlatform { + if target.contains("-windows-") { + NativePlatform::Windows + } else { + NativePlatform::Unix + } +} + fn default_rg_command() -> PathBuf { if cfg!(windows) { PathBuf::from("rg.exe") @@ -177,6 +174,7 @@ mod tests { assert_eq!( context, InstallContext::Native { + platform: NativePlatform::Unix, release_dir: release_dir.canonicalize()?, version: "1.2.3".to_string(), target: "x86_64-unknown-linux-musl".to_string(), @@ -186,6 +184,33 @@ mod tests { Ok(()) } + #[test] + fn detects_windows_native_platform_from_target() -> std::io::Result<()> { + let root = tempfile::tempdir()?; + let release_dir = root.path().join("1.2.3-x86_64-pc-windows-msvc"); + fs::create_dir(&release_dir)?; + fs::write( + release_dir.join("metadata.toml"), + "install_method = \"native\"\nversion = \"1.2.3\"\ntarget = \"x86_64-pc-windows-msvc\"\n", + )?; + let exe_path = release_dir.join("codex"); + fs::write(&exe_path, "")?; + fs::write(release_dir.join("rg.exe"), "")?; + + let context = InstallContext::from_exe(false, Some(&exe_path), false, false); + assert_eq!( + context, + InstallContext::Native { + platform: NativePlatform::Windows, + release_dir: release_dir.canonicalize()?, + version: "1.2.3".to_string(), + target: "x86_64-pc-windows-msvc".to_string(), + rg_command: release_dir.join("rg.exe"), + } + ); + Ok(()) + } + #[test] fn native_metadata_rejects_non_native_install_method() -> std::io::Result<()> { let root = tempfile::tempdir()?; diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 64cac956b2e9..67c7e9f98b57 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -34,7 +34,6 @@ use crate::text_formatting::truncate_text; use crate::tooltips; use crate::ui_consts::LIVE_PREFIX_COLS; use crate::update_action::UpdateAction; -use crate::update_action::command_str; use crate::version::CODEX_CLI_VERSION; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_line; @@ -521,7 +520,7 @@ impl HistoryCell for UpdateAvailableHistoryCell { use ratatui_macros::line; use ratatui_macros::text; let update_instruction = if let Some(update_action) = self.update_action { - line!["Run ", command_str(update_action).cyan(), " to update."] + line!["Run ", update_action.command_str().cyan(), " to update."] } else { line![ "See ", diff --git a/codex-rs/tui/src/update_action.rs b/codex-rs/tui/src/update_action.rs index a02da12fba50..5224aa916e4b 100644 --- a/codex-rs/tui/src/update_action.rs +++ b/codex-rs/tui/src/update_action.rs @@ -1,151 +1,121 @@ #[cfg(any(not(debug_assertions), test))] use codex_install_context::InstallContext; -use codex_install_context::InstallManager; +#[cfg(any(not(debug_assertions), test))] +use codex_install_context::NativePlatform; -pub type UpdateAction = InstallManager; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpdateAction { + Npm, + Bun, + Brew, + NativeUnix, + NativeWindows, +} -/// Returns the list of command-line arguments for invoking the update. -pub fn command_args(action: UpdateAction) -> (String, Vec) { - match action { - InstallManager::Npm => ( - "npm".to_string(), - vec!["install".into(), "-g".into(), "@openai/codex".into()], - ), - InstallManager::Bun => ( - "bun".to_string(), - vec!["install".into(), "-g".into(), "@openai/codex".into()], - ), - InstallManager::Native => { - #[cfg(windows)] - { - ( - "powershell".to_string(), - vec![ - "-NoProfile".into(), - "-ExecutionPolicy".into(), - "Bypass".into(), - "-Command".into(), - "$tmp = New-TemporaryFile; Invoke-WebRequest -Uri 'https://chatgpt.com/codex/install.ps1' -OutFile $tmp; & $tmp; Remove-Item $tmp".into(), - ], - ) - } - #[cfg(not(windows))] - { - ( - "sh".to_string(), - vec![ - "-c".into(), - "tmp=\"$(mktemp)\" && if command -v curl >/dev/null 2>&1; then curl -fsSL 'https://chatgpt.com/codex/install.sh' -o \"$tmp\"; elif command -v wget >/dev/null 2>&1; then wget -q -O \"$tmp\" 'https://chatgpt.com/codex/install.sh'; else echo 'curl or wget is required to update Codex.' >&2; rm -f \"$tmp\"; exit 1; fi && sh \"$tmp\"; status=$?; rm -f \"$tmp\"; exit $status".into(), - ], - ) - } +impl UpdateAction { + pub(crate) fn from_install_context(context: &InstallContext) -> Option { + match context { + InstallContext::Native { platform, .. } => Some(match platform { + NativePlatform::Unix => UpdateAction::NativeUnix, + NativePlatform::Windows => UpdateAction::NativeWindows, + }), + InstallContext::Npm => Some(UpdateAction::Npm), + InstallContext::Bun => Some(UpdateAction::Bun), + InstallContext::Brew => Some(UpdateAction::Brew), + InstallContext::Other => None, } - InstallManager::Brew => ( - "brew".to_string(), - vec!["upgrade".into(), "--cask".into(), "codex".into()], - ), - InstallManager::Other => { - unreachable!("non-updatable installs should not reach command_args") + } + + /// Returns the list of command-line arguments for invoking the update. + pub fn command_args(self) -> (String, Vec) { + match self { + UpdateAction::Npm => ( + "npm".to_string(), + vec!["install".into(), "-g".into(), "@openai/codex".into()], + ), + UpdateAction::Bun => ( + "bun".to_string(), + vec!["install".into(), "-g".into(), "@openai/codex".into()], + ), + UpdateAction::NativeUnix => ( + "sh".to_string(), + vec![ + "-c".into(), + "curl -fsSL https://chatgpt.com/codex/install.sh | sh".into(), + ], + ), + UpdateAction::NativeWindows => ( + "powershell".to_string(), + vec![ + "-c".into(), + "irm https://chatgpt.com/codex/install.ps1|iex".into(), + ], + ), + UpdateAction::Brew => ( + "brew".to_string(), + vec!["upgrade".into(), "--cask".into(), "codex".into()], + ), } } -} -/// Returns string representation of the command-line arguments for invoking the update. -pub fn command_str(action: UpdateAction) -> String { - let (command, args) = command_args(action); - shlex::try_join(std::iter::once(command.as_str()).chain(args.iter().map(String::as_str))) - .unwrap_or_else(|_| format!("{command} {}", args.join(" "))) + /// Returns string representation of the command-line arguments for invoking the update. + pub fn command_str(self) -> String { + let (command, args) = self.command_args(); + shlex::try_join(std::iter::once(command.as_str()).chain(args.iter().map(String::as_str))) + .unwrap_or_else(|_| format!("{command} {}", args.join(" "))) + } } #[cfg(not(debug_assertions))] pub(crate) fn get_update_action() -> Option { - update_action_for_context(InstallContext::current()) -} - -#[cfg(any(not(debug_assertions), test))] -fn detect_update_action( - is_macos: bool, - current_exe: &std::path::Path, - managed_by_npm: bool, - managed_by_bun: bool, -) -> Option { - let context = - InstallContext::from_exe(is_macos, Some(current_exe), managed_by_npm, managed_by_bun); - update_action_for_context(&context) -} - -#[cfg(any(not(debug_assertions), test))] -fn update_action_for_context(context: &InstallContext) -> Option { - match context.manager() { - InstallManager::Npm => Some(InstallManager::Npm), - InstallManager::Bun => Some(InstallManager::Bun), - InstallManager::Native => Some(InstallManager::Native), - InstallManager::Brew => Some(InstallManager::Brew), - InstallManager::Other => None, - } + UpdateAction::from_install_context(InstallContext::current()) } #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; - use std::fs; + use std::path::PathBuf; #[test] - fn detects_update_action_without_env_mutation() { + fn maps_install_context_to_update_action() { + let native_release_dir = PathBuf::from("/tmp/native-release"); + assert_eq!( - detect_update_action( - /*is_macos*/ false, - std::path::Path::new("/any/path"), - /*managed_by_npm*/ false, - /*managed_by_bun*/ false - ), + UpdateAction::from_install_context(&InstallContext::Other), None ); assert_eq!( - detect_update_action(false, std::path::Path::new("/any/path"), true, false), - Some(InstallManager::Npm) + UpdateAction::from_install_context(&InstallContext::Npm), + Some(UpdateAction::Npm) ); assert_eq!( - detect_update_action(false, std::path::Path::new("/any/path"), false, true), - Some(InstallManager::Bun) + UpdateAction::from_install_context(&InstallContext::Bun), + Some(UpdateAction::Bun) ); assert_eq!( - detect_update_action( - /*is_macos*/ true, - std::path::Path::new("/opt/homebrew/bin/codex"), - /*managed_by_npm*/ false, - /*managed_by_bun*/ false - ), - Some(InstallManager::Brew) + UpdateAction::from_install_context(&InstallContext::Brew), + Some(UpdateAction::Brew) ); assert_eq!( - detect_update_action( - /*is_macos*/ true, - std::path::Path::new("/usr/local/bin/codex"), - /*managed_by_npm*/ false, - /*managed_by_bun*/ false - ), - Some(InstallManager::Brew) + UpdateAction::from_install_context(&InstallContext::Native { + platform: NativePlatform::Unix, + release_dir: native_release_dir.clone(), + version: "1.2.3".to_string(), + target: "x86_64-unknown-linux-musl".to_string(), + rg_command: native_release_dir.join("rg"), + }), + Some(UpdateAction::NativeUnix) ); - } - - #[test] - fn detects_native_update_action_from_metadata() -> std::io::Result<()> { - let root = tempfile::tempdir()?; - let release_dir = root.path().join("1.2.3-x86_64-unknown-linux-musl"); - fs::create_dir(&release_dir)?; - fs::write( - release_dir.join("metadata.toml"), - "install_method = \"native\"\nversion = \"1.2.3\"\ntarget = \"x86_64-unknown-linux-musl\"\n", - )?; - let exe_path = release_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); - fs::write(&exe_path, "")?; - assert_eq!( - detect_update_action(false, &exe_path, false, false), - Some(InstallManager::Native) + UpdateAction::from_install_context(&InstallContext::Native { + platform: NativePlatform::Windows, + release_dir: native_release_dir.clone(), + version: "1.2.3".to_string(), + target: "x86_64-pc-windows-msvc".to_string(), + rg_command: native_release_dir.join("rg.exe"), + }), + Some(UpdateAction::NativeWindows) ); - Ok(()) } } diff --git a/codex-rs/tui/src/update_prompt.rs b/codex-rs/tui/src/update_prompt.rs index 89a8740d0a68..d53f6a56d187 100644 --- a/codex-rs/tui/src/update_prompt.rs +++ b/codex-rs/tui/src/update_prompt.rs @@ -11,7 +11,6 @@ use crate::tui::FrameRequester; use crate::tui::Tui; use crate::tui::TuiEvent; use crate::update_action::UpdateAction; -use crate::update_action::command_str; use crate::updates; use codex_core::config::Config; use color_eyre::Result; @@ -187,7 +186,7 @@ impl WidgetRef for &UpdatePromptScreen { Clear.render(area, buf); let mut column = ColumnRenderable::new(); - let update_command = command_str(self.update_action); + let update_command = self.update_action.command_str(); column.push(""); column.push(Line::from(vec![ From 27af29d03c10a60a7c4135a0a21479e6afe1d40c Mon Sep 17 00:00:00 2001 From: Edward Frazer Date: Sun, 8 Mar 2026 18:25:13 +0000 Subject: [PATCH 04/23] adding bazel build file & fixing an install script bug --- codex-rs/install-context/BUILD.bazel | 6 ++++++ scripts/install/install.sh | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 codex-rs/install-context/BUILD.bazel diff --git a/codex-rs/install-context/BUILD.bazel b/codex-rs/install-context/BUILD.bazel new file mode 100644 index 000000000000..68254d10cc50 --- /dev/null +++ b/codex-rs/install-context/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "install-context", + crate_name = "codex_install_context", +) diff --git a/scripts/install/install.sh b/scripts/install/install.sh index 06e876bee8c4..2426936bdd52 100755 --- a/scripts/install/install.sh +++ b/scripts/install/install.sh @@ -443,4 +443,4 @@ case "$path_action" in ;; esac -printf 'Codex CLI %s installed successfully.\n' "$resolved_version" +printf 'Codex CLI %s installed successfully.\n' "$resolved_version" \ No newline at end of file From 231320fd2c819e88d5b50855852556669259129d Mon Sep 17 00:00:00 2001 From: Edward Frazer Date: Sun, 8 Mar 2026 18:39:07 +0000 Subject: [PATCH 05/23] manually fixing annoying codex nits --- codex-rs/tui/src/update_action.rs | 57 +++++++++++++------------------ codex-rs/tui/src/update_prompt.rs | 2 +- codex-rs/tui/src/updates.rs | 2 +- 3 files changed, 26 insertions(+), 35 deletions(-) diff --git a/codex-rs/tui/src/update_action.rs b/codex-rs/tui/src/update_action.rs index 5224aa916e4b..070b7a980161 100644 --- a/codex-rs/tui/src/update_action.rs +++ b/codex-rs/tui/src/update_action.rs @@ -3,57 +3,48 @@ use codex_install_context::InstallContext; #[cfg(any(not(debug_assertions), test))] use codex_install_context::NativePlatform; +/// Update action the CLI should perform after the TUI exits. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum UpdateAction { - Npm, - Bun, - Brew, + /// Update via `npm install -g @openai/codex@latest`. + NpmGlobalLatest, + /// Update via `bun install -g @openai/codex@latest`. + BunGlobalLatest, + /// Update via `brew upgrade codex`. + BrewUpgrade, + /// Update via `curl -fsSL https://chatgpt.com/codex/install.sh | sh`. NativeUnix, + /// Update via `irm https://chatgpt.com/codex/install.ps1|iex`. NativeWindows, } impl UpdateAction { pub(crate) fn from_install_context(context: &InstallContext) -> Option { match context { + InstallContext::Npm => Some(UpdateAction::NpmGlobalLatest), + InstallContext::Bun => Some(UpdateAction::BunGlobalLatest), + InstallContext::Brew => Some(UpdateAction::BrewUpgrade), InstallContext::Native { platform, .. } => Some(match platform { NativePlatform::Unix => UpdateAction::NativeUnix, NativePlatform::Windows => UpdateAction::NativeWindows, }), - InstallContext::Npm => Some(UpdateAction::Npm), - InstallContext::Bun => Some(UpdateAction::Bun), - InstallContext::Brew => Some(UpdateAction::Brew), InstallContext::Other => None, } } /// Returns the list of command-line arguments for invoking the update. - pub fn command_args(self) -> (String, Vec) { + pub fn command_args(self) -> (&'static str, &'static [&'static str]) { match self { - UpdateAction::Npm => ( - "npm".to_string(), - vec!["install".into(), "-g".into(), "@openai/codex".into()], - ), - UpdateAction::Bun => ( - "bun".to_string(), - vec!["install".into(), "-g".into(), "@openai/codex".into()], - ), + UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex"]), + UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex"]), + UpdateAction::BrewUpgrade => ("brew", &["upgrade", "--cask", "codex"]), UpdateAction::NativeUnix => ( - "sh".to_string(), - vec![ - "-c".into(), - "curl -fsSL https://chatgpt.com/codex/install.sh | sh".into(), - ], + "sh", + &["-c", "curl -fsSL https://chatgpt.com/codex/install.sh | sh"], ), UpdateAction::NativeWindows => ( - "powershell".to_string(), - vec![ - "-c".into(), - "irm https://chatgpt.com/codex/install.ps1|iex".into(), - ], - ), - UpdateAction::Brew => ( - "brew".to_string(), - vec!["upgrade".into(), "--cask".into(), "codex".into()], + "powershell", + &["-c", "irm https://chatgpt.com/codex/install.ps1|iex"], ), } } @@ -61,7 +52,7 @@ impl UpdateAction { /// Returns string representation of the command-line arguments for invoking the update. pub fn command_str(self) -> String { let (command, args) = self.command_args(); - shlex::try_join(std::iter::once(command.as_str()).chain(args.iter().map(String::as_str))) + shlex::try_join(std::iter::once(command).chain(args.iter().copied())) .unwrap_or_else(|_| format!("{command} {}", args.join(" "))) } } @@ -87,15 +78,15 @@ mod tests { ); assert_eq!( UpdateAction::from_install_context(&InstallContext::Npm), - Some(UpdateAction::Npm) + Some(UpdateAction::NpmGlobalLatest) ); assert_eq!( UpdateAction::from_install_context(&InstallContext::Bun), - Some(UpdateAction::Bun) + Some(UpdateAction::BunGlobalLatest) ); assert_eq!( UpdateAction::from_install_context(&InstallContext::Brew), - Some(UpdateAction::Brew) + Some(UpdateAction::BrewUpgrade) ); assert_eq!( UpdateAction::from_install_context(&InstallContext::Native { diff --git a/codex-rs/tui/src/update_prompt.rs b/codex-rs/tui/src/update_prompt.rs index d53f6a56d187..43ee0dbd4004 100644 --- a/codex-rs/tui/src/update_prompt.rs +++ b/codex-rs/tui/src/update_prompt.rs @@ -253,7 +253,7 @@ mod tests { UpdatePromptScreen::new( FrameRequester::test_dummy(), "9.9.9".into(), - UpdateAction::Npm, + UpdateAction::NpmGlobalLatest, ) } diff --git a/codex-rs/tui/src/updates.rs b/codex-rs/tui/src/updates.rs index 0a5302a3ff70..1edd871d0b63 100644 --- a/codex-rs/tui/src/updates.rs +++ b/codex-rs/tui/src/updates.rs @@ -80,7 +80,7 @@ fn read_version_info(version_file: &Path) -> anyhow::Result { async fn check_for_update(version_file: &Path) -> anyhow::Result<()> { let latest_version = match update_action::get_update_action() { - Some(UpdateAction::Brew) => { + Some(UpdateAction::BrewUpgrade) => { let HomebrewCaskInfo { version } = create_client() .get(HOMEBREW_CASK_API_URL) .send() From 8effe7f1f566fd0ec04a82a5aea8749de5814b81 Mon Sep 17 00:00:00 2001 From: Edward Frazer Date: Sun, 8 Mar 2026 19:19:08 +0000 Subject: [PATCH 06/23] build fix on release. --- codex-rs/tui/src/update_action.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/tui/src/update_action.rs b/codex-rs/tui/src/update_action.rs index 070b7a980161..d5220cde7cb8 100644 --- a/codex-rs/tui/src/update_action.rs +++ b/codex-rs/tui/src/update_action.rs @@ -19,6 +19,7 @@ pub enum UpdateAction { } impl UpdateAction { + #[cfg(any(not(debug_assertions), test))] pub(crate) fn from_install_context(context: &InstallContext) -> Option { match context { InstallContext::Npm => Some(UpdateAction::NpmGlobalLatest), From 623b08e62795cb8acc8af5ab425c461a8aff1165 Mon Sep 17 00:00:00 2001 From: Edward Frazer Date: Sun, 8 Mar 2026 20:04:38 +0000 Subject: [PATCH 07/23] fixing flaky test --- codex-rs/install-context/src/lib.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/codex-rs/install-context/src/lib.rs b/codex-rs/install-context/src/lib.rs index 8c508dd4da3b..20e048e4575f 100644 --- a/codex-rs/install-context/src/lib.rs +++ b/codex-rs/install-context/src/lib.rs @@ -165,20 +165,21 @@ mod tests { "install_method = \"native\"\nversion = \"1.2.3\"\ntarget = \"x86_64-unknown-linux-musl\"\n", )?; let exe_name = if cfg!(windows) { "codex.exe" } else { "codex" }; - let rg_name = if cfg!(windows) { "rg.exe" } else { "rg" }; + let rg_name = "rg"; let exe_path = release_dir.join(exe_name); fs::write(&exe_path, "")?; fs::write(release_dir.join(rg_name), "")?; + let canonical_release_dir = release_dir.canonicalize()?; let context = InstallContext::from_exe(false, Some(&exe_path), false, false); assert_eq!( context, InstallContext::Native { platform: NativePlatform::Unix, - release_dir: release_dir.canonicalize()?, + release_dir: canonical_release_dir.clone(), version: "1.2.3".to_string(), target: "x86_64-unknown-linux-musl".to_string(), - rg_command: release_dir.join(rg_name), + rg_command: canonical_release_dir.join(rg_name), } ); Ok(()) @@ -196,16 +197,17 @@ mod tests { let exe_path = release_dir.join("codex"); fs::write(&exe_path, "")?; fs::write(release_dir.join("rg.exe"), "")?; + let canonical_release_dir = release_dir.canonicalize()?; let context = InstallContext::from_exe(false, Some(&exe_path), false, false); assert_eq!( context, InstallContext::Native { platform: NativePlatform::Windows, - release_dir: release_dir.canonicalize()?, + release_dir: canonical_release_dir.clone(), version: "1.2.3".to_string(), target: "x86_64-pc-windows-msvc".to_string(), - rg_command: release_dir.join("rg.exe"), + rg_command: canonical_release_dir.join("rg.exe"), } ); Ok(()) From 74b8140a84d2662683e325648e3bf1b83e562e0e Mon Sep 17 00:00:00 2001 From: Edward Frazer Date: Sun, 8 Mar 2026 23:57:22 +0000 Subject: [PATCH 08/23] fix: harden native installer recovery and PATH updates Validate existing native release directories before reusing them, rewrite the shell PATH block when the install dir changes, and refuse destructive PowerShell junction replacement for normal directories. Co-authored-by: Codex --- scripts/install/install.ps1 | 46 ++++++++++++++++-- scripts/install/install.sh | 96 +++++++++++++++++++++++++++++++++---- 2 files changed, 130 insertions(+), 12 deletions(-) diff --git a/scripts/install/install.ps1 b/scripts/install/install.ps1 index b6e2d9e220af..20bab9138669 100644 --- a/scripts/install/install.ps1 +++ b/scripts/install/install.ps1 @@ -111,18 +111,53 @@ function Ensure-Junction { [string]$TargetPath ) - if (Test-Path $LinkPath) { + if (Test-Path -LiteralPath $LinkPath) { $item = Get-Item -LiteralPath $LinkPath -Force if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + Remove-Item -LiteralPath $LinkPath -Force + } elseif ($item.PSIsContainer) { + if ((Get-ChildItem -LiteralPath $LinkPath -Force | Select-Object -First 1) -ne $null) { + throw "Refusing to replace non-empty directory at $LinkPath with a junction." + } + Remove-Item -LiteralPath $LinkPath -Force } else { - Remove-Item -LiteralPath $LinkPath -Recurse -Force + throw "Refusing to replace file at $LinkPath with a junction." } } New-Item -ItemType Junction -Path $LinkPath -Target $TargetPath | Out-Null } +function Test-ReleaseIsComplete { + param( + [string]$ReleaseDir, + [string]$ExpectedVersion, + [string]$ExpectedTarget + ) + + if (-not (Test-Path -LiteralPath $ReleaseDir -PathType Container)) { + return $false + } + + $expectedFiles = @( + "codex.exe", + "codex-command-runner.exe", + "codex-windows-sandbox-setup.exe", + "rg.exe", + "metadata.toml" + ) + foreach ($name in $expectedFiles) { + if (-not (Test-Path -LiteralPath (Join-Path $ReleaseDir $name) -PathType Leaf)) { + return $false + } + } + + $version = Read-MetadataValue -MetadataPath (Join-Path $ReleaseDir "metadata.toml") -Key "version" + $target = Read-MetadataValue -MetadataPath (Join-Path $ReleaseDir "metadata.toml") -Key "target" + return $version -eq $ExpectedVersion -and $target -eq $ExpectedTarget +} + function Get-ExistingCodexCommand { $existing = Get-Command codex -ErrorAction SilentlyContinue if ($null -eq $existing) { @@ -259,7 +294,12 @@ $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("codex-install-" + [Syst New-Item -ItemType Directory -Force -Path $tempDir | Out-Null try { - if (-not (Test-Path $releaseDir)) { + if (-not (Test-ReleaseIsComplete -ReleaseDir $releaseDir -ExpectedVersion $resolvedVersion -ExpectedTarget $target)) { + if (Test-Path -LiteralPath $releaseDir) { + Write-WarningStep "Found incomplete existing release at $releaseDir. Reinstalling." + Remove-Item -LiteralPath $releaseDir -Recurse -Force + } + $archivePath = Join-Path $tempDir $packageAsset $extractDir = Join-Path $tempDir "extract" $stagingDir = Join-Path $tempDir "release" diff --git a/scripts/install/install.sh b/scripts/install/install.sh index 2426936bdd52..3080eb890aae 100755 --- a/scripts/install/install.sh +++ b/scripts/install/install.sh @@ -172,16 +172,73 @@ add_to_path() { path_line="export PATH=\"$BIN_DIR:\$PATH\"" if [ -f "$profile" ] && grep -F "$begin_marker" "$profile" >/dev/null 2>&1; then - path_action="configured" - return + if grep -F "$path_line" "$profile" >/dev/null 2>&1; then + path_action="configured" + return + fi + + if grep -F "$end_marker" "$profile" >/dev/null 2>&1; then + rewrite_path_block "$profile" "$begin_marker" "$end_marker" "$path_line" + path_action="updated" + return + fi fi + append_path_block "$profile" "$begin_marker" "$end_marker" "$path_line" + path_action="added" +} + +append_path_block() { + profile="$1" + begin_marker="$2" + end_marker="$3" + path_line="$4" + { printf '\n%s\n' "$begin_marker" printf '%s\n' "$path_line" printf '%s\n' "$end_marker" } >>"$profile" - path_action="added" +} + +rewrite_path_block() { + profile="$1" + begin_marker="$2" + end_marker="$3" + path_line="$4" + tmp_profile="$tmp_dir/profile.$$.tmp" + + awk -v begin="$begin_marker" -v end="$end_marker" -v line="$path_line" ' + BEGIN { + in_block = 0 + replaced = 0 + } + $0 == begin { + if (!replaced) { + print begin + print line + print end + replaced = 1 + } + in_block = 1 + next + } + in_block { + if ($0 == end) { + in_block = 0 + } + next + } + { + print + } + END { + if (in_block != 0) { + exit 1 + } + } + ' "$profile" >"$tmp_profile" + mv "$tmp_profile" "$profile" } read_metadata_value() { @@ -285,12 +342,12 @@ handle_conflicting_install() { install_release() { release_dir="$1" vendor_root="$2" + stage_release="$tmp_dir/release" - if [ -d "$release_dir" ]; then - return + rm -rf "$stage_release" + if [ -e "$release_dir" ] || [ -L "$release_dir" ]; then + rm -rf "$release_dir" fi - - stage_release="$tmp_dir/release" mkdir -p "$stage_release" cp "$vendor_root/codex/codex" "$stage_release/codex" cp "$vendor_root/path/rg" "$stage_release/rg" @@ -306,6 +363,18 @@ EOF mv "$stage_release" "$release_dir" } +release_dir_is_complete() { + release_dir="$1" + expected_version="$2" + expected_target="$3" + + [ -d "$release_dir" ] && + [ -x "$release_dir/codex" ] && + [ -x "$release_dir/rg" ] && + [ "$(read_metadata_value "$release_dir/metadata.toml" version || true)" = "$expected_version" ] && + [ "$(read_metadata_value "$release_dir/metadata.toml" target || true)" = "$expected_target" ] +} + update_current_link() { release_dir="$1" tmp_link="$NATIVE_ROOT/.current.$$" @@ -408,7 +477,11 @@ cleanup() { } trap cleanup EXIT INT TERM -if [ ! -d "$release_dir" ]; then +if ! release_dir_is_complete "$release_dir" "$resolved_version" "$vendor_target"; then + if [ -e "$release_dir" ] || [ -L "$release_dir" ]; then + warn "Found incomplete existing release at $release_dir; reinstalling." + fi + archive_path="$tmp_dir/$asset" extract_dir="$tmp_dir/extract" @@ -432,6 +505,11 @@ case "$path_action" in step "Run now: export PATH=\"$BIN_DIR:\$PATH\" && codex" step "Or open a new terminal and run: codex" ;; + updated) + step "PATH updated in $path_profile" + step "Run now: export PATH=\"$BIN_DIR:\$PATH\" && codex" + step "Or open a new terminal and run: codex" + ;; configured) step "PATH is already configured for future shells in $path_profile" step "Run now: export PATH=\"$BIN_DIR:\$PATH\" && codex" @@ -443,4 +521,4 @@ case "$path_action" in ;; esac -printf 'Codex CLI %s installed successfully.\n' "$resolved_version" \ No newline at end of file +printf 'Codex CLI %s installed successfully.\n' "$resolved_version" From e884b8804deda01562bc257813e0b37d242c5550 Mon Sep 17 00:00:00 2001 From: Edward Frazer Date: Wed, 11 Mar 2026 21:15:44 +0000 Subject: [PATCH 09/23] fix: improve native installer launch UX Offer an immediate launch prompt after install and print clearer instructions for the current session and future terminals. Co-authored-by: Codex --- scripts/install/install.ps1 | 45 +++++++++++++++++----------- scripts/install/install.sh | 60 +++++++++++++++++++++++++++++-------- 2 files changed, 75 insertions(+), 30 deletions(-) diff --git a/scripts/install/install.ps1 b/scripts/install/install.ps1 index 20bab9138669..9f93caf64888 100644 --- a/scripts/install/install.ps1 +++ b/scripts/install/install.ps1 @@ -22,6 +22,19 @@ function Write-WarningStep { Write-Warning $Message } +function Prompt-YesNo { + param( + [string]$Prompt + ) + + if ([Console]::IsInputRedirected -or [Console]::IsOutputRedirected) { + return $false + } + + $choice = Read-Host "$Prompt [y/N]" + return $choice -match "^(?i:y(?:es)?)$" +} + function Normalize-Version { param( [string]$RawVersion @@ -213,8 +226,7 @@ function Maybe-HandleConflictingInstall { } $uninstallCommand = if ($manager -eq "bun") { "bun" } else { "npm" } - $choice = Read-Host "Uninstall the existing $manager-managed Codex now? [y/N]" - if ($choice -match "^(?i:y(?:es)?)$") { + if (Prompt-YesNo "Uninstall the existing $manager-managed Codex now?") { Write-Step "Running: $uninstallCommand $($uninstallArgs -join ' ')" try { & $uninstallCommand @uninstallArgs @@ -345,7 +357,6 @@ target = "$target" } $userPath = [Environment]::GetEnvironmentVariable("Path", "User") -$pathNeedsNewShell = $false if (-not (Path-Contains -PathValue $userPath -Entry $visibleBinDir)) { if ([string]::IsNullOrWhiteSpace($userPath)) { $newUserPath = $visibleBinDir @@ -354,27 +365,27 @@ if (-not (Path-Contains -PathValue $userPath -Entry $visibleBinDir)) { } [Environment]::SetEnvironmentVariable("Path", $newUserPath, "User") - if (-not (Path-Contains -PathValue $env:Path -Entry $visibleBinDir)) { - if ([string]::IsNullOrWhiteSpace($env:Path)) { - $env:Path = $visibleBinDir - } else { - $env:Path = "$visibleBinDir;$env:Path" - } - } Write-Step "PATH updated for future PowerShell sessions." - $pathNeedsNewShell = $true } elseif (Path-Contains -PathValue $env:Path -Entry $visibleBinDir) { Write-Step "$visibleBinDir is already on PATH." } else { Write-Step "PATH is already configured for future PowerShell sessions." - $pathNeedsNewShell = $true } -if ($pathNeedsNewShell) { - Write-Step ('Run now: $env:Path = "{0};$env:Path"; codex' -f $visibleBinDir) - Write-Step "Or open a new PowerShell window and run: codex" -} else { - Write-Step "Run: codex" +if (-not (Path-Contains -PathValue $env:Path -Entry $visibleBinDir)) { + if ([string]::IsNullOrWhiteSpace($env:Path)) { + $env:Path = $visibleBinDir + } else { + $env:Path = "$visibleBinDir;$env:Path" + } } +Write-Step "Current PowerShell session: codex" +Write-Step "Future PowerShell windows: open a new PowerShell window and run: codex" Write-Host "Codex CLI $resolvedVersion installed successfully." + +$codexCommand = Join-Path $visibleBinDir "codex.exe" +if (Prompt-YesNo "Start Codex now?") { + Write-Step "Launching Codex" + & $codexCommand +} diff --git a/scripts/install/install.sh b/scripts/install/install.sh index 3080eb890aae..c077baf2b5c4 100755 --- a/scripts/install/install.sh +++ b/scripts/install/install.sh @@ -290,12 +290,20 @@ classify_existing_codex() { prompt_yes_no() { prompt="$1" - if [ ! -t 0 ]; then + if [ -r /dev/tty ] && [ -w /dev/tty ]; then + printf '%s [y/N] ' "$prompt" >/dev/tty + if ! IFS= read -r answer Date: Mon, 16 Mar 2026 21:43:07 +0000 Subject: [PATCH 10/23] fix: improve standalone installer behavior Use the shared CODEX_HOME helper in install-context, tighten standalone install handling, and improve installer launch UX for shell and PowerShell. Also make the shell installer quiet in non-interactive environments and verify the standalone install path with smoke tests. Co-authored-by: Codex --- codex-rs/Cargo.lock | 1 + codex-rs/README.md | 2 +- codex-rs/install-context/Cargo.toml | 1 + codex-rs/install-context/src/lib.rs | 245 ++++++++++-------- codex-rs/tui/src/update_action.rs | 36 ++- .../src/helper_materialization.rs | 53 ++-- .../src/setup_orchestrator.rs | 10 + scripts/install/install.ps1 | 69 ++--- scripts/install/install.sh | 53 ++-- 9 files changed, 263 insertions(+), 207 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index faed568bcd09..2ae7025b9c4b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2202,6 +2202,7 @@ dependencies = [ name = "codex-install-context" version = "0.0.0" dependencies = [ + "codex-utils-home-dir", "pretty_assertions", "serde", "tempfile", diff --git a/codex-rs/README.md b/codex-rs/README.md index 6307668f3999..2ad7158f981e 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -1,6 +1,6 @@ # Codex CLI (Rust Implementation) -We provide Codex CLI as a standalone, native executable to ensure a zero-dependency install. +We provide Codex CLI as a standalone executable to ensure a zero-dependency install. ## Installing Codex diff --git a/codex-rs/install-context/Cargo.toml b/codex-rs/install-context/Cargo.toml index 6df75c26107a..d0d6adeca7e5 100644 --- a/codex-rs/install-context/Cargo.toml +++ b/codex-rs/install-context/Cargo.toml @@ -12,6 +12,7 @@ path = "src/lib.rs" workspace = true [dependencies] +codex-utils-home-dir = { workspace = true } serde = { workspace = true, features = ["derive"] } toml = { workspace = true } diff --git a/codex-rs/install-context/src/lib.rs b/codex-rs/install-context/src/lib.rs index 20e048e4575f..1c0c8f9614d0 100644 --- a/codex-rs/install-context/src/lib.rs +++ b/codex-rs/install-context/src/lib.rs @@ -2,34 +2,28 @@ use std::path::Path; use std::path::PathBuf; use std::sync::OnceLock; -use serde::Deserialize; - -const METADATA_FILENAME: &str = "metadata.toml"; +const RELEASES_DIRNAME: &str = "releases"; +const RESOURCES_DIRNAME: &str = "codex-resources"; +const STANDALONE_PACKAGES_DIRNAME: &str = "standalone"; static INSTALL_CONTEXT: OnceLock = OnceLock::new(); #[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum NativePlatform { +pub enum StandalonePlatform { Unix, Windows, } #[derive(Clone, Debug, Eq, PartialEq)] pub enum InstallContext { - Native { - /// The native release directory that contains `codex`, `rg`, and - /// `metadata.toml`, for example - /// `~/.codex/packages/native/releases/0.111.0-x86_64-unknown-linux-musl`. + Standalone { + /// The managed standalone release directory, for example + /// `~/.codex/packages/standalone/releases/0.111.0-x86_64-unknown-linux-musl`. release_dir: PathBuf, - /// The installed native Codex version, for example `0.111.0`. - version: String, - /// The target triple recorded in native metadata, for example - /// `x86_64-unknown-linux-musl` or `aarch64-apple-darwin`. - target: String, - /// The bundled ripgrep binary for this native release, for example - /// `~/.codex/packages/native/releases/.../rg`. - rg_command: PathBuf, - /// The platform of the native release, either `Unix` or `Windows`. - platform: NativePlatform, + /// The bundled resource directory that sits next to the executable when + /// this install ships managed dependencies. + resources_dir: Option, + /// The platform of the standalone release, either `Unix` or `Windows`. + platform: StandalonePlatform, }, /// A Codex binary launched through the npm-managed `codex.js` shim. Npm, @@ -50,6 +44,23 @@ impl InstallContext { current_exe: Option<&Path>, 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 { if managed_by_npm { return Self::Npm; @@ -60,9 +71,9 @@ impl InstallContext { } if let Some(exe_path) = current_exe - && let Some(native_context) = native_install_context(exe_path) + && let Some(standalone_context) = standalone_install_context(exe_path, codex_home) { - return native_context; + return standalone_context; } if is_macos @@ -91,53 +102,68 @@ impl InstallContext { pub fn rg_command(&self) -> PathBuf { match self { - Self::Native { rg_command, .. } => rg_command.clone(), - Self::Npm | Self::Bun | Self::Brew | Self::Other => default_rg_command(), + Self::Standalone { + resources_dir: Some(resources_dir), + platform, + .. + } => { + let rg_name = match platform { + StandalonePlatform::Unix => "rg", + StandalonePlatform::Windows => "rg.exe", + }; + let bundled_rg = resources_dir.join(rg_name); + if bundled_rg.exists() { + bundled_rg + } else { + default_rg_command() + } + } + Self::Standalone { + resources_dir: None, + .. + } + | Self::Npm + | Self::Bun + | Self::Brew + | Self::Other => default_rg_command(), } } } -#[derive(Debug, Deserialize, Eq, PartialEq)] -struct NativeInstallMetadata { - install_method: String, - version: String, - target: String, -} - -fn native_install_context(exe_path: &Path) -> Option { +fn standalone_install_context( + exe_path: &Path, + codex_home: Option<&Path>, +) -> Option { let canonical_exe = std::fs::canonicalize(exe_path).ok()?; let release_dir = canonical_exe.parent()?.to_path_buf(); - let metadata = parse_native_install_metadata(&release_dir.join(METADATA_FILENAME))?; - let platform = native_platform_from_target(&metadata.target); - let rg_name = match platform { - NativePlatform::Unix => "rg", - NativePlatform::Windows => "rg.exe", - }; - let rg_command = release_dir.join(rg_name); - - Some(InstallContext::Native { - platform, + if !is_managed_release_dir(&release_dir, codex_home?) { + return None; + } + + let resources_dir = release_dir.join(RESOURCES_DIRNAME); + Some(InstallContext::Standalone { release_dir, - version: metadata.version, - target: metadata.target, - rg_command, + resources_dir: resources_dir.is_dir().then_some(resources_dir), + platform: standalone_platform(), }) } -fn parse_native_install_metadata(path: &Path) -> Option { - let contents = std::fs::read_to_string(path).ok()?; - let metadata: NativeInstallMetadata = toml::from_str(&contents).ok()?; - if metadata.install_method != "native" { - return None; - } - Some(metadata) +fn is_managed_release_dir(release_dir: &Path, codex_home: &Path) -> bool { + release_dir.starts_with(releases_root(codex_home, STANDALONE_PACKAGES_DIRNAME)) } -fn native_platform_from_target(target: &str) -> NativePlatform { - if target.contains("-windows-") { - NativePlatform::Windows +fn releases_root(codex_home: &Path, package_dirname: &str) -> PathBuf { + codex_home + .join("packages") + .join(package_dirname) + .join(RELEASES_DIRNAME) +} + +fn standalone_platform() -> StandalonePlatform { + if cfg!(windows) { + StandalonePlatform::Windows } else { - NativePlatform::Unix + StandalonePlatform::Unix } } @@ -156,98 +182,87 @@ mod tests { use std::fs; #[test] - fn detects_native_install_from_adjacent_metadata() -> std::io::Result<()> { - let root = tempfile::tempdir()?; - let release_dir = root.path().join("1.2.3-x86_64-unknown-linux-musl"); - fs::create_dir(&release_dir)?; - fs::write( - release_dir.join("metadata.toml"), - "install_method = \"native\"\nversion = \"1.2.3\"\ntarget = \"x86_64-unknown-linux-musl\"\n", - )?; - let exe_name = if cfg!(windows) { "codex.exe" } else { "codex" }; - let rg_name = "rg"; - let exe_path = release_dir.join(exe_name); + fn detects_standalone_install_from_release_layout() -> std::io::Result<()> { + let codex_home = tempfile::tempdir()?; + let release_dir = codex_home + .path() + .join("packages/standalone/releases/1.2.3-x86_64-unknown-linux-musl"); + let resources_dir = release_dir.join(RESOURCES_DIRNAME); + fs::create_dir_all(&resources_dir)?; + let exe_path = release_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); fs::write(&exe_path, "")?; - fs::write(release_dir.join(rg_name), "")?; + fs::write(resources_dir.join("rg"), "")?; let canonical_release_dir = release_dir.canonicalize()?; + let canonical_resources_dir = resources_dir.canonicalize()?; - let context = InstallContext::from_exe(false, Some(&exe_path), false, false); - assert_eq!( - context, - InstallContext::Native { - platform: NativePlatform::Unix, - release_dir: canonical_release_dir.clone(), - version: "1.2.3".to_string(), - target: "x86_64-unknown-linux-musl".to_string(), - rg_command: canonical_release_dir.join(rg_name), - } + let context = InstallContext::from_exe_with_codex_home( + false, + Some(&exe_path), + false, + false, + Some(codex_home.path()), ); - Ok(()) - } - - #[test] - fn detects_windows_native_platform_from_target() -> std::io::Result<()> { - let root = tempfile::tempdir()?; - let release_dir = root.path().join("1.2.3-x86_64-pc-windows-msvc"); - fs::create_dir(&release_dir)?; - fs::write( - release_dir.join("metadata.toml"), - "install_method = \"native\"\nversion = \"1.2.3\"\ntarget = \"x86_64-pc-windows-msvc\"\n", - )?; - let exe_path = release_dir.join("codex"); - fs::write(&exe_path, "")?; - fs::write(release_dir.join("rg.exe"), "")?; - let canonical_release_dir = release_dir.canonicalize()?; - - let context = InstallContext::from_exe(false, Some(&exe_path), false, false); assert_eq!( context, - InstallContext::Native { - platform: NativePlatform::Windows, - release_dir: canonical_release_dir.clone(), - version: "1.2.3".to_string(), - target: "x86_64-pc-windows-msvc".to_string(), - rg_command: canonical_release_dir.join("rg.exe"), + InstallContext::Standalone { + release_dir: canonical_release_dir, + resources_dir: Some(canonical_resources_dir), + platform: StandalonePlatform::Unix, } ); Ok(()) } #[test] - fn native_metadata_rejects_non_native_install_method() -> std::io::Result<()> { - let root = tempfile::tempdir()?; - let release_dir = root.path().join("bad-release"); - fs::create_dir(&release_dir)?; - fs::write( - release_dir.join("metadata.toml"), - "install_method = \"npm\"\nversion = \"1.2.3\"\ntarget = \"x86_64-unknown-linux-musl\"\n", - )?; + fn standalone_rg_falls_back_when_resources_are_missing() -> std::io::Result<()> { + let codex_home = tempfile::tempdir()?; + let release_dir = codex_home + .path() + .join("packages/standalone/releases/1.2.3-x86_64-unknown-linux-musl"); + fs::create_dir_all(&release_dir)?; let exe_path = release_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); fs::write(&exe_path, "")?; - let context = InstallContext::from_exe(false, Some(&exe_path), false, false); - assert_eq!(context, InstallContext::Other); + let context = InstallContext::from_exe_with_codex_home( + false, + Some(&exe_path), + false, + false, + Some(codex_home.path()), + ); + assert_eq!(context.rg_command(), PathBuf::from("rg")); Ok(()) } #[test] fn npm_and_bun_take_precedence() { - let npm_context = - InstallContext::from_exe(false, Some(Path::new("/tmp/codex")), true, false); + let npm_context = InstallContext::from_exe_with_codex_home( + false, + Some(Path::new("/tmp/codex")), + true, + false, + None, + ); assert_eq!(npm_context, InstallContext::Npm); - let bun_context = - InstallContext::from_exe(false, Some(Path::new("/tmp/codex")), false, true); + let bun_context = InstallContext::from_exe_with_codex_home( + false, + Some(Path::new("/tmp/codex")), + false, + true, + None, + ); assert_eq!(bun_context, InstallContext::Bun); } #[test] fn brew_is_detected_on_macos_prefixes() { - let context = InstallContext::from_exe( + let context = InstallContext::from_exe_with_codex_home( true, Some(Path::new("/opt/homebrew/bin/codex")), false, false, + None, ); assert_eq!(context, InstallContext::Brew); } diff --git a/codex-rs/tui/src/update_action.rs b/codex-rs/tui/src/update_action.rs index d5220cde7cb8..44634b8317af 100644 --- a/codex-rs/tui/src/update_action.rs +++ b/codex-rs/tui/src/update_action.rs @@ -1,7 +1,7 @@ #[cfg(any(not(debug_assertions), test))] use codex_install_context::InstallContext; #[cfg(any(not(debug_assertions), test))] -use codex_install_context::NativePlatform; +use codex_install_context::StandalonePlatform; /// Update action the CLI should perform after the TUI exits. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -13,9 +13,9 @@ pub enum UpdateAction { /// Update via `brew upgrade codex`. BrewUpgrade, /// Update via `curl -fsSL https://chatgpt.com/codex/install.sh | sh`. - NativeUnix, + StandaloneUnix, /// Update via `irm https://chatgpt.com/codex/install.ps1|iex`. - NativeWindows, + StandaloneWindows, } impl UpdateAction { @@ -25,9 +25,9 @@ impl UpdateAction { InstallContext::Npm => Some(UpdateAction::NpmGlobalLatest), InstallContext::Bun => Some(UpdateAction::BunGlobalLatest), InstallContext::Brew => Some(UpdateAction::BrewUpgrade), - InstallContext::Native { platform, .. } => Some(match platform { - NativePlatform::Unix => UpdateAction::NativeUnix, - NativePlatform::Windows => UpdateAction::NativeWindows, + InstallContext::Standalone { platform, .. } => Some(match platform { + StandalonePlatform::Unix => UpdateAction::StandaloneUnix, + StandalonePlatform::Windows => UpdateAction::StandaloneWindows, }), InstallContext::Other => None, } @@ -39,11 +39,11 @@ impl UpdateAction { UpdateAction::NpmGlobalLatest => ("npm", &["install", "-g", "@openai/codex"]), UpdateAction::BunGlobalLatest => ("bun", &["install", "-g", "@openai/codex"]), UpdateAction::BrewUpgrade => ("brew", &["upgrade", "--cask", "codex"]), - UpdateAction::NativeUnix => ( + UpdateAction::StandaloneUnix => ( "sh", &["-c", "curl -fsSL https://chatgpt.com/codex/install.sh | sh"], ), - UpdateAction::NativeWindows => ( + UpdateAction::StandaloneWindows => ( "powershell", &["-c", "irm https://chatgpt.com/codex/install.ps1|iex"], ), @@ -90,24 +90,20 @@ mod tests { Some(UpdateAction::BrewUpgrade) ); assert_eq!( - UpdateAction::from_install_context(&InstallContext::Native { - platform: NativePlatform::Unix, + UpdateAction::from_install_context(&InstallContext::Standalone { + platform: StandalonePlatform::Unix, release_dir: native_release_dir.clone(), - version: "1.2.3".to_string(), - target: "x86_64-unknown-linux-musl".to_string(), - rg_command: native_release_dir.join("rg"), + resources_dir: Some(native_release_dir.join("codex-resources")), }), - Some(UpdateAction::NativeUnix) + Some(UpdateAction::StandaloneUnix) ); assert_eq!( - UpdateAction::from_install_context(&InstallContext::Native { - platform: NativePlatform::Windows, + UpdateAction::from_install_context(&InstallContext::Standalone { + platform: StandalonePlatform::Windows, release_dir: native_release_dir.clone(), - version: "1.2.3".to_string(), - target: "x86_64-pc-windows-msvc".to_string(), - rg_command: native_release_dir.join("rg.exe"), + resources_dir: Some(native_release_dir.join("codex-resources")), }), - Some(UpdateAction::NativeWindows) + Some(UpdateAction::StandaloneWindows) ); } } diff --git a/codex-rs/windows-sandbox-rs/src/helper_materialization.rs b/codex-rs/windows-sandbox-rs/src/helper_materialization.rs index 375199f5cca8..068cc4be686d 100644 --- a/codex-rs/windows-sandbox-rs/src/helper_materialization.rs +++ b/codex-rs/windows-sandbox-rs/src/helper_materialization.rs @@ -13,6 +13,8 @@ use tempfile::NamedTempFile; use crate::logging::log_note; use crate::sandbox_bin_dir; +const RESOURCES_DIRNAME: &str = "codex-resources"; + #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub(crate) enum HelperExecutable { CommandRunner, @@ -46,12 +48,9 @@ pub(crate) fn helper_bin_dir(codex_home: &Path) -> PathBuf { pub(crate) fn legacy_lookup(kind: HelperExecutable) -> PathBuf { if let Ok(exe) = std::env::current_exe() - && let Some(dir) = exe.parent() + && let Some(candidate) = source_path_for_exe(&exe, kind.file_name()) { - let candidate = dir.join(kind.file_name()); - if candidate.exists() { - return candidate; - } + return candidate; } PathBuf::from(kind.file_name()) } @@ -179,18 +178,23 @@ fn store_helper_path(cache_key: String, path: PathBuf) { fn sibling_source_path(kind: HelperExecutable) -> Result { let exe = std::env::current_exe().context("resolve current executable for helper lookup")?; - let dir = exe - .parent() - .ok_or_else(|| anyhow!("current executable has no parent directory"))?; - let candidate = dir.join(kind.file_name()); - if candidate.exists() { - Ok(candidate) - } else { - Err(anyhow!( - "helper not found next to current executable: {}", - candidate.display() - )) + source_path_for_exe(&exe, kind.file_name()).ok_or_else(|| { + anyhow!( + "helper not found next to current executable or under {RESOURCES_DIRNAME}: {}", + exe.display() + ) + }) +} + +fn source_path_for_exe(exe: &Path, file_name: &str) -> Option { + let dir = exe.parent()?; + let direct_candidate = dir.join(file_name); + if direct_candidate.exists() { + return Some(direct_candidate); } + + let resource_candidate = dir.join(RESOURCES_DIRNAME).join(file_name); + resource_candidate.exists().then_some(resource_candidate) } fn copy_from_source_if_needed(source: &Path, destination: &Path) -> Result { @@ -292,6 +296,7 @@ fn destination_is_fresh(source: &Path, destination: &Path) -> Result { #[cfg(test)] mod tests { + use super::source_path_for_exe; use super::destination_is_fresh; use super::helper_bin_dir; use super::copy_from_source_if_needed; @@ -376,4 +381,20 @@ mod tests { fs::read(&runner_destination).expect("read runner") ); } + + #[test] + fn helper_source_lookup_checks_resource_dir() { + let tmp = TempDir::new().expect("tempdir"); + let release_dir = tmp.path().join("release"); + let resources_dir = release_dir.join(RESOURCES_DIRNAME); + fs::create_dir_all(&resources_dir).expect("create resources dir"); + let exe = release_dir.join("codex.exe"); + let helper = resources_dir.join("codex-command-runner.exe"); + fs::write(&exe, b"codex").expect("write exe"); + fs::write(&helper, b"runner").expect("write helper"); + + let resolved = source_path_for_exe(&exe, "codex-command-runner.exe").expect("helper path"); + + assert_eq!(resolved, helper); + } } diff --git a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs index 99a11e631746..7a871d8a9dd9 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs @@ -577,6 +577,16 @@ fn find_setup_exe() -> PathBuf { if candidate.exists() { return candidate; } + + // Standalone installs keep Windows helper binaries under + // `codex-resources/` next to `codex.exe`, so elevation needs to probe + // that sibling folder before falling back to PATH. + let resource_candidate = dir + .join("codex-resources") + .join("codex-windows-sandbox-setup.exe"); + if resource_candidate.exists() { + return resource_candidate; + } } PathBuf::from("codex-windows-sandbox-setup.exe") } diff --git a/scripts/install/install.ps1 b/scripts/install/install.ps1 index 9f93caf64888..07b846dfbca2 100644 --- a/scripts/install/install.ps1 +++ b/scripts/install/install.ps1 @@ -99,20 +99,36 @@ function Resolve-Version { return (Normalize-Version -RawVersion $release.tag_name) } -function Read-MetadataValue { +function Get-VersionFromBinary { param( - [string]$MetadataPath, - [string]$Key + [string]$CodexPath ) - if (-not (Test-Path $MetadataPath)) { + if (-not (Test-Path -LiteralPath $CodexPath -PathType Leaf)) { return $null } - foreach ($line in Get-Content $MetadataPath) { - if ($line -match "^\s*$Key\s*=\s*""([^""]+)""") { - return $matches[1] - } + try { + $versionOutput = & $CodexPath --version 2>$null + } catch { + return $null + } + + if ($versionOutput -match '([0-9][0-9A-Za-z.+-]*)$') { + return $matches[1] + } + + return $null +} + +function Get-CurrentInstalledVersion { + param( + [string]$StandaloneCurrentDir + ) + + $standaloneVersion = Get-VersionFromBinary -CodexPath (Join-Path $StandaloneCurrentDir "codex.exe") + if (-not [string]::IsNullOrWhiteSpace($standaloneVersion)) { + return $standaloneVersion } return $null @@ -155,10 +171,9 @@ function Test-ReleaseIsComplete { $expectedFiles = @( "codex.exe", - "codex-command-runner.exe", - "codex-windows-sandbox-setup.exe", - "rg.exe", - "metadata.toml" + "codex-resources\codex-command-runner.exe", + "codex-resources\codex-windows-sandbox-setup.exe", + "codex-resources\rg.exe" ) foreach ($name in $expectedFiles) { if (-not (Test-Path -LiteralPath (Join-Path $ReleaseDir $name) -PathType Leaf)) { @@ -166,9 +181,7 @@ function Test-ReleaseIsComplete { } } - $version = Read-MetadataValue -MetadataPath (Join-Path $ReleaseDir "metadata.toml") -Key "version" - $target = Read-MetadataValue -MetadataPath (Join-Path $ReleaseDir "metadata.toml") -Key "target" - return $version -eq $ExpectedVersion -and $target -eq $ExpectedTarget + return (Split-Path -Leaf $ReleaseDir) -eq "$ExpectedVersion-$ExpectedTarget" } function Get-ExistingCodexCommand { @@ -231,7 +244,7 @@ function Maybe-HandleConflictingInstall { try { & $uninstallCommand @uninstallArgs } catch { - Write-WarningStep "Failed to uninstall the existing $manager-managed Codex. Continuing with the native install." + Write-WarningStep "Failed to uninstall the existing $manager-managed Codex. Continuing with the standalone install." } } else { Write-WarningStep "Leaving the existing $manager-managed Codex installed. PATH order will determine which codex runs." @@ -274,9 +287,9 @@ $codexHome = if ([string]::IsNullOrWhiteSpace($env:CODEX_HOME)) { } else { $env:CODEX_HOME } -$nativeRoot = Join-Path $codexHome "packages\native" -$releasesDir = Join-Path $nativeRoot "releases" -$currentDir = Join-Path $nativeRoot "current" +$standaloneRoot = Join-Path $codexHome "packages\standalone" +$releasesDir = Join-Path $standaloneRoot "releases" +$currentDir = Join-Path $standaloneRoot "current" if ([string]::IsNullOrWhiteSpace($env:CODEX_INSTALL_DIR)) { $visibleBinDir = Join-Path $env:LOCALAPPDATA "Programs\OpenAI\Codex\bin" @@ -284,7 +297,7 @@ if ([string]::IsNullOrWhiteSpace($env:CODEX_INSTALL_DIR)) { $visibleBinDir = $env:CODEX_INSTALL_DIR } -$currentVersion = Read-MetadataValue -MetadataPath (Join-Path $currentDir "metadata.toml") -Key "version" +$currentVersion = Get-CurrentInstalledVersion -StandaloneCurrentDir $currentDir $resolvedVersion = Resolve-Version $releaseName = "$resolvedVersion-$target" $releaseDir = Join-Path $releasesDir $releaseName @@ -325,28 +338,24 @@ try { tar -xzf $archivePath -C $extractDir $vendorRoot = Join-Path $extractDir "package/vendor/$target" + $resourcesDir = Join-Path $stagingDir "codex-resources" + New-Item -ItemType Directory -Force -Path $resourcesDir | Out-Null $copyMap = @{ "codex/codex.exe" = "codex.exe" - "codex/codex-command-runner.exe" = "codex-command-runner.exe" - "codex/codex-windows-sandbox-setup.exe" = "codex-windows-sandbox-setup.exe" - "path/rg.exe" = "rg.exe" + "codex/codex-command-runner.exe" = "codex-resources\codex-command-runner.exe" + "codex/codex-windows-sandbox-setup.exe" = "codex-resources\codex-windows-sandbox-setup.exe" + "path/rg.exe" = "codex-resources\rg.exe" } foreach ($relativeSource in $copyMap.Keys) { Copy-Item -LiteralPath (Join-Path $vendorRoot $relativeSource) -Destination (Join-Path $stagingDir $copyMap[$relativeSource]) } - @" -install_method = "native" -version = "$resolvedVersion" -target = "$target" -"@ | Set-Content -LiteralPath (Join-Path $stagingDir "metadata.toml") -NoNewline - New-Item -ItemType Directory -Force -Path $releasesDir | Out-Null Move-Item -LiteralPath $stagingDir -Destination $releaseDir } - New-Item -ItemType Directory -Force -Path $nativeRoot | Out-Null + New-Item -ItemType Directory -Force -Path $standaloneRoot | Out-Null Ensure-Junction -LinkPath $currentDir -TargetPath $releaseDir $visibleParent = Split-Path -Parent $visibleBinDir diff --git a/scripts/install/install.sh b/scripts/install/install.sh index c077baf2b5c4..cbea32fb70e6 100755 --- a/scripts/install/install.sh +++ b/scripts/install/install.sh @@ -7,9 +7,9 @@ RELEASE="latest" BIN_DIR="${CODEX_INSTALL_DIR:-$HOME/.local/bin}" BIN_PATH="$BIN_DIR/codex" CODEX_HOME_DIR="${CODEX_HOME:-$HOME/.codex}" -NATIVE_ROOT="$CODEX_HOME_DIR/packages/native" -RELEASES_DIR="$NATIVE_ROOT/releases" -CURRENT_LINK="$NATIVE_ROOT/current" +STANDALONE_ROOT="$CODEX_HOME_DIR/packages/standalone" +RELEASES_DIR="$STANDALONE_ROOT/releases" +CURRENT_LINK="$STANDALONE_ROOT/current" path_action="already" path_profile="" @@ -241,15 +241,24 @@ rewrite_path_block() { mv "$tmp_profile" "$profile" } -read_metadata_value() { - metadata_path="$1" - key="$2" +version_from_binary() { + codex_path="$1" - if [ ! -f "$metadata_path" ]; then + if [ ! -x "$codex_path" ]; then return 1 fi - sed -n "s/^${key}[[:space:]]*=[[:space:]]*\"\([^\"]*\)\"/\1/p" "$metadata_path" | head -n 1 + "$codex_path" --version 2>/dev/null | sed -n 's/.* \([0-9][0-9A-Za-z.+-]*\)$/\1/p' | head -n 1 +} + +current_installed_version() { + version="$(version_from_binary "$CURRENT_LINK/codex" || true)" + if [ -n "$version" ]; then + printf '%s\n' "$version" + return 0 + fi + + return 0 } resolve_existing_codex() { @@ -290,7 +299,7 @@ classify_existing_codex() { prompt_yes_no() { prompt="$1" - if [ -r /dev/tty ] && [ -w /dev/tty ]; then + if ( : /dev/null; then printf '%s [y/N] ' "$prompt" >/dev/tty if ! IFS= read -r answer "$stage_release/metadata.toml" < Date: Mon, 16 Mar 2026 21:59:57 +0000 Subject: [PATCH 11/23] fix: repair CI regressions in native installer branch Import the shared resources-dir constant into the windows sandbox test module and remove unused install-context dependencies so cargo shear passes again. Co-authored-by: Codex --- codex-rs/Cargo.lock | 2 -- codex-rs/install-context/Cargo.toml | 2 -- codex-rs/windows-sandbox-rs/src/helper_materialization.rs | 7 ++++--- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 2ae7025b9c4b..6bc649124431 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2204,9 +2204,7 @@ version = "0.0.0" dependencies = [ "codex-utils-home-dir", "pretty_assertions", - "serde", "tempfile", - "toml 0.9.11+spec-1.1.0", ] [[package]] diff --git a/codex-rs/install-context/Cargo.toml b/codex-rs/install-context/Cargo.toml index d0d6adeca7e5..ce4eeefe7763 100644 --- a/codex-rs/install-context/Cargo.toml +++ b/codex-rs/install-context/Cargo.toml @@ -13,8 +13,6 @@ workspace = true [dependencies] codex-utils-home-dir = { workspace = true } -serde = { workspace = true, features = ["derive"] } -toml = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/codex-rs/windows-sandbox-rs/src/helper_materialization.rs b/codex-rs/windows-sandbox-rs/src/helper_materialization.rs index 068cc4be686d..8b3b5990d325 100644 --- a/codex-rs/windows-sandbox-rs/src/helper_materialization.rs +++ b/codex-rs/windows-sandbox-rs/src/helper_materialization.rs @@ -296,11 +296,12 @@ fn destination_is_fresh(source: &Path, destination: &Path) -> Result { #[cfg(test)] mod tests { - use super::source_path_for_exe; - use super::destination_is_fresh; - use super::helper_bin_dir; use super::copy_from_source_if_needed; use super::CopyOutcome; + use super::destination_is_fresh; + use super::helper_bin_dir; + use super::RESOURCES_DIRNAME; + use super::source_path_for_exe; use pretty_assertions::assert_eq; use std::fs; use std::path::Path; From 14eee824491032c20847f470bad08071f38af2ea Mon Sep 17 00:00:00 2001 From: Edward Frazer Date: Tue, 7 Apr 2026 09:17:22 -0700 Subject: [PATCH 12/23] fix: canonicalize codex home for standalone detection --- codex-rs/install-context/src/lib.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/codex-rs/install-context/src/lib.rs b/codex-rs/install-context/src/lib.rs index 1c0c8f9614d0..11ed8c270af0 100644 --- a/codex-rs/install-context/src/lib.rs +++ b/codex-rs/install-context/src/lib.rs @@ -135,8 +135,9 @@ fn standalone_install_context( codex_home: Option<&Path>, ) -> Option { let canonical_exe = std::fs::canonicalize(exe_path).ok()?; + let canonical_codex_home = std::fs::canonicalize(codex_home?).ok()?; let release_dir = canonical_exe.parent()?.to_path_buf(); - if !is_managed_release_dir(&release_dir, codex_home?) { + if !is_managed_release_dir(&release_dir, &canonical_codex_home) { return None; } From a791658d93030992be812e1741636e7dcf3afabc Mon Sep 17 00:00:00 2001 From: Edward Frazer Date: Tue, 7 Apr 2026 09:58:49 -0700 Subject: [PATCH 13/23] codex: fix CI failure on PR #17022 --- codex-rs/install-context/src/lib.rs | 50 ++++++++++++++--------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/codex-rs/install-context/src/lib.rs b/codex-rs/install-context/src/lib.rs index 11ed8c270af0..4d6638a53333 100644 --- a/codex-rs/install-context/src/lib.rs +++ b/codex-rs/install-context/src/lib.rs @@ -197,11 +197,11 @@ mod tests { let canonical_resources_dir = resources_dir.canonicalize()?; let context = InstallContext::from_exe_with_codex_home( - false, - Some(&exe_path), - false, - false, - Some(codex_home.path()), + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*managed_by_npm*/ false, + /*managed_by_bun*/ false, + /*codex_home*/ Some(codex_home.path()), ); assert_eq!( context, @@ -225,11 +225,11 @@ mod tests { fs::write(&exe_path, "")?; let context = InstallContext::from_exe_with_codex_home( - false, - Some(&exe_path), - false, - false, - Some(codex_home.path()), + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*managed_by_npm*/ false, + /*managed_by_bun*/ false, + /*codex_home*/ Some(codex_home.path()), ); assert_eq!(context.rg_command(), PathBuf::from("rg")); Ok(()) @@ -238,20 +238,20 @@ mod tests { #[test] fn npm_and_bun_take_precedence() { let npm_context = InstallContext::from_exe_with_codex_home( - false, - Some(Path::new("/tmp/codex")), - true, - false, - None, + /*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, InstallContext::Npm); let bun_context = InstallContext::from_exe_with_codex_home( - false, - Some(Path::new("/tmp/codex")), - false, - true, - None, + /*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, InstallContext::Bun); } @@ -259,11 +259,11 @@ mod tests { #[test] fn brew_is_detected_on_macos_prefixes() { let context = InstallContext::from_exe_with_codex_home( - true, - Some(Path::new("/opt/homebrew/bin/codex")), - false, - false, - None, + /*is_macos*/ true, + /*current_exe*/ Some(Path::new("/opt/homebrew/bin/codex")), + /*managed_by_npm*/ false, + /*managed_by_bun*/ false, + /*codex_home*/ None, ); assert_eq!(context, InstallContext::Brew); } From 71d331a433adada2544d609e485fdedd96fef3ea Mon Sep 17 00:00:00 2001 From: Edward Frazer Date: Tue, 7 Apr 2026 11:02:14 -0700 Subject: [PATCH 14/23] fix: address native installer review comments --- codex-rs/install-context/src/lib.rs | 13 ++++------- .../src/helper_materialization.rs | 22 ++++++++++++++++++- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/codex-rs/install-context/src/lib.rs b/codex-rs/install-context/src/lib.rs index 4d6638a53333..afef7a6f0d07 100644 --- a/codex-rs/install-context/src/lib.rs +++ b/codex-rs/install-context/src/lib.rs @@ -104,14 +104,9 @@ impl InstallContext { match self { Self::Standalone { resources_dir: Some(resources_dir), - platform, .. } => { - let rg_name = match platform { - StandalonePlatform::Unix => "rg", - StandalonePlatform::Windows => "rg.exe", - }; - let bundled_rg = resources_dir.join(rg_name); + let bundled_rg = resources_dir.join(default_rg_command()); if bundled_rg.exists() { bundled_rg } else { @@ -192,7 +187,7 @@ mod tests { fs::create_dir_all(&resources_dir)?; let exe_path = release_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); fs::write(&exe_path, "")?; - fs::write(resources_dir.join("rg"), "")?; + fs::write(resources_dir.join(default_rg_command()), "")?; let canonical_release_dir = release_dir.canonicalize()?; let canonical_resources_dir = resources_dir.canonicalize()?; @@ -208,7 +203,7 @@ mod tests { InstallContext::Standalone { release_dir: canonical_release_dir, resources_dir: Some(canonical_resources_dir), - platform: StandalonePlatform::Unix, + platform: standalone_platform(), } ); Ok(()) @@ -231,7 +226,7 @@ mod tests { /*managed_by_bun*/ false, /*codex_home*/ Some(codex_home.path()), ); - assert_eq!(context.rg_command(), PathBuf::from("rg")); + assert_eq!(context.rg_command(), default_rg_command()); Ok(()) } diff --git a/codex-rs/windows-sandbox-rs/src/helper_materialization.rs b/codex-rs/windows-sandbox-rs/src/helper_materialization.rs index 8b3b5990d325..ad5cc4e2b2f5 100644 --- a/codex-rs/windows-sandbox-rs/src/helper_materialization.rs +++ b/codex-rs/windows-sandbox-rs/src/helper_materialization.rs @@ -394,8 +394,28 @@ mod tests { fs::write(&exe, b"codex").expect("write exe"); fs::write(&helper, b"runner").expect("write helper"); - let resolved = source_path_for_exe(&exe, "codex-command-runner.exe").expect("helper path"); + let resolved = + source_path_for_exe(&exe, /*file_name*/ "codex-command-runner.exe").expect("helper path"); assert_eq!(resolved, helper); } + + #[test] + fn helper_source_lookup_prefers_direct_sibling_over_resource_dir() { + let tmp = TempDir::new().expect("tempdir"); + let release_dir = tmp.path().join("release"); + let resources_dir = release_dir.join(RESOURCES_DIRNAME); + fs::create_dir_all(&resources_dir).expect("create resources dir"); + let exe = release_dir.join("codex.exe"); + let sibling_helper = release_dir.join("codex-command-runner.exe"); + let resource_helper = resources_dir.join("codex-command-runner.exe"); + fs::write(&exe, b"codex").expect("write exe"); + fs::write(&sibling_helper, b"sibling runner").expect("write sibling helper"); + fs::write(&resource_helper, b"resource runner").expect("write resource helper"); + + let resolved = + source_path_for_exe(&exe, /*file_name*/ "codex-command-runner.exe").expect("helper path"); + + assert_eq!(resolved, sibling_helper); + } } From 319edbad8b70b5fb3cf6211606a7695dfe6b9d1e Mon Sep 17 00:00:00 2001 From: Edward Frazer Date: Tue, 7 Apr 2026 11:14:12 -0700 Subject: [PATCH 15/23] fix: update unified exec test path join --- codex-rs/core/tests/suite/unified_exec.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 226c5c6af559..a165db0451d2 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -171,7 +171,7 @@ async fn create_workspace_directory( test: &TestCodex, rel_path: impl AsRef, ) -> Result { - let abs_path = test.config.cwd.join(rel_path.as_ref())?; + let abs_path = test.config.cwd.join(rel_path.as_ref()); test.fs() .create_directory(&abs_path, CreateDirectoryOptions { recursive: true }) .await?; From a3872bee48d55c1bf5f55c82f96a4497806abf38 Mon Sep 17 00:00:00 2001 From: Edward Frazer Date: Tue, 7 Apr 2026 13:51:06 -0700 Subject: [PATCH 16/23] refactor: inline standalone release-root check --- codex-rs/install-context/src/lib.rs | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/codex-rs/install-context/src/lib.rs b/codex-rs/install-context/src/lib.rs index afef7a6f0d07..980fc3f546a0 100644 --- a/codex-rs/install-context/src/lib.rs +++ b/codex-rs/install-context/src/lib.rs @@ -132,7 +132,11 @@ fn standalone_install_context( let canonical_exe = std::fs::canonicalize(exe_path).ok()?; let canonical_codex_home = std::fs::canonicalize(codex_home?).ok()?; let release_dir = canonical_exe.parent()?.to_path_buf(); - if !is_managed_release_dir(&release_dir, &canonical_codex_home) { + let releases_root = canonical_codex_home + .join("packages") + .join(STANDALONE_PACKAGES_DIRNAME) + .join(RELEASES_DIRNAME); + if !release_dir.starts_with(releases_root) { return None; } @@ -144,17 +148,6 @@ fn standalone_install_context( }) } -fn is_managed_release_dir(release_dir: &Path, codex_home: &Path) -> bool { - release_dir.starts_with(releases_root(codex_home, STANDALONE_PACKAGES_DIRNAME)) -} - -fn releases_root(codex_home: &Path, package_dirname: &str) -> PathBuf { - codex_home - .join("packages") - .join(package_dirname) - .join(RELEASES_DIRNAME) -} - fn standalone_platform() -> StandalonePlatform { if cfg!(windows) { StandalonePlatform::Windows From 7b28752e5583115c992a0cf333a8e51f99185821 Mon Sep 17 00:00:00 2001 From: Edward Frazer Date: Tue, 7 Apr 2026 13:51:15 -0700 Subject: [PATCH 17/23] fix: run standalone windows updater without cmd --- codex-rs/cli/src/main.rs | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 24dd558ea700..b95479c51bff 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -490,10 +490,24 @@ fn run_update_action(action: UpdateAction) -> anyhow::Result<()> { let status = { #[cfg(windows)] { - // On Windows, run via cmd.exe so .CMD/.BAT are correctly resolved (PATHEXT semantics). - std::process::Command::new("cmd") - .args(["/C", &cmd_str]) - .status()? + match action { + UpdateAction::StandaloneWindows => { + let (cmd, args) = action.command_args(); + // Run PowerShell directly. If this goes through `cmd.exe`, + // the `|iex` in the installer script is parsed as a cmd + // pipeline instead of as part of the PowerShell command. + std::process::Command::new(cmd).args(args).status()? + } + UpdateAction::NpmGlobalLatest + | UpdateAction::BunGlobalLatest + | UpdateAction::BrewUpgrade + | UpdateAction::StandaloneUnix => { + // On Windows, run via cmd.exe so .CMD/.BAT are correctly resolved (PATHEXT semantics). + std::process::Command::new("cmd") + .args(["/C", &cmd_str]) + .status()? + } + } } #[cfg(not(windows))] { From 19bcbd488a00ad071698d67efa657ec3ebed147d Mon Sep 17 00:00:00 2001 From: Edward Frazer Date: Tue, 14 Apr 2026 17:36:05 -0700 Subject: [PATCH 18/23] fix: harden standalone installer activation and updates --- codex-rs/cli/src/main.rs | 7 +- codex-rs/tui/src/update_action.rs | 18 ++ docs/standalone-installer-hardening-plan.md | 307 ++++++++++++++++++++ scripts/install/install.ps1 | 292 ++++++++++++++++--- scripts/install/install.sh | 192 ++++++++++-- 5 files changed, 748 insertions(+), 68 deletions(-) create mode 100644 docs/standalone-installer-hardening-plan.md diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index b95479c51bff..8cfd14ba89c2 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -493,9 +493,10 @@ fn run_update_action(action: UpdateAction) -> anyhow::Result<()> { match action { UpdateAction::StandaloneWindows => { let (cmd, args) = action.command_args(); - // Run PowerShell directly. If this goes through `cmd.exe`, - // the `|iex` in the installer script is parsed as a cmd - // pipeline instead of as part of the PowerShell command. + // Run the standalone PowerShell installer with PowerShell + // itself. Routing this through `cmd.exe /C` would parse + // PowerShell metacharacters like `|` before PowerShell sees + // the installer command. std::process::Command::new(cmd).args(args).status()? } UpdateAction::NpmGlobalLatest diff --git a/codex-rs/tui/src/update_action.rs b/codex-rs/tui/src/update_action.rs index 44634b8317af..1ac2ff675fb6 100644 --- a/codex-rs/tui/src/update_action.rs +++ b/codex-rs/tui/src/update_action.rs @@ -106,4 +106,22 @@ mod tests { Some(UpdateAction::StandaloneWindows) ); } + + #[test] + fn standalone_update_commands_rerun_latest_installer() { + assert_eq!( + UpdateAction::StandaloneUnix.command_args(), + ( + "sh", + &["-c", "curl -fsSL https://chatgpt.com/codex/install.sh | sh"][..], + ) + ); + assert_eq!( + UpdateAction::StandaloneWindows.command_args(), + ( + "powershell", + &["-c", "irm https://chatgpt.com/codex/install.ps1|iex"][..], + ) + ); + } } diff --git a/docs/standalone-installer-hardening-plan.md b/docs/standalone-installer-hardening-plan.md new file mode 100644 index 000000000000..c33de40e88f3 --- /dev/null +++ b/docs/standalone-installer-hardening-plan.md @@ -0,0 +1,307 @@ +# Standalone Installer Hardening Plan + +This plan addresses the review feedback on the standalone installer rework. + +The installer is moving Codex from a flat executable layout into a managed +release layout: + +```text +$CODEX_HOME/ + packages/ + standalone/ + current -> releases/- + releases/ + -/ + codex + codex-resources/ + rg +``` + +That shape is still the right direction. The problems are in the transition +and activation mechanics. A standalone installer must make the new release +ready first, then switch users over in one safe step, and it must not destroy a +working install if anything fails before that switch. + +## Goals + +- A fresh install works on macOS, Linux, and Windows. +- A rerun of the same version is safe and idempotent. +- An upgrade switches from the old release to the new release atomically where + the platform allows it. +- Existing users of the old standalone layout migrate without deleting files by + hand. +- Concurrent installer runs cannot delete or half-activate each other's work. +- The visible `codex` command works before we offer to remove npm, bun, or + brew installs. +- The TUI update path preserves the install choices that matter to future + updates. + +## Current Review Findings + +### 1. Unix `current` symlink replacement is broken on macOS + +`install.sh` creates a temporary symlink and then runs: + +```sh +mv -f "$tmp_link" "$CURRENT_LINK" +``` + +On macOS/BSD `mv`, when `CURRENT_LINK` is a symlink to a directory, this can +move the temporary symlink into the existing target directory instead of +replacing `CURRENT_LINK`. + +The bad result looks like this: + +```text +standalone/current -> releases/old-version +standalone/releases/old-version/.current. -> releases/new-version +``` + +The user keeps running the old release. + +Fix: + +- Add `replace_symlink` in `install.sh`. +- Use `mv -T` when available. +- Use `mv -h` on macOS/BSD. +- If neither exists, remove the old symlink and rename the new symlink while + holding the installer lock. + +The fallback is not fully atomic, but the lock makes it safe from another +installer process. The normal Linux and macOS paths stay atomic. + +### 2. Windows migration from the old standalone layout fails + +The old Windows installer wrote real files into: + +```text +%LOCALAPPDATA%\Programs\OpenAI\Codex\bin + codex.exe + rg.exe + codex-command-runner.exe + codex-windows-sandbox-setup.exe +``` + +The PR now tries to replace that `bin` directory with a junction. The helper +`Ensure-Junction` refuses to replace a non-empty directory, so existing users +can hit a hard failure. + +Fix: + +- Detect only the known old standalone `bin` layout at the default Windows + install path. +- Ask the user before replacing that old layout. +- Move the old `bin` directory aside before creating the new junction. +- Delete the backup only after the new visible `codex.exe --version` check + passes. +- Keep refusing unknown non-empty directories. + +This gives existing standalone users a migration path without teaching the +installer to replace arbitrary user directories. + +### 3. Concurrent installs can corrupt activation + +Two installer processes can both decide a release is incomplete, both download +it, and both manipulate the same `release_dir`, `current`, and visible +command. + +The risky Unix sequence is: + +```sh +if [ -e "$release_dir" ] || [ -L "$release_dir" ]; then + rm -rf "$release_dir" +fi +... +mv "$stage_release" "$release_dir" +``` + +Fix: + +- Add one installer lock per standalone root. +- Hold the lock across: + - release completeness check + - staging + - final release rename + - `current` update + - visible command update + - install metadata write +- On Unix, prefer `flock` when available. +- On macOS without `flock`, use an atomic `mkdir "$lock_dir"` lock with a + trap cleanup. +- On Windows, use a named mutex or an exclusive lock file opened with no share + mode. + +The lock should live under: + +```text +$CODEX_HOME/packages/standalone/install.lock +``` + +### 4. Unix staging happens under `/tmp` + +The Unix installer currently stages under `mktemp -d`, then moves the staged +release into `$RELEASES_DIR`. + +If `/tmp` and `CODEX_HOME` are on different filesystems, the final move is a +copy plus delete. That is not the atomic activation model the installer claims. + +Fix: + +- Download and extract into a temp directory as today. +- Stage the final release directory under `$RELEASES_DIR`. +- Use a staging path like: + +```text +$RELEASES_DIR/.staging.. +``` + +- Rename the staging directory to the final release directory on the same + filesystem. + +The archive can still download into `/tmp`. The release directory that becomes +active must be staged beside the final destination. + +### 5. Conflicting installs are removed too early + +The installer currently offers to uninstall npm, bun, or brew Codex before the +standalone install has succeeded. + +That ordering can remove a working `codex`, then fail during download, +extraction, activation, or PATH setup. + +Fix: + +1. Detect the conflicting manager-owned install early, but do not uninstall it. +2. Download, stage, and activate standalone. +3. Verify the visible `codex` command works: + +```sh +"$BIN_PATH" --version +``` + +4. Only then ask whether to uninstall the old npm, bun, or brew install. + +If the uninstall fails, keep the standalone install and print a warning about +PATH order. + +### 6. No checksum verification + +The installer downloads GitHub release tarballs over TLS and extracts them +directly. TLS protects transport, but the installer does not verify that the +archive matches an expected release digest. + +Fix: + +- Use GitHub's release asset `digest` field for the exact installer tarball. +- Verify the archive digest before extraction. +- Fail closed if the digest is missing or does not match. + +GitHub already exposes SHA-256 digests for release assets, including +`codex-npm--.tgz`. A separate OpenAI-authored manifest can +still be a later supply-chain hardening step, but it is not required for basic +archive verification. + +### 7. TUI update keeps a simple latest-install path + +Standalone TUI updates rerun the generic installer command: + +```sh +curl -fsSL https://chatgpt.com/codex/install.sh | sh +``` + +The installer accepts `CODEX_HOME` and `CODEX_INSTALL_DIR` as environment +overrides, but those are escape hatches rather than durable product +preferences. Recording them as installer state would make the updater replay +values that may only have been set for one shell session. + +Fix: + +- Keep runtime install detection based on `current_exe()`. +- Keep the TUI update command as a latest standalone installer rerun. +- Do not add an installer state file until there are real user-facing install + preferences, such as a channel or update policy. + +## Implementation Order + +1. Add installer locks on Unix and Windows. +2. Move Unix release staging under `$RELEASES_DIR`. +3. Fix Unix symlink replacement. +4. Fix Windows visible `bin` migration by keeping `bin` as a real directory. +5. Move conflicting-install uninstall after standalone activation and + verification. +6. Add install metadata and use it from the TUI update action. +7. Add checksum verification using GitHub's release asset digest. + +The first five should land before this PR is considered safe to merge. The last +two can land in the same PR if the patch stays manageable; otherwise they should +be tracked as immediate follow-ups. + +## Test Plan + +### Unix tests + +- Fresh install into isolated `HOME`, `CODEX_HOME`, and `CODEX_INSTALL_DIR`. +- Same-version rerun. +- Upgrade from fake old release to fake new release. +- macOS symlink replacement fixture: + - create `current -> old-release` + - update to `new-release` + - assert `current` points to `new-release` + - assert no `.current.*` file appears inside `old-release` +- Staging filesystem check: + - assert final staging dir is created under `$RELEASES_DIR` + - assert final activation uses rename from sibling staging dir +- Concurrent install smoke: + - start two same-version installs against the same `CODEX_HOME` + - assert both exit successfully + - assert `current/codex --version` works + - assert the release dir is complete + +### Windows tests + +- Fresh install into isolated `CODEX_HOME` and `CODEX_INSTALL_DIR`. +- Same-version rerun against the same `CODEX_HOME` and `CODEX_INSTALL_DIR`. +- Migration from old standalone layout: + - create a real non-empty visible `bin` directory + - include `codex.exe`, `rg.exe`, and helper binary names + - run installer in non-interactive mode + - assert installer asks for confirmation and fails closed + - assert old files are preserved +- Unknown non-empty visible directory: + - create a real directory with an extra user file + - assert installer refuses to replace it + - assert user file is preserved +- Same-version rerun. +- Concurrent install smoke with two PowerShell installer processes. +- TUI standalone update command on Windows: + - assert PowerShell is called directly + - assert `|iex` is passed to PowerShell, not `cmd /C` + +### Failure-ordering tests + +- Simulate a conflicting npm/bun/brew install. +- Force standalone download or extraction failure. +- Assert the old manager-owned install is not removed. +- Force standalone success and visible command verification success. +- Assert the uninstall prompt happens after verification. + +### Manual smoke tests + +- macOS fresh install and rerun. +- macOS upgrade between two locally served test releases. +- Linux fresh install and rerun. +- Windows fresh install. +- Windows migration from the old visible `bin` layout. + +## Acceptance Criteria + +- The installer never removes a working manager-owned Codex before standalone + has been activated and verified. +- The Unix `current` link is replaced correctly on macOS and Linux. +- Release activation uses same-filesystem staging. +- Concurrent same-version installs leave a complete release and working visible + command. +- Existing Windows standalone users do not need to delete their old install + directory manually. +- TUI standalone update preserves the install directory choices from the + original standalone install. diff --git a/scripts/install/install.ps1 b/scripts/install/install.ps1 index 07b846dfbca2..05cfdad8a496 100644 --- a/scripts/install/install.ps1 +++ b/scripts/install/install.ps1 @@ -55,13 +55,39 @@ function Normalize-Version { return $RawVersion } -function Get-ReleaseUrl { +function Get-ReleaseAssetMetadata { param( [string]$AssetName, [string]$ResolvedVersion ) - return "https://github.com/openai/codex/releases/download/rust-v$ResolvedVersion/$AssetName" + $release = Invoke-RestMethod -Uri "https://api.github.com/repos/openai/codex/releases/tags/rust-v$ResolvedVersion" + $asset = $release.assets | Where-Object { $_.name -eq $AssetName } | Select-Object -First 1 + if ($null -eq $asset) { + throw "Could not find release asset $AssetName for Codex $ResolvedVersion." + } + + $digestMatch = [regex]::Match([string]$asset.digest, "^sha256:([0-9a-fA-F]{64})$") + if (-not $digestMatch.Success) { + throw "Could not find SHA-256 digest for release asset $AssetName." + } + + return [PSCustomObject]@{ + Url = $asset.browser_download_url + Sha256 = $digestMatch.Groups[1].Value.ToLowerInvariant() + } +} + +function Test-ArchiveDigest { + param( + [string]$ArchivePath, + [string]$ExpectedDigest + ) + + $actualDigest = (Get-FileHash -LiteralPath $ArchivePath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actualDigest -ne $ExpectedDigest) { + throw "Downloaded Codex archive checksum did not match release metadata. Expected $ExpectedDigest but got $actualDigest." + } } function Path-Contains { @@ -84,6 +110,33 @@ function Path-Contains { return $false } +function Invoke-WithInstallLock { + param( + [string]$LockPath, + [scriptblock]$Script + ) + + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $LockPath) | Out-Null + $lock = $null + while ($null -eq $lock) { + try { + $lock = [System.IO.File]::Open( + $LockPath, + [System.IO.FileMode]::OpenOrCreate, + [System.IO.FileAccess]::ReadWrite, + [System.IO.FileShare]::None + ) + } catch [System.IO.IOException] { + Start-Sleep -Milliseconds 250 + } + } + try { + & $Script + } finally { + $lock.Dispose() + } +} + function Resolve-Version { $normalizedVersion = Normalize-Version -RawVersion $Release if ($normalizedVersion -ne "latest") { @@ -134,28 +187,128 @@ function Get-CurrentInstalledVersion { return $null } +function Test-OldStandaloneBinLayout { + param( + [string]$VisibleBinDir, + [string]$DefaultVisibleBinDir + ) + + if (-not $VisibleBinDir.Equals($DefaultVisibleBinDir, [System.StringComparison]::OrdinalIgnoreCase)) { + return $false + } + if (-not (Test-Path -LiteralPath $VisibleBinDir -PathType Container)) { + return $false + } + + $item = Get-Item -LiteralPath $VisibleBinDir -Force + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + return $false + } + + $requiredFiles = @("codex.exe", "rg.exe") + foreach ($fileName in $requiredFiles) { + if (-not (Test-Path -LiteralPath (Join-Path $VisibleBinDir $fileName) -PathType Leaf)) { + return $false + } + } + + $knownFiles = @( + "codex.exe", + "rg.exe", + "codex-command-runner.exe", + "codex-windows-sandbox.exe", + "codex-windows-sandbox-setup.exe" + ) + foreach ($child in Get-ChildItem -LiteralPath $VisibleBinDir -Force) { + if ($child.PSIsContainer) { + return $false + } + if ($knownFiles -notcontains $child.Name) { + return $false + } + } + + return $true +} + +function Move-OldStandaloneBinIfApproved { + param( + [string]$VisibleBinDir, + [string]$DefaultVisibleBinDir + ) + + if (-not (Test-OldStandaloneBinLayout -VisibleBinDir $VisibleBinDir -DefaultVisibleBinDir $DefaultVisibleBinDir)) { + return $null + } + + Write-Step "We found an older Codex install at $VisibleBinDir" + Write-WarningStep "To continue, Codex needs to update the install at this path." + if (-not (Prompt-YesNo "Replace it with the current Codex setup now?")) { + throw "Cannot replace older standalone install without confirmation: $VisibleBinDir" + } + + $backupDir = "$VisibleBinDir.backup.$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds()).$PID" + Write-Step "Moving older standalone install to $backupDir" + Move-Item -LiteralPath $VisibleBinDir -Destination $backupDir + return $backupDir +} + function Ensure-Junction { param( [string]$LinkPath, [string]$TargetPath ) + $swapId = [System.Guid]::NewGuid().ToString("N") + $pendingPath = "$LinkPath.pending.$swapId" + $backupPath = "$LinkPath.backup.$swapId" + + if (Test-Path -LiteralPath $pendingPath) { + [System.IO.Directory]::Delete($pendingPath) + } + if (Test-Path -LiteralPath $backupPath) { + [System.IO.Directory]::Delete($backupPath) + } + + New-Item -ItemType Junction -Path $pendingPath -Target $TargetPath | Out-Null + if (Test-Path -LiteralPath $LinkPath) { $item = Get-Item -LiteralPath $LinkPath -Force if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { - Remove-Item -LiteralPath $LinkPath -Force + $existingTarget = [string]$item.Target + if ($existingTarget.Equals($TargetPath, [System.StringComparison]::OrdinalIgnoreCase)) { + [System.IO.Directory]::Delete($pendingPath) + return + } + + try { + Move-Item -LiteralPath $LinkPath -Destination $backupPath + Move-Item -LiteralPath $pendingPath -Destination $LinkPath + [System.IO.Directory]::Delete($backupPath) + } catch { + if ((-not (Test-Path -LiteralPath $LinkPath)) -and (Test-Path -LiteralPath $backupPath)) { + Move-Item -LiteralPath $backupPath -Destination $LinkPath + } + if (Test-Path -LiteralPath $pendingPath) { + [System.IO.Directory]::Delete($pendingPath) + } + throw + } } elseif ($item.PSIsContainer) { if ((Get-ChildItem -LiteralPath $LinkPath -Force | Select-Object -First 1) -ne $null) { + [System.IO.Directory]::Delete($pendingPath) throw "Refusing to replace non-empty directory at $LinkPath with a junction." } Remove-Item -LiteralPath $LinkPath -Force + Move-Item -LiteralPath $pendingPath -Destination $LinkPath } else { + [System.IO.Directory]::Delete($pendingPath) throw "Refusing to replace file at $LinkPath with a junction." } + } else { + Move-Item -LiteralPath $pendingPath -Destination $LinkPath } - - New-Item -ItemType Junction -Path $LinkPath -Target $TargetPath | Out-Null } function Test-ReleaseIsComplete { @@ -218,7 +371,7 @@ function Get-ExistingCodexManager { return $null } -function Maybe-HandleConflictingInstall { +function Get-ConflictingInstall { param( [string]$VisibleBinDir ) @@ -226,12 +379,29 @@ function Maybe-HandleConflictingInstall { $existingPath = Get-ExistingCodexCommand $manager = Get-ExistingCodexManager -ExistingPath $existingPath -VisibleBinDir $VisibleBinDir if ($null -eq $manager) { - return + return $null } Write-Step "Detected existing $manager-managed Codex at $existingPath" Write-WarningStep "Multiple managed Codex installs can be ambiguous because PATH order decides which one runs." + return [PSCustomObject]@{ + Manager = $manager + Path = $existingPath + } +} + +function Maybe-HandleConflictingInstall { + param( + [object]$Conflict + ) + + if ($null -eq $Conflict) { + return + } + + $manager = $Conflict.Manager + $uninstallArgs = if ($manager -eq "bun") { @("remove", "-g", "@openai/codex") } else { @@ -251,6 +421,18 @@ function Maybe-HandleConflictingInstall { } } +function Test-VisibleCodexCommand { + param( + [string]$VisibleBinDir + ) + + $codexCommand = Join-Path $VisibleBinDir "codex.exe" + & $codexCommand --version *> $null + if ($LASTEXITCODE -ne 0) { + throw "Installed Codex command failed verification: $codexCommand --version" + } +} + if ($env:OS -ne "Windows_NT") { Write-Error "install.ps1 supports Windows only. Use install.sh on macOS or Linux." exit 1 @@ -290,9 +472,11 @@ $codexHome = if ([string]::IsNullOrWhiteSpace($env:CODEX_HOME)) { $standaloneRoot = Join-Path $codexHome "packages\standalone" $releasesDir = Join-Path $standaloneRoot "releases" $currentDir = Join-Path $standaloneRoot "current" +$lockPath = Join-Path $standaloneRoot "install.lock" +$defaultVisibleBinDir = Join-Path $env:LOCALAPPDATA "Programs\OpenAI\Codex\bin" if ([string]::IsNullOrWhiteSpace($env:CODEX_INSTALL_DIR)) { - $visibleBinDir = Join-Path $env:LOCALAPPDATA "Programs\OpenAI\Codex\bin" + $visibleBinDir = $defaultVisibleBinDir } else { $visibleBinDir = $env:CODEX_INSTALL_DIR } @@ -312,59 +496,75 @@ if (-not [string]::IsNullOrWhiteSpace($currentVersion) -and $currentVersion -ne Write-Step "Detected platform: $platformLabel" Write-Step "Resolved version: $resolvedVersion" -Maybe-HandleConflictingInstall -VisibleBinDir $visibleBinDir +$conflictingInstall = Get-ConflictingInstall -VisibleBinDir $visibleBinDir +$oldStandaloneBackup = $null $packageAsset = "codex-npm-$npmTag-$resolvedVersion.tgz" $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("codex-install-" + [System.Guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Force -Path $tempDir | Out-Null try { - if (-not (Test-ReleaseIsComplete -ReleaseDir $releaseDir -ExpectedVersion $resolvedVersion -ExpectedTarget $target)) { - if (Test-Path -LiteralPath $releaseDir) { - Write-WarningStep "Found incomplete existing release at $releaseDir. Reinstalling." - Remove-Item -LiteralPath $releaseDir -Recurse -Force - } + Invoke-WithInstallLock -LockPath $lockPath -Script { + if (-not (Test-ReleaseIsComplete -ReleaseDir $releaseDir -ExpectedVersion $resolvedVersion -ExpectedTarget $target)) { + if (Test-Path -LiteralPath $releaseDir) { + Write-WarningStep "Found incomplete existing release at $releaseDir. Reinstalling." + } - $archivePath = Join-Path $tempDir $packageAsset - $extractDir = Join-Path $tempDir "extract" - $stagingDir = Join-Path $tempDir "release" - $url = Get-ReleaseUrl -AssetName $packageAsset -ResolvedVersion $resolvedVersion - - Write-Step "Downloading Codex CLI" - Invoke-WebRequest -Uri $url -OutFile $archivePath - - New-Item -ItemType Directory -Force -Path $extractDir | Out-Null - New-Item -ItemType Directory -Force -Path $stagingDir | Out-Null - tar -xzf $archivePath -C $extractDir - - $vendorRoot = Join-Path $extractDir "package/vendor/$target" - $resourcesDir = Join-Path $stagingDir "codex-resources" - New-Item -ItemType Directory -Force -Path $resourcesDir | Out-Null - $copyMap = @{ - "codex/codex.exe" = "codex.exe" - "codex/codex-command-runner.exe" = "codex-resources\codex-command-runner.exe" - "codex/codex-windows-sandbox-setup.exe" = "codex-resources\codex-windows-sandbox-setup.exe" - "path/rg.exe" = "codex-resources\rg.exe" - } + $archivePath = Join-Path $tempDir $packageAsset + $extractDir = Join-Path $tempDir "extract" + $stagingDir = Join-Path $releasesDir ".staging.$releaseName.$PID" + $assetMetadata = Get-ReleaseAssetMetadata -AssetName $packageAsset -ResolvedVersion $resolvedVersion - foreach ($relativeSource in $copyMap.Keys) { - Copy-Item -LiteralPath (Join-Path $vendorRoot $relativeSource) -Destination (Join-Path $stagingDir $copyMap[$relativeSource]) - } + Write-Step "Downloading Codex CLI" + Invoke-WebRequest -Uri $assetMetadata.Url -OutFile $archivePath + Test-ArchiveDigest -ArchivePath $archivePath -ExpectedDigest $assetMetadata.Sha256 - New-Item -ItemType Directory -Force -Path $releasesDir | Out-Null - Move-Item -LiteralPath $stagingDir -Destination $releaseDir - } + New-Item -ItemType Directory -Force -Path $extractDir | Out-Null + New-Item -ItemType Directory -Force -Path $releasesDir | Out-Null + if (Test-Path -LiteralPath $stagingDir) { + Remove-Item -LiteralPath $stagingDir -Recurse -Force + } + New-Item -ItemType Directory -Force -Path $stagingDir | Out-Null + tar -xzf $archivePath -C $extractDir + + $vendorRoot = Join-Path $extractDir "package/vendor/$target" + $resourcesDir = Join-Path $stagingDir "codex-resources" + New-Item -ItemType Directory -Force -Path $resourcesDir | Out-Null + $copyMap = @{ + "codex/codex.exe" = "codex.exe" + "codex/codex-command-runner.exe" = "codex-resources\codex-command-runner.exe" + "codex/codex-windows-sandbox-setup.exe" = "codex-resources\codex-windows-sandbox-setup.exe" + "path/rg.exe" = "codex-resources\rg.exe" + } + + foreach ($relativeSource in $copyMap.Keys) { + Copy-Item -LiteralPath (Join-Path $vendorRoot $relativeSource) -Destination (Join-Path $stagingDir $copyMap[$relativeSource]) + } - New-Item -ItemType Directory -Force -Path $standaloneRoot | Out-Null - Ensure-Junction -LinkPath $currentDir -TargetPath $releaseDir + if (Test-Path -LiteralPath $releaseDir) { + Remove-Item -LiteralPath $releaseDir -Recurse -Force + } + Move-Item -LiteralPath $stagingDir -Destination $releaseDir + } + + New-Item -ItemType Directory -Force -Path $standaloneRoot | Out-Null + Ensure-Junction -LinkPath $currentDir -TargetPath $releaseDir - $visibleParent = Split-Path -Parent $visibleBinDir - New-Item -ItemType Directory -Force -Path $visibleParent | Out-Null - Ensure-Junction -LinkPath $visibleBinDir -TargetPath $currentDir + $visibleParent = Split-Path -Parent $visibleBinDir + New-Item -ItemType Directory -Force -Path $visibleParent | Out-Null + $oldStandaloneBackup = Move-OldStandaloneBinIfApproved -VisibleBinDir $visibleBinDir -DefaultVisibleBinDir $defaultVisibleBinDir + Ensure-Junction -LinkPath $visibleBinDir -TargetPath $currentDir + Test-VisibleCodexCommand -VisibleBinDir $visibleBinDir + if ($null -ne $oldStandaloneBackup) { + Remove-Item -LiteralPath $oldStandaloneBackup -Recurse -Force + } + } } finally { Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue } +Maybe-HandleConflictingInstall -Conflict $conflictingInstall + $userPath = [Environment]::GetEnvironmentVariable("Path", "User") if (-not (Path-Contains -PathValue $userPath -Entry $visibleBinDir)) { if ([string]::IsNullOrWhiteSpace($userPath)) { diff --git a/scripts/install/install.sh b/scripts/install/install.sh index cbea32fb70e6..c0be4194ff4b 100755 --- a/scripts/install/install.sh +++ b/scripts/install/install.sh @@ -10,9 +10,15 @@ CODEX_HOME_DIR="${CODEX_HOME:-$HOME/.codex}" STANDALONE_ROOT="$CODEX_HOME_DIR/packages/standalone" RELEASES_DIR="$STANDALONE_ROOT/releases" CURRENT_LINK="$STANDALONE_ROOT/current" +LOCK_FILE="$STANDALONE_ROOT/install.lock" +LOCK_DIR="$STANDALONE_ROOT/install.lock.d" path_action="already" path_profile="" +conflict_manager="" +conflict_path="" +lock_kind="" +tmp_dir="" step() { printf '==> %s\n' "$1" @@ -107,6 +113,92 @@ release_url_for_asset() { printf 'https://github.com/openai/codex/releases/download/rust-v%s/%s\n' "$resolved_version" "$asset" } +release_metadata_url() { + resolved_version="$1" + + printf 'https://api.github.com/repos/openai/codex/releases/tags/rust-v%s\n' "$resolved_version" +} + +release_asset_digest() { + asset="$1" + resolved_version="$2" + release_json="$(download_text "$(release_metadata_url "$resolved_version")")" + + digest="$(printf '%s\n' "$release_json" | awk -v asset="$asset" ' + { + if ($0 ~ "\"name\":[[:space:]]*\"" asset "\"") { + in_asset = 1 + asset_depth = depth + } + + if (in_asset && /"digest":[[:space:]]*"[^"]+"/) { + sub(/^.*"digest":[[:space:]]*"/, "") + sub(/".*$/, "") + digest = $0 + } + + line = $0 + opens = gsub(/\{/, "{", line) + closes = gsub(/\}/, "}", line) + depth += opens - closes + + if (in_asset && depth < asset_depth) { + in_asset = 0 + } + } + END { + if (digest != "") { + print digest + } + } + ')" + + case "$digest" in + sha256:????????????????????????????????????????????????????????????????) + printf '%s\n' "${digest#sha256:}" + ;; + *) + echo "Could not find SHA-256 digest for release asset $asset." >&2 + exit 1 + ;; + esac +} + +file_sha256() { + path="$1" + + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$path" | awk '{print $1}' + return + fi + + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$path" | awk '{print $1}' + return + fi + + if command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 "$path" | sed 's/^.*= //' + return + fi + + echo "sha256sum, shasum, or openssl is required to verify the Codex download." >&2 + exit 1 +} + +verify_archive_digest() { + archive_path="$1" + expected_digest="$2" + actual_digest="$(file_sha256 "$archive_path")" + + if [ "$actual_digest" != "$expected_digest" ]; then + echo "Downloaded Codex archive checksum did not match release metadata." >&2 + echo "expected: $expected_digest" >&2 + echo "actual: $actual_digest" >&2 + exit 1 + fi +} + require_command() { if ! command -v "$1" >/dev/null 2>&1; then echo "$1 is required to install Codex." >&2 @@ -241,6 +333,51 @@ rewrite_path_block() { mv "$tmp_profile" "$profile" } +acquire_install_lock() { + mkdir -p "$STANDALONE_ROOT" + + if command -v flock >/dev/null 2>&1; then + exec 9>"$LOCK_FILE" + flock 9 + lock_kind="flock" + return + fi + + while ! mkdir "$LOCK_DIR" 2>/dev/null; do + sleep 1 + done + lock_kind="mkdir" +} + +release_install_lock() { + if [ "$lock_kind" = "mkdir" ]; then + rmdir "$LOCK_DIR" 2>/dev/null || true + elif [ "$lock_kind" = "flock" ]; then + flock -u 9 2>/dev/null || true + fi + lock_kind="" +} + +replace_path_with_symlink() { + link_path="$1" + link_target="$2" + tmp_link="$3" + + rm -f "$tmp_link" + ln -s "$link_target" "$tmp_link" + + if mv -Tf "$tmp_link" "$link_path" 2>/dev/null; then + return + fi + + if mv -hf "$tmp_link" "$link_path" 2>/dev/null; then + return + fi + + rm -f "$link_path" + mv -f "$tmp_link" "$link_path" +} + version_from_binary() { codex_path="$1" @@ -354,7 +491,7 @@ maybe_launch_codex_now() { fi } -handle_conflicting_install() { +detect_conflicting_install() { existing_path="$(resolve_existing_codex)" manager="$(classify_existing_codex "$existing_path" || true)" @@ -362,10 +499,18 @@ handle_conflicting_install() { return fi + conflict_manager="$manager" + conflict_path="$existing_path" step "Detected existing $manager-managed Codex at $existing_path" warn "Multiple managed Codex installs can be ambiguous because PATH order decides which one runs." +} + +handle_conflicting_install() { + if [ -z "$conflict_manager" ]; then + return + fi - case "$manager" in + case "$conflict_manager" in brew) uninstall_cmd="brew uninstall --cask codex" ;; @@ -377,32 +522,32 @@ handle_conflicting_install() { ;; esac - if prompt_yes_no "Uninstall the existing $manager-managed Codex now?"; then + if prompt_yes_no "Uninstall the existing $conflict_manager-managed Codex now?"; then step "Running: $uninstall_cmd" if ! sh -c "$uninstall_cmd"; then - warn "Failed to uninstall the existing $manager-managed Codex. Continuing with the standalone install." + warn "Failed to uninstall the existing $conflict_manager-managed Codex. Continuing with the standalone install." fi else - warn "Leaving the existing $manager-managed Codex installed. PATH order will determine which codex runs." + warn "Leaving the existing $conflict_manager-managed Codex installed. PATH order will determine which codex runs." fi } install_release() { release_dir="$1" vendor_root="$2" - stage_release="$tmp_dir/release" + stage_release="$RELEASES_DIR/.staging.$(basename "$release_dir").$$" + mkdir -p "$RELEASES_DIR" rm -rf "$stage_release" - if [ -e "$release_dir" ] || [ -L "$release_dir" ]; then - rm -rf "$release_dir" - fi mkdir -p "$stage_release/codex-resources" cp "$vendor_root/codex/codex" "$stage_release/codex" cp "$vendor_root/path/rg" "$stage_release/codex-resources/rg" chmod 0755 "$stage_release/codex" chmod 0755 "$stage_release/codex-resources/rg" - mkdir -p "$RELEASES_DIR" + if [ -e "$release_dir" ] || [ -L "$release_dir" ]; then + rm -rf "$release_dir" + fi mv "$stage_release" "$release_dir" } @@ -421,18 +566,18 @@ update_current_link() { release_dir="$1" tmp_link="$STANDALONE_ROOT/.current.$$" - rm -f "$tmp_link" - ln -s "$release_dir" "$tmp_link" - mv -f "$tmp_link" "$CURRENT_LINK" + replace_path_with_symlink "$CURRENT_LINK" "$release_dir" "$tmp_link" } update_visible_command() { mkdir -p "$BIN_DIR" tmp_link="$BIN_DIR/.codex.$$" - rm -f "$tmp_link" - ln -s "$CURRENT_LINK/codex" "$tmp_link" - mv -f "$tmp_link" "$BIN_PATH" + replace_path_with_symlink "$BIN_PATH" "$CURRENT_LINK/codex" "$tmp_link" +} + +verify_visible_command() { + "$BIN_PATH" --version >/dev/null } parse_args "$@" @@ -511,14 +656,19 @@ fi step "Detected platform: $platform_label" step "Resolved version: $resolved_version" -handle_conflicting_install +detect_conflicting_install tmp_dir="$(mktemp -d)" cleanup() { - rm -rf "$tmp_dir" + release_install_lock + if [ -n "$tmp_dir" ]; then + rm -rf "$tmp_dir" + fi } trap cleanup EXIT INT TERM +acquire_install_lock + if ! release_dir_is_complete "$release_dir" "$resolved_version" "$vendor_target"; then if [ -e "$release_dir" ] || [ -L "$release_dir" ]; then warn "Found incomplete existing release at $release_dir; reinstalling." @@ -528,7 +678,9 @@ if ! release_dir_is_complete "$release_dir" "$resolved_version" "$vendor_target" extract_dir="$tmp_dir/extract" step "Downloading Codex CLI" + expected_digest="$(release_asset_digest "$asset" "$resolved_version")" download_file "$download_url" "$archive_path" + verify_archive_digest "$archive_path" "$expected_digest" mkdir -p "$extract_dir" tar -xzf "$archive_path" -C "$extract_dir" @@ -536,10 +688,12 @@ if ! release_dir_is_complete "$release_dir" "$resolved_version" "$vendor_target" step "Installing standalone package to $release_dir" install_release "$release_dir" "$extract_dir/package/vendor/$vendor_target" fi -mkdir -p "$STANDALONE_ROOT" update_current_link "$release_dir" update_visible_command add_to_path +verify_visible_command +release_install_lock +handle_conflicting_install case "$path_action" in added) From cada50f25fe7558293dae1206d90903924248d10 Mon Sep 17 00:00:00 2001 From: Edward Frazer Date: Tue, 14 Apr 2026 17:56:38 -0700 Subject: [PATCH 19/23] fix: clean stale installer artifacts before activation --- scripts/install/install.ps1 | 33 +++++++++++++++++++++++++++++++++ scripts/install/install.sh | 12 ++++++++++++ 2 files changed, 45 insertions(+) diff --git a/scripts/install/install.ps1 b/scripts/install/install.ps1 index 05cfdad8a496..5ca92eed6139 100644 --- a/scripts/install/install.ps1 +++ b/scripts/install/install.ps1 @@ -137,6 +137,37 @@ function Invoke-WithInstallLock { } } +function Remove-StaleSwapArtifacts { + param( + [string]$LinkPath + ) + + $parent = Split-Path -Parent $LinkPath + if (-not (Test-Path -LiteralPath $parent -PathType Container)) { + return + } + + $leaf = Split-Path -Leaf $LinkPath + Get-ChildItem -LiteralPath $parent -Force -Filter "$leaf.pending.*" -ErrorAction SilentlyContinue | + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue +} + +function Remove-StaleInstallArtifacts { + param( + [string]$ReleasesDir, + [string]$CurrentDir, + [string]$VisibleBinDir + ) + + if (Test-Path -LiteralPath $ReleasesDir -PathType Container) { + Get-ChildItem -LiteralPath $ReleasesDir -Force -Directory -Filter ".staging.*" -ErrorAction SilentlyContinue | + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue + } + + Remove-StaleSwapArtifacts -LinkPath $CurrentDir + Remove-StaleSwapArtifacts -LinkPath $VisibleBinDir +} + function Resolve-Version { $normalizedVersion = Normalize-Version -RawVersion $Release if ($normalizedVersion -ne "latest") { @@ -505,6 +536,8 @@ New-Item -ItemType Directory -Force -Path $tempDir | Out-Null try { Invoke-WithInstallLock -LockPath $lockPath -Script { + Remove-StaleInstallArtifacts -ReleasesDir $releasesDir -CurrentDir $currentDir -VisibleBinDir $visibleBinDir + if (-not (Test-ReleaseIsComplete -ReleaseDir $releaseDir -ExpectedVersion $resolvedVersion -ExpectedTarget $target)) { if (Test-Path -LiteralPath $releaseDir) { Write-WarningStep "Found incomplete existing release at $releaseDir. Reinstalling." diff --git a/scripts/install/install.sh b/scripts/install/install.sh index c0be4194ff4b..a67041b588e4 100755 --- a/scripts/install/install.sh +++ b/scripts/install/install.sh @@ -358,6 +358,17 @@ release_install_lock() { lock_kind="" } +cleanup_stale_install_artifacts() { + mkdir -p "$RELEASES_DIR" "$STANDALONE_ROOT" + + find "$RELEASES_DIR" -mindepth 1 -maxdepth 1 -name '.staging.*' -exec rm -rf {} + + find "$STANDALONE_ROOT" -mindepth 1 -maxdepth 1 -name '.current.*' -exec rm -f {} + + + if [ -d "$BIN_DIR" ]; then + find "$BIN_DIR" -mindepth 1 -maxdepth 1 -name '.codex.*' -exec rm -f {} + + fi +} + replace_path_with_symlink() { link_path="$1" link_target="$2" @@ -668,6 +679,7 @@ cleanup() { trap cleanup EXIT INT TERM acquire_install_lock +cleanup_stale_install_artifacts if ! release_dir_is_complete "$release_dir" "$resolved_version" "$vendor_target"; then if [ -e "$release_dir" ] || [ -L "$release_dir" ]; then From e1851ee53c485dad49a33b35f3315572e03807fb Mon Sep 17 00:00:00 2001 From: Edward Frazer Date: Wed, 15 Apr 2026 10:09:01 -0700 Subject: [PATCH 20/23] fix: simplify windows updater branch and drop plan doc --- codex-rs/cli/src/main.rs | 30 +- docs/standalone-installer-hardening-plan.md | 307 -------------------- 2 files changed, 12 insertions(+), 325 deletions(-) delete mode 100644 docs/standalone-installer-hardening-plan.md diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 8cfd14ba89c2..184936dec48c 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -490,24 +490,18 @@ fn run_update_action(action: UpdateAction) -> anyhow::Result<()> { let status = { #[cfg(windows)] { - match action { - UpdateAction::StandaloneWindows => { - let (cmd, args) = action.command_args(); - // Run the standalone PowerShell installer with PowerShell - // itself. Routing this through `cmd.exe /C` would parse - // PowerShell metacharacters like `|` before PowerShell sees - // the installer command. - std::process::Command::new(cmd).args(args).status()? - } - UpdateAction::NpmGlobalLatest - | UpdateAction::BunGlobalLatest - | UpdateAction::BrewUpgrade - | UpdateAction::StandaloneUnix => { - // On Windows, run via cmd.exe so .CMD/.BAT are correctly resolved (PATHEXT semantics). - std::process::Command::new("cmd") - .args(["/C", &cmd_str]) - .status()? - } + if action == UpdateAction::StandaloneWindows { + let (cmd, args) = action.command_args(); + // Run the standalone PowerShell installer with PowerShell + // itself. Routing this through `cmd.exe /C` would parse + // PowerShell metacharacters like `|` before PowerShell sees + // the installer command. + std::process::Command::new(cmd).args(args).status()? + } else { + // On Windows, run via cmd.exe so .CMD/.BAT are correctly resolved (PATHEXT semantics). + std::process::Command::new("cmd") + .args(["/C", &cmd_str]) + .status()? } } #[cfg(not(windows))] diff --git a/docs/standalone-installer-hardening-plan.md b/docs/standalone-installer-hardening-plan.md deleted file mode 100644 index c33de40e88f3..000000000000 --- a/docs/standalone-installer-hardening-plan.md +++ /dev/null @@ -1,307 +0,0 @@ -# Standalone Installer Hardening Plan - -This plan addresses the review feedback on the standalone installer rework. - -The installer is moving Codex from a flat executable layout into a managed -release layout: - -```text -$CODEX_HOME/ - packages/ - standalone/ - current -> releases/- - releases/ - -/ - codex - codex-resources/ - rg -``` - -That shape is still the right direction. The problems are in the transition -and activation mechanics. A standalone installer must make the new release -ready first, then switch users over in one safe step, and it must not destroy a -working install if anything fails before that switch. - -## Goals - -- A fresh install works on macOS, Linux, and Windows. -- A rerun of the same version is safe and idempotent. -- An upgrade switches from the old release to the new release atomically where - the platform allows it. -- Existing users of the old standalone layout migrate without deleting files by - hand. -- Concurrent installer runs cannot delete or half-activate each other's work. -- The visible `codex` command works before we offer to remove npm, bun, or - brew installs. -- The TUI update path preserves the install choices that matter to future - updates. - -## Current Review Findings - -### 1. Unix `current` symlink replacement is broken on macOS - -`install.sh` creates a temporary symlink and then runs: - -```sh -mv -f "$tmp_link" "$CURRENT_LINK" -``` - -On macOS/BSD `mv`, when `CURRENT_LINK` is a symlink to a directory, this can -move the temporary symlink into the existing target directory instead of -replacing `CURRENT_LINK`. - -The bad result looks like this: - -```text -standalone/current -> releases/old-version -standalone/releases/old-version/.current. -> releases/new-version -``` - -The user keeps running the old release. - -Fix: - -- Add `replace_symlink` in `install.sh`. -- Use `mv -T` when available. -- Use `mv -h` on macOS/BSD. -- If neither exists, remove the old symlink and rename the new symlink while - holding the installer lock. - -The fallback is not fully atomic, but the lock makes it safe from another -installer process. The normal Linux and macOS paths stay atomic. - -### 2. Windows migration from the old standalone layout fails - -The old Windows installer wrote real files into: - -```text -%LOCALAPPDATA%\Programs\OpenAI\Codex\bin - codex.exe - rg.exe - codex-command-runner.exe - codex-windows-sandbox-setup.exe -``` - -The PR now tries to replace that `bin` directory with a junction. The helper -`Ensure-Junction` refuses to replace a non-empty directory, so existing users -can hit a hard failure. - -Fix: - -- Detect only the known old standalone `bin` layout at the default Windows - install path. -- Ask the user before replacing that old layout. -- Move the old `bin` directory aside before creating the new junction. -- Delete the backup only after the new visible `codex.exe --version` check - passes. -- Keep refusing unknown non-empty directories. - -This gives existing standalone users a migration path without teaching the -installer to replace arbitrary user directories. - -### 3. Concurrent installs can corrupt activation - -Two installer processes can both decide a release is incomplete, both download -it, and both manipulate the same `release_dir`, `current`, and visible -command. - -The risky Unix sequence is: - -```sh -if [ -e "$release_dir" ] || [ -L "$release_dir" ]; then - rm -rf "$release_dir" -fi -... -mv "$stage_release" "$release_dir" -``` - -Fix: - -- Add one installer lock per standalone root. -- Hold the lock across: - - release completeness check - - staging - - final release rename - - `current` update - - visible command update - - install metadata write -- On Unix, prefer `flock` when available. -- On macOS without `flock`, use an atomic `mkdir "$lock_dir"` lock with a - trap cleanup. -- On Windows, use a named mutex or an exclusive lock file opened with no share - mode. - -The lock should live under: - -```text -$CODEX_HOME/packages/standalone/install.lock -``` - -### 4. Unix staging happens under `/tmp` - -The Unix installer currently stages under `mktemp -d`, then moves the staged -release into `$RELEASES_DIR`. - -If `/tmp` and `CODEX_HOME` are on different filesystems, the final move is a -copy plus delete. That is not the atomic activation model the installer claims. - -Fix: - -- Download and extract into a temp directory as today. -- Stage the final release directory under `$RELEASES_DIR`. -- Use a staging path like: - -```text -$RELEASES_DIR/.staging.. -``` - -- Rename the staging directory to the final release directory on the same - filesystem. - -The archive can still download into `/tmp`. The release directory that becomes -active must be staged beside the final destination. - -### 5. Conflicting installs are removed too early - -The installer currently offers to uninstall npm, bun, or brew Codex before the -standalone install has succeeded. - -That ordering can remove a working `codex`, then fail during download, -extraction, activation, or PATH setup. - -Fix: - -1. Detect the conflicting manager-owned install early, but do not uninstall it. -2. Download, stage, and activate standalone. -3. Verify the visible `codex` command works: - -```sh -"$BIN_PATH" --version -``` - -4. Only then ask whether to uninstall the old npm, bun, or brew install. - -If the uninstall fails, keep the standalone install and print a warning about -PATH order. - -### 6. No checksum verification - -The installer downloads GitHub release tarballs over TLS and extracts them -directly. TLS protects transport, but the installer does not verify that the -archive matches an expected release digest. - -Fix: - -- Use GitHub's release asset `digest` field for the exact installer tarball. -- Verify the archive digest before extraction. -- Fail closed if the digest is missing or does not match. - -GitHub already exposes SHA-256 digests for release assets, including -`codex-npm--.tgz`. A separate OpenAI-authored manifest can -still be a later supply-chain hardening step, but it is not required for basic -archive verification. - -### 7. TUI update keeps a simple latest-install path - -Standalone TUI updates rerun the generic installer command: - -```sh -curl -fsSL https://chatgpt.com/codex/install.sh | sh -``` - -The installer accepts `CODEX_HOME` and `CODEX_INSTALL_DIR` as environment -overrides, but those are escape hatches rather than durable product -preferences. Recording them as installer state would make the updater replay -values that may only have been set for one shell session. - -Fix: - -- Keep runtime install detection based on `current_exe()`. -- Keep the TUI update command as a latest standalone installer rerun. -- Do not add an installer state file until there are real user-facing install - preferences, such as a channel or update policy. - -## Implementation Order - -1. Add installer locks on Unix and Windows. -2. Move Unix release staging under `$RELEASES_DIR`. -3. Fix Unix symlink replacement. -4. Fix Windows visible `bin` migration by keeping `bin` as a real directory. -5. Move conflicting-install uninstall after standalone activation and - verification. -6. Add install metadata and use it from the TUI update action. -7. Add checksum verification using GitHub's release asset digest. - -The first five should land before this PR is considered safe to merge. The last -two can land in the same PR if the patch stays manageable; otherwise they should -be tracked as immediate follow-ups. - -## Test Plan - -### Unix tests - -- Fresh install into isolated `HOME`, `CODEX_HOME`, and `CODEX_INSTALL_DIR`. -- Same-version rerun. -- Upgrade from fake old release to fake new release. -- macOS symlink replacement fixture: - - create `current -> old-release` - - update to `new-release` - - assert `current` points to `new-release` - - assert no `.current.*` file appears inside `old-release` -- Staging filesystem check: - - assert final staging dir is created under `$RELEASES_DIR` - - assert final activation uses rename from sibling staging dir -- Concurrent install smoke: - - start two same-version installs against the same `CODEX_HOME` - - assert both exit successfully - - assert `current/codex --version` works - - assert the release dir is complete - -### Windows tests - -- Fresh install into isolated `CODEX_HOME` and `CODEX_INSTALL_DIR`. -- Same-version rerun against the same `CODEX_HOME` and `CODEX_INSTALL_DIR`. -- Migration from old standalone layout: - - create a real non-empty visible `bin` directory - - include `codex.exe`, `rg.exe`, and helper binary names - - run installer in non-interactive mode - - assert installer asks for confirmation and fails closed - - assert old files are preserved -- Unknown non-empty visible directory: - - create a real directory with an extra user file - - assert installer refuses to replace it - - assert user file is preserved -- Same-version rerun. -- Concurrent install smoke with two PowerShell installer processes. -- TUI standalone update command on Windows: - - assert PowerShell is called directly - - assert `|iex` is passed to PowerShell, not `cmd /C` - -### Failure-ordering tests - -- Simulate a conflicting npm/bun/brew install. -- Force standalone download or extraction failure. -- Assert the old manager-owned install is not removed. -- Force standalone success and visible command verification success. -- Assert the uninstall prompt happens after verification. - -### Manual smoke tests - -- macOS fresh install and rerun. -- macOS upgrade between two locally served test releases. -- Linux fresh install and rerun. -- Windows fresh install. -- Windows migration from the old visible `bin` layout. - -## Acceptance Criteria - -- The installer never removes a working manager-owned Codex before standalone - has been activated and verified. -- The Unix `current` link is replaced correctly on macOS and Linux. -- Release activation uses same-filesystem staging. -- Concurrent same-version installs leave a complete release and working visible - command. -- Existing Windows standalone users do not need to delete their old install - directory manually. -- TUI standalone update preserves the install directory choices from the - original standalone install. From 75957c1cafaf67080f1fe09163ad22212f3569ac Mon Sep 17 00:00:00 2001 From: Edward Frazer Date: Wed, 15 Apr 2026 11:59:10 -0700 Subject: [PATCH 21/23] fix: retarget installer-owned junctions in place --- scripts/install/install.ps1 | 239 ++++++++++++++++++++++++++---------- 1 file changed, 173 insertions(+), 66 deletions(-) diff --git a/scripts/install/install.ps1 b/scripts/install/install.ps1 index 5ca92eed6139..6189154b4ed3 100644 --- a/scripts/install/install.ps1 +++ b/scripts/install/install.ps1 @@ -137,35 +137,15 @@ function Invoke-WithInstallLock { } } -function Remove-StaleSwapArtifacts { - param( - [string]$LinkPath - ) - - $parent = Split-Path -Parent $LinkPath - if (-not (Test-Path -LiteralPath $parent -PathType Container)) { - return - } - - $leaf = Split-Path -Leaf $LinkPath - Get-ChildItem -LiteralPath $parent -Force -Filter "$leaf.pending.*" -ErrorAction SilentlyContinue | - Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -} - function Remove-StaleInstallArtifacts { param( - [string]$ReleasesDir, - [string]$CurrentDir, - [string]$VisibleBinDir + [string]$ReleasesDir ) if (Test-Path -LiteralPath $ReleasesDir -PathType Container) { Get-ChildItem -LiteralPath $ReleasesDir -Force -Directory -Filter ".staging.*" -ErrorAction SilentlyContinue | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue } - - Remove-StaleSwapArtifacts -LinkPath $CurrentDir - Remove-StaleSwapArtifacts -LinkPath $VisibleBinDir } function Resolve-Version { @@ -284,62 +264,189 @@ function Move-OldStandaloneBinIfApproved { return $backupDir } -function Ensure-Junction { +function Add-JunctionSupportType { + if (([System.Management.Automation.PSTypeName]'CodexInstaller.Junction').Type) { + return + } + + Add-Type -TypeDefinition @" +using System; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace CodexInstaller +{ + public static class Junction + { + private const uint GENERIC_WRITE = 0x40000000; + private const uint FILE_SHARE_READ = 0x00000001; + private const uint FILE_SHARE_WRITE = 0x00000002; + private const uint FILE_SHARE_DELETE = 0x00000004; + private const uint OPEN_EXISTING = 3; + private const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000; + private const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000; + private const uint FSCTL_SET_REPARSE_POINT = 0x000900A4; + private const uint IO_REPARSE_TAG_MOUNT_POINT = 0xA0000003; + private const int HeaderLength = 20; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFileW( + string lpFileName, + uint dwDesiredAccess, + uint dwShareMode, + IntPtr lpSecurityAttributes, + uint dwCreationDisposition, + uint dwFlagsAndAttributes, + IntPtr hTemplateFile); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool DeviceIoControl( + SafeFileHandle hDevice, + uint dwIoControlCode, + byte[] lpInBuffer, + int nInBufferSize, + IntPtr lpOutBuffer, + int nOutBufferSize, + out int lpBytesReturned, + IntPtr lpOverlapped); + + public static void SetTarget(string linkPath, string targetPath) + { + string substituteName = "\\??\\" + Path.GetFullPath(targetPath); + byte[] substituteNameBytes = Encoding.Unicode.GetBytes(substituteName); + if (substituteNameBytes.Length > ushort.MaxValue - HeaderLength) { + throw new ArgumentException("Junction target path is too long.", "targetPath"); + } + + byte[] reparseBuffer = new byte[substituteNameBytes.Length + HeaderLength]; + WriteUInt32(reparseBuffer, 0, IO_REPARSE_TAG_MOUNT_POINT); + WriteUInt16(reparseBuffer, 4, checked((ushort)(substituteNameBytes.Length + 12))); + WriteUInt16(reparseBuffer, 8, 0); + WriteUInt16(reparseBuffer, 10, checked((ushort)substituteNameBytes.Length)); + WriteUInt16(reparseBuffer, 12, checked((ushort)(substituteNameBytes.Length + 2))); + WriteUInt16(reparseBuffer, 14, 0); + Buffer.BlockCopy(substituteNameBytes, 0, reparseBuffer, 16, substituteNameBytes.Length); + + using (SafeFileHandle handle = CreateFileW( + linkPath, + GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + IntPtr.Zero, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, + IntPtr.Zero)) + { + if (handle.IsInvalid) { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + int bytesReturned; + if (!DeviceIoControl( + handle, + FSCTL_SET_REPARSE_POINT, + reparseBuffer, + reparseBuffer.Length, + IntPtr.Zero, + 0, + out bytesReturned, + IntPtr.Zero)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + } + } + + private static void WriteUInt16(byte[] buffer, int offset, ushort value) + { + buffer[offset] = (byte)value; + buffer[offset + 1] = (byte)(value >> 8); + } + + private static void WriteUInt32(byte[] buffer, int offset, uint value) + { + buffer[offset] = (byte)value; + buffer[offset + 1] = (byte)(value >> 8); + buffer[offset + 2] = (byte)(value >> 16); + buffer[offset + 3] = (byte)(value >> 24); + } + } +} +"@ +} + +function Set-JunctionTarget { param( [string]$LinkPath, [string]$TargetPath ) - $swapId = [System.Guid]::NewGuid().ToString("N") - $pendingPath = "$LinkPath.pending.$swapId" - $backupPath = "$LinkPath.backup.$swapId" + Add-JunctionSupportType + [CodexInstaller.Junction]::SetTarget($LinkPath, $TargetPath) +} - if (Test-Path -LiteralPath $pendingPath) { - [System.IO.Directory]::Delete($pendingPath) - } - if (Test-Path -LiteralPath $backupPath) { - [System.IO.Directory]::Delete($backupPath) +function Test-IsJunction { + param( + [string]$Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + return $false } - New-Item -ItemType Junction -Path $pendingPath -Target $TargetPath | Out-Null + $item = Get-Item -LiteralPath $Path -Force + return ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -and $item.LinkType -eq "Junction" +} - if (Test-Path -LiteralPath $LinkPath) { - $item = Get-Item -LiteralPath $LinkPath -Force - if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { - $existingTarget = [string]$item.Target - if ($existingTarget.Equals($TargetPath, [System.StringComparison]::OrdinalIgnoreCase)) { - [System.IO.Directory]::Delete($pendingPath) - return - } +function Ensure-Junction { + param( + [string]$LinkPath, + [string]$TargetPath, + [string]$InstallerOwnedTargetPrefix + ) - try { - Move-Item -LiteralPath $LinkPath -Destination $backupPath - Move-Item -LiteralPath $pendingPath -Destination $LinkPath - [System.IO.Directory]::Delete($backupPath) - } catch { - if ((-not (Test-Path -LiteralPath $LinkPath)) -and (Test-Path -LiteralPath $backupPath)) { - Move-Item -LiteralPath $backupPath -Destination $LinkPath - } - if (Test-Path -LiteralPath $pendingPath) { - [System.IO.Directory]::Delete($pendingPath) - } - throw - } - } elseif ($item.PSIsContainer) { - if ((Get-ChildItem -LiteralPath $LinkPath -Force | Select-Object -First 1) -ne $null) { - [System.IO.Directory]::Delete($pendingPath) - throw "Refusing to replace non-empty directory at $LinkPath with a junction." + if (-not (Test-Path -LiteralPath $LinkPath)) { + New-Item -ItemType Junction -Path $LinkPath -Target $TargetPath | Out-Null + return + } + + $item = Get-Item -LiteralPath $LinkPath -Force + if (Test-IsJunction -Path $LinkPath) { + $existingTarget = [string]$item.Target + if (-not [string]::IsNullOrWhiteSpace($InstallerOwnedTargetPrefix)) { + $ownedTargetPrefix = $InstallerOwnedTargetPrefix.TrimEnd("\\") + if (-not $existingTarget.StartsWith($ownedTargetPrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Refusing to retarget junction at $LinkPath because it is not managed by this installer." } + } + if ($existingTarget.Equals($TargetPath, [System.StringComparison]::OrdinalIgnoreCase)) { + return + } + + # Keep the path itself in place and only retarget the junction. That + # avoids a gap where current or the visible bin path disappears during + # an update. + Set-JunctionTarget -LinkPath $LinkPath -TargetPath $TargetPath + return + } - Remove-Item -LiteralPath $LinkPath -Force - Move-Item -LiteralPath $pendingPath -Destination $LinkPath - } else { - [System.IO.Directory]::Delete($pendingPath) - throw "Refusing to replace file at $LinkPath with a junction." + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { + throw "Refusing to replace non-junction reparse point at $LinkPath." + } + + if ($item.PSIsContainer) { + if ((Get-ChildItem -LiteralPath $LinkPath -Force | Select-Object -First 1) -ne $null) { + throw "Refusing to replace non-empty directory at $LinkPath with a junction." } - } else { - Move-Item -LiteralPath $pendingPath -Destination $LinkPath + + Remove-Item -LiteralPath $LinkPath -Force + New-Item -ItemType Junction -Path $LinkPath -Target $TargetPath | Out-Null + return } + + throw "Refusing to replace file at $LinkPath with a junction." } function Test-ReleaseIsComplete { @@ -536,7 +643,7 @@ New-Item -ItemType Directory -Force -Path $tempDir | Out-Null try { Invoke-WithInstallLock -LockPath $lockPath -Script { - Remove-StaleInstallArtifacts -ReleasesDir $releasesDir -CurrentDir $currentDir -VisibleBinDir $visibleBinDir + Remove-StaleInstallArtifacts -ReleasesDir $releasesDir if (-not (Test-ReleaseIsComplete -ReleaseDir $releaseDir -ExpectedVersion $resolvedVersion -ExpectedTarget $target)) { if (Test-Path -LiteralPath $releaseDir) { @@ -581,12 +688,12 @@ try { } New-Item -ItemType Directory -Force -Path $standaloneRoot | Out-Null - Ensure-Junction -LinkPath $currentDir -TargetPath $releaseDir + Ensure-Junction -LinkPath $currentDir -TargetPath $releaseDir -InstallerOwnedTargetPrefix $releasesDir $visibleParent = Split-Path -Parent $visibleBinDir New-Item -ItemType Directory -Force -Path $visibleParent | Out-Null $oldStandaloneBackup = Move-OldStandaloneBinIfApproved -VisibleBinDir $visibleBinDir -DefaultVisibleBinDir $defaultVisibleBinDir - Ensure-Junction -LinkPath $visibleBinDir -TargetPath $currentDir + Ensure-Junction -LinkPath $visibleBinDir -TargetPath $currentDir -InstallerOwnedTargetPrefix $standaloneRoot Test-VisibleCodexCommand -VisibleBinDir $visibleBinDir if ($null -ne $oldStandaloneBackup) { Remove-Item -LiteralPath $oldStandaloneBackup -Recurse -Force From 3b1036b2ed0958a56392294fecb3b871e23b30fa Mon Sep 17 00:00:00 2001 From: Edward Frazer Date: Wed, 15 Apr 2026 13:00:21 -0700 Subject: [PATCH 22/23] fix: restore legacy windows bin on migration failure --- scripts/install/install.ps1 | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/scripts/install/install.ps1 b/scripts/install/install.ps1 index 6189154b4ed3..ed4a3e1a3201 100644 --- a/scripts/install/install.ps1 +++ b/scripts/install/install.ps1 @@ -693,8 +693,18 @@ try { $visibleParent = Split-Path -Parent $visibleBinDir New-Item -ItemType Directory -Force -Path $visibleParent | Out-Null $oldStandaloneBackup = Move-OldStandaloneBinIfApproved -VisibleBinDir $visibleBinDir -DefaultVisibleBinDir $defaultVisibleBinDir - Ensure-Junction -LinkPath $visibleBinDir -TargetPath $currentDir -InstallerOwnedTargetPrefix $standaloneRoot - Test-VisibleCodexCommand -VisibleBinDir $visibleBinDir + try { + Ensure-Junction -LinkPath $visibleBinDir -TargetPath $currentDir -InstallerOwnedTargetPrefix $standaloneRoot + Test-VisibleCodexCommand -VisibleBinDir $visibleBinDir + } catch { + if ($null -ne $oldStandaloneBackup -and (Test-Path -LiteralPath $oldStandaloneBackup)) { + if (Test-Path -LiteralPath $visibleBinDir) { + Remove-Item -LiteralPath $visibleBinDir -Recurse -Force + } + Move-Item -LiteralPath $oldStandaloneBackup -Destination $visibleBinDir + } + throw + } if ($null -ne $oldStandaloneBackup) { Remove-Item -LiteralPath $oldStandaloneBackup -Recurse -Force } From a186044b1050eeced064cba591533e7bee5da832 Mon Sep 17 00:00:00 2001 From: Edward Frazer Date: Wed, 15 Apr 2026 14:19:14 -0700 Subject: [PATCH 23/23] fix: use durable unix installer locks --- scripts/install/install.sh | 47 +++++++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/scripts/install/install.sh b/scripts/install/install.sh index a67041b588e4..8c225e4d3b1d 100755 --- a/scripts/install/install.sh +++ b/scripts/install/install.sh @@ -12,6 +12,7 @@ RELEASES_DIR="$STANDALONE_ROOT/releases" CURRENT_LINK="$STANDALONE_ROOT/current" LOCK_FILE="$STANDALONE_ROOT/install.lock" LOCK_DIR="$STANDALONE_ROOT/install.lock.d" +LOCK_STALE_AFTER_SECS=600 path_action="already" path_profile="" @@ -333,9 +334,41 @@ rewrite_path_block() { mv "$tmp_profile" "$profile" } +mkdir_lock_is_stale() { + [ -d "$LOCK_DIR" ] || return 1 + + pid="$(cat "$LOCK_DIR/pid" 2>/dev/null || true)" + started_at="$(cat "$LOCK_DIR/started_at" 2>/dev/null || true)" + now="$(date +%s 2>/dev/null || printf '0')" + + case "$started_at" in + ''|*[!0-9]*) + started_at=0 + ;; + esac + + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + return 1 + fi + + if [ "$started_at" -eq 0 ] || [ "$now" -eq 0 ]; then + return 0 + fi + + [ $((now - started_at)) -ge "$LOCK_STALE_AFTER_SECS" ] +} + acquire_install_lock() { mkdir -p "$STANDALONE_ROOT" + if [ "$os" = "darwin" ] && command -v lockf >/dev/null 2>&1; then + : >>"$LOCK_FILE" + exec 9<>"$LOCK_FILE" + lockf 9 + lock_kind="lockf" + return + fi + if command -v flock >/dev/null 2>&1; then exec 9>"$LOCK_FILE" flock 9 @@ -344,16 +377,24 @@ acquire_install_lock() { fi while ! mkdir "$LOCK_DIR" 2>/dev/null; do + if mkdir_lock_is_stale; then + warn "Removing stale installer lock at $LOCK_DIR" + rm -rf "$LOCK_DIR" + continue + fi sleep 1 done + + printf '%s\n' "$$" >"$LOCK_DIR/pid" + date +%s >"$LOCK_DIR/started_at" 2>/dev/null || true lock_kind="mkdir" } release_install_lock() { if [ "$lock_kind" = "mkdir" ]; then - rmdir "$LOCK_DIR" 2>/dev/null || true - elif [ "$lock_kind" = "flock" ]; then - flock -u 9 2>/dev/null || true + rm -rf "$LOCK_DIR" 2>/dev/null || true + elif [ "$lock_kind" = "flock" ] || [ "$lock_kind" = "lockf" ]; then + exec 9>&- 2>/dev/null || true fi lock_kind="" }