mirror of
https://github.com/openai/codex.git
synced 2026-08-23 13:09:46 +00:00
## What changed - Make the shell and PowerShell installers download release metadata and assets from `releases.openai.com` by default, with bounded request timeouts. - Fall back to GitHub Releases when metadata is unusable, required assets are missing, or downloaded assets fail checksum or manifest validation. - Document `CODEX_INSTALLER_USE_RELEASES_OPENAI_COM=false` for opting out of the default release source. ## Testing - Cover default-source installation, malformed and incomplete metadata, version mismatches, corrupt assets, invalid checksums, incomplete manifests, and GitHub fallback failures in the shell installer tests. GitOrigin-RevId: 1c65d527ca79761358c3a4cd7a5fd675e1ef75b2
1090 lines
36 KiB
PowerShell
1090 lines
36 KiB
PowerShell
[CmdletBinding()]
|
|
param(
|
|
[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)$"
|
|
$DefaultPreferReleasesOpenAICom = $true
|
|
$PreferReleasesOpenAICom = if ([string]::IsNullOrWhiteSpace($env:CODEX_INSTALLER_USE_RELEASES_OPENAI_COM)) {
|
|
$DefaultPreferReleasesOpenAICom
|
|
} else {
|
|
$env:CODEX_INSTALLER_USE_RELEASES_OPENAI_COM -match "^(?i:1|true|yes)$"
|
|
}
|
|
$ReleasesBaseUri = "https://releases.openai.com/codex"
|
|
$ReleasesMetadataTimeoutSec = 30
|
|
$ReleasesAssetTimeoutSec = 300
|
|
|
|
function Write-Step {
|
|
param(
|
|
[string]$Message
|
|
)
|
|
|
|
Write-Host "==> $Message"
|
|
}
|
|
|
|
function Write-WarningStep {
|
|
param(
|
|
[string]$Message
|
|
)
|
|
|
|
Write-Warning $Message
|
|
}
|
|
|
|
function Prompt-YesNo {
|
|
param(
|
|
[string]$Prompt
|
|
)
|
|
|
|
if ($NonInteractive) {
|
|
return $false
|
|
}
|
|
|
|
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
|
|
)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($RawVersion) -or $RawVersion -eq "latest") {
|
|
return "latest"
|
|
}
|
|
|
|
if ($RawVersion.StartsWith("rust-v")) {
|
|
return $RawVersion.Substring(6)
|
|
}
|
|
|
|
if ($RawVersion.StartsWith("v")) {
|
|
return $RawVersion.Substring(1)
|
|
}
|
|
|
|
return $RawVersion
|
|
}
|
|
|
|
function Assert-ValidReleaseVersion {
|
|
param(
|
|
[string]$Version
|
|
)
|
|
|
|
if ($Version -cne "latest" -and $Version -cnotmatch "^[0-9]+\.[0-9]+\.[0-9]+(?:-alpha(?:\.[0-9]+){0,2}|-beta(?:\.[0-9]+)?)?$") {
|
|
throw "Invalid Codex release version: $Version. Expected latest or x.y.z[-alpha[.N[.M]]|-beta[.N]]."
|
|
}
|
|
}
|
|
|
|
function Find-ReleaseAssetMetadata {
|
|
param(
|
|
[string]$AssetName,
|
|
[object]$ReleaseMetadata,
|
|
[string]$Url = $null,
|
|
[string]$FallbackUrl = $null
|
|
)
|
|
|
|
$asset = $ReleaseMetadata.assets | Where-Object { $_.name -eq $AssetName } | Select-Object -First 1
|
|
if ($null -eq $asset) {
|
|
return $null
|
|
}
|
|
|
|
$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 = if ([string]::IsNullOrWhiteSpace($Url)) { $asset.browser_download_url } else { $Url }
|
|
FallbackUrl = $FallbackUrl
|
|
Sha256 = $digestMatch.Groups[1].Value.ToLowerInvariant()
|
|
}
|
|
}
|
|
|
|
function Invoke-WebRequestWithFallback {
|
|
param(
|
|
[object]$Metadata,
|
|
[string]$OutFile,
|
|
[string]$ExpectedDigest,
|
|
[string]$AssetName,
|
|
[string]$ReleaseVersion,
|
|
[string]$RequiredManifestAsset
|
|
)
|
|
|
|
try {
|
|
if ($Metadata.Url.StartsWith("$ReleasesBaseUri/", [System.StringComparison]::OrdinalIgnoreCase)) {
|
|
Invoke-WebRequest -UseBasicParsing -Uri $Metadata.Url -OutFile $OutFile -TimeoutSec $ReleasesAssetTimeoutSec
|
|
} else {
|
|
Invoke-WebRequest -UseBasicParsing -Uri $Metadata.Url -OutFile $OutFile
|
|
}
|
|
Test-ArchiveDigest -ArchivePath $OutFile -ExpectedDigest $ExpectedDigest
|
|
if (-not [string]::IsNullOrWhiteSpace($RequiredManifestAsset)) {
|
|
$null = Get-PackageArchiveDigest -ManifestPath $OutFile -AssetName $RequiredManifestAsset
|
|
}
|
|
} catch {
|
|
if ([string]::IsNullOrWhiteSpace($Metadata.FallbackUrl)) {
|
|
throw
|
|
}
|
|
Write-WarningStep "Could not download or verify $($Metadata.Url); retrying from GitHub Releases."
|
|
Invoke-WebRequest -UseBasicParsing -Uri $Metadata.FallbackUrl -OutFile $OutFile
|
|
try {
|
|
Test-ArchiveDigest -ArchivePath $OutFile -ExpectedDigest $ExpectedDigest
|
|
if (-not [string]::IsNullOrWhiteSpace($RequiredManifestAsset)) {
|
|
$null = Get-PackageArchiveDigest -ManifestPath $OutFile -AssetName $RequiredManifestAsset
|
|
}
|
|
} catch {
|
|
$githubRelease = Resolve-ReleaseFromGitHub -NormalizedVersion $ReleaseVersion
|
|
$githubAssetMetadata = Find-ReleaseAssetMetadata -AssetName $AssetName -ReleaseMetadata $githubRelease.Metadata
|
|
if ($null -eq $githubAssetMetadata) {
|
|
throw "Could not find GitHub release metadata for asset $AssetName."
|
|
}
|
|
Test-ArchiveDigest -ArchivePath $OutFile -ExpectedDigest $githubAssetMetadata.Sha256
|
|
if (-not [string]::IsNullOrWhiteSpace($RequiredManifestAsset)) {
|
|
$null = Get-PackageArchiveDigest -ManifestPath $OutFile -AssetName $RequiredManifestAsset
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function Resolve-ReleaseAssetSelection {
|
|
param(
|
|
[object]$ResolvedRelease,
|
|
[string]$Target,
|
|
[string]$NpmTag
|
|
)
|
|
|
|
$version = $ResolvedRelease.Version
|
|
$releaseMetadata = $ResolvedRelease.Metadata
|
|
$packageAsset = "codex-package-$Target.tar.gz"
|
|
$checksumAsset = "codex-package_SHA256SUMS"
|
|
$packageUrl = $null
|
|
$packageFallbackUrl = $null
|
|
$checksumUrl = $null
|
|
$checksumFallbackUrl = $null
|
|
if ($ResolvedRelease.Source -eq "ReleasesOpenAICom") {
|
|
$packageUrl = "$ReleasesBaseUri/releases/$version/$packageAsset"
|
|
$packageFallbackUrl = "https://github.com/openai/codex/releases/download/rust-v$version/$packageAsset"
|
|
$checksumUrl = "$ReleasesBaseUri/releases/$version/$checksumAsset"
|
|
$checksumFallbackUrl = "https://github.com/openai/codex/releases/download/rust-v$version/$checksumAsset"
|
|
}
|
|
|
|
$packageMetadata = Find-ReleaseAssetMetadata -AssetName $packageAsset -ReleaseMetadata $releaseMetadata -Url $packageUrl -FallbackUrl $packageFallbackUrl
|
|
$checksumMetadata = Find-ReleaseAssetMetadata -AssetName $checksumAsset -ReleaseMetadata $releaseMetadata -Url $checksumUrl -FallbackUrl $checksumFallbackUrl
|
|
if ($null -ne $packageMetadata -and $null -ne $checksumMetadata) {
|
|
return [PSCustomObject]@{
|
|
PackageAsset = $packageAsset
|
|
PackageMetadata = $packageMetadata
|
|
ChecksumMetadata = $checksumMetadata
|
|
InstallLayout = "Package"
|
|
}
|
|
}
|
|
|
|
$packageAsset = "codex-npm-$NpmTag-$version.tgz"
|
|
$packageUrl = $null
|
|
$packageFallbackUrl = $null
|
|
if ($ResolvedRelease.Source -eq "ReleasesOpenAICom") {
|
|
$packageUrl = "$ReleasesBaseUri/releases/$version/$packageAsset"
|
|
$packageFallbackUrl = "https://github.com/openai/codex/releases/download/rust-v$version/$packageAsset"
|
|
}
|
|
$packageMetadata = Find-ReleaseAssetMetadata -AssetName $packageAsset -ReleaseMetadata $releaseMetadata -Url $packageUrl -FallbackUrl $packageFallbackUrl
|
|
if ($null -eq $packageMetadata) {
|
|
throw "Could not find Codex package or platform npm release assets for Codex $version."
|
|
}
|
|
|
|
return [PSCustomObject]@{
|
|
PackageAsset = $packageAsset
|
|
PackageMetadata = $packageMetadata
|
|
ChecksumMetadata = $null
|
|
InstallLayout = "LegacyPlatformNpm"
|
|
}
|
|
}
|
|
|
|
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 expected digest. Expected $ExpectedDigest but got $actualDigest."
|
|
}
|
|
}
|
|
|
|
function Get-PackageArchiveDigest {
|
|
param(
|
|
[string]$ManifestPath,
|
|
[string]$AssetName
|
|
)
|
|
|
|
$escapedAssetName = [regex]::Escape($AssetName)
|
|
foreach ($line in Get-Content -LiteralPath $ManifestPath) {
|
|
$match = [regex]::Match($line, "^\s*([0-9a-fA-F]{64})\s+$escapedAssetName\s*$")
|
|
if ($match.Success) {
|
|
return $match.Groups[1].Value.ToLowerInvariant()
|
|
}
|
|
}
|
|
|
|
throw "Could not find SHA-256 digest for $AssetName in codex-package_SHA256SUMS."
|
|
}
|
|
|
|
function Path-Contains {
|
|
param(
|
|
[string]$PathValue,
|
|
[string]$Entry
|
|
)
|
|
|
|
if ([string]::IsNullOrWhiteSpace($PathValue)) {
|
|
return $false
|
|
}
|
|
|
|
$needle = $Entry.TrimEnd("\")
|
|
foreach ($segment in $PathValue.Split(";", [System.StringSplitOptions]::RemoveEmptyEntries)) {
|
|
if ($segment.TrimEnd("\") -ieq $needle) {
|
|
return $true
|
|
}
|
|
}
|
|
|
|
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,
|
|
[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 Remove-StaleInstallArtifacts {
|
|
param(
|
|
[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
|
|
}
|
|
}
|
|
|
|
function Resolve-VersionFromReleaseMetadata {
|
|
param(
|
|
[object]$ReleaseMetadata
|
|
)
|
|
|
|
if (-not $ReleaseMetadata.tag_name) {
|
|
throw "Failed to resolve the latest Codex release version."
|
|
}
|
|
|
|
$resolvedVersion = Normalize-Version -RawVersion $ReleaseMetadata.tag_name
|
|
Assert-ValidReleaseVersion -Version $resolvedVersion
|
|
return $resolvedVersion
|
|
}
|
|
|
|
function Resolve-ReleaseFromGitHub {
|
|
param(
|
|
[string]$NormalizedVersion
|
|
)
|
|
|
|
if ($NormalizedVersion -eq "latest") {
|
|
$requestedRelease = "latest"
|
|
$metadataUri = "https://api.github.com/repos/openai/codex/releases/latest"
|
|
} else {
|
|
$resolvedVersion = $NormalizedVersion
|
|
$requestedRelease = $resolvedVersion
|
|
$metadataUri = "https://api.github.com/repos/openai/codex/releases/tags/rust-v$resolvedVersion"
|
|
}
|
|
|
|
try {
|
|
$releaseMetadata = Invoke-RestMethod -Uri $metadataUri
|
|
} catch {
|
|
throw "Could not fetch GitHub release metadata for Codex $requestedRelease. GitHub API may be unavailable or rate limited. $($_.Exception.Message)"
|
|
}
|
|
|
|
if ($NormalizedVersion -eq "latest") {
|
|
$resolvedVersion = Resolve-VersionFromReleaseMetadata -ReleaseMetadata $releaseMetadata
|
|
}
|
|
|
|
return [PSCustomObject]@{
|
|
Version = $resolvedVersion
|
|
Metadata = $releaseMetadata
|
|
Source = "GitHub"
|
|
}
|
|
}
|
|
|
|
function Resolve-ReleaseFromReleases {
|
|
param(
|
|
[string]$NormalizedVersion
|
|
)
|
|
|
|
$metadataUri = if ($NormalizedVersion -eq "latest") {
|
|
"$ReleasesBaseUri/channels/latest"
|
|
} else {
|
|
"$ReleasesBaseUri/releases/$NormalizedVersion/release.json"
|
|
}
|
|
try {
|
|
$metadataResponse = Invoke-WebRequest -UseBasicParsing -Uri $metadataUri -TimeoutSec $ReleasesMetadataTimeoutSec
|
|
$releaseMetadata = [string]$metadataResponse.Content | ConvertFrom-Json -ErrorAction Stop
|
|
$resolvedVersion = Resolve-VersionFromReleaseMetadata -ReleaseMetadata $releaseMetadata
|
|
if ($NormalizedVersion -ne "latest" -and $resolvedVersion -cne $NormalizedVersion) {
|
|
throw "Release metadata version did not match requested Codex version $NormalizedVersion."
|
|
}
|
|
$resolvedRelease = [PSCustomObject]@{
|
|
Version = $resolvedVersion
|
|
Metadata = $releaseMetadata
|
|
Source = "ReleasesOpenAICom"
|
|
}
|
|
$null = Resolve-ReleaseAssetSelection -ResolvedRelease $resolvedRelease -Target $target -NpmTag $npmTag
|
|
} catch {
|
|
return $null
|
|
}
|
|
return $resolvedRelease
|
|
}
|
|
|
|
function Resolve-Release {
|
|
$normalizedVersion = Normalize-Version -RawVersion $Release
|
|
Assert-ValidReleaseVersion -Version $normalizedVersion
|
|
|
|
if ($PreferReleasesOpenAICom) {
|
|
$release = Resolve-ReleaseFromReleases -NormalizedVersion $normalizedVersion
|
|
if ($null -ne $release) {
|
|
return $release
|
|
}
|
|
Write-WarningStep "releases.openai.com is unavailable; falling back to GitHub Releases."
|
|
}
|
|
|
|
return Resolve-ReleaseFromGitHub -NormalizedVersion $normalizedVersion
|
|
}
|
|
|
|
function Get-VersionFromBinary {
|
|
param(
|
|
[string]$CodexPath
|
|
)
|
|
|
|
if (-not (Test-Path -LiteralPath $CodexPath -PathType Leaf)) {
|
|
return $null
|
|
}
|
|
|
|
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 "bin\codex.exe")
|
|
if (-not [string]::IsNullOrWhiteSpace($standaloneVersion)) {
|
|
return $standaloneVersion
|
|
}
|
|
|
|
$standaloneVersion = Get-VersionFromBinary -CodexPath (Join-Path $StandaloneCurrentDir "codex.exe")
|
|
if (-not [string]::IsNullOrWhiteSpace($standaloneVersion)) {
|
|
return $standaloneVersion
|
|
}
|
|
|
|
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 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
|
|
)
|
|
|
|
Add-JunctionSupportType
|
|
[CodexInstaller.Junction]::SetTarget($LinkPath, $TargetPath)
|
|
}
|
|
|
|
function Test-IsJunction {
|
|
param(
|
|
[string]$Path
|
|
)
|
|
|
|
if (-not (Test-Path -LiteralPath $Path)) {
|
|
return $false
|
|
}
|
|
|
|
$item = Get-Item -LiteralPath $Path -Force
|
|
return ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -and $item.LinkType -eq "Junction"
|
|
}
|
|
|
|
function Ensure-Junction {
|
|
param(
|
|
[string]$LinkPath,
|
|
[string]$TargetPath,
|
|
[string]$InstallerOwnedTargetPrefix
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
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."
|
|
}
|
|
|
|
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-PackageContentsAreComplete {
|
|
param(
|
|
[string]$PackageDir
|
|
)
|
|
|
|
if (-not (Test-Path -LiteralPath $PackageDir -PathType Container)) {
|
|
return $false
|
|
}
|
|
|
|
$expectedFiles = @(
|
|
"codex-package.json",
|
|
"bin\codex.exe",
|
|
"bin\codex-code-mode-host.exe",
|
|
"codex-path\rg.exe",
|
|
"codex-resources\codex-command-runner.exe",
|
|
"codex-resources\codex-windows-sandbox-setup.exe"
|
|
)
|
|
foreach ($name in $expectedFiles) {
|
|
if (-not (Test-Path -LiteralPath (Join-Path $PackageDir $name) -PathType Leaf)) {
|
|
return $false
|
|
}
|
|
}
|
|
|
|
return $true
|
|
}
|
|
|
|
function Test-LegacyPlatformNpmContentsAreComplete {
|
|
param(
|
|
[string]$PackageDir
|
|
)
|
|
|
|
if (-not (Test-Path -LiteralPath $PackageDir -PathType Container)) {
|
|
return $false
|
|
}
|
|
|
|
$expectedFiles = @(
|
|
"codex.exe",
|
|
"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 $PackageDir $name) -PathType Leaf)) {
|
|
return $false
|
|
}
|
|
}
|
|
|
|
return $true
|
|
}
|
|
|
|
function Test-ReleaseIsComplete {
|
|
param(
|
|
[string]$ReleaseDir,
|
|
[string]$ExpectedVersion,
|
|
[string]$ExpectedTarget,
|
|
[string]$Layout
|
|
)
|
|
|
|
switch ($Layout) {
|
|
"Package" {
|
|
if (-not (Test-PackageContentsAreComplete -PackageDir $ReleaseDir)) {
|
|
return $false
|
|
}
|
|
$codexPath = Join-Path $ReleaseDir "bin\codex.exe"
|
|
}
|
|
"LegacyPlatformNpm" {
|
|
if (-not (Test-LegacyPlatformNpmContentsAreComplete -PackageDir $ReleaseDir)) {
|
|
return $false
|
|
}
|
|
$codexPath = Join-Path $ReleaseDir "codex.exe"
|
|
}
|
|
default {
|
|
throw "Unknown Codex installer layout: $Layout"
|
|
}
|
|
}
|
|
|
|
return (Split-Path -Leaf $ReleaseDir) -eq "$ExpectedVersion-$ExpectedTarget" -and
|
|
(Get-VersionFromBinary -CodexPath $codexPath) -ceq $ExpectedVersion
|
|
}
|
|
|
|
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 Get-ConflictingInstall {
|
|
param(
|
|
[string]$VisibleBinDir
|
|
)
|
|
|
|
$existingPath = Get-ExistingCodexCommand
|
|
$manager = Get-ExistingCodexManager -ExistingPath $existingPath -VisibleBinDir $VisibleBinDir
|
|
if ($null -eq $manager) {
|
|
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 {
|
|
@("uninstall", "-g", "@openai/codex")
|
|
}
|
|
$uninstallCommand = if ($manager -eq "bun") { "bun" } else { "npm" }
|
|
|
|
if (Prompt-YesNo "Uninstall the existing $manager-managed Codex now?") {
|
|
Write-Step "Running: $uninstallCommand $($uninstallArgs -join ' ')"
|
|
try {
|
|
& $uninstallCommand @uninstallArgs
|
|
} catch {
|
|
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."
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
if (-not [Environment]::Is64BitOperatingSystem) {
|
|
Write-Error "Codex requires a 64-bit version of Windows."
|
|
exit 1
|
|
}
|
|
|
|
$architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
|
|
$target = $null
|
|
$platformLabel = $null
|
|
$npmTag = $null
|
|
switch ($architecture) {
|
|
"Arm64" {
|
|
$target = "aarch64-pc-windows-msvc"
|
|
$platformLabel = "Windows (ARM64)"
|
|
$npmTag = "win32-arm64"
|
|
}
|
|
"X64" {
|
|
$target = "x86_64-pc-windows-msvc"
|
|
$platformLabel = "Windows (x64)"
|
|
$npmTag = "win32-x64"
|
|
}
|
|
default {
|
|
Write-Error "Unsupported architecture: $architecture"
|
|
exit 1
|
|
}
|
|
}
|
|
|
|
$codexHome = if ([string]::IsNullOrWhiteSpace($env:CODEX_HOME)) {
|
|
Join-Path $env:USERPROFILE ".codex"
|
|
} else {
|
|
$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 = $defaultVisibleBinDir
|
|
} else {
|
|
$visibleBinDir = $env:CODEX_INSTALL_DIR
|
|
}
|
|
|
|
$currentVersion = Get-CurrentInstalledVersion -StandaloneCurrentDir $currentDir
|
|
$resolvedRelease = Resolve-Release
|
|
$resolvedVersion = $resolvedRelease.Version
|
|
$releaseMetadata = $resolvedRelease.Metadata
|
|
$releaseName = "$resolvedVersion-$target"
|
|
$releaseDir = Join-Path $releasesDir $releaseName
|
|
|
|
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"
|
|
|
|
$conflictingInstall = Get-ConflictingInstall -VisibleBinDir $visibleBinDir
|
|
$oldStandaloneBackup = $null
|
|
|
|
$checksumAsset = "codex-package_SHA256SUMS"
|
|
$assetSelection = Resolve-ReleaseAssetSelection -ResolvedRelease $resolvedRelease -Target $target -NpmTag $npmTag
|
|
$packageAsset = $assetSelection.PackageAsset
|
|
$packageMetadata = $assetSelection.PackageMetadata
|
|
$checksumMetadata = $assetSelection.ChecksumMetadata
|
|
$installLayout = $assetSelection.InstallLayout
|
|
$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("codex-install-" + [System.Guid]::NewGuid().ToString("N"))
|
|
New-Item -ItemType Directory -Force -Path $tempDir | Out-Null
|
|
|
|
try {
|
|
Invoke-WithInstallLock -LockPath $lockPath -Script {
|
|
Remove-StaleInstallArtifacts -ReleasesDir $releasesDir
|
|
|
|
if (-not (Test-ReleaseIsComplete -ReleaseDir $releaseDir -ExpectedVersion $resolvedVersion -ExpectedTarget $target -Layout $installLayout)) {
|
|
if (Test-Path -LiteralPath $releaseDir) {
|
|
Write-WarningStep "Found incomplete existing release at $releaseDir. Reinstalling."
|
|
}
|
|
|
|
$archivePath = Join-Path $tempDir $packageAsset
|
|
$checksumPath = Join-Path $tempDir $checksumAsset
|
|
$stagingDir = Join-Path $releasesDir ".staging.$releaseName.$PID"
|
|
|
|
Write-Step "Downloading Codex CLI"
|
|
if ($installLayout -eq "Package") {
|
|
Invoke-WebRequestWithFallback -Metadata $checksumMetadata -OutFile $checksumPath -ExpectedDigest $checksumMetadata.Sha256 -AssetName $checksumAsset -ReleaseVersion $resolvedVersion -RequiredManifestAsset $packageAsset
|
|
$expectedPackageDigest = Get-PackageArchiveDigest -ManifestPath $checksumPath -AssetName $packageAsset
|
|
} else {
|
|
$expectedPackageDigest = $packageMetadata.Sha256
|
|
}
|
|
Invoke-WebRequestWithFallback -Metadata $packageMetadata -OutFile $archivePath -ExpectedDigest $expectedPackageDigest -AssetName $packageAsset -ReleaseVersion $resolvedVersion
|
|
|
|
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
|
|
if ($installLayout -eq "Package") {
|
|
tar -xzf $archivePath -C $stagingDir
|
|
if (-not (Test-PackageContentsAreComplete -PackageDir $stagingDir)) {
|
|
throw "Downloaded Codex package archive did not contain the expected package layout."
|
|
}
|
|
} else {
|
|
$extractDir = Join-Path $tempDir "extract"
|
|
New-Item -ItemType Directory -Force -Path $extractDir | 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])
|
|
}
|
|
|
|
if (-not (Test-LegacyPlatformNpmContentsAreComplete -PackageDir $stagingDir)) {
|
|
throw "Downloaded Codex npm archive did not contain the expected legacy platform package layout."
|
|
}
|
|
}
|
|
|
|
if (Test-Path -LiteralPath $releaseDir) {
|
|
Remove-Item -LiteralPath $releaseDir -Recurse -Force
|
|
}
|
|
Move-Item -LiteralPath $stagingDir -Destination $releaseDir
|
|
}
|
|
|
|
if (-not (Test-ReleaseIsComplete -ReleaseDir $releaseDir -ExpectedVersion $resolvedVersion -ExpectedTarget $target -Layout $installLayout)) {
|
|
throw "Installed Codex command did not report expected version $resolvedVersion."
|
|
}
|
|
|
|
New-Item -ItemType Directory -Force -Path $standaloneRoot | Out-Null
|
|
Ensure-Junction -LinkPath $currentDir -TargetPath $releaseDir -InstallerOwnedTargetPrefix $releasesDir
|
|
|
|
$visibleParent = Split-Path -Parent $visibleBinDir
|
|
$currentBinDir = if ($installLayout -eq "Package") {
|
|
Join-Path $currentDir "bin"
|
|
} else {
|
|
$currentDir
|
|
}
|
|
New-Item -ItemType Directory -Force -Path $visibleParent | Out-Null
|
|
$oldStandaloneBackup = Move-OldStandaloneBinIfApproved -VisibleBinDir $visibleBinDir -DefaultVisibleBinDir $defaultVisibleBinDir
|
|
try {
|
|
Ensure-Junction -LinkPath $visibleBinDir -TargetPath $currentBinDir -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
|
|
}
|
|
}
|
|
} finally {
|
|
Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue
|
|
}
|
|
|
|
Maybe-HandleConflictingInstall -Conflict $conflictingInstall
|
|
|
|
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
|
|
$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 {
|
|
$newUserPath = "$visibleBinDir;$userPath"
|
|
}
|
|
|
|
[Environment]::SetEnvironmentVariable("Path", $newUserPath, "User")
|
|
Write-Step "PATH updated for future PowerShell sessions."
|
|
} 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."
|
|
}
|
|
|
|
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 {
|
|
$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
|
|
}
|