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/.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/ 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] 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/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 58aa1c4449..ee68faf001 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", @@ -1604,6 +1632,7 @@ "PluginListMarketplaceKind": { "enum": [ "local", + "vertical", "workspace-directory", "shared-with-me" ], @@ -5324,6 +5353,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..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 @@ -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", @@ -12097,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 9da530463a..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 @@ -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", @@ -8646,6 +8742,7 @@ "PluginListMarketplaceKind": { "enum": [ "local", + "vertical", "workspace-directory", "shared-with-me" ], 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/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/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/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/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-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/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/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`. 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/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/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/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/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/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-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") } 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/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 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/mod.rs b/codex-rs/core/src/config/mod.rs index 93b67deb1a..51afdf0122 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2438,14 +2438,17 @@ impl Config { guardian_policy_config_source: _, } = config_layer_stack.requirements().clone(); - let user_instructions = - AgentsMdManager::load_global_instructions(LOCAL_FS.as_ref(), Some(&codex_home)) - .await - .map(|loaded| loaded.contents); let mut startup_warnings = config_layer_stack .startup_warnings() .unwrap_or_default() .to_vec(); + let user_instructions = AgentsMdManager::load_global_instructions( + LOCAL_FS.as_ref(), + Some(&codex_home), + &mut startup_warnings, + ) + .await + .map(|loaded| loaded.contents); // Destructure ConfigOverrides fully to ensure all overrides are applied. let ConfigOverrides { 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, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 8f0d2f1c01..bdffd4e3ca 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 @@ -504,9 +490,14 @@ impl Codex { } let primary_environment = environment_selections.primary_environment(); + let mut user_instruction_warnings = Vec::new(); let user_instructions = AgentsMdManager::new(&config) - .user_instructions(primary_environment.as_deref()) + .user_instructions( + primary_environment.as_deref(), + &mut user_instruction_warnings, + ) .await; + config.startup_warnings.extend(user_instruction_warnings); let exec_policy = if crate::guardian::is_guardian_reviewer_source(&session_source) { // Guardian review should rely on the built-in shell safety checks, 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| { 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 3dea7092f5..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,21 +68,29 @@ 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| { 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/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 8c0cc89680..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, @@ -691,8 +692,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 { @@ -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(()) } 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/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/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; 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) );