From 64ef6cd1e4d17c9c2aaa424e65d90011c64fd1c2 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Tue, 19 May 2026 17:54:41 -0700 Subject: [PATCH 01/11] Fan out rust-ci-full nextest by platform (#23358) ## Why `rust-ci-full` was paying the full Cargo nextest build-and-run cost once per platform, with Windows ARM64 as the long pole. This change moves the heavy work into one reusable per-platform flow: build a nextest archive once, then replay it across four shards so the platform lane spends less time running tests serially. For Windows ARM64, the archive is cross-compiled on Windows x64 and replayed on native Windows ARM64 shards so the slow ARM64 machine is used for execution rather than compilation. ## What changed - split the `rust-ci-full` nextest matrix into five explicit per-platform reusable-workflow calls - add `.github/workflows/rust-ci-full-nextest-platform.yml` to build one archive, upload timings/helpers, replay four nextest shards, upload per-shard JUnit, and roll the shard status back up per platform - add Windows CI helpers for Dev Drive setup and MSVC ARM64 linker environment export so the Windows ARM64 archive can be produced on Windows x64 - keep the existing Cargo git CLI fetch hardening inside the reusable workflow, since caller workflow-level `env` does not flow through `workflow_call` - document the archive-backed shard shape in `.github/workflows/README.md` - raise the default nextest slow timeout to 30s so the sharded full-CI path does not treat every >15s test as stuck ## Verification - validated the archive/shard flow with live GitHub Actions runs on this PR branch - Windows ARM64 cross-compile latency on completed runs: - https://github.com/openai/codex/actions/runs/26118759651: `34m30s` lane e2e, `17m16s` archive build, `9m55s` shard phase - https://github.com/openai/codex/actions/runs/26120777976: `30m36s` lane e2e, `17m21s` archive build, `6m50s` shard phase - comparable pre-cross-compile sharded Windows ARM64 runs were `55m01s`, `50m21s`, and `46m42s`, so the completed cross-compile runs improved the lane by roughly `12m` to `24m` versus the prior range - latest corrected cross-compile run: https://github.com/openai/codex/actions/runs/26120777976 - Windows ARM64 archive built successfully on Windows x64 - native Windows ARM64 shards started immediately after the archive upload - 3/4 Windows ARM64 shards passed; the failing shard hit the same existing `code_mode` test failure seen outside this lane - downloaded failed-shard JUnit XML from the validation runs and confirmed the remaining red is from known test failures, not archive/shard wiring - no local Codex tests run per repo guidance ## Notes - this PR does not change developers.openai.com documentation --- .github/actions/setup-msvc-env/action.yml | 17 + .../actions/setup-msvc-env/setup-msvc-env.ps1 | 257 ++++++++++ .github/scripts/setup-dev-drive.ps1 | 62 +++ .github/workflows/README.md | 3 +- .../rust-ci-full-nextest-platform.yml | 464 ++++++++++++++++++ .github/workflows/rust-ci-full.yml | 313 +++--------- codex-rs/.config/nextest.toml | 4 +- 7 files changed, 883 insertions(+), 237 deletions(-) create mode 100644 .github/actions/setup-msvc-env/action.yml create mode 100644 .github/actions/setup-msvc-env/setup-msvc-env.ps1 create mode 100644 .github/scripts/setup-dev-drive.ps1 create mode 100644 .github/workflows/rust-ci-full-nextest-platform.yml diff --git a/.github/actions/setup-msvc-env/action.yml b/.github/actions/setup-msvc-env/action.yml new file mode 100644 index 0000000000..287cb7f217 --- /dev/null +++ b/.github/actions/setup-msvc-env/action.yml @@ -0,0 +1,17 @@ +name: setup-msvc-env +description: Expose an MSVC developer environment for the requested Windows target. +inputs: + target: + description: Rust target triple that will be built on this Windows runner. + required: true + host-arch: + description: Optional Visual Studio host architecture override. + required: false + default: "" + +runs: + using: composite + steps: + - name: Expose MSVC SDK environment + shell: pwsh + run: '& "$env:GITHUB_ACTION_PATH/setup-msvc-env.ps1" -Target "${{ inputs.target }}" -HostArch "${{ inputs.host-arch }}"' diff --git a/.github/actions/setup-msvc-env/setup-msvc-env.ps1 b/.github/actions/setup-msvc-env/setup-msvc-env.ps1 new file mode 100644 index 0000000000..e2706d9117 --- /dev/null +++ b/.github/actions/setup-msvc-env/setup-msvc-env.ps1 @@ -0,0 +1,257 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Target, + + [string]$HostArch = "" +) + +# Cargo can cross-compile the Rust code for Windows ARM64 on a Windows x64 +# runner, but rustup alone does not expose the matching MSVC/UCRT include and +# library paths. Ask Visual Studio for the target-specific developer +# environment, then persist the relevant variables through GITHUB_ENV so the +# later Cargo step sees the same environment as a normal VsDevCmd shell. +switch ($Target) { + "x86_64-pc-windows-msvc" { + $TargetArch = "x64" + $RequiredComponent = "Microsoft.VisualStudio.Component.VC.Tools.x86.x64" + } + "aarch64-pc-windows-msvc" { + $TargetArch = "arm64" + $RequiredComponent = "Microsoft.VisualStudio.Component.VC.Tools.ARM64" + } + default { + throw "Unsupported Windows MSVC target: $Target" + } +} + +# VsDevCmd needs both sides of the cross compile: the architecture of the +# machine running the tools and the architecture of the binaries being linked. +# Infer the host from the runner unless a caller needs to override it. +if (-not $HostArch) { + $HostArch = if ($env:PROCESSOR_ARCHITEW6432 -eq "ARM64" -or $env:PROCESSOR_ARCHITECTURE -eq "ARM64") { + "arm64" + } else { + "x64" + } +} + +$VsWhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +if (-not (Test-Path $VsWhere)) { + throw "vswhere.exe not found" +} + +# Require the target VC tools component, not merely any Visual Studio install, +# so an x64 archive producer cannot silently link ARM64 tests with the wrong +# SDK/toolchain layout. +$InstallPath = & $VsWhere -latest -products * -requires $RequiredComponent -property installationPath 2>$null +if (-not $InstallPath) { + throw "Could not locate a Visual Studio installation with component $RequiredComponent" +} + +$VsDevCmd = Join-Path $InstallPath "Common7\Tools\VsDevCmd.bat" +if (-not (Test-Path $VsDevCmd)) { + throw "VsDevCmd.bat not found at $VsDevCmd" +} + +$VarsToExport = @( + "INCLUDE", + "LIB", + "LIBPATH", + "PATH", + "UCRTVersion", + "UniversalCRTSdkDir", + "VCINSTALLDIR", + "VCToolsInstallDir", + "WindowsLibPath", + "WindowsSdkBinPath", + "WindowsSdkDir", + "WindowsSDKLibVersion", + "WindowsSDKVersion" +) + +# Run VsDevCmd inside cmd.exe because it is a batch file, then copy just the +# variables Cargo/rustc need into the GitHub Actions environment file. PowerShell +# cannot mutate the parent composite-action environment directly. +$EnvLines = & cmd.exe /c ('"{0}" -no_logo -arch={1} -host_arch={2} >nul && set' -f $VsDevCmd, $TargetArch, $HostArch) +$VcToolsInstallDir = $null +foreach ($Line in $EnvLines) { + if ($Line -notmatch "^(.*?)=(.*)$") { + continue + } + + $Name = $Matches[1] + $Value = $Matches[2] + if ($VarsToExport -contains $Name) { + if ($Name -ieq "Path") { + $Name = "PATH" + } + if ($Name -eq "VCToolsInstallDir") { + $VcToolsInstallDir = $Value + } + "$Name=$Value" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + } +} + +if (-not $VcToolsInstallDir) { + throw "VCToolsInstallDir was not exported by VsDevCmd.bat" +} + +# Prefer Rust's bundled linker when rustup provides one, then Visual Studio's +# LLVM linker, and finally MSVC link.exe. This keeps the cross-compile path close +# to Rust's normal Windows MSVC behavior while still working on runner images +# where one of those linkers is absent. +$Linker = $null +$Rustc = Get-Command rustc -ErrorAction SilentlyContinue +if ($Rustc) { + $Sysroot = (& rustc --print sysroot 2>$null).Trim() + $RustHost = & rustc -vV 2>$null | Select-String "^host: " | ForEach-Object { $_.Line.Substring(6) } + if ($RustHost) { + $RustHost = $RustHost.Trim() + } + if ($Sysroot -and $RustHost) { + $RustLld = Join-Path $Sysroot "lib\rustlib\$RustHost\bin\rust-lld.exe" + if (Test-Path $RustLld) { + $Linker = $RustLld + } + } +} +if (-not $Linker) { + $Linker = Join-Path $InstallPath "VC\Tools\Llvm\x64\bin\lld-link.exe" +} +if (-not (Test-Path $Linker)) { + $Linker = Join-Path $VcToolsInstallDir "bin\Host${HostArch}\${TargetArch}\link.exe" +} +if (-not (Test-Path $Linker)) { + throw "Windows linker not found at $Linker" +} + +# rustc passes `/arm64hazardfree` for ARM64 MSVC links. The lld variants on our +# Windows x64 archive producers reject that flag, including when rustc places it +# inside a response file. Compile a tiny forwarding wrapper that strips only +# that unsupported flag, then delegate every other argument to the real linker. +if ($TargetArch -eq "arm64" -and (Split-Path -Leaf $Linker) -match "lld") { + $WrapperDir = Join-Path $env:RUNNER_TEMP "msvc-lld-wrapper" + New-Item -Path $WrapperDir -ItemType Directory -Force | Out-Null + $WrapperPath = Join-Path $WrapperDir "lld-link-wrapper.exe" + $WrapperSource = @' +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; + +internal static class Program +{ + private static int Main(string[] args) + { + var linker = Environment.GetEnvironmentVariable("MSVC_REAL_LINKER"); + if (string.IsNullOrEmpty(linker)) + { + Console.Error.WriteLine("MSVC_REAL_LINKER is not set"); + return 1; + } + + var startInfo = new ProcessStartInfo(linker) + { + UseShellExecute = false, + }; + var filteredArgs = new List { "-flavor", "link", "/defaultlib:ucrt", "/nodefaultlib:libucrt" }; + foreach (var arg in args) + { + if (!string.Equals(arg, "/arm64hazardfree", StringComparison.OrdinalIgnoreCase)) + { + filteredArgs.Add(QuoteArgument(FilterResponseFile(arg))); + } + } + startInfo.Arguments = string.Join(" ", filteredArgs); + + using var process = Process.Start(startInfo); + if (process is null) + { + Console.Error.WriteLine($"Failed to start linker: {linker}"); + return 1; + } + + process.WaitForExit(); + return process.ExitCode; + } + + private static string FilterResponseFile(string argument) + { + if (argument.Length < 2 || argument[0] != '@') + { + return argument; + } + + var responsePath = argument.Substring(1); + if (!File.Exists(responsePath)) + { + return argument; + } + + var filteredResponsePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".rsp"); + var responseContents = Regex.Replace( + File.ReadAllText(responsePath), + "/arm64hazardfree", + string.Empty, + RegexOptions.IgnoreCase); + File.WriteAllText(filteredResponsePath, responseContents); + return "@" + filteredResponsePath; + } + + private static string QuoteArgument(string argument) + { + if (argument.Length == 0) + { + return "\"\""; + } + if (argument.IndexOfAny(new[] { ' ', '\t', '"' }) < 0) + { + return argument; + } + + var quoted = new StringBuilder("\""); + var backslashes = 0; + foreach (var character in argument) + { + if (character == '\\') + { + backslashes++; + continue; + } + if (character == '"') + { + quoted.Append('\\', (backslashes * 2) + 1); + quoted.Append(character); + backslashes = 0; + continue; + } + + quoted.Append('\\', backslashes); + backslashes = 0; + quoted.Append(character); + } + quoted.Append('\\', backslashes * 2); + quoted.Append('"'); + return quoted.ToString(); + } +} +'@ + $WrapperSourcePath = Join-Path $WrapperDir "lld-link-wrapper.cs" + $WrapperSource | Out-File -FilePath $WrapperSourcePath -Encoding utf8 + $Csc = Join-Path $InstallPath "MSBuild\Current\Bin\Roslyn\csc.exe" + if (-not (Test-Path $Csc)) { + throw "csc.exe not found at $Csc" + } + & $Csc /nologo /target:exe /out:$WrapperPath $WrapperSourcePath + if ($LASTEXITCODE -ne 0) { + throw "Failed to compile lld-link wrapper" + } + "MSVC_REAL_LINKER=$Linker" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + $Linker = $WrapperPath +} + +Write-Output "Using Windows linker: $Linker" +$CargoTarget = $Target.ToUpperInvariant().Replace("-", "_") +"CARGO_TARGET_${CargoTarget}_LINKER=$Linker" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append diff --git a/.github/scripts/setup-dev-drive.ps1 b/.github/scripts/setup-dev-drive.ps1 new file mode 100644 index 0000000000..2b94e1b66f --- /dev/null +++ b/.github/scripts/setup-dev-drive.ps1 @@ -0,0 +1,62 @@ +# Configure a fast drive for Windows CI jobs. +# +# GitHub-hosted Windows runners do not always expose a secondary D: volume. When +# they do not, try to create a Dev Drive VHD and fall back to C: if the runner +# image does not allow that provisioning path. + +function Use-FallbackDrive { + param([string]$Reason) + + Write-Warning "$Reason Falling back to C:" + return "C:" +} + +function Invoke-BestEffort { + param([scriptblock]$Script, [string]$Description) + + try { + & $Script + } catch { + Write-Warning "$Description failed: $($_.Exception.Message)" + } +} + +if (Test-Path "D:\") { + Write-Output "Using existing drive at D:" + $Drive = "D:" +} else { + try { + $VhdPath = Join-Path $env:RUNNER_TEMP "codex-dev-drive.vhdx" + $SizeBytes = 64GB + + if (Test-Path $VhdPath) { + Remove-Item -Path $VhdPath -Force + } + + New-VHD -Path $VhdPath -SizeBytes $SizeBytes -Dynamic -ErrorAction Stop | Out-Null + $Mounted = Mount-VHD -Path $VhdPath -Passthru -ErrorAction Stop + $Disk = $Mounted | Get-Disk -ErrorAction Stop + $Disk | Initialize-Disk -PartitionStyle GPT -ErrorAction Stop + $Partition = $Disk | New-Partition -AssignDriveLetter -UseMaximumSize -ErrorAction Stop + $Volume = $Partition | Format-Volume -FileSystem ReFS -NewFileSystemLabel "CodexDevDrive" -DevDrive -Confirm:$false -Force -ErrorAction Stop + + $Drive = "$($Volume.DriveLetter):" + + Invoke-BestEffort { fsutil devdrv trust $Drive } "Trusting Dev Drive $Drive" + Invoke-BestEffort { fsutil devdrv enable /disallowAv } "Disabling AV filter attachment for Dev Drives" + Invoke-BestEffort { fsutil devdrv query $Drive } "Querying Dev Drive $Drive" + + Write-Output "Using Dev Drive at $Drive" + } catch { + $Drive = Use-FallbackDrive "Failed to create Dev Drive: $($_.Exception.Message)" + } +} + +$Tmp = "$Drive\codex-tmp" +New-Item -Path $Tmp -ItemType Directory -Force | Out-Null + +@( + "DEV_DRIVE=$Drive" + "TMP=$Tmp" + "TEMP=$Tmp" +) | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append diff --git a/.github/workflows/README.md b/.github/workflows/README.md index d14817f002..b2403b749c 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -21,7 +21,8 @@ The workflows in this directory are split so that pull requests get fast, review - `rust-ci-full.yml` is the full Cargo-native verification workflow. It keeps the heavier checks off the PR path while still validating them after merge: - the full Cargo `clippy` matrix - - the full Cargo `nextest` matrix + - the full Cargo `nextest` matrix via per-platform archive-backed shards + - Windows ARM64 nextest archives cross-compiled on Windows x64, then replayed on native Windows ARM64 shards - release-profile Cargo builds - cross-platform `argument-comment-lint` - Linux remote-env tests diff --git a/.github/workflows/rust-ci-full-nextest-platform.yml b/.github/workflows/rust-ci-full-nextest-platform.yml new file mode 100644 index 0000000000..7dc39d33ed --- /dev/null +++ b/.github/workflows/rust-ci-full-nextest-platform.yml @@ -0,0 +1,464 @@ +name: rust-ci-full nextest platform + +on: + workflow_call: + inputs: + runner: + required: true + type: string + runner_group: + required: false + default: "" + type: string + runner_labels: + required: false + default: "" + type: string + archive_runner: + required: false + default: "" + type: string + archive_runner_group: + required: false + default: "" + type: string + archive_runner_labels: + required: false + default: "" + type: string + target: + required: true + type: string + profile: + required: true + type: string + artifact_id: + required: true + type: string + remote_env: + required: false + default: false + type: boolean + test_threads: + required: false + default: 0 + type: number + use_sccache: + required: false + default: false + type: boolean + +# Caller workflow-level env does not flow through workflow_call, so keep the +# Cargo git transport hardening on the archive and shard jobs directly here. +env: + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + +jobs: + archive: + name: Build nextest archive + runs-on: ${{ inputs.archive_runner_group != '' && fromJSON(format('{{"group":"{0}","labels":"{1}"}}', inputs.archive_runner_group, inputs.archive_runner_labels)) || inputs.archive_runner != '' && inputs.archive_runner || inputs.runner_group != '' && fromJSON(format('{{"group":"{0}","labels":"{1}"}}', inputs.runner_group, inputs.runner_labels)) || inputs.runner }} + timeout-minutes: 60 + defaults: + run: + working-directory: codex-rs + env: + # Windows ARM64 archives are built on Windows x64, while their shards run + # on native Windows ARM64. Key producer-side caches by the archive runner + # so the cross-compile build reuses the Windows x64 cache lineage. + ARCHIVE_CACHE_RUNNER: ${{ inputs.archive_runner != '' && inputs.archive_runner || inputs.runner }} + USE_SCCACHE: ${{ inputs.use_sccache && 'true' || 'false' }} + CARGO_INCREMENTAL: "0" + SCCACHE_CACHE_SIZE: 10G + NEXTEST_ARCHIVE_FILE: nextest-${{ inputs.artifact_id }}.tar.zst + TEST_HELPERS_ARTIFACT: nextest-test-helpers-${{ inputs.artifact_id }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Configure Dev Drive (Windows) + if: ${{ runner.os == 'Windows' }} + shell: pwsh + run: ../.github/scripts/setup-dev-drive.ps1 + + - name: Install Linux build dependencies + if: ${{ runner.os == 'Linux' }} + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + sudo apt-get update -y + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev bubblewrap + fi + + - name: Install DotSlash + uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 + + - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + with: + targets: ${{ inputs.target }} + + - name: Expose MSVC SDK environment (Windows) + if: ${{ runner.os == 'Windows' && inputs.target == 'aarch64-pc-windows-msvc' }} + uses: ./.github/actions/setup-msvc-env + with: + target: ${{ inputs.target }} + + - name: Compute lockfile hash + id: lockhash + shell: bash + run: | + set -euo pipefail + echo "hash=$(sha256sum Cargo.lock | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + echo "toolchain_hash=$(sha256sum rust-toolchain.toml | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + + - name: Restore cargo home cache + id: cache_cargo_home_restore + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + key: cargo-home-${{ env.ARCHIVE_CACHE_RUNNER }}-${{ inputs.target }}-${{ inputs.profile }}-${{ steps.lockhash.outputs.hash }}-${{ steps.lockhash.outputs.toolchain_hash }} + restore-keys: | + cargo-home-${{ env.ARCHIVE_CACHE_RUNNER }}-${{ inputs.target }}-${{ inputs.profile }}- + + - name: Install sccache + if: ${{ env.USE_SCCACHE == 'true' }} + uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 + with: + tool: sccache + version: 0.7.5 + + - name: Configure sccache backend + if: ${{ env.USE_SCCACHE == 'true' }} + shell: bash + run: | + set -euo pipefail + if [[ -n "${ACTIONS_CACHE_URL:-}" && -n "${ACTIONS_RUNTIME_TOKEN:-}" ]]; then + echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" + echo "Using sccache GitHub backend" + else + echo "SCCACHE_GHA_ENABLED=false" >> "$GITHUB_ENV" + if [[ -n "${DEV_DRIVE:-}" ]]; then + echo "SCCACHE_DIR=${DEV_DRIVE}\\.sccache" >> "$GITHUB_ENV" + else + echo "SCCACHE_DIR=${{ github.workspace }}/.sccache" >> "$GITHUB_ENV" + fi + echo "Using sccache local disk + actions/cache fallback" + fi + + - name: Enable sccache wrapper + if: ${{ env.USE_SCCACHE == 'true' }} + shell: bash + run: | + set -euo pipefail + wrapper="$(command -v sccache)" + if [[ "${RUNNER_OS}" == "Windows" ]] && command -v cygpath >/dev/null 2>&1; then + wrapper="$(cygpath -w "${wrapper}")" + fi + echo "RUSTC_WRAPPER=${wrapper}" >> "$GITHUB_ENV" + echo "CARGO_BUILD_RUSTC_WRAPPER=${wrapper}" >> "$GITHUB_ENV" + + - name: Restore sccache cache (fallback) + if: ${{ env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' }} + id: cache_sccache_restore + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: ${{ env.SCCACHE_DIR }} + key: sccache-${{ env.ARCHIVE_CACHE_RUNNER }}-${{ inputs.target }}-${{ inputs.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }} + restore-keys: | + sccache-${{ env.ARCHIVE_CACHE_RUNNER }}-${{ inputs.target }}-${{ inputs.profile }}-${{ steps.lockhash.outputs.hash }}- + sccache-${{ env.ARCHIVE_CACHE_RUNNER }}-${{ inputs.target }}-${{ inputs.profile }}- + + - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 + with: + tool: nextest + version: 0.9.103 + + - name: Enable unprivileged user namespaces (Linux) + if: runner.os == 'Linux' + run: | + sudo sysctl -w kernel.unprivileged_userns_clone=1 + if sudo sysctl -a 2>/dev/null | grep -q '^kernel.apparmor_restrict_unprivileged_userns'; then + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + fi + + - name: Build nextest archive + shell: bash + run: | + set -euo pipefail + archive_dir="${RUNNER_TEMP}/nextest-archive" + mkdir -p "${archive_dir}" + cargo nextest archive \ + --target ${{ inputs.target }} \ + --cargo-profile ${{ inputs.profile }} \ + --timings \ + --archive-file "${archive_dir}/${NEXTEST_ARCHIVE_FILE}" + + - name: Build runtime test helpers + if: ${{ runner.os == 'Linux' || runner.os == 'Windows' }} + shell: bash + run: | + set -euo pipefail + helper_dir="${RUNNER_TEMP}/${TEST_HELPERS_ARTIFACT}" + mkdir -p "${helper_dir}" + + if [[ "${RUNNER_OS}" == "Linux" ]]; then + cargo build \ + --target ${{ inputs.target }} \ + --profile ${{ inputs.profile }} \ + -p codex-linux-sandbox \ + --bin codex-linux-sandbox + cp "target/${{ inputs.target }}/${{ inputs.profile }}/codex-linux-sandbox" "${helper_dir}/" + else + cargo build \ + --target ${{ inputs.target }} \ + --profile ${{ inputs.profile }} \ + -p codex-windows-sandbox \ + --bin codex-windows-sandbox-setup \ + --bin codex-command-runner + cp "target/${{ inputs.target }}/${{ inputs.profile }}/codex-windows-sandbox-setup.exe" "${helper_dir}/" + cp "target/${{ inputs.target }}/${{ inputs.profile }}/codex-command-runner.exe" "${helper_dir}/" + fi + + - name: Upload Cargo timings (nextest) + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: cargo-timings-rust-ci-nextest-${{ inputs.target }}-${{ inputs.profile }} + path: codex-rs/target/**/cargo-timings/cargo-timing.html + if-no-files-found: warn + + - name: Upload nextest archive + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: nextest-archive-${{ inputs.artifact_id }} + path: ${{ runner.temp }}/nextest-archive/${{ env.NEXTEST_ARCHIVE_FILE }} + if-no-files-found: error + retention-days: 1 + + - name: Upload runtime test helpers + if: ${{ runner.os == 'Linux' || runner.os == 'Windows' }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ env.TEST_HELPERS_ARTIFACT }} + path: ${{ runner.temp }}/${{ env.TEST_HELPERS_ARTIFACT }}/* + if-no-files-found: error + retention-days: 1 + + - name: Save cargo home cache + if: always() && !cancelled() && steps.cache_cargo_home_restore.outputs.cache-hit != 'true' + continue-on-error: true + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + key: cargo-home-${{ env.ARCHIVE_CACHE_RUNNER }}-${{ inputs.target }}-${{ inputs.profile }}-${{ steps.lockhash.outputs.hash }}-${{ steps.lockhash.outputs.toolchain_hash }} + + - name: Save sccache cache (fallback) + if: always() && !cancelled() && env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' + continue-on-error: true + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: ${{ env.SCCACHE_DIR }} + key: sccache-${{ env.ARCHIVE_CACHE_RUNNER }}-${{ inputs.target }}-${{ inputs.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }} + + - name: sccache stats + if: always() && env.USE_SCCACHE == 'true' + continue-on-error: true + run: sccache --show-stats || true + + - name: sccache summary + if: always() && env.USE_SCCACHE == 'true' + shell: bash + run: | + { + echo "### sccache stats — ${{ inputs.target }} (tests)"; + echo; + echo '```'; + sccache --show-stats || true; + echo '```'; + } >> "$GITHUB_STEP_SUMMARY" + + shard: + name: Tests shard ${{ matrix.shard }}/4 + needs: archive + runs-on: ${{ inputs.runner_group != '' && fromJSON(format('{{"group":"{0}","labels":"{1}"}}', inputs.runner_group, inputs.runner_labels)) || inputs.runner }} + timeout-minutes: 60 + defaults: + run: + working-directory: codex-rs + env: + NEXTEST_ARCHIVE_FILE: nextest-${{ inputs.artifact_id }}.tar.zst + TEST_HELPERS_ARTIFACT: nextest-test-helpers-${{ inputs.artifact_id }} + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install Linux build dependencies + if: ${{ runner.os == 'Linux' }} + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + sudo apt-get update -y + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev bubblewrap + fi + + - name: Install DotSlash + uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 + + - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + with: + targets: ${{ inputs.target }} + + - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 + with: + tool: nextest + version: 0.9.103 + + - name: Enable unprivileged user namespaces (Linux) + if: runner.os == 'Linux' + run: | + sudo sysctl -w kernel.unprivileged_userns_clone=1 + if sudo sysctl -a 2>/dev/null | grep -q '^kernel.apparmor_restrict_unprivileged_userns'; then + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + fi + + - name: Set up remote test env (Docker) + if: ${{ runner.os == 'Linux' && inputs.remote_env }} + shell: bash + run: | + set -euo pipefail + export CODEX_TEST_REMOTE_ENV_CONTAINER_NAME="codex-remote-test-env-${{ github.run_id }}-${{ matrix.shard }}" + source "${GITHUB_WORKSPACE}/scripts/test-remote-env.sh" + echo "CODEX_TEST_REMOTE_ENV=${CODEX_TEST_REMOTE_ENV}" >> "$GITHUB_ENV" + echo "CODEX_TEST_REMOTE_EXEC_SERVER_URL=${CODEX_TEST_REMOTE_EXEC_SERVER_URL}" >> "$GITHUB_ENV" + + - name: Download nextest archive + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: nextest-archive-${{ inputs.artifact_id }} + path: ${{ runner.temp }}/nextest-archive + + - name: Download runtime test helpers + if: ${{ runner.os == 'Linux' || runner.os == 'Windows' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ env.TEST_HELPERS_ARTIFACT }} + path: ${{ runner.temp }}/${{ env.TEST_HELPERS_ARTIFACT }} + + - name: tests + id: test + shell: bash + run: | + set -euo pipefail + archive_file="${RUNNER_TEMP}/nextest-archive/${NEXTEST_ARCHIVE_FILE}" + workspace_root="$(pwd)" + + if [[ "${RUNNER_OS}" == "Windows" ]]; then + archive_file="$(cygpath -w "${archive_file}")" + workspace_root="$(cygpath -w "${workspace_root}")" + fi + + if [[ "${RUNNER_OS}" == "Linux" ]]; then + helper_dir="${RUNNER_TEMP}/${TEST_HELPERS_ARTIFACT}" + helper_target_dir="$(pwd)/target/${{ inputs.target }}/${{ inputs.profile }}" + mkdir -p "${helper_target_dir}" + cp "${helper_dir}/codex-linux-sandbox" "${helper_target_dir}/" + chmod +x "${helper_target_dir}/codex-linux-sandbox" + elif [[ "${RUNNER_OS}" == "Windows" ]]; then + helper_dir="${RUNNER_TEMP}/${TEST_HELPERS_ARTIFACT}" + helper_target_dir="$(pwd)/target/${{ inputs.target }}/${{ inputs.profile }}" + mkdir -p "${helper_target_dir}" + cp "${helper_dir}/codex-windows-sandbox-setup.exe" "${helper_target_dir}/" + cp "${helper_dir}/codex-command-runner.exe" "${helper_target_dir}/" + fi + + nextest_args=( + run + --no-fail-fast + --archive-file "${archive_file}" + --workspace-remap "${workspace_root}" + --partition "hash:${{ matrix.shard }}/4" + ) + if [[ "${{ inputs.test_threads }}" != "0" ]]; then + nextest_args+=(--test-threads "${{ inputs.test_threads }}") + fi + + test_command=(cargo nextest "${nextest_args[@]}") + if [[ "${RUNNER_OS}" == "Linux" ]]; then + sandbox_helper="${helper_target_dir}/codex-linux-sandbox" + test_command=( + env + "CARGO_BIN_EXE_codex-linux-sandbox=${sandbox_helper}" + "CARGO_BIN_EXE_codex_linux_sandbox=${sandbox_helper}" + cargo nextest "${nextest_args[@]}" + ) + elif [[ "${RUNNER_OS}" == "Windows" ]]; then + setup_helper="$(cygpath -w "${helper_target_dir}/codex-windows-sandbox-setup.exe")" + command_runner="$(cygpath -w "${helper_target_dir}/codex-command-runner.exe")" + test_command=( + env + "CARGO_BIN_EXE_codex_windows_sandbox_setup=${setup_helper}" + "CARGO_BIN_EXE_codex_command_runner=${command_runner}" + cargo nextest "${nextest_args[@]}" + ) + fi + + "${test_command[@]}" + env: + RUST_BACKTRACE: 1 + RUST_MIN_STACK: "8388608" # 8 MiB + NEXTEST_STATUS_LEVEL: leak + + - name: Upload nextest JUnit report + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: nextest-junit-rust-ci-${{ inputs.artifact_id }}-shard-${{ matrix.shard }} + path: codex-rs/target/nextest/default/junit.xml + if-no-files-found: warn + + - name: Tear down remote test env + if: ${{ always() && runner.os == 'Linux' && inputs.remote_env }} + shell: bash + run: | + set +e + if [[ "${STEPS_TEST_OUTCOME}" != "success" ]]; then + docker logs "${CODEX_TEST_REMOTE_ENV}" || true + fi + docker rm -f "${CODEX_TEST_REMOTE_ENV}" >/dev/null 2>&1 || true + env: + STEPS_TEST_OUTCOME: ${{ steps.test.outcome }} + + - name: verify tests passed + if: steps.test.outcome == 'failure' + run: | + echo "Tests failed. See logs for details." + exit 1 + + result: + name: Platform result + needs: shard + if: always() + runs-on: ubuntu-24.04 + steps: + - name: Confirm test shards passed + shell: bash + run: | + if [[ "${{ needs.shard.result }}" != "success" ]]; then + echo "Nextest shards finished with result: ${{ needs.shard.result }}" >&2 + exit 1 + fi diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index b3c25b3f03..08e0709e17 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -521,242 +521,73 @@ jobs: /var/cache/apt key: apt-${{ matrix.runner }}-${{ matrix.target }}-v1 - tests: - name: Tests — ${{ matrix.runner }} - ${{ matrix.target }}${{ matrix.remote_env == 'true' && ' (remote)' || '' }} - runs-on: ${{ matrix.runs_on || matrix.runner }} - # Windows ARM64 is the long pole here, and nextest retries plus targeted - # Windows timeout headroom need more than 45m to finish reliably. - timeout-minutes: 60 - defaults: - run: - working-directory: codex-rs - env: - # Speed up repeated builds across CI runs by caching compiled objects, except on - # arm64 macOS runners cross-targeting x86_64 where ring/cc-rs can produce - # mixed-architecture archives under sccache. - USE_SCCACHE: ${{ (startsWith(matrix.runner, 'windows') || (matrix.runner == 'macos-15-xlarge' && matrix.target == 'x86_64-apple-darwin')) && 'false' || 'true' }} - CARGO_INCREMENTAL: "0" - SCCACHE_CACHE_SIZE: 10G + tests_macos_aarch64: + name: Tests — macos-15-xlarge - aarch64-apple-darwin + uses: ./.github/workflows/rust-ci-full-nextest-platform.yml + with: + runner: macos-15-xlarge + target: aarch64-apple-darwin + profile: ci-test + artifact_id: macos-aarch64 + use_sccache: true + secrets: inherit - strategy: - fail-fast: false - matrix: - include: - - runner: macos-15-xlarge - target: aarch64-apple-darwin - profile: dev - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-gnu - profile: dev - remote_env: "true" - runs_on: - group: codex-runners - labels: codex-linux-x64 - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-gnu - profile: dev - runs_on: - group: codex-runners - labels: codex-linux-arm64 - - runner: windows-x64 - target: x86_64-pc-windows-msvc - profile: dev - runs_on: - group: codex-runners - labels: codex-windows-x64 - - runner: windows-arm64 - target: aarch64-pc-windows-msvc - profile: dev - runs_on: - group: codex-runners - labels: codex-windows-arm64 + tests_linux_x64_remote: + name: Tests — ubuntu-24.04 - x86_64-unknown-linux-gnu (remote) + uses: ./.github/workflows/rust-ci-full-nextest-platform.yml + with: + runner: ubuntu-24.04 + runner_group: codex-runners + runner_labels: codex-linux-x64 + target: x86_64-unknown-linux-gnu + profile: ci-test + artifact_id: linux-x64-remote + remote_env: true + use_sccache: true + secrets: inherit - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - name: Install Linux build dependencies - if: ${{ runner.os == 'Linux' }} - shell: bash - run: | - set -euo pipefail - if command -v apt-get >/dev/null 2>&1; then - sudo apt-get update -y - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev bubblewrap - fi + tests_linux_arm64: + name: Tests — ubuntu-24.04-arm - aarch64-unknown-linux-gnu + uses: ./.github/workflows/rust-ci-full-nextest-platform.yml + with: + runner: ubuntu-24.04-arm + runner_group: codex-runners + runner_labels: codex-linux-arm64 + target: aarch64-unknown-linux-gnu + profile: ci-test + artifact_id: linux-arm64 + use_sccache: true + secrets: inherit - # Some integration tests rely on DotSlash being installed. - # See https://github.com/openai/codex/pull/7617. - - name: Install DotSlash - uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 + tests_windows_x64: + name: Tests — windows-x64 - x86_64-pc-windows-msvc + uses: ./.github/workflows/rust-ci-full-nextest-platform.yml + with: + runner: windows-x64 + runner_group: codex-runners + runner_labels: codex-windows-x64 + target: x86_64-pc-windows-msvc + profile: ci-test + artifact_id: windows-x64 + test_threads: 8 + secrets: inherit - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 - with: - targets: ${{ matrix.target }} - - - name: Compute lockfile hash - id: lockhash - working-directory: codex-rs - shell: bash - run: | - set -euo pipefail - echo "hash=$(sha256sum Cargo.lock | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" - echo "toolchain_hash=$(sha256sum rust-toolchain.toml | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" - - - name: Restore cargo home cache - id: cache_cargo_home_restore - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ steps.lockhash.outputs.toolchain_hash }} - restore-keys: | - cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}- - - - name: Install sccache - if: ${{ env.USE_SCCACHE == 'true' }} - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 - with: - tool: sccache - version: 0.7.5 - - - name: Configure sccache backend - if: ${{ env.USE_SCCACHE == 'true' }} - shell: bash - run: | - set -euo pipefail - if [[ -n "${ACTIONS_CACHE_URL:-}" && -n "${ACTIONS_RUNTIME_TOKEN:-}" ]]; then - echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" - echo "Using sccache GitHub backend" - else - echo "SCCACHE_GHA_ENABLED=false" >> "$GITHUB_ENV" - echo "SCCACHE_DIR=${{ github.workspace }}/.sccache" >> "$GITHUB_ENV" - echo "Using sccache local disk + actions/cache fallback" - fi - - - name: Enable sccache wrapper - if: ${{ env.USE_SCCACHE == 'true' }} - shell: bash - run: echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV" - - - name: Restore sccache cache (fallback) - if: ${{ env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' }} - id: cache_sccache_restore - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 - with: - path: ${{ github.workspace }}/.sccache/ - key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }} - restore-keys: | - sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}- - sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}- - - - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 - with: - tool: nextest - version: 0.9.103 - - - name: Enable unprivileged user namespaces (Linux) - if: runner.os == 'Linux' - run: | - # Required for bubblewrap to work on Linux CI runners. - sudo sysctl -w kernel.unprivileged_userns_clone=1 - # Ubuntu 24.04+ can additionally gate unprivileged user namespaces - # behind AppArmor. - if sudo sysctl -a 2>/dev/null | grep -q '^kernel.apparmor_restrict_unprivileged_userns'; then - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - fi - - - name: Set up remote test env (Docker) - if: ${{ runner.os == 'Linux' && matrix.remote_env == 'true' }} - shell: bash - run: | - set -euo pipefail - export CODEX_TEST_REMOTE_ENV_CONTAINER_NAME=codex-remote-test-env - source "${GITHUB_WORKSPACE}/scripts/test-remote-env.sh" - echo "CODEX_TEST_REMOTE_ENV=${CODEX_TEST_REMOTE_ENV}" >> "$GITHUB_ENV" - echo "CODEX_TEST_REMOTE_EXEC_SERVER_URL=${CODEX_TEST_REMOTE_EXEC_SERVER_URL}" >> "$GITHUB_ENV" - - - name: tests - id: test - run: cargo nextest run --no-fail-fast --target ${{ matrix.target }} --cargo-profile ci-test --timings - env: - RUST_BACKTRACE: 1 - RUST_MIN_STACK: "8388608" # 8 MiB - NEXTEST_STATUS_LEVEL: leak - - - name: Upload nextest JUnit report - if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: nextest-junit-rust-ci-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }} - path: codex-rs/target/nextest/default/junit.xml - if-no-files-found: warn - - - name: Upload Cargo timings (nextest) - if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: cargo-timings-rust-ci-nextest-${{ matrix.target }}-${{ matrix.profile }} - path: codex-rs/target/**/cargo-timings/cargo-timing.html - if-no-files-found: warn - - - name: Save cargo home cache - if: always() && !cancelled() && steps.cache_cargo_home_restore.outputs.cache-hit != 'true' - continue-on-error: true - uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 - with: - path: | - ~/.cargo/bin/ - ~/.cargo/registry/index/ - ~/.cargo/registry/cache/ - ~/.cargo/git/db/ - key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ steps.lockhash.outputs.toolchain_hash }} - - - name: Save sccache cache (fallback) - if: always() && !cancelled() && env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' - continue-on-error: true - uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 - with: - path: ${{ github.workspace }}/.sccache/ - key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }} - - - name: sccache stats - if: always() && env.USE_SCCACHE == 'true' - continue-on-error: true - run: sccache --show-stats || true - - - name: sccache summary - if: always() && env.USE_SCCACHE == 'true' - shell: bash - run: | - { - echo "### sccache stats — ${{ matrix.target }} (tests)"; - echo; - echo '```'; - sccache --show-stats || true; - echo '```'; - } >> "$GITHUB_STEP_SUMMARY" - - - name: Tear down remote test env - if: ${{ always() && runner.os == 'Linux' && matrix.remote_env == 'true' }} - shell: bash - run: | - set +e - if [[ "${STEPS_TEST_OUTCOME}" != "success" ]]; then - docker logs codex-remote-test-env || true - fi - docker rm -f codex-remote-test-env >/dev/null 2>&1 || true - env: - STEPS_TEST_OUTCOME: ${{ steps.test.outcome }} - - - name: verify tests passed - if: steps.test.outcome == 'failure' - run: | - echo "Tests failed. See logs for details." - exit 1 + tests_windows_arm64: + name: Tests — windows-arm64 - aarch64-pc-windows-msvc + uses: ./.github/workflows/rust-ci-full-nextest-platform.yml + with: + runner: windows-arm64 + runner_group: codex-runners + runner_labels: codex-windows-arm64 + archive_runner: windows-x64 + archive_runner_group: codex-runners + archive_runner_labels: codex-windows-x64 + target: aarch64-pc-windows-msvc + profile: ci-test + artifact_id: windows-arm64 + test_threads: 8 + use_sccache: true + secrets: inherit # --- Gatherer job for the full post-merge workflow -------------------------- results: @@ -768,7 +599,11 @@ jobs: argument_comment_lint_package, argument_comment_lint_prebuilt, lint_build, - tests, + tests_macos_aarch64, + tests_linux_x64_remote, + tests_linux_arm64, + tests_windows_x64, + tests_windows_arm64, ] if: always() runs-on: ubuntu-24.04 @@ -781,13 +616,21 @@ jobs: echo "general: ${{ needs.general.result }}" echo "shear : ${{ needs.cargo_shear.result }}" echo "lint : ${{ needs.lint_build.result }}" - echo "tests : ${{ needs.tests.result }}" + echo "test macos : ${{ needs.tests_macos_aarch64.result }}" + echo "test linux : ${{ needs.tests_linux_x64_remote.result }}" + echo "test arm64 : ${{ needs.tests_linux_arm64.result }}" + echo "test winx64: ${{ needs.tests_windows_x64.result }}" + echo "test winarm: ${{ needs.tests_windows_arm64.result }}" [[ '${{ needs.argument_comment_lint_package.result }}' == 'success' ]] || { echo 'argument_comment_lint_package failed'; exit 1; } [[ '${{ needs.argument_comment_lint_prebuilt.result }}' == 'success' ]] || { echo 'argument_comment_lint_prebuilt failed'; exit 1; } [[ '${{ needs.general.result }}' == 'success' ]] || { echo 'general failed'; exit 1; } [[ '${{ needs.cargo_shear.result }}' == 'success' ]] || { echo 'cargo_shear failed'; exit 1; } [[ '${{ needs.lint_build.result }}' == 'success' ]] || { echo 'lint_build failed'; exit 1; } - [[ '${{ needs.tests.result }}' == 'success' ]] || { echo 'tests failed'; exit 1; } + [[ '${{ needs.tests_macos_aarch64.result }}' == 'success' ]] || { echo 'tests_macos_aarch64 failed'; exit 1; } + [[ '${{ needs.tests_linux_x64_remote.result }}' == 'success' ]] || { echo 'tests_linux_x64_remote failed'; exit 1; } + [[ '${{ needs.tests_linux_arm64.result }}' == 'success' ]] || { echo 'tests_linux_arm64 failed'; exit 1; } + [[ '${{ needs.tests_windows_x64.result }}' == 'success' ]] || { echo 'tests_windows_x64 failed'; exit 1; } + [[ '${{ needs.tests_windows_arm64.result }}' == 'success' ]] || { echo 'tests_windows_arm64 failed'; exit 1; } - name: sccache summary note if: always() diff --git a/codex-rs/.config/nextest.toml b/codex-rs/.config/nextest.toml index 399caae16a..01f4a98dc8 100644 --- a/codex-rs/.config/nextest.toml +++ b/codex-rs/.config/nextest.toml @@ -1,6 +1,8 @@ [profile.default] # Retry once so one transient failure does not fail full-CI outright. -slow-timeout = { period = "15s", terminate-after = 2 } +# Fanout keeps the full-CI shards moving without treating every >30s test as +# stuck. Keep this aligned with the broader timeout budget we give sharded CI. +slow-timeout = { period = "30s", terminate-after = 2 } retries = 1 [profile.default.junit] From 14953023471159aaed89f360c0f3da2346cb4bc0 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 19 May 2026 19:01:05 -0700 Subject: [PATCH 02/11] feat: expose codex-app-server version flag (#23593) ## Why `codex-app-server` is published as a standalone release binary, so it should support the same basic version inspection behavior users expect from command-line tools. This is independent of package assembly: package metadata now comes from `codex-rs/Cargo.toml`, but the standalone app-server binary should still answer `--version` directly. ## What changed - Enables Clap's generated `--version` flag for the `codex-app-server` binary by adding `#[command(version)]` to its top-level parser. ## Verification - Ran `cargo run -p codex-app-server --bin codex-app-server -- --version` and verified it prints `codex-app-server 0.0.0`. --- codex-rs/app-server/src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 6f5ecabf90..2435c532e8 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -17,6 +17,7 @@ const MANAGED_CONFIG_PATH_ENV_VAR: &str = "CODEX_APP_SERVER_MANAGED_CONFIG_PATH" const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; #[derive(Debug, Parser)] +#[command(version)] struct AppServerArgs { /// Transport endpoint URL. Supported values: `stdio://` (default), /// `unix://`, `unix://PATH`, `ws://IP:PORT`, `off`. From c3faea0b09cadbe74f62503fc7cb3832d82a3ff8 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Tue, 19 May 2026 19:42:56 -0700 Subject: [PATCH 03/11] feat: add permission profile list api (#23412) ## Why Clients need a typed permission-profile catalog instead of reconstructing that state from config internals. ## What changed - Added `permissionProfile/list` to the app-server v2 protocol with cursor pagination and optional `cwd`. - The list response includes built-in permission profiles plus config-defined `[permissions.]` profiles from the effective config for the request context. - Permission profiles keep optional `description` metadata for display purposes. - App-server docs and schema fixtures are updated for the new RPC. --- .../schema/json/ClientRequest.json | 52 ++++ .../codex_app_server_protocol.schemas.json | 96 ++++++++ .../codex_app_server_protocol.v2.schemas.json | 96 ++++++++ .../json/v2/PermissionProfileListParams.json | 30 +++ .../v2/PermissionProfileListResponse.json | 44 ++++ .../schema/typescript/ClientRequest.ts | 3 +- .../v2/PermissionProfileListParams.ts | 17 ++ .../v2/PermissionProfileListResponse.ts | 11 + .../typescript/v2/PermissionProfileSummary.ts | 13 + .../schema/typescript/v2/index.ts | 3 + .../src/protocol/common.rs | 5 + .../src/protocol/v2/permissions.rs | 35 +++ codex-rs/app-server/README.md | 1 + codex-rs/app-server/src/message_processor.rs | 3 + codex-rs/app-server/src/request_processors.rs | 6 + .../request_processors/catalog_processor.rs | 82 ++++++ .../app-server/tests/common/mcp_process.rs | 10 + codex-rs/app-server/tests/suite/v2/mod.rs | 1 + .../tests/suite/v2/permission_profile_list.rs | 233 ++++++++++++++++++ codex-rs/config/src/permissions_toml.rs | 1 + codex-rs/core/config.schema.json | 3 + codex-rs/core/src/config/config_tests.rs | 22 ++ codex-rs/core/src/config/permissions_tests.rs | 3 + 23 files changed, 769 insertions(+), 1 deletion(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileListParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileListResponse.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSummary.ts create mode 100644 codex-rs/app-server/tests/suite/v2/permission_profile_list.rs diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 58aa1c4449..506358d25d 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1541,6 +1541,34 @@ ], "type": "string" }, + "PermissionProfileListParams": { + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "cwd": { + "description": "Optional working directory to resolve project config layers.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Optional page size; defaults to the full result set.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, "Personality": { "enum": [ "none", @@ -5324,6 +5352,30 @@ "title": "ExperimentalFeature/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "permissionProfile/list" + ], + "title": "PermissionProfile/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PermissionProfileListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "PermissionProfile/listRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index b50cacefa4..73e4335bf9 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -1429,6 +1429,30 @@ "title": "ExperimentalFeature/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "permissionProfile/list" + ], + "title": "PermissionProfile/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/PermissionProfileListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "PermissionProfile/listRequest", + "type": "object" + }, { "properties": { "id": { @@ -11708,6 +11732,78 @@ } ] }, + "PermissionProfileListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "cwd": { + "description": "Optional working directory to resolve project config layers.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Optional page size; defaults to the full result set.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "title": "PermissionProfileListParams", + "type": "object" + }, + "PermissionProfileListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/v2/PermissionProfileSummary" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "PermissionProfileListResponse", + "type": "object" + }, + "PermissionProfileSummary": { + "properties": { + "description": { + "description": "Optional user-facing description for display in clients.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Available permission profile identifier.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, "Personality": { "enum": [ "none", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 9da530463a..ea8655d0fe 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -2155,6 +2155,30 @@ "title": "ExperimentalFeature/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "permissionProfile/list" + ], + "title": "PermissionProfile/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PermissionProfileListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "PermissionProfile/listRequest", + "type": "object" + }, { "properties": { "id": { @@ -8257,6 +8281,78 @@ } ] }, + "PermissionProfileListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "cwd": { + "description": "Optional working directory to resolve project config layers.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Optional page size; defaults to the full result set.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "title": "PermissionProfileListParams", + "type": "object" + }, + "PermissionProfileListResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/PermissionProfileSummary" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "PermissionProfileListResponse", + "type": "object" + }, + "PermissionProfileSummary": { + "properties": { + "description": { + "description": "Optional user-facing description for display in clients.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Available permission profile identifier.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, "Personality": { "enum": [ "none", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListParams.json b/codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListParams.json new file mode 100644 index 0000000000..402dab6221 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListParams.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "cwd": { + "description": "Optional working directory to resolve project config layers.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Optional page size; defaults to the full result set.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "title": "PermissionProfileListParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListResponse.json new file mode 100644 index 0000000000..4d5a47f8d5 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListResponse.json @@ -0,0 +1,44 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "PermissionProfileSummary": { + "properties": { + "description": { + "description": "Optional user-facing description for display in clients.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Available permission profile identifier.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + } + }, + "properties": { + "data": { + "items": { + "$ref": "#/definitions/PermissionProfileSummary" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "PermissionProfileListResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 7f017ed700..14be7db47a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -42,6 +42,7 @@ import type { McpServerOauthLoginParams } from "./v2/McpServerOauthLoginParams"; import type { McpServerToolCallParams } from "./v2/McpServerToolCallParams"; import type { ModelListParams } from "./v2/ModelListParams"; import type { ModelProviderCapabilitiesReadParams } from "./v2/ModelProviderCapabilitiesReadParams"; +import type { PermissionProfileListParams } from "./v2/PermissionProfileListParams"; import type { PluginInstallParams } from "./v2/PluginInstallParams"; import type { PluginInstalledParams } from "./v2/PluginInstalledParams"; import type { PluginListParams } from "./v2/PluginListParams"; @@ -81,4 +82,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileListParams.ts new file mode 100644 index 0000000000..24582c9507 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileListParams.ts @@ -0,0 +1,17 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PermissionProfileListParams = { +/** + * Opaque pagination cursor returned by a previous call. + */ +cursor?: string | null, +/** + * Optional page size; defaults to the full result set. + */ +limit?: number | null, +/** + * Optional working directory to resolve project config layers. + */ +cwd?: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileListResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileListResponse.ts new file mode 100644 index 0000000000..ba0ccbc0d6 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileListResponse.ts @@ -0,0 +1,11 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PermissionProfileSummary } from "./PermissionProfileSummary"; + +export type PermissionProfileListResponse = { data: Array, +/** + * Opaque cursor to pass to the next call to continue after the last item. + * If None, there are no more items to return. + */ +nextCursor: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSummary.ts new file mode 100644 index 0000000000..9d02fd776b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSummary.ts @@ -0,0 +1,13 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PermissionProfileSummary = { +/** + * Available permission profile identifier. + */ +id: string, +/** + * Optional user-facing description for display in clients. + */ +description: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 7c09be0def..d9815baec6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -256,6 +256,9 @@ export type { OverriddenMetadata } from "./OverriddenMetadata"; export type { PatchApplyStatus } from "./PatchApplyStatus"; export type { PatchChangeKind } from "./PatchChangeKind"; export type { PermissionGrantScope } from "./PermissionGrantScope"; +export type { PermissionProfileListParams } from "./PermissionProfileListParams"; +export type { PermissionProfileListResponse } from "./PermissionProfileListResponse"; +export type { PermissionProfileSummary } from "./PermissionProfileSummary"; export type { PermissionsRequestApprovalParams } from "./PermissionsRequestApprovalParams"; export type { PermissionsRequestApprovalResponse } from "./PermissionsRequestApprovalResponse"; export type { PlanDeltaNotification } from "./PlanDeltaNotification"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 7a7fe5642c..4adcf5f592 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -800,6 +800,11 @@ client_request_definitions! { serialization: global("config"), response: v2::ExperimentalFeatureListResponse, }, + PermissionProfileList => "permissionProfile/list" { + params: v2::PermissionProfileListParams, + serialization: global_shared_read("config"), + response: v2::PermissionProfileListResponse, + }, ExperimentalFeatureEnablementSet => "experimentalFeature/enablement/set" { params: v2::ExperimentalFeatureEnablementSetParams, serialization: global("config"), diff --git a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs index 15eea54e14..99924ca1e5 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -287,6 +287,41 @@ impl From for CoreFileSystemSandboxEntry { } } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PermissionProfileListParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional page size; defaults to the full result set. + #[ts(optional = nullable)] + pub limit: Option, + /// Optional working directory to resolve project config layers. + #[ts(optional = nullable)] + pub cwd: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PermissionProfileSummary { + /// Available permission profile identifier. + pub id: String, + /// Optional user-facing description for display in clients. + pub description: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PermissionProfileListResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// If None, there are no more items to return. + pub next_cursor: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 1b7575141f..a3a7910884 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -189,6 +189,7 @@ Example with notification opt-out: - `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. - `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`. +- `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.]` entries to be included in the current catalog view. - `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. - `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; returns `{}` and does not change the default environment. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index e37c9afd22..eb54307849 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1149,6 +1149,9 @@ impl MessageProcessor { .experimental_feature_list(params) .await } + ClientRequest::PermissionProfileList { params, .. } => { + self.catalog_processor.permission_profile_list(params).await + } ClientRequest::CollaborationModeList { params, .. } => { self.catalog_processor.collaboration_mode_list(params).await } diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 3e84c397f8..168640c0eb 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -103,6 +103,9 @@ use codex_app_server_protocol::MockExperimentalMethodParams; use codex_app_server_protocol::MockExperimentalMethodResponse; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; +use codex_app_server_protocol::PermissionProfileListParams; +use codex_app_server_protocol::PermissionProfileListResponse; +use codex_app_server_protocol::PermissionProfileSummary; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstallResponse; @@ -357,6 +360,9 @@ use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; #[cfg(test)] use codex_protocol::items::TurnItem; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::ResponseItem; #[cfg(test)] use codex_protocol::permissions::FileSystemSandboxPolicy; diff --git a/codex-rs/app-server/src/request_processors/catalog_processor.rs b/codex-rs/app-server/src/request_processors/catalog_processor.rs index 834f70654d..d2845d7d6a 100644 --- a/codex-rs/app-server/src/request_processors/catalog_processor.rs +++ b/codex-rs/app-server/src/request_processors/catalog_processor.rs @@ -1,4 +1,5 @@ use super::*; +use codex_config::config_toml::ConfigToml; use futures::StreamExt; #[derive(Clone)] @@ -155,6 +156,15 @@ impl CatalogRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) async fn permission_profile_list( + &self, + params: PermissionProfileListParams, + ) -> Result, JSONRPCErrorError> { + self.permission_profile_list_response(params) + .await + .map(|response| Some(response.into())) + } + pub(crate) async fn collaboration_mode_list( &self, params: CollaborationModeListParams, @@ -389,6 +399,78 @@ impl CatalogRequestProcessor { Ok(ExperimentalFeatureListResponse { data, next_cursor }) } + async fn permission_profile_list_response( + &self, + params: PermissionProfileListParams, + ) -> Result { + let PermissionProfileListParams { cursor, limit, cwd } = params; + let config_layer_stack = match cwd { + Some(cwd) => { + let cwd = PathBuf::from(cwd); + let (_, config_layer_stack) = self + .resolve_cwd_config(&cwd) + .await + .map_err(|err| internal_error(format!("failed to reload config: {err}")))?; + config_layer_stack + } + None => self + .config_manager + .load_config_layers(/*cwd*/ None) + .await + .map_err(|err| internal_error(format!("failed to reload config: {err}")))?, + }; + let effective_config: ConfigToml = config_layer_stack + .effective_config() + .try_into() + .map_err(|err| internal_error(format!("failed to read effective config: {err}")))?; + let mut profiles = vec![ + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(), + description: None, + }, + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), + description: None, + }, + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(), + description: None, + }, + ]; + let mut configured_profiles = effective_config + .permissions + .into_iter() + .flat_map(|permissions| permissions.entries) + .map(|(id, profile)| PermissionProfileSummary { + id, + description: profile.description, + }) + .collect::>(); + configured_profiles.sort_by(|left, right| left.id.cmp(&right.id)); + profiles.extend(configured_profiles); + let total = profiles.len(); + let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; + let effective_limit = effective_limit.min(total); + let start = match cursor { + Some(cursor) => cursor + .parse::() + .map_err(|_| invalid_request(format!("invalid cursor: {cursor}")))?, + None => 0, + }; + + if start > total { + return Err(invalid_request(format!( + "cursor {start} exceeds total permission profiles {total}" + ))); + } + + let end = start.saturating_add(effective_limit).min(total); + let data = profiles[start..end].to_vec(); + let next_cursor = (end < total).then_some(end.to_string()); + + Ok(PermissionProfileListResponse { data, next_cursor }) + } + async fn mock_experimental_method_inner( &self, params: MockExperimentalMethodParams, diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 3445010a7f..115764f822 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -56,6 +56,7 @@ use codex_app_server_protocol::McpServerToolCallParams; use codex_app_server_protocol::MockExperimentalMethodParams; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelProviderCapabilitiesReadParams; +use codex_app_server_protocol::PermissionProfileListParams; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstalledParams; use codex_app_server_protocol::PluginListParams; @@ -561,6 +562,15 @@ impl McpProcess { self.send_request("experimentalFeature/list", params).await } + /// Send a `permissionProfile/list` JSON-RPC request. + pub async fn send_permission_profile_list_request( + &mut self, + params: PermissionProfileListParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("permissionProfile/list", params).await + } + /// Send an `experimentalFeature/enablement/set` JSON-RPC request. pub async fn send_experimental_feature_enablement_set_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index bdbe7b7ddd..1c81fe1ed6 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -29,6 +29,7 @@ mod memory_reset; mod model_list; mod model_provider_capabilities_read; mod output_schema; +mod permission_profile_list; mod plan_item; mod plugin_install; mod plugin_list; diff --git a/codex-rs/app-server/tests/suite/v2/permission_profile_list.rs b/codex-rs/app-server/tests/suite/v2/permission_profile_list.rs new file mode 100644 index 0000000000..5939065280 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/permission_profile_list.rs @@ -0,0 +1,233 @@ +use std::time::Duration; + +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::PermissionProfileListParams; +use codex_app_server_protocol::PermissionProfileListResponse; +use codex_app_server_protocol::PermissionProfileSummary; +use codex_app_server_protocol::RequestId; +use codex_core::config::set_project_trust_level; +use codex_protocol::config_types::TrustLevel; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); + +#[tokio::test] +async fn permission_profile_list_returns_builtin_and_configured_profiles() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#" +default_permissions = "dev" + +[permissions.dev] +description = "Day-to-day coding work." + +[permissions.dev.filesystem] +":workspace_roots" = "write" + +[permissions.audit] +description = "Inspect without writes." + +[permissions.audit.filesystem] +":workspace_roots" = "read" +"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_permission_profile_list_request(PermissionProfileListParams { + cursor: None, + limit: None, + cwd: None, + }) + .await?; + let actual = read_response::(&mut mcp, request_id).await?; + + assert_eq!( + actual, + PermissionProfileListResponse { + data: vec![ + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(), + description: None, + }, + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), + description: None, + }, + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(), + description: None, + }, + PermissionProfileSummary { + id: "audit".to_string(), + description: Some("Inspect without writes.".to_string()), + }, + PermissionProfileSummary { + id: "dev".to_string(), + description: Some("Day-to-day coding work.".to_string()), + }, + ], + next_cursor: None, + } + ); + Ok(()) +} + +#[tokio::test] +async fn permission_profile_list_resolves_project_profiles_and_paginates() -> Result<()> { + let codex_home = TempDir::new()?; + let workspace = TempDir::new()?; + let project_config_dir = workspace.path().join(".codex"); + std::fs::create_dir_all(&project_config_dir)?; + std::fs::write( + codex_home.path().join("config.toml"), + r#" +default_permissions = ":workspace" +"#, + )?; + std::fs::write( + project_config_dir.join("config.toml"), + r#" +[permissions.project] +description = "Project-scoped profile." + +[permissions.project.filesystem] +":workspace_roots" = "write" +"#, + )?; + set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let first_request_id = mcp + .send_permission_profile_list_request(PermissionProfileListParams { + cursor: None, + limit: Some(3), + cwd: Some(workspace.path().to_string_lossy().into_owned()), + }) + .await?; + let first = read_response::(&mut mcp, first_request_id).await?; + assert_eq!( + first, + PermissionProfileListResponse { + data: vec![ + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(), + description: None, + }, + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), + description: None, + }, + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(), + description: None, + }, + ], + next_cursor: Some("3".to_string()), + } + ); + + let second_request_id = mcp + .send_permission_profile_list_request(PermissionProfileListParams { + cursor: first.next_cursor, + limit: Some(3), + cwd: Some(workspace.path().to_string_lossy().into_owned()), + }) + .await?; + let second = + read_response::(&mut mcp, second_request_id).await?; + assert_eq!( + second, + PermissionProfileListResponse { + data: vec![PermissionProfileSummary { + id: "project".to_string(), + description: Some("Project-scoped profile.".to_string()), + }], + next_cursor: None, + } + ); + Ok(()) +} + +#[tokio::test] +async fn permission_profile_list_discovers_project_profiles_without_default_selection() -> Result<()> +{ + let codex_home = TempDir::new()?; + let workspace = TempDir::new()?; + let project_config_dir = workspace.path().join(".codex"); + std::fs::create_dir_all(&project_config_dir)?; + std::fs::write( + project_config_dir.join("config.toml"), + r#" +[permissions.project] +description = "Project-scoped profile." + +[permissions.project.filesystem] +":workspace_roots" = "write" +"#, + )?; + set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_permission_profile_list_request(PermissionProfileListParams { + cursor: None, + limit: None, + cwd: Some(workspace.path().to_string_lossy().into_owned()), + }) + .await?; + let actual = read_response::(&mut mcp, request_id).await?; + + assert_eq!( + actual, + PermissionProfileListResponse { + data: vec![ + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(), + description: None, + }, + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), + description: None, + }, + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(), + description: None, + }, + PermissionProfileSummary { + id: "project".to_string(), + description: Some("Project-scoped profile.".to_string()), + }, + ], + next_cursor: None, + } + ); + Ok(()) +} + +async fn read_response( + mcp: &mut McpProcess, + request_id: i64, +) -> Result { + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response(response) +} diff --git a/codex-rs/config/src/permissions_toml.rs b/codex-rs/config/src/permissions_toml.rs index fff8c67706..4d533776ad 100644 --- a/codex-rs/config/src/permissions_toml.rs +++ b/codex-rs/config/src/permissions_toml.rs @@ -25,6 +25,7 @@ impl PermissionsToml { #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct PermissionProfileToml { + pub description: Option, pub workspace_roots: Option, pub filesystem: Option, pub network: Option, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 00fc4931a4..0e811e9017 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2002,6 +2002,9 @@ "PermissionProfileToml": { "additionalProperties": false, "properties": { + "description": { + "type": "string" + }, "filesystem": { "$ref": "#/definitions/FilesystemPermissionsToml" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index dac1bf317e..f968677483 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -734,6 +734,9 @@ fn config_toml_deserializes_permission_profiles() { let toml = r#" default_permissions = "workspace" +[permissions.workspace] +description = "Day-to-day workspace access." + [permissions.workspace.workspace_roots] "~/code/openai" = true "~/code/ignored" = false @@ -765,6 +768,7 @@ allow_upstream_proxy = false entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: Some("Day-to-day workspace access.".to_string()), workspace_roots: Some(WorkspaceRootsToml { entries: BTreeMap::from([ ("~/code/ignored".to_string(), false), @@ -830,6 +834,7 @@ async fn permissions_profiles_proxy_policy_does_not_start_managed_network_proxy_ entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -984,6 +989,7 @@ async fn network_proxy_feature_matrix_preserves_sandbox_network_semantics() -> s entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -1135,6 +1141,7 @@ async fn network_proxy_feature_uses_profile_network_proxy_settings() -> std::io: entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -1238,6 +1245,7 @@ enabled = false entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -1287,6 +1295,7 @@ async fn permissions_profiles_network_disabled_by_default_does_not_start_proxy() entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -1334,6 +1343,7 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -1638,6 +1648,7 @@ async fn permission_profile_override_preserves_configured_network_policy_without entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -1698,6 +1709,7 @@ async fn workspace_root_glob_none_compiles_to_filesystem_pattern_entry() -> std: entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: Some(2), @@ -1777,6 +1789,7 @@ async fn permissions_profiles_require_default_permissions() -> std::io::Result<( entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -1904,6 +1917,7 @@ async fn workspace_profile_applies_rules_to_runtime_and_profile_workspace_roots( entries: BTreeMap::from([( "dev".to_string(), PermissionProfileToml { + description: None, workspace_roots: Some(WorkspaceRootsToml { entries: BTreeMap::from([( profile_root.to_string_lossy().into_owned(), @@ -2362,6 +2376,7 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -2423,6 +2438,7 @@ async fn permissions_profiles_reject_nested_entries_for_non_workspace_roots() -> entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -2484,6 +2500,7 @@ async fn load_workspace_permission_profile( #[tokio::test] async fn permissions_profiles_allow_unknown_special_paths() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -2528,6 +2545,7 @@ async fn permissions_profiles_allow_unknown_special_paths() -> std::io::Result<( async fn permissions_profiles_allow_unknown_special_paths_with_nested_entries() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -2565,6 +2583,7 @@ async fn permissions_profiles_allow_unknown_special_paths_with_nested_entries() #[tokio::test] async fn permissions_profiles_allow_missing_filesystem_with_warning() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { + description: None, workspace_roots: None, filesystem: None, network: None, @@ -2594,6 +2613,7 @@ async fn permissions_profiles_allow_missing_filesystem_with_warning() -> std::io #[tokio::test] async fn permissions_profiles_allow_empty_filesystem_with_warning() -> std::io::Result<()> { let config = load_workspace_permission_profile(PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -2630,6 +2650,7 @@ async fn permissions_profiles_reject_workspace_root_parent_traversal() -> std::i entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -2677,6 +2698,7 @@ async fn permissions_profiles_allow_network_enablement() -> std::io::Result<()> entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, diff --git a/codex-rs/core/src/config/permissions_tests.rs b/codex-rs/core/src/config/permissions_tests.rs index d6f358017e..5e1fcf3525 100644 --- a/codex-rs/core/src/config/permissions_tests.rs +++ b/codex-rs/core/src/config/permissions_tests.rs @@ -67,6 +67,7 @@ async fn restricted_read_implicitly_allows_helper_executables() -> std::io::Resu entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -285,6 +286,7 @@ fn compile_permission_profile_workspace_roots_resolves_enabled_entries() -> std: entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: Some(WorkspaceRootsToml { entries: BTreeMap::from([ ("backend".to_string(), true), @@ -394,6 +396,7 @@ fn read_write_trailing_glob_suffix_compiles_as_subpath() -> std::io::Result<()> entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, From 532b9c83ae83a3cdcf1e174c5239451314cd1508 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 19 May 2026 20:05:52 -0700 Subject: [PATCH 04/11] Move plugin and skill warmup into session startup (#23535) ## Why Plugin and skill loading is useful as warmup and early validation, but session startup does not need to wait for that work before it can continue building the session. Keeping it on the serial startup path adds avoidable latency to every fresh thread start. We still want invalid skill configurations to show up quickly, and we want the warmup to exercise the same plugin and skill manager caches that the normal turn path uses. ## What changed - moved plugin and skill warmup into the session startup async path instead of eagerly awaiting it on the serial setup path - kept the warmup using the session's resolved filesystem/environment context so skill loading still sees the right roots - preserved early skill-load error logging so broken skill configurations still surface during startup - left the per-turn plugin and skill loading path unchanged, so turns still use the normal cached managers ## Testing - Not run locally; relying on CI for validation. --- codex-rs/core/src/session/mod.rs | 14 ------- codex-rs/core/src/session/session.rs | 57 +++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 8f0d2f1c01..646528cf97 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -480,20 +480,6 @@ impl Codex { } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); - let fs = environment_selections.primary_filesystem(); - let plugins_input = config.plugins_config_input(); - let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await; - let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots(); - let skills_input = skills_load_input_from_config(&config, effective_skill_roots); - let loaded_skills = skills_manager.skills_for_config(&skills_input, fs).await; - - for err in &loaded_skills.errors { - error!( - "failed to load skill {}: {}", - err.path.display(), - err.message - ); - } if let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) = session_source && depth >= config.agent_max_depth diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 884c440f28..8945880c33 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1,6 +1,7 @@ use super::input_queue::InputQueue; use super::*; use crate::goals::GoalRuntimeState; +use crate::skills::SkillError; use crate::state::ActiveTurn; use codex_protocol::SessionId; use codex_protocol::config_types::ServiceTier; @@ -405,6 +406,29 @@ pub(crate) struct AppServerClientMetadata { pub(crate) client_version: Option, } +async fn warm_plugins_and_skills_for_session_init( + config: Arc, + environment_manager: Arc, + plugins_manager: Arc, + skills_manager: Arc, + environments: Vec, +) -> Vec { + let fs = crate::environment_selection::resolve_environment_selections( + environment_manager.as_ref(), + &environments, + ) + .ok() + .and_then(|resolved| resolved.primary_filesystem()); + let plugins_input = config.plugins_config_input(); + let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await; + let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots(); + let skills_input = skills_load_input_from_config(config.as_ref(), effective_skill_roots); + skills_manager + .skills_for_config(&skills_input, fs) + .await + .errors +} + impl Session { /// Returns the concrete identity for this thread. pub(crate) fn thread_id(&self) -> ThreadId { @@ -578,9 +602,38 @@ impl Session { otel.name = "session_init.auth_mcp", )); + let plugin_and_skill_warmup_fut = warm_plugins_and_skills_for_session_init( + Arc::clone(&config), + Arc::clone(&environment_manager), + Arc::clone(&plugins_manager), + Arc::clone(&skills_manager), + session_configuration.environments.clone(), + ) + .instrument(info_span!( + "session_init.plugin_skill_warmup", + otel.name = "session_init.plugin_skill_warmup", + )); + // Join all independent futures. - let (thread_persistence_result, state_db_ctx, (auth, mcp_servers, auth_statuses)) = - tokio::join!(thread_persistence_fut, state_db_fut, auth_and_mcp_fut); + let ( + thread_persistence_result, + state_db_ctx, + (auth, mcp_servers, auth_statuses), + plugin_skill_errors, + ) = tokio::join!( + thread_persistence_fut, + state_db_fut, + auth_and_mcp_fut, + plugin_and_skill_warmup_fut + ); + + for err in &plugin_skill_errors { + error!( + "failed to load skill {}: {}", + err.path.display(), + err.message + ); + } let mut live_thread_init = LiveThreadInitGuard::new(thread_persistence_result.map_err(|e| { From e43a2e297fe72285890e0f04f9f1aece843c2338 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 19 May 2026 20:48:37 -0700 Subject: [PATCH 05/11] Fix stale background terminal poll events (#23231) ## Why Issue #23214 reports `/ps` showing no background terminals while the status line still says it is waiting for a background terminal. The race is in core: `write_stdin` can poll a process that exits before the response returns. The process manager correctly returns `process_id: None`, but the handler still emitted a `TerminalInteraction` event using the requested session id, causing clients to believe a dead process was still being polled. Fixes #23214. ## What changed - Suppress `TerminalInteraction` events for empty `write_stdin` polls once `response.process_id` is `None`. - Continue emitting interactions for non-empty stdin, even if that input causes the process to exit before the response returns. - Extend the unified exec integration test to assert completed empty polls do not emit terminal interactions. ## Verification - `cargo test -p codex-core --test all unified_exec_emits_one_begin_and_one_end_event` - `cargo test -p codex-core --test all unified_exec_emits_terminal_interaction_for_write_stdin` `cargo test -p codex-core` currently aborts in unrelated `agent::control::tests::resume_agent_from_rollout_uses_edge_data_when_descendant_metadata_source_is_stale` with a reproducible stack overflow. --- .../handlers/unified_exec/write_stdin.rs | 23 ++++++++++++------- .../core/src/unified_exec/process_manager.rs | 4 ++-- codex-rs/core/tests/suite/unified_exec.rs | 8 +++++++ .../tui/src/chatwidget/command_lifecycle.rs | 6 ++++- .../tui/src/chatwidget/tests/exec_flow.rs | 16 +++++++++++++ 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs b/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs index 3dea7092f5..843f9e638f 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs @@ -78,14 +78,21 @@ impl ToolExecutor for WriteStdinHandler { FunctionCallError::RespondToModel(format!("write_stdin failed: {err}")) })?; - let interaction = TerminalInteractionEvent { - call_id: response.event_call_id.clone(), - process_id: args.session_id.to_string(), - stdin: args.chars.clone(), - }; - session - .send_event(turn.as_ref(), EventMsg::TerminalInteraction(interaction)) - .await; + // Empty stdin is a background poll, so emit it only while there is + // still a live process for the UI to wait on. Non-empty stdin is a real + // terminal interaction and should remain visible even if it completes + // the process before the response returns. + if !args.chars.is_empty() || response.process_id.is_some() { + let process_id = response.process_id.unwrap_or(args.session_id); + let interaction = TerminalInteractionEvent { + call_id: response.event_call_id.clone(), + process_id: process_id.to_string(), + stdin: args.chars.clone(), + }; + session + .send_event(turn.as_ref(), EventMsg::TerminalInteraction(interaction)) + .await; + } Ok(boxed_tool_output(response)) } diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 8c0cc89680..c6fc2ab3e5 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -691,8 +691,8 @@ impl UnifiedExecProcessManager { // After polling, refresh_process_state tells us whether the PTY is // still alive or has exited and been removed from the store; we thread - // that through so the handler can tag TerminalInteraction with an - // appropriate process_id and exit_code. + // that through so the handler can tag or suppress TerminalInteraction + // with an appropriate process_id and exit_code. let status = if let Some(status) = status_after_write { status } else { diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index d61e427386..9eb831b084 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -1322,6 +1322,7 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { let mut begin_events = Vec::new(); let mut end_events = Vec::new(); + let mut terminal_interactions = Vec::new(); let mut task_completed = false; loop { let event_msg = wait_for_event(&test.codex, |_| true).await; @@ -1332,6 +1333,9 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { EventMsg::ExecCommandEnd(event) if event.call_id == open_call_id => { end_events.push(event); } + EventMsg::TerminalInteraction(event) if event.call_id == open_call_id => { + terminal_interactions.push(event); + } EventMsg::TurnComplete(_) => { task_completed = true; } @@ -1353,6 +1357,10 @@ async fn unified_exec_emits_one_begin_and_one_end_event() -> Result<()> { 1, "expected end event for the write_stdin call" ); + assert!( + terminal_interactions.is_empty(), + "completed empty polls should not emit terminal interactions: {terminal_interactions:?}" + ); let open_event = &begin_events[0]; diff --git a/codex-rs/tui/src/chatwidget/command_lifecycle.rs b/codex-rs/tui/src/chatwidget/command_lifecycle.rs index efe7522963..6f13d37987 100644 --- a/codex-rs/tui/src/chatwidget/command_lifecycle.rs +++ b/codex-rs/tui/src/chatwidget/command_lifecycle.rs @@ -76,12 +76,16 @@ impl ChatWidget { if !self.bottom_pane.is_task_running() { return; } - self.flush_answer_stream_with_separator(); let command_display = self .unified_exec_processes .iter() .find(|process| process.key == process_id) .map(|process| process.command_display.clone()); + if stdin.is_empty() && command_display.is_none() { + return; + } + + self.flush_answer_stream_with_separator(); if stdin.is_empty() { // Empty stdin means we are polling for background output. // Surface this in the status indicator (single "waiting" surface) instead of diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index d54783615c..ffc8833777 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -719,6 +719,22 @@ async fn unified_exec_wait_status_header_updates_on_late_command_display() { assert_eq!(status.details(), Some("sleep 5")); } +#[tokio::test] +async fn unified_exec_empty_poll_for_finished_process_does_not_show_waiting_status() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.on_task_started(); + + terminal_interaction(&mut chat, "call-finished", "proc-finished", ""); + + assert_eq!(chat.status_state.current_status.header, "Working"); + let status = chat + .bottom_pane + .status_widget() + .expect("task status indicator should remain visible"); + assert_eq!(status.header(), "Working"); + assert!(chat.unified_exec_wait_streak.is_none()); +} + #[tokio::test] async fn unified_exec_waiting_multiple_empty_snapshots() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; From 5a4202ad909c2918486bbdd911babf636f498990 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 19 May 2026 21:02:14 -0700 Subject: [PATCH 06/11] [codex] Preserve raw code-mode exec output by default (#23564) ## Why Code mode can use nested unified exec calls as data sources. When those calls omit `max_output_tokens`, code mode should receive raw command output so the script can parse or summarize it itself. When code mode does provide `max_output_tokens`, that explicit nested budget should be respected, including values above the default unified exec limit, rather than being capped before code mode sees the result. ## What - Preserve direct unified exec truncation behavior, while letting code-mode exec/write_stdin keep `max_output_tokens` as `None` unless explicitly supplied. - Make code-mode tool results use raw output when no explicit limit is present, and use the explicit nested limit directly when one is specified. - Refactor unified exec output formatting so `truncated_output` takes the caller-selected token budget. - Add e2e integration coverage for explicit nested exec limits, omitted nested exec limits, outer exec limit propagation, omitted-limit outputs that exceed both the default and a small truncation policy, explicit nested limits above those caps, and high explicit limits that still compact larger command output. - Reuse the code-mode turn setup helper while directly asserting the exact exec output item in each test. ## Testing - `just fmt` - `git diff --check` - Not run locally per repo guidance; CI should validate the e2e integration tests. --- codex-rs/core/src/tools/context.rs | 19 +- codex-rs/core/src/tools/context_tests.rs | 1 + .../core/src/tools/handlers/unified_exec.rs | 9 - .../handlers/unified_exec/exec_command.rs | 11 +- .../handlers/unified_exec/write_stdin.rs | 6 +- .../src/tools/handlers/unified_exec_tests.rs | 9 + codex-rs/core/src/unified_exec/mod.rs | 2 + codex-rs/core/src/unified_exec/mod_tests.rs | 39 ++- .../core/src/unified_exec/process_manager.rs | 2 + codex-rs/core/tests/suite/code_mode.rs | 237 ++++++++++++++++-- 10 files changed, 278 insertions(+), 57 deletions(-) diff --git a/codex-rs/core/src/tools/context.rs b/codex-rs/core/src/tools/context.rs index 5fc19a2a2e..57cf0d80b3 100644 --- a/codex-rs/core/src/tools/context.rs +++ b/codex-rs/core/src/tools/context.rs @@ -311,6 +311,7 @@ pub struct ExecCommandToolOutput { pub wall_time: Duration, /// Raw bytes returned for this unified exec call before any truncation. pub raw_output: Vec, + pub truncation_policy: TruncationPolicy, pub max_output_tokens: Option, pub process_id: Option, pub exit_code: Option, @@ -357,7 +358,9 @@ impl ToolOutput for ExecCommandToolOutput { return None; } - Some(JsonValue::String(self.truncated_output())) + Some(JsonValue::String( + self.truncated_output(self.model_output_max_tokens()), + )) } fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { @@ -381,7 +384,10 @@ impl ToolOutput for ExecCommandToolOutput { exit_code: self.exit_code, session_id: self.process_id, original_token_count: self.original_token_count, - output: self.truncated_output(), + output: match self.max_output_tokens { + Some(max_tokens) => self.truncated_output(max_tokens), + None => String::from_utf8_lossy(&self.raw_output).to_string(), + }, }; serde_json::to_value(result).unwrap_or_else(|err| { @@ -391,9 +397,12 @@ impl ToolOutput for ExecCommandToolOutput { } impl ExecCommandToolOutput { - pub(crate) fn truncated_output(&self) -> String { + fn model_output_max_tokens(&self) -> usize { + resolve_max_tokens(self.max_output_tokens).min(self.truncation_policy.token_budget()) + } + + pub(crate) fn truncated_output(&self, max_tokens: usize) -> String { let text = String::from_utf8_lossy(&self.raw_output).to_string(); - let max_tokens = resolve_max_tokens(self.max_output_tokens); formatted_truncate_text(&text, TruncationPolicy::Tokens(max_tokens)) } @@ -420,7 +429,7 @@ impl ExecCommandToolOutput { } sections.push("Output:".to_string()); - sections.push(self.truncated_output()); + sections.push(self.truncated_output(self.model_output_max_tokens())); sections.join("\n") } diff --git a/codex-rs/core/src/tools/context_tests.rs b/codex-rs/core/src/tools/context_tests.rs index fe40341e95..6ac5b6978f 100644 --- a/codex-rs/core/src/tools/context_tests.rs +++ b/codex-rs/core/src/tools/context_tests.rs @@ -429,6 +429,7 @@ fn exec_command_tool_output_formats_truncated_response() { chunk_id: "abc123".to_string(), wall_time: std::time::Duration::from_millis(1250), raw_output: b"token one token two token three token four token five".to_vec(), + truncation_policy: TruncationPolicy::Tokens(10_000), max_output_tokens: Some(4), process_id: None, exit_code: Some(0), diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index b4e7bd6acf..170c1bd72f 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -7,10 +7,8 @@ use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; use crate::tools::hook_names::HookToolName; use crate::tools::registry::PostToolUsePayload; -use crate::unified_exec::resolve_max_tokens; use codex_protocol::models::AdditionalPermissionProfile; use codex_tools::UnifiedExecShellMode; -use codex_utils_output_truncation::TruncationPolicy; use serde::Deserialize; use std::path::PathBuf; use std::sync::Arc; @@ -72,13 +70,6 @@ fn default_tty() -> bool { false } -fn effective_max_output_tokens( - max_output_tokens: Option, - truncation_policy: TruncationPolicy, -) -> usize { - resolve_max_tokens(max_output_tokens).min(truncation_policy.token_budget()) -} - #[derive(Debug)] pub(crate) struct ResolvedCommand { pub(crate) command: Vec, diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index af1c752528..62b1ed3d9f 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -36,7 +36,6 @@ use super::super::shell_spec::CommandToolOptions; use super::super::shell_spec::create_exec_command_tool_with_environment_id; use super::ExecCommandArgs; use super::ExecCommandEnvironmentArgs; -use super::effective_max_output_tokens; use super::get_command; use super::post_unified_exec_tool_use_payload; @@ -162,8 +161,6 @@ impl ToolExecutor for ExecCommandHandler { prefix_rule, .. } = args; - let max_output_tokens = - effective_max_output_tokens(max_output_tokens, turn.truncation_policy); let exec_permission_approvals_enabled = session.features().enabled(Feature::ExecPermissionApprovals); @@ -241,7 +238,8 @@ impl ToolExecutor for ExecCommandHandler { chunk_id: String::new(), wall_time: std::time::Duration::ZERO, raw_output: output.into_text().into_bytes(), - max_output_tokens: Some(max_output_tokens), + truncation_policy: turn.truncation_policy, + max_output_tokens, process_id: None, exit_code: None, original_token_count: None, @@ -258,7 +256,7 @@ impl ToolExecutor for ExecCommandHandler { hook_command: hook_command.clone(), process_id, yield_time_ms, - max_output_tokens: Some(max_output_tokens), + max_output_tokens, cwd, sandbox_cwd: turn_environment.cwd.clone(), environment, @@ -284,7 +282,8 @@ impl ToolExecutor for ExecCommandHandler { chunk_id: generate_chunk_id(), wall_time: output.duration, raw_output: output_text.into_bytes(), - max_output_tokens: Some(max_output_tokens), + truncation_policy: turn.truncation_policy, + max_output_tokens, // Sandbox denial is terminal, so there is no live // process for write_stdin to resume. process_id: None, diff --git a/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs b/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs index 843f9e638f..77565eaac2 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/write_stdin.rs @@ -14,7 +14,6 @@ use codex_tools::ToolSpec; use serde::Deserialize; use super::super::shell_spec::create_write_stdin_tool; -use super::effective_max_output_tokens; use super::post_unified_exec_tool_use_payload; #[derive(Debug, Deserialize)] @@ -62,8 +61,6 @@ impl ToolExecutor for WriteStdinHandler { }; let args: WriteStdinArgs = parse_arguments(&arguments)?; - let max_output_tokens = - effective_max_output_tokens(args.max_output_tokens, turn.truncation_policy); let response = session .services .unified_exec_manager @@ -71,7 +68,8 @@ impl ToolExecutor for WriteStdinHandler { process_id: args.session_id, input: &args.chars, yield_time_ms: args.yield_time_ms, - max_output_tokens: Some(max_output_tokens), + max_output_tokens: args.max_output_tokens, + truncation_policy: turn.truncation_policy, }) .await .map_err(|err| { diff --git a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs index 50a8e44214..0eb9ebbedb 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs @@ -4,6 +4,7 @@ use crate::shell::default_user_shell; use codex_tools::UnifiedExecShellMode; use codex_tools::ZshForkConfig; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_output_truncation::TruncationPolicy; use pretty_assertions::assert_eq; use std::sync::Arc; @@ -17,6 +18,8 @@ use crate::tools::registry::CoreToolRuntime; use crate::turn_diff_tracker::TurnDiffTracker; use tokio::sync::Mutex; +const TEST_TRUNCATION_POLICY: TruncationPolicy = TruncationPolicy::Tokens(10_000); + async fn invocation_for_payload( tool_name: &str, call_id: &str, @@ -258,6 +261,7 @@ async fn exec_command_post_tool_use_payload_uses_output_for_noninteractive_one_s chunk_id: "chunk-1".to_string(), wall_time: std::time::Duration::from_millis(498), raw_output: b"three".to_vec(), + truncation_policy: TEST_TRUNCATION_POLICY, max_output_tokens: None, process_id: None, exit_code: Some(0), @@ -287,6 +291,7 @@ async fn exec_command_post_tool_use_payload_uses_output_for_interactive_completi chunk_id: "chunk-1".to_string(), wall_time: std::time::Duration::from_millis(498), raw_output: b"three".to_vec(), + truncation_policy: TEST_TRUNCATION_POLICY, max_output_tokens: None, process_id: None, exit_code: Some(0), @@ -317,6 +322,7 @@ async fn exec_command_post_tool_use_payload_skips_running_sessions() { chunk_id: "chunk-1".to_string(), wall_time: std::time::Duration::from_millis(498), raw_output: b"three".to_vec(), + truncation_policy: TEST_TRUNCATION_POLICY, max_output_tokens: None, process_id: Some(45), exit_code: None, @@ -342,6 +348,7 @@ async fn write_stdin_post_tool_use_payload_uses_original_exec_call_id_and_comman chunk_id: "chunk-2".to_string(), wall_time: std::time::Duration::from_millis(498), raw_output: b"finished\n".to_vec(), + truncation_policy: TEST_TRUNCATION_POLICY, max_output_tokens: None, process_id: None, exit_code: Some(0), @@ -372,6 +379,7 @@ async fn write_stdin_post_tool_use_payload_keeps_parallel_session_metadata_separ chunk_id: "chunk-a".to_string(), wall_time: std::time::Duration::from_millis(498), raw_output: b"alpha\n".to_vec(), + truncation_policy: TEST_TRUNCATION_POLICY, max_output_tokens: None, process_id: None, exit_code: Some(0), @@ -383,6 +391,7 @@ async fn write_stdin_post_tool_use_payload_keeps_parallel_session_metadata_separ chunk_id: "chunk-b".to_string(), wall_time: std::time::Duration::from_millis(498), raw_output: b"beta\n".to_vec(), + truncation_policy: TEST_TRUNCATION_POLICY, max_output_tokens: None, process_id: None, exit_code: Some(0), diff --git a/codex-rs/core/src/unified_exec/mod.rs b/codex-rs/core/src/unified_exec/mod.rs index 25abaf2835..6be32d26e7 100644 --- a/codex-rs/core/src/unified_exec/mod.rs +++ b/codex-rs/core/src/unified_exec/mod.rs @@ -31,6 +31,7 @@ use codex_exec_server::Environment; use codex_network_proxy::NetworkProxy; use codex_protocol::models::AdditionalPermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_output_truncation::TruncationPolicy; use rand::Rng; use rand::rng; use tokio::sync::Mutex; @@ -111,6 +112,7 @@ pub(crate) struct WriteStdinRequest<'a> { pub input: &'a str, pub yield_time_ms: u64, pub max_output_tokens: Option, + pub truncation_policy: TruncationPolicy, } #[derive(Default)] diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index 6926f9cbf1..d416be2e33 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -10,6 +10,7 @@ use crate::tools::context::ExecCommandToolOutput; use crate::unified_exec::WriteStdinRequest; use crate::unified_exec::process::OutputHandles; use codex_sandboxing::SandboxType; +use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::approx_token_count; use core_test_support::get_remote_test_env; use core_test_support::skip_if_sandbox; @@ -162,6 +163,7 @@ async fn exec_command_with_tty( chunk_id: generate_chunk_id(), wall_time, raw_output: collected, + truncation_policy: turn.truncation_policy, max_output_tokens: None, process_id: response_process_id, exit_code, @@ -195,6 +197,7 @@ async fn write_stdin( input, yield_time_ms, max_output_tokens: None, + truncation_policy: TruncationPolicy::Tokens(10_000), }) .await } @@ -260,7 +263,9 @@ async fn unified_exec_persists_across_requests() -> anyhow::Result<()> { ) .await?; assert!( - out_2.truncated_output().contains("codex"), + out_2 + .truncated_output(DEFAULT_MAX_OUTPUT_TOKENS) + .contains("codex"), "expected environment variable output" ); @@ -301,7 +306,9 @@ async fn multi_unified_exec_sessions() -> anyhow::Result<()> { "short command should not report a process id if it exits quickly" ); assert!( - !out_2.truncated_output().contains("codex"), + !out_2 + .truncated_output(DEFAULT_MAX_OUTPUT_TOKENS) + .contains("codex"), "short command should run in a fresh shell" ); @@ -313,7 +320,9 @@ async fn multi_unified_exec_sessions() -> anyhow::Result<()> { ) .await?; assert!( - out_3.truncated_output().contains("codex"), + out_3 + .truncated_output(DEFAULT_MAX_OUTPUT_TOKENS) + .contains("codex"), "session should preserve state" ); @@ -350,7 +359,9 @@ async fn unified_exec_timeouts() -> anyhow::Result<()> { ) .await?; assert!( - !out_2.truncated_output().contains(TEST_VAR_VALUE), + !out_2 + .truncated_output(DEFAULT_MAX_OUTPUT_TOKENS) + .contains(TEST_VAR_VALUE), "timeout too short should yield incomplete output" ); @@ -359,7 +370,9 @@ async fn unified_exec_timeouts() -> anyhow::Result<()> { let out_3 = write_stdin(&session, process_id, "", /*yield_time_ms*/ 100).await?; assert!( - out_3.truncated_output().contains(TEST_VAR_VALUE), + out_3 + .truncated_output(DEFAULT_MAX_OUTPUT_TOKENS) + .contains(TEST_VAR_VALUE), "subsequent poll should retrieve output" ); @@ -394,7 +407,9 @@ async fn unified_exec_pause_blocks_yield_timeout() -> anyhow::Result<()> { "pause should block the unified exec yield timeout" ); assert!( - response.truncated_output().contains("unified-exec-done"), + response + .truncated_output(DEFAULT_MAX_OUTPUT_TOKENS) + .contains("unified-exec-done"), "exec_command should wait for output after the pause lifts" ); assert!( @@ -420,7 +435,11 @@ async fn requests_with_large_timeout_are_capped() -> anyhow::Result<()> { .await?; assert!(result.process_id.is_some()); - assert!(result.truncated_output().contains("codex")); + assert!( + result + .truncated_output(DEFAULT_MAX_OUTPUT_TOKENS) + .contains("codex") + ); Ok(()) } @@ -442,7 +461,11 @@ async fn completed_commands_do_not_persist_sessions() -> anyhow::Result<()> { result.process_id.is_some(), "completed command should report a process id" ); - assert!(result.truncated_output().contains("codex")); + assert!( + result + .truncated_output(DEFAULT_MAX_OUTPUT_TOKENS) + .contains("codex") + ); assert!( session diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index c6fc2ab3e5..2f5de77319 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -581,6 +581,7 @@ impl UnifiedExecProcessManager { chunk_id, wall_time, raw_output: collected, + truncation_policy: context.turn.truncation_policy, max_output_tokens: request.max_output_tokens, process_id: response_process_id, exit_code, @@ -725,6 +726,7 @@ impl UnifiedExecProcessManager { chunk_id, wall_time, raw_output: collected, + truncation_policy: request.truncation_policy, max_output_tokens: request.max_output_tokens, process_id, exit_code, diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 19373b52e4..b733d7bded 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -5,6 +5,7 @@ use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_config::types::McpServerConfig; use codex_config::types::McpServerTransportConfig; +use codex_core::config::Config; use codex_features::Feature; use codex_login::CodexAuth; use codex_models_manager::bundled_models_response; @@ -144,11 +145,21 @@ async fn run_code_mode_turn( server: &MockServer, prompt: &str, code: &str, +) -> Result<(TestCodex, ResponseMock)> { + run_code_mode_turn_with_config(server, prompt, code, |_| {}).await +} + +async fn run_code_mode_turn_with_config( + server: &MockServer, + prompt: &str, + code: &str, + configure: impl FnOnce(&mut Config) + Send + 'static, ) -> Result<(TestCodex, ResponseMock)> { let mut builder = test_codex() .with_model("test-gpt-5.1-codex") .with_config(move |config| { let _ = config.features.enable(Feature::CodeMode); + configure(config); }); let test = builder.build(server).await?; @@ -292,8 +303,7 @@ text(JSON.stringify(await tools.exec_command({ cmd: "printf code_mode_exec_marke ) .await?; - let req = second_mock.single_request(); - let items = custom_tool_output_items(&req, "call-1"); + let items = custom_tool_output_items(&second_mock.single_request(), "call-1"); assert_eq!(items.len(), 2); assert_regex_match( concat!( @@ -645,40 +655,217 @@ text(JSON.stringify(results)); #[cfg_attr(windows, ignore = "no exec_command on Windows")] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn code_mode_can_truncate_final_result_with_configured_budget() -> Result<()> { +async fn code_mode_exec_command_explicit_max_output_tokens_truncates() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; let (_test, second_mock) = run_code_mode_turn( &server, - "use exec to truncate the final result", - r#"// @exec: {"max_output_tokens": 6} -text(JSON.stringify(await tools.exec_command({ - cmd: "printf 'token one token two token three token four token five token six token seven'", - max_output_tokens: 100 -}))); + "use exec_command from code mode", + r#" +const result = await tools.exec_command({ + cmd: "printf '0123456789012345678901234567890123456789'", + max_output_tokens: 5 +}); +text(result.output); "#, ) .await?; - let req = second_mock.single_request(); - let items = custom_tool_output_items(&req, "call-1"); - assert_eq!(items.len(), 2); - assert_regex_match( - concat!( - r"(?s)\A", - r"Script completed\nWall time \d+\.\d seconds\nOutput:\n\z" + assert_eq!( + text_item( + &custom_tool_output_items(&second_mock.single_request(), "call-1"), + /*index*/ 1 ), - text_item(&items, /*index*/ 0), + "Total output lines: 1\n\n0123456789…5 tokens truncated…0123456789" + ); + + Ok(()) +} + +#[cfg_attr(windows, ignore = "no exec_command on Windows")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_exec_explicit_max_above_default_preserves_output() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let (_test, second_mock) = run_code_mode_turn( + &server, + "use exec_command from code mode", + r#"// @exec: {"max_output_tokens": 20000} +const result = await tools.exec_command({ + cmd: "python3 -c \"import sys; sys.stdout.write('x' * 50000)\"", + max_output_tokens: 20000 +}); +text(result.output); +"#, + ) + .await?; + + assert_eq!( + text_item( + &custom_tool_output_items(&second_mock.single_request(), "call-1"), + /*index*/ 1 + ), + "x".repeat(50_000) + ); + + Ok(()) +} + +#[cfg_attr(windows, ignore = "no exec_command on Windows")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_exec_explicit_max_above_default_truncates_larger_output() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let (_test, second_mock) = run_code_mode_turn( + &server, + "use exec_command from code mode", + r#"// @exec: {"max_output_tokens": 25000} +const result = await tools.exec_command({ + cmd: "python3 -c \"import sys; sys.stdout.write('A' * 90000)\"", + max_output_tokens: 20000 +}); +text(result.output); +"#, + ) + .await?; + + assert_eq!( + text_item( + &custom_tool_output_items(&second_mock.single_request(), "call-1"), + /*index*/ 1 + ), + format!( + "Total output lines: 1\n\n{}…2500 tokens truncated…{}", + "A".repeat(40_000), + "A".repeat(40_000) + ) + ); + + Ok(()) +} + +#[cfg_attr(windows, ignore = "no exec_command on Windows")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_exec_explicit_max_above_truncation_policy_preserves_output() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let (_test, second_mock) = run_code_mode_turn_with_config( + &server, + "use exec_command from code mode", + r#"// @exec: {"max_output_tokens": 20000} +const result = await tools.exec_command({ + cmd: "python3 -c \"import sys; sys.stdout.write('x' * 50000)\"", + max_output_tokens: 20000 +}); +text(result.output); +"#, + |config| { + config.tool_output_token_limit = Some(50); + }, + ) + .await?; + + assert_eq!( + text_item( + &custom_tool_output_items(&second_mock.single_request(), "call-1"), + /*index*/ 1 + ), + "x".repeat(50_000) + ); + + Ok(()) +} + +#[cfg_attr(windows, ignore = "no exec_command on Windows")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_exec_without_max_preserves_output_beyond_default() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let (_test, second_mock) = run_code_mode_turn( + &server, + "use exec_command from code mode", + r#"// @exec: {"max_output_tokens": 20000} +const result = await tools.exec_command({ + cmd: "python3 -c \"import sys; sys.stdout.write('x' * 50000)\"" +}); +text(result.output); +"#, + ) + .await?; + + assert_eq!( + text_item( + &custom_tool_output_items(&second_mock.single_request(), "call-1"), + /*index*/ 1 + ), + "x".repeat(50_000) + ); + + Ok(()) +} + +#[cfg_attr(windows, ignore = "no exec_command on Windows")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_exec_without_max_preserves_output_beyond_truncation_policy() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let (_test, second_mock) = run_code_mode_turn_with_config( + &server, + "use exec_command from code mode", + r#"// @exec: {"max_output_tokens": 20000} +const result = await tools.exec_command({ + cmd: "python3 -c \"import sys; sys.stdout.write('x' * 50000)\"" +}); +text(result.output); +"#, + |config| { + config.tool_output_token_limit = Some(50); + }, + ) + .await?; + + assert_eq!( + text_item( + &custom_tool_output_items(&second_mock.single_request(), "call-1"), + /*index*/ 1 + ), + "x".repeat(50_000) + ); + + Ok(()) +} + +#[cfg_attr(windows, ignore = "no exec_command on Windows")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn code_mode_exec_explicit_max_output_tokens_truncates() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let (_test, second_mock) = run_code_mode_turn( + &server, + "use exec_command from code mode", + r#"// @exec: {"max_output_tokens": 5} +const result = await tools.exec_command({ + cmd: "printf '0123456789012345678901234567890123456789'" +}); +text(result.output); +"#, + ) + .await?; + + assert_eq!( + text_item( + &custom_tool_output_items(&second_mock.single_request(), "call-1"), + /*index*/ 1 + ), + "Total output lines: 1\n\n0123456789…5 tokens truncated…0123456789" ); - let expected_pattern = r#"(?sx) -\A -Total\ output\ lines:\ 1\n -\n -.*…\d+\ tokens\ truncated….* -\z -"#; - assert_regex_match(expected_pattern, text_item(&items, /*index*/ 1)); Ok(()) } From 9dda71dbae98976bc6d0c1c769810bcf24bb6092 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Tue, 19 May 2026 21:56:46 -0700 Subject: [PATCH 07/11] Warn on invalid UTF-8 in AGENTS.md files (#23232) Fixes #23223. ## Why Malformed AGENTS instructions should not fail silently. The reported issue had invalid UTF-8 in a global `AGENTS.md`; before this change, Codex treated that decode failure like a missing file, so the personal instructions disappeared without a user-visible explanation and the rollout had no `# AGENTS.md instructions` block. Project-level AGENTS files already used lossy decoding, so their instructions still appeared, but invalid bytes were replaced without telling the user. Global and project AGENTS files should behave consistently: keep usable instruction text when possible, and surface a diagnostic when bytes had to be replaced. ## What changed Global `AGENTS.override.md` and `AGENTS.md` loading now reads bytes and decodes with replacement characters on invalid UTF-8, matching project-level AGENTS behavior. Both global and project AGENTS loading now emit a startup warning when invalid UTF-8 is found, and both keep the instruction text with invalid byte sequences replaced. Missing files, non-file candidates, empty files, and the existing `AGENTS.override.md` before `AGENTS.md` precedence keep their current behavior. ## How users see it The warnings flow through the existing startup warning surface. App-server clients receive config-time startup warnings as `configWarning` notifications during initialization, and thread startup emits startup warnings as thread-scoped `warning` notifications. Global AGENTS invalid UTF-8 warnings can appear on both surfaces. Project-level AGENTS invalid UTF-8 warnings are discovered while building thread instructions, so they appear as thread-scoped `warning` notifications. Clients that render warning notifications in the conversation surface show the message as a visible diagnostic instead of silently hiding or altering instructions. --- codex-rs/core/src/agents_md.rs | 72 +++++++++++++++++++++------- codex-rs/core/src/agents_md_tests.rs | 59 ++++++++++++++++++++++- codex-rs/core/src/config/mod.rs | 11 +++-- codex-rs/core/src/session/mod.rs | 7 ++- 4 files changed, 126 insertions(+), 23 deletions(-) diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index 2c635e7002..6daaff4a40 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -62,18 +62,31 @@ impl<'a> AgentsMdManager<'a> { pub(crate) async fn load_global_instructions( fs: &dyn ExecutorFileSystem, codex_dir: Option<&AbsolutePathBuf>, + startup_warnings: &mut Vec, ) -> Option { let base = codex_dir?; for candidate in [LOCAL_AGENTS_MD_FILENAME, DEFAULT_AGENTS_MD_FILENAME] { let path = base.join(candidate); - if let Ok(contents) = fs.read_file_text(&path, /*sandbox*/ None).await { - let trimmed = contents.trim(); - if !trimmed.is_empty() { - return Some(LoadedAgentsMd { - contents: trimmed.to_string(), - path, - }); + let data = match fs.read_file(&path, /*sandbox*/ None).await { + Ok(data) => data, + Err(err) if err.kind() == io::ErrorKind::NotFound => continue, + Err(err) if err.kind() == io::ErrorKind::IsADirectory => continue, + Err(err) => { + startup_warnings.push(format!( + "Failed to read global AGENTS.md instructions from `{}`: {err}", + path.display() + )); + continue; } + }; + warn_invalid_utf8(&path, &data, "Global", startup_warnings); + let contents = String::from_utf8_lossy(&data); + let trimmed = contents.trim(); + if !trimmed.is_empty() { + return Some(LoadedAgentsMd { + contents: trimmed.to_string(), + path, + }); } } None @@ -84,16 +97,19 @@ impl<'a> AgentsMdManager<'a> { pub(crate) async fn user_instructions( &self, environment: Option<&Environment>, + startup_warnings: &mut Vec, ) -> Option { let fs = environment?.get_filesystem(); - self.user_instructions_with_fs(fs.as_ref()).await + self.user_instructions_with_fs(fs.as_ref(), startup_warnings) + .await } - pub(crate) async fn user_instructions_with_fs( + async fn user_instructions_with_fs( &self, fs: &dyn ExecutorFileSystem, + startup_warnings: &mut Vec, ) -> Option { - let agents_md_docs = self.read_agents_md(fs).await; + let agents_md_docs = self.read_agents_md(fs, startup_warnings).await; let mut output = String::new(); @@ -130,11 +146,15 @@ impl<'a> AgentsMdManager<'a> { /// Returns all instruction source files included in the current config. pub async fn instruction_sources(&self, fs: &dyn ExecutorFileSystem) -> Vec { - let mut paths = - Self::load_global_instructions(LOCAL_FS.as_ref(), Some(&self.config.codex_home)) - .await - .map(|loaded| vec![loaded.path]) - .unwrap_or_default(); + let mut global_instruction_warnings = Vec::new(); + let mut paths = Self::load_global_instructions( + LOCAL_FS.as_ref(), + Some(&self.config.codex_home), + &mut global_instruction_warnings, + ) + .await + .map(|loaded| vec![loaded.path]) + .unwrap_or_default(); match self.agents_md_paths(fs).await { Ok(agents_md_paths) => paths.extend(agents_md_paths), Err(err) => { @@ -150,7 +170,11 @@ impl<'a> AgentsMdManager<'a> { /// concatenation of all discovered docs. If no documentation file is found /// the function returns `Ok(None)`. Unexpected I/O failures bubble up as /// `Err` so callers can decide how to handle them. - async fn read_agents_md(&self, fs: &dyn ExecutorFileSystem) -> io::Result> { + async fn read_agents_md( + &self, + fs: &dyn ExecutorFileSystem, + startup_warnings: &mut Vec, + ) -> io::Result> { let max_total = self.config.project_doc_max_bytes; if max_total == 0 { @@ -182,6 +206,8 @@ impl<'a> AgentsMdManager<'a> { Err(err) if err.kind() == io::ErrorKind::NotFound => continue, Err(err) => return Err(err), }; + warn_invalid_utf8(&p, &data, "Project", startup_warnings); + let size = data.len() as u64; if size > remaining { data.truncate(remaining as usize); @@ -324,6 +350,20 @@ impl<'a> AgentsMdManager<'a> { } } +fn warn_invalid_utf8( + path: &AbsolutePathBuf, + data: &[u8], + source: &str, + startup_warnings: &mut Vec, +) { + if let Err(err) = std::str::from_utf8(data) { + startup_warnings.push(format!( + "{source} AGENTS.md instructions from `{}` contain invalid UTF-8: {err}. Invalid byte sequences were replaced.", + path.display() + )); + } +} + #[cfg(test)] #[path = "agents_md_tests.rs"] mod tests; diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index a3a7544823..2d6a5fa30b 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -7,12 +7,14 @@ use core_test_support::PathBufExt; use core_test_support::TempDirExt; use pretty_assertions::assert_eq; use std::fs; +use std::path::Path; use std::path::PathBuf; use tempfile::TempDir; async fn get_user_instructions(config: &Config) -> Option { + let mut warnings = Vec::new(); AgentsMdManager::new(config) - .user_instructions_with_fs(LOCAL_FS.as_ref()) + .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) .await } @@ -22,6 +24,19 @@ async fn agents_md_paths(config: &Config) -> std::io::Result Date: Tue, 19 May 2026 22:03:08 -0700 Subject: [PATCH 08/11] feat: Add vertical remote plugin collection support (#23584) - Adds an explicit vertical marketplace kind for plugin/list that fail-open fetches collection=vertical only when full remote plugins are disabled. - Renames the global remote marketplace/cache identity to openai-curated-remote and materializes remote installs with backend release versions and app manifests. --- .../schema/json/ClientRequest.json | 1 + .../codex_app_server_protocol.schemas.json | 1 + .../codex_app_server_protocol.v2.schemas.json | 1 + .../schema/json/v2/PluginListParams.json | 1 + .../v2/PluginListMarketplaceKind.ts | 2 +- .../src/protocol/v2/plugin.rs | 3 + .../src/protocol/v2/tests.rs | 22 +- .../src/request_processors/plugins.rs | 40 +- .../tests/suite/v2/plugin_install.rs | 109 +++- .../app-server/tests/suite/v2/plugin_list.rs | 544 +++++++++++++----- .../app-server/tests/suite/v2/plugin_read.rs | 22 +- .../tests/suite/v2/plugin_uninstall.rs | 16 +- .../app-server/tests/suite/v2/skills_list.rs | 2 +- codex-rs/core-plugins/src/manager_tests.rs | 8 +- codex-rs/core-plugins/src/remote.rs | 69 ++- .../remote/remote_installed_plugin_sync.rs | 3 +- .../core-plugins/src/remote/share/checkout.rs | 1 + codex-rs/core-plugins/src/remote_bundle.rs | 182 +++++- 18 files changed, 806 insertions(+), 221 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 506358d25d..ee68faf001 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1632,6 +1632,7 @@ "PluginListMarketplaceKind": { "enum": [ "local", + "vertical", "workspace-directory", "shared-with-me" ], diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 73e4335bf9..3db4a87ef2 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -12193,6 +12193,7 @@ "PluginListMarketplaceKind": { "enum": [ "local", + "vertical", "workspace-directory", "shared-with-me" ], diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index ea8655d0fe..7b62d6d4bf 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -8742,6 +8742,7 @@ "PluginListMarketplaceKind": { "enum": [ "local", + "vertical", "workspace-directory", "shared-with-me" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json index 65b1b4e88d..9c15ed5a7c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json @@ -8,6 +8,7 @@ "PluginListMarketplaceKind": { "enum": [ "local", + "vertical", "workspace-directory", "shared-with-me" ], diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListMarketplaceKind.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListMarketplaceKind.ts index 6ff6161f34..1be75e6f02 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListMarketplaceKind.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListMarketplaceKind.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type PluginListMarketplaceKind = "local" | "workspace-directory" | "shared-with-me"; +export type PluginListMarketplaceKind = "local" | "vertical" | "workspace-directory" | "shared-with-me"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs index 33c2670077..70d283994e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -144,6 +144,9 @@ pub enum PluginListMarketplaceKind { #[serde(rename = "local")] #[ts(rename = "local")] Local, + #[serde(rename = "vertical")] + #[ts(rename = "vertical")] + Vertical, #[serde(rename = "workspace-directory")] #[ts(rename = "workspace-directory")] WorkspaceDirectory, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 2ee583f991..09a8b609be 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -2708,14 +2708,14 @@ fn marketplace_upgrade_params_serialization_uses_optional_marketplace_name() { fn plugin_marketplace_entry_serializes_remote_only_path_as_null() { assert_eq!( serde_json::to_value(PluginMarketplaceEntry { - name: "openai-curated".to_string(), + name: "openai-curated-remote".to_string(), path: None, interface: None, plugins: Vec::new(), }) .unwrap(), json!({ - "name": "openai-curated", + "name": "openai-curated-remote", "path": null, "interface": null, "plugins": [], @@ -2799,6 +2799,7 @@ fn plugin_list_params_serializes_marketplace_kind_filter() { cwds: None, marketplace_kinds: Some(vec![ PluginListMarketplaceKind::Local, + PluginListMarketplaceKind::Vertical, PluginListMarketplaceKind::WorkspaceDirectory, PluginListMarketplaceKind::SharedWithMe, ]), @@ -2808,6 +2809,7 @@ fn plugin_list_params_serializes_marketplace_kind_filter() { "cwds": null, "marketplaceKinds": [ "local", + "vertical", "workspace-directory", "shared-with-me", ], @@ -2875,13 +2877,13 @@ fn plugin_read_params_serialization_uses_install_source_fields() { assert_eq!( serde_json::from_value::(json!({ - "remoteMarketplaceName": "openai-curated", + "remoteMarketplaceName": "openai-curated-remote", "pluginName": "gmail", })) .unwrap(), PluginReadParams { marketplace_path: None, - remote_marketplace_name: Some("openai-curated".to_string()), + remote_marketplace_name: Some("openai-curated-remote".to_string()), plugin_name: "gmail".to_string(), }, ); @@ -2926,14 +2928,14 @@ fn plugin_install_params_serialization_omits_force_remote_sync() { assert_eq!( serde_json::from_value::(json!({ - "remoteMarketplaceName": "openai-curated", + "remoteMarketplaceName": "openai-curated-remote", "pluginName": "gmail", "forceRemoteSync": true, })) .unwrap(), PluginInstallParams { marketplace_path: None, - remote_marketplace_name: Some("openai-curated".to_string()), + remote_marketplace_name: Some("openai-curated-remote".to_string()), plugin_name: "gmail".to_string(), }, ); @@ -2943,13 +2945,13 @@ fn plugin_install_params_serialization_omits_force_remote_sync() { fn plugin_skill_read_params_serialization_uses_remote_plugin_id() { assert_eq!( serde_json::to_value(PluginSkillReadParams { - remote_marketplace_name: "chatgpt-global".to_string(), + remote_marketplace_name: "openai-curated-remote".to_string(), remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), skill_name: "plan-work".to_string(), }) .unwrap(), json!({ - "remoteMarketplaceName": "chatgpt-global", + "remoteMarketplaceName": "openai-curated-remote", "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", "skillName": "plan-work", }), @@ -3144,7 +3146,7 @@ fn plugin_share_list_response_serializes_share_items() { serde_json::to_value(PluginShareListResponse { data: vec![PluginShareListItem { plugin: PluginSummary { - id: "gmail@chatgpt-global".to_string(), + id: "gmail@openai-curated-remote".to_string(), remote_plugin_id: Some( "plugins~Plugin_00000000000000000000000000000000".to_string(), ), @@ -3167,7 +3169,7 @@ fn plugin_share_list_response_serializes_share_items() { json!({ "data": [{ "plugin": { - "id": "gmail@chatgpt-global", + "id": "gmail@openai-curated-remote", "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", "localVersion": null, "name": "gmail", diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 199ddf1191..61fd363b2e 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -475,6 +475,7 @@ impl PluginRequestProcessor { let marketplace_kinds = marketplace_kinds.unwrap_or_else(|| vec![PluginListMarketplaceKind::Local]); let include_local = marketplace_kinds.contains(&PluginListMarketplaceKind::Local); + let include_vertical = marketplace_kinds.contains(&PluginListMarketplaceKind::Vertical); let config = self.load_latest_config(/*fallback_cwd*/ None).await?; let empty_response = || PluginListResponse { @@ -564,6 +565,35 @@ impl PluginRequestProcessor { (Vec::new(), Vec::new()) }; + // TODO(remote plugins): Remove this once remote plugins are ready and vertical plugins are + // served directly from the normal remote catalog. + if include_vertical && !config.features.enabled(Feature::RemotePlugin) { + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + match codex_core_plugins::remote::fetch_openai_curated_remote_collection_marketplace( + &remote_plugin_service_config, + auth.as_ref(), + ) + .await + { + Ok(Some(remote_marketplace)) => { + data.push(remote_marketplace_to_info(remote_marketplace)); + } + Ok(None) => {} + Err( + RemotePluginCatalogError::AuthRequired + | RemotePluginCatalogError::UnsupportedAuthMode, + ) => {} + Err(err) => { + warn!( + error = %err, + "plugin/list openai-curated-remote collection fetch failed; returning local marketplaces only" + ); + } + } + } + let mut remote_sources = Vec::new(); if !explicit_marketplace_kinds && config.features.enabled(Feature::RemotePlugin) { remote_sources.push(RemoteMarketplaceSource::Global); @@ -592,14 +622,7 @@ impl PluginRequestProcessor { .into_iter() .map(remote_marketplace_to_info) { - if let Some(existing) = data - .iter_mut() - .find(|marketplace| marketplace.name == remote_marketplace.name) - { - *existing = remote_marketplace; - } else { - data.push(remote_marketplace); - } + data.push(remote_marketplace); } } Err( @@ -1407,6 +1430,7 @@ impl PluginRequestProcessor { &remote_detail.summary.name, remote_detail.release_version.as_deref(), remote_detail.bundle_download_url.as_deref(), + remote_detail.app_manifest.clone(), ) .map_err(remote_plugin_bundle_install_error_to_jsonrpc)?; diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 6adcd92195..9d17e8747f 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -132,7 +132,7 @@ async fn plugin_install_rejects_multiple_install_sources() -> Result<()> { marketplace_path: Some(AbsolutePathBuf::try_from( codex_home.path().join("marketplace.json"), )?), - remote_marketplace_name: Some("openai-curated".to_string()), + remote_marketplace_name: Some("openai-curated-remote".to_string()), plugin_name: "sample-plugin".to_string(), }) .await?; @@ -167,7 +167,7 @@ plugins = false let request_id = mcp .send_plugin_install_request(PluginInstallParams { marketplace_path: None, - remote_marketplace_name: Some("chatgpt-global".to_string()), + remote_marketplace_name: Some("openai-curated-remote".to_string()), plugin_name: "plugins~Plugin_22222222222222222222222222222222".to_string(), }) .await?; @@ -193,15 +193,32 @@ async fn plugin_install_writes_remote_plugin_to_cloud_and_cache() -> Result<()> let server = MockServer::start().await; let installed_path = codex_home .path() - .join("plugins/cache/chatgpt-global/linear/1.2.3"); + .join("plugins/cache/openai-curated-remote/linear/1.2.3"); + let remote_app_manifest = json!({ + "apps": { + "linear-remote": { + "id": "remote-linear-app" + } + } + }); let bundle_url = mount_remote_plugin_bundle( &server, /*status_code*/ 200, - remote_plugin_bundle_tar_gz_bytes("linear")?, + remote_plugin_bundle_tar_gz_bytes_with_contents( + r#"{"name":"linear","version":"0.0.1"}"#, + Some(r#"{"apps":{"linear-bundled":{"id":"bundled-linear-app"}}}"#), + )?, ) .await; configure_remote_plugin_test(codex_home.path(), &server)?; - mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await; + mount_remote_plugin_detail_with_app_manifest( + &server, + REMOTE_PLUGIN_ID, + "1.2.3", + Some(&bundle_url), + remote_app_manifest.clone(), + ) + .await; mount_empty_remote_installed_plugins(&server).await; mount_remote_plugin_install_after_cache_write( &server, @@ -247,12 +264,20 @@ async fn plugin_install_writes_remote_plugin_to_cloud_and_cache() -> Result<()> ) .await?; assert!(installed_path.join(".codex-plugin/plugin.json").is_file()); + let installed_plugin_manifest: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(installed_path.join(".codex-plugin/plugin.json"))?, + )?; + assert_eq!(installed_plugin_manifest["name"], json!("linear")); + assert_eq!(installed_plugin_manifest["version"], json!("1.2.3")); + let installed_app_manifest: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(installed_path.join(".app.json"))?)?; + assert_eq!(installed_app_manifest, remote_app_manifest); assert!(installed_path.join("skills/plan-work/SKILL.md").is_file()); assert!( !codex_home .path() .join(format!( - "plugins/cache/chatgpt-global/{REMOTE_PLUGIN_ID}/1.2.3" + "plugins/cache/openai-curated-remote/{REMOTE_PLUGIN_ID}/1.2.3" )) .exists() ); @@ -299,7 +324,7 @@ async fn plugin_install_rejects_missing_remote_bundle_url() -> Result<()> { assert!( !codex_home .path() - .join("plugins/cache/chatgpt-global/linear") + .join("plugins/cache/openai-curated-remote/linear") .exists() ); Ok(()) @@ -340,7 +365,7 @@ async fn plugin_install_rejects_plain_http_remote_bundle_url() -> Result<()> { assert!( !codex_home .path() - .join("plugins/cache/chatgpt-global/linear") + .join("plugins/cache/openai-curated-remote/linear") .exists() ); Ok(()) @@ -382,7 +407,7 @@ async fn plugin_install_rejects_invalid_remote_release_version() -> Result<()> { assert!( !codex_home .path() - .join("plugins/cache/chatgpt-global/linear") + .join("plugins/cache/openai-curated-remote/linear") .exists() ); Ok(()) @@ -398,7 +423,7 @@ async fn plugin_install_rejects_invalid_remote_plugin_name() -> Result<()> { let request_id = mcp .send_plugin_install_request(PluginInstallParams { marketplace_path: None, - remote_marketplace_name: Some("chatgpt-global".to_string()), + remote_marketplace_name: Some("openai-curated-remote".to_string()), plugin_name: "linear/../../oops".to_string(), }) .await?; @@ -468,7 +493,7 @@ async fn plugin_install_rejects_remote_plugin_disabled_by_admin_before_download( assert!( !codex_home .path() - .join("plugins/cache/chatgpt-global/linear") + .join("plugins/cache/openai-curated-remote/linear") .exists() ); Ok(()) @@ -764,7 +789,7 @@ async fn plugin_install_tracks_remote_plugin_analytics_event() -> Result<()> { "event_params": { "plugin_id": REMOTE_PLUGIN_ID, "plugin_name": "linear", - "marketplace_name": "chatgpt-global", + "marketplace_name": "openai-curated-remote", "has_skills": true, "mcp_server_count": 0, "connector_ids": [], @@ -824,7 +849,7 @@ async fn plugin_install_errors_when_remote_bundle_download_fails() -> Result<()> assert!( !codex_home .path() - .join("plugins/cache/chatgpt-global/linear") + .join("plugins/cache/openai-curated-remote/linear") .exists() ); Ok(()) @@ -1349,12 +1374,49 @@ async fn mount_remote_plugin_detail( .await; } +async fn mount_remote_plugin_detail_with_app_manifest( + server: &MockServer, + remote_plugin_id: &str, + release_version: &str, + bundle_download_url: Option<&str>, + app_manifest: serde_json::Value, +) { + mount_remote_plugin_detail_with_status_and_app_manifest( + server, + remote_plugin_id, + release_version, + bundle_download_url, + PluginAvailability::Available, + Some(app_manifest), + ) + .await; +} + async fn mount_remote_plugin_detail_with_status( server: &MockServer, remote_plugin_id: &str, release_version: &str, bundle_download_url: Option<&str>, status: PluginAvailability, +) { + mount_remote_plugin_detail_with_status_and_app_manifest( + server, + remote_plugin_id, + release_version, + bundle_download_url, + status, + /*app_manifest*/ None, + ) + .await; +} + +async fn mount_remote_plugin_detail_with_status_and_app_manifest( + server: &MockServer, + remote_plugin_id: &str, + release_version: &str, + bundle_download_url: Option<&str>, + status: PluginAvailability, + app_manifest: Option, ) { let status = match status { PluginAvailability::Available => "ENABLED", @@ -1363,6 +1425,9 @@ async fn mount_remote_plugin_detail_with_status( let bundle_download_url_field = bundle_download_url .map(|url| format!(r#" "bundle_download_url": "{url}","#)) .unwrap_or_default(); + let app_manifest_field = app_manifest + .map(|manifest| format!(r#" "app_manifest": {manifest},"#)) + .unwrap_or_default(); let detail_body = format!( r#"{{ "id": "{remote_plugin_id}", @@ -1377,6 +1442,7 @@ async fn mount_remote_plugin_detail_with_status( "display_name": "Linear", "description": "Track work in Linear", "app_ids": [], +{app_manifest_field} "interface": {{ "short_description": "Plan and track work" }}, @@ -1576,13 +1642,20 @@ fn write_plugin_source( fn remote_plugin_bundle_tar_gz_bytes(plugin_name: &str) -> Result> { let manifest = format!(r#"{{"name":"{plugin_name}"}}"#); + remote_plugin_bundle_tar_gz_bytes_with_contents(&manifest, /*app_manifest*/ None) +} + +fn remote_plugin_bundle_tar_gz_bytes_with_contents( + plugin_manifest: &str, + app_manifest: Option<&str>, +) -> Result> { let skill = "# Plan Work\n\nTrack work in Linear.\n"; let encoder = GzEncoder::new(Vec::new(), Compression::default()); let mut tar = tar::Builder::new(encoder); - for (path, contents, mode) in [ + let mut entries = vec![ ( ".codex-plugin/plugin.json", - manifest.as_bytes(), + plugin_manifest.as_bytes(), /*mode*/ 0o644, ), ( @@ -1590,7 +1663,11 @@ fn remote_plugin_bundle_tar_gz_bytes(plugin_name: &str) -> Result> { skill.as_bytes(), /*mode*/ 0o644, ), - ] { + ]; + if let Some(app_manifest) = app_manifest { + entries.push((".app.json", app_manifest.as_bytes(), /*mode*/ 0o644)); + } + for (path, contents, mode) in entries { let mut header = tar::Header::new_gnu(); header.set_size(contents.len() as u64); header.set_mode(mode); diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index d439a4585b..93dbfb4773 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -1470,15 +1470,26 @@ async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() -> remote_plugin_bundle_tar_gz_bytes("linear")?, ) .await; - let global_installed_body = - remote_installed_plugin_body(&bundle_url, "1.2.3", /*enabled*/ true); + let remote_app_manifest = serde_json::json!({ + "apps": { + "linear-remote": { + "id": "remote-linear-app" + } + } + }); + let global_installed_body = remote_installed_plugin_body_with_app_manifest( + &bundle_url, + "1.2.3", + /*enabled*/ true, + remote_app_manifest.clone(), + ); mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await; mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) .await; let installed_path = codex_home .path() - .join("plugins/cache/chatgpt-global/linear/1.2.3"); + .join("plugins/cache/openai-curated-remote/linear/1.2.3"); let mut mcp = McpProcess::new_with_env_and_plugin_startup_tasks( codex_home.path(), &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], @@ -1487,9 +1498,19 @@ async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() -> timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; wait_for_path_exists(&installed_path.join(".codex-plugin/plugin.json")).await?; + let installed_plugin_manifest: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(installed_path.join(".codex-plugin/plugin.json"))?, + )?; + assert_eq!( + installed_plugin_manifest["version"], + serde_json::json!("1.2.3") + ); + let installed_app_manifest: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(installed_path.join(".app.json"))?)?; + assert_eq!(installed_app_manifest, remote_app_manifest); assert!(installed_path.join("skills/plan-work/SKILL.md").is_file()); let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; - assert!(!config.contains("linear@chatgpt-global")); + assert!(!config.contains("linear@openai-curated-remote")); Ok(()) } @@ -1509,8 +1530,8 @@ async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles() .chatgpt_account_id("account-123"), AuthCredentialsStoreMode::File, )?; - write_installed_plugin_with_version(&codex_home, "chatgpt-global", "linear", "1.0.0")?; - write_installed_plugin_with_version(&codex_home, "chatgpt-global", "stale", "1.0.0")?; + write_installed_plugin_with_version(&codex_home, "openai-curated-remote", "linear", "1.0.0")?; + write_installed_plugin_with_version(&codex_home, "openai-curated-remote", "stale", "1.0.0")?; let bundle_url = mount_remote_plugin_bundle( &server, @@ -1518,8 +1539,19 @@ async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles() remote_plugin_bundle_tar_gz_bytes("linear")?, ) .await; - let global_installed_body = - remote_installed_plugin_body(&bundle_url, "1.2.3", /*enabled*/ true); + let remote_app_manifest = serde_json::json!({ + "apps": { + "linear-remote": { + "id": "remote-linear-app" + } + } + }); + let global_installed_body = remote_installed_plugin_body_with_app_manifest( + &bundle_url, + "1.2.3", + /*enabled*/ true, + remote_app_manifest.clone(), + ); mount_remote_plugin_list(&server, "GLOBAL", &global_installed_body).await; mount_remote_plugin_list(&server, "WORKSPACE", empty_remote_installed_plugins_body()).await; mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await; @@ -1528,11 +1560,13 @@ async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles() let old_path = codex_home .path() - .join("plugins/cache/chatgpt-global/linear/1.0.0"); + .join("plugins/cache/openai-curated-remote/linear/1.0.0"); let new_path = codex_home .path() - .join("plugins/cache/chatgpt-global/linear/1.2.3"); - let stale_path = codex_home.path().join("plugins/cache/chatgpt-global/stale"); + .join("plugins/cache/openai-curated-remote/linear/1.2.3"); + let stale_path = codex_home + .path() + .join("plugins/cache/openai-curated-remote/stale"); let mut mcp = McpProcess::new_with_env( codex_home.path(), @@ -1556,22 +1590,32 @@ async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles() let remote_marketplace = response .marketplaces .into_iter() - .find(|marketplace| marketplace.name == "chatgpt-global") - .expect("expected chatgpt-global marketplace entry"); + .find(|marketplace| marketplace.name == "openai-curated-remote") + .expect("expected openai-curated-remote marketplace entry"); assert_eq!( remote_marketplace .plugins .into_iter() .map(|plugin| (plugin.id, plugin.installed, plugin.enabled)) .collect::>(), - vec![("linear@chatgpt-global".to_string(), true, true)] + vec![("linear@openai-curated-remote".to_string(), true, true)] ); wait_for_path_exists(&new_path.join(".codex-plugin/plugin.json")).await?; + let installed_plugin_manifest: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(new_path.join(".codex-plugin/plugin.json"))?, + )?; + assert_eq!( + installed_plugin_manifest["version"], + serde_json::json!("1.2.3") + ); + let installed_app_manifest: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(new_path.join(".app.json"))?)?; + assert_eq!(installed_app_manifest, remote_app_manifest); wait_for_path_missing(&old_path).await?; wait_for_path_missing(&stale_path).await?; let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; - assert!(!config.contains("linear@chatgpt-global")); + assert!(!config.contains("linear@openai-curated-remote")); Ok(()) } @@ -1714,18 +1758,21 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - let remote_marketplace = response .marketplaces .into_iter() - .find(|marketplace| marketplace.name == "chatgpt-global") - .expect("expected ChatGPT remote marketplace"); + .find(|marketplace| marketplace.name == "openai-curated-remote") + .expect("expected openai-curated remote marketplace"); assert_eq!(remote_marketplace.path, None); assert_eq!( remote_marketplace .interface .as_ref() .and_then(|interface| interface.display_name.as_deref()), - Some("ChatGPT Plugins") + Some("OpenAI Curated Remote") ); assert_eq!(remote_marketplace.plugins.len(), 1); - assert_eq!(remote_marketplace.plugins[0].id, "linear@chatgpt-global"); + assert_eq!( + remote_marketplace.plugins[0].id, + "linear@openai-curated-remote" + ); assert_eq!( remote_marketplace.plugins[0].remote_plugin_id.as_deref(), Some("plugins~Plugin_00000000000000000000000000000000") @@ -1753,6 +1800,283 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - ] ); assert_eq!(response.featured_plugin_ids, Vec::::new()); + assert!( + !server + .received_requests() + .await + .expect("wiremock should record requests") + .iter() + .any(|request| request + .url + .query_pairs() + .any(|(name, value)| name == "collection" && value == "vertical")) + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_includes_openai_curated_remote_collection_when_requested() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_plugins_enabled_config_with_base_url( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let collection_body = r#"{ + "plugins": [ + { + "id": "plugins~Plugin_00000000000000000000000000000000", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "status": "ENABLED", + "release": { + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "interface": { + "short_description": "Plan and track work", + "capabilities": ["Read", "Write"] + }, + "skills": [] + } + } + ], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + mount_openai_curated_remote_collection_plugin_list(&server, collection_body).await; + mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await; + mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: Some(vec![PluginListMarketplaceKind::Vertical]), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + let remote_marketplace = response + .marketplaces + .into_iter() + .find(|marketplace| marketplace.name == "openai-curated-remote") + .expect("expected openai-curated remote marketplace"); + assert_eq!(remote_marketplace.path, None); + assert_eq!( + remote_marketplace + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()), + Some("OpenAI Curated Remote") + ); + assert_eq!(remote_marketplace.plugins.len(), 1); + let plugin = &remote_marketplace.plugins[0]; + assert_eq!(plugin.id, "linear@openai-curated-remote"); + assert_eq!( + plugin.remote_plugin_id.as_deref(), + Some("plugins~Plugin_00000000000000000000000000000000") + ); + assert_eq!(plugin.name, "linear"); + assert_eq!(plugin.source, PluginSource::Remote); + assert_eq!(plugin.installed, false); + assert_eq!(plugin.enabled, false); + + let requests = server + .received_requests() + .await + .expect("wiremock should record requests"); + assert!(requests.iter().any(|request| { + request.method == "GET" + && request.url.path().ends_with("/ps/plugins/list") + && request + .url + .query_pairs() + .any(|(name, value)| name == "collection" && value == "vertical") + })); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_fail_opens_openai_curated_remote_collection_errors() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_plugins_enabled_config_with_base_url( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/list")) + .and(query_param("scope", "GLOBAL")) + .and(query_param("limit", "200")) + .and(query_param("collection", "vertical")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(500).set_body_string("temporary failure")) + .mount(&server) + .await; + mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await; + mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: Some(vec![PluginListMarketplaceKind::Vertical]), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + assert!( + response + .marketplaces + .iter() + .all(|marketplace| marketplace.name != "openai-curated-remote") + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_does_not_query_openai_curated_remote_collection_by_default() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_plugins_enabled_config_with_base_url( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + assert!( + response + .marketplaces + .iter() + .all(|marketplace| marketplace.name != "openai-curated-remote") + ); + assert!( + server + .received_requests() + .await + .expect("wiremock should record requests") + .iter() + .all(|request| !request + .url + .query_pairs() + .any(|(name, value)| name == "collection" && value == "vertical")) + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_vertical_kind_noops_when_remote_plugin_enabled() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: Some(vec![PluginListMarketplaceKind::Vertical]), + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + assert!( + response + .marketplaces + .iter() + .all(|marketplace| marketplace.name != "openai-curated-remote") + ); + assert!( + server + .received_requests() + .await + .expect("wiremock should record requests") + .iter() + .all(|request| !request + .url + .query_pairs() + .any(|(name, value)| name == "collection" && value == "vertical")) + ); Ok(()) } @@ -1795,7 +2119,7 @@ async fn plugin_list_does_not_append_global_remote_when_marketplace_kinds_are_ex response .marketplaces .iter() - .all(|marketplace| marketplace.name != "chatgpt-global") + .all(|marketplace| marketplace.name != "openai-curated-remote") ); wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?; Ok(()) @@ -1962,18 +2286,18 @@ plugin_sharing = false )?; assert_eq!(response.marketplaces.len(), 1); - assert_eq!(response.marketplaces[0].name, "chatgpt-global"); + assert_eq!(response.marketplaces[0].name, "openai-curated-remote"); assert_eq!( response.marketplaces[0] .plugins .iter() .map(|plugin| (plugin.id.clone(), plugin.installed, plugin.enabled)) .collect::>(), - vec![("linear@chatgpt-global".to_string(), true, true)] + vec![("linear@openai-curated-remote".to_string(), true, true)] ); let installed_path = codex_home .path() - .join("plugins/cache/chatgpt-global/linear/1.2.3/.codex-plugin/plugin.json"); + .join("plugins/cache/openai-curated-remote/linear/1.2.3/.codex-plugin/plugin.json"); wait_for_path_exists(&installed_path).await?; wait_for_remote_installed_scope_request(&server, "GLOBAL").await?; wait_for_remote_installed_scope_request(&server, "WORKSPACE").await?; @@ -2442,7 +2766,7 @@ async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> { let remote_marketplace = response .marketplaces .into_iter() - .find(|marketplace| marketplace.name == "chatgpt-global") + .find(|marketplace| marketplace.name == "openai-curated-remote") .expect("expected ChatGPT remote marketplace"); let plugin = remote_marketplace .plugins @@ -2457,133 +2781,6 @@ async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> { Ok(()) } -#[tokio::test] -async fn plugin_list_remote_marketplace_replaces_local_marketplace_with_same_name() -> Result<()> { - let codex_home = TempDir::new()?; - let server = MockServer::start().await; - write_remote_plugin_catalog_config( - codex_home.path(), - &format!("{}/backend-api/", server.uri()), - )?; - write_chatgpt_auth( - codex_home.path(), - ChatGptAuthFixture::new("chatgpt-token") - .account_id("account-123") - .chatgpt_user_id("user-123") - .chatgpt_account_id("account-123"), - AuthCredentialsStoreMode::File, - )?; - let local_plugin_root = codex_home - .path() - .join(".agents/plugins/plugins/local-linear/.codex-plugin"); - std::fs::create_dir_all(&local_plugin_root)?; - std::fs::write( - codex_home.path().join(".agents/plugins/marketplace.json"), - r#"{ - "name": "chatgpt-global", - "plugins": [ - { - "name": "local-linear", - "source": { - "source": "local", - "path": "./plugins/local-linear" - } - } - ] -}"#, - )?; - std::fs::write( - local_plugin_root.join("plugin.json"), - r#"{"name":"local-linear"}"#, - )?; - - let global_directory_body = r#"{ - "plugins": [ - { - "id": "plugins~Plugin_00000000000000000000000000000000", - "name": "linear", - "scope": "GLOBAL", - "installation_policy": "AVAILABLE", - "authentication_policy": "ON_USE", - "release": { - "display_name": "Linear", - "description": "Track work in Linear", - "app_ids": [], - "interface": {}, - "skills": [] - } - } - ], - "pagination": { - "limit": 50, - "next_page_token": null - } -}"#; - let empty_page_body = r#"{ - "plugins": [], - "pagination": { - "limit": 50, - "next_page_token": null - } -}"#; - for (scope, body) in [ - ("GLOBAL", global_directory_body), - ("WORKSPACE", empty_page_body), - ] { - Mock::given(method("GET")) - .and(path("/backend-api/ps/plugins/list")) - .and(query_param("scope", scope)) - .and(query_param("limit", "200")) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with(ResponseTemplate::new(200).set_body_string(body)) - .mount(&server) - .await; - } - for scope in ["GLOBAL", "WORKSPACE"] { - Mock::given(method("GET")) - .and(path("/backend-api/ps/plugins/installed")) - .and(query_param("scope", scope)) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with(ResponseTemplate::new(200).set_body_string(empty_page_body)) - .mount(&server) - .await; - } - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - - let request_id = mcp - .send_plugin_list_request(PluginListParams { - cwds: None, - marketplace_kinds: None, - }) - .await?; - - let response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - let response: PluginListResponse = to_response(response)?; - let matching_marketplaces = response - .marketplaces - .iter() - .filter(|marketplace| marketplace.name == "chatgpt-global") - .collect::>(); - - assert_eq!(matching_marketplaces.len(), 1); - assert_eq!(matching_marketplaces[0].path, None); - assert_eq!(matching_marketplaces[0].plugins.len(), 1); - assert_eq!( - matching_marketplaces[0].plugins[0].source, - PluginSource::Remote - ); - assert_eq!(matching_marketplaces[0].plugins[0].name, "linear"); - Ok(()) -} - #[tokio::test] async fn plugin_list_does_not_fetch_remote_marketplaces_when_plugins_disabled() -> Result<()> { let codex_home = TempDir::new()?; @@ -2817,6 +3014,19 @@ async fn mount_remote_plugin_list(server: &MockServer, scope: &str, body: &str) .await; } +async fn mount_openai_curated_remote_collection_plugin_list(server: &MockServer, body: &str) { + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/list")) + .and(query_param("scope", "GLOBAL")) + .and(query_param("limit", "200")) + .and(query_param("collection", "vertical")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(body)) + .mount(server) + .await; +} + async fn mount_shared_workspace_plugins(server: &MockServer, body: &str) { Mock::given(method("GET")) .and(path("/backend-api/ps/plugins/workspace/shared")) @@ -2910,6 +3120,37 @@ fn remote_installed_plugin_body( release_version: &str, enabled: bool, ) -> String { + remote_installed_plugin_body_with_optional_app_manifest( + bundle_download_url, + release_version, + enabled, + /*app_manifest*/ None, + ) +} + +fn remote_installed_plugin_body_with_app_manifest( + bundle_download_url: &str, + release_version: &str, + enabled: bool, + app_manifest: serde_json::Value, +) -> String { + remote_installed_plugin_body_with_optional_app_manifest( + bundle_download_url, + release_version, + enabled, + Some(app_manifest), + ) +} + +fn remote_installed_plugin_body_with_optional_app_manifest( + bundle_download_url: &str, + release_version: &str, + enabled: bool, + app_manifest: Option, +) -> String { + let app_manifest_field = app_manifest + .map(|manifest| format!(r#" "app_manifest": {manifest},"#)) + .unwrap_or_default(); format!( r#"{{ "plugins": [ @@ -2925,6 +3166,7 @@ fn remote_installed_plugin_body( "description": "Track work in Linear", "bundle_download_url": "{bundle_download_url}", "app_ids": [], +{app_manifest_field} "interface": {{}}, "skills": [] }}, diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 812f884a55..3ec2366f54 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -101,7 +101,7 @@ async fn plugin_read_rejects_multiple_read_sources() -> Result<()> { marketplace_path: Some(AbsolutePathBuf::try_from( codex_home.path().join("marketplace.json"), )?), - remote_marketplace_name: Some("openai-curated".to_string()), + remote_marketplace_name: Some("openai-curated-remote".to_string()), plugin_name: "sample-plugin".to_string(), }) .await?; @@ -196,7 +196,7 @@ plugins = true let request_id = mcp .send_plugin_read_request(PluginReadParams { marketplace_path: None, - remote_marketplace_name: Some("chatgpt-global".to_string()), + remote_marketplace_name: Some("openai-curated-remote".to_string()), plugin_name: "plugins~Plugin_00000000000000000000000000000000".to_string(), }) .await?; @@ -208,8 +208,8 @@ plugins = true .await??; let response: PluginReadResponse = to_response(response)?; - assert_eq!(response.plugin.marketplace_name, "chatgpt-global"); - assert_eq!(response.plugin.summary.id, "linear@chatgpt-global"); + assert_eq!(response.plugin.marketplace_name, "openai-curated-remote"); + assert_eq!(response.plugin.summary.id, "linear@openai-curated-remote"); assert_eq!( response.plugin.summary.remote_plugin_id.as_deref(), Some("plugins~Plugin_00000000000000000000000000000000") @@ -484,7 +484,7 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> let request_id = mcp .send_plugin_read_request(PluginReadParams { marketplace_path: None, - remote_marketplace_name: Some("chatgpt-global".to_string()), + remote_marketplace_name: Some("openai-curated-remote".to_string()), plugin_name: "plugins~Plugin_00000000000000000000000000000000".to_string(), }) .await?; @@ -496,10 +496,10 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> .await??; let response: PluginReadResponse = to_response(response)?; - assert_eq!(response.plugin.marketplace_name, "chatgpt-global"); + assert_eq!(response.plugin.marketplace_name, "openai-curated-remote"); assert_eq!(response.plugin.marketplace_path, None); assert_eq!(response.plugin.summary.source, PluginSource::Remote); - assert_eq!(response.plugin.summary.id, "linear@chatgpt-global"); + assert_eq!(response.plugin.summary.id, "linear@openai-curated-remote"); assert_eq!( response.plugin.summary.remote_plugin_id.as_deref(), Some("plugins~Plugin_00000000000000000000000000000000") @@ -568,7 +568,7 @@ async fn plugin_skill_read_reads_remote_skill_contents_when_remote_plugin_enable let request_id = mcp .send_plugin_skill_read_request(PluginSkillReadParams { - remote_marketplace_name: "chatgpt-global".to_string(), + remote_marketplace_name: "openai-curated-remote".to_string(), remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), skill_name: "plan-work".to_string(), }) @@ -621,7 +621,7 @@ async fn plugin_read_maps_missing_remote_plugin_to_invalid_request() -> Result<( let request_id = mcp .send_plugin_read_request(PluginReadParams { marketplace_path: None, - remote_marketplace_name: Some("chatgpt-global".to_string()), + remote_marketplace_name: Some("openai-curated-remote".to_string()), plugin_name: "plugins~Plugin_missing".to_string(), }) .await?; @@ -673,7 +673,7 @@ remote_plugin = true let request_id = mcp .send_plugin_read_request(PluginReadParams { marketplace_path: None, - remote_marketplace_name: Some("chatgpt-global".to_string()), + remote_marketplace_name: Some("openai-curated-remote".to_string()), plugin_name: "linear".to_string(), }) .await?; @@ -703,7 +703,7 @@ async fn plugin_read_rejects_invalid_remote_plugin_name() -> Result<()> { let request_id = mcp .send_plugin_read_request(PluginReadParams { marketplace_path: None, - remote_marketplace_name: Some("chatgpt-global".to_string()), + remote_marketplace_name: Some("openai-curated-remote".to_string()), plugin_name: "linear/../../oops".to_string(), }) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs index 8fdf149a01..4feede934d 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs @@ -219,15 +219,15 @@ async fn plugin_uninstall_writes_remote_plugin_to_cloud_when_remote_plugin_enabl let remote_plugin_cache_root = codex_home .path() - .join("plugins/cache/chatgpt-global/linear"); + .join("plugins/cache/openai-curated-remote/linear"); std::fs::create_dir_all(remote_plugin_cache_root.join("1.0.0/.codex-plugin"))?; std::fs::write( remote_plugin_cache_root.join("1.0.0/.codex-plugin/plugin.json"), r#"{"name":"linear","version":"1.0.0"}"#, )?; - let legacy_remote_plugin_cache_root = codex_home - .path() - .join(format!("plugins/cache/chatgpt-global/{REMOTE_PLUGIN_ID}")); + let legacy_remote_plugin_cache_root = codex_home.path().join(format!( + "plugins/cache/openai-curated-remote/{REMOTE_PLUGIN_ID}" + )); std::fs::create_dir_all(legacy_remote_plugin_cache_root.join("local/.codex-plugin"))?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -299,7 +299,7 @@ async fn plugin_uninstall_uses_detail_scope_for_cache_namespace() -> Result<()> )?; let global_cache_root = codex_home .path() - .join("plugins/cache/chatgpt-global/linear"); + .join("plugins/cache/openai-curated-remote/linear"); std::fs::create_dir_all(global_cache_root.join("1.0.0/.codex-plugin"))?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -420,9 +420,9 @@ async fn plugin_uninstall_rejects_before_post_when_remote_detail_fetch_fails() - AuthCredentialsStoreMode::File, )?; - let legacy_remote_plugin_cache_root = codex_home - .path() - .join(format!("plugins/cache/chatgpt-global/{REMOTE_PLUGIN_ID}")); + let legacy_remote_plugin_cache_root = codex_home.path().join(format!( + "plugins/cache/openai-curated-remote/{REMOTE_PLUGIN_ID}" + )); std::fs::create_dir_all(legacy_remote_plugin_cache_root.join("local/.codex-plugin"))?; let mut mcp = McpProcess::new(codex_home.path()).await?; diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index db982bc5d0..74100bc19f 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -117,7 +117,7 @@ fn write_plugin_with_skill( fn write_cached_remote_plugin_with_skill( codex_home: &std::path::Path, ) -> Result { - let plugin_root = codex_home.join("plugins/cache/chatgpt-global/linear/local"); + let plugin_root = codex_home.join("plugins/cache/openai-curated-remote/linear/local"); std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; std::fs::write( plugin_root.join(".codex-plugin/plugin.json"), diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 791a143c4f..53f72ed508 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -150,7 +150,7 @@ async fn load_config(codex_home: &Path, cwd: &Path) -> PluginsConfigInput { fn remote_installed_linear_plugin() -> RemoteInstalledPlugin { RemoteInstalledPlugin { - marketplace_name: "chatgpt-global".to_string(), + marketplace_name: "openai-curated-remote".to_string(), id: "plugins~Plugin_linear".to_string(), name: "linear".to_string(), enabled: true, @@ -401,11 +401,11 @@ async fn build_remote_installed_plugin_marketplaces_from_cache_uses_remote_metad .build_remote_installed_plugin_marketplaces_from_cache(&[RemotePluginScope::Global]) .expect("remote installed cache should be present"); assert_eq!(marketplaces.len(), 1); - assert_eq!(marketplaces[0].name, "chatgpt-global"); - assert_eq!(marketplaces[0].display_name, "ChatGPT Plugins"); + assert_eq!(marketplaces[0].name, "openai-curated-remote"); + assert_eq!(marketplaces[0].display_name, "OpenAI Curated Remote"); assert_eq!(marketplaces[0].plugins.len(), 1); let plugin = &marketplaces[0].plugins[0]; - assert_eq!(plugin.id, "linear@chatgpt-global"); + assert_eq!(plugin.id, "linear@openai-curated-remote"); assert_eq!(plugin.remote_plugin_id, "plugins~Plugin_linear"); assert_eq!(plugin.name, "linear"); assert_eq!(plugin.installed, true); diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 56427473a1..16ae512931 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -12,6 +12,7 @@ use codex_plugin::PluginId; use codex_utils_absolute_path::AbsolutePathBuf; use reqwest::RequestBuilder; use serde::Deserialize; +use serde_json::Value as JsonValue; use std::collections::BTreeMap; use std::collections::BTreeSet; use std::collections::HashSet; @@ -46,19 +47,20 @@ pub use share::load_plugin_share_remote_ids_by_local_path; pub use share::save_remote_plugin_share; pub use share::update_remote_plugin_share_targets; -pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "chatgpt-global"; +pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "openai-curated-remote"; pub const REMOTE_WORKSPACE_MARKETPLACE_NAME: &str = "workspace-directory"; pub const REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME: &str = "workspace-shared-with-me"; pub const REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME: &str = "workspace-shared-with-me-private"; pub const REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME: &str = "workspace-shared-with-me-unlisted"; -pub const REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME: &str = "ChatGPT Plugins"; +pub const REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME: &str = "OpenAI Curated Remote"; pub const REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME: &str = "Workspace Directory"; pub const REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_DISPLAY_NAME: &str = "Shared with me"; pub const REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_DISPLAY_NAME: &str = "Shared with me (unlisted)"; +const OPENAI_CURATED_REMOTE_COLLECTION_KEY: &str = "vertical"; const REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30); const REMOTE_PLUGIN_LIST_PAGE_LIMIT: u32 = 200; const MAX_REMOTE_DEFAULT_PROMPT_LEN: usize = 128; @@ -158,6 +160,7 @@ pub struct RemotePluginDetail { pub description: Option, pub release_version: Option, pub bundle_download_url: Option, + pub app_manifest: Option, pub skills: Vec, pub app_ids: Vec, } @@ -391,6 +394,8 @@ struct RemotePluginReleaseResponse { #[serde(default)] app_ids: Vec, #[serde(default)] + app_manifest: Option, + #[serde(default)] keywords: Vec, interface: RemotePluginReleaseInterfaceResponse, #[serde(default)] @@ -595,6 +600,31 @@ pub async fn fetch_remote_marketplaces( Ok(marketplaces) } +pub async fn fetch_openai_curated_remote_collection_marketplace( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, +) -> Result, RemotePluginCatalogError> { + let auth = ensure_chatgpt_auth(auth)?; + let scope = RemotePluginScope::Global; + let (directory_plugins, installed_plugins) = tokio::try_join!( + fetch_directory_plugins_for_scope_with_collection( + config, + auth, + scope, + OPENAI_CURATED_REMOTE_COLLECTION_KEY, + ), + fetch_installed_plugins_for_scope(config, auth, scope), + )?; + + build_remote_marketplace( + REMOTE_GLOBAL_MARKETPLACE_NAME, + REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME, + directory_plugins, + installed_plugins, + /*include_installed_only*/ false, + ) +} + fn build_remote_marketplace( name: &str, display_name: &str, @@ -866,6 +896,7 @@ async fn build_remote_plugin_detail( description: non_empty_string(Some(&plugin.release.description)), release_version: plugin.release.version, bundle_download_url: plugin.release.bundle_download_url, + app_manifest: plugin.release.app_manifest, skills, app_ids: plugin.release.app_ids, }) @@ -1174,12 +1205,40 @@ async fn fetch_directory_plugins_for_scope( config: &RemotePluginServiceConfig, auth: &CodexAuth, scope: RemotePluginScope, +) -> Result, RemotePluginCatalogError> { + fetch_directory_plugins_for_scope_with_optional_collection( + config, auth, scope, /*collection*/ None, + ) + .await +} + +async fn fetch_directory_plugins_for_scope_with_collection( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + scope: RemotePluginScope, + collection: &str, +) -> Result, RemotePluginCatalogError> { + fetch_directory_plugins_for_scope_with_optional_collection( + config, + auth, + scope, + Some(collection), + ) + .await +} + +async fn fetch_directory_plugins_for_scope_with_optional_collection( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + scope: RemotePluginScope, + collection: Option<&str>, ) -> Result, RemotePluginCatalogError> { let mut plugins = Vec::new(); let mut page_token = None; loop { let response = - get_remote_plugin_list_page(config, auth, scope, page_token.as_deref()).await?; + get_remote_plugin_list_page(config, auth, scope, page_token.as_deref(), collection) + .await?; plugins.extend(response.plugins); let Some(next_page_token) = response.pagination.next_page_token else { break; @@ -1249,6 +1308,7 @@ async fn get_remote_plugin_list_page( auth: &CodexAuth, scope: RemotePluginScope, page_token: Option<&str>, + collection: Option<&str>, ) -> Result { let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/ps/plugins/list"); @@ -1256,6 +1316,9 @@ async fn get_remote_plugin_list_page( let mut request = authenticated_request(client.get(&url), auth)?; request = request.query(&[("scope", scope.api_value())]); request = request.query(&[("limit", REMOTE_PLUGIN_LIST_PAGE_LIMIT)]); + if let Some(collection) = collection { + request = request.query(&[("collection", collection)]); + } if let Some(page_token) = page_token { request = request.query(&[("pageToken", page_token)]); } diff --git a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs index 69cbfe2569..fe3a597b0f 100644 --- a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs +++ b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs @@ -208,6 +208,7 @@ pub async fn sync_remote_installed_plugin_bundles_once( &plugin.name, release_version, plugin.release.bundle_download_url.as_deref(), + plugin.release.app_manifest.clone(), ) { Ok(bundle) => bundle, Err(err) => { @@ -511,7 +512,7 @@ mod tests { &installed_plugin_names_by_marketplace, ) .expect("cleanup after install guard is dropped"); - assert_eq!(removed, vec!["linear@chatgpt-global".to_string()]); + assert_eq!(removed, vec!["linear@openai-curated-remote".to_string()]); assert!(!cached_manifest.exists()); } diff --git a/codex-rs/core-plugins/src/remote/share/checkout.rs b/codex-rs/core-plugins/src/remote/share/checkout.rs index 8aabccff8b..6b12d2588d 100644 --- a/codex-rs/core-plugins/src/remote/share/checkout.rs +++ b/codex-rs/core-plugins/src/remote/share/checkout.rs @@ -85,6 +85,7 @@ pub async fn checkout_remote_plugin_share( &plugin_name, detail.release_version.as_deref(), detail.bundle_download_url.as_deref(), + /*app_manifest*/ None, ) .map_err(|err| { RemotePluginCatalogError::UnexpectedResponse(format!( diff --git a/codex-rs/core-plugins/src/remote_bundle.rs b/codex-rs/core-plugins/src/remote_bundle.rs index ed7cd56a86..7862573e44 100644 --- a/codex-rs/core-plugins/src/remote_bundle.rs +++ b/codex-rs/core-plugins/src/remote_bundle.rs @@ -1,3 +1,4 @@ +use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; use crate::store::PluginInstallResult; use crate::store::PluginStore; use crate::store::PluginStoreError; @@ -10,6 +11,7 @@ use codex_utils_plugins::find_plugin_manifest_path; use flate2::read::GzDecoder; use reqwest::Response; use reqwest::StatusCode; +use serde_json::Value as JsonValue; use std::fs; use std::io; use std::io::Read; @@ -33,6 +35,7 @@ const TEST_ALLOW_LOOPBACK_HTTP_REMOTE_PLUGIN_BUNDLES_ENV: &str = pub struct ValidatedRemotePluginBundle { pub plugin_id: PluginId, pub plugin_version: String, + app_manifest: Option, bundle_download_url: String, } @@ -137,6 +140,7 @@ pub fn validate_remote_plugin_bundle( plugin_name: &str, release_version: Option<&str>, bundle_download_url: Option<&str>, + app_manifest: Option, ) -> Result { let plugin_id = PluginId::new(plugin_name.to_string(), remote_marketplace_name.to_string()) .map_err(|source| RemotePluginBundleInstallError::InvalidPluginId { @@ -187,6 +191,7 @@ pub fn validate_remote_plugin_bundle( Ok(ValidatedRemotePluginBundle { plugin_id, plugin_version, + app_manifest, bundle_download_url, }) } @@ -367,6 +372,7 @@ fn install_remote_plugin_bundle( extract_plugin_bundle_tar_gz(&bundle_bytes, extract_dir.path())?; let plugin_root = find_extracted_plugin_root(extract_dir.path())?; + prepare_extracted_remote_plugin_root(&plugin_root, &bundle)?; let plugin_root = AbsolutePathBuf::try_from(plugin_root).map_err(|err| { RemotePluginBundleInstallError::InvalidBundle(format!( "failed to resolve extracted remote plugin bundle root: {err}" @@ -436,6 +442,90 @@ fn extract_remote_plugin_bundle_to_path( Ok(destination) } +fn prepare_extracted_remote_plugin_root( + plugin_root: &Path, + bundle: &ValidatedRemotePluginBundle, +) -> Result<(), RemotePluginBundleInstallError> { + if bundle.plugin_id.marketplace_name != REMOTE_GLOBAL_MARKETPLACE_NAME { + return Ok(()); + } + + overwrite_plugin_manifest_version(plugin_root, &bundle.plugin_version)?; + if let Some(app_manifest) = &bundle.app_manifest { + overwrite_plugin_app_manifest(plugin_root, app_manifest)?; + } + Ok(()) +} + +fn overwrite_plugin_manifest_version( + plugin_root: &Path, + plugin_version: &str, +) -> Result<(), RemotePluginBundleInstallError> { + let manifest_path = find_plugin_manifest_path(plugin_root).ok_or_else(|| { + RemotePluginBundleInstallError::InvalidBundle( + "remote plugin bundle did not contain a valid plugin.json".to_string(), + ) + })?; + let contents = fs::read_to_string(&manifest_path).map_err(|source| { + RemotePluginBundleInstallError::io("failed to read remote plugin manifest", source) + })?; + let mut manifest: JsonValue = serde_json::from_str(&contents).map_err(|err| { + RemotePluginBundleInstallError::InvalidBundle(format!( + "failed to parse remote plugin manifest: {err}" + )) + })?; + let Some(manifest_object) = manifest.as_object_mut() else { + return Err(RemotePluginBundleInstallError::InvalidBundle( + "remote plugin manifest must be a JSON object".to_string(), + )); + }; + manifest_object.insert( + "version".to_string(), + JsonValue::String(plugin_version.to_string()), + ); + write_json_file( + &manifest_path, + &manifest, + "failed to write remote plugin manifest", + ) +} + +fn overwrite_plugin_app_manifest( + plugin_root: &Path, + app_manifest: &JsonValue, +) -> Result<(), RemotePluginBundleInstallError> { + let app_manifest_path = crate::manifest::load_plugin_manifest(plugin_root) + .and_then(|manifest| manifest.paths.apps.map(|path| path.to_path_buf())) + .unwrap_or_else(|| plugin_root.join(".app.json")); + write_json_file( + &app_manifest_path, + app_manifest, + "failed to write remote plugin app manifest", + ) +} + +fn write_json_file( + path: &Path, + value: &JsonValue, + context: &'static str, +) -> Result<(), RemotePluginBundleInstallError> { + let parent = path.parent().ok_or_else(|| { + RemotePluginBundleInstallError::InvalidBundle(format!( + "remote plugin output path has no parent: {}", + path.display() + )) + })?; + fs::create_dir_all(parent) + .map_err(|source| RemotePluginBundleInstallError::io(context, source))?; + let mut contents = serde_json::to_vec_pretty(value).map_err(|err| { + RemotePluginBundleInstallError::InvalidBundle(format!( + "failed to serialize remote plugin JSON override: {err}" + )) + })?; + contents.push(b'\n'); + fs::write(path, contents).map_err(|source| RemotePluginBundleInstallError::io(context, source)) +} + fn extract_plugin_bundle_tar_gz( bytes: &[u8], destination: &Path, @@ -624,15 +714,16 @@ mod tests { fn validate_remote_plugin_bundle_uses_detail_name_for_local_plugin_id() { let bundle = validate_remote_plugin_bundle( REMOTE_PLUGIN_ID, - "chatgpt-global", + "openai-curated-remote", "linear", Some("1.2.3"), Some("https://example.com/linear.tar.gz"), + /*app_manifest*/ None, ) .expect("valid install plan"); assert_eq!(bundle.plugin_id.plugin_name, "linear"); - assert_eq!(bundle.plugin_id.marketplace_name, "chatgpt-global"); + assert_eq!(bundle.plugin_id.marketplace_name, "openai-curated-remote"); assert_eq!(bundle.plugin_version, "1.2.3"); assert_eq!( bundle.bundle_download_url.as_str(), @@ -644,10 +735,11 @@ mod tests { fn validate_remote_plugin_bundle_rejects_missing_release_version() { let err = validate_remote_plugin_bundle( REMOTE_PLUGIN_ID, - "chatgpt-global", + "openai-curated-remote", "linear", /*release_version*/ None, Some("https://example.com/linear.tar.gz"), + /*app_manifest*/ None, ) .expect_err("missing release version should be rejected"); @@ -661,10 +753,11 @@ mod tests { fn validate_remote_plugin_bundle_rejects_invalid_release_version() { let err = validate_remote_plugin_bundle( REMOTE_PLUGIN_ID, - "chatgpt-global", + "openai-curated-remote", "linear", Some("../1.2.3"), Some("https://example.com/linear.tar.gz"), + /*app_manifest*/ None, ) .expect_err("invalid release version should be rejected"); @@ -678,10 +771,11 @@ mod tests { fn validate_remote_plugin_bundle_rejects_missing_download_url() { let err = validate_remote_plugin_bundle( REMOTE_PLUGIN_ID, - "chatgpt-global", + "openai-curated-remote", "linear", Some("1.2.3"), /*bundle_download_url*/ None, + /*app_manifest*/ None, ) .expect_err("missing bundle download URL should be rejected"); @@ -695,10 +789,11 @@ mod tests { fn validate_remote_plugin_bundle_rejects_unsupported_download_url_scheme() { let err = validate_remote_plugin_bundle( REMOTE_PLUGIN_ID, - "chatgpt-global", + "openai-curated-remote", "linear", Some("1.2.3"), Some("http://example.com/linear.tar.gz"), + /*app_manifest*/ None, ) .expect_err("plain HTTP URLs should be rejected before cloud install"); @@ -755,6 +850,78 @@ mod tests { ); } + #[test] + fn install_preserves_non_global_bundle_manifest_metadata() { + let codex_home = tempdir().expect("tempdir"); + let bundle = validate_remote_plugin_bundle( + REMOTE_PLUGIN_ID, + "workspace-shared-with-me", + "linear", + Some("backend-version"), + Some("https://example.com/linear.tar.gz"), + Some(serde_json::json!({ + "apps": { + "remote": { + "id": "remote-app" + } + } + })), + ) + .expect("valid install plan"); + + let result = install_remote_plugin_bundle( + codex_home.path().to_path_buf(), + bundle, + tar_gz_bytes(&[ + ( + ".codex-plugin/plugin.json", + br#"{"name":"linear","version":"bundle-version"}"#, + /*mode*/ 0o644, + ), + ( + ".app.json", + br#"{"apps":{"bundled":{"id":"bundled-app"}}}"#, + /*mode*/ 0o644, + ), + ]), + ) + .expect("install bundle"); + + assert_eq!(result.plugin_version, "backend-version"); + let installed_manifest: JsonValue = serde_json::from_str( + &std::fs::read_to_string( + result + .installed_path + .join(".codex-plugin/plugin.json") + .as_path(), + ) + .expect("read installed plugin manifest"), + ) + .expect("parse installed plugin manifest"); + assert_eq!( + installed_manifest, + serde_json::json!({ + "name": "linear", + "version": "bundle-version", + }) + ); + let installed_app_manifest: JsonValue = serde_json::from_str( + &std::fs::read_to_string(result.installed_path.join(".app.json").as_path()) + .expect("read installed app manifest"), + ) + .expect("parse installed app manifest"); + assert_eq!( + installed_app_manifest, + serde_json::json!({ + "apps": { + "bundled": { + "id": "bundled-app", + }, + }, + }) + ); + } + #[test] fn find_extracted_plugin_root_uses_local_manifest_discovery() { let extraction_root = tempdir().expect("tempdir"); @@ -866,10 +1033,11 @@ mod tests { fn valid_remote_plugin_bundle() -> ValidatedRemotePluginBundle { validate_remote_plugin_bundle( REMOTE_PLUGIN_ID, - "chatgpt-global", + "openai-curated-remote", "linear", Some("1.2.3"), Some("https://example.com/linear.tar.gz"), + /*app_manifest*/ None, ) .expect("valid install plan") } From 343a74076ffcd810d57a54e18ded0b3f93b2dc1b Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 19 May 2026 22:10:03 -0700 Subject: [PATCH 09/11] build: package prebuilt Codex entrypoints (#23586) ## Why The package builder should describe the binaries it is actually packaging, not require callers to restate release metadata out of band. A caller-provided `--version` flag can drift from the workspace version, but running the target entrypoint to discover its version breaks cross-target packages when the produced binary cannot execute on the build host. This PR keeps package metadata tied to the repository source of truth by reading `[workspace.package].version` from `codex-rs/Cargo.toml`. It also prepares the package layout for `codex-app-server` packages: the same package structure can now represent either the CLI entrypoint or the app-server entrypoint while keeping shared sidecars such as `rg`, `bwrap`, and Windows sandbox helpers in the existing package directories. ## What changed - Removes the `--version` CLI flag from `scripts/build_codex_package.py`. - Adds Cargo.toml version discovery for `codex-package.json.version` via `codex-rs/Cargo.toml`. - Adds `--entrypoint-bin` so callers can package a prebuilt entrypoint instead of rebuilding it with Cargo. - Makes `--variant` an explicit choice between `codex` and `codex-app-server`, and uses it to select the cargo binary and packaged `bin/` entrypoint name. - Updates `scripts/codex_package/README.md` to document variants, prebuilt entrypoints, and Cargo.toml version detection. ## Verification - Compiled `scripts/build_codex_package.py` and `scripts/codex_package/*.py` with `PYTHONDONTWRITEBYTECODE=1`. - Ran `scripts/build_codex_package.py --help` and verified `--version` is gone while `--variant` and `--entrypoint-bin` are present. - Verified the package builder reads version `0.0.0` from `codex-rs/Cargo.toml`. - Built a fake cross-target `codex-app-server` package using a non-executable `--entrypoint-bin`; verified metadata records version `0.0.0`, variant `codex-app-server`, and `bin/codex-app-server` as the entrypoint. --- scripts/codex_package/README.md | 14 +++++--- scripts/codex_package/cargo.py | 55 +++++++++++++++++++++----------- scripts/codex_package/cli.py | 37 +++++++++++++++------ scripts/codex_package/layout.py | 25 +++++++++++---- scripts/codex_package/targets.py | 30 ++++++++++++++--- scripts/codex_package/version.py | 29 +++++++++++++++++ 6 files changed, 147 insertions(+), 43 deletions(-) create mode 100644 scripts/codex_package/version.py diff --git a/scripts/codex_package/README.md b/scripts/codex_package/README.md index c4abb58afd..c1674a8b77 100644 --- a/scripts/codex_package/README.md +++ b/scripts/codex_package/README.md @@ -10,7 +10,7 @@ The builder creates a canonical Codex package directory: . ├── codex-package.json ├── bin -│ └── codex[.exe] +│ └── [.exe] ├── codex-resources │ ├── bwrap # Linux only │ ├── codex-command-runner.exe # Windows only @@ -28,18 +28,24 @@ artifacts; pass a GNU Linux target explicitly for native glibc local builds. If `--package-dir` is omitted, the builder creates a new temporary directory and prints its path after the package is built. +The `--variant` flag selects the package entrypoint. Supported variants are +`codex` and `codex-app-server`. The `version` field in `codex-package.json` is +read from `[workspace.package].version` in `codex-rs/Cargo.toml`. + ## Source-built artifacts Artifacts built from this repository are always built by the package builder in -one grouped `cargo build` command per package: +one grouped `cargo build` command per package when they are needed: -- all targets: `codex` +- all targets: the selected entrypoint, unless `--entrypoint-bin` is provided - Linux targets: `bwrap` - Windows targets: `codex-command-runner` and `codex-windows-sandbox-setup` The default cargo profile is `dev-small` because local iteration should favor fast, small builds. Release jobs should pass `--cargo-profile release` and an -explicit target. +explicit target. Release jobs that already built and signed/notarized the +entrypoint should pass `--entrypoint-bin` so the package contains that exact +binary instead of rebuilding it. `rg` is not built from this repository, so the builder fetches it from the DotSlash manifest at `codex-cli/bin/rg`. Downloaded archives are cached under diff --git a/scripts/codex_package/cargo.py b/scripts/codex_package/cargo.py index 9ee2d2f832..c265808b16 100644 --- a/scripts/codex_package/cargo.py +++ b/scripts/codex_package/cargo.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from pathlib import Path from .targets import REPO_ROOT +from .targets import PackageVariant from .targets import TargetSpec @@ -14,7 +15,7 @@ CODEX_RS_ROOT = REPO_ROOT / "codex-rs" @dataclass(frozen=True) class SourceBuildOutputs: - codex_bin: Path + entrypoint_bin: Path bwrap_bin: Path | None codex_command_runner_bin: Path | None codex_windows_sandbox_setup_bin: Path | None @@ -22,28 +23,39 @@ class SourceBuildOutputs: def build_source_binaries( spec: TargetSpec, + variant: PackageVariant, *, cargo: str, profile: str, + entrypoint_bin: Path | None, ) -> SourceBuildOutputs: - binaries = source_binaries_for_target(spec) - cmd = [ - cargo, - "build", - "--target", - spec.target, - "--profile", - profile, - ] - for binary in binaries: - cmd.extend(["--bin", binary]) + binaries = source_binaries_for_target( + spec, + variant, + build_entrypoint=entrypoint_bin is None, + ) + if binaries: + cmd = [ + cargo, + "build", + "--target", + spec.target, + "--profile", + profile, + ] + for binary in binaries: + cmd.extend(["--bin", binary]) - print("+", " ".join(cmd)) - subprocess.run(cmd, cwd=CODEX_RS_ROOT, check=True) + print("+", " ".join(cmd)) + subprocess.run(cmd, cwd=CODEX_RS_ROOT, check=True) output_dir = cargo_profile_output_dir(spec, profile) outputs = SourceBuildOutputs( - codex_bin=output_dir / spec.codex_name, + entrypoint_bin=( + entrypoint_bin.resolve() + if entrypoint_bin is not None + else output_dir / variant.entrypoint_name(spec) + ), bwrap_bin=output_dir / "bwrap" if spec.is_linux else None, codex_command_runner_bin=( output_dir / "codex-command-runner.exe" if spec.is_windows else None @@ -56,8 +68,15 @@ def build_source_binaries( return outputs -def source_binaries_for_target(spec: TargetSpec) -> list[str]: - binaries = ["codex"] +def source_binaries_for_target( + spec: TargetSpec, + variant: PackageVariant, + *, + build_entrypoint: bool, +) -> list[str]: + binaries = [] + if build_entrypoint: + binaries.append(variant.cargo_bin) if spec.is_linux: binaries.append("bwrap") if spec.is_windows: @@ -97,7 +116,7 @@ def cargo_profile_dirname(profile: str) -> str: def validate_source_outputs(outputs: SourceBuildOutputs) -> None: for path in [ - outputs.codex_bin, + outputs.entrypoint_bin, outputs.bwrap_bin, outputs.codex_command_runner_bin, outputs.codex_windows_sandbox_setup_bin, diff --git a/scripts/codex_package/cli.py b/scripts/codex_package/cli.py index 6b2598445f..16324cf44d 100644 --- a/scripts/codex_package/cli.py +++ b/scripts/codex_package/cli.py @@ -10,9 +10,12 @@ from .layout import build_package_dir from .layout import prepare_package_dir from .layout import validate_package_dir from .ripgrep import resolve_rg_bin +from .targets import PACKAGE_VARIANTS from .targets import TARGET_SPECS from .targets import PackageInputs from .targets import default_target +from .targets import resolve_input_path +from .version import read_workspace_version def parse_args() -> argparse.Namespace: @@ -29,15 +32,11 @@ def parse_args() -> argparse.Namespace: "for this host platform." ), ) - parser.add_argument( - "--version", - default="0.0.0-dev", - help="Codex version to record in codex-package.json.", - ) parser.add_argument( "--variant", + choices=sorted(PACKAGE_VARIANTS), default="codex", - help="Package variant to record in codex-package.json.", + help="Package variant to build.", ) parser.add_argument( "--package-dir", @@ -74,6 +73,14 @@ def parse_args() -> argparse.Namespace: "release packages." ), ) + parser.add_argument( + "--entrypoint-bin", + type=Path, + help=( + "Optional prebuilt entrypoint executable for the selected package " + "variant. If omitted, the entrypoint is built with Cargo." + ), + ) parser.add_argument( "--rg-bin", type=Path, @@ -88,6 +95,7 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() spec = TARGET_SPECS[getattr(args, "target", None) or default_target()] + variant = PACKAGE_VARIANTS[args.variant] package_dir_arg = getattr(args, "package_dir", None) package_dir = ( package_dir_arg.resolve() @@ -97,19 +105,30 @@ def main() -> int: source_outputs = build_source_binaries( spec, + variant, cargo=args.cargo, profile=args.cargo_profile, + entrypoint_bin=( + resolve_input_path( + args.entrypoint_bin, + "prebuilt entrypoint executable", + "--entrypoint-bin", + ) + if args.entrypoint_bin is not None + else None + ), ) + version = read_workspace_version() inputs = PackageInputs( - codex_bin=source_outputs.codex_bin, + entrypoint_bin=source_outputs.entrypoint_bin, rg_bin=resolve_rg_bin(spec, args.rg_bin), bwrap_bin=source_outputs.bwrap_bin, codex_command_runner_bin=source_outputs.codex_command_runner_bin, codex_windows_sandbox_setup_bin=source_outputs.codex_windows_sandbox_setup_bin, ) prepare_package_dir(package_dir, force=args.force) - build_package_dir(package_dir, args.version, args.variant, spec, inputs) - validate_package_dir(package_dir, spec) + build_package_dir(package_dir, version, variant, spec, inputs) + validate_package_dir(package_dir, variant, spec) archive_output = args.archive_output if archive_output is not None: diff --git a/scripts/codex_package/layout.py b/scripts/codex_package/layout.py index faf24ef810..6d8982a631 100644 --- a/scripts/codex_package/layout.py +++ b/scripts/codex_package/layout.py @@ -6,6 +6,7 @@ import stat from pathlib import Path from .targets import PackageInputs +from .targets import PackageVariant from .targets import TargetSpec @@ -30,7 +31,7 @@ def prepare_package_dir(package_dir: Path, *, force: bool) -> None: def build_package_dir( package_dir: Path, version: str, - variant: str, + variant: PackageVariant, spec: TargetSpec, inputs: PackageInputs, ) -> None: @@ -41,7 +42,12 @@ def build_package_dir( resources_dir.mkdir() path_dir.mkdir() - copy_executable(inputs.codex_bin, bin_dir / spec.codex_name, is_windows=spec.is_windows) + entrypoint_name = variant.entrypoint_name(spec) + copy_executable( + inputs.entrypoint_bin, + bin_dir / entrypoint_name, + is_windows=spec.is_windows, + ) copy_executable(inputs.rg_bin, path_dir / spec.rg_name, is_windows=spec.is_windows) if inputs.bwrap_bin is not None: @@ -65,15 +71,19 @@ def build_package_dir( "layoutVersion": LAYOUT_VERSION, "version": version, "target": spec.target, - "variant": variant, - "entrypoint": f"bin/{spec.codex_name}", + "variant": variant.name, + "entrypoint": f"bin/{entrypoint_name}", "resourcesDir": "codex-resources", "pathDir": "codex-path", } write_json(package_dir / "codex-package.json", metadata) -def validate_package_dir(package_dir: Path, spec: TargetSpec) -> None: +def validate_package_dir( + package_dir: Path, + variant: PackageVariant, + spec: TargetSpec, +) -> None: required_dirs = [ Path("bin"), Path("codex-resources"), @@ -94,7 +104,8 @@ def validate_package_dir(package_dir: Path, spec: TargetSpec) -> None: expected_metadata = { "layoutVersion": LAYOUT_VERSION, "target": spec.target, - "entrypoint": f"bin/{spec.codex_name}", + "variant": variant.name, + "entrypoint": f"bin/{variant.entrypoint_name(spec)}", "resourcesDir": "codex-resources", "pathDir": "codex-path", } @@ -106,7 +117,7 @@ def validate_package_dir(package_dir: Path, spec: TargetSpec) -> None: ) required_files = [ - Path("bin") / spec.codex_name, + Path("bin") / variant.entrypoint_name(spec), Path("codex-path") / spec.rg_name, ] executable_files = list(required_files) diff --git a/scripts/codex_package/targets.py b/scripts/codex_package/targets.py index d4f85953c2..4af0d4a00d 100644 --- a/scripts/codex_package/targets.py +++ b/scripts/codex_package/targets.py @@ -21,24 +21,44 @@ class TargetSpec: def exe_suffix(self) -> str: return ".exe" if self.is_windows else "" - @property - def codex_name(self) -> str: - return f"codex{self.exe_suffix}" - @property def rg_name(self) -> str: return f"rg{self.exe_suffix}" +@dataclass(frozen=True) +class PackageVariant: + name: str + cargo_bin: str + executable_stem: str + + def entrypoint_name(self, spec: TargetSpec) -> str: + return f"{self.executable_stem}{spec.exe_suffix}" + + @dataclass(frozen=True) class PackageInputs: - codex_bin: Path + entrypoint_bin: Path rg_bin: Path bwrap_bin: Path | None codex_command_runner_bin: Path | None codex_windows_sandbox_setup_bin: Path | None +PACKAGE_VARIANTS: dict[str, PackageVariant] = { + "codex": PackageVariant( + name="codex", + cargo_bin="codex", + executable_stem="codex", + ), + "codex-app-server": PackageVariant( + name="codex-app-server", + cargo_bin="codex-app-server", + executable_stem="codex-app-server", + ), +} + + TARGET_SPECS: dict[str, TargetSpec] = { "x86_64-unknown-linux-gnu": TargetSpec( target="x86_64-unknown-linux-gnu", diff --git a/scripts/codex_package/version.py b/scripts/codex_package/version.py new file mode 100644 index 0000000000..7aec8b9ccb --- /dev/null +++ b/scripts/codex_package/version.py @@ -0,0 +1,29 @@ +"""Version discovery for Codex packages.""" + +import re + +from .targets import REPO_ROOT + + +WORKSPACE_VERSION_PATTERN = re.compile(r'^version\s*=\s*"([^"]+)"') + + +def read_workspace_version() -> str: + cargo_toml = REPO_ROOT / "codex-rs" / "Cargo.toml" + in_workspace_package = False + with open(cargo_toml, encoding="utf-8") as fh: + for line in fh: + stripped = line.strip() + if stripped == "[workspace.package]": + in_workspace_package = True + continue + + if in_workspace_package and stripped.startswith("["): + break + + if in_workspace_package: + match = WORKSPACE_VERSION_PATTERN.match(stripped) + if match is not None: + return match.group(1) + + raise RuntimeError(f"Could not find [workspace.package].version in {cargo_toml}") From 0f0720629705caf48986fdf0a25807dc1d37fc94 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 19 May 2026 22:10:18 -0700 Subject: [PATCH 10/11] ci: build Codex package archives in release workflow --- .github/workflows/rust-release.yml | 44 ++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 50953506d3..dc47563825 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -519,6 +519,45 @@ jobs: cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" fi + - name: Build Codex package archive + if: ${{ runner.os != 'macOS' || env.SIGN_MACOS == 'true' }} + shell: bash + env: + TARGET: ${{ matrix.target }} + BUNDLE: ${{ matrix.bundle }} + run: | + set -euo pipefail + + case "$BUNDLE" in + primary) + variant="codex" + entrypoint="codex" + archive_stem="codex-package" + ;; + app-server) + variant="codex-app-server" + entrypoint="codex-app-server" + archive_stem="codex-app-server-package" + ;; + *) + echo "No Codex package variant for bundle: $BUNDLE" + exit 1 + ;; + esac + + package_dir="${RUNNER_TEMP}/${archive_stem}-${TARGET}" + archive_path="dist/${TARGET}/${archive_stem}-${TARGET}.tar.gz" + rm -rf "$package_dir" + + ../scripts/build_codex_package.py \ + --target "$TARGET" \ + --variant "$variant" \ + --entrypoint-bin "target/${TARGET}/release/${entrypoint}" \ + --cargo-profile release \ + --package-dir "$package_dir" \ + --archive-output "$archive_path" \ + --force + - name: Build Python runtime wheel if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }} shell: bash @@ -1079,6 +1118,11 @@ jobs: # If included in files: dist/**, release upload races on duplicate # asset names and can fail with 404s. find dist -type f -name 'cargo-timing.html' -delete + # Keep package-builder sidecar archives as workflow artifacts only + # until distribution channels are ready to consume them. + find dist -type f \ + \( -name 'codex-package-*' -o -name 'codex-app-server-package-*' \) \ + -delete find dist -type d -empty -delete ls -R dist/ From af339f3a150bff9cdfd3a613e14fd40a6b65e39f Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 19 May 2026 22:10:18 -0700 Subject: [PATCH 11/11] runtime: detect Codex package layout --- codex-rs/Cargo.lock | 1 + codex-rs/cli/src/doctor.rs | 77 ++++-- codex-rs/cli/src/doctor/runtime.rs | 44 ++-- codex-rs/cli/src/doctor/updates.rs | 35 +-- codex-rs/install-context/Cargo.toml | 1 + codex-rs/install-context/src/lib.rs | 372 +++++++++++++++++++++++----- codex-rs/tui/src/update_action.rs | 62 +++-- 7 files changed, 467 insertions(+), 125 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 1950939e65..09c5786a9c 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2997,6 +2997,7 @@ dependencies = [ name = "codex-install-context" version = "0.0.0" dependencies = [ + "codex-utils-absolute-path", "codex-utils-home-dir", "pretty_assertions", "tempfile", diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index aec57c1e82..3f8c3ff094 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -39,7 +39,9 @@ use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; use codex_features::FEATURES; +use codex_install_context::CodexPackageLayout; use codex_install_context::InstallContext; +use codex_install_context::InstallMethod; use codex_install_context::StandalonePlatform; use codex_login::AuthDotJson; use codex_login::AuthManager; @@ -812,7 +814,10 @@ fn installation_check(show_details: bool) -> DoctorCheck { fn doctor_install_context(current_exe: Option<&Path>) -> InstallContext { if inherited_managed_env_for_cargo_binary(current_exe) { - InstallContext::Other + InstallContext { + method: InstallMethod::Other, + package_layout: None, + } } else { InstallContext::current().clone() } @@ -843,8 +848,8 @@ fn inherited_managed_env_for_cargo_binary(current_exe: Option<&Path>) -> bool { } fn describe_install_context(context: &InstallContext) -> String { - match context { - InstallContext::Standalone { + match &context.method { + InstallMethod::Standalone { release_dir, resources_dir, platform, @@ -853,22 +858,63 @@ fn describe_install_context(context: &InstallContext) -> String { StandalonePlatform::Unix => "unix", StandalonePlatform::Windows => "windows", }; - let resources = resources_dir - .as_ref() - .map(|path| path.display().to_string()) - .unwrap_or_else(|| "none".to_string()); + match &context.package_layout { + Some(package_layout) => { + let resources = display_optional_path(package_layout.resources_dir.as_deref()); + let path = display_optional_path(package_layout.path_dir.as_deref()); + format!( + "standalone ({platform}, package {}, bin {}, resources {resources}, path {path})", + package_layout.package_dir.display(), + package_layout.bin_dir.display() + ) + } + None => { + let resources = display_optional_path(resources_dir.as_deref()); + format!( + "standalone ({platform}, release {}, resources {resources})", + release_dir.display() + ) + } + } + } + InstallMethod::Npm => { + describe_method_with_package_layout("npm", context.package_layout.as_ref()) + } + InstallMethod::Bun => { + describe_method_with_package_layout("bun", context.package_layout.as_ref()) + } + InstallMethod::Brew => { + describe_method_with_package_layout("brew", context.package_layout.as_ref()) + } + InstallMethod::Other => { + describe_method_with_package_layout("other", context.package_layout.as_ref()) + } + } +} + +fn describe_method_with_package_layout( + method: &str, + package_layout: Option<&CodexPackageLayout>, +) -> String { + match package_layout { + Some(package_layout) => { + let resources = display_optional_path(package_layout.resources_dir.as_deref()); + let path = display_optional_path(package_layout.path_dir.as_deref()); format!( - "standalone ({platform}, release {}, resources {resources})", - release_dir.display() + "{method} (package {}, bin {}, resources {resources}, path {path})", + package_layout.package_dir.display(), + package_layout.bin_dir.display() ) } - InstallContext::Npm => "npm".to_string(), - InstallContext::Bun => "bun".to_string(), - InstallContext::Brew => "brew".to_string(), - InstallContext::Other => "other".to_string(), + None => method.to_string(), } } +fn display_optional_path(path: Option<&Path>) -> String { + path.map(|path| path.display().to_string()) + .unwrap_or_else(|| "none".to_string()) +} + #[derive(Debug, PartialEq, Eq)] enum NpmRootCheck { Match { @@ -2791,13 +2837,14 @@ fn path_readiness(details: &mut Vec, label: &str, path: &Path) { } fn standalone_release_cache_details(details: &mut Vec) { - let InstallContext::Standalone { release_dir, .. } = InstallContext::current() else { + let context = InstallContext::current(); + let InstallMethod::Standalone { release_dir, .. } = &context.method else { return; }; let Some(releases_dir) = release_dir.parent() else { return; }; - let Ok(entries) = std::fs::read_dir(releases_dir) else { + let Ok(entries) = std::fs::read_dir(&releases_dir) else { return; }; let release_count = entries.filter_map(Result::ok).count(); diff --git a/codex-rs/cli/src/doctor/runtime.rs b/codex-rs/cli/src/doctor/runtime.rs index 96e806762c..14afa70e4c 100644 --- a/codex-rs/cli/src/doctor/runtime.rs +++ b/codex-rs/cli/src/doctor/runtime.rs @@ -3,12 +3,13 @@ //! Runtime diagnostics answer provenance questions that are hard to infer from //! user reports: which binary is running, which install channel it resembles, //! which platform it targets, and whether the search command comes from bundled -//! standalone resources or from PATH. +//! package files or from PATH. use std::env; use std::process::Command; use codex_install_context::InstallContext; +use codex_install_context::InstallMethod; use super::CheckStatus; use super::DoctorCheck; @@ -49,9 +50,10 @@ pub(super) fn runtime_check() -> DoctorCheck { /// Verifies that the search command selected by the install context is usable. /// -/// Standalone installs should point at a bundled ripgrep binary, while local or -/// package-managed installs usually resolve rg from PATH. A warning here means -/// features that depend on file search may degrade even when the CLI launches. +/// Package-layout installs should point at a bundled ripgrep binary, while local +/// installs without that layout usually resolve rg from PATH. A warning here +/// means features that depend on file search may degrade even when the CLI +/// launches. pub(super) fn search_check() -> DoctorCheck { let current_exe = env::current_exe().ok(); let install_context = doctor_install_context(current_exe.as_deref()); @@ -109,28 +111,40 @@ pub(super) fn search_check() -> DoctorCheck { }; let mut check = DoctorCheck::new("runtime.search", "search", status, summary).details(details); if status != CheckStatus::Ok { - check = check.remediation("Install ripgrep or repair the bundled standalone resources."); + check = check.remediation("Install ripgrep or repair the bundled Codex package."); } check } fn install_method_name(context: &InstallContext) -> &'static str { - match context { - InstallContext::Standalone { .. } => "standalone", - InstallContext::Npm => "npm", - InstallContext::Bun => "bun", - InstallContext::Brew => "brew", - InstallContext::Other => "local build", + match &context.method { + InstallMethod::Standalone { .. } => "standalone", + InstallMethod::Npm => "npm", + InstallMethod::Bun => "bun", + InstallMethod::Brew => "brew", + InstallMethod::Other => "local build", } } fn search_provider(context: &InstallContext) -> &'static str { - match context { - InstallContext::Standalone { + let rg_command = context.rg_command(); + let from_package_layout = context + .package_layout + .as_ref() + .and_then(|package_layout| package_layout.path_dir.as_ref()) + .is_some_and(|path_dir| rg_command.starts_with(path_dir)); + let from_legacy_standalone = matches!( + &context.method, + InstallMethod::Standalone { resources_dir: Some(resources_dir), .. - } if context.rg_command().starts_with(resources_dir) => "bundled", - _ => "system", + } if rg_command.starts_with(resources_dir) + ); + + if from_package_layout || from_legacy_standalone { + "bundled" + } else { + "system" } } diff --git a/codex-rs/cli/src/doctor/updates.rs b/codex-rs/cli/src/doctor/updates.rs index 3d728fc07b..246eac2b39 100644 --- a/codex-rs/cli/src/doctor/updates.rs +++ b/codex-rs/cli/src/doctor/updates.rs @@ -10,6 +10,7 @@ use std::path::Path; use codex_core::config::Config; use codex_install_context::InstallContext; +use codex_install_context::InstallMethod; use serde::Deserialize; use super::CheckStatus; @@ -129,22 +130,22 @@ fn push_cached_version_details(details: &mut Vec, version_file: &Path) { } fn update_action_label(context: &InstallContext) -> &'static str { - match context { - InstallContext::Npm => "npm install -g @openai/codex", - InstallContext::Bun => "bun install -g @openai/codex", - InstallContext::Brew => "brew upgrade --cask codex", - InstallContext::Standalone { .. } => "standalone installer", - InstallContext::Other => "manual or unknown", + match &context.method { + InstallMethod::Npm => "npm install -g @openai/codex", + InstallMethod::Bun => "bun install -g @openai/codex", + InstallMethod::Brew => "brew upgrade --cask codex", + InstallMethod::Standalone { .. } => "standalone installer", + InstallMethod::Other => "manual or unknown", } } fn fetch_latest_version(context: &InstallContext) -> Result { - match context { - InstallContext::Brew => fetch_homebrew_cask_version(), - InstallContext::Npm - | InstallContext::Bun - | InstallContext::Standalone { .. } - | InstallContext::Other => fetch_latest_github_release_version(), + match &context.method { + InstallMethod::Brew => fetch_homebrew_cask_version(), + InstallMethod::Npm + | InstallMethod::Bun + | InstallMethod::Standalone { .. } + | InstallMethod::Other => fetch_latest_github_release_version(), } } @@ -216,11 +217,17 @@ mod tests { #[test] fn update_action_labels_install_contexts() { assert_eq!( - update_action_label(&InstallContext::Npm), + update_action_label(&InstallContext { + method: InstallMethod::Npm, + package_layout: None, + }), "npm install -g @openai/codex" ); assert_eq!( - update_action_label(&InstallContext::Other), + update_action_label(&InstallContext { + method: InstallMethod::Other, + package_layout: None, + }), "manual or unknown" ); } diff --git a/codex-rs/install-context/Cargo.toml b/codex-rs/install-context/Cargo.toml index 52938a0812..5882a19763 100644 --- a/codex-rs/install-context/Cargo.toml +++ b/codex-rs/install-context/Cargo.toml @@ -13,6 +13,7 @@ doctest = false workspace = true [dependencies] +codex-utils-absolute-path = { workspace = true } codex-utils-home-dir = { workspace = true } [dev-dependencies] diff --git a/codex-rs/install-context/src/lib.rs b/codex-rs/install-context/src/lib.rs index 980fc3f546..d1f8d3839b 100644 --- a/codex-rs/install-context/src/lib.rs +++ b/codex-rs/install-context/src/lib.rs @@ -1,7 +1,13 @@ +use std::ffi::OsStr; use std::path::Path; use std::path::PathBuf; use std::sync::OnceLock; +use codex_utils_absolute_path::AbsolutePathBuf; + +const BIN_DIRNAME: &str = "bin"; +const PACKAGE_METADATA_FILENAME: &str = "codex-package.json"; +const PATH_DIRNAME: &str = "codex-path"; const RELEASES_DIRNAME: &str = "releases"; const RESOURCES_DIRNAME: &str = "codex-resources"; const STANDALONE_PACKAGES_DIRNAME: &str = "standalone"; @@ -14,14 +20,34 @@ pub enum StandalonePlatform { } #[derive(Clone, Debug, Eq, PartialEq)] -pub enum InstallContext { +pub struct CodexPackageLayout { + /// The package root that contains the metadata file and layout directories. + pub package_dir: AbsolutePathBuf, + /// Directory containing the Codex entrypoint executable. + pub bin_dir: AbsolutePathBuf, + /// Directory containing managed helper binaries and data files, when present. + pub resources_dir: Option, + /// Folder that should be prepended to the PATH, when present. + pub path_dir: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InstallContext { + pub method: InstallMethod, + pub package_layout: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum InstallMethod { Standalone { - /// The managed standalone release directory, for example + /// The managed standalone release directory. Legacy installs use paths + /// such as /// `~/.codex/packages/standalone/releases/0.111.0-x86_64-unknown-linux-musl`. - release_dir: PathBuf, - /// The bundled resource directory that sits next to the executable when - /// this install ships managed dependencies. - resources_dir: Option, + /// Package-layout installs use the package root that contains `bin/`, + /// `codex-resources/`, and `codex-path/`. + release_dir: AbsolutePathBuf, + /// The bundled resource directory for managed dependencies. + resources_dir: Option, /// The platform of the standalone release, either `Unix` or `Windows`. platform: StandalonePlatform, }, @@ -62,28 +88,21 @@ impl InstallContext { managed_by_bun: bool, codex_home: Option<&Path>, ) -> Self { - if managed_by_npm { - return Self::Npm; - } + let package_layout = current_exe.and_then(CodexPackageLayout::from_exe); + let method = if managed_by_npm { + InstallMethod::Npm + } else if managed_by_bun { + InstallMethod::Bun + } else if let Some(exe_path) = current_exe { + install_method_from_exe(exe_path, codex_home, package_layout.as_ref(), is_macos) + } else { + InstallMethod::Other + }; - if managed_by_bun { - return Self::Bun; + Self { + method, + package_layout, } - - if let Some(exe_path) = current_exe - && let Some(standalone_context) = standalone_install_context(exe_path, codex_home) - { - return standalone_context; - } - - if is_macos - && let Some(exe_path) = current_exe - && (exe_path.starts_with("/opt/homebrew") || exe_path.starts_with("/usr/local")) - { - return Self::Brew; - } - - Self::Other } pub fn current() -> &'static Self { @@ -101,53 +120,102 @@ impl InstallContext { } pub fn rg_command(&self) -> PathBuf { - match self { - Self::Standalone { - resources_dir: Some(resources_dir), - .. - } => { - let bundled_rg = resources_dir.join(default_rg_command()); - if bundled_rg.exists() { - bundled_rg - } else { - default_rg_command() - } + if let Some(package_layout) = &self.package_layout + && let Some(path_dir) = &package_layout.path_dir + { + let bundled_rg = path_dir.join(default_rg_command()); + if bundled_rg.exists() { + return bundled_rg.into_path_buf(); } - Self::Standalone { - resources_dir: None, - .. - } - | Self::Npm - | Self::Bun - | Self::Brew - | Self::Other => default_rg_command(), } + + if let InstallMethod::Standalone { + resources_dir: Some(resources_dir), + .. + } = &self.method + { + let bundled_rg = resources_dir.join(default_rg_command()); + if bundled_rg.exists() { + return bundled_rg.into_path_buf(); + } + } + + default_rg_command() } } -fn standalone_install_context( +impl CodexPackageLayout { + fn from_exe(exe_path: &Path) -> Option { + let canonical_exe = canonical_absolute_path(exe_path)?; + let bin_dir = canonical_exe.parent()?; + if bin_dir.file_name() != Some(OsStr::new(BIN_DIRNAME)) { + return None; + } + + let package_dir = bin_dir.parent()?; + if !package_dir.join(PACKAGE_METADATA_FILENAME).is_file() { + return None; + } + + Some(Self { + resources_dir: existing_dir(package_dir.join(RESOURCES_DIRNAME)), + path_dir: existing_dir(package_dir.join(PATH_DIRNAME)), + package_dir, + bin_dir, + }) + } +} + +fn install_method_from_exe( exe_path: &Path, 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(); + package_layout: Option<&CodexPackageLayout>, + is_macos: bool, +) -> InstallMethod { + if let Some(standalone_method) = standalone_install_method(exe_path, codex_home, package_layout) + { + return standalone_method; + } + + if is_macos && (exe_path.starts_with("/opt/homebrew") || exe_path.starts_with("/usr/local")) { + InstallMethod::Brew + } else { + InstallMethod::Other + } +} + +fn standalone_install_method( + exe_path: &Path, + codex_home: Option<&Path>, + package_layout: Option<&CodexPackageLayout>, +) -> Option { + let canonical_codex_home = canonical_absolute_path(codex_home?)?; + let release_dir = if let Some(package_layout) = package_layout { + package_layout.package_dir.clone() + } else { + canonical_absolute_path(exe_path)?.parent()? + }; let releases_root = canonical_codex_home .join("packages") .join(STANDALONE_PACKAGES_DIRNAME) .join(RELEASES_DIRNAME); - if !release_dir.starts_with(releases_root) { + if !release_dir.starts_with(releases_root.as_path()) { return None; } let resources_dir = release_dir.join(RESOURCES_DIRNAME); - Some(InstallContext::Standalone { + Some(InstallMethod::Standalone { release_dir, resources_dir: resources_dir.is_dir().then_some(resources_dir), platform: standalone_platform(), }) } +fn canonical_absolute_path(path: &Path) -> Option { + let canonical_path = std::fs::canonicalize(path).ok()?; + AbsolutePathBuf::from_absolute_path(canonical_path).ok() +} + fn standalone_platform() -> StandalonePlatform { if cfg!(windows) { StandalonePlatform::Windows @@ -156,6 +224,10 @@ fn standalone_platform() -> StandalonePlatform { } } +fn existing_dir(path: AbsolutePathBuf) -> Option { + path.is_dir().then_some(path) +} + fn default_rg_command() -> PathBuf { if cfg!(windows) { PathBuf::from("rg.exe") @@ -181,8 +253,10 @@ mod tests { let exe_path = release_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); fs::write(&exe_path, "")?; fs::write(resources_dir.join(default_rg_command()), "")?; - let canonical_release_dir = release_dir.canonicalize()?; - let canonical_resources_dir = resources_dir.canonicalize()?; + let canonical_release_dir = + AbsolutePathBuf::from_absolute_path(release_dir.canonicalize()?)?; + let canonical_resources_dir = + AbsolutePathBuf::from_absolute_path(resources_dir.canonicalize()?)?; let context = InstallContext::from_exe_with_codex_home( /*is_macos*/ false, @@ -193,10 +267,13 @@ mod tests { ); assert_eq!( context, - InstallContext::Standalone { - release_dir: canonical_release_dir, - resources_dir: Some(canonical_resources_dir), - platform: standalone_platform(), + InstallContext { + method: InstallMethod::Standalone { + release_dir: canonical_release_dir, + resources_dir: Some(canonical_resources_dir), + platform: standalone_platform(), + }, + package_layout: None, } ); Ok(()) @@ -223,6 +300,161 @@ mod tests { Ok(()) } + #[test] + fn detects_package_layout_independently_from_install_method() -> std::io::Result<()> { + let package_dir = tempfile::tempdir()?; + let bin_dir = package_dir.path().join(BIN_DIRNAME); + let resources_dir = package_dir.path().join(RESOURCES_DIRNAME); + let path_dir = package_dir.path().join(PATH_DIRNAME); + fs::create_dir_all(&bin_dir)?; + fs::create_dir_all(&resources_dir)?; + fs::create_dir_all(&path_dir)?; + fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; + let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + fs::write(&exe_path, "")?; + fs::write(path_dir.join(default_rg_command()), "")?; + let canonical_package_dir = + AbsolutePathBuf::from_absolute_path(package_dir.path().canonicalize()?)?; + let canonical_bin_dir = AbsolutePathBuf::from_absolute_path(bin_dir.canonicalize()?)?; + let canonical_resources_dir = + AbsolutePathBuf::from_absolute_path(resources_dir.canonicalize()?)?; + let canonical_path_dir = AbsolutePathBuf::from_absolute_path(path_dir.canonicalize()?)?; + let package_layout = CodexPackageLayout { + package_dir: canonical_package_dir, + bin_dir: canonical_bin_dir, + resources_dir: Some(canonical_resources_dir), + path_dir: Some(canonical_path_dir.clone()), + }; + + let context = InstallContext::from_exe_with_codex_home( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*managed_by_npm*/ false, + /*managed_by_bun*/ false, + /*codex_home*/ None, + ); + assert_eq!( + context, + InstallContext { + method: InstallMethod::Other, + package_layout: Some(package_layout), + } + ); + assert_eq!( + context.rg_command(), + canonical_path_dir + .join(default_rg_command()) + .into_path_buf() + ); + Ok(()) + } + + #[test] + fn standalone_package_layout_keeps_standalone_install_method() -> std::io::Result<()> { + let codex_home = tempfile::tempdir()?; + let package_dir = codex_home + .path() + .join("packages/standalone/releases/1.2.3-x86_64-unknown-linux-musl"); + let bin_dir = package_dir.join(BIN_DIRNAME); + let resources_dir = package_dir.join(RESOURCES_DIRNAME); + let path_dir = package_dir.join(PATH_DIRNAME); + fs::create_dir_all(&bin_dir)?; + fs::create_dir_all(&resources_dir)?; + fs::create_dir_all(&path_dir)?; + fs::write(package_dir.join(PACKAGE_METADATA_FILENAME), "{}")?; + let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + fs::write(&exe_path, "")?; + fs::write(path_dir.join(default_rg_command()), "")?; + let canonical_package_dir = + AbsolutePathBuf::from_absolute_path(package_dir.canonicalize()?)?; + let canonical_bin_dir = AbsolutePathBuf::from_absolute_path(bin_dir.canonicalize()?)?; + let canonical_resources_dir = + AbsolutePathBuf::from_absolute_path(resources_dir.canonicalize()?)?; + let canonical_path_dir = AbsolutePathBuf::from_absolute_path(path_dir.canonicalize()?)?; + + let context = InstallContext::from_exe_with_codex_home( + /*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, + InstallContext { + method: InstallMethod::Standalone { + release_dir: canonical_package_dir.clone(), + resources_dir: Some(canonical_resources_dir.clone()), + platform: standalone_platform(), + }, + package_layout: Some(CodexPackageLayout { + package_dir: canonical_package_dir, + bin_dir: canonical_bin_dir, + resources_dir: Some(canonical_resources_dir), + path_dir: Some(canonical_path_dir.clone()), + }), + } + ); + assert_eq!( + context.rg_command(), + canonical_path_dir + .join(default_rg_command()) + .into_path_buf() + ); + Ok(()) + } + + #[test] + fn npm_managed_package_keeps_package_layout() -> std::io::Result<()> { + let package_dir = tempfile::tempdir()?; + let bin_dir = package_dir.path().join(BIN_DIRNAME); + let path_dir = package_dir.path().join(PATH_DIRNAME); + fs::create_dir_all(&bin_dir)?; + fs::create_dir_all(&path_dir)?; + fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; + let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + fs::write(&exe_path, "")?; + fs::write(path_dir.join(default_rg_command()), "")?; + let canonical_path_dir = AbsolutePathBuf::from_absolute_path(path_dir.canonicalize()?)?; + + let context = InstallContext::from_exe_with_codex_home( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*managed_by_npm*/ true, + /*managed_by_bun*/ false, + /*codex_home*/ None, + ); + assert_eq!(context.method, InstallMethod::Npm); + assert!(context.package_layout.is_some()); + assert_eq!( + context.rg_command(), + canonical_path_dir + .join(default_rg_command()) + .into_path_buf() + ); + Ok(()) + } + + #[test] + fn standalone_package_rg_falls_back_when_codex_path_is_missing() -> std::io::Result<()> { + let package_dir = tempfile::tempdir()?; + let bin_dir = package_dir.path().join(BIN_DIRNAME); + fs::create_dir_all(&bin_dir)?; + fs::write(package_dir.path().join(PACKAGE_METADATA_FILENAME), "{}")?; + let exe_path = bin_dir.join(if cfg!(windows) { "codex.exe" } else { "codex" }); + fs::write(&exe_path, "")?; + + let context = InstallContext::from_exe_with_codex_home( + /*is_macos*/ false, + /*current_exe*/ Some(&exe_path), + /*managed_by_npm*/ false, + /*managed_by_bun*/ false, + /*codex_home*/ None, + ); + assert_eq!(context.rg_command(), default_rg_command()); + Ok(()) + } + #[test] fn npm_and_bun_take_precedence() { let npm_context = InstallContext::from_exe_with_codex_home( @@ -232,7 +464,13 @@ mod tests { /*managed_by_bun*/ false, /*codex_home*/ None, ); - assert_eq!(npm_context, InstallContext::Npm); + assert_eq!( + npm_context, + InstallContext { + method: InstallMethod::Npm, + package_layout: None, + } + ); let bun_context = InstallContext::from_exe_with_codex_home( /*is_macos*/ false, @@ -241,7 +479,13 @@ mod tests { /*managed_by_bun*/ true, /*codex_home*/ None, ); - assert_eq!(bun_context, InstallContext::Bun); + assert_eq!( + bun_context, + InstallContext { + method: InstallMethod::Bun, + package_layout: None, + } + ); } #[test] @@ -253,6 +497,12 @@ mod tests { /*managed_by_bun*/ false, /*codex_home*/ None, ); - assert_eq!(context, InstallContext::Brew); + assert_eq!( + context, + InstallContext { + method: InstallMethod::Brew, + package_layout: None, + } + ); } } diff --git a/codex-rs/tui/src/update_action.rs b/codex-rs/tui/src/update_action.rs index aca065440c..cb4aa662ca 100644 --- a/codex-rs/tui/src/update_action.rs +++ b/codex-rs/tui/src/update_action.rs @@ -1,6 +1,8 @@ #[cfg(any(not(debug_assertions), test))] use codex_install_context::InstallContext; #[cfg(any(not(debug_assertions), test))] +use codex_install_context::InstallMethod; +#[cfg(any(not(debug_assertions), test))] use codex_install_context::StandalonePlatform; /// Update action the CLI should perform after the TUI exits. @@ -21,15 +23,15 @@ 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), - InstallContext::Bun => Some(UpdateAction::BunGlobalLatest), - InstallContext::Brew => Some(UpdateAction::BrewUpgrade), - InstallContext::Standalone { platform, .. } => Some(match platform { + match &context.method { + InstallMethod::Npm => Some(UpdateAction::NpmGlobalLatest), + InstallMethod::Bun => Some(UpdateAction::BunGlobalLatest), + InstallMethod::Brew => Some(UpdateAction::BrewUpgrade), + InstallMethod::Standalone { platform, .. } => Some(match platform { StandalonePlatform::Unix => UpdateAction::StandaloneUnix, StandalonePlatform::Windows => UpdateAction::StandaloneWindows, }), - InstallContext::Other => None, + InstallMethod::Other => None, } } @@ -66,42 +68,62 @@ pub fn get_update_action() -> Option { #[cfg(test)] mod tests { use super::*; + use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; - use std::path::PathBuf; #[test] fn maps_install_context_to_update_action() { - let native_release_dir = PathBuf::from("/tmp/native-release"); + let native_release_dir = + AbsolutePathBuf::from_absolute_path(std::env::temp_dir().join("native-release")) + .expect("temp dir path should be absolute"); assert_eq!( - UpdateAction::from_install_context(&InstallContext::Other), + UpdateAction::from_install_context(&InstallContext { + method: InstallMethod::Other, + package_layout: None, + }), None ); assert_eq!( - UpdateAction::from_install_context(&InstallContext::Npm), + UpdateAction::from_install_context(&InstallContext { + method: InstallMethod::Npm, + package_layout: None, + }), Some(UpdateAction::NpmGlobalLatest) ); assert_eq!( - UpdateAction::from_install_context(&InstallContext::Bun), + UpdateAction::from_install_context(&InstallContext { + method: InstallMethod::Bun, + package_layout: None, + }), Some(UpdateAction::BunGlobalLatest) ); assert_eq!( - UpdateAction::from_install_context(&InstallContext::Brew), + UpdateAction::from_install_context(&InstallContext { + method: InstallMethod::Brew, + package_layout: None, + }), Some(UpdateAction::BrewUpgrade) ); assert_eq!( - UpdateAction::from_install_context(&InstallContext::Standalone { - platform: StandalonePlatform::Unix, - release_dir: native_release_dir.clone(), - resources_dir: Some(native_release_dir.join("codex-resources")), + UpdateAction::from_install_context(&InstallContext { + method: InstallMethod::Standalone { + platform: StandalonePlatform::Unix, + release_dir: native_release_dir.clone(), + resources_dir: Some(native_release_dir.join("codex-resources")), + }, + package_layout: None, }), Some(UpdateAction::StandaloneUnix) ); assert_eq!( - UpdateAction::from_install_context(&InstallContext::Standalone { - platform: StandalonePlatform::Windows, - release_dir: native_release_dir.clone(), - resources_dir: Some(native_release_dir.join("codex-resources")), + UpdateAction::from_install_context(&InstallContext { + method: InstallMethod::Standalone { + platform: StandalonePlatform::Windows, + release_dir: native_release_dir.clone(), + resources_dir: Some(native_release_dir.join("codex-resources")), + }, + package_layout: None, }), Some(UpdateAction::StandaloneWindows) );