mirror of
https://github.com/openai/codex.git
synced 2026-10-23 18:08:45 +00:00
Merge af339f3a15 into sapling-pr-archive-bolinfest
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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]
|
||||
|
||||
Generated
+1
@@ -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": {
|
||||
|
||||
+97
@@ -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"
|
||||
],
|
||||
|
||||
+97
@@ -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"
|
||||
],
|
||||
|
||||
+30
@@ -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"
|
||||
}
|
||||
+44
@@ -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
+17
@@ -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, };
|
||||
+11
@@ -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, };
|
||||
+13
@@ -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, };
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -2002,6 +2002,9 @@
|
||||
"PermissionProfileToml": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"filesystem": {
|
||||
"$ref": "#/definitions/FilesystemPermissionsToml"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ doctest = false
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-home-dir = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user