fix: add noninteractive install script mode (#21567)

# Summary

The Codex standalone installers can pause after installation to ask
about an older managed install or launching Codex. That makes unattended
bootstrap and update flows hard to complete reliably.

This PR adds noninteractive installer control on macOS/Linux and Windows
through `CODEX_NON_INTERACTIVE=1`. Noninteractive operation is
environment-only, which gives automated callers one stable way to
suppress prompts. When a noninteractive install leaves an older npm,
bun, or brew-managed Codex installed, the standalone bin is configured
ahead of that command on `PATH` so the newly installed Codex is the one
future launches select. It also supports `CODEX_RELEASE` for callers
that select a release through environment variables while retaining the
existing explicit release inputs. Release selection accepts `latest`,
stable `x.y.z` versions, and Codex prereleases written as
`rust-v0.134.0-alpha.3`, `v0.134.0-alpha.3`, or `0.134.0-alpha.3`; it
validates that shape before constructing release requests.

# Stack

1. [#21567](https://github.com/openai/codex/pull/21567) - Adds release
and noninteractive environment controls to the installers. (current)
2. [#24637](https://github.com/openai/codex/pull/24637) - Runs
standalone updater installs with `CODEX_NON_INTERACTIVE=1`.
3. [#24639](https://github.com/openai/codex/pull/24639) - Removes
explicit release argument inputs in favor of `CODEX_RELEASE`.

# Evidence

| Before | After |
| --- | --- |
| ![Interactive install
prompts](https://github.com/user-attachments/assets/feecb45a-7087-4681-8775-ba57b07e97fa)
| ![Noninteractive install completes without
prompts](https://github.com/user-attachments/assets/53dcc791-383a-46e2-9a95-3b37b80ae053)
|

Environment-controlled macOS install with an existing npm-managed Codex
on `PATH`:


https://github.com/user-attachments/assets/442e0b5b-4a32-4bf5-996b-68784777380d

# Design decisions

Windows installs using the older standalone bin layout still require an
interactive migration confirmation. Noninteractive mode does not
auto-migrate that existing directory because replacing it is a
destructive transition for an early, limited-use layout; unattended
installs on that layout fail with an instruction to rerun interactively.

# Testing

Tests: installer syntax validation, release-selector acceptance and
rejection coverage including PowerShell `Latest` compatibility, macOS
live-terminal installer smoke testing with environment-controlled stable
and prerelease installation and competing PATH precedence, shell
rejection of the omitted noninteractive flag, and Windows ARM64
PowerShell smoke testing with environment-only noninteractive behavior,
retained release input, and competing PATH precedence through Parallels.
This commit is contained in:
efrazer-oai
2026-05-26 22:09:54 -07:00
committed by GitHub
parent cca1e0ba1d
commit 8ed38fe38e
2 changed files with 85 additions and 6 deletions

View File

@@ -1,11 +1,18 @@
[CmdletBinding()]
param(
[string]$Release = "latest"
[string]$Release = $env:CODEX_RELEASE
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
if ([string]::IsNullOrWhiteSpace($Release)) {
$Release = "latest"
}
$NonInteractive = $env:CODEX_NON_INTERACTIVE -match "^(?i:1|true|yes)$"
function Write-Step {
param(
[string]$Message
@@ -27,6 +34,10 @@ function Prompt-YesNo {
[string]$Prompt
)
if ($NonInteractive) {
return $false
}
if ([Console]::IsInputRedirected -or [Console]::IsOutputRedirected) {
return $false
}
@@ -55,6 +66,16 @@ function Normalize-Version {
return $RawVersion
}
function Assert-ValidReleaseVersion {
param(
[string]$Version
)
if ($Version -cne "latest" -and $Version -cnotmatch "^[0-9]+\.[0-9]+\.[0-9]+(?:-(?:alpha|beta)(?:\.[0-9]+)?)?$") {
throw "Invalid Codex release version: $Version. Expected latest or x.y.z[-alpha[.N]|-beta[.N]]."
}
}
function Find-ReleaseAssetMetadata {
param(
[string]$AssetName,
@@ -141,6 +162,22 @@ function Path-Contains {
return $false
}
function Prepend-PathEntry {
param(
[string]$PathValue,
[string]$Entry
)
$needle = $Entry.TrimEnd("\")
$segments = @($Entry)
if (-not [string]::IsNullOrWhiteSpace($PathValue)) {
$segments += $PathValue.Split(";", [System.StringSplitOptions]::RemoveEmptyEntries) |
Where-Object { $_.TrimEnd("\") -ine $needle }
}
return ($segments -join ";")
}
function Invoke-WithInstallLock {
param(
[string]$LockPath,
@@ -181,6 +218,7 @@ function Remove-StaleInstallArtifacts {
function Resolve-Version {
$normalizedVersion = Normalize-Version -RawVersion $Release
Assert-ValidReleaseVersion -Version $normalizedVersion
if ($normalizedVersion -ne "latest") {
return $normalizedVersion
}
@@ -191,7 +229,9 @@ function Resolve-Version {
exit 1
}
return (Normalize-Version -RawVersion $release.tag_name)
$resolvedVersion = Normalize-Version -RawVersion $release.tag_name
Assert-ValidReleaseVersion -Version $resolvedVersion
return $resolvedVersion
}
function Get-VersionFromBinary {
@@ -839,7 +879,16 @@ try {
Maybe-HandleConflictingInstall -Conflict $conflictingInstall
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
if (-not (Path-Contains -PathValue $userPath -Entry $visibleBinDir)) {
$prioritizeVisibleBin = $null -ne $conflictingInstall
if ($prioritizeVisibleBin) {
$newUserPath = Prepend-PathEntry -PathValue $userPath -Entry $visibleBinDir
if ($newUserPath -cne $userPath) {
[Environment]::SetEnvironmentVariable("Path", $newUserPath, "User")
Write-Step "PATH updated for future PowerShell sessions."
} else {
Write-Step "$visibleBinDir is already first on PATH."
}
} elseif (-not (Path-Contains -PathValue $userPath -Entry $visibleBinDir)) {
if ([string]::IsNullOrWhiteSpace($userPath)) {
$newUserPath = $visibleBinDir
} else {
@@ -854,7 +903,9 @@ if (-not (Path-Contains -PathValue $userPath -Entry $visibleBinDir)) {
Write-Step "PATH is already configured for future PowerShell sessions."
}
if (-not (Path-Contains -PathValue $env:Path -Entry $visibleBinDir)) {
if ($prioritizeVisibleBin) {
$env:Path = Prepend-PathEntry -PathValue $env:Path -Entry $visibleBinDir
} elseif (-not (Path-Contains -PathValue $env:Path -Entry $visibleBinDir)) {
if ([string]::IsNullOrWhiteSpace($env:Path)) {
$env:Path = $visibleBinDir
} else {

View File

@@ -2,7 +2,8 @@
set -eu
RELEASE="latest"
RELEASE="${CODEX_RELEASE:-latest}"
NON_INTERACTIVE="${CODEX_NON_INTERACTIVE:-false}"
BIN_DIR="${CODEX_INSTALL_DIR:-$HOME/.local/bin}"
BIN_PATH="$BIN_DIR/codex"
@@ -46,6 +47,19 @@ normalize_version() {
esac
}
validate_version() {
version="$1"
if [ "$version" = "latest" ]; then
return
fi
if ! printf '%s\n' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?$'; then
echo "Invalid Codex release version: $version. Expected latest or x.y.z[-alpha[.N]|-beta[.N]]." >&2
exit 1
fi
}
parse_args() {
while [ "$#" -gt 0 ]; do
case "$1" in
@@ -60,6 +74,10 @@ parse_args() {
--help | -h)
cat <<EOF
Usage: install.sh [--release VERSION]
Environment:
CODEX_RELEASE Version to install; overridden by --release.
CODEX_NON_INTERACTIVE Set to 1, true, or yes to skip prompts.
EOF
exit 0
;;
@@ -259,6 +277,7 @@ require_command() {
resolve_version() {
normalized_version="$(normalize_version "$RELEASE")"
validate_version "$normalized_version"
if [ "$normalized_version" != "latest" ]; then
printf '%s\n' "$normalized_version"
@@ -273,6 +292,7 @@ resolve_version() {
exit 1
fi
validate_version "$resolved"
printf '%s\n' "$resolved"
}
@@ -304,7 +324,9 @@ add_to_path() {
case ":$PATH:" in
*":$BIN_DIR:"*)
return
if [ -z "$conflict_manager" ]; then
return
fi
;;
esac
@@ -544,6 +566,12 @@ classify_existing_codex() {
prompt_yes_no() {
prompt="$1"
case "$NON_INTERACTIVE" in
1 | [Tt][Rr][Uu][Ee] | [Yy][Ee][Ss])
return 1
;;
esac
if ( : </dev/tty ) 2>/dev/null; then
printf '%s [y/N] ' "$prompt" >/dev/tty
if ! IFS= read -r answer </dev/tty; then