Merge af339f3a15 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-05-19 22:21:05 -07:00
committed by GitHub
72 changed files with 3475 additions and 691 deletions
+17
View File
@@ -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 }}"'
@@ -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<string> { "-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
+62
View File
@@ -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
+2 -1
View File
@@ -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
@@ -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
+78 -235
View File
@@ -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()
+44
View File
@@ -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/
+3 -1
View File
@@ -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]
+1
View File
@@ -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",
@@ -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": {
@@ -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"
],
@@ -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"
],
@@ -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"
}
@@ -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"
}
@@ -8,6 +8,7 @@
"PluginListMarketplaceKind": {
"enum": [
"local",
"vertical",
"workspace-directory",
"shared-with-me"
],
File diff suppressed because one or more lines are too long
@@ -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, };
@@ -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<PermissionProfileSummary>,
/**
* 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, };
@@ -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, };
@@ -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";
@@ -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";
@@ -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"),
@@ -287,6 +287,41 @@ impl From<FileSystemSandboxEntry> 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<String>,
/// Optional page size; defaults to the full result set.
#[ts(optional = nullable)]
pub limit: Option<u32>,
/// Optional working directory to resolve project config layers.
#[ts(optional = nullable)]
pub cwd: Option<String>,
}
#[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<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PermissionProfileListResponse {
pub data: Vec<PermissionProfileSummary>,
/// 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<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -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,
@@ -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::<PluginReadParams>(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::<PluginInstallParams>(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",
+1
View File
@@ -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.<id>]` 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 <feature_name> > 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.
+1
View File
@@ -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`.
@@ -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
}
@@ -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;
@@ -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<Option<ClientResponsePayload>, 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<PermissionProfileListResponse, JSONRPCErrorError> {
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::<Vec<_>>();
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::<usize>()
.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,
@@ -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)?;
@@ -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<i64> {
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,
@@ -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;
@@ -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::<PermissionProfileListResponse>(&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::<PermissionProfileListResponse>(&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::<PermissionProfileListResponse>(&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::<PermissionProfileListResponse>(&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<T: serde::de::DeserializeOwned>(
mcp: &mut McpProcess,
request_id: i64,
) -> Result<T> {
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
to_response(response)
}
@@ -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<serde_json::Value>,
) {
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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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);
+393 -151
View File
@@ -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<_>>(),
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::<String>::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<_>>(),
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::<Vec<_>>();
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<serde_json::Value>,
) -> 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": []
}},
@@ -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?;
@@ -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?;
@@ -117,7 +117,7 @@ fn write_plugin_with_skill(
fn write_cached_remote_plugin_with_skill(
codex_home: &std::path::Path,
) -> Result<std::path::PathBuf> {
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"),
+62 -15
View File
@@ -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<String>, label: &str, path: &Path) {
}
fn standalone_release_cache_details(details: &mut Vec<String>) {
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();
+29 -15
View File
@@ -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"
}
}
+21 -14
View File
@@ -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<String>, 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<String, String> {
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"
);
}
+1
View File
@@ -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<String>,
pub workspace_roots: Option<WorkspaceRootsToml>,
pub filesystem: Option<FilesystemPermissionsToml>,
pub network: Option<NetworkToml>,
+4 -4
View File
@@ -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);
+66 -3
View File
@@ -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<String>,
pub release_version: Option<String>,
pub bundle_download_url: Option<String>,
pub app_manifest: Option<JsonValue>,
pub skills: Vec<RemotePluginSkill>,
pub app_ids: Vec<String>,
}
@@ -391,6 +394,8 @@ struct RemotePluginReleaseResponse {
#[serde(default)]
app_ids: Vec<String>,
#[serde(default)]
app_manifest: Option<JsonValue>,
#[serde(default)]
keywords: Vec<String>,
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<Option<RemoteMarketplace>, 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<Vec<RemotePluginDirectoryItem>, 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<Vec<RemotePluginDirectoryItem>, 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<Vec<RemotePluginDirectoryItem>, 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<RemotePluginListResponse, RemotePluginCatalogError> {
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)]);
}
@@ -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());
}
@@ -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!(
+175 -7
View File
@@ -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<JsonValue>,
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<JsonValue>,
) -> Result<ValidatedRemotePluginBundle, RemotePluginBundleInstallError> {
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")
}
+3
View File
@@ -2002,6 +2002,9 @@
"PermissionProfileToml": {
"additionalProperties": false,
"properties": {
"description": {
"type": "string"
},
"filesystem": {
"$ref": "#/definitions/FilesystemPermissionsToml"
},
+56 -16
View File
@@ -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<String>,
) -> Option<LoadedAgentsMd> {
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<String>,
) -> Option<String> {
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<String>,
) -> Option<String> {
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<AbsolutePathBuf> {
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<Option<String>> {
async fn read_agents_md(
&self,
fs: &dyn ExecutorFileSystem,
startup_warnings: &mut Vec<String>,
) -> io::Result<Option<String>> {
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<String>,
) {
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;
+57 -2
View File
@@ -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<String> {
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<Vec<AbsolutePathBuf
.await
}
fn assert_invalid_utf8_warning(warnings: &[String], source: &str, path: &Path) {
let path_display = path.display().to_string();
assert_eq!(warnings.len(), 1, "expected one warning, got {warnings:?}");
let warning = &warnings[0];
assert!(
warning.contains(&format!("{source} AGENTS.md instructions"))
&& warning.contains(&path_display)
&& warning.contains("invalid UTF-8")
&& warning.contains("Invalid byte sequences were replaced."),
"unexpected invalid UTF-8 warning: {warning:?}"
);
}
/// Helper that returns a `Config` pointing at `root` and using `limit` as
/// the maximum number of bytes to embed from AGENTS.md. The caller can
/// optionally specify a custom `instructions` string – when `None` the
@@ -105,8 +120,9 @@ async fn no_environment_returns_none() {
let tmp = tempfile::tempdir().expect("tempdir");
let config = make_config(&tmp, /*limit*/ 4096, Some("user instructions")).await;
let mut warnings = Vec::new();
let res = AgentsMdManager::new(&config)
.user_instructions(/*environment*/ None)
.user_instructions(/*environment*/ None, &mut warnings)
.await;
assert_eq!(res, None);
@@ -129,6 +145,45 @@ async fn doc_smaller_than_limit_is_returned() {
);
}
#[tokio::test]
async fn global_doc_invalid_utf8_warns_and_uses_lossy_text() {
let codex_home = tempfile::tempdir().expect("tempdir");
let codex_home_abs = codex_home.abs();
let path = codex_home_abs.join(DEFAULT_AGENTS_MD_FILENAME);
fs::write(&path, b"global\xFF doc").unwrap();
let mut warnings = Vec::new();
let loaded = AgentsMdManager::load_global_instructions(
LOCAL_FS.as_ref(),
Some(&codex_home_abs),
&mut warnings,
)
.await
.expect("global doc expected");
assert_eq!(loaded.contents, "global\u{FFFD} doc");
assert_eq!(loaded.path, path);
assert_invalid_utf8_warning(&warnings, "Global", path.as_path());
}
#[tokio::test]
async fn project_doc_invalid_utf8_warns_and_uses_lossy_text() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("AGENTS.md");
fs::write(&path, b"project\xFF doc").unwrap();
let config = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
let mut warnings = Vec::new();
let res = AgentsMdManager::new(&config)
.user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings)
.await
.expect("doc expected");
assert_eq!(res, "project\u{FFFD} doc");
let canonical_path = dunce::canonicalize(&path).expect("canonical doc path");
assert_invalid_utf8_warning(&warnings, "Project", &canonical_path);
}
/// Oversize file is truncated to `project_doc_max_bytes`.
#[tokio::test]
async fn doc_larger_than_limit_is_truncated() {
+22
View File
@@ -734,6 +734,9 @@ fn config_toml_deserializes_permission_profiles() {
let toml = r#"
default_permissions = "workspace"
[permissions.workspace]
description = "Day-to-day workspace access."
[permissions.workspace.workspace_roots]
"~/code/openai" = true
"~/code/ignored" = false
@@ -765,6 +768,7 @@ allow_upstream_proxy = false
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
description: Some("Day-to-day workspace access.".to_string()),
workspace_roots: Some(WorkspaceRootsToml {
entries: BTreeMap::from([
("~/code/ignored".to_string(), false),
@@ -830,6 +834,7 @@ async fn permissions_profiles_proxy_policy_does_not_start_managed_network_proxy_
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
description: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
@@ -984,6 +989,7 @@ async fn network_proxy_feature_matrix_preserves_sandbox_network_semantics() -> s
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
description: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
@@ -1135,6 +1141,7 @@ async fn network_proxy_feature_uses_profile_network_proxy_settings() -> std::io:
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
description: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
@@ -1238,6 +1245,7 @@ enabled = false
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
description: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
@@ -1287,6 +1295,7 @@ async fn permissions_profiles_network_disabled_by_default_does_not_start_proxy()
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
description: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
@@ -1334,6 +1343,7 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std::
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
description: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
@@ -1638,6 +1648,7 @@ async fn permission_profile_override_preserves_configured_network_policy_without
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
description: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
@@ -1698,6 +1709,7 @@ async fn workspace_root_glob_none_compiles_to_filesystem_pattern_entry() -> std:
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
description: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: Some(2),
@@ -1777,6 +1789,7 @@ async fn permissions_profiles_require_default_permissions() -> std::io::Result<(
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
description: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
@@ -1904,6 +1917,7 @@ async fn workspace_profile_applies_rules_to_runtime_and_profile_workspace_roots(
entries: BTreeMap::from([(
"dev".to_string(),
PermissionProfileToml {
description: None,
workspace_roots: Some(WorkspaceRootsToml {
entries: BTreeMap::from([(
profile_root.to_string_lossy().into_owned(),
@@ -2362,6 +2376,7 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root()
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
description: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
@@ -2423,6 +2438,7 @@ async fn permissions_profiles_reject_nested_entries_for_non_workspace_roots() ->
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
description: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
@@ -2484,6 +2500,7 @@ async fn load_workspace_permission_profile(
#[tokio::test]
async fn permissions_profiles_allow_unknown_special_paths() -> std::io::Result<()> {
let config = load_workspace_permission_profile(PermissionProfileToml {
description: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
@@ -2528,6 +2545,7 @@ async fn permissions_profiles_allow_unknown_special_paths() -> std::io::Result<(
async fn permissions_profiles_allow_unknown_special_paths_with_nested_entries()
-> std::io::Result<()> {
let config = load_workspace_permission_profile(PermissionProfileToml {
description: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
@@ -2565,6 +2583,7 @@ async fn permissions_profiles_allow_unknown_special_paths_with_nested_entries()
#[tokio::test]
async fn permissions_profiles_allow_missing_filesystem_with_warning() -> std::io::Result<()> {
let config = load_workspace_permission_profile(PermissionProfileToml {
description: None,
workspace_roots: None,
filesystem: None,
network: None,
@@ -2594,6 +2613,7 @@ async fn permissions_profiles_allow_missing_filesystem_with_warning() -> std::io
#[tokio::test]
async fn permissions_profiles_allow_empty_filesystem_with_warning() -> std::io::Result<()> {
let config = load_workspace_permission_profile(PermissionProfileToml {
description: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
@@ -2630,6 +2650,7 @@ async fn permissions_profiles_reject_workspace_root_parent_traversal() -> std::i
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
description: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
@@ -2677,6 +2698,7 @@ async fn permissions_profiles_allow_network_enablement() -> std::io::Result<()>
entries: BTreeMap::from([(
"workspace".to_string(),
PermissionProfileToml {
description: None,
workspace_roots: None,
filesystem: Some(FilesystemPermissionsToml {
glob_scan_max_depth: None,
+7 -4
View File
@@ -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 {
@@ -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,
+6 -15
View File
@@ -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,
+55 -2
View File
@@ -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<String>,
}
async fn warm_plugins_and_skills_for_session_init(
config: Arc<Config>,
environment_manager: Arc<EnvironmentManager>,
plugins_manager: Arc<PluginsManager>,
skills_manager: Arc<SkillsManager>,
environments: Vec<TurnEnvironmentSelection>,
) -> Vec<SkillError> {
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| {
+14 -5
View File
@@ -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<u8>,
pub truncation_policy: TruncationPolicy,
pub max_output_tokens: Option<usize>,
pub process_id: Option<i32>,
pub exit_code: Option<i32>,
@@ -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")
}
+1
View File
@@ -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),
@@ -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<usize>,
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<String>,
@@ -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<ToolInvocation> 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<ToolInvocation> 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<ToolInvocation> 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<ToolInvocation> 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,
@@ -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<ToolInvocation> 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<ToolInvocation> 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))
}
@@ -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),
+2
View File
@@ -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<usize>,
pub truncation_policy: TruncationPolicy,
}
#[derive(Default)]
+31 -8
View File
@@ -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
@@ -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,
+212 -25
View File
@@ -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(())
}
@@ -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];
+1
View File
@@ -13,6 +13,7 @@ doctest = false
workspace = true
[dependencies]
codex-utils-absolute-path = { workspace = true }
codex-utils-home-dir = { workspace = true }
[dev-dependencies]
+311 -61
View File
@@ -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<AbsolutePathBuf>,
/// Folder that should be prepended to the PATH, when present.
pub path_dir: Option<AbsolutePathBuf>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InstallContext {
pub method: InstallMethod,
pub package_layout: Option<CodexPackageLayout>,
}
#[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<PathBuf>,
/// 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<AbsolutePathBuf>,
/// 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<Self> {
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<InstallContext> {
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<InstallMethod> {
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<AbsolutePathBuf> {
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<AbsolutePathBuf> {
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,
}
);
}
}
@@ -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
@@ -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;
+42 -20
View File
@@ -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<Self> {
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<UpdateAction> {
#[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)
);