diff --git a/.bazelrc b/.bazelrc index 911e0802b5..6357eb83e4 100644 --- a/.bazelrc +++ b/.bazelrc @@ -193,5 +193,10 @@ common --@v8//:v8_enable_sandbox=True common:v8-release-compat --@v8//:v8_enable_pointer_compression=False common:v8-release-compat --@v8//:v8_enable_sandbox=False +# Match rusty_v8's upstream GN release contract for published artifacts: every +# target object uses Chromium's custom libc++ headers and the archive folds in +# the matching runtime objects. +common:rusty-v8-upstream-libcxx --@v8//:v8_use_rusty_v8_custom_libcxx=True + # Optional per-user local overrides. try-import %workspace%/user.bazelrc diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml index f67f1983f2..4595a1fa65 100644 --- a/.codex/environments/environment.toml +++ b/.codex/environments/environment.toml @@ -8,4 +8,4 @@ script = "" [[actions]] name = "Run" icon = "run" -command = "cargo +1.93.0 run --manifest-path=codex-rs/Cargo.toml --bin codex -- -c mcp_oauth_credentials_store=file" +command = "cargo +1.95.0 run --manifest-path=codex-rs/Cargo.toml --bin codex -- -c mcp_oauth_credentials_store=file" diff --git a/.codex/skills/update-v8-version/SKILL.md b/.codex/skills/update-v8-version/SKILL.md new file mode 100644 index 0000000000..512cc0d5a7 --- /dev/null +++ b/.codex/skills/update-v8-version/SKILL.md @@ -0,0 +1,72 @@ +--- +name: update-v8-version +description: Update Codex's pinned `v8` / `rusty_v8` versions, validate the release-candidate path, and investigate failed V8 canary or artifact builds. Use when asked to bump V8, update `rusty_v8` artifacts, prepare or validate a V8 release candidate, check `v8-canary`, or diagnose why a V8 version update no longer builds. +--- + +# Update V8 Version + +## Core Workflow + +1. Read `third_party/v8/README.md` and follow its version-bump sequence. Treat + that document as the release-process source of truth. +2. Inspect and update the concrete repo surfaces that carry the pin: + - `codex-rs/Cargo.toml` + - `codex-rs/Cargo.lock` + - `MODULE.bazel` + - `third_party/v8/BUILD.bazel` + - `third_party/v8/README.md` + - the matching `third_party/v8/rusty_v8_.sha256` manifest when the + remaining prebuilt inputs change +3. Keep the existing checksum helpers in the loop: + + ```bash + python3 .github/scripts/rusty_v8_bazel.py update-module-bazel + python3 .github/scripts/rusty_v8_bazel.py check-module-bazel + python3 -m unittest discover -s .github/scripts -p test_rusty_v8_bazel.py + ``` + +4. Validate the release-candidate path before broadening the work: + - Prefer checking the `v8-canary` CI result for the candidate branch or PR + when one exists, using GitHub check tooling or `gh` as appropriate. + - If CI is unavailable or the user asked for a local-only check, run the + closest local validation that is practical for the changed surface and say + explicitly that it is a local substitute, not the full hosted canary. +5. If the canary path passes, stop there. Summarize the result and encourage the + user to commit the candidate changes or proceed with the release flow they + requested. Do not publish tags, releases, or pushes unless the user asked. + +## Failure Path + +Enter this path only when the canary or local build path fails. + +1. Capture the failing target, workflow job, and first actionable error. +2. Compare the currently pinned version with the target version at the relevant + upstream tag or SHA. Inspect both: + - `denoland/rusty_v8` + - upstream V8 source at the target Bazel-pinned version +3. Track build-relevant deltas rather than broad source churn: + - generated binding layout changes + - archive or asset naming changes + - GN/Bazel target changes + - custom libc++ / libc++abi / llvm-libc inputs + - sandbox or pointer-compression feature relationships + - patch hunks in `patches/` that no longer apply or no longer match upstream +4. Trace each failing delta back into Codex's build graph: + - `MODULE.bazel` + - `third_party/v8/BUILD.bazel` + - `.github/scripts/rusty_v8_bazel.py` + - `.github/workflows/v8-canary.yml` + - `.github/workflows/rusty-v8-release.yml` +5. Update only the pieces required to restore the target version's build and + artifact contract. Keep patch explanations and doc changes close to the + affected files. +6. Re-run the focused validation. If it becomes green, return to the normal + workflow and stop with a concise summary plus the remaining release step. + +## Reporting + +- Say whether validation came from hosted `v8-canary` or from a local + substitute. +- Distinguish "version bump complete" from "release published". +- When blocked, report the upstream delta that matters, the Codex file it hits, + and the next concrete fix to try. diff --git a/.codex/skills/update-v8-version/agents/openai.yaml b/.codex/skills/update-v8-version/agents/openai.yaml new file mode 100644 index 0000000000..36e7af80d8 --- /dev/null +++ b/.codex/skills/update-v8-version/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Update V8 Version" + short_description: "Guide V8 bumps and release validation" + default_prompt: "Use $update-v8-version to update Codex to a new v8 release and validate the release-candidate path." diff --git a/.devcontainer/Dockerfile.secure b/.devcontainer/Dockerfile.secure index 6c1878eafa..83af317d8b 100644 --- a/.devcontainer/Dockerfile.secure +++ b/.devcontainer/Dockerfile.secure @@ -3,7 +3,7 @@ FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04 ARG TZ ARG DEBIAN_FRONTEND=noninteractive ARG NODE_MAJOR=22 -ARG RUST_TOOLCHAIN=1.92.0 +ARG RUST_TOOLCHAIN=1.95.0 # Keep this in sync with .devcontainer/codex-install/package.json and pnpm-lock.yaml. ARG CODEX_NPM_VERSION=0.121.0 diff --git a/.devcontainer/devcontainer.secure.json b/.devcontainer/devcontainer.secure.json index 5d5808e541..7b05fb9a6c 100644 --- a/.devcontainer/devcontainer.secure.json +++ b/.devcontainer/devcontainer.secure.json @@ -7,7 +7,7 @@ "args": { "TZ": "${localEnv:TZ:UTC}", "NODE_MAJOR": "22", - "RUST_TOOLCHAIN": "1.92.0", + "RUST_TOOLCHAIN": "1.95.0", "CODEX_NPM_VERSION": "0.121.0" } }, diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 322aad46f4..668897c48e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,6 +1,7 @@ # Core crate ownership. /codex-rs/core/ @openai/codex-core-agent-team /codex-rs/ext/extension-api/ @openai/codex-core-agent-team +/codex-rs/prompts/ @openai/codex-core-agent-team # Keep ownership changes reviewed by the same team. /.github/CODEOWNERS @openai/codex-core-agent-team diff --git a/.github/actions/setup-msvc-env/action.yml b/.github/actions/setup-msvc-env/action.yml new file mode 100644 index 0000000000..287cb7f217 --- /dev/null +++ b/.github/actions/setup-msvc-env/action.yml @@ -0,0 +1,17 @@ +name: setup-msvc-env +description: Expose an MSVC developer environment for the requested Windows target. +inputs: + target: + description: Rust target triple that will be built on this Windows runner. + required: true + host-arch: + description: Optional Visual Studio host architecture override. + required: false + default: "" + +runs: + using: composite + steps: + - name: Expose MSVC SDK environment + shell: pwsh + run: '& "$env:GITHUB_ACTION_PATH/setup-msvc-env.ps1" -Target "${{ inputs.target }}" -HostArch "${{ inputs.host-arch }}"' diff --git a/.github/actions/setup-msvc-env/setup-msvc-env.ps1 b/.github/actions/setup-msvc-env/setup-msvc-env.ps1 new file mode 100644 index 0000000000..e2706d9117 --- /dev/null +++ b/.github/actions/setup-msvc-env/setup-msvc-env.ps1 @@ -0,0 +1,257 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Target, + + [string]$HostArch = "" +) + +# Cargo can cross-compile the Rust code for Windows ARM64 on a Windows x64 +# runner, but rustup alone does not expose the matching MSVC/UCRT include and +# library paths. Ask Visual Studio for the target-specific developer +# environment, then persist the relevant variables through GITHUB_ENV so the +# later Cargo step sees the same environment as a normal VsDevCmd shell. +switch ($Target) { + "x86_64-pc-windows-msvc" { + $TargetArch = "x64" + $RequiredComponent = "Microsoft.VisualStudio.Component.VC.Tools.x86.x64" + } + "aarch64-pc-windows-msvc" { + $TargetArch = "arm64" + $RequiredComponent = "Microsoft.VisualStudio.Component.VC.Tools.ARM64" + } + default { + throw "Unsupported Windows MSVC target: $Target" + } +} + +# VsDevCmd needs both sides of the cross compile: the architecture of the +# machine running the tools and the architecture of the binaries being linked. +# Infer the host from the runner unless a caller needs to override it. +if (-not $HostArch) { + $HostArch = if ($env:PROCESSOR_ARCHITEW6432 -eq "ARM64" -or $env:PROCESSOR_ARCHITECTURE -eq "ARM64") { + "arm64" + } else { + "x64" + } +} + +$VsWhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" +if (-not (Test-Path $VsWhere)) { + throw "vswhere.exe not found" +} + +# Require the target VC tools component, not merely any Visual Studio install, +# so an x64 archive producer cannot silently link ARM64 tests with the wrong +# SDK/toolchain layout. +$InstallPath = & $VsWhere -latest -products * -requires $RequiredComponent -property installationPath 2>$null +if (-not $InstallPath) { + throw "Could not locate a Visual Studio installation with component $RequiredComponent" +} + +$VsDevCmd = Join-Path $InstallPath "Common7\Tools\VsDevCmd.bat" +if (-not (Test-Path $VsDevCmd)) { + throw "VsDevCmd.bat not found at $VsDevCmd" +} + +$VarsToExport = @( + "INCLUDE", + "LIB", + "LIBPATH", + "PATH", + "UCRTVersion", + "UniversalCRTSdkDir", + "VCINSTALLDIR", + "VCToolsInstallDir", + "WindowsLibPath", + "WindowsSdkBinPath", + "WindowsSdkDir", + "WindowsSDKLibVersion", + "WindowsSDKVersion" +) + +# Run VsDevCmd inside cmd.exe because it is a batch file, then copy just the +# variables Cargo/rustc need into the GitHub Actions environment file. PowerShell +# cannot mutate the parent composite-action environment directly. +$EnvLines = & cmd.exe /c ('"{0}" -no_logo -arch={1} -host_arch={2} >nul && set' -f $VsDevCmd, $TargetArch, $HostArch) +$VcToolsInstallDir = $null +foreach ($Line in $EnvLines) { + if ($Line -notmatch "^(.*?)=(.*)$") { + continue + } + + $Name = $Matches[1] + $Value = $Matches[2] + if ($VarsToExport -contains $Name) { + if ($Name -ieq "Path") { + $Name = "PATH" + } + if ($Name -eq "VCToolsInstallDir") { + $VcToolsInstallDir = $Value + } + "$Name=$Value" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + } +} + +if (-not $VcToolsInstallDir) { + throw "VCToolsInstallDir was not exported by VsDevCmd.bat" +} + +# Prefer Rust's bundled linker when rustup provides one, then Visual Studio's +# LLVM linker, and finally MSVC link.exe. This keeps the cross-compile path close +# to Rust's normal Windows MSVC behavior while still working on runner images +# where one of those linkers is absent. +$Linker = $null +$Rustc = Get-Command rustc -ErrorAction SilentlyContinue +if ($Rustc) { + $Sysroot = (& rustc --print sysroot 2>$null).Trim() + $RustHost = & rustc -vV 2>$null | Select-String "^host: " | ForEach-Object { $_.Line.Substring(6) } + if ($RustHost) { + $RustHost = $RustHost.Trim() + } + if ($Sysroot -and $RustHost) { + $RustLld = Join-Path $Sysroot "lib\rustlib\$RustHost\bin\rust-lld.exe" + if (Test-Path $RustLld) { + $Linker = $RustLld + } + } +} +if (-not $Linker) { + $Linker = Join-Path $InstallPath "VC\Tools\Llvm\x64\bin\lld-link.exe" +} +if (-not (Test-Path $Linker)) { + $Linker = Join-Path $VcToolsInstallDir "bin\Host${HostArch}\${TargetArch}\link.exe" +} +if (-not (Test-Path $Linker)) { + throw "Windows linker not found at $Linker" +} + +# rustc passes `/arm64hazardfree` for ARM64 MSVC links. The lld variants on our +# Windows x64 archive producers reject that flag, including when rustc places it +# inside a response file. Compile a tiny forwarding wrapper that strips only +# that unsupported flag, then delegate every other argument to the real linker. +if ($TargetArch -eq "arm64" -and (Split-Path -Leaf $Linker) -match "lld") { + $WrapperDir = Join-Path $env:RUNNER_TEMP "msvc-lld-wrapper" + New-Item -Path $WrapperDir -ItemType Directory -Force | Out-Null + $WrapperPath = Join-Path $WrapperDir "lld-link-wrapper.exe" + $WrapperSource = @' +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Text.RegularExpressions; + +internal static class Program +{ + private static int Main(string[] args) + { + var linker = Environment.GetEnvironmentVariable("MSVC_REAL_LINKER"); + if (string.IsNullOrEmpty(linker)) + { + Console.Error.WriteLine("MSVC_REAL_LINKER is not set"); + return 1; + } + + var startInfo = new ProcessStartInfo(linker) + { + UseShellExecute = false, + }; + var filteredArgs = new List { "-flavor", "link", "/defaultlib:ucrt", "/nodefaultlib:libucrt" }; + foreach (var arg in args) + { + if (!string.Equals(arg, "/arm64hazardfree", StringComparison.OrdinalIgnoreCase)) + { + filteredArgs.Add(QuoteArgument(FilterResponseFile(arg))); + } + } + startInfo.Arguments = string.Join(" ", filteredArgs); + + using var process = Process.Start(startInfo); + if (process is null) + { + Console.Error.WriteLine($"Failed to start linker: {linker}"); + return 1; + } + + process.WaitForExit(); + return process.ExitCode; + } + + private static string FilterResponseFile(string argument) + { + if (argument.Length < 2 || argument[0] != '@') + { + return argument; + } + + var responsePath = argument.Substring(1); + if (!File.Exists(responsePath)) + { + return argument; + } + + var filteredResponsePath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".rsp"); + var responseContents = Regex.Replace( + File.ReadAllText(responsePath), + "/arm64hazardfree", + string.Empty, + RegexOptions.IgnoreCase); + File.WriteAllText(filteredResponsePath, responseContents); + return "@" + filteredResponsePath; + } + + private static string QuoteArgument(string argument) + { + if (argument.Length == 0) + { + return "\"\""; + } + if (argument.IndexOfAny(new[] { ' ', '\t', '"' }) < 0) + { + return argument; + } + + var quoted = new StringBuilder("\""); + var backslashes = 0; + foreach (var character in argument) + { + if (character == '\\') + { + backslashes++; + continue; + } + if (character == '"') + { + quoted.Append('\\', (backslashes * 2) + 1); + quoted.Append(character); + backslashes = 0; + continue; + } + + quoted.Append('\\', backslashes); + backslashes = 0; + quoted.Append(character); + } + quoted.Append('\\', backslashes * 2); + quoted.Append('"'); + return quoted.ToString(); + } +} +'@ + $WrapperSourcePath = Join-Path $WrapperDir "lld-link-wrapper.cs" + $WrapperSource | Out-File -FilePath $WrapperSourcePath -Encoding utf8 + $Csc = Join-Path $InstallPath "MSBuild\Current\Bin\Roslyn\csc.exe" + if (-not (Test-Path $Csc)) { + throw "csc.exe not found at $Csc" + } + & $Csc /nologo /target:exe /out:$WrapperPath $WrapperSourcePath + if ($LASTEXITCODE -ne 0) { + throw "Failed to compile lld-link wrapper" + } + "MSVC_REAL_LINKER=$Linker" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + $Linker = $WrapperPath +} + +Write-Output "Using Windows linker: $Linker" +$CargoTarget = $Target.ToUpperInvariant().Replace("-", "_") +"CARGO_TARGET_${CargoTarget}_LINKER=$Linker" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append diff --git a/.github/actions/setup-rusty-v8-musl/action.yml b/.github/actions/setup-rusty-v8/action.yml similarity index 60% rename from .github/actions/setup-rusty-v8-musl/action.yml rename to .github/actions/setup-rusty-v8/action.yml index 871c73a268..d9c4484657 100644 --- a/.github/actions/setup-rusty-v8-musl/action.yml +++ b/.github/actions/setup-rusty-v8/action.yml @@ -1,29 +1,20 @@ -name: setup-rusty-v8-musl -description: Download and verify musl rusty_v8 artifacts for Cargo builds. +name: setup-rusty-v8 +description: Download and verify Codex-built rusty_v8 artifacts for Cargo builds. inputs: target: - description: Rust musl target triple. + description: Rust target triple with Codex-built V8 release artifacts. required: true runs: using: composite steps: - - name: Configure musl rusty_v8 artifact overrides and verify checksums + - name: Configure rusty_v8 artifact overrides and verify checksums shell: bash env: TARGET: ${{ inputs.target }} run: | set -euo pipefail - case "${TARGET}" in - x86_64-unknown-linux-musl|aarch64-unknown-linux-musl) - ;; - *) - echo "Unsupported musl rusty_v8 target: ${TARGET}" >&2 - exit 1 - ;; - esac - version="$(python3 "${GITHUB_WORKSPACE}/.github/scripts/rusty_v8_bazel.py" resolved-v8-crate-version)" release_tag="rusty-v8-v${version}" base_url="https://github.com/openai/codex/releases/download/${release_tag}" @@ -31,19 +22,21 @@ runs: archive_path="${binding_dir}/librusty_v8_release_${TARGET}.a.gz" binding_path="${binding_dir}/src_binding_release_${TARGET}.rs" checksums_path="${binding_dir}/rusty_v8_release_${TARGET}.sha256" - checksums_source="${GITHUB_WORKSPACE}/third_party/v8/rusty_v8_${version//./_}.sha256" mkdir -p "${binding_dir}" curl -fsSL "${base_url}/librusty_v8_release_${TARGET}.a.gz" -o "${archive_path}" curl -fsSL "${base_url}/src_binding_release_${TARGET}.rs" -o "${binding_path}" - grep -E " (librusty_v8_release_${TARGET}[.]a[.]gz|src_binding_release_${TARGET}[.]rs)$" \ - "${checksums_source}" > "${checksums_path}" + curl -fsSL "${base_url}/rusty_v8_release_${TARGET}.sha256" -o "${checksums_path}" if [[ "$(wc -l < "${checksums_path}")" -ne 2 ]]; then - echo "Expected exactly two checksums for ${TARGET} in ${checksums_source}" >&2 + echo "Expected exactly two checksums for ${TARGET} in ${checksums_path}" >&2 exit 1 fi - (cd "${binding_dir}" && sha256sum -c "${checksums_path}") + if command -v sha256sum >/dev/null 2>&1; then + (cd "${binding_dir}" && sha256sum -c "${checksums_path}") + else + (cd "${binding_dir}" && shasum -a 256 -c "${checksums_path}") + fi echo "RUSTY_V8_ARCHIVE=${archive_path}" >> "${GITHUB_ENV}" echo "RUSTY_V8_SRC_BINDING_PATH=${binding_path}" >> "${GITHUB_ENV}" diff --git a/.github/dotslash-config.json b/.github/dotslash-config.json index a0297c269a..78ea6b37e6 100644 --- a/.github/dotslash-config.json +++ b/.github/dotslash-config.json @@ -3,56 +3,56 @@ "codex": { "platforms": { "macos-aarch64": { - "regex": "^codex-aarch64-apple-darwin\\.zst$", - "path": "codex" + "regex": "^codex-package-aarch64-apple-darwin\\.tar\\.zst$", + "path": "bin/codex" }, "macos-x86_64": { - "regex": "^codex-x86_64-apple-darwin\\.zst$", - "path": "codex" + "regex": "^codex-package-x86_64-apple-darwin\\.tar\\.zst$", + "path": "bin/codex" }, "linux-x86_64": { - "regex": "^codex-x86_64-unknown-linux-musl-bundle\\.tar\\.zst$", - "path": "codex" + "regex": "^codex-package-x86_64-unknown-linux-musl\\.tar\\.zst$", + "path": "bin/codex" }, "linux-aarch64": { - "regex": "^codex-aarch64-unknown-linux-musl-bundle\\.tar\\.zst$", - "path": "codex" + "regex": "^codex-package-aarch64-unknown-linux-musl\\.tar\\.zst$", + "path": "bin/codex" }, "windows-x86_64": { - "regex": "^codex-x86_64-pc-windows-msvc\\.exe\\.zst$", - "path": "codex.exe" + "regex": "^codex-package-x86_64-pc-windows-msvc\\.tar\\.zst$", + "path": "bin/codex.exe" }, "windows-aarch64": { - "regex": "^codex-aarch64-pc-windows-msvc\\.exe\\.zst$", - "path": "codex.exe" + "regex": "^codex-package-aarch64-pc-windows-msvc\\.tar\\.zst$", + "path": "bin/codex.exe" } } }, "codex-app-server": { "platforms": { "macos-aarch64": { - "regex": "^codex-app-server-aarch64-apple-darwin\\.zst$", - "path": "codex-app-server" + "regex": "^codex-app-server-package-aarch64-apple-darwin\\.tar\\.zst$", + "path": "bin/codex-app-server" }, "macos-x86_64": { - "regex": "^codex-app-server-x86_64-apple-darwin\\.zst$", - "path": "codex-app-server" + "regex": "^codex-app-server-package-x86_64-apple-darwin\\.tar\\.zst$", + "path": "bin/codex-app-server" }, "linux-x86_64": { - "regex": "^codex-app-server-x86_64-unknown-linux-musl\\.zst$", - "path": "codex-app-server" + "regex": "^codex-app-server-package-x86_64-unknown-linux-musl\\.tar\\.zst$", + "path": "bin/codex-app-server" }, "linux-aarch64": { - "regex": "^codex-app-server-aarch64-unknown-linux-musl\\.zst$", - "path": "codex-app-server" + "regex": "^codex-app-server-package-aarch64-unknown-linux-musl\\.tar\\.zst$", + "path": "bin/codex-app-server" }, "windows-x86_64": { - "regex": "^codex-app-server-x86_64-pc-windows-msvc\\.exe\\.zst$", - "path": "codex-app-server.exe" + "regex": "^codex-app-server-package-x86_64-pc-windows-msvc\\.tar\\.zst$", + "path": "bin/codex-app-server.exe" }, "windows-aarch64": { - "regex": "^codex-app-server-aarch64-pc-windows-msvc\\.exe\\.zst$", - "path": "codex-app-server.exe" + "regex": "^codex-app-server-package-aarch64-pc-windows-msvc\\.tar\\.zst$", + "path": "bin/codex-app-server.exe" } } }, diff --git a/.github/dotslash-unsigned-config.json b/.github/dotslash-unsigned-config.json deleted file mode 100644 index 65c44d5e8d..0000000000 --- a/.github/dotslash-unsigned-config.json +++ /dev/null @@ -1,124 +0,0 @@ -{ - "outputs": { - "codex-unsigned": { - "platforms": { - "macos-aarch64": { - "regex": "^codex-aarch64-apple-darwin-unsigned\\.zst$", - "path": "codex" - }, - "macos-x86_64": { - "regex": "^codex-x86_64-apple-darwin-unsigned\\.zst$", - "path": "codex" - }, - "linux-x86_64": { - "regex": "^codex-x86_64-unknown-linux-musl-bundle\\.tar\\.zst$", - "path": "codex" - }, - "linux-aarch64": { - "regex": "^codex-aarch64-unknown-linux-musl-bundle\\.tar\\.zst$", - "path": "codex" - }, - "windows-x86_64": { - "regex": "^codex-x86_64-pc-windows-msvc\\.exe\\.zst$", - "path": "codex.exe" - }, - "windows-aarch64": { - "regex": "^codex-aarch64-pc-windows-msvc\\.exe\\.zst$", - "path": "codex.exe" - } - } - }, - "codex-app-server-unsigned": { - "platforms": { - "macos-aarch64": { - "regex": "^codex-app-server-aarch64-apple-darwin-unsigned\\.zst$", - "path": "codex-app-server" - }, - "macos-x86_64": { - "regex": "^codex-app-server-x86_64-apple-darwin-unsigned\\.zst$", - "path": "codex-app-server" - }, - "linux-x86_64": { - "regex": "^codex-app-server-x86_64-unknown-linux-musl\\.zst$", - "path": "codex-app-server" - }, - "linux-aarch64": { - "regex": "^codex-app-server-aarch64-unknown-linux-musl\\.zst$", - "path": "codex-app-server" - }, - "windows-x86_64": { - "regex": "^codex-app-server-x86_64-pc-windows-msvc\\.exe\\.zst$", - "path": "codex-app-server.exe" - }, - "windows-aarch64": { - "regex": "^codex-app-server-aarch64-pc-windows-msvc\\.exe\\.zst$", - "path": "codex-app-server.exe" - } - } - }, - "codex-responses-api-proxy-unsigned": { - "platforms": { - "macos-aarch64": { - "regex": "^codex-responses-api-proxy-aarch64-apple-darwin-unsigned\\.zst$", - "path": "codex-responses-api-proxy" - }, - "macos-x86_64": { - "regex": "^codex-responses-api-proxy-x86_64-apple-darwin-unsigned\\.zst$", - "path": "codex-responses-api-proxy" - }, - "linux-x86_64": { - "regex": "^codex-responses-api-proxy-x86_64-unknown-linux-musl\\.zst$", - "path": "codex-responses-api-proxy" - }, - "linux-aarch64": { - "regex": "^codex-responses-api-proxy-aarch64-unknown-linux-musl\\.zst$", - "path": "codex-responses-api-proxy" - }, - "windows-x86_64": { - "regex": "^codex-responses-api-proxy-x86_64-pc-windows-msvc\\.exe\\.zst$", - "path": "codex-responses-api-proxy.exe" - }, - "windows-aarch64": { - "regex": "^codex-responses-api-proxy-aarch64-pc-windows-msvc\\.exe\\.zst$", - "path": "codex-responses-api-proxy.exe" - } - } - }, - "bwrap": { - "platforms": { - "linux-x86_64": { - "regex": "^bwrap-x86_64-unknown-linux-musl\\.zst$", - "path": "bwrap" - }, - "linux-aarch64": { - "regex": "^bwrap-aarch64-unknown-linux-musl\\.zst$", - "path": "bwrap" - } - } - }, - "codex-command-runner": { - "platforms": { - "windows-x86_64": { - "regex": "^codex-command-runner-x86_64-pc-windows-msvc\\.exe\\.zst$", - "path": "codex-command-runner.exe" - }, - "windows-aarch64": { - "regex": "^codex-command-runner-aarch64-pc-windows-msvc\\.exe\\.zst$", - "path": "codex-command-runner.exe" - } - } - }, - "codex-windows-sandbox-setup": { - "platforms": { - "windows-x86_64": { - "regex": "^codex-windows-sandbox-setup-x86_64-pc-windows-msvc\\.exe\\.zst$", - "path": "codex-windows-sandbox-setup.exe" - }, - "windows-aarch64": { - "regex": "^codex-windows-sandbox-setup-aarch64-pc-windows-msvc\\.exe\\.zst$", - "path": "codex-windows-sandbox-setup.exe" - } - } - } - } -} diff --git a/.github/dotslash-zsh-config.json b/.github/dotslash-zsh-config.json index db2c416401..37285f19e2 100644 --- a/.github/dotslash-zsh-config.json +++ b/.github/dotslash-zsh-config.json @@ -7,6 +7,11 @@ "format": "tar.gz", "path": "codex-zsh/bin/zsh" }, + "macos-x86_64": { + "name": "codex-zsh-x86_64-apple-darwin.tar.gz", + "format": "tar.gz", + "path": "codex-zsh/bin/zsh" + }, "linux-x86_64": { "name": "codex-zsh-x86_64-unknown-linux-musl.tar.gz", "format": "tar.gz", diff --git a/.github/scripts/archive-release-symbols-and-strip-binaries.sh b/.github/scripts/archive-release-symbols-and-strip-binaries.sh new file mode 100755 index 0000000000..3e5894bb99 --- /dev/null +++ b/.github/scripts/archive-release-symbols-and-strip-binaries.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: archive-release-symbols-and-strip-binaries.sh \ + --target \ + --artifact-name \ + --release-dir \ + --archive-dir \ + --binaries "" +EOF +} + +target="" +artifact_name="" +release_dir="" +archive_dir="" +binaries="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --target) + target="${2:?--target requires a value}" + shift 2 + ;; + --artifact-name) + artifact_name="${2:?--artifact-name requires a value}" + shift 2 + ;; + --release-dir) + release_dir="${2:?--release-dir requires a value}" + shift 2 + ;; + --archive-dir) + archive_dir="${2:?--archive-dir requires a value}" + shift 2 + ;; + --binaries) + binaries="${2:?--binaries requires a value}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unexpected argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ -z "$target" || -z "$artifact_name" || -z "$release_dir" || -z "$archive_dir" || -z "$binaries" ]]; then + usage >&2 + exit 1 +fi + +symbols_root="${RUNNER_TEMP:-/tmp}/codex-symbols-${artifact_name}" +symbols_dir="${symbols_root}/codex-symbols-${artifact_name}" +archive_path="${archive_dir%/}/codex-symbols-${artifact_name}.tar.gz" +rm -rf "$symbols_root" +mkdir -p "$symbols_dir" "$archive_dir" +read -r -a binary_names <<< "$binaries" + +case "$target" in + *apple-darwin) + for binary in "${binary_names[@]}"; do + binary_path="${release_dir%/}/${binary}" + dsym_path="${binary_path}.dSYM" + if [[ ! -f "$binary_path" ]]; then + echo "Binary $binary_path not found" >&2 + exit 1 + fi + if [[ ! -d "$dsym_path" ]]; then + echo "dSYM $dsym_path not found" >&2 + exit 1 + fi + + cp -RL "$dsym_path" "${symbols_dir}/${binary}.dSYM" + strip -S -x "$binary_path" + done + ;; + *linux*) + objcopy_bin="${OBJCOPY:-objcopy}" + strip_bin="${STRIP:-strip}" + for binary in "${binary_names[@]}"; do + binary_path="${release_dir%/}/${binary}" + debug_path="${symbols_dir}/${binary}.debug" + if [[ ! -f "$binary_path" ]]; then + echo "Binary $binary_path not found" >&2 + exit 1 + fi + + "$objcopy_bin" --only-keep-debug "$binary_path" "$debug_path" + "$strip_bin" --strip-debug --strip-unneeded "$binary_path" + "$objcopy_bin" --add-gnu-debuglink="$debug_path" "$binary_path" + done + ;; + *windows*) + for binary in "${binary_names[@]}"; do + pdb_path="${release_dir%/}/${binary}.pdb" + if [[ ! -f "$pdb_path" ]]; then + echo "PDB $pdb_path not found" >&2 + exit 1 + fi + + cp "$pdb_path" "${symbols_dir}/${binary}.pdb" + done + ;; + *) + echo "No symbols packaging support for target: $target" >&2 + exit 1 + ;; +esac + +rm -f "$archive_path" +tar -C "$symbols_root" -czf "$archive_path" "codex-symbols-${artifact_name}" diff --git a/.github/scripts/build-codex-package-archive.sh b/.github/scripts/build-codex-package-archive.sh new file mode 100644 index 0000000000..80da4cf20c --- /dev/null +++ b/.github/scripts/build-codex-package-archive.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: build-codex-package-archive.sh \ + --target \ + --bundle \ + --entrypoint-dir \ + --archive-dir \ + [--bwrap-bin ] \ + [--codex-command-runner-bin ] \ + [--codex-windows-sandbox-setup-bin ] \ + [--target-suffixed-entrypoint] +EOF +} + +target="" +bundle="" +entrypoint_dir="" +archive_dir="" +target_suffixed_entrypoint="false" +resource_args=() +bwrap_bin_provided="false" +command_runner_bin_provided="false" +sandbox_setup_bin_provided="false" + +while [[ $# -gt 0 ]]; do + case "$1" in + --target) + target="${2:?--target requires a value}" + shift 2 + ;; + --bundle) + bundle="${2:?--bundle requires a value}" + shift 2 + ;; + --entrypoint-dir) + entrypoint_dir="${2:?--entrypoint-dir requires a value}" + shift 2 + ;; + --archive-dir) + archive_dir="${2:?--archive-dir requires a value}" + shift 2 + ;; + --bwrap-bin) + resource_args+=(--bwrap-bin "${2:?--bwrap-bin requires a value}") + bwrap_bin_provided="true" + shift 2 + ;; + --codex-command-runner-bin) + resource_args+=( + --codex-command-runner-bin + "${2:?--codex-command-runner-bin requires a value}" + ) + command_runner_bin_provided="true" + shift 2 + ;; + --codex-windows-sandbox-setup-bin) + resource_args+=( + --codex-windows-sandbox-setup-bin + "${2:?--codex-windows-sandbox-setup-bin requires a value}" + ) + sandbox_setup_bin_provided="true" + shift 2 + ;; + --target-suffixed-entrypoint) + target_suffixed_entrypoint="true" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unexpected argument: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ -z "$target" || -z "$bundle" || -z "$entrypoint_dir" || -z "$archive_dir" ]]; then + usage >&2 + exit 1 +fi + +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" >&2 + exit 1 + ;; +esac + +exe_suffix="" +case "$target" in + *windows*) + exe_suffix=".exe" + ;; +esac + +entrypoint_name="$entrypoint" +if [[ "$target_suffixed_entrypoint" == "true" ]]; then + entrypoint_name="${entrypoint_name}-${target}" +fi + +case "$target" in + *linux*) + bwrap_bin="${entrypoint_dir%/}/bwrap" + if [[ "$bwrap_bin_provided" == "false" && -f "$bwrap_bin" ]]; then + resource_args+=(--bwrap-bin "$bwrap_bin") + fi + ;; + *windows*) + command_runner_bin="${entrypoint_dir%/}/codex-command-runner.exe" + sandbox_setup_bin="${entrypoint_dir%/}/codex-windows-sandbox-setup.exe" + if [[ "$command_runner_bin_provided" == "false" && -f "$command_runner_bin" ]]; then + resource_args+=(--codex-command-runner-bin "$command_runner_bin") + fi + if [[ "$sandbox_setup_bin_provided" == "false" && -f "$sandbox_setup_bin" ]]; then + resource_args+=(--codex-windows-sandbox-setup-bin "$sandbox_setup_bin") + fi + ;; +esac + +repo_root="${GITHUB_WORKSPACE:-}" +if [[ -z "$repo_root" ]]; then + repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +fi + +if command -v python3 >/dev/null 2>&1; then + python_bin="python3" +else + python_bin="python" +fi + +if ! command -v zstd >/dev/null 2>&1 && [[ -x "${repo_root}/.github/workflows/zstd" ]]; then + export PATH="${repo_root}/.github/workflows:${PATH}" +fi + +mkdir -p "$archive_dir" +package_dir="${RUNNER_TEMP:-/tmp}/${archive_stem}-${target}" +gzip_archive_path="${archive_dir}/${archive_stem}-${target}.tar.gz" +zstd_archive_path="${archive_dir}/${archive_stem}-${target}.tar.zst" +rm -rf "$package_dir" + +python_args=( + "${repo_root}/scripts/build_codex_package.py" + --target "$target" + --variant "$variant" + --entrypoint-bin "${entrypoint_dir%/}/${entrypoint_name}${exe_suffix}" + --cargo-profile release + --package-dir "$package_dir" + --archive-output "$gzip_archive_path" + --archive-output "$zstd_archive_path" +) +if ((${#resource_args[@]} > 0)); then + python_args+=("${resource_args[@]}") +fi +python_args+=(--force) + +"$python_bin" "${python_args[@]}" diff --git a/.github/scripts/install-musl-build-tools.sh b/.github/scripts/install-musl-build-tools.sh index e4c6683d0e..49035f5391 100644 --- a/.github/scripts/install-musl-build-tools.sh +++ b/.github/scripts/install-musl-build-tools.sh @@ -150,7 +150,9 @@ for arg in "\$@"; do args+=("\${arg}") done -exec "${zig_bin}" cc -target "${zig_target}" "\${args[@]}" +# Zig enables UBSan for debug C builds by default. Rust links these objects +# without Zig's sanitizer runtime, so keep native dependencies uninstrumented. +exec "${zig_bin}" cc -target "${zig_target}" "\${args[@]}" -fno-sanitize=undefined EOF cat >"${cxx}" <> "$GITHUB_ENV" pkg_config_path_var="PKG_CONFIG_PATH_${TARGET}" pkg_config_path_var="${pkg_config_path_var//-/_}" echo "${pkg_config_path_var}=${libcap_pkgconfig_dir}" >> "$GITHUB_ENV" +pkg_config_libdir_var="PKG_CONFIG_LIBDIR_${TARGET}" +pkg_config_libdir_var="${pkg_config_libdir_var//-/_}" +# Do not let musl cross-builds resolve native libraries from the host glibc +# pkg-config directories. libcap is the only target package provided here. +echo "${pkg_config_libdir_var}=${libcap_pkgconfig_dir}" >> "$GITHUB_ENV" if [[ -n "${sysroot}" && "${sysroot}" != "/" ]]; then echo "PKG_CONFIG_SYSROOT_DIR=${sysroot}" >> "$GITHUB_ENV" diff --git a/.github/scripts/rusty_v8_bazel.py b/.github/scripts/rusty_v8_bazel.py index 5ad6d3c408..2f46daf45e 100644 --- a/.github/scripts/rusty_v8_bazel.py +++ b/.github/scripts/rusty_v8_bazel.py @@ -5,17 +5,18 @@ from __future__ import annotations import argparse import gzip import hashlib +import os import re import shutil import subprocess import sys -import tempfile import tomllib from pathlib import Path from rusty_v8_module_bazel import ( RustyV8ChecksumError, check_module_bazel, + rusty_v8_http_file_versions, update_module_bazel, ) @@ -23,12 +24,16 @@ from rusty_v8_module_bazel import ( ROOT = Path(__file__).resolve().parents[2] MODULE_BAZEL = ROOT / "MODULE.bazel" RUSTY_V8_CHECKSUMS_DIR = ROOT / "third_party" / "v8" -MUSL_RUNTIME_ARCHIVE_LABELS = [ - "@llvm//runtimes/libcxx:libcxx.static", - "@llvm//runtimes/libcxx:libcxxabi.static", -] -LLVM_AR_LABEL = "@llvm//tools:llvm-ar" -LLVM_RANLIB_LABEL = "@llvm//tools:llvm-ranlib" +RELEASE_ARTIFACT_PROFILE = "release" +SANDBOX_ARTIFACT_PROFILE = "ptrcomp_sandbox_release" +ARTIFACT_BAZEL_CONFIGS = ["rusty-v8-upstream-libcxx"] + + +def bazel_remote_args() -> list[str]: + buildbuddy_api_key = os.environ.get("BUILDBUDDY_API_KEY") + if not buildbuddy_api_key: + return [] + return [f"--remote_header=x-buildbuddy-api-key={buildbuddy_api_key}"] def bazel_execroot() -> Path: @@ -75,6 +80,7 @@ def bazel_output_files( compilation_mode, f"--platforms=@llvm//platforms:{platform}", *[f"--config={config}" for config in bazel_configs], + *bazel_remote_args(), "--output=files", expression, ], @@ -91,8 +97,10 @@ def bazel_build( labels: list[str], compilation_mode: str = "fastbuild", bazel_configs: list[str] | None = None, + download_toplevel: bool = False, ) -> None: bazel_configs = bazel_configs or [] + download_args = ["--remote_download_toplevel"] if download_toplevel else [] subprocess.run( [ "bazel", @@ -101,6 +109,8 @@ def bazel_build( compilation_mode, f"--platforms=@llvm//platforms:{platform}", *[f"--config={config}" for config in bazel_configs], + *bazel_remote_args(), + *download_args, *labels, ], cwd=ROOT, @@ -114,11 +124,15 @@ def ensure_bazel_output_files( compilation_mode: str = "fastbuild", bazel_configs: list[str] | None = None, ) -> list[Path]: - outputs = bazel_output_files(platform, labels, compilation_mode, bazel_configs) - if all(path.exists() for path in outputs): - return outputs - - bazel_build(platform, labels, compilation_mode, bazel_configs) + # Bazel output paths can be reused across config flips, so existence alone + # does not prove the files match the requested flags. + bazel_build( + platform, + labels, + compilation_mode, + bazel_configs, + download_toplevel=True, + ) outputs = bazel_output_files(platform, labels, compilation_mode, bazel_configs) missing = [str(path) for path in outputs if not path.exists()] if missing: @@ -126,9 +140,18 @@ def ensure_bazel_output_files( return outputs -def release_pair_label(target: str) -> str: +def artifact_bazel_configs(bazel_configs: list[str] | None = None) -> list[str]: + configured = list(ARTIFACT_BAZEL_CONFIGS) + for config in bazel_configs or []: + if config not in configured: + configured.append(config) + return configured + + +def release_pair_label(target: str, sandbox: bool = False) -> str: target_suffix = target.replace("-", "_") - return f"//third_party/v8:rusty_v8_release_pair_{target_suffix}" + pair_kind = "sandbox_release_pair" if sandbox else "release_pair" + return f"//third_party/v8:rusty_v8_{pair_kind}_{target_suffix}" def resolved_v8_crate_version() -> str: @@ -169,6 +192,16 @@ def rusty_v8_checksum_manifest_path(version: str) -> Path: def command_version(version: str | None) -> str: if version is not None: return version + + manifest_versions = rusty_v8_http_file_versions(MODULE_BAZEL.read_text()) + if len(manifest_versions) == 1: + return manifest_versions[0] + if len(manifest_versions) > 1: + raise SystemExit( + "expected at most one rusty_v8 http_file version in MODULE.bazel, " + f"found: {manifest_versions}; pass --version explicitly" + ) + return resolved_v8_crate_version() @@ -180,66 +213,76 @@ def command_manifest_path(manifest: Path | None, version: str) -> Path: return ROOT / manifest -def staged_archive_name(target: str, source_path: Path) -> str: - if source_path.suffix == ".lib": - return f"rusty_v8_release_{target}.lib.gz" - return f"librusty_v8_release_{target}.a.gz" +def staged_archive_name(target: str, source_path: Path, artifact_profile: str) -> str: + if target.endswith("-pc-windows-msvc"): + return f"rusty_v8_{artifact_profile}_{target}.lib.gz" + return f"librusty_v8_{artifact_profile}_{target}.a.gz" -def is_musl_archive_target(target: str, source_path: Path) -> bool: - return target.endswith("-unknown-linux-musl") and source_path.suffix == ".a" +def staged_binding_name(target: str, artifact_profile: str) -> str: + return f"src_binding_{artifact_profile}_{target}.rs" -def single_bazel_output_file( - platform: str, - label: str, - compilation_mode: str = "fastbuild", - bazel_configs: list[str] | None = None, -) -> Path: - outputs = ensure_bazel_output_files(platform, [label], compilation_mode, bazel_configs) - if len(outputs) != 1: - raise SystemExit(f"expected exactly one output for {label}, found {outputs}") - return outputs[0] +def staged_checksums_name(target: str, artifact_profile: str) -> str: + return f"rusty_v8_{artifact_profile}_{target}.sha256" -def merged_musl_archive( - platform: str, +def stage_artifacts( + target: str, lib_path: Path, - compilation_mode: str = "fastbuild", - bazel_configs: list[str] | None = None, -) -> Path: - llvm_ar = single_bazel_output_file(platform, LLVM_AR_LABEL, compilation_mode, bazel_configs) - llvm_ranlib = single_bazel_output_file( - platform, - LLVM_RANLIB_LABEL, - compilation_mode, - bazel_configs, - ) - runtime_archives = [ - single_bazel_output_file(platform, label, compilation_mode, bazel_configs) - for label in MUSL_RUNTIME_ARCHIVE_LABELS - ] + binding_path: Path, + output_dir: Path, + sandbox: bool, +) -> None: + missing_paths = [str(path) for path in [lib_path, binding_path] if not path.exists()] + if missing_paths: + raise SystemExit(f"missing release outputs for {target}: {missing_paths}") - temp_dir = Path(tempfile.mkdtemp(prefix="rusty-v8-musl-stage-")) - merged_archive = temp_dir / lib_path.name - merge_commands = "\n".join( - [ - f"create {merged_archive}", - f"addlib {lib_path}", - *[f"addlib {archive}" for archive in runtime_archives], - "save", - "end", - ] - ) - subprocess.run( - [str(llvm_ar), "-M"], - cwd=ROOT, - check=True, - input=merge_commands, - text=True, - ) - subprocess.run([str(llvm_ranlib), str(merged_archive)], cwd=ROOT, check=True) - return merged_archive + output_dir.mkdir(parents=True, exist_ok=True) + artifact_profile = SANDBOX_ARTIFACT_PROFILE if sandbox else RELEASE_ARTIFACT_PROFILE + staged_library = output_dir / staged_archive_name(target, lib_path, artifact_profile) + staged_binding = output_dir / staged_binding_name(target, artifact_profile) + + with lib_path.open("rb") as src, staged_library.open("wb") as dst: + with gzip.GzipFile( + filename="", + mode="wb", + fileobj=dst, + compresslevel=6, + mtime=0, + ) as gz: + shutil.copyfileobj(src, gz) + + shutil.copyfile(binding_path, staged_binding) + + staged_checksums = output_dir / staged_checksums_name(target, artifact_profile) + with staged_checksums.open("w", encoding="utf-8") as checksums: + for path in [staged_library, staged_binding]: + digest = hashlib.sha256() + with path.open("rb") as artifact: + for chunk in iter(lambda: artifact.read(1024 * 1024), b""): + digest.update(chunk) + checksums.write(f"{digest.hexdigest()} {path.name}\n") + + print(staged_library) + print(staged_binding) + print(staged_checksums) + + +def upstream_release_pair_paths(source_root: Path, target: str) -> tuple[Path, Path]: + lib_name = "rusty_v8.lib" if target.endswith("-pc-windows-msvc") else "librusty_v8.a" + gn_out = source_root / "target" / target / "release" / "gn_out" + return gn_out / "obj" / lib_name, gn_out / "src_binding.rs" + + +def stage_upstream_release_pair( + source_root: Path, + target: str, + output_dir: Path, + sandbox: bool = False, +) -> None: + lib_path, binding_path = upstream_release_pair_paths(source_root, target) + stage_artifacts(target, lib_path, binding_path, output_dir, sandbox) def stage_release_pair( @@ -248,10 +291,12 @@ def stage_release_pair( output_dir: Path, compilation_mode: str = "fastbuild", bazel_configs: list[str] | None = None, + sandbox: bool = False, ) -> None: + bazel_configs = artifact_bazel_configs(bazel_configs) outputs = ensure_bazel_output_files( platform, - [release_pair_label(target)], + [release_pair_label(target, sandbox)], compilation_mode, bazel_configs, ) @@ -266,39 +311,7 @@ def stage_release_pair( except StopIteration as exc: raise SystemExit(f"missing Rust binding output for {target}") from exc - output_dir.mkdir(parents=True, exist_ok=True) - staged_library = output_dir / staged_archive_name(target, lib_path) - staged_binding = output_dir / f"src_binding_release_{target}.rs" - source_archive = ( - merged_musl_archive(platform, lib_path, compilation_mode, bazel_configs) - if is_musl_archive_target(target, lib_path) - else lib_path - ) - - with source_archive.open("rb") as src, staged_library.open("wb") as dst: - with gzip.GzipFile( - filename="", - mode="wb", - fileobj=dst, - compresslevel=6, - mtime=0, - ) as gz: - shutil.copyfileobj(src, gz) - - shutil.copyfile(binding_path, staged_binding) - - staged_checksums = output_dir / f"rusty_v8_release_{target}.sha256" - with staged_checksums.open("w", encoding="utf-8") as checksums: - for path in [staged_library, staged_binding]: - digest = hashlib.sha256() - with path.open("rb") as artifact: - for chunk in iter(lambda: artifact.read(1024 * 1024), b""): - digest.update(chunk) - checksums.write(f"{digest.hexdigest()} {path.name}\n") - - print(staged_library) - print(staged_binding) - print(staged_checksums) + stage_artifacts(target, lib_path, binding_path, output_dir, sandbox) def parse_args() -> argparse.Namespace: @@ -309,6 +322,7 @@ def parse_args() -> argparse.Namespace: stage_release_pair_parser.add_argument("--platform", required=True) stage_release_pair_parser.add_argument("--target", required=True) stage_release_pair_parser.add_argument("--output-dir", required=True) + stage_release_pair_parser.add_argument("--sandbox", action="store_true") stage_release_pair_parser.add_argument( "--bazel-config", action="append", @@ -321,6 +335,14 @@ def parse_args() -> argparse.Namespace: choices=["fastbuild", "opt", "dbg"], ) + stage_upstream_release_pair_parser = subparsers.add_parser( + "stage-upstream-release-pair" + ) + stage_upstream_release_pair_parser.add_argument("--source-root", type=Path, required=True) + stage_upstream_release_pair_parser.add_argument("--target", required=True) + stage_upstream_release_pair_parser.add_argument("--output-dir", required=True) + stage_upstream_release_pair_parser.add_argument("--sandbox", action="store_true") + subparsers.add_parser("resolved-v8-crate-version") check_module_bazel_parser = subparsers.add_parser("check-module-bazel") @@ -353,6 +375,15 @@ def main() -> int: output_dir=Path(args.output_dir), compilation_mode=args.compilation_mode, bazel_configs=args.bazel_configs, + sandbox=args.sandbox, + ) + return 0 + if args.command == "stage-upstream-release-pair": + stage_upstream_release_pair( + source_root=args.source_root, + target=args.target, + output_dir=Path(args.output_dir), + sandbox=args.sandbox, ) return 0 if args.command == "resolved-v8-crate-version": diff --git a/.github/scripts/rusty_v8_module_bazel.py b/.github/scripts/rusty_v8_module_bazel.py index 7f474fc5d2..5d5dec08d5 100644 --- a/.github/scripts/rusty_v8_module_bazel.py +++ b/.github/scripts/rusty_v8_module_bazel.py @@ -9,6 +9,7 @@ from pathlib import Path SHA256_RE = re.compile(r"[0-9a-f]{64}") HTTP_FILE_BLOCK_RE = re.compile(r"(?ms)^http_file\(\n.*?^\)\n?") +HTTP_FILE_VERSION_RE = re.compile(r"^rusty_v8_([0-9]+)_([0-9]+)_([0-9]+)_") class RustyV8ChecksumError(ValueError): @@ -95,6 +96,18 @@ def rusty_v8_http_files(module_bazel: str, version: str) -> list[RustyV8HttpFile return entries +def rusty_v8_http_file_versions(module_bazel: str) -> list[str]: + versions = set() + for match in HTTP_FILE_BLOCK_RE.finditer(module_bazel): + name = string_field(match.group(0), "name") + if not name: + continue + version_match = HTTP_FILE_VERSION_RE.match(name) + if version_match: + versions.add(".".join(version_match.groups())) + return sorted(versions) + + def module_entry_set_errors( entries: list[RustyV8HttpFile], checksums: dict[str, str], diff --git a/.github/scripts/setup-dev-drive.ps1 b/.github/scripts/setup-dev-drive.ps1 new file mode 100644 index 0000000000..2b94e1b66f --- /dev/null +++ b/.github/scripts/setup-dev-drive.ps1 @@ -0,0 +1,62 @@ +# Configure a fast drive for Windows CI jobs. +# +# GitHub-hosted Windows runners do not always expose a secondary D: volume. When +# they do not, try to create a Dev Drive VHD and fall back to C: if the runner +# image does not allow that provisioning path. + +function Use-FallbackDrive { + param([string]$Reason) + + Write-Warning "$Reason Falling back to C:" + return "C:" +} + +function Invoke-BestEffort { + param([scriptblock]$Script, [string]$Description) + + try { + & $Script + } catch { + Write-Warning "$Description failed: $($_.Exception.Message)" + } +} + +if (Test-Path "D:\") { + Write-Output "Using existing drive at D:" + $Drive = "D:" +} else { + try { + $VhdPath = Join-Path $env:RUNNER_TEMP "codex-dev-drive.vhdx" + $SizeBytes = 64GB + + if (Test-Path $VhdPath) { + Remove-Item -Path $VhdPath -Force + } + + New-VHD -Path $VhdPath -SizeBytes $SizeBytes -Dynamic -ErrorAction Stop | Out-Null + $Mounted = Mount-VHD -Path $VhdPath -Passthru -ErrorAction Stop + $Disk = $Mounted | Get-Disk -ErrorAction Stop + $Disk | Initialize-Disk -PartitionStyle GPT -ErrorAction Stop + $Partition = $Disk | New-Partition -AssignDriveLetter -UseMaximumSize -ErrorAction Stop + $Volume = $Partition | Format-Volume -FileSystem ReFS -NewFileSystemLabel "CodexDevDrive" -DevDrive -Confirm:$false -Force -ErrorAction Stop + + $Drive = "$($Volume.DriveLetter):" + + Invoke-BestEffort { fsutil devdrv trust $Drive } "Trusting Dev Drive $Drive" + Invoke-BestEffort { fsutil devdrv enable /disallowAv } "Disabling AV filter attachment for Dev Drives" + Invoke-BestEffort { fsutil devdrv query $Drive } "Querying Dev Drive $Drive" + + Write-Output "Using Dev Drive at $Drive" + } catch { + $Drive = Use-FallbackDrive "Failed to create Dev Drive: $($_.Exception.Message)" + } +} + +$Tmp = "$Drive\codex-tmp" +New-Item -Path $Tmp -ItemType Directory -Force | Out-Null + +@( + "DEV_DRIVE=$Drive" + "TMP=$Tmp" + "TEMP=$Tmp" +) | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append diff --git a/.github/scripts/test_rusty_v8_bazel.py b/.github/scripts/test_rusty_v8_bazel.py index e86e82e8b2..19690dbece 100644 --- a/.github/scripts/test_rusty_v8_bazel.py +++ b/.github/scripts/test_rusty_v8_bazel.py @@ -4,11 +4,270 @@ from __future__ import annotations import textwrap import unittest +from os import environ +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch +import rusty_v8_bazel import rusty_v8_module_bazel class RustyV8BazelTest(unittest.TestCase): + def test_consumer_selectors_track_resolved_crate_version(self) -> None: + build_bazel = ( + rusty_v8_bazel.ROOT / "third_party" / "v8" / "BUILD.bazel" + ).read_text() + version_suffix = rusty_v8_bazel.resolved_v8_crate_version().replace(".", "_") + + for selector in [ + "aarch64_apple_darwin_bazel", + "aarch64_pc_windows_gnullvm", + "aarch64_pc_windows_msvc", + "aarch64_unknown_linux_gnu_bazel", + "aarch64_unknown_linux_musl_release_base", + "x86_64_apple_darwin_bazel", + "x86_64_pc_windows_gnullvm", + "x86_64_pc_windows_msvc", + "x86_64_unknown_linux_gnu_bazel", + "x86_64_unknown_linux_musl_release", + ]: + self.assertIn( + f":v8_{version_suffix}_{selector}", + build_bazel, + ) + + for selector in [ + "aarch64_apple_darwin", + "aarch64_pc_windows_gnullvm", + "aarch64_pc_windows_msvc", + "aarch64_unknown_linux_gnu", + "aarch64_unknown_linux_musl", + "x86_64_apple_darwin", + "x86_64_pc_windows_gnullvm", + "x86_64_pc_windows_msvc", + "x86_64_unknown_linux_gnu", + "x86_64_unknown_linux_musl", + ]: + self.assertIn( + f":src_binding_release_{selector}_{version_suffix}_release", + build_bazel, + ) + + def test_command_version_tracks_remaining_http_file_assets(self) -> None: + with TemporaryDirectory() as temp_dir: + module_bazel = Path(temp_dir) / "MODULE.bazel" + module_bazel.write_text( + textwrap.dedent( + """\ + http_file( + name = "rusty_v8_146_4_0_x86_64_unknown_linux_gnu_archive", + downloaded_file_path = "librusty_v8_release_x86_64-unknown-linux-gnu.a.gz", + urls = ["https://example.test/archive.gz"], + ) + """ + ) + ) + + with patch.object(rusty_v8_bazel, "MODULE_BAZEL", module_bazel): + self.assertEqual("146.4.0", rusty_v8_bazel.command_version(None)) + + def test_artifact_bazel_configs_always_enable_upstream_libcxx(self) -> None: + self.assertEqual( + ["rusty-v8-upstream-libcxx"], + rusty_v8_bazel.artifact_bazel_configs(), + ) + self.assertEqual( + ["rusty-v8-upstream-libcxx", "v8-release-compat"], + rusty_v8_bazel.artifact_bazel_configs(["v8-release-compat"]), + ) + self.assertEqual( + ["rusty-v8-upstream-libcxx", "v8-release-compat"], + rusty_v8_bazel.artifact_bazel_configs( + ["rusty-v8-upstream-libcxx", "v8-release-compat"] + ), + ) + + def test_bazel_remote_args_include_buildbuddy_header_when_present(self) -> None: + with patch.dict(environ, {"BUILDBUDDY_API_KEY": "token"}, clear=False): + self.assertEqual( + ["--remote_header=x-buildbuddy-api-key=token"], + rusty_v8_bazel.bazel_remote_args(), + ) + + with patch.dict(environ, {}, clear=True): + self.assertEqual([], rusty_v8_bazel.bazel_remote_args()) + + def test_release_pair_labels_and_staged_names_distinguish_sandbox_artifacts(self) -> None: + self.assertEqual( + "//third_party/v8:rusty_v8_release_pair_x86_64_unknown_linux_musl", + rusty_v8_bazel.release_pair_label("x86_64-unknown-linux-musl"), + ) + self.assertEqual( + "//third_party/v8:rusty_v8_sandbox_release_pair_x86_64_unknown_linux_musl", + rusty_v8_bazel.release_pair_label("x86_64-unknown-linux-musl", sandbox=True), + ) + self.assertEqual( + "//third_party/v8:rusty_v8_sandbox_release_pair_x86_64_apple_darwin", + rusty_v8_bazel.release_pair_label("x86_64-apple-darwin", sandbox=True), + ) + self.assertEqual( + "librusty_v8_release_x86_64-unknown-linux-musl.a.gz", + rusty_v8_bazel.staged_archive_name( + "x86_64-unknown-linux-musl", + Path("libv8.a"), + rusty_v8_bazel.RELEASE_ARTIFACT_PROFILE, + ), + ) + self.assertEqual( + "rusty_v8_ptrcomp_sandbox_release_x86_64-pc-windows-msvc.lib.gz", + rusty_v8_bazel.staged_archive_name( + "x86_64-pc-windows-msvc", + Path("v8.a"), + rusty_v8_bazel.SANDBOX_ARTIFACT_PROFILE, + ), + ) + self.assertEqual( + "src_binding_ptrcomp_sandbox_release_x86_64-unknown-linux-musl.rs", + rusty_v8_bazel.staged_binding_name( + "x86_64-unknown-linux-musl", + rusty_v8_bazel.SANDBOX_ARTIFACT_PROFILE, + ), + ) + self.assertEqual( + "rusty_v8_ptrcomp_sandbox_release_x86_64-unknown-linux-musl.sha256", + rusty_v8_bazel.staged_checksums_name( + "x86_64-unknown-linux-musl", + rusty_v8_bazel.SANDBOX_ARTIFACT_PROFILE, + ), + ) + + def test_stage_artifacts(self) -> None: + with TemporaryDirectory() as source_dir, TemporaryDirectory() as output_dir: + source_root = Path(source_dir) + archive = source_root / "librusty_v8.a" + binding = source_root / "src_binding.rs" + archive.write_bytes(b"archive") + binding.write_text("binding") + + rusty_v8_bazel.stage_artifacts( + "aarch64-apple-darwin", + archive, + binding, + Path(output_dir), + sandbox=True, + ) + + self.assertEqual( + { + "librusty_v8_ptrcomp_sandbox_release_aarch64-apple-darwin.a.gz", + "src_binding_ptrcomp_sandbox_release_aarch64-apple-darwin.rs", + "rusty_v8_ptrcomp_sandbox_release_aarch64-apple-darwin.sha256", + }, + {path.name for path in Path(output_dir).iterdir()}, + ) + + def test_upstream_release_pair_paths(self) -> None: + self.assertEqual( + ( + Path( + "/tmp/rusty_v8/target/x86_64-apple-darwin/release/gn_out/obj/" + "librusty_v8.a" + ), + Path( + "/tmp/rusty_v8/target/x86_64-apple-darwin/release/gn_out/" + "src_binding.rs" + ), + ), + rusty_v8_bazel.upstream_release_pair_paths( + Path("/tmp/rusty_v8"), + "x86_64-apple-darwin", + ), + ) + self.assertEqual( + ( + Path( + "/tmp/rusty_v8/target/x86_64-pc-windows-msvc/release/gn_out/" + "obj/rusty_v8.lib" + ), + Path( + "/tmp/rusty_v8/target/x86_64-pc-windows-msvc/release/gn_out/" + "src_binding.rs" + ), + ), + rusty_v8_bazel.upstream_release_pair_paths( + Path("/tmp/rusty_v8"), + "x86_64-pc-windows-msvc", + ), + ) + + def test_stage_upstream_release_pair(self) -> None: + with TemporaryDirectory() as source_dir, TemporaryDirectory() as output_dir: + source_root = Path(source_dir) + gn_out = ( + source_root + / "target" + / "x86_64-pc-windows-msvc" + / "release" + / "gn_out" + ) + (gn_out / "obj").mkdir(parents=True) + (gn_out / "obj" / "rusty_v8.lib").write_bytes(b"archive") + (gn_out / "src_binding.rs").write_text("binding") + + rusty_v8_bazel.stage_upstream_release_pair( + source_root, + "x86_64-pc-windows-msvc", + Path(output_dir), + sandbox=True, + ) + + self.assertEqual( + { + "rusty_v8_ptrcomp_sandbox_release_x86_64-pc-windows-msvc.lib.gz", + "src_binding_ptrcomp_sandbox_release_x86_64-pc-windows-msvc.rs", + "rusty_v8_ptrcomp_sandbox_release_x86_64-pc-windows-msvc.sha256", + }, + {path.name for path in Path(output_dir).iterdir()}, + ) + + def test_ensure_bazel_output_files_rebuilds_existing_outputs(self) -> None: + with TemporaryDirectory() as output_dir: + output = Path(output_dir) / "libv8.a" + output.write_bytes(b"archive") + + with ( + patch.object(rusty_v8_bazel, "bazel_build") as bazel_build, + patch.object( + rusty_v8_bazel, + "bazel_output_files", + return_value=[output], + ) as bazel_output_files, + ): + self.assertEqual( + [output], + rusty_v8_bazel.ensure_bazel_output_files( + "macos_arm64", + ["//third_party/v8:pair"], + "opt", + ["rusty-v8-upstream-libcxx"], + ), + ) + + bazel_build.assert_called_once_with( + "macos_arm64", + ["//third_party/v8:pair"], + "opt", + ["rusty-v8-upstream-libcxx"], + download_toplevel=True, + ) + bazel_output_files.assert_called_once_with( + "macos_arm64", + ["//third_party/v8:pair"], + "opt", + ["rusty-v8-upstream-libcxx"], + ) + def test_update_module_bazel_replaces_and_inserts_sha256(self) -> None: module_bazel = textwrap.dedent( """\ @@ -121,6 +380,34 @@ class RustyV8BazelTest(unittest.TestCase): "146.4.0", ) + def test_rusty_v8_http_file_versions(self) -> None: + module_bazel = textwrap.dedent( + """\ + http_file( + name = "rusty_v8_146_4_0_x86_64_unknown_linux_gnu_archive", + downloaded_file_path = "archive.gz", + urls = ["https://example.test/archive.gz"], + ) + + http_file( + name = "rusty_v8_147_4_0_x86_64_unknown_linux_gnu_archive", + downloaded_file_path = "new-archive.gz", + urls = ["https://example.test/new-archive.gz"], + ) + + http_file( + name = "unrelated_archive", + downloaded_file_path = "other.gz", + urls = ["https://example.test/other.gz"], + ) + """ + ) + + self.assertEqual( + ["146.4.0", "147.4.0"], + rusty_v8_module_bazel.rusty_v8_http_file_versions(module_bazel), + ) + if __name__ == "__main__": unittest.main() diff --git a/.github/workflows/README.md b/.github/workflows/README.md index d14817f002..b2403b749c 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -21,7 +21,8 @@ The workflows in this directory are split so that pull requests get fast, review - `rust-ci-full.yml` is the full Cargo-native verification workflow. It keeps the heavier checks off the PR path while still validating them after merge: - the full Cargo `clippy` matrix - - the full Cargo `nextest` matrix + - the full Cargo `nextest` matrix via per-platform archive-backed shards + - Windows ARM64 nextest archives cross-compiled on Windows x64, then replayed on native Windows ARM64 shards - release-profile Cargo builds - cross-platform `argument-comment-lint` - Linux remote-env tests diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 11c0988ceb..153ace0fc9 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -141,7 +141,9 @@ jobs: - 2 - 3 - 4 - runs-on: windows-latest + runs-on: + group: codex-runners + labels: codex-windows-x64 name: Bazel test on windows-latest for x86_64-pc-windows-gnullvm shard ${{ matrix.shard }}/4 steps: @@ -246,7 +248,9 @@ jobs: # it a larger timeout. if: github.event_name == 'push' && github.ref == 'refs/heads/main' timeout-minutes: 40 - runs-on: windows-latest + runs-on: + group: codex-runners + labels: codex-windows-x64 name: Bazel test on windows-latest for x86_64-pc-windows-gnullvm (native main) steps: @@ -332,7 +336,10 @@ jobs: target: aarch64-apple-darwin - os: windows-latest target: x86_64-pc-windows-gnullvm - runs-on: ${{ matrix.os }} + runs_on: + group: codex-runners + labels: codex-windows-x64 + runs-on: ${{ matrix.runs_on || matrix.os }} name: Bazel clippy on ${{ matrix.os }} for ${{ matrix.target }} steps: @@ -422,7 +429,10 @@ jobs: target: aarch64-apple-darwin - os: windows-latest target: x86_64-pc-windows-gnullvm - runs-on: ${{ matrix.os }} + runs_on: + group: codex-runners + labels: codex-windows-x64 + runs-on: ${{ matrix.runs_on || matrix.os }} name: Verify release build on ${{ matrix.os }} for ${{ matrix.target }} steps: diff --git a/.github/workflows/cargo-deny.yml b/.github/workflows/cargo-deny.yml index f20d09e112..656c50ef28 100644 --- a/.github/workflows/cargo-deny.yml +++ b/.github/workflows/cargo-deny.yml @@ -20,10 +20,10 @@ jobs: persist-credentials: false - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 - name: Run cargo-deny uses: EmbarkStudios/cargo-deny-action@82eb9f621fbc699dd0918f3ea06864c14cc84246 # v2 with: - rust-version: 1.93.0 + rust-version: 1.95.0 manifest-path: ./codex-rs/Cargo.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1c60acc26..9adc90e040 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,9 @@ jobs: - name: Verify Bazel clippy flags match Cargo workspace lints run: python3 .github/scripts/verify_bazel_clippy_lints.py + - name: Test Codex package builder + run: python3 -m unittest discover -s scripts/codex_package -p 'test_*.py' + - name: Setup pnpm uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 with: @@ -39,9 +42,6 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - # stage_npm_packages.py requires DotSlash when staging releases. - - uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 - - name: Stage npm package id: stage_npm_package env: @@ -52,15 +52,13 @@ jobs: # cross-platform native payload required by the npm package layout. # Passing the workflow URL directly avoids relying on old rust-v* # branches remaining discoverable via `gh run list --branch ...`. - CODEX_VERSION=0.125.0 - WORKFLOW_URL="https://github.com/openai/codex/actions/runs/24901475298" + CODEX_VERSION=0.133.0-alpha.4 + WORKFLOW_URL="https://github.com/openai/codex/actions/runs/26201494185" OUTPUT_DIR="${RUNNER_TEMP}" - # This reused workflow predates the standalone bwrap artifact. python3 ./scripts/stage_npm_packages.py \ --release-version "$CODEX_VERSION" \ --workflow-url "$WORKFLOW_URL" \ --package codex \ - --allow-missing-native-component bwrap \ --output-dir "$OUTPUT_DIR" PACK_OUTPUT="${OUTPUT_DIR}/codex-npm-${CODEX_VERSION}.tgz" echo "pack_output=$PACK_OUTPUT" >> "$GITHUB_OUTPUT" @@ -76,5 +74,15 @@ jobs: - name: Check root README ToC run: python3 scripts/readme_toc.py README.md + - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 + with: + tool: just + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: "0.11.3" + - name: Ruff format Python scripts (run `just fmt` to fix) + run: just fmt-scripts-check + - name: Prettier (run `pnpm run format:fix` to fix) run: pnpm run format diff --git a/.github/workflows/issue-deduplicator.yml b/.github/workflows/issue-deduplicator.yml index 11b4e914fe..fea6348c46 100644 --- a/.github/workflows/issue-deduplicator.yml +++ b/.github/workflows/issue-deduplicator.yml @@ -12,17 +12,12 @@ jobs: # Prevent runs on forks (requires OpenAI API key, wastes Actions minutes) if: github.repository == 'openai/codex' && (github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-deduplicate')) runs-on: ubuntu-latest + environment: issue-triage permissions: contents: read outputs: - issues_json: ${{ steps.normalize-all.outputs.issues_json }} - reason: ${{ steps.normalize-all.outputs.reason }} - has_matches: ${{ steps.normalize-all.outputs.has_matches }} + codex_output: ${{ steps.codex-all.outputs.final-message }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - name: Prepare Codex inputs env: GH_TOKEN: ${{ github.token }} @@ -67,6 +62,8 @@ jobs: with: openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }} allow-users: "*" + safety-strategy: drop-sudo + sandbox: read-only prompt: | You are an assistant that triages new GitHub issues by identifying potential duplicates. @@ -100,10 +97,21 @@ jobs: "additionalProperties": false } + normalize-duplicates-all: + name: Normalize pass 1 output + needs: gather-duplicates-all + if: ${{ needs.gather-duplicates-all.result == 'success' }} + runs-on: ubuntu-latest + permissions: {} + outputs: + issues_json: ${{ steps.normalize-all.outputs.issues_json }} + reason: ${{ steps.normalize-all.outputs.reason }} + has_matches: ${{ steps.normalize-all.outputs.has_matches }} + steps: - id: normalize-all name: Normalize pass 1 output env: - CODEX_OUTPUT: ${{ steps.codex-all.outputs.final-message }} + CODEX_OUTPUT: ${{ needs.gather-duplicates-all.outputs.codex_output }} CURRENT_ISSUE_NUMBER: ${{ github.event.issue.number }} run: | set -eo pipefail @@ -146,21 +154,16 @@ jobs: gather-duplicates-open: name: Identify potential duplicates (open issues fallback) - # Pass 1 may drop sudo on the runner, so run the fallback in a fresh job. - needs: gather-duplicates-all - if: ${{ needs.gather-duplicates-all.result == 'success' && needs.gather-duplicates-all.outputs.has_matches != 'true' }} + # Pass 1 Codex execution drops sudo on its runner, so run the fallback in a fresh job. + needs: normalize-duplicates-all + if: ${{ needs.normalize-duplicates-all.result == 'success' && needs.normalize-duplicates-all.outputs.has_matches != 'true' }} runs-on: ubuntu-latest + environment: issue-triage permissions: contents: read outputs: - issues_json: ${{ steps.normalize-open.outputs.issues_json }} - reason: ${{ steps.normalize-open.outputs.reason }} - has_matches: ${{ steps.normalize-open.outputs.has_matches }} + codex_output: ${{ steps.codex-open.outputs.final-message }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - - name: Prepare Codex inputs env: GH_TOKEN: ${{ github.token }} @@ -203,6 +206,8 @@ jobs: with: openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }} allow-users: "*" + safety-strategy: drop-sudo + sandbox: read-only prompt: | You are an assistant that triages new GitHub issues by identifying potential duplicates. @@ -236,10 +241,21 @@ jobs: "additionalProperties": false } + normalize-duplicates-open: + name: Normalize pass 2 output + needs: gather-duplicates-open + if: ${{ needs.gather-duplicates-open.result == 'success' }} + runs-on: ubuntu-latest + permissions: {} + outputs: + issues_json: ${{ steps.normalize-open.outputs.issues_json }} + reason: ${{ steps.normalize-open.outputs.reason }} + has_matches: ${{ steps.normalize-open.outputs.has_matches }} + steps: - id: normalize-open name: Normalize pass 2 output env: - CODEX_OUTPUT: ${{ steps.codex-open.outputs.final-message }} + CODEX_OUTPUT: ${{ needs.gather-duplicates-open.outputs.codex_output }} CURRENT_ISSUE_NUMBER: ${{ github.event.issue.number }} run: | set -eo pipefail @@ -283,9 +299,9 @@ jobs: select-final: name: Select final duplicate set needs: - - gather-duplicates-all - - gather-duplicates-open - if: ${{ always() && needs.gather-duplicates-all.result == 'success' && (needs.gather-duplicates-open.result == 'success' || needs.gather-duplicates-open.result == 'skipped') }} + - normalize-duplicates-all + - normalize-duplicates-open + if: ${{ always() && needs.normalize-duplicates-all.result == 'success' && (needs.normalize-duplicates-open.result == 'success' || needs.normalize-duplicates-open.result == 'skipped') }} runs-on: ubuntu-latest permissions: contents: read @@ -295,12 +311,12 @@ jobs: - id: select-final name: Select final duplicate set env: - PASS1_ISSUES: ${{ needs.gather-duplicates-all.outputs.issues_json }} - PASS1_REASON: ${{ needs.gather-duplicates-all.outputs.reason }} - PASS2_ISSUES: ${{ needs.gather-duplicates-open.outputs.issues_json }} - PASS2_REASON: ${{ needs.gather-duplicates-open.outputs.reason }} - PASS1_HAS_MATCHES: ${{ needs.gather-duplicates-all.outputs.has_matches }} - PASS2_HAS_MATCHES: ${{ needs.gather-duplicates-open.outputs.has_matches }} + PASS1_ISSUES: ${{ needs.normalize-duplicates-all.outputs.issues_json }} + PASS1_REASON: ${{ needs.normalize-duplicates-all.outputs.reason }} + PASS2_ISSUES: ${{ needs.normalize-duplicates-open.outputs.issues_json }} + PASS2_REASON: ${{ needs.normalize-duplicates-open.outputs.reason }} + PASS1_HAS_MATCHES: ${{ needs.normalize-duplicates-all.outputs.has_matches }} + PASS2_HAS_MATCHES: ${{ needs.normalize-duplicates-open.outputs.has_matches }} run: | set -eo pipefail diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml new file mode 100644 index 0000000000..2c4eb6aa68 --- /dev/null +++ b/.github/workflows/issue-labeler.yml @@ -0,0 +1,152 @@ +name: Issue Labeler + +on: + issues: + types: + - opened + - labeled + +jobs: + gather-labels: + name: Generate label suggestions + # Prevent runs on forks (requires OpenAI API key, wastes Actions minutes) + if: github.repository == 'openai/codex' && (github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-label')) + runs-on: ubuntu-latest + environment: issue-triage + permissions: + contents: read + outputs: + codex_output: ${{ steps.codex.outputs.final-message }} + steps: + - id: codex + uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02 # v1.7 + with: + openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }} + allow-users: "*" + safety-strategy: drop-sudo + sandbox: read-only + prompt: | + You are an assistant that reviews GitHub issues for the repository. + + Your job is to choose the most appropriate labels for the issue described later in this prompt. + Follow these rules: + + - Add one (and only one) of the following three labels to distinguish the type of issue. Default to "bug" if unsure. + 1. bug — Reproducible defects in Codex products (CLI, VS Code extension, web, auth). + 2. enhancement — Feature requests or usability improvements that ask for new capabilities, better ergonomics, or quality-of-life tweaks. + 3. documentation — Updates or corrections needed in docs/README/config references (broken links, missing examples, outdated keys, clarification requests). + + - If applicable, add one of the following labels to specify which sub-product or product surface the issue relates to. + 1. CLI — the Codex command line interface. + 2. extension — VS Code (or other IDE) extension-specific issues. + 3. app - Issues related to the Codex desktop application. + 4. codex-web — Issues targeting the Codex web UI/Cloud experience. + 5. github-action — Issues with the Codex GitHub action. + 6. iOS — Issues with the Codex iOS app. + + - Additionally add zero or more of the following labels that are relevant to the issue content. Prefer a small set of precise labels over many broad ones. + - For agent-area issues, prefer the most specific applicable label. Use "agent" only as a fallback for agent-related issues that do not fit a more specific agent-area label. Prefer "app-server" over "session" or "config" when the issue is about app-server protocol, API, RPC, schema, launch, or bridge behavior. Use "memory" for agentic memory storage/retrieval and "performance" for high process memory utilization or memory leaks. + 1. windows-os — Bugs or friction specific to Windows environments (always when PowerShell is mentioned, path handling, copy/paste, OS-specific auth or tooling failures). + 2. mcp — Topics involving Model Context Protocol servers/clients. + 3. mcp-server — Problems related to the codex mcp-server command, where codex runs as an MCP server. + 4. azure — Problems or requests tied to Azure OpenAI deployments. + 5. model-behavior — Undesirable LLM behavior: forgetting goals, refusing work, hallucinating environment details, quota misreports, or other reasoning/performance anomalies. + 6. code-review — Issues related to the code review feature or functionality. + 7. safety-check - Issues related to cyber risk detection or trusted access verification. + 8. auth - Problems related to authentication, login, or access tokens. + 9. exec - Problems related to the "codex exec" command or functionality. + 10. hooks - Problems related to event hooks + 11. context - Problems related to compaction, context windows, or available context reporting. + 12. skills - Problems related to skills or plugins + 13. custom-model - Problems that involve using custom model providers, local models, or OSS models. + 14. rate-limits - Problems related to token limits, rate limits, or token usage reporting. + 15. sandbox - Issues related to local sandbox environments or tool call approvals to override sandbox restrictions. + 16. tool-calls - Problems related to specific tool call invocations including unexpected errors, failures, or hangs. + 17. TUI - Problems with the terminal user interface (TUI) including keyboard shortcuts, copy & pasting, menus, or screen update issues. + 18. app-server - Issues involving the app-server protocol or interfaces, including SDK/API payloads, thread/* and turn/* RPCs, app-server launch behavior, external app/controller bridges, and app-server protocol/schema behavior. + 19. connectivity - Network connectivity or endpoint issues, including reconnecting messages, stream dropped/disconnected errors, websocket/SSE/transport failures, timeout/network/VPN/proxy/API endpoint failures, and related retry behavior. + 20. subagent - Issues involving subagents, sub-agents, or multi-agent behavior, including spawn_agent, wait_agent, close_agent, worker/explorer roles, delegation, agent teams, lifecycle, model/config inheritance, quotas, and orchestration. + 21. session - Issues involving session or thread management, including resume, fork, archive, rename/title, thread history, rollout persistence, compaction, checkpoints, retention, and cross-session state. + 22. config - Issues involving config.toml, config keys, config key merging, config updates, profiles, hooks config, project config, agent role TOMLs, instruction/personality config, and config schema behavior. + 23. plan - Issues involving plan mode, planning workflows, or plan-specific tools/behavior. + 24. computer-use - Issues involving agentic computer use or SkyComputerUseService. + 25. browser - Issues involving agentic browser use, IAB, or the built-in browser within the Codex app. + 26. memory - Issues involving agentic memory storage and retrieval. + 27. imagen - Issues involving image generation. + 28. remote - Issues involving remote access, remote control, or SSH. + 29. performance - Issues involving slow, laggy performance, high memory utilization, or memory leaks. + 30. automations - Issues involving scheduled automation tasks or heartbeats. + 31. pets - Issues involving pets avatars and animations. + 32. agent - Fallback only for core agent loop or agent-related issues that do not fit app-server, connectivity, subagent, session, config, plan, computer-use, browser, memory, imagen, remote, performance, automations, or pets. + + Issue number: ${{ github.event.issue.number }} + + Issue title: + ${{ github.event.issue.title }} + + Issue body: + ${{ github.event.issue.body }} + + Repository full name: + ${{ github.repository }} + + output-schema: | + { + "type": "object", + "properties": { + "labels": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": ["labels"], + "additionalProperties": false + } + + apply-labels: + name: Apply labels from Codex output + needs: gather-labels + if: ${{ needs.gather-labels.result != 'skipped' }} + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + CODEX_OUTPUT: ${{ needs.gather-labels.outputs.codex_output }} + steps: + - name: Apply labels + run: | + json=${CODEX_OUTPUT//$'\r'/} + if [ -z "$json" ]; then + echo "Codex produced no output. Skipping label application." + exit 0 + fi + + if ! printf '%s' "$json" | jq -e 'type == "object" and (.labels | type == "array")' >/dev/null 2>&1; then + echo "Codex output did not include a labels array. Raw output: $json" + exit 0 + fi + + labels=$(printf '%s' "$json" | jq -r '.labels[] | tostring') + if [ -z "$labels" ]; then + echo "Codex returned an empty array. Nothing to do." + exit 0 + fi + + cmd=(gh issue edit "$ISSUE_NUMBER") + while IFS= read -r label; do + cmd+=(--add-label "$label") + done <<< "$labels" + + "${cmd[@]}" || true + + - name: Remove codex-label trigger + if: ${{ always() && github.event.action == 'labeled' && github.event.label.name == 'codex-label' }} + run: | + gh issue edit "$ISSUE_NUMBER" --remove-label codex-label || true + echo "Attempted to remove label: codex-label" diff --git a/.github/workflows/python-sdk-release.yml b/.github/workflows/python-sdk-release.yml new file mode 100644 index 0000000000..654dd8d822 --- /dev/null +++ b/.github/workflows/python-sdk-release.yml @@ -0,0 +1,91 @@ +name: python-sdk-release + +on: + push: + tags: + - "python-v*" + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + build-python-sdk: + if: github.repository == 'openai/codex' + name: build-python-sdk + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Validate tag and build Python SDK package + shell: bash + run: | + set -euo pipefail + + sdk_version="${GITHUB_REF_NAME#python-v}" + if [[ ! "${sdk_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+b[0-9]+$ ]]; then + echo "Python SDK release tags must identify a beta release, for example python-v0.1.0b1." + exit 1 + fi + + # The pinned runtime currently publishes a musllinux Linux wheel. + # Build in Alpine so release type generation installs that wheel. + docker run --rm \ + --user "$(id -u):$(id -g)" \ + -e HOME=/tmp/codex-python-sdk-home \ + -e UV_LINK_MODE=copy \ + -e SDK_VERSION="${sdk_version}" \ + -e SDK_STAGE_DIR="${RUNNER_TEMP}/openai-codex" \ + -e SDK_DIST_DIR="${GITHUB_WORKSPACE}/dist/python-sdk" \ + -v "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}" \ + -v "${RUNNER_TEMP}:${RUNNER_TEMP}" \ + -w "${GITHUB_WORKSPACE}/sdk/python" \ + python:3.12-alpine \ + sh -euxc ' + python -m venv /tmp/release-tools + /tmp/release-tools/bin/python -m pip install build twine uv==0.11.3 + /tmp/release-tools/bin/uv sync --extra dev --frozen + /tmp/release-tools/bin/uv run --extra dev --frozen python scripts/update_sdk_artifacts.py \ + stage-sdk "${SDK_STAGE_DIR}" \ + --sdk-version "${SDK_VERSION}" + /tmp/release-tools/bin/python -m build \ + --wheel \ + --sdist \ + --outdir "${SDK_DIST_DIR}" \ + "${SDK_STAGE_DIR}" + /tmp/release-tools/bin/python -m twine check --strict "${SDK_DIST_DIR}/"* + ' + + - name: Upload Python SDK package + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: python-sdk-package + path: dist/python-sdk/* + if-no-files-found: error + + publish-python-sdk: + name: publish-python-sdk + needs: build-python-sdk + runs-on: ubuntu-latest + environment: pypi + permissions: + contents: read + id-token: write # Required for PyPI trusted publishing. + + steps: + - name: Download Python SDK package + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-sdk-package + path: dist/python-sdk + + - name: Publish Python SDK to PyPI + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + with: + packages-dir: dist/python-sdk diff --git a/.github/workflows/rust-ci-full-nextest-platform.yml b/.github/workflows/rust-ci-full-nextest-platform.yml new file mode 100644 index 0000000000..3fdf7b51ee --- /dev/null +++ b/.github/workflows/rust-ci-full-nextest-platform.yml @@ -0,0 +1,464 @@ +name: rust-ci-full nextest platform + +on: + workflow_call: + inputs: + runner: + required: true + type: string + runner_group: + required: false + default: "" + type: string + runner_labels: + required: false + default: "" + type: string + archive_runner: + required: false + default: "" + type: string + archive_runner_group: + required: false + default: "" + type: string + archive_runner_labels: + required: false + default: "" + type: string + target: + required: true + type: string + profile: + required: true + type: string + artifact_id: + required: true + type: string + remote_env: + required: false + default: false + type: boolean + test_threads: + required: false + default: 0 + type: number + use_sccache: + required: false + default: false + type: boolean + +# Caller workflow-level env does not flow through workflow_call, so keep the +# Cargo git transport hardening on the archive and shard jobs directly here. +env: + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + +jobs: + archive: + name: Build nextest archive + runs-on: ${{ inputs.archive_runner_group != '' && fromJSON(format('{{"group":"{0}","labels":"{1}"}}', inputs.archive_runner_group, inputs.archive_runner_labels)) || inputs.archive_runner != '' && inputs.archive_runner || inputs.runner_group != '' && fromJSON(format('{{"group":"{0}","labels":"{1}"}}', inputs.runner_group, inputs.runner_labels)) || inputs.runner }} + timeout-minutes: 60 + defaults: + run: + working-directory: codex-rs + env: + # Windows ARM64 archives are built on Windows x64, while their shards run + # on native Windows ARM64. Key producer-side caches by the archive runner + # so the cross-compile build reuses the Windows x64 cache lineage. + ARCHIVE_CACHE_RUNNER: ${{ inputs.archive_runner != '' && inputs.archive_runner || inputs.runner }} + USE_SCCACHE: ${{ inputs.use_sccache && 'true' || 'false' }} + CARGO_INCREMENTAL: "0" + SCCACHE_CACHE_SIZE: 10G + NEXTEST_ARCHIVE_FILE: nextest-${{ inputs.artifact_id }}.tar.zst + TEST_HELPERS_ARTIFACT: nextest-test-helpers-${{ inputs.artifact_id }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Configure Dev Drive (Windows) + if: ${{ runner.os == 'Windows' }} + shell: pwsh + run: ../.github/scripts/setup-dev-drive.ps1 + + - name: Install Linux build dependencies + if: ${{ runner.os == 'Linux' }} + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + sudo apt-get update -y + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev bubblewrap + fi + + - name: Install DotSlash + uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 + + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.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@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + with: + targets: ${{ inputs.target }} + + - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 + with: + tool: nextest + version: 0.9.103 + + - name: Enable unprivileged user namespaces (Linux) + if: runner.os == 'Linux' + run: | + sudo sysctl -w kernel.unprivileged_userns_clone=1 + if sudo sysctl -a 2>/dev/null | grep -q '^kernel.apparmor_restrict_unprivileged_userns'; then + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + fi + + - name: Set up remote test env (Docker) + if: ${{ runner.os == 'Linux' && inputs.remote_env }} + shell: bash + run: | + set -euo pipefail + export CODEX_TEST_REMOTE_ENV_CONTAINER_NAME="codex-remote-test-env-${{ github.run_id }}-${{ matrix.shard }}" + source "${GITHUB_WORKSPACE}/scripts/test-remote-env.sh" + echo "CODEX_TEST_REMOTE_ENV=${CODEX_TEST_REMOTE_ENV}" >> "$GITHUB_ENV" + echo "CODEX_TEST_REMOTE_EXEC_SERVER_URL=${CODEX_TEST_REMOTE_EXEC_SERVER_URL}" >> "$GITHUB_ENV" + + - name: Download nextest archive + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: nextest-archive-${{ inputs.artifact_id }} + path: ${{ runner.temp }}/nextest-archive + + - name: Download runtime test helpers + if: ${{ runner.os == 'Linux' || runner.os == 'Windows' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ${{ env.TEST_HELPERS_ARTIFACT }} + path: ${{ runner.temp }}/${{ env.TEST_HELPERS_ARTIFACT }} + + - name: tests + id: test + shell: bash + run: | + set -euo pipefail + archive_file="${RUNNER_TEMP}/nextest-archive/${NEXTEST_ARCHIVE_FILE}" + workspace_root="$(pwd)" + + if [[ "${RUNNER_OS}" == "Windows" ]]; then + archive_file="$(cygpath -w "${archive_file}")" + workspace_root="$(cygpath -w "${workspace_root}")" + fi + + if [[ "${RUNNER_OS}" == "Linux" ]]; then + helper_dir="${RUNNER_TEMP}/${TEST_HELPERS_ARTIFACT}" + helper_target_dir="$(pwd)/target/${{ inputs.target }}/${{ inputs.profile }}" + mkdir -p "${helper_target_dir}" + cp "${helper_dir}/codex-linux-sandbox" "${helper_target_dir}/" + chmod +x "${helper_target_dir}/codex-linux-sandbox" + elif [[ "${RUNNER_OS}" == "Windows" ]]; then + helper_dir="${RUNNER_TEMP}/${TEST_HELPERS_ARTIFACT}" + helper_target_dir="$(pwd)/target/${{ inputs.target }}/${{ inputs.profile }}" + mkdir -p "${helper_target_dir}" + cp "${helper_dir}/codex-windows-sandbox-setup.exe" "${helper_target_dir}/" + cp "${helper_dir}/codex-command-runner.exe" "${helper_target_dir}/" + fi + + nextest_args=( + run + --no-fail-fast + --archive-file "${archive_file}" + --workspace-remap "${workspace_root}" + --partition "hash:${{ matrix.shard }}/4" + ) + if [[ "${{ inputs.test_threads }}" != "0" ]]; then + nextest_args+=(--test-threads "${{ inputs.test_threads }}") + fi + + test_command=(cargo nextest "${nextest_args[@]}") + if [[ "${RUNNER_OS}" == "Linux" ]]; then + sandbox_helper="${helper_target_dir}/codex-linux-sandbox" + test_command=( + env + "CARGO_BIN_EXE_codex-linux-sandbox=${sandbox_helper}" + "CARGO_BIN_EXE_codex_linux_sandbox=${sandbox_helper}" + cargo nextest "${nextest_args[@]}" + ) + elif [[ "${RUNNER_OS}" == "Windows" ]]; then + setup_helper="$(cygpath -w "${helper_target_dir}/codex-windows-sandbox-setup.exe")" + command_runner="$(cygpath -w "${helper_target_dir}/codex-command-runner.exe")" + test_command=( + env + "CARGO_BIN_EXE_codex_windows_sandbox_setup=${setup_helper}" + "CARGO_BIN_EXE_codex_command_runner=${command_runner}" + cargo nextest "${nextest_args[@]}" + ) + fi + + "${test_command[@]}" + env: + RUST_BACKTRACE: 1 + RUST_MIN_STACK: "8388608" # 8 MiB + NEXTEST_STATUS_LEVEL: leak + + - name: Upload nextest JUnit report + if: always() + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: nextest-junit-rust-ci-${{ inputs.artifact_id }}-shard-${{ matrix.shard }} + path: codex-rs/target/nextest/default/junit.xml + if-no-files-found: warn + + - name: Tear down remote test env + if: ${{ always() && runner.os == 'Linux' && inputs.remote_env }} + shell: bash + run: | + set +e + if [[ "${STEPS_TEST_OUTCOME}" != "success" ]]; then + docker logs "${CODEX_TEST_REMOTE_ENV}" || true + fi + docker rm -f "${CODEX_TEST_REMOTE_ENV}" >/dev/null 2>&1 || true + env: + STEPS_TEST_OUTCOME: ${{ steps.test.outcome }} + + - name: verify tests passed + if: steps.test.outcome == 'failure' + run: | + echo "Tests failed. See logs for details." + exit 1 + + result: + name: Platform result + needs: shard + if: always() + runs-on: ubuntu-24.04 + steps: + - name: Confirm test shards passed + shell: bash + run: | + if [[ "${{ needs.shard.result }}" != "success" ]]; then + echo "Nextest shards finished with result: ${{ needs.shard.result }}" >&2 + exit 1 + fi diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index de37faa4ca..60d508b21d 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -25,11 +25,16 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: components: rustfmt + - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 + with: + tool: just - name: cargo fmt run: cargo fmt -- --config imports_granularity=Item --check + - name: Rust benchmark smoke test + run: just bench-smoke cargo_shear: name: cargo shear @@ -41,7 +46,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 with: tool: cargo-shear@1.11.2 @@ -58,7 +63,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: toolchain: nightly-2025-09-18 components: llvm-tools-preview, rustc-dev, rust-src @@ -255,13 +260,9 @@ jobs: set -euo pipefail if command -v apt-get >/dev/null 2>&1; then sudo apt-get update -y - packages=(pkg-config libcap-dev) - if [[ "${{ matrix.target }}" == 'x86_64-unknown-linux-musl' || "${{ matrix.target }}" == 'aarch64-unknown-linux-musl' ]]; then - packages+=(libubsan1) - fi - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "${packages[@]}" + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev fi - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: targets: ${{ matrix.target }} components: clippy @@ -343,14 +344,6 @@ jobs: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}- sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}- - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Disable sccache wrapper (musl) - shell: bash - run: | - set -euo pipefail - echo "RUSTC_WRAPPER=" >> "$GITHUB_ENV" - echo "RUSTC_WORKSPACE_WRAPPER=" >> "$GITHUB_ENV" - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Prepare APT cache directories (musl) shell: bash @@ -384,61 +377,9 @@ jobs: shell: bash run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh" - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Configure rustc UBSan wrapper (musl host) - shell: bash - run: | - set -euo pipefail - ubsan="" - if command -v ldconfig >/dev/null 2>&1; then - ubsan="$(ldconfig -p | grep -m1 'libubsan\.so\.1' | sed -E 's/.*=> (.*)$/\1/')" - fi - wrapper_root="${RUNNER_TEMP:-/tmp}" - wrapper="${wrapper_root}/rustc-ubsan-wrapper" - cat > "${wrapper}" <> "$GITHUB_ENV" - echo "RUSTC_WORKSPACE_WRAPPER=" >> "$GITHUB_ENV" - - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Clear sanitizer flags (musl) - shell: bash - run: | - set -euo pipefail - # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. - echo "RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "RUSTDOCFLAGS=" >> "$GITHUB_ENV" - # Override any runner-level Cargo config rustflags as well. - echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" - - sanitize_flags() { - local input="$1" - input="${input//-fsanitize=undefined/}" - input="${input//-fno-sanitize-recover=undefined/}" - input="${input//-fno-sanitize-trap=undefined/}" - echo "$input" - } - - cflags="$(sanitize_flags "${CFLAGS-}")" - cxxflags="$(sanitize_flags "${CXXFLAGS-}")" - echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" - echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" - - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }} - name: Configure musl rusty_v8 artifact overrides and verify checksums - uses: ./.github/actions/setup-rusty-v8-musl + - if: ${{ !contains(matrix.target, 'windows') }} + name: Configure rusty_v8 artifact overrides and verify checksums + uses: ./.github/actions/setup-rusty-v8 with: target: ${{ matrix.target }} @@ -521,235 +462,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 }} - # Perhaps we can bring this back down to 30m once we finish the cutover - # from tui_app_server/ to tui/. Incidentally, windows-arm64 was the main - # offender for exceeding the timeout. - timeout-minutes: 45 - 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 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: @@ -761,7 +540,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 @@ -774,13 +557,21 @@ jobs: echo "general: ${{ needs.general.result }}" echo "shear : ${{ needs.cargo_shear.result }}" echo "lint : ${{ needs.lint_build.result }}" - echo "tests : ${{ needs.tests.result }}" + echo "test macos : ${{ needs.tests_macos_aarch64.result }}" + echo "test linux : ${{ needs.tests_linux_x64_remote.result }}" + echo "test arm64 : ${{ needs.tests_linux_arm64.result }}" + echo "test winx64: ${{ needs.tests_windows_x64.result }}" + echo "test winarm: ${{ needs.tests_windows_arm64.result }}" [[ '${{ needs.argument_comment_lint_package.result }}' == 'success' ]] || { echo 'argument_comment_lint_package failed'; exit 1; } [[ '${{ needs.argument_comment_lint_prebuilt.result }}' == 'success' ]] || { echo 'argument_comment_lint_prebuilt failed'; exit 1; } [[ '${{ needs.general.result }}' == 'success' ]] || { echo 'general failed'; exit 1; } [[ '${{ needs.cargo_shear.result }}' == 'success' ]] || { echo 'cargo_shear failed'; exit 1; } [[ '${{ needs.lint_build.result }}' == 'success' ]] || { echo 'lint_build failed'; exit 1; } - [[ '${{ needs.tests.result }}' == 'success' ]] || { echo 'tests failed'; exit 1; } + [[ '${{ needs.tests_macos_aarch64.result }}' == 'success' ]] || { echo 'tests_macos_aarch64 failed'; exit 1; } + [[ '${{ needs.tests_linux_x64_remote.result }}' == 'success' ]] || { echo 'tests_linux_x64_remote failed'; exit 1; } + [[ '${{ needs.tests_linux_arm64.result }}' == 'success' ]] || { echo 'tests_linux_arm64 failed'; exit 1; } + [[ '${{ needs.tests_windows_x64.result }}' == 'success' ]] || { echo 'tests_windows_x64 failed'; exit 1; } + [[ '${{ needs.tests_windows_arm64.result }}' == 'success' ]] || { echo 'tests_windows_arm64 failed'; exit 1; } - name: sccache summary note if: always() diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 75c5c33601..d1d7c0df96 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -67,11 +67,16 @@ jobs: with: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: components: rustfmt + - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 + with: + tool: just - name: cargo fmt run: cargo fmt -- --config imports_granularity=Item --check + - name: Rust benchmark smoke test + run: just bench-smoke cargo_shear: name: cargo shear @@ -86,7 +91,7 @@ jobs: with: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 with: tool: cargo-shear@1.11.2 @@ -106,7 +111,7 @@ jobs: with: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 - name: Install nightly argument-comment-lint toolchain shell: bash run: | diff --git a/.github/workflows/rust-release-argument-comment-lint.yml b/.github/workflows/rust-release-argument-comment-lint.yml index f654bd9dd7..94f15af7d4 100644 --- a/.github/workflows/rust-release-argument-comment-lint.yml +++ b/.github/workflows/rust-release-argument-comment-lint.yml @@ -60,7 +60,7 @@ jobs: with: persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: toolchain: nightly-2025-09-18 targets: ${{ matrix.target }} diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index 8a1b6030a8..91887cbe0b 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -34,6 +34,8 @@ jobs: working-directory: codex-rs env: CARGO_PROFILE_RELEASE_LTO: ${{ inputs.release-lto }} + CARGO_PROFILE_RELEASE_DEBUG: full + CARGO_PROFILE_RELEASE_STRIP: "false" strategy: fail-fast: false @@ -100,18 +102,22 @@ jobs: Write-Host "Total RAM: $ramGiB GiB" Write-Host "Disk usage:" Get-PSDrive -PSProvider FileSystem | Format-Table -AutoSize Name, @{Name='Size(GB)';Expression={[math]::Round(($_.Used + $_.Free) / 1GB, 1)}}, @{Name='Free(GB)';Expression={[math]::Round($_.Free / 1GB, 1)}} - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: targets: ${{ matrix.target }} - name: Cargo build (Windows binaries) shell: bash run: | + target="${{ matrix.target }}" + if [[ "$target" == "x86_64-pc-windows-msvc" ]]; then + export LIBSQLITE3_FLAGS=SQLITE_DISABLE_INTRINSIC + fi build_args=() for binary in ${{ matrix.binaries }}; do build_args+=(--bin "$binary") done - cargo build --target ${{ matrix.target }} --release --timings "${build_args[@]}" + cargo build --target "$target" --release --timings "${build_args[@]}" - name: Upload Cargo timings uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 @@ -127,6 +133,7 @@ jobs: mkdir -p "$output_dir" for binary in ${{ matrix.binaries }}; do cp "target/${{ matrix.target }}/release/${binary}.exe" "$output_dir/${binary}.exe" + cp "target/${{ matrix.target }}/release/${binary}.pdb" "$output_dir/${binary}.pdb" done - name: Upload Windows binaries @@ -209,6 +216,23 @@ jobs: account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} + - name: Build symbols archive + shell: bash + run: | + bash "${GITHUB_WORKSPACE}/.github/scripts/archive-release-symbols-and-strip-binaries.sh" \ + --target "${{ matrix.target }}" \ + --artifact-name "${{ matrix.target }}" \ + --release-dir "target/${{ matrix.target }}/release" \ + --archive-dir "symbols-dist/${{ matrix.target }}" \ + --binaries "${WINDOWS_BINARIES}" + + - name: Upload symbols archive + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ matrix.target }}-symbols + path: codex-rs/symbols-dist/${{ matrix.target }}/* + if-no-files-found: error + - name: Stage artifacts shell: bash run: | @@ -220,6 +244,21 @@ jobs: "$dest/${binary}-${{ matrix.target }}.exe" done + - name: Install DotSlash + uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 + + - name: Build Codex package archives + shell: bash + run: | + set -euo pipefail + for bundle in primary app-server; do + bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \ + --target "${{ matrix.target }}" \ + --bundle "$bundle" \ + --entrypoint-dir "target/${{ matrix.target }}/release" \ + --archive-dir "dist/${{ matrix.target }}" + done + - name: Build Python runtime wheel shell: bash run: | @@ -243,16 +282,12 @@ jobs: stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" - # Keep the helpers next to codex.exe in the runtime wheel so Windows - # sandbox/elevation lookup matches the standalone release zip. python "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" \ stage-runtime \ "$stage_dir" \ - "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex.exe" \ + "dist/${{ matrix.target }}/codex-package-${{ matrix.target }}.tar.gz" \ --codex-version "${GITHUB_REF_NAME}" \ - --platform-tag "$platform_tag" \ - --resource-binary "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex-command-runner.exe" \ - --resource-binary "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe" + --platform-tag "$platform_tag" "${RUNNER_TEMP}/python-runtime-build-venv/Scripts/python.exe" -m build --wheel --outdir "$wheel_dir" "$stage_dir" - name: Upload Python runtime wheel @@ -262,9 +297,6 @@ jobs: path: python-runtime-dist/${{ matrix.target }}/*.whl if-no-files-found: error - - name: Install DotSlash - uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 - - name: Compress artifacts shell: bash run: | @@ -283,7 +315,7 @@ jobs: base="$(basename "$f")" # Skip files that are already archives (shouldn't happen, but be # safe). - if [[ "$base" == *.tar.gz || "$base" == *.zip || "$base" == *.dmg ]]; then + if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then continue fi diff --git a/.github/workflows/rust-release-zsh.yml b/.github/workflows/rust-release-zsh.yml index 492b8dc5e7..b55d2e714b 100644 --- a/.github/workflows/rust-release-zsh.yml +++ b/.github/workflows/rust-release-zsh.yml @@ -69,6 +69,10 @@ jobs: fail-fast: false matrix: include: + - runner: macos-15-large + target: x86_64-apple-darwin + variant: macos-15 + archive_name: codex-zsh-x86_64-apple-darwin.tar.gz - runner: macos-15-xlarge target: aarch64-apple-darwin variant: macos-15 diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index ca082812c6..364fe78902 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -4,6 +4,13 @@ # git tag -a rust-v0.1.0 -m "Release 0.1.0" # git push origin rust-v0.1.0 # ``` +# +# To use external macOS signing, manually dispatch `release_mode=build_unsigned`, +# sign the unsigned macOS artifacts in a secure enclave, upload the signed handoff +# archive as a GitHub Release asset, then manually dispatch +# `release_mode=promote_signed` with `unsigned_run_id` and `signed_macos_asset`. +# The signed handoff archive should contain target or artifact directories such +# as `aarch64-apple-darwin/` with signed binaries. name: rust-release on: @@ -12,11 +19,31 @@ on: - "rust-v*.*.*" workflow_dispatch: inputs: + release_mode: + description: "build_unsigned creates unsigned macOS handoff artifacts; promote_signed finishes a release from signed macOS handoff artifacts." + required: false + type: choice + default: build_unsigned + options: + - build_unsigned + - promote_signed sign_macos: - description: "Sign and notarize macOS release artifacts." + description: "Deprecated compatibility input; use release_mode instead." required: false type: boolean - default: true + default: false + unsigned_run_id: + description: "For promote_signed: workflow run id from the build_unsigned run." + required: false + type: string + signed_macos_asset: + description: "For promote_signed: exact GitHub Release asset name containing signed macOS handoff artifacts." + required: false + type: string + signed_macos_sha256: + description: "For promote_signed: optional SHA-256 of signed_macos_asset." + required: false + type: string concurrency: group: ${{ github.workflow }} @@ -29,18 +56,61 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 - name: Validate tag matches Cargo.toml version shell: bash env: - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.sign_macos }} + RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} + REQUESTED_SIGN_MACOS: ${{ inputs.sign_macos }} + SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} + UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} run: | set -euo pipefail echo "::group::Tag validation" - if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${SIGN_MACOS}" == "true" ]]; then - echo "❌ Manual rust-release runs must set sign_macos=false" - exit 1 + case "${RELEASE_MODE}" in + signed) + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + echo "❌ Manual rust-release runs must use release_mode=build_unsigned or release_mode=promote_signed" + exit 1 + fi + ;; + build_unsigned) + if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then + echo "❌ release_mode=build_unsigned is only valid for manual runs" + exit 1 + fi + ;; + promote_signed) + if [[ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]]; then + echo "❌ release_mode=promote_signed is only valid for manual runs" + exit 1 + fi + if [[ ! "${UNSIGNED_RUN_ID}" =~ ^[0-9]+$ ]]; then + echo "❌ release_mode=promote_signed requires unsigned_run_id to be a workflow run id" + exit 1 + fi + if [[ -z "${SIGNED_MACOS_ASSET}" ]]; then + echo "❌ release_mode=promote_signed requires signed_macos_asset" + exit 1 + fi + if [[ "${SIGNED_MACOS_ASSET}" == */* || "${SIGNED_MACOS_ASSET}" == *"*"* || "${SIGNED_MACOS_ASSET}" == *"?"* || "${SIGNED_MACOS_ASSET}" == *"["* ]]; then + echo "❌ signed_macos_asset must be an exact release asset name, not a path or glob" + exit 1 + fi + if [[ "${UNSIGNED_RUN_ID}" == "${GITHUB_RUN_ID}" ]]; then + echo "❌ unsigned_run_id must refer to the earlier build_unsigned run, not this run" + exit 1 + fi + ;; + *) + echo "❌ Unknown release_mode '${RELEASE_MODE}'" + exit 1 + ;; + esac + + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${REQUESTED_SIGN_MACOS}" == "true" ]]; then + echo "::warning title=Deprecated sign_macos input ignored::Use release_mode=build_unsigned or release_mode=promote_signed instead." fi # 1. Must be a tag and match the regex @@ -62,6 +132,7 @@ jobs: echo "::endgroup::" build: + if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} needs: tag-check name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} runs-on: ${{ matrix.runs_on || matrix.runner }} @@ -78,7 +149,15 @@ jobs: # 2026-03-04: temporarily change releases to use thin LTO because # Ubuntu ARM is timing out at 60 minutes. CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }} - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.sign_macos }} + CARGO_PROFILE_RELEASE_DEBUG: full + CARGO_PROFILE_RELEASE_SPLIT_DEBUGINFO: ${{ contains(matrix.target, 'apple-darwin') && 'packed' || 'off' }} + CARGO_PROFILE_RELEASE_STRIP: "false" + # Use the git CLI instead of Cargo's libgit2 path for git dependencies. + # macOS release runners have intermittently failed to fetch nested + # submodules through SecureTransport/libgit2, especially libwebrtc's + # libyuv submodule from chromium.googlesource.com. + CARGO_NET_GIT_FETCH_WITH_CLI: "true" + SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' }} strategy: fail-fast: false @@ -109,25 +188,25 @@ jobs: binaries: "codex-app-server" build_dmg: "false" # Release artifacts intentionally ship MUSL-linked Linux binaries. - - runner: ubuntu-24.04 + - runner: codex-linux-x64-xl target: x86_64-unknown-linux-musl bundle: primary artifact_name: x86_64-unknown-linux-musl binaries: "codex codex-responses-api-proxy bwrap" build_dmg: "false" - - runner: ubuntu-24.04 + - runner: codex-linux-x64-xl target: x86_64-unknown-linux-musl bundle: app-server artifact_name: x86_64-unknown-linux-musl-app-server binaries: "codex-app-server" build_dmg: "false" - - runner: ubuntu-24.04-arm + - runner: codex-linux-arm64 target: aarch64-unknown-linux-musl bundle: primary artifact_name: aarch64-unknown-linux-musl binaries: "codex codex-responses-api-proxy bwrap" build_dmg: "false" - - runner: ubuntu-24.04-arm + - runner: codex-linux-arm64 target: aarch64-unknown-linux-musl bundle: app-server artifact_name: aarch64-unknown-linux-musl-app-server @@ -173,17 +252,8 @@ jobs: run: | set -euo pipefail sudo apt-get update -y - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev - - name: Install UBSan runtime (musl) - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }} - 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 libubsan1 - fi - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends binutils pkg-config libcap-dev + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: targets: ${{ matrix.target }} @@ -212,30 +282,7 @@ jobs: run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh" - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Configure rustc UBSan wrapper (musl host) - shell: bash - run: | - set -euo pipefail - ubsan="" - if command -v ldconfig >/dev/null 2>&1; then - ubsan="$(ldconfig -p | grep -m1 'libubsan\.so\.1' | sed -E 's/.*=> (.*)$/\1/')" - fi - wrapper_root="${RUNNER_TEMP:-/tmp}" - wrapper="${wrapper_root}/rustc-ubsan-wrapper" - cat > "${wrapper}" <> "$GITHUB_ENV" - echo "RUSTC_WORKSPACE_WRAPPER=" >> "$GITHUB_ENV" - - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Clear sanitizer flags (musl) + name: Disable aws-lc jitter entropy (musl) shell: bash run: | set -euo pipefail @@ -245,37 +292,12 @@ jobs: target_no_jitter="${target_no_jitter//-/_}" echo "${target_no_jitter}=1" >> "$GITHUB_ENV" - # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. - echo "RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "RUSTDOCFLAGS=" >> "$GITHUB_ENV" - # Override any runner-level Cargo config rustflags as well. - echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" - - sanitize_flags() { - local input="$1" - input="${input//-fsanitize=undefined/}" - input="${input//-fno-sanitize-recover=undefined/}" - input="${input//-fno-sanitize-trap=undefined/}" - echo "$input" - } - - cflags="$(sanitize_flags "${CFLAGS-}")" - cxxflags="$(sanitize_flags "${CXXFLAGS-}")" - echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" - echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" - - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }} - name: Configure musl rusty_v8 artifact overrides and verify checksums - uses: ./.github/actions/setup-rusty-v8-musl + - name: Configure rusty_v8 artifact overrides and verify checksums + uses: ./.github/actions/setup-rusty-v8 with: target: ${{ matrix.target }} - - if: ${{ contains(matrix.target, 'linux') && matrix.bundle == 'primary' }} + - if: ${{ contains(matrix.target, 'linux') }} name: Build bwrap and export digest shell: bash run: | @@ -289,6 +311,10 @@ jobs: exit 1 fi + # Codex embeds this digest at build time and verifies the bundled + # bwrap resource before use. Strip bwrap before hashing so the digest + # covers the exact bytes that the release packages. + strip --strip-debug --strip-unneeded "$bwrap_path" digest="$(sha256sum "$bwrap_path" | awk '{print $1}')" echo "CODEX_BWRAP_SHA256=${digest}" >> "$GITHUB_ENV" echo "Built bwrap ${bwrap_path} with sha256:${digest}" @@ -296,12 +322,21 @@ jobs: - name: Cargo build shell: bash run: | + target="${{ matrix.target }}" + if [[ "$target" == "x86_64-pc-windows-msvc" ]]; then + export LIBSQLITE3_FLAGS=SQLITE_DISABLE_INTRINSIC + fi build_args=() for binary in ${{ matrix.binaries }}; do + # bwrap was built, finalized, and hashed before this build so + # Codex can embed the digest of the bytes that will be packaged. + if [[ "$binary" == "bwrap" ]]; then + continue + fi build_args+=(--bin "$binary") done echo "CARGO_PROFILE_RELEASE_LTO: ${CARGO_PROFILE_RELEASE_LTO}" - cargo build --target ${{ matrix.target }} --release --timings "${build_args[@]}" + cargo build --target "$target" --release --timings "${build_args[@]}" - name: Upload Cargo timings uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 @@ -310,7 +345,33 @@ jobs: path: codex-rs/target/**/cargo-timings/cargo-timing.html if-no-files-found: warn - - if: ${{ runner.os == 'macOS' }} + - name: Build symbols archive and strip binaries + shell: bash + run: | + binaries=() + for binary in ${{ matrix.binaries }}; do + # bwrap is already stripped before hashing. Its symbols are not + # useful enough to justify a separate pre-Codex symbols pass. + if [[ "$binary" == "bwrap" ]]; then + continue + fi + binaries+=("$binary") + done + bash "${GITHUB_WORKSPACE}/.github/scripts/archive-release-symbols-and-strip-binaries.sh" \ + --target "${{ matrix.target }}" \ + --artifact-name "${{ matrix.artifact_name }}" \ + --release-dir "target/${{ matrix.target }}/release" \ + --archive-dir "symbols-dist/${{ matrix.artifact_name }}" \ + --binaries "${binaries[*]}" + + - name: Upload symbols archive + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ matrix.artifact_name }}-symbols + path: codex-rs/symbols-dist/${{ matrix.artifact_name }}/* + if-no-files-found: error + + - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} name: Stage unsigned macOS artifacts shell: bash run: | @@ -335,7 +396,7 @@ jobs: zstd -T0 -19 --rm "${unsigned_path}" done - - if: ${{ runner.os == 'macOS' }} + - if: ${{ runner.os == 'macOS' && env.SIGN_MACOS != 'true' }} name: Upload unsigned macOS artifacts uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: @@ -448,6 +509,20 @@ 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 + bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \ + --target "$TARGET" \ + --bundle "$BUNDLE" \ + --entrypoint-dir "target/${TARGET}/release" \ + --archive-dir "dist/${TARGET}" + - name: Build Python runtime wheel if: ${{ matrix.bundle == 'primary' && (runner.os != 'macOS' || env.SIGN_MACOS == 'true') }} shell: bash @@ -462,10 +537,10 @@ jobs: platform_tag="macosx_10_9_x86_64" ;; aarch64-unknown-linux-musl) - platform_tag="musllinux_1_1_aarch64" + platform_tag="manylinux_2_17_aarch64" ;; x86_64-unknown-linux-musl) - platform_tag="musllinux_1_1_x86_64" + platform_tag="manylinux_2_17_x86_64" ;; *) echo "No Python runtime wheel platform tag for ${{ matrix.target }}" @@ -484,18 +559,10 @@ jobs: "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" stage-runtime "$stage_dir" - "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex" + "dist/${{ matrix.target }}/codex-package-${{ matrix.target }}.tar.gz" --codex-version "${GITHUB_REF_NAME}" --platform-tag "$platform_tag" ) - if [[ "${{ matrix.target }}" == *linux* ]]; then - # Keep bwrap in the runtime wheel so Linux sandbox fallback behavior - # matches the standalone release bundle on hosts without system bwrap. - stage_runtime_args+=( - --resource-binary - "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/bwrap" - ) - fi python3 "${stage_runtime_args[@]}" "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" @@ -553,7 +620,247 @@ jobs: path: | codex-rs/dist/${{ matrix.target }}/* + stage-signed-macos: + if: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode == 'promote_signed' }} + needs: tag-check + name: Stage signed macOS handoff - ${{ matrix.target }} - ${{ matrix.bundle }} + runs-on: macos-15-xlarge + timeout-minutes: 30 + permissions: + contents: read + defaults: + run: + working-directory: codex-rs + + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + bundle: primary + artifact_name: aarch64-apple-darwin + binaries: "codex codex-responses-api-proxy" + build_dmg: "false" + - target: aarch64-apple-darwin + bundle: app-server + artifact_name: aarch64-apple-darwin-app-server + binaries: "codex-app-server" + build_dmg: "false" + - target: x86_64-apple-darwin + bundle: primary + artifact_name: x86_64-apple-darwin + binaries: "codex codex-responses-api-proxy" + build_dmg: "false" + - target: x86_64-apple-darwin + bundle: app-server + artifact_name: x86_64-apple-darwin-app-server + binaries: "codex-app-server" + build_dmg: "false" + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Download signed macOS handoff + shell: bash + env: + GH_TOKEN: ${{ github.token }} + SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} + SIGNED_MACOS_SHA256: ${{ inputs.signed_macos_sha256 }} + run: | + set -euo pipefail + + download_dir="${RUNNER_TEMP}/signed-macos-download" + handoff_dir="${RUNNER_TEMP}/signed-macos-handoff" + rm -rf "$download_dir" "$handoff_dir" + mkdir -p "$download_dir" "$handoff_dir" + + gh release download "$GITHUB_REF_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + --pattern "$SIGNED_MACOS_ASSET" \ + --dir "$download_dir" + + asset_count="$(find "$download_dir" -maxdepth 1 -type f | wc -l | tr -d '[:space:]')" + if [[ "$asset_count" != "1" ]]; then + echo "Expected exactly one signed macOS handoff asset named ${SIGNED_MACOS_ASSET}; found ${asset_count}" + find "$download_dir" -maxdepth 1 -type f -print + exit 1 + fi + + asset_path="$(find "$download_dir" -maxdepth 1 -type f -print -quit)" + if [[ -n "${SIGNED_MACOS_SHA256}" ]]; then + expected_sha="$(printf '%s' "$SIGNED_MACOS_SHA256" | tr '[:upper:]' '[:lower:]')" + actual_sha="$(shasum -a 256 "$asset_path" | awk '{print $1}')" + if [[ "$actual_sha" != "$expected_sha" ]]; then + echo "signed_macos_sha256 mismatch for ${SIGNED_MACOS_ASSET}" + echo "expected: ${expected_sha}" + echo "actual: ${actual_sha}" + exit 1 + fi + fi + + asset_name="$(basename "$asset_path")" + case "$asset_name" in + *.tar.zst) + zstd -dc "$asset_path" | tar -C "$handoff_dir" -xf - + ;; + *.tar.gz|*.tgz) + tar -C "$handoff_dir" -xzf "$asset_path" + ;; + *.zip) + ditto -x -k "$asset_path" "$handoff_dir" + ;; + *) + echo "Unsupported signed macOS handoff archive format: ${asset_name}" + exit 1 + ;; + esac + + echo "SIGNED_MACOS_HANDOFF_DIR=$handoff_dir" >> "$GITHUB_ENV" + + - name: Stage signed macOS artifacts + shell: bash + run: | + set -euo pipefail + + target="${{ matrix.target }}" + artifact_name="${{ matrix.artifact_name }}" + source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" + if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" ]]; then + source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" + fi + if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/${target}" ]]; then + source_dir="${SIGNED_MACOS_HANDOFF_DIR}/${target}" + fi + if [[ ! -d "$source_dir" && -d "${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" ]]; then + source_dir="${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" + fi + if [[ ! -d "$source_dir" ]]; then + echo "Signed macOS handoff is missing ${artifact_name}/" + echo "Expected either:" + echo " ${SIGNED_MACOS_HANDOFF_DIR}/${artifact_name}" + echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${artifact_name}" + echo " ${SIGNED_MACOS_HANDOFF_DIR}/${target}" + echo " ${SIGNED_MACOS_HANDOFF_DIR}/dist/${target}" + find "$SIGNED_MACOS_HANDOFF_DIR" -maxdepth 3 -type f -print + exit 1 + fi + + dest="dist/${target}" + mkdir -p "$dest" + + for binary in ${{ matrix.binaries }}; do + source_path="${source_dir}/${binary}" + if [[ ! -f "$source_path" ]]; then + source_path="${source_dir}/${binary}-${target}" + fi + if [[ ! -f "$source_path" ]]; then + echo "Signed macOS handoff is missing ${binary} for ${artifact_name}" + exit 1 + fi + + release_path="${dest}/${binary}-${target}" + ditto "$source_path" "$release_path" + chmod 0755 "$release_path" + codesign --verify --strict --verbose=2 "$release_path" + done + + # DMG staging is disabled for signed promotion because we no longer + # distribute DMGs from this release path. Keep the branch here so the + # handoff can opt back in by flipping matrix.build_dmg if needed. + if [[ "${{ matrix.build_dmg }}" == "true" ]]; then + dmg_name="codex-${target}.dmg" + dmg_source="${source_dir}/${dmg_name}" + if [[ ! -f "$dmg_source" ]]; then + echo "Signed macOS handoff is missing ${dmg_name} for ${artifact_name}" + exit 1 + fi + + codesign --verify --strict --verbose=2 "$dmg_source" + xcrun stapler validate "$dmg_source" + cp "$dmg_source" "$dest/$dmg_name" + fi + + - name: Build Codex package archive + shell: bash + env: + TARGET: ${{ matrix.target }} + BUNDLE: ${{ matrix.bundle }} + run: | + set -euo pipefail + bash "${GITHUB_WORKSPACE}/.github/scripts/build-codex-package-archive.sh" \ + --target "$TARGET" \ + --bundle "$BUNDLE" \ + --entrypoint-dir "dist/${TARGET}" \ + --archive-dir "dist/${TARGET}" \ + --target-suffixed-entrypoint + + - name: Build Python runtime wheel + if: ${{ matrix.bundle == 'primary' }} + shell: bash + run: | + set -euo pipefail + + case "${{ matrix.target }}" in + aarch64-apple-darwin) + platform_tag="macosx_11_0_arm64" + ;; + x86_64-apple-darwin) + platform_tag="macosx_10_9_x86_64" + ;; + *) + echo "No Python runtime wheel platform tag for ${{ matrix.target }}" + exit 1 + ;; + esac + + python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" + "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build + + stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" + wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" + python3 \ + "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" \ + stage-runtime \ + "$stage_dir" \ + "dist/${{ matrix.target }}/codex-package-${{ matrix.target }}.tar.gz" \ + --codex-version "${GITHUB_REF_NAME}" \ + --platform-tag "$platform_tag" + "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" + + - name: Upload Python runtime wheel + if: ${{ matrix.bundle == 'primary' }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: python-runtime-wheel-${{ matrix.target }} + path: python-runtime-dist/${{ matrix.target }}/*.whl + if-no-files-found: error + + - name: Compress artifacts + shell: bash + run: | + set -euo pipefail + + dest="dist/${{ matrix.target }}" + for f in "$dest"/*; do + base="$(basename "$f")" + if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then + continue + fi + + tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" + zstd -T0 -19 --rm "$dest/$base" + done + + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: ${{ matrix.artifact_name }} + path: | + codex-rs/dist/${{ matrix.target }}/* + build-windows: + if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} needs: tag-check uses: ./.github/workflows/rust-release-windows.yml with: @@ -561,6 +868,7 @@ jobs: secrets: inherit argument-comment-lint-release-assets: + if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} name: argument-comment-lint release assets needs: tag-check uses: ./.github/workflows/rust-release-argument-comment-lint.yml @@ -568,23 +876,53 @@ jobs: publish: true zsh-release-assets: + if: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed' }} name: zsh release assets needs: tag-check uses: ./.github/workflows/rust-release-zsh.yml release: needs: + - tag-check - build + - stage-signed-macos - build-windows - argument-comment-lint-release-assets - zsh-release-assets + if: >- + ${{ + always() && + needs.tag-check.result == 'success' && + ( + ( + github.event_name == 'workflow_dispatch' && + inputs.release_mode == 'promote_signed' && + needs.stage-signed-macos.result == 'success' && + needs.build.result == 'skipped' && + needs.build-windows.result == 'skipped' && + needs.argument-comment-lint-release-assets.result == 'skipped' && + needs.zsh-release-assets.result == 'skipped' + ) || + ( + (github.event_name != 'workflow_dispatch' || inputs.release_mode != 'promote_signed') && + needs.build.result == 'success' && + needs.stage-signed-macos.result == 'skipped' && + needs.build-windows.result == 'success' && + needs.argument-comment-lint-release-assets.result == 'success' && + needs.zsh-release-assets.result == 'success' + ) + ) + }} name: release runs-on: ubuntu-latest permissions: contents: write actions: read env: - SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.sign_macos }} + RELEASE_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_mode || 'signed' }} + SIGN_MACOS: ${{ github.event_name != 'workflow_dispatch' || inputs.release_mode == 'promote_signed' }} + SIGNED_MACOS_ASSET: ${{ inputs.signed_macos_asset }} + UNSIGNED_RUN_ID: ${{ inputs.unsigned_run_id }} outputs: version: ${{ steps.release_name.outputs.name }} tag: ${{ github.ref_name }} @@ -602,6 +940,7 @@ jobs: - name: Define release mode id: release_mode run: | + echo "release_mode=${RELEASE_MODE}" >> "$GITHUB_OUTPUT" echo "sign_macos=${SIGN_MACOS}" >> "$GITHUB_OUTPUT" - name: Generate release notes from tag commit message @@ -628,6 +967,100 @@ jobs: with: path: dist + - name: Validate unsigned build run + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + run_summary="$(gh run view "$UNSIGNED_RUN_ID" \ + --repo "$GITHUB_REPOSITORY" \ + --json conclusion,event,headBranch,headSha,status,workflowName,url \ + --jq '[.workflowName, .event, .headBranch, .headSha, .status, .conclusion, .url] | @tsv')" + IFS=$'\t' read -r workflow_name event head_branch head_sha status conclusion run_url <<< "$run_summary" + expected_head_sha="$(git rev-parse "${GITHUB_SHA}^{commit}")" + + if [[ "$workflow_name" != "$GITHUB_WORKFLOW" ]]; then + echo "unsigned_run_id ${UNSIGNED_RUN_ID} is for workflow '${workflow_name}', expected '${GITHUB_WORKFLOW}'" + echo "Run URL: ${run_url}" + exit 1 + fi + + if [[ "$event" != "workflow_dispatch" ]]; then + echo "unsigned_run_id ${UNSIGNED_RUN_ID} was triggered by '${event}', expected 'workflow_dispatch'" + echo "Run URL: ${run_url}" + exit 1 + fi + + if [[ "$head_branch" != "$GITHUB_REF_NAME" ]]; then + echo "unsigned_run_id ${UNSIGNED_RUN_ID} used ref '${head_branch}', expected '${GITHUB_REF_NAME}'" + echo "Run URL: ${run_url}" + exit 1 + fi + + if [[ "$head_sha" != "$expected_head_sha" ]]; then + echo "unsigned_run_id ${UNSIGNED_RUN_ID} used head SHA '${head_sha}', expected '${expected_head_sha}'" + echo "Run URL: ${run_url}" + exit 1 + fi + + if [[ "$status" != "completed" || "$conclusion" != "success" ]]; then + echo "unsigned_run_id ${UNSIGNED_RUN_ID} is ${status}/${conclusion}, expected completed/success" + echo "Run URL: ${run_url}" + exit 1 + fi + + - name: Download artifacts from unsigned build run + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh run download "$UNSIGNED_RUN_ID" \ + --repo "$GITHUB_REPOSITORY" \ + --dir dist + + - name: Remove unsigned macOS staging artifacts + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + run: | + set -euo pipefail + find dist -mindepth 1 -maxdepth 1 -type d \ + -name '*-apple-darwin*-unsigned' \ + -exec rm -rf {} + + + - name: Re-upload promoted Linux x64 artifacts + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: x86_64-unknown-linux-musl + path: dist/x86_64-unknown-linux-musl/* + if-no-files-found: error + + - name: Re-upload promoted Linux arm64 artifacts + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: aarch64-unknown-linux-musl + path: dist/aarch64-unknown-linux-musl/* + if-no-files-found: error + + - name: Re-upload promoted Windows x64 artifacts + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: x86_64-pc-windows-msvc + path: dist/x86_64-pc-windows-msvc/* + if-no-files-found: error + + - name: Re-upload promoted Windows arm64 artifacts + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: aarch64-pc-windows-msvc + path: dist/aarch64-pc-windows-msvc/* + if-no-files-found: error + - name: List run: ls -R dist/ @@ -636,6 +1069,7 @@ jobs: run: | find dist -mindepth 1 -maxdepth 1 -type d \ ! -name '*-apple-darwin*-unsigned' \ + ! -name '*-symbols' \ ! -name 'aarch64-unknown-linux-musl' \ ! -name 'aarch64-unknown-linux-musl-app-server' \ ! -name 'x86_64-unknown-linux-musl' \ @@ -660,6 +1094,29 @@ jobs: ls -R dist/ + - name: Add Codex package checksum manifest + run: | + set -euo pipefail + + manifest="dist/codex-package_SHA256SUMS" + tmp_manifest="$(mktemp)" + find dist -type f \ + \( -name 'codex-package-*.tar.gz' -o -name 'codex-app-server-package-*.tar.gz' \) \ + -print | + sort | + while IFS= read -r archive; do + sha256sum "$archive" | + awk -v name="$(basename "$archive")" '{ print $1 " " name }' + done > "$tmp_manifest" + + if [[ ! -s "$tmp_manifest" ]]; then + echo "No Codex package archives found for checksum manifest" + exit 1 + fi + + mv "$tmp_manifest" "$manifest" + cat "$manifest" + - name: Add config schema release asset run: | cp codex-rs/core/config.schema.json dist/config-schema.json @@ -734,16 +1191,16 @@ jobs: if: ${{ env.SIGN_MACOS == 'true' }} run: pnpm install --frozen-lockfile - # stage_npm_packages.py requires DotSlash when staging releases. - - uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 - name: Stage npm packages if: ${{ env.SIGN_MACOS == 'true' }} env: GH_TOKEN: ${{ github.token }} RELEASE_VERSION: ${{ steps.release_name.outputs.name }} run: | + workflow_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" ./scripts/stage_npm_packages.py \ --release-version "$RELEASE_VERSION" \ + --workflow-url "$workflow_url" \ --package codex \ --package codex-responses-api-proxy \ --package codex-sdk @@ -761,11 +1218,38 @@ jobs: tag_name: ${{ github.ref_name }} body_path: ${{ steps.release_notes.outputs.path }} files: dist/** + overwrite_files: true make_latest: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }} # Mark as prerelease only when the version has a suffix after x.y.z # (e.g. -alpha, -beta). Otherwise publish a normal release. prerelease: ${{ contains(steps.release_name.outputs.name, '-') }} + - name: Clean up signed promotion handoff assets + if: ${{ env.RELEASE_MODE == 'promote_signed' }} + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${GITHUB_REF_NAME}" --jq '.id')" + gh api --paginate "repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets" \ + --jq '.[] | [.id, .name] | @tsv' | + while IFS=$'\t' read -r asset_id asset_name; do + if [[ -z "$asset_id" || -z "$asset_name" ]]; then + continue + fi + + delete_asset=false + if [[ "$asset_name" == *unsigned* || "$asset_name" == "$SIGNED_MACOS_ASSET" ]]; then + delete_asset=true + fi + + if [[ "$delete_asset" == "true" ]]; then + echo "Deleting release asset ${asset_name}" + gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" + fi + done + - if: ${{ env.SIGN_MACOS == 'true' }} uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 env: @@ -774,14 +1258,6 @@ jobs: tag: ${{ github.ref_name }} config: .github/dotslash-config.json - - if: ${{ env.SIGN_MACOS == 'false' }} - uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-unsigned-config.json - - if: ${{ env.SIGN_MACOS == 'true' }} uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2 env: @@ -798,25 +1274,20 @@ jobs: tag: ${{ github.ref_name }} config: .github/dotslash-argument-comment-lint-config.json - - name: Trigger developers.openai.com deploy - # Only trigger the deploy if the release is not a pre-release. - # The deploy is used to update the developers.openai.com website with the new config schema json file. - if: ${{ env.SIGN_MACOS == 'true' && !contains(steps.release_name.outputs.name, '-') }} - continue-on-error: true - env: - DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL: ${{ secrets.DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL }} - run: | - if ! curl -sS -f -o /dev/null -X POST "$DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL"; then - echo "::warning title=developers.openai.com deploy hook failed::Vercel deploy hook POST failed for ${GITHUB_REF_NAME}" - exit 1 - fi - # Publish to npm using OIDC authentication. # July 31, 2025: https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/ # npm docs: https://docs.npmjs.com/trusted-publishers publish-npm: # Publish to npm for stable releases and alpha pre-releases with numeric suffixes. - if: ${{ needs.release.outputs.should_publish_npm == 'true' }} + # promote_signed intentionally skips build jobs that are ancestors of release; + # include the !cancelled() status function so Actions does not apply its implicit + # success() check to the whole dependency chain before evaluating release outputs. + if: >- + ${{ + !cancelled() && + needs.release.result == 'success' && + needs.release.outputs.should_publish_npm == 'true' + }} name: publish-npm needs: release runs-on: ubuntu-latest @@ -974,7 +1445,12 @@ jobs: # need release follow-up, but should not invalidate the Rust release itself. publish-python-runtime: # Publish to PyPI for stable releases and alpha pre-releases with numeric suffixes. - if: ${{ needs.release.outputs.should_publish_python_runtime == 'true' }} + if: >- + ${{ + !cancelled() && + needs.release.result == 'success' && + needs.release.outputs.should_publish_python_runtime == 'true' + }} name: publish-python-runtime needs: release runs-on: ubuntu-latest @@ -1010,12 +1486,48 @@ jobs: packages-dir: dist/python-runtime skip-existing: true + deploy-dev-website: + name: Trigger developers.openai.com deploy + needs: release + # Only trigger the deploy for a stable signed release. + # The deploy updates developers.openai.com with the new config schema json file. + if: >- + ${{ + !cancelled() && + needs.release.result == 'success' && + needs.release.outputs.sign_macos == 'true' && + !contains(needs.release.outputs.version, '-') + }} + runs-on: ubuntu-latest + continue-on-error: true + permissions: {} + environment: + name: dev-website-vercel-deploy + deployment: false + + steps: + - name: Trigger developers.openai.com deploy + continue-on-error: true + env: + DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL: ${{ secrets.DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL }} + run: | + if ! curl -sS -f -o /dev/null -X POST "$DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL"; then + echo "::warning title=developers.openai.com deploy hook failed::Vercel deploy hook POST failed for ${GITHUB_REF_NAME}" + exit 1 + fi + winget: name: winget needs: release # Only publish stable/mainline releases to WinGet; pre-releases include a # '-' in the semver string (e.g., 1.2.3-alpha.1). - if: ${{ needs.release.outputs.sign_macos == 'true' && !contains(needs.release.outputs.version, '-') }} + if: >- + ${{ + !cancelled() && + needs.release.result == 'success' && + needs.release.outputs.sign_macos == 'true' && + !contains(needs.release.outputs.version, '-') + }} # This job only invokes a GitHub Action to open/update the winget-pkgs PR; # it does not execute Windows-only tooling, so Linux is sufficient. runs-on: ubuntu-latest @@ -1035,7 +1547,12 @@ jobs: update-branch: name: Update latest-alpha-cli branch - if: ${{ needs.release.outputs.sign_macos == 'true' }} + if: >- + ${{ + !cancelled() && + needs.release.result == 'success' && + needs.release.outputs.sign_macos == 'true' + }} permissions: contents: write needs: release diff --git a/.github/workflows/rusty-v8-release.yml b/.github/workflows/rusty-v8-release.yml index 5a436722bb..d98cdb2c43 100644 --- a/.github/workflows/rusty-v8-release.yml +++ b/.github/workflows/rusty-v8-release.yml @@ -46,14 +46,14 @@ jobs: expected_release_tag="rusty-v8-v${V8_VERSION}" release_tag="${GITHUB_REF_NAME}" if [[ "${release_tag}" != "${expected_release_tag}" ]]; then - echo "Tag ${release_tag} does not match resolved v8 crate version ${V8_VERSION}." >&2 + echo "Tag ${release_tag} does not match expected release tag ${expected_release_tag}." >&2 exit 1 fi echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT" build: - name: Build ${{ matrix.target }} + name: Build ${{ matrix.variant }} ${{ matrix.target }} needs: metadata runs-on: ${{ matrix.runner }} permissions: @@ -64,11 +64,77 @@ jobs: matrix: include: - runner: ubuntu-24.04 - platform: linux_amd64_musl - target: x86_64-unknown-linux-musl + bazel_config: ci-v8 + platform: linux_amd64 + sandbox: false + target: x86_64-unknown-linux-gnu + variant: release + - runner: ubuntu-24.04 + bazel_config: ci-v8 + platform: linux_amd64 + sandbox: true + target: x86_64-unknown-linux-gnu + variant: ptrcomp-sandbox - runner: ubuntu-24.04-arm + bazel_config: ci-v8 + platform: linux_arm64 + sandbox: false + target: aarch64-unknown-linux-gnu + variant: release + - runner: ubuntu-24.04-arm + bazel_config: ci-v8 + platform: linux_arm64 + sandbox: true + target: aarch64-unknown-linux-gnu + variant: ptrcomp-sandbox + - runner: macos-15-xlarge + bazel_config: ci-macos + platform: macos_amd64 + sandbox: false + target: x86_64-apple-darwin + variant: release + - runner: macos-15-xlarge + bazel_config: ci-macos + platform: macos_amd64 + sandbox: true + target: x86_64-apple-darwin + variant: ptrcomp-sandbox + - runner: macos-15-xlarge + bazel_config: ci-macos + platform: macos_arm64 + sandbox: false + target: aarch64-apple-darwin + variant: release + - runner: macos-15-xlarge + bazel_config: ci-macos + platform: macos_arm64 + sandbox: true + target: aarch64-apple-darwin + variant: ptrcomp-sandbox + - runner: ubuntu-24.04 + bazel_config: ci-v8 + platform: linux_amd64_musl + sandbox: false + target: x86_64-unknown-linux-musl + variant: release + - runner: ubuntu-24.04-arm + bazel_config: ci-v8 platform: linux_arm64_musl + sandbox: false target: aarch64-unknown-linux-musl + variant: release + - runner: ubuntu-24.04 + bazel_config: ci-v8 + platform: linux_amd64_musl + sandbox: true + target: x86_64-unknown-linux-musl + variant: ptrcomp-sandbox + - runner: ubuntu-24.04-arm + bazel_config: ci-v8 + platform: linux_arm64_musl + sandbox: true + target: aarch64-unknown-linux-musl + variant: ptrcomp-sandbox steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -85,61 +151,115 @@ jobs: with: python-version: "3.12" + - name: Set up Rust toolchain for Cargo smoke + uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + with: + toolchain: "1.95.0" + - name: Build Bazel V8 release pair env: BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} PLATFORM: ${{ matrix.platform }} + SANDBOX: ${{ matrix.sandbox }} TARGET: ${{ matrix.target }} shell: bash run: | set -euo pipefail target_suffix="${TARGET//-/_}" - pair_target="//third_party/v8:rusty_v8_release_pair_${target_suffix}" - extra_targets=() - if [[ "${TARGET}" == *-unknown-linux-musl ]]; then - extra_targets=( - "@llvm//runtimes/libcxx:libcxx.static" - "@llvm//runtimes/libcxx:libcxxabi.static" - ) + pair_kind="release_pair" + if [[ "${SANDBOX}" == "true" ]]; then + pair_kind="sandbox_release_pair" fi + pair_target="//third_party/v8:rusty_v8_${pair_kind}_${target_suffix}" bazel_args=( build -c opt "--platforms=@llvm//platforms:${PLATFORM}" - --config=v8-release-compat + --config=rusty-v8-upstream-libcxx "${pair_target}" - "${extra_targets[@]}" --build_metadata=COMMIT_SHA=$(git rev-parse HEAD) ) + if [[ "${SANDBOX}" != "true" ]]; then + bazel_args+=(--config=v8-release-compat) + fi bazel \ --noexperimental_remote_repo_contents_cache \ "${bazel_args[@]}" \ - --config=ci-v8 \ + "--config=${{ matrix.bazel_config }}" \ "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" - name: Stage release pair env: + BAZEL_CONFIG: ${{ matrix.bazel_config }} + BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} PLATFORM: ${{ matrix.platform }} + SANDBOX: ${{ matrix.sandbox }} TARGET: ${{ matrix.target }} shell: bash run: | set -euo pipefail - python3 .github/scripts/rusty_v8_bazel.py stage-release-pair \ - --platform "${PLATFORM}" \ - --target "${TARGET}" \ - --compilation-mode opt \ - --bazel-config v8-release-compat \ + stage_args=( + --platform "${PLATFORM}" + --target "${TARGET}" + --compilation-mode opt --output-dir "dist/${TARGET}" + --bazel-config "${BAZEL_CONFIG}" + ) + if [[ "${SANDBOX}" == "true" ]]; then + stage_args+=(--sandbox) + else + stage_args+=(--bazel-config v8-release-compat) + fi - - name: Upload staged musl artifacts + python3 .github/scripts/rusty_v8_bazel.py stage-release-pair "${stage_args[@]}" + + - name: Smoke test staged artifact with Cargo + env: + SANDBOX: ${{ matrix.sandbox }} + TARGET: ${{ matrix.target }} + shell: bash + run: | + set -euo pipefail + + host_arch="$(uname -m)" + case "${TARGET}:${host_arch}" in + x86_64-apple-darwin:x86_64|aarch64-apple-darwin:arm64|x86_64-unknown-linux-gnu:x86_64|aarch64-unknown-linux-gnu:aarch64) + ;; + *) + echo "Skipping non-native Cargo smoke for ${TARGET} on ${host_arch}." + exit 0 + ;; + esac + + archive="$(find "dist/${TARGET}" -maxdepth 1 -type f -name 'librusty_v8_*.a.gz' -print -quit)" + binding="$(find "dist/${TARGET}" -maxdepth 1 -type f -name 'src_binding_*.rs' -print -quit)" + if [[ -z "${archive}" || -z "${binding}" ]]; then + echo "Missing staged archive or binding for ${TARGET}." >&2 + exit 1 + fi + + cargo_args=(test -p codex-v8-poc) + if [[ "${SANDBOX}" == "true" ]]; then + cargo_args+=(--features sandbox) + fi + + ( + cd codex-rs + CARGO_TARGET_DIR="${RUNNER_TEMP}/rusty-v8-cargo-smoke-${TARGET}-${SANDBOX}" \ + RUSTY_V8_ARCHIVE="${GITHUB_WORKSPACE}/${archive}" \ + RUSTY_V8_SRC_BINDING_PATH="${GITHUB_WORKSPACE}/${binding}" \ + cargo "${cargo_args[@]}" + ) + + - name: Upload staged artifacts uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: - name: rusty-v8-${{ needs.metadata.outputs.v8_version }}-${{ matrix.target }} + name: rusty-v8-${{ needs.metadata.outputs.v8_version }}-${{ matrix.variant }}-${{ matrix.target }} path: dist/${{ matrix.target }}/* publish-release: @@ -152,7 +272,8 @@ jobs: actions: read steps: - - name: Ensure release tag is new + - name: Check whether release already exists + id: release env: GH_TOKEN: ${{ github.token }} RELEASE_TAG: ${{ needs.metadata.outputs.release_tag }} @@ -161,8 +282,9 @@ jobs: set -euo pipefail if gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" > /dev/null 2>&1; then - echo "Release tag ${RELEASE_TAG} already exists; musl artifact tags are immutable." >&2 - exit 1 + echo "exists=true" >> "${GITHUB_OUTPUT}" + else + echo "exists=false" >> "${GITHUB_OUTPUT}" fi - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -170,6 +292,7 @@ jobs: path: dist - name: Create GitHub Release + if: ${{ steps.release.outputs.exists != 'true' }} uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 with: tag_name: ${{ needs.metadata.outputs.release_tag }} @@ -177,3 +300,14 @@ jobs: files: dist/** # Keep V8 artifact releases out of Codex's normal "latest release" channel. prerelease: true + + - name: Amend existing GitHub Release + if: ${{ steps.release.outputs.exists == 'true' }} + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 + with: + tag_name: ${{ needs.metadata.outputs.release_tag }} + name: ${{ needs.metadata.outputs.release_tag }} + files: dist/** + overwrite_files: true + # Keep V8 artifact releases out of Codex's normal "latest release" channel. + prerelease: true diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index 6e71367d1f..6341f218a6 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -3,28 +3,36 @@ name: v8-canary on: pull_request: paths: + - ".bazelrc" - ".github/actions/setup-bazel-ci/**" - ".github/scripts/rusty_v8_bazel.py" + - ".github/scripts/rusty_v8_module_bazel.py" - ".github/workflows/rusty-v8-release.yml" - ".github/workflows/v8-canary.yml" - "MODULE.bazel" - "MODULE.bazel.lock" - "codex-rs/Cargo.toml" - "patches/BUILD.bazel" + - "patches/llvm_*.patch" + - "patches/rules_cc_*.patch" - "patches/v8_*.patch" - "third_party/v8/**" push: branches: - main paths: + - ".bazelrc" - ".github/actions/setup-bazel-ci/**" - ".github/scripts/rusty_v8_bazel.py" + - ".github/scripts/rusty_v8_module_bazel.py" - ".github/workflows/rusty-v8-release.yml" - ".github/workflows/v8-canary.yml" - "MODULE.bazel" - "MODULE.bazel.lock" - "codex-rs/Cargo.toml" - "patches/BUILD.bazel" + - "patches/llvm_*.patch" + - "patches/rules_cc_*.patch" - "patches/v8_*.patch" - "third_party/v8/**" workflow_dispatch: @@ -59,7 +67,7 @@ jobs: echo "version=${version}" >> "$GITHUB_OUTPUT" build: - name: Build ${{ matrix.target }} + name: Build ${{ matrix.variant }} ${{ matrix.target }} needs: metadata runs-on: ${{ matrix.runner }} permissions: @@ -70,12 +78,77 @@ jobs: matrix: include: - runner: ubuntu-24.04 - platform: linux_amd64_musl - target: x86_64-unknown-linux-musl + bazel_config: ci-v8 + platform: linux_amd64 + sandbox: false + target: x86_64-unknown-linux-gnu + variant: release + - runner: ubuntu-24.04 + bazel_config: ci-v8 + platform: linux_amd64 + sandbox: true + target: x86_64-unknown-linux-gnu + variant: ptrcomp-sandbox - runner: ubuntu-24.04-arm + bazel_config: ci-v8 + platform: linux_arm64 + sandbox: false + target: aarch64-unknown-linux-gnu + variant: release + - runner: ubuntu-24.04-arm + bazel_config: ci-v8 + platform: linux_arm64 + sandbox: true + target: aarch64-unknown-linux-gnu + variant: ptrcomp-sandbox + - runner: macos-15-xlarge + bazel_config: ci-macos + platform: macos_amd64 + sandbox: false + target: x86_64-apple-darwin + variant: release + - runner: macos-15-xlarge + bazel_config: ci-macos + platform: macos_amd64 + sandbox: true + target: x86_64-apple-darwin + variant: ptrcomp-sandbox + - runner: macos-15-xlarge + bazel_config: ci-macos + platform: macos_arm64 + sandbox: false + target: aarch64-apple-darwin + variant: release + - runner: macos-15-xlarge + bazel_config: ci-macos + platform: macos_arm64 + sandbox: true + target: aarch64-apple-darwin + variant: ptrcomp-sandbox + - runner: ubuntu-24.04 + bazel_config: ci-v8 + platform: linux_amd64_musl + sandbox: false + target: x86_64-unknown-linux-musl + variant: release + - runner: ubuntu-24.04 + bazel_config: ci-v8 + platform: linux_amd64_musl + sandbox: true + target: x86_64-unknown-linux-musl + variant: ptrcomp-sandbox + - runner: ubuntu-24.04-arm + bazel_config: ci-v8 platform: linux_arm64_musl + sandbox: false target: aarch64-unknown-linux-musl - + variant: release + - runner: ubuntu-24.04-arm + bazel_config: ci-v8 + platform: linux_arm64_musl + sandbox: true + target: aarch64-unknown-linux-musl + variant: ptrcomp-sandbox steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -92,53 +165,247 @@ jobs: with: python-version: "3.12" + - name: Set up Rust toolchain for Cargo smoke + uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + with: + toolchain: "1.95.0" + - name: Build Bazel V8 release pair env: BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} PLATFORM: ${{ matrix.platform }} + SANDBOX: ${{ matrix.sandbox }} TARGET: ${{ matrix.target }} shell: bash run: | set -euo pipefail target_suffix="${TARGET//-/_}" - pair_target="//third_party/v8:rusty_v8_release_pair_${target_suffix}" - extra_targets=( - "@llvm//runtimes/libcxx:libcxx.static" - "@llvm//runtimes/libcxx:libcxxabi.static" - ) + pair_kind="release_pair" + if [[ "${SANDBOX}" == "true" ]]; then + pair_kind="sandbox_release_pair" + fi + pair_target="//third_party/v8:rusty_v8_${pair_kind}_${target_suffix}" bazel_args=( build "--platforms=@llvm//platforms:${PLATFORM}" - --config=v8-release-compat + --config=rusty-v8-upstream-libcxx "${pair_target}" - "${extra_targets[@]}" --build_metadata=COMMIT_SHA=$(git rev-parse HEAD) ) + if [[ "${SANDBOX}" != "true" ]]; then + bazel_args+=(--config=v8-release-compat) + fi bazel \ --noexperimental_remote_repo_contents_cache \ "${bazel_args[@]}" \ - --config=ci-v8 \ + "--config=${{ matrix.bazel_config }}" \ "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" - name: Stage release pair env: + BAZEL_CONFIG: ${{ matrix.bazel_config }} + BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} PLATFORM: ${{ matrix.platform }} + SANDBOX: ${{ matrix.sandbox }} TARGET: ${{ matrix.target }} shell: bash run: | set -euo pipefail - python3 .github/scripts/rusty_v8_bazel.py stage-release-pair \ - --platform "${PLATFORM}" \ - --target "${TARGET}" \ - --bazel-config v8-release-compat \ + stage_args=( + --platform "${PLATFORM}" + --target "${TARGET}" --output-dir "dist/${TARGET}" + --bazel-config "${BAZEL_CONFIG}" + ) + if [[ "${SANDBOX}" == "true" ]]; then + stage_args+=(--sandbox) + else + stage_args+=(--bazel-config v8-release-compat) + fi - - name: Upload staged musl artifacts + python3 .github/scripts/rusty_v8_bazel.py stage-release-pair "${stage_args[@]}" + + - name: Smoke test staged artifact with Cargo + env: + SANDBOX: ${{ matrix.sandbox }} + TARGET: ${{ matrix.target }} + shell: bash + run: | + set -euo pipefail + + host_arch="$(uname -m)" + case "${TARGET}:${host_arch}" in + x86_64-apple-darwin:x86_64|aarch64-apple-darwin:arm64|x86_64-unknown-linux-gnu:x86_64|aarch64-unknown-linux-gnu:aarch64) + ;; + *) + echo "Skipping non-native Cargo smoke for ${TARGET} on ${host_arch}." + exit 0 + ;; + esac + + archive="$(find "dist/${TARGET}" -maxdepth 1 -type f -name 'librusty_v8_*.a.gz' -print -quit)" + binding="$(find "dist/${TARGET}" -maxdepth 1 -type f -name 'src_binding_*.rs' -print -quit)" + if [[ -z "${archive}" || -z "${binding}" ]]; then + echo "Missing staged archive or binding for ${TARGET}." >&2 + exit 1 + fi + + cargo_args=(test -p codex-v8-poc) + if [[ "${SANDBOX}" == "true" ]]; then + cargo_args+=(--features sandbox) + fi + + ( + cd codex-rs + CARGO_TARGET_DIR="${RUNNER_TEMP}/rusty-v8-cargo-smoke-${TARGET}-${SANDBOX}" \ + RUSTY_V8_ARCHIVE="${GITHUB_WORKSPACE}/${archive}" \ + RUSTY_V8_SRC_BINDING_PATH="${GITHUB_WORKSPACE}/${binding}" \ + cargo "${cargo_args[@]}" + ) + + - name: Upload staged artifacts uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: - name: v8-canary-${{ needs.metadata.outputs.v8_version }}-${{ matrix.target }} + name: v8-canary-${{ needs.metadata.outputs.v8_version }}-${{ matrix.variant }}-${{ matrix.target }} + path: dist/${{ matrix.target }}/* + + build-windows-source: + name: Build ptrcomp-sandbox ${{ matrix.target }} from source + needs: metadata + runs-on: ${{ matrix.runner }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - runner: windows-2022 + target: x86_64-pc-windows-msvc + - runner: windows-2022 + target: aarch64-pc-windows-msvc + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Configure git for upstream checkout + shell: bash + run: git config --global core.symlinks true + + - name: Check out upstream rusty_v8 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + repository: denoland/rusty_v8 + ref: v${{ needs.metadata.outputs.v8_version }} + path: upstream-rusty-v8 + submodules: recursive + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: "3.11" + architecture: x64 + + - name: Set up Codex Rust toolchain for Cargo smoke + uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 + with: + toolchain: "1.95.0" + targets: ${{ matrix.target }} + + - name: Install rusty_v8 Rust toolchain + env: + TARGET: ${{ matrix.target }} + shell: bash + run: | + set -euo pipefail + rustup toolchain install 1.91.0 --profile minimal --no-self-update + rustup target add --toolchain 1.91.0 "${TARGET}" + + - name: Write upstream submodule status + shell: bash + working-directory: upstream-rusty-v8 + run: git submodule status --recursive > git_submodule_status.txt + + - name: Restore upstream source-build cache + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + with: + path: | + upstream-rusty-v8/target/sccache + upstream-rusty-v8/target/${{ matrix.target }}/release/gn_out + key: rusty-v8-source-${{ matrix.target }}-sandbox-${{ hashFiles('upstream-rusty-v8/Cargo.lock', 'upstream-rusty-v8/build.rs', 'upstream-rusty-v8/git_submodule_status.txt') }} + restore-keys: | + rusty-v8-source-${{ matrix.target }}-sandbox- + + - name: Install and start sccache + shell: pwsh + env: + SCCACHE_CACHE_SIZE: 256M + SCCACHE_DIR: ${{ github.workspace }}/upstream-rusty-v8/target/sccache + SCCACHE_IDLE_TIMEOUT: 0 + run: | + $version = "v0.8.2" + $platform = "x86_64-pc-windows-msvc" + $basename = "sccache-$version-$platform" + $url = "https://github.com/mozilla/sccache/releases/download/$version/$basename.tar.gz" + cd ~ + curl -LO $url + tar -xzvf "$basename.tar.gz" + . $basename/sccache --start-server + echo "$(pwd)/$basename" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: Install Chromium clang for ARM64 MSVC cross build + if: matrix.target == 'aarch64-pc-windows-msvc' + shell: bash + working-directory: upstream-rusty-v8 + run: python3 tools/clang/scripts/update.py + + - name: Build upstream rusty_v8 sandbox release pair + env: + SCCACHE_IDLE_TIMEOUT: 0 + TARGET: ${{ matrix.target }} + V8_FROM_SOURCE: "1" + shell: bash + working-directory: upstream-rusty-v8 + run: cargo +1.91.0 build --locked --release --target "${TARGET}" --features v8_enable_sandbox + + - name: Stage upstream sandbox release pair + env: + TARGET: ${{ matrix.target }} + shell: bash + run: | + set -euo pipefail + python3 .github/scripts/rusty_v8_bazel.py stage-upstream-release-pair \ + --source-root upstream-rusty-v8 \ + --target "${TARGET}" \ + --output-dir "dist/${TARGET}" \ + --sandbox + + - name: Smoke link staged artifact with Cargo + env: + TARGET: ${{ matrix.target }} + shell: bash + run: | + set -euo pipefail + + archive="$(find "dist/${TARGET}" -maxdepth 1 -type f -name 'rusty_v8_*.lib.gz' -print -quit)" + binding="$(find "dist/${TARGET}" -maxdepth 1 -type f -name 'src_binding_*.rs' -print -quit)" + if [[ -z "${archive}" || -z "${binding}" ]]; then + echo "Missing staged archive or binding for ${TARGET}." >&2 + exit 1 + fi + + ( + cd codex-rs + RUSTY_V8_ARCHIVE="${GITHUB_WORKSPACE}/${archive}" \ + RUSTY_V8_SRC_BINDING_PATH="${GITHUB_WORKSPACE}/${binding}" \ + cargo +1.95.0 test -p codex-v8-poc --target "${TARGET}" --features sandbox --no-run + ) + + - name: Upload staged artifacts + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + with: + name: v8-canary-${{ needs.metadata.outputs.v8_version }}-ptrcomp-sandbox-${{ matrix.target }} path: dist/${{ matrix.target }}/* diff --git a/.vscode/extensions.json b/.vscode/extensions.json index e92864a68a..5003c6de08 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,5 +1,6 @@ { "recommendations": [ + "BazelBuild.vscode-bazel", "rust-lang.rust-analyzer", "charliermarsh.ruff", "tamasfe.even-better-toml", diff --git a/AGENTS.md b/AGENTS.md index c13fdea641..63a9ba67c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ In the codex-rs folder where the rust code lives: - Prefer private modules and explicitly exported public crate API. - If you change `ConfigToml` or nested config types, run `just write-config-schema` to update `codex-rs/core/config.schema.json`. - When working with MCP tool calls, prefer using `codex-rs/codex-mcp/src/mcp_connection_manager.rs` to handle mutation of tools and tool calls. Aim to minimize the footprint of changes and leverage existing abstractions rather than plumbing code through multiple levels of function calls. +- Do not call `reset_client_session` unnecessarily; let the incremental check logic decide whether to reuse the previous request. - If you change Rust dependencies (`Cargo.toml` or `Cargo.lock`), run `just bazel-lock-update` from the repo root to refresh `MODULE.bazel.lock`, and include that lockfile update in the same change. - After dependency changes, run `just bazel-lock-check` from the repo root so lockfile drift is caught @@ -52,12 +53,13 @@ In the codex-rs folder where the rust code lives: the new implementation so the invariants stay close to the code that owns them. - Avoid adding new standalone methods to `codex-rs/tui/src/chatwidget.rs` unless the change is trivial; prefer new modules/files and keep `chatwidget.rs` focused on orchestration. -- When running Rust commands (e.g. `just fix` or `cargo test`) be patient with the command and never try to kill them using the PID. Rust lock can make the execution slow, this is expected. +- When running Rust commands (e.g. `just fix` or `just test`) be patient with the command and never try to kill them using the PID. Rust lock can make the execution slow, this is expected. -Run `just fmt` (in `codex-rs` directory) automatically after you have finished making Rust code changes; do not ask for approval to run it. Additionally, run the tests: +Run `just fmt` (in the `codex-rs` directory) automatically after you have finished making code changes anywhere in this repository; do not ask for approval to run it. Additionally, run the tests: -1. Run the test for the specific project that was changed. For example, if changes were made in `codex-rs/tui`, run `cargo test -p codex-tui`. -2. Once those pass, if any changes were made in common, core, or protocol, run the complete test suite with `cargo test` (or `just test` if `cargo-nextest` is installed). Avoid `--all-features` for routine local runs because it expands the build matrix and can significantly increase `target/` disk usage; use it only when you specifically need full feature coverage. project-specific or individual tests can be run without asking the user, but do ask the user before running the complete test suite. +1. Do not run `cargo test` directly. Use `just test` so test execution follows the repo defaults. +2. Run the test for the specific project that was changed. For example, if changes were made in `codex-rs/tui`, run `just test -p codex-tui`. +3. Once those pass, if any changes were made in common, core, or protocol, run the complete test suite with `just test`. Avoid `--all-features` for routine local runs because it expands the build matrix and can significantly increase `target/` disk usage; use it only when you specifically need full feature coverage. project-specific or individual tests can be run without asking the user, but do ask the user before running the complete test suite. Before finalizing a large change to `codex-rs`, run `just fix -p ` (in `codex-rs` directory) to fix any linter issues in the code. Prefer scoping with `-p` to avoid slow workspace‑wide Clippy builds; only run `just fix` without `-p` if you changed shared crates. Do not re-run tests after running `fix` or `fmt`. @@ -108,6 +110,19 @@ See `codex-rs/tui/styles.md`. ## Tests +### Test module organization + +- When adding a new test module, define its contents in a separate sibling file rather than inline in the implementation file. +- Use an explicit `#[path = "..._tests.rs"]` attribute so the test filename is descriptive and easy to locate: + + ```rust + #[cfg(test)] + #[path = "parser_tests.rs"] + mod tests; + ``` + +- This applies only when introducing a new test module. Do not move or rewrite existing inline `#[cfg(test)] mod tests { ... }` modules solely to follow this convention. + ### Snapshot tests This repo uses snapshot tests (via `insta`), especially in `codex-rs/tui`, to validate rendered output. @@ -120,7 +135,7 @@ is easy to review and future diffs stay visual. When UI or text output changes intentionally, update the snapshots as follows: - Run tests to generate any updated snapshots: - - `cargo test -p codex-tui` + - `just test -p codex-tui` - Check what’s pending: - `cargo insta pending-snapshots -p codex-tui` - Review changes by reading the generated `*.snap.new` files directly in the repo, or preview a specific file: @@ -214,6 +229,15 @@ These guidelines apply to app-server protocol work in `codex-rs`, especially: - Regenerate schema fixtures when API shapes change: `just write-app-server-schema` (and `just write-app-server-schema --experimental` when experimental API fixtures are affected). -- Validate with `cargo test -p codex-app-server-protocol`. +- Validate with `just test -p codex-app-server-protocol`. - Avoid boilerplate tests that only assert experimental field markers for individual request fields in `common.rs`; rely on schema generation/tests and behavioral coverage instead. + +## Python Development Best Practices + +### Ignore Python 2 compatibility + +This project uses Python 3+. You should not use the `__future__` module. + +If you need to worry about feature compatibility between different 3.xx point releases, check the +closest `pyproject.toml`'s `requires-python` field to see what minimum runtime version is supported. diff --git a/MODULE.bazel b/MODULE.bazel index f675038791..abca889d64 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -10,6 +10,7 @@ single_version_override( module_name = "llvm", patch_strip = 1, patches = [ + "//patches:llvm_rusty_v8_custom_libcxx.patch", "//patches:llvm_windows_symlink_extract.patch", ], ) @@ -77,6 +78,13 @@ use_repo(osx, "macos_sdk") # Needed to disable xcode... bazel_dep(name = "apple_support", version = "2.1.0") bazel_dep(name = "rules_cc", version = "0.2.16") +single_version_override( + module_name = "rules_cc", + patch_strip = 1, + patches = [ + "//patches:rules_cc_rusty_v8_custom_libcxx.patch", + ], +) bazel_dep(name = "rules_platform", version = "0.1.0") bazel_dep(name = "rules_rs", version = "0.0.58") # `rules_rs` still does not model `windows-gnullvm` as a distinct Windows exec @@ -155,7 +163,7 @@ use_repo(nightly_rust, "rust_toolchains") toolchains = use_extension("@rules_rs//rs/experimental/toolchains:module_extension.bzl", "toolchains") toolchains.toolchain( edition = "2024", - version = "1.93.0", + version = "1.95.0", ) use_repo(toolchains, "default_rust_toolchains") @@ -407,18 +415,18 @@ crate.annotation( inject_repo(crate, "alsa_lib") -bazel_dep(name = "v8", version = "14.6.202.9") +bazel_dep(name = "v8", version = "14.7.173.20") archive_override( module_name = "v8", - integrity = "sha256-JphDwLAzsd9KvgRZ7eQvNtPU6qGd3XjFt/a/1QITAJU=", + integrity = "sha256-v/x6I4X38a2wckzUIft3Dh0SUdkuOTokwxyF7lzW8Lc=", patch_strip = 3, patches = [ "//patches:v8_module_deps.patch", "//patches:v8_bazel_rules.patch", "//patches:v8_source_portability.patch", ], - strip_prefix = "v8-14.6.202.9", - urls = ["https://github.com/v8/v8/archive/refs/tags/14.6.202.9.tar.gz"], + strip_prefix = "v8-14.7.173.20", + urls = ["https://github.com/v8/v8/archive/refs/tags/14.7.173.20.tar.gz"], ) http_archive( @@ -430,93 +438,53 @@ http_archive( urls = ["https://static.crates.io/crates/v8/v8-146.4.0.crate"], ) -http_file( - name = "rusty_v8_146_4_0_aarch64_apple_darwin_archive", - downloaded_file_path = "librusty_v8_release_aarch64-apple-darwin.a.gz", - sha256 = "bfe2c9be32a56c28546f0f965825ee68fbf606405f310cc4e17b448a568cf98a", - urls = [ - "https://github.com/denoland/rusty_v8/releases/download/v146.4.0/librusty_v8_release_aarch64-apple-darwin.a.gz", - ], +http_archive( + name = "v8_crate_147_4_0", + build_file = "//third_party/v8:v8_crate.BUILD.bazel", + sha256 = "2df8fffd507fb18ed000673a83d937f58e60fb07f3306b2274284125b15137cd", + strip_prefix = "v8-147.4.0", + type = "tar.gz", + urls = ["https://static.crates.io/crates/v8/v8-147.4.0.crate"], +) + +git_repository = use_repo_rule("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository") + +git_repository( + name = "rusty_v8_libcxx", + build_file = "//third_party/v8:libcxx.BUILD.bazel", + commit = "7ab65651aed6802d2599dcb7a73b1f82d5179d05", + remote = "https://chromium.googlesource.com/external/github.com/llvm/llvm-project/libcxx.git", +) + +git_repository( + name = "rusty_v8_libcxxabi", + build_file = "//third_party/v8:libcxxabi.BUILD.bazel", + commit = "8f11bb1d4438d0239d0dfc1bd9456a9f31629dda", + remote = "https://chromium.googlesource.com/external/github.com/llvm/llvm-project/libcxxabi.git", +) + +git_repository( + name = "rusty_v8_llvm_libc", + build_file = "//third_party/v8:llvm_libc.BUILD.bazel", + commit = "b3aa5bb702ff9e890179fd1e7d3ba346e17ecf8e", + remote = "https://chromium.googlesource.com/external/github.com/llvm/llvm-project/libc.git", ) http_file( - name = "rusty_v8_146_4_0_aarch64_unknown_linux_gnu_archive", - downloaded_file_path = "librusty_v8_release_aarch64-unknown-linux-gnu.a.gz", - sha256 = "dbf165b07c81bdb054bc046b43d23e69fcf7bcc1a4c1b5b4776983a71062ecd8", - urls = [ - "https://github.com/denoland/rusty_v8/releases/download/v146.4.0/librusty_v8_release_aarch64-unknown-linux-gnu.a.gz", - ], -) - -http_file( - name = "rusty_v8_146_4_0_aarch64_pc_windows_msvc_archive", + name = "rusty_v8_147_4_0_aarch64_pc_windows_msvc_archive", downloaded_file_path = "rusty_v8_release_aarch64-pc-windows-msvc.lib.gz", - sha256 = "ed13363659c6d08583ac8fdc40493445c5767d8b94955a4d5d7bb8d5a81f6bf8", + sha256 = "1fa3f94d9e09cff1f6bcce94c478e5cb072c0755f6a0357abadb9dd3b48d8127", urls = [ - "https://github.com/denoland/rusty_v8/releases/download/v146.4.0/rusty_v8_release_aarch64-pc-windows-msvc.lib.gz", + "https://github.com/denoland/rusty_v8/releases/download/v147.4.0/rusty_v8_release_aarch64-pc-windows-msvc.lib.gz", ], ) http_file( - name = "rusty_v8_146_4_0_x86_64_apple_darwin_archive", - downloaded_file_path = "librusty_v8_release_x86_64-apple-darwin.a.gz", - sha256 = "630cd240f1bbecdb071417dc18387ab81cf67c549c1c515a0b4fcf9eba647bb7", - urls = [ - "https://github.com/denoland/rusty_v8/releases/download/v146.4.0/librusty_v8_release_x86_64-apple-darwin.a.gz", - ], -) - -http_file( - name = "rusty_v8_146_4_0_x86_64_unknown_linux_gnu_archive", - downloaded_file_path = "librusty_v8_release_x86_64-unknown-linux-gnu.a.gz", - sha256 = "e64b4d99e4ae293a2e846244a89b80178ba10382c13fb591c1fa6968f5291153", - urls = [ - "https://github.com/denoland/rusty_v8/releases/download/v146.4.0/librusty_v8_release_x86_64-unknown-linux-gnu.a.gz", - ], -) - -http_file( - name = "rusty_v8_146_4_0_x86_64_pc_windows_msvc_archive", + name = "rusty_v8_147_4_0_x86_64_pc_windows_msvc_archive", downloaded_file_path = "rusty_v8_release_x86_64-pc-windows-msvc.lib.gz", - sha256 = "90a9a2346acd3685a355e98df85c24dbe406cb124367d16259a4b5d522621862", + sha256 = "e2827ff98b1a9d4c0343000fc5124ac30dfab3007bc0129c168c9355fc2fcd7c", urls = [ - "https://github.com/denoland/rusty_v8/releases/download/v146.4.0/rusty_v8_release_x86_64-pc-windows-msvc.lib.gz", - ], -) - -http_file( - name = "rusty_v8_146_4_0_aarch64_unknown_linux_musl_archive", - downloaded_file_path = "librusty_v8_release_aarch64-unknown-linux-musl.a.gz", - sha256 = "27a08ed26c34297bfd93e514692ccc44b85f8b15c6aa39cf34e784f84fb37e8e", - urls = [ - "https://github.com/openai/codex/releases/download/rusty-v8-v146.4.0/librusty_v8_release_aarch64-unknown-linux-musl.a.gz", - ], -) - -http_file( - name = "rusty_v8_146_4_0_aarch64_unknown_linux_musl_binding", - downloaded_file_path = "src_binding_release_aarch64-unknown-linux-musl.rs", - sha256 = "09f8900ced8297c229246c7a50b2e0ec23c54d0a554f369619cc29863f38dd1a", - urls = [ - "https://github.com/openai/codex/releases/download/rusty-v8-v146.4.0/src_binding_release_aarch64-unknown-linux-musl.rs", - ], -) - -http_file( - name = "rusty_v8_146_4_0_x86_64_unknown_linux_musl_archive", - downloaded_file_path = "librusty_v8_release_x86_64-unknown-linux-musl.a.gz", - sha256 = "20d8271ad712323d352c1383c36e3c4b755abc41ece35819c49c75ec7134d2f8", - urls = [ - "https://github.com/openai/codex/releases/download/rusty-v8-v146.4.0/librusty_v8_release_x86_64-unknown-linux-musl.a.gz", - ], -) - -http_file( - name = "rusty_v8_146_4_0_x86_64_unknown_linux_musl_binding", - downloaded_file_path = "src_binding_release_x86_64-unknown-linux-musl.rs", - sha256 = "09f8900ced8297c229246c7a50b2e0ec23c54d0a554f369619cc29863f38dd1a", - urls = [ - "https://github.com/openai/codex/releases/download/rusty-v8-v146.4.0/src_binding_release_x86_64-unknown-linux-musl.rs", + "https://github.com/denoland/rusty_v8/releases/download/v147.4.0/rusty_v8_release_x86_64-pc-windows-msvc.lib.gz", ], ) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 87434e1c68..f541a2c7bf 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -704,6 +704,7 @@ "bitflags_2.11.0": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"arbitrary\",\"req\":\"^1.0\"},{\"name\":\"bytemuck\",\"optional\":true,\"req\":\"^1.12\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"bytemuck\",\"req\":\"^1.12.2\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.228\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde_lib\",\"package\":\"serde\",\"req\":\"^1.0.103\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.19\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.18\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"zerocopy\",\"req\":\"^0.8\"}],\"features\":{\"example_generated\":[],\"serde\":[\"serde_core\"],\"std\":[]}}", "blake2_0.10.6": "{\"dependencies\":[{\"features\":[\"mac\"],\"name\":\"digest\",\"req\":\"^0.10.3\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.10.3\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"}],\"features\":{\"default\":[\"std\"],\"reset\":[],\"simd\":[],\"simd_asm\":[\"simd_opt\"],\"simd_opt\":[\"simd\"],\"size_opt\":[],\"std\":[\"digest/std\"]}}", "block-buffer_0.10.4": "{\"dependencies\":[{\"name\":\"generic-array\",\"req\":\"^0.14\"}],\"features\":{}}", + "block-buffer_0.12.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"name\":\"hybrid-array\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.4\"}],\"features\":{}}", "block-padding_0.3.3": "{\"dependencies\":[{\"name\":\"generic-array\",\"req\":\"^0.14\"}],\"features\":{\"std\":[]}}", "block2_0.6.2": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"name\":\"objc2\",\"req\":\">=0.6.2, <0.8.0\"}],\"features\":{\"alloc\":[],\"compiler-rt\":[\"objc2/unstable-compiler-rt\"],\"default\":[\"std\"],\"gnustep-1-7\":[\"objc2/gnustep-1-7\"],\"gnustep-1-8\":[\"gnustep-1-7\",\"objc2/gnustep-1-8\"],\"gnustep-1-9\":[\"gnustep-1-8\",\"objc2/gnustep-1-9\"],\"gnustep-2-0\":[\"gnustep-1-9\",\"objc2/gnustep-2-0\"],\"gnustep-2-1\":[\"gnustep-2-0\",\"objc2/gnustep-2-1\"],\"std\":[\"alloc\"],\"unstable-coerce-pointee\":[],\"unstable-objfw\":[],\"unstable-private\":[],\"unstable-winobjc\":[\"gnustep-1-8\"]}}", "blocking_1.6.2": "{\"dependencies\":[{\"name\":\"async-channel\",\"req\":\"^2.0.0\"},{\"name\":\"async-task\",\"req\":\"^4.4.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures-io\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"name\":\"futures-lite\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"futures-lite\",\"req\":\"^2.0.0\"},{\"name\":\"piper\",\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.37\"}],\"features\":{}}", @@ -723,7 +724,7 @@ "cached_0.56.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"ahash\",\"optional\":true,\"req\":\"^0.8\"},{\"features\":[\"attributes\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.6\"},{\"name\":\"async-trait\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"cached_proc_macro\",\"optional\":true,\"req\":\"^0.25.0\"},{\"name\":\"cached_proc_macro_types\",\"optional\":true,\"req\":\"^0.1.1\"},{\"kind\":\"dev\",\"name\":\"copy_dir\",\"req\":\"^0.1.3\"},{\"name\":\"directories\",\"optional\":true,\"req\":\"^6.0\"},{\"default_features\":false,\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"googletest\",\"req\":\"^0.11.0\"},{\"default_features\":false,\"features\":[\"inline-more\"],\"name\":\"hashbrown\",\"req\":\"^0.15\"},{\"name\":\"once_cell\",\"req\":\"^1\"},{\"name\":\"r2d2\",\"optional\":true,\"req\":\"^0.8\"},{\"features\":[\"r2d2\"],\"name\":\"redis\",\"optional\":true,\"req\":\"^0.32\"},{\"name\":\"rmp-serde\",\"optional\":true,\"req\":\"^1.1\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3\"},{\"name\":\"sled\",\"optional\":true,\"req\":\"^0.34\"},{\"kind\":\"dev\",\"name\":\"smartstring\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.1\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"macros\",\"time\",\"sync\",\"parking_lot\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"web-time\",\"req\":\"^1.1.0\"}],\"features\":{\"ahash\":[\"dep:ahash\",\"hashbrown/default\"],\"async\":[\"futures\",\"tokio\",\"async-trait\"],\"async_tokio_rt_multi_thread\":[\"async\",\"tokio/rt-multi-thread\"],\"default\":[\"proc_macro\",\"ahash\"],\"disk_store\":[\"sled\",\"serde\",\"rmp-serde\",\"directories\"],\"proc_macro\":[\"cached_proc_macro\",\"cached_proc_macro_types\"],\"redis_ahash\":[\"redis_store\",\"redis/ahash\"],\"redis_async_std\":[\"redis_store\",\"async\",\"redis/aio\",\"redis/async-std-comp\",\"redis/tls\",\"redis/async-std-tls-comp\"],\"redis_connection_manager\":[\"redis_store\",\"redis/connection-manager\"],\"redis_store\":[\"redis\",\"r2d2\",\"serde\",\"serde_json\"],\"redis_tokio\":[\"redis_store\",\"async\",\"redis/aio\",\"redis/tokio-comp\",\"redis/tls\",\"redis/tokio-native-tls-comp\"],\"wasm\":[]}}", "cached_proc_macro_0.25.0": "{\"dependencies\":[{\"name\":\"darling\",\"req\":\"^0.20.8\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.49\"},{\"name\":\"quote\",\"req\":\"^1.0.6\"},{\"name\":\"syn\",\"req\":\"^2.0.52\"}],\"features\":{}}", "cached_proc_macro_types_0.1.1": "{\"dependencies\":[],\"features\":{}}", - "calendrical_calculations_0.2.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"core_maths\",\"req\":\"^0.1.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"}],\"features\":{\"logging\":[\"dep:log\"]}}", + "calendrical_calculations_0.2.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"core_maths\",\"req\":\"^0.1.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"}],\"features\":{\"logging\":[\"dep:log\"]}}", "camino_1.2.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1\"},{\"name\":\"proptest\",\"optional\":true,\"req\":\"^1.0.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.223\"},{\"kind\":\"dev\",\"name\":\"serde_bytes\",\"req\":\"^0.11.8\"},{\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"proptest1\":[\"dep:proptest\"],\"serde1\":[\"dep:serde_core\"]}}", "cargo-platform_0.3.2": "{\"dependencies\":[{\"name\":\"serde\",\"req\":\"^1.0.228\",\"target\":\"cfg(any())\"},{\"name\":\"serde_core\",\"req\":\"^1.0.228\"}],\"features\":{}}", "cargo_metadata_0.23.1": "{\"dependencies\":[{\"features\":[\"serde1\"],\"name\":\"camino\",\"req\":\"^1.1.10\"},{\"name\":\"cargo-platform\",\"req\":\"^0.3.0\"},{\"name\":\"derive_builder\",\"optional\":true,\"req\":\"^0.20\"},{\"features\":[\"serde\"],\"name\":\"semver\",\"req\":\"^1.0.26\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.219\"},{\"features\":[\"unbounded_depth\"],\"name\":\"serde_json\",\"req\":\"^1.0.142\"},{\"name\":\"thiserror\",\"req\":\"^2.0.12\"}],\"features\":{\"builder\":[\"derive_builder\"],\"default\":[],\"unstable\":[]}}", @@ -738,6 +739,7 @@ "cfg-if_1.0.4": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"}],\"features\":{\"rustc-dep-of-std\":[\"core\"]}}", "cfg_aliases_0.1.1": "{\"dependencies\":[],\"features\":{}}", "cfg_aliases_0.2.1": "{\"dependencies\":[],\"features\":{}}", + "chacha20_0.10.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"features\":[\"stream-wrapper\"],\"name\":\"cipher\",\"optional\":true,\"req\":\"^0.5\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"cipher\",\"req\":\"^0.5\"},{\"name\":\"cpufeatures\",\"req\":\"^0.3\",\"target\":\"cfg(any(target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.8.1\"}],\"features\":{\"default\":[\"cipher\"],\"legacy\":[\"cipher\"],\"rng\":[\"dep:rand_core\"],\"xchacha\":[\"cipher\"]}}", "chacha20_0.9.1": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"cipher\",\"req\":\"^0.4.4\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"cipher\",\"req\":\"^0.4.4\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3.3\"}],\"features\":{\"std\":[\"cipher/std\"],\"zeroize\":[\"cipher/zeroize\"]}}", "chacha20poly1305_0.10.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aead\",\"req\":\"^0.5\"},{\"default_features\":false,\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"aead\",\"req\":\"^0.5\"},{\"features\":[\"zeroize\"],\"name\":\"chacha20\",\"req\":\"^0.9\"},{\"name\":\"cipher\",\"req\":\"^0.4\"},{\"name\":\"poly1305\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"zeroize\",\"req\":\"^1.5\"}],\"features\":{\"alloc\":[\"aead/alloc\"],\"default\":[\"alloc\",\"getrandom\"],\"getrandom\":[\"aead/getrandom\",\"rand_core\"],\"heapless\":[\"aead/heapless\"],\"rand_core\":[\"aead/rand_core\"],\"reduced-round\":[],\"std\":[\"aead/std\",\"alloc\"],\"stream\":[\"aead/stream\"]}}", "chardetng_0.1.17": "{\"dependencies\":[{\"name\":\"arrayvec\",\"optional\":true,\"req\":\"^0.5.1\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"detone\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"encoding_rs\",\"req\":\"^0.8.29\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.2.0\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.3.0\"}],\"features\":{\"multithreading\":[\"rayon\",\"arrayvec\"],\"testing-only-no-semver-guarantees-do-not-use\":[]}}", @@ -757,6 +759,7 @@ "clipboard-win_5.4.1": "{\"dependencies\":[{\"name\":\"error-code\",\"req\":\"^3\",\"target\":\"cfg(windows)\"},{\"name\":\"windows-win\",\"optional\":true,\"req\":\"^3\",\"target\":\"cfg(windows)\"}],\"features\":{\"monitor\":[\"windows-win\"],\"std\":[\"error-code/std\"]}}", "clru_0.6.3": "{\"dependencies\":[{\"name\":\"hashbrown\",\"req\":\"^0.16\"}],\"features\":{}}", "cmake_0.1.57": "{\"dependencies\":[{\"name\":\"cc\",\"req\":\"^1.2.46\"}],\"features\":{}}", + "cmov_0.5.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.9\",\"target\":\"cfg(any(unix, windows))\"}],\"features\":{}}", "cmp_any_0.8.1": "{\"dependencies\":[],\"features\":{}}", "codespan-reporting_0.13.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.6.3\"},{\"kind\":\"dev\",\"name\":\"peg\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"pico-args\",\"req\":\"^0.5.0\"},{\"kind\":\"dev\",\"name\":\"rustyline\",\"req\":\"^6\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"termcolor\",\"optional\":true,\"req\":\"^1.0.4\"},{\"name\":\"unicode-width\",\"req\":\">=0.1, <0.3\"},{\"kind\":\"dev\",\"name\":\"unindent\",\"req\":\"^0.1\"}],\"features\":{\"ascii-only\":[],\"default\":[\"std\",\"termcolor\"],\"serialization\":[\"serde\"],\"std\":[\"serde?/std\"],\"termcolor\":[\"std\",\"dep:termcolor\"]}}", "color-eyre_0.6.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ansi-parser\",\"req\":\"^0.8.0\"},{\"name\":\"backtrace\",\"req\":\"^0.3.59\"},{\"name\":\"color-spantrace\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"eyre\",\"req\":\"^0.6\"},{\"name\":\"indenter\",\"req\":\"^0.3.0\"},{\"name\":\"once_cell\",\"req\":\"^1.18.0\"},{\"name\":\"owo-colors\",\"req\":\"^4.0\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^1.0.19\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1.13\"},{\"name\":\"tracing-error\",\"optional\":true,\"req\":\"^0.2.0\"},{\"features\":[\"env-filter\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.0\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.1.1\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.15\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"}],\"features\":{\"capture-spantrace\":[\"tracing-error\",\"color-spantrace\"],\"default\":[\"track-caller\",\"capture-spantrace\"],\"issue-url\":[\"url\"],\"track-caller\":[]}}", @@ -768,6 +771,7 @@ "compact_str_0.8.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"borsh\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"castaway\",\"req\":\"^0.2.3\"},{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"cfg-if\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"diesel\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"itoa\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"markup\",\"optional\":true,\"req\":\"^0.13\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"proptest\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"size_32\"],\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"alloc\",\"size_32\"],\"kind\":\"dev\",\"name\":\"rkyv\",\"req\":\"^0.7\"},{\"name\":\"rustversion\",\"req\":\"^1\"},{\"name\":\"ryu\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"features\":[\"union\"],\"name\":\"smallvec\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"sqlx\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"static_assertions\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"test-case\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"test-strategy\",\"req\":\"^0.3\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"borsh\":[\"dep:borsh\"],\"bytes\":[\"dep:bytes\"],\"default\":[\"std\"],\"diesel\":[\"dep:diesel\"],\"markup\":[\"dep:markup\"],\"proptest\":[\"dep:proptest\"],\"quickcheck\":[\"dep:quickcheck\"],\"rkyv\":[\"dep:rkyv\"],\"serde\":[\"dep:serde\"],\"smallvec\":[\"dep:smallvec\"],\"sqlx\":[\"dep:sqlx\",\"std\"],\"sqlx-mysql\":[\"sqlx\",\"sqlx/mysql\"],\"sqlx-postgres\":[\"sqlx\",\"sqlx/postgres\"],\"sqlx-sqlite\":[\"sqlx\",\"sqlx/sqlite\"],\"std\":[]}}", "compiletest_rs_0.11.2": "{\"dependencies\":[{\"name\":\"diff\",\"req\":\"^0.1.10\"},{\"name\":\"filetime\",\"req\":\"^0.2\"},{\"name\":\"getopts\",\"req\":\"^0.2\"},{\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"miow\",\"req\":\"^0.6\",\"target\":\"cfg(windows)\"},{\"name\":\"regex\",\"req\":\"^1.0\"},{\"name\":\"rustfix\",\"req\":\"^0.8\"},{\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_derive\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"tempfile\",\"optional\":true,\"req\":\"^3.0\"},{\"name\":\"tester\",\"req\":\"^0.9\"},{\"features\":[\"Win32\"],\"name\":\"windows-sys\",\"req\":\"^0.59\",\"target\":\"cfg(windows)\"}],\"features\":{\"rustc\":[],\"stable\":[],\"tmp\":[\"tempfile\"]}}", "concurrent-queue_2.5.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"crossbeam-utils\",\"req\":\"^0.8.11\"},{\"kind\":\"dev\",\"name\":\"easy-parallel\",\"req\":\"^3.1.0\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"name\":\"loom\",\"optional\":true,\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", + "condtype_1.3.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"cfg-if\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.141\"}],\"features\":{}}", "console_0.15.11": "{\"dependencies\":[{\"name\":\"encode_unicode\",\"req\":\"^1\",\"target\":\"cfg(windows)\"},{\"name\":\"libc\",\"req\":\"^0.2.99\"},{\"name\":\"once_cell\",\"req\":\"^1.8\"},{\"default_features\":false,\"features\":[\"std\",\"bit-set\",\"break-dead-code\"],\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.4.2\"},{\"name\":\"unicode-width\",\"optional\":true,\"req\":\"^0.2\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Console\",\"Win32_Storage_FileSystem\",\"Win32_UI_Input_KeyboardAndMouse\"],\"name\":\"windows-sys\",\"req\":\"^0.59\",\"target\":\"cfg(windows)\"}],\"features\":{\"ansi-parsing\":[],\"default\":[\"unicode-width\",\"ansi-parsing\"],\"windows-console-colors\":[\"ansi-parsing\"]}}", "const-hex_1.17.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"},{\"kind\":\"dev\",\"name\":\"divan\",\"package\":\"codspeed-divan-compat\",\"req\":\"^3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"faster-hex\",\"req\":\"^0.10.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"~0.4.2\"},{\"default_features\":false,\"name\":\"proptest\",\"optional\":true,\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"rustc-hex\",\"req\":\"^2.1\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"__fuzzing\":[\"dep:proptest\",\"std\"],\"alloc\":[\"serde_core?/alloc\",\"proptest?/alloc\"],\"core-error\":[],\"default\":[\"std\"],\"force-generic\":[],\"hex\":[],\"nightly\":[],\"portable-simd\":[],\"serde\":[\"dep:serde_core\"],\"std\":[\"serde_core?/std\",\"proptest?/std\",\"alloc\"]}}", "const-oid_0.9.6": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"}],\"features\":{\"db\":[],\"std\":[]}}", @@ -788,6 +792,7 @@ "coreaudio-sys_0.2.17": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"runtime\"],\"kind\":\"build\",\"name\":\"bindgen\",\"req\":\"^0.72\"}],\"features\":{\"audio_server_plugin\":[],\"audio_toolbox\":[],\"audio_unit\":[],\"core_audio\":[],\"core_midi\":[],\"default\":[\"audio_toolbox\",\"audio_unit\",\"core_audio\",\"audio_server_plugin\",\"open_al\",\"core_midi\"],\"io_kit_audio\":[],\"open_al\":[]}}", "cpal_0.15.3": "{\"dependencies\":[{\"name\":\"alsa\",\"req\":\"^0.9\",\"target\":\"cfg(any(target_os = \\\"linux\\\", target_os = \\\"dragonfly\\\", target_os = \\\"freebsd\\\", target_os = \\\"netbsd\\\"))\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"asio-sys\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.0\"},{\"name\":\"core-foundation-sys\",\"req\":\"^0.8.2\",\"target\":\"cfg(any(target_os = \\\"macos\\\", target_os = \\\"ios\\\"))\"},{\"default_features\":false,\"features\":[\"audio_unit\",\"core_audio\",\"audio_toolbox\"],\"name\":\"coreaudio-rs\",\"req\":\"^0.11\",\"target\":\"cfg(target_os = \\\"ios\\\")\"},{\"default_features\":false,\"features\":[\"audio_unit\",\"core_audio\"],\"name\":\"coreaudio-rs\",\"req\":\"^0.11\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"name\":\"dasp_sample\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"hound\",\"req\":\"^3.5\"},{\"name\":\"jack\",\"optional\":true,\"req\":\"^0.11\",\"target\":\"cfg(any(target_os = \\\"linux\\\", target_os = \\\"dragonfly\\\", target_os = \\\"freebsd\\\", target_os = \\\"netbsd\\\"))\"},{\"name\":\"jni\",\"req\":\"^0.21\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"js-sys\",\"req\":\"^0.3.35\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"js-sys\",\"req\":\"^0.3.35\",\"target\":\"cfg(target_os = \\\"emscripten\\\")\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_os = \\\"linux\\\", target_os = \\\"dragonfly\\\", target_os = \\\"freebsd\\\", target_os = \\\"netbsd\\\"))\"},{\"name\":\"mach2\",\"req\":\"^0.4\",\"target\":\"cfg(any(target_os = \\\"macos\\\", target_os = \\\"ios\\\"))\"},{\"default_features\":false,\"name\":\"ndk\",\"req\":\"^0.8\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"ndk-context\",\"req\":\"^0.1\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"kind\":\"dev\",\"name\":\"ndk-glue\",\"req\":\"^0.7\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"num-traits\",\"optional\":true,\"req\":\"^0.2.6\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"features\":[\"java-interface\"],\"name\":\"oboe\",\"req\":\"^0.6\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"kind\":\"dev\",\"name\":\"ringbuf\",\"req\":\"^0.3\"},{\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2.58\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(target_os = \\\"emscripten\\\")\"},{\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4.33\",\"target\":\"cfg(target_os = \\\"emscripten\\\")\"},{\"features\":[\"AudioContext\",\"AudioContextOptions\",\"AudioBuffer\",\"AudioBufferSourceNode\",\"AudioNode\",\"AudioDestinationNode\",\"Window\",\"AudioContextState\"],\"name\":\"web-sys\",\"req\":\"^0.3.35\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"AudioContext\",\"AudioContextOptions\",\"AudioBuffer\",\"AudioBufferSourceNode\",\"AudioNode\",\"AudioDestinationNode\",\"Window\",\"AudioContextState\"],\"name\":\"web-sys\",\"req\":\"^0.3.35\",\"target\":\"cfg(target_os = \\\"emscripten\\\")\"},{\"features\":[\"Win32_Media_Audio\",\"Win32_Foundation\",\"Win32_Devices_Properties\",\"Win32_Media_KernelStreaming\",\"Win32_System_Com_StructuredStorage\",\"Win32_System_Threading\",\"Win32_Security\",\"Win32_System_SystemServices\",\"Win32_System_Variant\",\"Win32_Media_Multimedia\",\"Win32_UI_Shell_PropertiesSystem\"],\"name\":\"windows\",\"req\":\"^0.54.0\",\"target\":\"cfg(target_os = \\\"windows\\\")\"}],\"features\":{\"asio\":[\"asio-sys\",\"num-traits\"],\"oboe-shared-stdcxx\":[\"oboe/shared-stdcxx\"]}}", "cpufeatures_0.2.17": "{\"dependencies\":[{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"aarch64-linux-android\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(target_arch = \\\"aarch64\\\", target_os = \\\"linux\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(target_arch = \\\"aarch64\\\", target_vendor = \\\"apple\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(target_arch = \\\"loongarch64\\\", target_os = \\\"linux\\\"))\"}],\"features\":{}}", + "cpufeatures_0.3.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(target_arch = \\\"aarch64\\\", target_os = \\\"android\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(target_arch = \\\"aarch64\\\", target_os = \\\"linux\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(target_arch = \\\"aarch64\\\", target_vendor = \\\"apple\\\"))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(target_arch = \\\"loongarch64\\\", target_os = \\\"linux\\\"))\"}],\"features\":{}}", "crc-catalog_2.4.0": "{\"dependencies\":[],\"features\":{}}", "crc32fast_1.5.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"default\":[\"std\"],\"nightly\":[],\"std\":[]}}", "crc_3.4.0": "{\"dependencies\":[{\"name\":\"crc-catalog\",\"req\":\"^2.4.0\"}],\"features\":{}}", @@ -801,6 +806,7 @@ "crunchy_0.2.4": "{\"dependencies\":[],\"features\":{\"default\":[\"limit_128\"],\"limit_1024\":[],\"limit_128\":[],\"limit_2048\":[],\"limit_256\":[],\"limit_512\":[],\"limit_64\":[],\"std\":[]}}", "crypto-bigint_0.5.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"der\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"generic-array\",\"optional\":true,\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"num-integer\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand_chacha\",\"req\":\"^0.3\"},{\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6.4\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.6\"},{\"default_features\":false,\"name\":\"rlp\",\"optional\":true,\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"serdect\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.4\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"serdect?/alloc\"],\"default\":[\"rand\"],\"extra-sizes\":[],\"rand\":[\"rand_core/std\"],\"serde\":[\"dep:serdect\"]}}", "crypto-common_0.1.7": "{\"dependencies\":[{\"features\":[\"more_lengths\"],\"name\":\"generic-array\",\"req\":\"=0.14.7\"},{\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"typenum\",\"req\":\"^1.14\"}],\"features\":{\"getrandom\":[\"rand_core/getrandom\"],\"std\":[]}}", + "crypto-common_0.2.2": "{\"dependencies\":[{\"features\":[\"sys_rng\"],\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"hybrid-array\",\"req\":\"^0.4.7\"},{\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.10\"}],\"features\":{\"getrandom\":[\"rand_core\",\"dep:getrandom\"],\"rand_core\":[\"dep:rand_core\"],\"zeroize\":[\"hybrid-array/zeroize\"]}}", "crypto_box_0.9.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aead\",\"req\":\"^0.5.2\"},{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"blake2\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"chacha20\",\"optional\":true,\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"crypto_secretbox\",\"req\":\"^0.1.1\"},{\"default_features\":false,\"features\":[\"zeroize\"],\"name\":\"curve25519-dalek\",\"req\":\"^4\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rmp-serde\",\"req\":\"^1\"},{\"name\":\"salsa20\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"serdect\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"zeroize\",\"req\":\"^1\"}],\"features\":{\"alloc\":[\"aead/alloc\"],\"chacha20\":[\"dep:chacha20\",\"crypto_secretbox/chacha20\"],\"default\":[\"alloc\",\"getrandom\",\"salsa20\"],\"getrandom\":[\"aead/getrandom\",\"rand_core\"],\"heapless\":[\"aead/heapless\"],\"rand_core\":[\"aead/rand_core\"],\"salsa20\":[\"dep:salsa20\",\"crypto_secretbox/salsa20\"],\"seal\":[\"dep:blake2\",\"alloc\"],\"serde\":[\"dep:serdect\"],\"std\":[\"aead/std\"]}}", "crypto_secretbox_0.1.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aead\",\"req\":\"^0.5\"},{\"features\":[\"zeroize\"],\"name\":\"chacha20\",\"optional\":true,\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"cipher\",\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"zeroize\"],\"name\":\"generic-array\",\"req\":\"^0.14.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"name\":\"poly1305\",\"req\":\"^0.8\"},{\"features\":[\"zeroize\"],\"name\":\"salsa20\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"zeroize\",\"req\":\"^1\"}],\"features\":{\"alloc\":[\"aead/alloc\"],\"default\":[\"alloc\",\"getrandom\",\"salsa20\"],\"getrandom\":[\"aead/getrandom\",\"rand_core\"],\"heapless\":[\"aead/heapless\"],\"rand_core\":[\"aead/rand_core\"],\"std\":[\"aead/std\",\"alloc\"],\"stream\":[\"aead/stream\"]}}", "csv-core_0.1.13": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"arrayvec\",\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2\"}],\"features\":{\"default\":[],\"libc\":[\"memchr/libc\"]}}", @@ -808,6 +814,7 @@ "ctor-proc-macro_0.0.7": "{\"dependencies\":[],\"features\":{\"default\":[]}}", "ctor_0.1.26": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"libc-print\",\"req\":\"^0.1.20\"},{\"name\":\"quote\",\"req\":\"^1.0.20\"},{\"default_features\":false,\"features\":[\"full\",\"parsing\",\"printing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^1.0.98\"}],\"features\":{}}", "ctor_0.6.3": "{\"dependencies\":[{\"name\":\"ctor-proc-macro\",\"optional\":true,\"req\":\"=0.0.7\"},{\"default_features\":false,\"name\":\"dtor\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"libc-print\",\"req\":\"^0.1.20\"}],\"features\":{\"__no_warn_on_missing_unsafe\":[\"dtor?/__no_warn_on_missing_unsafe\"],\"default\":[\"dtor\",\"proc_macro\",\"__no_warn_on_missing_unsafe\"],\"dtor\":[\"dep:dtor\"],\"proc_macro\":[\"dep:ctor-proc-macro\",\"dtor?/proc_macro\"],\"used_linker\":[\"dtor?/used_linker\"]}}", + "ctutils_0.4.2": "{\"dependencies\":[{\"name\":\"cmov\",\"req\":\"^0.5.3\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.11\"},{\"default_features\":false,\"name\":\"subtle\",\"optional\":true,\"req\":\"^2\"}],\"features\":{\"alloc\":[],\"subtle\":[\"dep:subtle\"]}}", "curve25519-dalek-derive_0.1.1": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.66\"},{\"name\":\"quote\",\"req\":\"^1.0.31\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.27\"}],\"features\":{}}", "curve25519-dalek_4.1.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1\"},{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2.6\",\"target\":\"cfg(target_arch = \\\"x86_64\\\")\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"name\":\"curve25519-dalek-derive\",\"req\":\"^0.1\",\"target\":\"cfg(all(not(curve25519_dalek_backend = \\\"fiat\\\"), not(curve25519_dalek_backend = \\\"serial\\\"), target_arch = \\\"x86_64\\\"))\"},{\"default_features\":false,\"name\":\"digest\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"ff\",\"optional\":true,\"req\":\"^0.13\"},{\"default_features\":false,\"name\":\"fiat-crypto\",\"req\":\"^0.2.1\",\"target\":\"cfg(curve25519_dalek_backend = \\\"fiat\\\")\"},{\"default_features\":false,\"name\":\"group\",\"optional\":true,\"req\":\"^0.13\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.2\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6.4\"},{\"default_features\":false,\"features\":[\"getrandom\"],\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.6\"},{\"kind\":\"build\",\"name\":\"rustc_version\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.3.0\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"zeroize?/alloc\"],\"default\":[\"alloc\",\"precomputed-tables\",\"zeroize\"],\"group\":[\"dep:group\",\"rand_core\"],\"group-bits\":[\"group\",\"ff/bits\"],\"legacy_compatibility\":[],\"precomputed-tables\":[]}}", "cxx-build_1.0.194": "{\"dependencies\":[{\"name\":\"cc\",\"req\":\"^1.0.101\"},{\"name\":\"codespan-reporting\",\"req\":\"^0.13.1\"},{\"kind\":\"dev\",\"name\":\"cxx\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"cxx-gen\",\"req\":\"^0.7\"},{\"name\":\"indexmap\",\"req\":\"^2.9.0\"},{\"kind\":\"dev\",\"name\":\"pkg-config\",\"req\":\"^0.3.27\"},{\"default_features\":false,\"features\":[\"span-locations\"],\"name\":\"proc-macro2\",\"req\":\"^1.0.74\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"name\":\"scratch\",\"req\":\"^1.0.5\"},{\"default_features\":false,\"features\":[\"clone-impls\",\"full\",\"parsing\",\"printing\"],\"name\":\"syn\",\"req\":\"^2.0.46\"}],\"features\":{\"parallel\":[\"cc/parallel\"]}}", @@ -849,9 +856,10 @@ "difflib_0.4.0": "{\"dependencies\":[],\"features\":{}}", "diffy_0.4.2": "{\"dependencies\":[{\"name\":\"nu-ansi-term\",\"req\":\"^0.50\"}],\"features\":{}}", "digest_0.10.7": "{\"dependencies\":[{\"name\":\"blobby\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"block-buffer\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"const-oid\",\"optional\":true,\"req\":\"^0.9\"},{\"name\":\"crypto-common\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"name\":\"subtle\",\"optional\":true,\"req\":\"^2.4\"}],\"features\":{\"alloc\":[],\"core-api\":[\"block-buffer\"],\"default\":[\"core-api\"],\"dev\":[\"blobby\"],\"mac\":[\"subtle\"],\"oid\":[\"const-oid\"],\"rand_core\":[\"crypto-common/rand_core\"],\"std\":[\"alloc\",\"crypto-common/std\"]}}", - "diplomat-runtime_0.14.0": "{\"dependencies\":[{\"name\":\"jni\",\"optional\":true,\"req\":\"^0.21\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"}],\"features\":{\"jvm-callback-support\":[\"dep:jni\"],\"log\":[\"dep:log\"]}}", - "diplomat_0.14.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"diplomat_core\",\"req\":\"^0.14.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.7.1\"},{\"kind\":\"dev\",\"name\":\"prettyplease\",\"req\":\"^0.2.30\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.27\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.2.0\"}],\"features\":{}}", - "diplomat_core_0.14.0": "{\"dependencies\":[{\"name\":\"displaydoc\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"either\",\"optional\":true,\"req\":\"^1.9.0\"},{\"features\":[\"yaml\"],\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.7.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.27\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"smallvec\",\"req\":\"^1.9.0\"},{\"features\":[\"ident\"],\"name\":\"strck\",\"req\":\"^1.0\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{\"hir\":[\"either\"]}}", + "digest_0.11.3": "{\"dependencies\":[{\"name\":\"blobby\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"block-buffer\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"common\",\"package\":\"crypto-common\",\"req\":\"^0.2\"},{\"name\":\"const-oid\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"ctutils\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.11\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.7\"}],\"features\":{\"alloc\":[],\"block-api\":[\"dep:block-buffer\"],\"default\":[\"block-api\"],\"dev\":[\"blobby\"],\"getrandom\":[\"common/getrandom\",\"rand_core\"],\"mac\":[\"dep:ctutils\"],\"oid\":[\"dep:const-oid\"],\"rand_core\":[\"common/rand_core\"],\"zeroize\":[\"dep:zeroize\",\"block-buffer?/zeroize\"]}}", + "diplomat-runtime_0.15.1": "{\"dependencies\":[{\"name\":\"jni\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"}],\"features\":{\"jvm-callback-support\":[\"dep:jni\"],\"log\":[\"dep:log\"]}}", + "diplomat_0.15.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"diplomat_core\",\"req\":\"^0.15.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.7.1\"},{\"kind\":\"dev\",\"name\":\"prettyplease\",\"req\":\"^0.2.30\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.27\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.2.0\"}],\"features\":{}}", + "diplomat_core_0.15.0": "{\"dependencies\":[{\"name\":\"displaydoc\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"either\",\"optional\":true,\"req\":\"^1.9.0\"},{\"features\":[\"yaml\"],\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.7.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.27\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"smallvec\",\"req\":\"^1.9.0\"},{\"features\":[\"ident\"],\"name\":\"strck\",\"req\":\"^1.0\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{\"hir\":[\"either\"]}}", "dirs-next_2.0.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"dirs-sys-next\",\"req\":\"^0.1\"}],\"features\":{}}", "dirs-sys-next_0.1.2": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"default_features\":false,\"name\":\"redox_users\",\"req\":\"^0.4.0\",\"target\":\"cfg(target_os = \\\"redox\\\")\"},{\"features\":[\"knownfolders\",\"objbase\",\"shlobj\",\"winbase\",\"winerror\"],\"name\":\"winapi\",\"req\":\"^0.3\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "dirs-sys_0.5.0": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"name\":\"option-ext\",\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"redox_users\",\"req\":\"^0.5\",\"target\":\"cfg(target_os = \\\"redox\\\")\"},{\"features\":[\"Win32_UI_Shell\",\"Win32_Foundation\",\"Win32_Globalization\",\"Win32_System_Com\"],\"name\":\"windows-sys\",\"req\":\">=0.59.0\",\"target\":\"cfg(windows)\"}],\"features\":{}}", @@ -859,6 +867,8 @@ "dispatch2_0.3.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"name\":\"bitflags\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"block2\",\"optional\":true,\"req\":\">=0.6.1, <0.8.0\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.80\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"objc2\",\"optional\":true,\"req\":\">=0.6.1, <0.8.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"}],\"features\":{\"alloc\":[],\"block2\":[\"dep:block2\"],\"default\":[\"std\",\"block2\",\"libc\",\"objc2\"],\"libc\":[\"dep:libc\"],\"objc2\":[\"dep:objc2\"],\"std\":[\"alloc\"]}}", "display_container_0.9.0": "{\"dependencies\":[{\"name\":\"either\",\"req\":\"^1.8\"},{\"name\":\"indenter\",\"req\":\"^0.3.3\"}],\"features\":{}}", "displaydoc_0.2.5": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^0.6.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1\"},{\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^1.0.24\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", + "divan-macros_0.1.21": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"full\",\"clone-impls\",\"parsing\",\"printing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0.18\"}],\"features\":{}}", + "divan_0.1.21": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\",\"env\"],\"name\":\"clap\",\"req\":\"^4\"},{\"name\":\"condtype\",\"req\":\"^1.3\"},{\"name\":\"divan-macros\",\"req\":\"=0.1.21\"},{\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"mimalloc\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"std\",\"string\"],\"name\":\"regex\",\"package\":\"regex-lite\",\"req\":\"^0.1\"}],\"features\":{\"default\":[\"wrap_help\"],\"dyn_thread_local\":[],\"help\":[\"clap/help\"],\"internal_benches\":[],\"wrap_help\":[\"help\",\"clap/wrap_help\"]}}", "dns-lookup_3.0.1": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"name\":\"socket2\",\"req\":\"^0.6.0\"},{\"features\":[\"Win32_Networking_WinSock\",\"Win32_Foundation\"],\"name\":\"windows-sys\",\"req\":\"^0.60\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "document-features_0.2.12": "{\"dependencies\":[{\"name\":\"litrs\",\"req\":\"^1.0.0\"}],\"features\":{\"default\":[],\"self-test\":[]}}", "dotenvy_0.15.7": "{\"dependencies\":[{\"name\":\"clap\",\"optional\":true,\"req\":\"^3.2\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.16.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.3.0\"}],\"features\":{\"cli\":[\"clap\"]}}", @@ -896,7 +906,7 @@ "erased-serde_0.3.31": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.13\"},{\"default_features\":false,\"name\":\"serde\",\"req\":\"^1.0.166\"},{\"kind\":\"dev\",\"name\":\"serde_cbor\",\"req\":\"^0.11.2\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.166\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.99\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.83\"}],\"features\":{\"alloc\":[\"serde/alloc\"],\"default\":[\"std\"],\"std\":[\"serde/std\"],\"unstable-debug\":[]}}", "errno_0.3.14": "{\"dependencies\":[{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(target_os=\\\"hermit\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(target_os=\\\"wasi\\\")\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Diagnostics_Debug\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"libc/std\"]}}", "error-code_3.3.2": "{\"dependencies\":[],\"features\":{\"std\":[]}}", - "etcetera_0.8.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"home\",\"req\":\"^0.5\"},{\"features\":[\"Win32_Foundation\",\"Win32_UI_Shell\"],\"name\":\"windows-sys\",\"req\":\"^0.48\",\"target\":\"cfg(windows)\"}],\"features\":{}}", + "etcetera_0.11.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Com\",\"Win32_UI_Shell\"],\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "event-listener-strategy_0.5.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"event-listener\",\"req\":\"^5.0.0\"},{\"kind\":\"dev\",\"name\":\"futures-lite\",\"req\":\"^2.0.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.12\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.37\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"default\":[\"std\"],\"loom\":[\"event-listener/loom\"],\"portable-atomic\":[\"event-listener/portable-atomic\"],\"std\":[\"event-listener/std\"]}}", "event-listener_5.4.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"concurrent-queue\",\"req\":\"^2.4.0\"},{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"default_features\":false,\"name\":\"critical-section\",\"optional\":true,\"req\":\"^1.2.0\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"critical-section\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"futures-lite\",\"req\":\"^2.0.0\"},{\"name\":\"loom\",\"optional\":true,\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"name\":\"parking\",\"optional\":true,\"req\":\"^2.0.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.12\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"portable-atomic-util\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"portable_atomic_crate\",\"optional\":true,\"package\":\"portable-atomic\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"try-lock\",\"req\":\"^0.2.5\"},{\"kind\":\"dev\",\"name\":\"waker-fn\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"default\":[\"std\"],\"loom\":[\"concurrent-queue/loom\",\"parking?/loom\",\"dep:loom\"],\"portable-atomic\":[\"portable-atomic-util\",\"portable_atomic_crate\",\"concurrent-queue/portable-atomic\"],\"std\":[\"concurrent-queue/std\",\"parking\"]}}", "eventsource-stream_0.2.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"http\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"nom\",\"req\":\"^7.1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.8\"},{\"features\":[\"stream\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.11\"},{\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2.2\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"futures-core/std\",\"nom/std\"]}}", @@ -914,7 +924,7 @@ "find-crate_0.6.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"quote\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"semver\",\"req\":\"^0.11\"},{\"name\":\"toml\",\"req\":\"^0.5.2\"}],\"features\":{}}", "find-msvc-tools_0.1.9": "{\"dependencies\":[],\"features\":{}}", "findshlibs_0.10.2": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.67\"},{\"name\":\"lazy_static\",\"req\":\"^1.4\",\"target\":\"cfg(any(target_os = \\\"macos\\\", target_os = \\\"ios\\\"))\"},{\"name\":\"libc\",\"req\":\"^0.2.104\"},{\"features\":[\"psapi\",\"memoryapi\",\"libloaderapi\",\"processthreadsapi\"],\"name\":\"winapi\",\"req\":\"^0.3.9\",\"target\":\"cfg(target_os = \\\"windows\\\")\"}],\"features\":{}}", - "fixed_decimal_0.7.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"features\":[\"wasm_js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_distr\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"small\"],\"name\":\"ryu\",\"optional\":true,\"req\":\"^1.0.5\"},{\"default_features\":false,\"name\":\"smallvec\",\"req\":\"^1.10.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"writeable\",\"req\":\"^0.6.0\"}],\"features\":{\"experimental\":[],\"ryu\":[\"dep:ryu\"]}}", + "fixed_decimal_0.7.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"features\":[\"wasm_js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_distr\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"small\"],\"name\":\"ryu\",\"optional\":true,\"req\":\"^1.0.5\"},{\"default_features\":false,\"features\":[\"const_new\"],\"name\":\"smallvec\",\"req\":\"^1.10.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"writeable\",\"req\":\"^0.6.1\"}],\"features\":{\"ryu\":[\"dep:ryu\"]}}", "fixedbitset_0.4.2": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "fixedbitset_0.5.7": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "flate2_1.1.8": "{\"dependencies\":[{\"name\":\"cloudflare-zlib-sys\",\"optional\":true,\"req\":\"^0.3.6\"},{\"name\":\"crc32fast\",\"req\":\"^1.2.0\"},{\"name\":\"document-features\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"libz-ng-sys\",\"optional\":true,\"req\":\"^1.1.16\"},{\"default_features\":false,\"name\":\"libz-sys\",\"optional\":true,\"req\":\"^1.1.20\"},{\"default_features\":false,\"features\":[\"with-alloc\",\"simd\"],\"name\":\"miniz_oxide\",\"req\":\"^0.8.5\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(target_os = \\\"emscripten\\\")))\"},{\"default_features\":false,\"features\":[\"with-alloc\",\"simd\"],\"name\":\"miniz_oxide\",\"optional\":true,\"req\":\"^0.8.5\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"std\",\"rust-allocator\"],\"name\":\"zlib-rs\",\"optional\":true,\"req\":\"^0.5.5\"}],\"features\":{\"any_c_zlib\":[\"any_zlib\"],\"any_impl\":[],\"any_zlib\":[\"any_impl\"],\"cloudflare_zlib\":[\"any_c_zlib\",\"cloudflare-zlib-sys\"],\"default\":[\"rust_backend\"],\"miniz-sys\":[\"rust_backend\"],\"rust_backend\":[\"miniz_oxide\",\"any_impl\"],\"zlib\":[\"any_c_zlib\",\"libz-sys\"],\"zlib-default\":[\"any_c_zlib\",\"libz-sys/default\"],\"zlib-ng\":[\"any_c_zlib\",\"libz-ng-sys\"],\"zlib-ng-compat\":[\"zlib\",\"libz-sys/zlib-ng\"],\"zlib-rs\":[\"any_zlib\",\"dep:zlib-rs\"]}}", @@ -923,7 +933,6 @@ "fluent-langneg_0.13.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"unic-langid\",\"req\":\"^0.9\"},{\"features\":[\"macros\"],\"kind\":\"dev\",\"name\":\"unic-langid\",\"req\":\"^0.9\"},{\"features\":[\"macros\"],\"kind\":\"dev\",\"name\":\"unic-locale\",\"req\":\"^0.9\"}],\"features\":{\"cldr\":[\"unic-langid/likelysubtags\"],\"default\":[]}}", "fluent-syntax_0.11.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"iai\",\"req\":\"^0.1\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"thiserror\",\"req\":\"^1.0\"}],\"features\":{\"all-benchmarks\":[],\"default\":[],\"json\":[\"serde\",\"serde_json\"]}}", "fluent_0.16.1": "{\"dependencies\":[{\"name\":\"fluent-bundle\",\"req\":\"^0.15.3\"},{\"name\":\"fluent-pseudo\",\"optional\":true,\"req\":\"^0.3.2\"},{\"name\":\"unic-langid\",\"req\":\"^0.9\"}],\"features\":{}}", - "flume_0.11.1": "{\"dependencies\":[{\"features\":[\"attributes\",\"unstable\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.13.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"crossbeam-channel\",\"req\":\"^0.5.5\"},{\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"^0.8.10\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3\"},{\"features\":[\"js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2.15\"},{\"features\":[\"getrandom\"],\"name\":\"nanorand\",\"optional\":true,\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.3\"},{\"features\":[\"mutex\"],\"name\":\"spin1\",\"package\":\"spin\",\"req\":\"^0.9.8\"},{\"features\":[\"rt\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.16.1\"},{\"kind\":\"dev\",\"name\":\"waker-fn\",\"req\":\"^1.1.0\"}],\"features\":{\"async\":[\"futures-sink\",\"futures-core\"],\"default\":[\"async\",\"select\",\"eventual-fairness\"],\"eventual-fairness\":[\"select\",\"nanorand\"],\"select\":[],\"spin\":[]}}", "flume_0.12.0": "{\"dependencies\":[{\"features\":[\"attributes\",\"unstable\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.13.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"crossbeam-channel\",\"req\":\"^0.5.5\"},{\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"^0.8.10\"},{\"features\":[\"std\",\"js\"],\"name\":\"fastrand\",\"optional\":true,\"req\":\"^2.3\"},{\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-core\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3\"},{\"features\":[\"js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2.15\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.3\"},{\"features\":[\"mutex\"],\"name\":\"spin1\",\"package\":\"spin\",\"req\":\"^0.9.8\"},{\"features\":[\"rt\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.16.1\"},{\"kind\":\"dev\",\"name\":\"waker-fn\",\"req\":\"^1.1.0\"}],\"features\":{\"async\":[\"futures-sink\",\"futures-core\"],\"default\":[\"async\",\"select\",\"eventual-fairness\"],\"eventual-fairness\":[\"select\",\"fastrand\"],\"select\":[],\"spin\":[]}}", "fnv_1.0.7": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "foldhash_0.1.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"fxhash\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"hashbrown\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"uuid\",\"req\":\"^1.8\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", @@ -935,20 +944,15 @@ "fs_extra_1.3.0": "{\"dependencies\":[],\"features\":{}}", "fsevent-sys_4.1.0": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.68\"}],\"features\":{}}", "fslock_0.2.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.66\",\"target\":\"cfg(unix)\"},{\"features\":[\"minwindef\",\"minwinbase\",\"winbase\",\"errhandlingapi\",\"winerror\",\"winnt\",\"synchapi\",\"handleapi\",\"fileapi\",\"processthreadsapi\"],\"name\":\"winapi\",\"req\":\"^0.3.8\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", - "futures-channel_0.3.31": "{\"dependencies\":[{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3.31\"}],\"features\":{\"alloc\":[\"futures-core/alloc\"],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"sink\":[\"futures-sink\"],\"std\":[\"alloc\",\"futures-core/std\"],\"unstable\":[]}}", "futures-channel_0.3.32": "{\"dependencies\":[{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3.32\"}],\"features\":{\"alloc\":[\"futures-core/alloc\"],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"sink\":[\"futures-sink\"],\"std\":[\"alloc\",\"futures-core/std\"],\"unstable\":[]}}", - "futures-core_0.3.31": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"require-cas\"],\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1.3\"}],\"features\":{\"alloc\":[],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"std\":[\"alloc\"],\"unstable\":[]}}", "futures-core_0.3.32": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"require-cas\"],\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1.3\"}],\"features\":{\"alloc\":[],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"std\":[\"alloc\"],\"unstable\":[]}}", - "futures-executor_0.3.31": "{\"dependencies\":[{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-task\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-util\",\"req\":\"^0.3.31\"},{\"name\":\"num_cpus\",\"optional\":true,\"req\":\"^1.8.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"futures-core/std\",\"futures-task/std\",\"futures-util/std\"],\"thread-pool\":[\"std\",\"num_cpus\"]}}", + "futures-executor_0.3.32": "{\"dependencies\":[{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-task\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-util\",\"req\":\"^0.3.32\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"futures-core/std\",\"futures-task/std\",\"futures-util/std\"],\"thread-pool\":[\"std\"]}}", "futures-intrusive_0.5.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"crossbeam\",\"req\":\"^0.7\"},{\"features\":[\"async-await\"],\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.0\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"futures-test\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4.0\"},{\"name\":\"lock_api\",\"req\":\"^0.4.1\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12.0\"},{\"kind\":\"dev\",\"name\":\"pin-utils\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"signal-hook\",\"req\":\"^0.1.11\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.14\"}],\"features\":{\"alloc\":[\"futures-core/alloc\"],\"default\":[\"std\"],\"std\":[\"alloc\",\"parking_lot\"]}}", - "futures-io_0.3.31": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[],\"unstable\":[]}}", + "futures-io_0.3.32": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[],\"unstable\":[]}}", "futures-lite_2.6.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"fastrand\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.5\"},{\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.5\"},{\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.3.3\"},{\"name\":\"parking\",\"optional\":true,\"req\":\"^2.2.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"spin_on\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"waker-fn\",\"req\":\"^1.0.0\"}],\"features\":{\"alloc\":[],\"default\":[\"race\",\"std\"],\"race\":[\"fastrand\"],\"std\":[\"alloc\",\"fastrand/std\",\"futures-io\",\"parking\"]}}", - "futures-macro_0.3.31": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.52\"}],\"features\":{}}", - "futures-sink_0.3.31": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", + "futures-macro_0.3.32": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0.52\"}],\"features\":{}}", "futures-sink_0.3.32": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", - "futures-task_0.3.31": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"std\":[\"alloc\"],\"unstable\":[]}}", "futures-task_0.3.32": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"cfg-target-has-atomic\":[],\"default\":[\"std\"],\"std\":[\"alloc\"],\"unstable\":[]}}", - "futures-util_0.3.31": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-macro\",\"optional\":true,\"req\":\"=0.3.31\"},{\"default_features\":false,\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-task\",\"req\":\"^0.3.31\"},{\"name\":\"futures_01\",\"optional\":true,\"package\":\"futures\",\"req\":\"^0.1.25\"},{\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.2\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.6\"},{\"name\":\"pin-utils\",\"req\":\"^0.1.0\"},{\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.2\"},{\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^0.1.11\"},{\"name\":\"tokio-io\",\"optional\":true,\"req\":\"^0.1.9\"}],\"features\":{\"alloc\":[\"futures-core/alloc\",\"futures-task/alloc\"],\"async-await\":[],\"async-await-macro\":[\"async-await\",\"futures-macro\"],\"bilock\":[],\"cfg-target-has-atomic\":[],\"channel\":[\"std\",\"futures-channel\"],\"compat\":[\"std\",\"futures_01\"],\"default\":[\"std\",\"async-await\",\"async-await-macro\"],\"io\":[\"std\",\"futures-io\",\"memchr\"],\"io-compat\":[\"io\",\"compat\",\"tokio-io\"],\"portable-atomic\":[\"futures-core/portable-atomic\"],\"sink\":[\"futures-sink\"],\"std\":[\"alloc\",\"futures-core/std\",\"futures-task/std\",\"slab\"],\"unstable\":[\"futures-core/unstable\",\"futures-task/unstable\"],\"write-all-vectored\":[\"io\"]}}", "futures-util_0.3.32": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-macro\",\"optional\":true,\"req\":\"=0.3.32\"},{\"default_features\":false,\"name\":\"futures-sink\",\"optional\":true,\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-task\",\"req\":\"^0.3.32\"},{\"name\":\"futures_01\",\"optional\":true,\"package\":\"futures\",\"req\":\"^0.1.25\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.26\"},{\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.2\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.6\"},{\"default_features\":false,\"name\":\"slab\",\"optional\":true,\"req\":\"^0.4.7\"},{\"name\":\"spin\",\"optional\":true,\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^0.1.11\"},{\"name\":\"tokio-io\",\"optional\":true,\"req\":\"^0.1.9\"}],\"features\":{\"alloc\":[\"futures-core/alloc\",\"futures-task/alloc\",\"slab\"],\"async-await\":[],\"async-await-macro\":[\"async-await\",\"futures-macro\"],\"bilock\":[],\"cfg-target-has-atomic\":[],\"channel\":[\"std\",\"futures-channel\"],\"compat\":[\"std\",\"futures_01\",\"libc\"],\"default\":[\"std\",\"async-await\",\"async-await-macro\"],\"io\":[\"std\",\"futures-io\",\"memchr\"],\"io-compat\":[\"io\",\"compat\",\"tokio-io\",\"libc\"],\"portable-atomic\":[\"futures-core/portable-atomic\"],\"sink\":[\"futures-sink\"],\"std\":[\"alloc\",\"futures-core/std\",\"futures-task/std\",\"slab/std\"],\"unstable\":[\"futures-core/unstable\",\"futures-task/unstable\"],\"write-all-vectored\":[\"io\"]}}", "futures_0.3.31": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.3.0\"},{\"default_features\":false,\"features\":[\"sink\"],\"name\":\"futures-channel\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-executor\",\"optional\":true,\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-io\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-sink\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"name\":\"futures-task\",\"req\":\"^0.3.31\"},{\"default_features\":false,\"features\":[\"sink\"],\"name\":\"futures-util\",\"req\":\"^0.3.31\"},{\"kind\":\"dev\",\"name\":\"pin-project\",\"req\":\"^1.0.11\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^0.1.11\"}],\"features\":{\"alloc\":[\"futures-core/alloc\",\"futures-task/alloc\",\"futures-sink/alloc\",\"futures-channel/alloc\",\"futures-util/alloc\"],\"async-await\":[\"futures-util/async-await\",\"futures-util/async-await-macro\"],\"bilock\":[\"futures-util/bilock\"],\"cfg-target-has-atomic\":[],\"compat\":[\"std\",\"futures-util/compat\"],\"default\":[\"std\",\"async-await\",\"executor\"],\"executor\":[\"std\",\"futures-executor/std\"],\"io-compat\":[\"compat\",\"futures-util/io-compat\"],\"std\":[\"alloc\",\"futures-core/std\",\"futures-task/std\",\"futures-io/std\",\"futures-sink/std\",\"futures-util/std\",\"futures-util/io\",\"futures-util/channel\"],\"thread-pool\":[\"executor\",\"futures-executor/thread-pool\"],\"unstable\":[\"futures-core/unstable\",\"futures-task/unstable\",\"futures-channel/unstable\",\"futures-io/unstable\",\"futures-util/unstable\"],\"write-all-vectored\":[\"futures-util/write-all-vectored\"]}}", "fxhash_0.2.1": "{\"dependencies\":[{\"name\":\"byteorder\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0.5\"},{\"kind\":\"dev\",\"name\":\"seahash\",\"req\":\"^3.0.5\"}],\"features\":{}}", @@ -1045,7 +1049,7 @@ "hashbrown_0.14.5": "{\"dependencies\":[{\"default_features\":false,\"name\":\"ahash\",\"optional\":true,\"req\":\"^0.8.7\"},{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"allocator-api2\",\"optional\":true,\"req\":\"^0.2.9\"},{\"features\":[\"allocator-api2\"],\"kind\":\"dev\",\"name\":\"bumpalo\",\"req\":\"^3.13.0\"},{\"name\":\"compiler_builtins\",\"optional\":true,\"req\":\"^0.1.2\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"equivalent\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0.7\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.3\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.7.42\"},{\"features\":[\"validation\"],\"kind\":\"dev\",\"name\":\"rkyv\",\"req\":\"^0.7.42\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.25\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"ahash\",\"inline-more\",\"allocator-api2\"],\"inline-more\":[],\"nightly\":[\"allocator-api2?/nightly\",\"bumpalo/allocator_api\"],\"raw\":[],\"rustc-dep-of-std\":[\"nightly\",\"core\",\"compiler_builtins\",\"alloc\",\"rustc-internal-api\"],\"rustc-internal-api\":[]}}", "hashbrown_0.15.5": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"allocator-api2\",\"optional\":true,\"req\":\"^0.2.9\"},{\"features\":[\"allocator-api2\"],\"kind\":\"dev\",\"name\":\"bumpalo\",\"req\":\"^3.13.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"equivalent\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0.7\"},{\"default_features\":false,\"name\":\"foldhash\",\"optional\":true,\"req\":\"^0.1.2\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9.0\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.2\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.2\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.25\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"default-hasher\",\"inline-more\",\"allocator-api2\",\"equivalent\",\"raw-entry\"],\"default-hasher\":[\"dep:foldhash\"],\"inline-more\":[],\"nightly\":[\"bumpalo/allocator_api\"],\"raw-entry\":[],\"rustc-dep-of-std\":[\"nightly\",\"core\",\"alloc\",\"rustc-internal-api\"],\"rustc-internal-api\":[]}}", "hashbrown_0.16.1": "{\"dependencies\":[{\"name\":\"alloc\",\"optional\":true,\"package\":\"rustc-std-workspace-alloc\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"allocator-api2\",\"optional\":true,\"req\":\"^0.2.9\"},{\"features\":[\"allocator-api2\"],\"kind\":\"dev\",\"name\":\"bumpalo\",\"req\":\"^3.13.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"equivalent\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"fnv\",\"req\":\"^1.0.7\"},{\"default_features\":false,\"name\":\"foldhash\",\"optional\":true,\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.4\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(unix)\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9.0\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.9.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.2\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\",\"target\":\"cfg(any())\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"default-hasher\",\"inline-more\",\"allocator-api2\",\"equivalent\",\"raw-entry\"],\"default-hasher\":[\"dep:foldhash\"],\"inline-more\":[],\"nightly\":[\"foldhash?/nightly\",\"bumpalo/allocator_api\"],\"raw-entry\":[],\"rustc-dep-of-std\":[\"nightly\",\"core\",\"alloc\",\"rustc-internal-api\"],\"rustc-internal-api\":[],\"serde\":[\"dep:serde_core\",\"dep:serde\"]}}", - "hashlink_0.10.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"default-hasher\",\"inline-more\"],\"name\":\"hashbrown\",\"req\":\"^0.15\"},{\"kind\":\"dev\",\"name\":\"rustc-hash\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"serde_impl\":[\"serde\"]}}", + "hashlink_0.11.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"default-hasher\"],\"name\":\"hashbrown\",\"req\":\"^0.16\"},{\"kind\":\"dev\",\"name\":\"rustc-hash\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"}],\"features\":{\"serde_impl\":[\"serde\"]}}", "headers-core_0.3.0": "{\"dependencies\":[{\"name\":\"http\",\"req\":\"^1.0.0\"}],\"features\":{}}", "headers_0.4.1": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"headers-core\",\"req\":\"^0.3\"},{\"name\":\"http\",\"req\":\"^1.0.0\"},{\"name\":\"httpdate\",\"req\":\"^1\"},{\"name\":\"mime\",\"req\":\"^0.3.14\"},{\"name\":\"sha1\",\"req\":\"^0.10\"}],\"features\":{\"nightly\":[]}}", "heapless_0.8.0": "{\"dependencies\":[{\"name\":\"defmt\",\"optional\":true,\"req\":\">=0.2.0, <0.4\"},{\"name\":\"hash32\",\"req\":\"^0.3.0\"},{\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"ufmt\",\"req\":\"^0.2\"},{\"name\":\"ufmt-write\",\"optional\":true,\"req\":\"^0.1\"}],\"features\":{\"defmt-03\":[\"dep:defmt\"],\"mpmc_large\":[],\"portable-atomic\":[\"dep:portable-atomic\"],\"portable-atomic-critical-section\":[\"dep:portable-atomic\",\"portable-atomic\",\"portable-atomic?/critical-section\"],\"portable-atomic-unsafe-assume-single-core\":[\"dep:portable-atomic\",\"portable-atomic\",\"portable-atomic?/unsafe-assume-single-core\"],\"serde\":[\"dep:serde\"],\"ufmt\":[\"dep:ufmt-write\"]}}", @@ -1056,7 +1060,9 @@ "hickory-proto_0.25.2": "{\"dependencies\":[{\"name\":\"async-trait\",\"req\":\"^0.1.43\"},{\"default_features\":false,\"features\":[\"prebuilt-nasm\"],\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.12.3\"},{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.50\"},{\"name\":\"bitflags\",\"optional\":true,\"req\":\"^2.4.1\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"critical-section\",\"optional\":true,\"req\":\"^1.1.1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"data-encoding\",\"req\":\"^2.2.0\"},{\"name\":\"enum-as-inner\",\"req\":\"^0.6\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"futures-channel\",\"req\":\"^0.3.5\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"futures-executor\",\"req\":\"^0.3.5\"},{\"default_features\":false,\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.5\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"futures-util\",\"req\":\"^0.3.5\"},{\"features\":[\"stream\"],\"name\":\"h2\",\"optional\":true,\"req\":\"^0.4.0\"},{\"name\":\"h3\",\"optional\":true,\"req\":\"^0.0.7\"},{\"name\":\"h3-quinn\",\"optional\":true,\"req\":\"^0.0.9\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1.1\"},{\"default_features\":false,\"features\":[\"alloc\",\"compiled_data\"],\"name\":\"idna\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"name\":\"ipnet\",\"req\":\"^2.3.0\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3.44\"},{\"default_features\":false,\"features\":[\"critical-section\"],\"name\":\"once_cell\",\"req\":\"^1.20.0\"},{\"name\":\"pin-project-lite\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"log\",\"runtime-tokio\"],\"name\":\"quinn\",\"optional\":true,\"req\":\"^0.11.2\"},{\"default_features\":false,\"features\":[\"alloc\",\"std_rng\"],\"name\":\"rand\",\"req\":\"^0.9\"},{\"features\":[\"std\"],\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"default_features\":false,\"features\":[\"logging\",\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.23\"},{\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.10\"},{\"name\":\"rustls-platform-verifier\",\"optional\":true,\"req\":\"^0.5\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"socket2\",\"optional\":true,\"req\":\"^0.5\"},{\"default_features\":false,\"name\":\"thiserror\",\"req\":\"^2\"},{\"name\":\"time\",\"optional\":true,\"req\":\"^0.3\"},{\"features\":[\"alloc\"],\"name\":\"tinyvec\",\"req\":\"^1.1.1\"},{\"features\":[\"io-util\",\"macros\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.21\"},{\"features\":[\"rt\",\"time\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.21\"},{\"default_features\":false,\"features\":[\"early-data\"],\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26\"},{\"default_features\":false,\"name\":\"tracing\",\"req\":\"^0.1.30\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"url\",\"req\":\"^2.5.4\"},{\"name\":\"wasm-bindgen-crate\",\"optional\":true,\"package\":\"wasm-bindgen\",\"req\":\"^0.2.58\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26\"}],\"features\":{\"__dnssec\":[\"dep:bitflags\",\"dep:rustls-pki-types\",\"dep:time\",\"std\"],\"__h3\":[\"dep:h3\",\"dep:h3-quinn\",\"dep:http\",\"std\"],\"__https\":[\"dep:bytes\",\"dep:h2\",\"dep:http\",\"std\"],\"__quic\":[\"dep:bytes\",\"dep:pin-project-lite\",\"dep:quinn\",\"std\"],\"__tls\":[\"dep:bytes\",\"dep:rustls\",\"dep:tokio-rustls\",\"std\",\"tokio\"],\"backtrace\":[\"dep:backtrace\",\"std\"],\"default\":[\"std\",\"tokio\"],\"dnssec-aws-lc-rs\":[\"dep:aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/ring-io\",\"__dnssec\"],\"dnssec-ring\":[\"dep:ring\",\"__dnssec\"],\"h3-aws-lc-rs\":[\"quic-aws-lc-rs\",\"__h3\"],\"h3-ring\":[\"quic-ring\",\"__h3\"],\"https-aws-lc-rs\":[\"tls-aws-lc-rs\",\"__https\"],\"https-ring\":[\"tls-ring\",\"__https\"],\"mdns\":[\"socket2/all\",\"std\"],\"no-std-rand\":[\"once_cell/critical-section\",\"dep:critical-section\"],\"quic-aws-lc-rs\":[\"quinn/rustls-aws-lc-rs\",\"tls-aws-lc-rs\",\"__quic\"],\"quic-ring\":[\"quinn/rustls-ring\",\"tls-ring\",\"__quic\"],\"rustls-platform-verifier\":[\"dep:rustls-platform-verifier\",\"std\"],\"serde\":[\"dep:serde\",\"std\",\"url/serde\"],\"std\":[\"data-encoding/std\",\"futures-channel/std\",\"futures-io/std\",\"futures-util/std\",\"ipnet/std\",\"rand/std\",\"rand/thread_rng\",\"ring?/std\",\"thiserror/std\",\"tracing-subscriber/env-filter\",\"tracing-subscriber/fmt\",\"tracing-subscriber/std\",\"tracing/std\",\"url/std\"],\"testing\":[\"std\"],\"text-parsing\":[\"std\"],\"tls-aws-lc-rs\":[\"tokio-rustls/aws-lc-rs\",\"__tls\"],\"tls-ring\":[\"tokio-rustls/ring\",\"__tls\"],\"tokio\":[\"dep:tokio\",\"std\",\"tokio/net\",\"tokio/rt\",\"tokio/time\",\"tokio/rt-multi-thread\"],\"wasm-bindgen\":[\"dep:wasm-bindgen-crate\",\"dep:js-sys\"]}}", "hickory-resolver_0.25.2": "{\"dependencies\":[{\"name\":\"backtrace\",\"optional\":true,\"req\":\"^0.3.50\"},{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"futures-executor\",\"req\":\"^0.3.5\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures-util\",\"req\":\"^0.3.5\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"hickory-proto\",\"req\":\"^0.25\"},{\"name\":\"ipconfig\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(windows)\"},{\"features\":[\"sync\"],\"name\":\"moka\",\"req\":\"^0.12\"},{\"default_features\":false,\"features\":[\"critical-section\"],\"name\":\"once_cell\",\"req\":\"^1.20.0\"},{\"name\":\"parking_lot\",\"req\":\"^0.12\"},{\"default_features\":false,\"features\":[\"log\",\"runtime-tokio\"],\"name\":\"quinn\",\"optional\":true,\"req\":\"^0.11.2\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"rand\",\"req\":\"^0.9\"},{\"features\":[\"system\"],\"name\":\"resolv-conf\",\"optional\":true,\"req\":\"^0.7.0\"},{\"default_features\":false,\"features\":[\"logging\",\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.23\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"smallvec\",\"req\":\"^1.6\"},{\"default_features\":false,\"name\":\"thiserror\",\"req\":\"^2\"},{\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.21\"},{\"features\":[\"macros\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.21\"},{\"default_features\":false,\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26\"},{\"kind\":\"dev\",\"name\":\"toml\",\"req\":\"^0.8.14\"},{\"default_features\":false,\"name\":\"tracing\",\"req\":\"^0.1.30\"},{\"default_features\":false,\"features\":[\"env-filter\",\"fmt\",\"std\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26\"}],\"features\":{\"__dnssec\":[],\"__h3\":[\"__quic\"],\"__https\":[\"__tls\"],\"__quic\":[\"dep:quinn\",\"__tls\"],\"__tls\":[\"dep:rustls\",\"dep:tokio-rustls\",\"tokio\"],\"backtrace\":[\"dep:backtrace\",\"hickory-proto/backtrace\"],\"default\":[\"system-config\",\"tokio\"],\"dnssec-aws-lc-rs\":[\"hickory-proto/dnssec-aws-lc-rs\",\"__dnssec\"],\"dnssec-ring\":[\"hickory-proto/dnssec-ring\",\"__dnssec\"],\"h3-aws-lc-rs\":[\"hickory-proto/h3-aws-lc-rs\",\"__h3\"],\"h3-ring\":[\"hickory-proto/h3-ring\",\"__h3\"],\"https-aws-lc-rs\":[\"hickory-proto/https-aws-lc-rs\",\"__https\"],\"https-ring\":[\"hickory-proto/https-ring\",\"__https\"],\"quic-aws-lc-rs\":[\"hickory-proto/quic-aws-lc-rs\",\"__quic\",\"quinn/rustls-aws-lc-rs\"],\"quic-ring\":[\"hickory-proto/quic-ring\",\"__quic\",\"quinn/rustls-ring\"],\"rustls-platform-verifier\":[\"hickory-proto/rustls-platform-verifier\"],\"serde\":[\"dep:serde\",\"hickory-proto/serde\"],\"system-config\":[\"dep:ipconfig\",\"dep:resolv-conf\"],\"tls-aws-lc-rs\":[\"hickory-proto/tls-aws-lc-rs\",\"__tls\"],\"tls-ring\":[\"hickory-proto/tls-ring\",\"__tls\"],\"tokio\":[\"dep:tokio\",\"tokio/rt\",\"hickory-proto/tokio\"],\"webpki-roots\":[\"dep:webpki-roots\",\"hickory-proto/webpki-roots\"]}}", "hkdf_0.12.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"blobby\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"},{\"name\":\"hmac\",\"req\":\"^0.12.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.10\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"}],\"features\":{\"std\":[\"hmac/std\"]}}", + "hkdf_0.13.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"blobby\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"name\":\"hmac\",\"req\":\"^0.13\"},{\"name\":\"kdf\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.11\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.11\"}],\"features\":{}}", "hmac_0.12.1": "{\"dependencies\":[{\"features\":[\"mac\"],\"name\":\"digest\",\"req\":\"^0.10.3\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"md-5\",\"req\":\"^0.10\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha-1\",\"req\":\"^0.10\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"streebog\",\"req\":\"^0.10\"}],\"features\":{\"reset\":[],\"std\":[\"digest/std\"]}}", + "hmac_0.13.0": "{\"dependencies\":[{\"features\":[\"mac\"],\"name\":\"digest\",\"req\":\"^0.11.2\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.11.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"md-5\",\"req\":\"^0.11\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.11\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.11\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"streebog\",\"req\":\"^0.11\"}],\"features\":{\"zeroize\":[\"digest/zeroize\"]}}", "home_0.5.12": "{\"dependencies\":[{\"features\":[\"Win32_Foundation\",\"Win32_UI_Shell\",\"Win32_System_Com\"],\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "home_0.5.9": "{\"dependencies\":[{\"features\":[\"Win32_Foundation\",\"Win32_UI_Shell\",\"Win32_System_Com\"],\"name\":\"windows-sys\",\"req\":\"^0.52\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "hostname_0.4.2": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(any(unix, target_os = \\\"redox\\\"))\"},{\"kind\":\"dev\",\"name\":\"similar-asserts\",\"req\":\"^1.6.1\"},{\"kind\":\"dev\",\"name\":\"version-sync\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"windows-bindgen\",\"req\":\"^0.65\"},{\"name\":\"windows-link\",\"req\":\"^0.2\",\"target\":\"cfg(target_os = \\\"windows\\\")\"}],\"features\":{\"default\":[],\"set\":[]}}", @@ -1068,6 +1074,7 @@ "http_1.4.0": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"name\":\"itoa\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.0\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "httparse_1.10.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.5\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "httpdate_1.0.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"}],\"features\":{}}", + "hybrid-array_0.4.12": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"bytemuck\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"ctutils\",\"optional\":true,\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"const-generics\"],\"name\":\"subtle\",\"optional\":true,\"req\":\"^2\"},{\"features\":[\"const-generics\"],\"name\":\"typenum\",\"req\":\"^1.20\"},{\"features\":[\"derive\"],\"name\":\"zerocopy\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.8\"}],\"features\":{\"alloc\":[],\"extra-sizes\":[]}}", "hyper-rustls_0.27.7": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"http\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"hyper\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"client-legacy\",\"tokio\"],\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"server-auto\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.4\"},{\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"rustls\",\"req\":\"^0.23\"},{\"default_features\":false,\"features\":[\"tls12\"],\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rustls-pemfile\",\"req\":\"^2\"},{\"name\":\"rustls-platform-verifier\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"tokio\",\"req\":\"^1.0\"},{\"features\":[\"io-std\",\"macros\",\"net\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"tokio-rustls\",\"req\":\"^0.26\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"aws-lc-rs\":[\"rustls/aws_lc_rs\"],\"default\":[\"native-tokio\",\"http1\",\"tls12\",\"logging\",\"aws-lc-rs\"],\"fips\":[\"aws-lc-rs\",\"rustls/fips\"],\"http1\":[\"hyper-util/http1\"],\"http2\":[\"hyper-util/http2\"],\"logging\":[\"log\",\"tokio-rustls/logging\",\"rustls/logging\"],\"native-tokio\":[\"rustls-native-certs\"],\"ring\":[\"rustls/ring\"],\"tls12\":[\"tokio-rustls/tls12\",\"rustls/tls12\"],\"webpki-tokio\":[\"webpki-roots\"]}}", "hyper-timeout_0.5.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"name\":\"hyper\",\"req\":\"^1.1\"},{\"features\":[\"http1\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.1\"},{\"kind\":\"dev\",\"name\":\"hyper-tls\",\"req\":\"^0.6\"},{\"features\":[\"client-legacy\",\"http1\"],\"name\":\"hyper-util\",\"req\":\"^0.1.10\"},{\"features\":[\"client-legacy\",\"http1\",\"server\",\"server-graceful\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.10\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"name\":\"tokio\",\"req\":\"^1.35\"},{\"features\":[\"io-std\",\"io-util\",\"macros\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.35\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"}],\"features\":{}}", "hyper-tls_0.6.0": "{\"dependencies\":[{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.0\"},{\"name\":\"hyper\",\"req\":\"^1\"},{\"features\":[\"client-legacy\",\"tokio\"],\"name\":\"hyper-util\",\"req\":\"^0.1.0\"},{\"features\":[\"http1\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.0\"},{\"name\":\"native-tls\",\"req\":\"^0.2.1\"},{\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"io-std\",\"macros\",\"io-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0.0\"},{\"name\":\"tokio-native-tls\",\"req\":\"^0.3\"},{\"name\":\"tower-service\",\"req\":\"^0.3\"}],\"features\":{\"alpn\":[\"native-tls/alpn\"],\"vendored\":[\"native-tls/vendored\"]}}", @@ -1079,19 +1086,28 @@ "i18n-embed_0.15.4": "{\"dependencies\":[{\"name\":\"arc-swap\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"name\":\"fluent\",\"optional\":true,\"req\":\"^0.16\"},{\"name\":\"fluent-langneg\",\"req\":\"^0.13\"},{\"name\":\"fluent-syntax\",\"optional\":true,\"req\":\"^0.11\"},{\"name\":\"gettext\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"i18n-embed-impl\",\"optional\":true,\"req\":\"^0.8.4\"},{\"name\":\"intl-memoizer\",\"req\":\"^0.5\"},{\"name\":\"locale_config\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"maplit\",\"req\":\"^1.0\"},{\"name\":\"notify\",\"optional\":true,\"req\":\"^8.0.0\"},{\"name\":\"parking_lot\",\"optional\":true,\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.4\"},{\"name\":\"rust-embed\",\"optional\":true,\"req\":\"^8.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3.0\"},{\"name\":\"thiserror\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"tr\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"unic-langid\",\"req\":\"^0.9\"},{\"name\":\"walkdir\",\"optional\":true,\"req\":\"^2.4\"},{\"features\":[\"Window\",\"Navigator\"],\"name\":\"web-sys\",\"optional\":true,\"req\":\"^0.3\"}],\"features\":{\"autoreload\":[\"notify\"],\"default\":[\"rust-embed\"],\"desktop-requester\":[\"locale_config\"],\"filesystem-assets\":[\"walkdir\"],\"fluent-system\":[\"fluent\",\"fluent-syntax\",\"parking_lot\",\"i18n-embed-impl\",\"i18n-embed-impl/fluent-system\",\"arc-swap\"],\"gettext-system\":[\"tr\",\"tr/gettext\",\"dep:gettext\",\"parking_lot\",\"i18n-embed-impl\",\"i18n-embed-impl/gettext-system\"],\"web-sys-requester\":[\"web-sys\"]}}", "iana-time-zone-haiku_0.1.2": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.79\"}],\"features\":{}}", "iana-time-zone_0.1.65": "{\"dependencies\":[{\"name\":\"android_system_properties\",\"req\":\"^0.1.5\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"kind\":\"dev\",\"name\":\"chrono-tz\",\"req\":\"^0.10.1\"},{\"name\":\"core-foundation-sys\",\"req\":\"^0.8.6\",\"target\":\"cfg(target_vendor = \\\"apple\\\")\"},{\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2.1\"},{\"features\":[\"js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2.1\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"iana-time-zone-haiku\",\"req\":\"^0.1.1\",\"target\":\"cfg(target_os = \\\"haiku\\\")\"},{\"name\":\"js-sys\",\"req\":\"^0.3.66\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"log\",\"req\":\"^0.4.14\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.46\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"windows-core\",\"req\":\">=0.56, <=0.62\",\"target\":\"cfg(target_os = \\\"windows\\\")\"}],\"features\":{\"fallback\":[]}}", - "icu_calendar_2.1.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"calendrical_calculations\",\"req\":\"^0.2.3\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"name\":\"icu_calendar_data\",\"optional\":true,\"req\":\"~2.1.1\"},{\"default_features\":false,\"name\":\"icu_locale\",\"optional\":true,\"req\":\"~2.1.1\"},{\"default_features\":false,\"name\":\"icu_locale_core\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"name\":\"icu_provider\",\"req\":\"^2.1.1\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"name\":\"ixdtf\",\"optional\":true,\"req\":\"^0.6.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"kind\":\"dev\",\"name\":\"simple_logger\",\"req\":\"^5.0.0\"},{\"default_features\":false,\"features\":[\"zerovec\"],\"name\":\"tinystr\",\"req\":\"^0.8.0\"},{\"kind\":\"dev\",\"name\":\"ureq\",\"req\":\"^3.0.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"zerovec\",\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"icu_locale_core/alloc\",\"tinystr/alloc\",\"serde?/alloc\"],\"compiled_data\":[\"dep:icu_calendar_data\",\"dep:icu_locale\",\"icu_locale?/compiled_data\",\"icu_provider/baked\"],\"datagen\":[\"serde\",\"dep:databake\",\"zerovec/databake\",\"tinystr/databake\",\"alloc\",\"icu_provider/export\"],\"default\":[\"compiled_data\",\"ixdtf\"],\"ixdtf\":[\"dep:ixdtf\"],\"logging\":[\"calendrical_calculations/logging\"],\"serde\":[\"dep:serde\",\"zerovec/serde\",\"tinystr/serde\",\"icu_provider/serde\"],\"unstable\":[]}}", - "icu_calendar_data_2.1.1": "{\"dependencies\":[],\"features\":{}}", + "icu_calendar_2.2.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"calendrical_calculations\",\"req\":\"^0.2.4\"},{\"default_features\":false,\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"name\":\"icu_calendar_data\",\"optional\":true,\"req\":\"~2.2.0\"},{\"default_features\":false,\"name\":\"icu_locale\",\"optional\":true,\"req\":\"~2.2.0\"},{\"default_features\":false,\"name\":\"icu_locale_core\",\"req\":\"^2.2.0\"},{\"default_features\":false,\"name\":\"icu_provider\",\"req\":\"^2.2.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.38.0\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"name\":\"ixdtf\",\"optional\":true,\"req\":\"^0.6.5\"},{\"default_features\":false,\"name\":\"jiff\",\"optional\":true,\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"kind\":\"dev\",\"name\":\"simple_logger\",\"req\":\"^5.0.0\"},{\"default_features\":false,\"name\":\"time\",\"optional\":true,\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"large-dates\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"zerovec\"],\"name\":\"tinystr\",\"req\":\"^0.8.3\"},{\"kind\":\"dev\",\"name\":\"ureq\",\"req\":\"^3.0.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"zerovec\",\"req\":\"^0.11.6\"}],\"features\":{\"alloc\":[\"icu_locale_core/alloc\",\"tinystr/alloc\",\"serde?/alloc\"],\"compiled_data\":[\"dep:icu_calendar_data\",\"dep:icu_locale\",\"icu_locale?/compiled_data\",\"icu_provider/baked\"],\"datagen\":[\"serde\",\"dep:databake\",\"zerovec/databake\",\"tinystr/databake\",\"alloc\",\"icu_provider/export\"],\"default\":[\"compiled_data\",\"ixdtf\"],\"ixdtf\":[\"dep:ixdtf\"],\"logging\":[\"calendrical_calculations/logging\"],\"serde\":[\"dep:serde\",\"zerovec/serde\",\"tinystr/serde\",\"icu_provider/serde\"],\"unstable\":[],\"unstable_chrono_0_4\":[\"dep:chrono\"],\"unstable_jiff_0_2\":[\"dep:jiff\"],\"unstable_time_0_3\":[\"dep:time\"]}}", + "icu_calendar_data_2.2.0": "{\"dependencies\":[],\"features\":{}}", "icu_collections_2.1.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"kind\":\"dev\",\"name\":\"iai\",\"req\":\"^0.1.1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"features\":[\"zerovec\"],\"name\":\"potential_utf\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"features\":[\"parse\"],\"kind\":\"dev\",\"name\":\"toml\",\"req\":\"^0.8.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"yoke\",\"req\":\"^0.8.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"zerofrom\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"features\":[\"derive\",\"yoke\"],\"name\":\"zerovec\",\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"serde?/alloc\",\"zerovec/alloc\"],\"databake\":[\"dep:databake\",\"zerovec/databake\"],\"serde\":[\"dep:serde\",\"zerovec/serde\",\"potential_utf/serde\",\"alloc\"]}}", - "icu_decimal_2.1.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"fixed_decimal\",\"req\":\"^0.7.0\"},{\"features\":[\"wasm_js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"icu_decimal_data\",\"optional\":true,\"req\":\"~2.1.1\"},{\"default_features\":false,\"name\":\"icu_locale\",\"optional\":true,\"req\":\"~2.1.1\"},{\"default_features\":false,\"name\":\"icu_locale_core\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"name\":\"icu_provider\",\"req\":\"^2.1.1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_distr\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"writeable\",\"req\":\"^0.6.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"zerovec\",\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"serde?/alloc\",\"zerovec/alloc\"],\"compiled_data\":[\"dep:icu_decimal_data\",\"dep:icu_locale\",\"icu_locale?/compiled_data\",\"icu_provider/baked\"],\"datagen\":[\"serde\",\"dep:databake\",\"zerovec/databake\",\"icu_provider/export\",\"alloc\"],\"default\":[\"compiled_data\"],\"ryu\":[\"fixed_decimal/ryu\"],\"serde\":[\"dep:serde\",\"icu_provider/serde\",\"zerovec/serde\"]}}", - "icu_decimal_data_2.1.1": "{\"dependencies\":[],\"features\":{}}", - "icu_locale_2.1.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"icu_collections\",\"req\":\"~2.1.1\"},{\"default_features\":false,\"features\":[\"alloc\",\"zerovec\"],\"name\":\"icu_locale_core\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"name\":\"icu_locale_data\",\"optional\":true,\"req\":\"~2.1.1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"icu_provider\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"features\":[\"alloc\",\"zerovec\"],\"name\":\"potential_utf\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"features\":[\"alloc\",\"zerovec\"],\"name\":\"tinystr\",\"req\":\"^0.8.0\"},{\"default_features\":false,\"features\":[\"alloc\",\"yoke\"],\"name\":\"zerovec\",\"req\":\"^0.11.3\"}],\"features\":{\"compiled_data\":[\"dep:icu_locale_data\",\"icu_provider/baked\"],\"datagen\":[\"serde\",\"dep:databake\",\"zerovec/databake\",\"icu_locale_core/databake\",\"tinystr/databake\",\"icu_collections/databake\",\"icu_provider/export\"],\"default\":[\"compiled_data\"],\"serde\":[\"dep:serde\",\"icu_locale_core/serde\",\"tinystr/serde\",\"zerovec/serde\",\"icu_provider/serde\",\"potential_utf/serde\",\"icu_collections/serde\"]}}", + "icu_collections_2.2.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"kind\":\"dev\",\"name\":\"iai\",\"req\":\"^0.1.1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"features\":[\"zerovec\"],\"name\":\"potential_utf\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"features\":[\"parse\"],\"kind\":\"dev\",\"name\":\"toml\",\"req\":\"^0.8.0\"},{\"default_features\":false,\"name\":\"utf8_iter\",\"req\":\"^1.0.2\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"yoke\",\"req\":\"^0.8.2\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"zerofrom\",\"req\":\"^0.1.6\"},{\"default_features\":false,\"features\":[\"derive\",\"yoke\"],\"name\":\"zerovec\",\"req\":\"^0.11.6\"}],\"features\":{\"alloc\":[\"serde?/alloc\",\"zerovec/alloc\"],\"databake\":[\"dep:databake\",\"zerovec/databake\"],\"serde\":[\"dep:serde\",\"zerovec/serde\",\"potential_utf/serde\",\"alloc\"]}}", + "icu_decimal_2.2.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"name\":\"fixed_decimal\",\"req\":\"^0.7.2\"},{\"features\":[\"wasm_js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"icu_decimal_data\",\"optional\":true,\"req\":\"~2.2.0\"},{\"default_features\":false,\"name\":\"icu_locale\",\"optional\":true,\"req\":\"~2.2.0\"},{\"default_features\":false,\"name\":\"icu_locale_core\",\"req\":\"^2.2.0\"},{\"default_features\":false,\"features\":[\"zerovec\"],\"name\":\"icu_pattern\",\"optional\":true,\"req\":\"^0.4.2\"},{\"default_features\":false,\"name\":\"icu_plurals\",\"optional\":true,\"req\":\"~2.2.0\"},{\"default_features\":false,\"name\":\"icu_provider\",\"req\":\"^2.2.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_distr\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"writeable\",\"req\":\"^0.6.1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"zerovec\",\"req\":\"^0.11.6\"}],\"features\":{\"alloc\":[\"serde?/alloc\",\"zerovec/alloc\"],\"compiled_data\":[\"dep:icu_decimal_data\",\"dep:icu_locale\",\"icu_locale?/compiled_data\",\"icu_provider/baked\",\"icu_plurals?/compiled_data\"],\"datagen\":[\"serde\",\"dep:databake\",\"zerovec/databake\",\"icu_provider/export\",\"alloc\",\"icu_plurals?/datagen\"],\"default\":[\"compiled_data\"],\"ryu\":[\"fixed_decimal/ryu\"],\"serde\":[\"alloc\",\"dep:serde\",\"icu_provider/serde\",\"zerovec/serde\",\"icu_pattern?/serde\",\"icu_plurals?/serde\"],\"unstable\":[\"dep:icu_pattern\",\"dep:icu_plurals\"]}}", + "icu_decimal_data_2.2.0": "{\"dependencies\":[],\"features\":{}}", + "icu_locale_2.2.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"icu_collections\",\"req\":\"~2.2.0\"},{\"default_features\":false,\"features\":[\"alloc\",\"zerovec\"],\"name\":\"icu_locale_core\",\"req\":\"^2.2.0\"},{\"default_features\":false,\"name\":\"icu_locale_data\",\"optional\":true,\"req\":\"~2.2.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"icu_provider\",\"req\":\"^2.2.0\"},{\"default_features\":false,\"features\":[\"alloc\",\"zerovec\"],\"name\":\"potential_utf\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"features\":[\"alloc\",\"zerovec\"],\"name\":\"tinystr\",\"req\":\"^0.8.3\"},{\"default_features\":false,\"features\":[\"alloc\",\"yoke\"],\"name\":\"zerovec\",\"req\":\"^0.11.6\"}],\"features\":{\"compiled_data\":[\"dep:icu_locale_data\",\"icu_provider/baked\"],\"datagen\":[\"serde\",\"dep:databake\",\"zerovec/databake\",\"icu_locale_core/databake\",\"tinystr/databake\",\"icu_collections/databake\",\"icu_provider/export\"],\"default\":[\"compiled_data\"],\"serde\":[\"dep:serde\",\"icu_locale_core/serde\",\"tinystr/serde\",\"zerovec/serde\",\"icu_provider/serde\",\"potential_utf/serde\",\"icu_collections/serde\"]}}", "icu_locale_core_2.1.1": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"name\":\"litemap\",\"req\":\"^0.8.0\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"tinystr\",\"req\":\"^0.8.0\"},{\"default_features\":false,\"name\":\"writeable\",\"req\":\"^0.6.0\"},{\"default_features\":false,\"name\":\"zerovec\",\"optional\":true,\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"litemap/alloc\",\"tinystr/alloc\",\"writeable/alloc\",\"serde?/alloc\"],\"databake\":[\"dep:databake\",\"alloc\"],\"serde\":[\"dep:serde\",\"tinystr/serde\"],\"zerovec\":[\"dep:zerovec\",\"tinystr/zerovec\"]}}", - "icu_locale_data_2.1.2": "{\"dependencies\":[],\"features\":{}}", + "icu_locale_core_2.2.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"kind\":\"dev\",\"name\":\"iai\",\"req\":\"^0.1.1\"},{\"default_features\":false,\"name\":\"litemap\",\"req\":\"^0.8.0\"},{\"default_features\":false,\"features\":[\"testing\"],\"kind\":\"dev\",\"name\":\"litemap\",\"req\":\"^0.8.0\"},{\"default_features\":false,\"features\":[\"use-std\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"potential_utf\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"name\":\"tinystr\",\"req\":\"^0.8.3\"},{\"default_features\":false,\"name\":\"writeable\",\"req\":\"^0.6.1\"},{\"default_features\":false,\"name\":\"zerovec\",\"optional\":true,\"req\":\"^0.11.6\"}],\"features\":{\"alloc\":[\"litemap/alloc\",\"tinystr/alloc\",\"writeable/alloc\",\"serde?/alloc\"],\"databake\":[\"dep:databake\",\"alloc\"],\"serde\":[\"dep:serde\",\"tinystr/serde\"],\"zerovec\":[\"dep:zerovec\",\"tinystr/zerovec\"]}}", + "icu_locale_data_2.2.0": "{\"dependencies\":[],\"features\":{}}", "icu_normalizer_2.1.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"arraystring\",\"req\":\"^0.3.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"arrayvec\",\"req\":\"^0.7.2\"},{\"kind\":\"dev\",\"name\":\"atoi\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"detone\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"icu_collections\",\"req\":\"~2.1.1\"},{\"default_features\":false,\"name\":\"icu_normalizer_data\",\"optional\":true,\"req\":\"~2.1.1\"},{\"default_features\":false,\"name\":\"icu_properties\",\"optional\":true,\"req\":\"~2.1.1\"},{\"default_features\":false,\"name\":\"icu_provider\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"smallvec\",\"req\":\"^1.10.0\"},{\"default_features\":false,\"name\":\"utf16_iter\",\"optional\":true,\"req\":\"^1.0.2\"},{\"default_features\":false,\"name\":\"utf8_iter\",\"optional\":true,\"req\":\"^1.0.2\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"write16\",\"optional\":true,\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"arrayvec\",\"smallvec\"],\"kind\":\"dev\",\"name\":\"write16\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"zerovec\",\"req\":\"^0.11.3\"}],\"features\":{\"compiled_data\":[\"dep:icu_normalizer_data\",\"icu_properties?/compiled_data\",\"icu_provider/baked\"],\"datagen\":[\"serde\",\"dep:databake\",\"icu_properties\",\"icu_collections/databake\",\"zerovec/databake\",\"icu_properties?/datagen\",\"icu_provider/export\"],\"default\":[\"compiled_data\",\"utf8_iter\",\"utf16_iter\"],\"experimental\":[],\"icu_properties\":[\"dep:icu_properties\"],\"serde\":[\"dep:serde\",\"icu_collections/serde\",\"zerovec/serde\",\"icu_properties?/serde\",\"icu_provider/serde\"],\"utf16_iter\":[\"dep:utf16_iter\",\"dep:write16\"],\"utf8_iter\":[\"dep:utf8_iter\"],\"write16\":[]}}", + "icu_normalizer_2.2.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"arraystring\",\"req\":\"^0.3.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"arrayvec\",\"req\":\"^0.7.2\"},{\"kind\":\"dev\",\"name\":\"atoi\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"detone\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"harfbuzz-traits\",\"optional\":true,\"req\":\"^0.6.0\"},{\"default_features\":false,\"name\":\"icu_collections\",\"req\":\"~2.2.0\"},{\"default_features\":false,\"name\":\"icu_normalizer_data\",\"optional\":true,\"req\":\"~2.2.0\"},{\"default_features\":false,\"name\":\"icu_properties\",\"optional\":true,\"req\":\"~2.2.0\"},{\"default_features\":false,\"name\":\"icu_provider\",\"req\":\"^2.2.0\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"smallvec\",\"req\":\"^1.10.0\"},{\"default_features\":false,\"name\":\"utf16_iter\",\"optional\":true,\"req\":\"^1.0.2\"},{\"default_features\":false,\"name\":\"utf8_iter\",\"optional\":true,\"req\":\"^1.0.2\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"write16\",\"optional\":true,\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"arrayvec\",\"smallvec\"],\"kind\":\"dev\",\"name\":\"write16\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"zerovec\",\"req\":\"^0.11.6\"}],\"features\":{\"compiled_data\":[\"dep:icu_normalizer_data\",\"icu_properties?/compiled_data\",\"icu_provider/baked\"],\"datagen\":[\"serde\",\"dep:databake\",\"icu_properties\",\"icu_collections/databake\",\"zerovec/databake\",\"icu_properties?/datagen\",\"icu_provider/export\"],\"default\":[\"compiled_data\",\"utf8_iter\",\"utf16_iter\"],\"harfbuzz_traits\":[\"dep:harfbuzz-traits\"],\"icu_properties\":[\"dep:icu_properties\"],\"serde\":[\"dep:serde\",\"icu_collections/serde\",\"zerovec/serde\",\"icu_properties?/serde\",\"icu_provider/serde\"],\"utf16_iter\":[\"dep:utf16_iter\",\"dep:write16\"],\"utf8_iter\":[\"dep:utf8_iter\"],\"write16\":[]}}", "icu_normalizer_data_2.1.1": "{\"dependencies\":[],\"features\":{}}", + "icu_normalizer_data_2.2.0": "{\"dependencies\":[],\"features\":{}}", + "icu_plurals_2.2.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"optional\":true,\"req\":\"^0.2.3\"},{\"default_features\":false,\"name\":\"fixed_decimal\",\"req\":\"^0.7.2\"},{\"default_features\":false,\"name\":\"icu_locale\",\"optional\":true,\"req\":\"~2.2.0\"},{\"default_features\":false,\"name\":\"icu_plurals_data\",\"optional\":true,\"req\":\"~2.2.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"icu_provider\",\"req\":\"^2.2.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"features\":[\"derive\",\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"features\":[\"alloc\",\"yoke\"],\"name\":\"zerovec\",\"req\":\"^0.11.6\"}],\"features\":{\"compiled_data\":[\"dep:icu_plurals_data\",\"dep:icu_locale\",\"icu_locale?/compiled_data\",\"icu_provider/baked\"],\"datagen\":[\"serde\",\"zerovec/databake\",\"dep:databake\",\"icu_provider/export\"],\"default\":[\"compiled_data\"],\"icu_normalizer_data\":[],\"serde\":[\"dep:serde\",\"zerovec/serde\",\"icu_provider/serde\",\"dep:displaydoc\"],\"unstable\":[]}}", + "icu_plurals_data_2.2.0": "{\"dependencies\":[],\"features\":{}}", "icu_properties_2.1.2": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"icu_collections\",\"req\":\"~2.1.1\"},{\"default_features\":false,\"features\":[\"zerovec\"],\"name\":\"icu_locale_core\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"name\":\"icu_properties_data\",\"optional\":true,\"req\":\"~2.1.2\"},{\"default_features\":false,\"name\":\"icu_provider\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"unicode-bidi\",\"optional\":true,\"req\":\"^0.3.11\"},{\"default_features\":false,\"features\":[\"yoke\",\"zerofrom\"],\"name\":\"zerotrie\",\"req\":\"^0.2.0\"},{\"default_features\":false,\"features\":[\"derive\",\"yoke\"],\"name\":\"zerovec\",\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"zerovec/alloc\",\"icu_collections/alloc\",\"serde?/alloc\"],\"compiled_data\":[\"dep:icu_properties_data\",\"icu_provider/baked\"],\"datagen\":[\"serde\",\"dep:databake\",\"zerovec/databake\",\"icu_collections/databake\",\"icu_locale_core/databake\",\"zerotrie/databake\",\"icu_provider/export\"],\"default\":[\"compiled_data\"],\"serde\":[\"dep:serde\",\"icu_locale_core/serde\",\"zerovec/serde\",\"icu_collections/serde\",\"icu_provider/serde\",\"zerotrie/serde\"],\"unicode_bidi\":[\"dep:unicode-bidi\"]}}", + "icu_properties_2.2.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"harfbuzz-traits\",\"optional\":true,\"req\":\"^0.6.0\"},{\"default_features\":false,\"name\":\"icu_collections\",\"req\":\"~2.2.0\"},{\"default_features\":false,\"features\":[\"zerovec\"],\"name\":\"icu_locale_core\",\"req\":\"^2.2.0\"},{\"default_features\":false,\"name\":\"icu_properties_data\",\"optional\":true,\"req\":\"~2.2.0\"},{\"default_features\":false,\"name\":\"icu_provider\",\"req\":\"^2.2.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"unicode-bidi\",\"optional\":true,\"req\":\"^0.3.11\"},{\"default_features\":false,\"features\":[\"yoke\",\"zerofrom\"],\"name\":\"zerotrie\",\"req\":\"^0.2.4\"},{\"default_features\":false,\"features\":[\"derive\",\"yoke\"],\"name\":\"zerovec\",\"req\":\"^0.11.6\"}],\"features\":{\"alloc\":[\"zerovec/alloc\",\"icu_collections/alloc\",\"serde?/alloc\"],\"compiled_data\":[\"dep:icu_properties_data\",\"icu_provider/baked\"],\"datagen\":[\"serde\",\"dep:databake\",\"zerovec/databake\",\"icu_collections/databake\",\"icu_locale_core/databake\",\"zerotrie/databake\",\"icu_provider/export\"],\"default\":[\"compiled_data\"],\"harfbuzz_traits\":[\"dep:harfbuzz-traits\"],\"serde\":[\"dep:serde\",\"icu_locale_core/serde\",\"zerovec/serde\",\"icu_collections/serde\",\"icu_provider/serde\",\"zerotrie/serde\"],\"unicode_bidi\":[\"dep:unicode-bidi\"]}}", "icu_properties_data_2.1.2": "{\"dependencies\":[],\"features\":{}}", + "icu_properties_data_2.2.0": "{\"dependencies\":[],\"features\":{}}", "icu_provider_2.1.1": "{\"dependencies\":[{\"name\":\"bincode\",\"optional\":true,\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"name\":\"erased-serde\",\"optional\":true,\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"icu_locale_core\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"},{\"default_features\":false,\"name\":\"postcard\",\"optional\":true,\"req\":\"^1.0.3\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.45\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"optional\":true,\"req\":\"^1.2.0\"},{\"default_features\":false,\"name\":\"writeable\",\"optional\":true,\"req\":\"^0.6.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"yoke\",\"req\":\"^0.8.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"zerofrom\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"name\":\"zerotrie\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"zerovec\",\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"icu_locale_core/alloc\",\"serde?/alloc\",\"yoke/alloc\",\"zerofrom/alloc\",\"zerovec/alloc\",\"zerotrie?/alloc\",\"dep:stable_deref_trait\",\"dep:writeable\"],\"baked\":[\"dep:zerotrie\",\"dep:writeable\"],\"deserialize_bincode_1\":[\"serde\",\"dep:bincode\",\"std\"],\"deserialize_json\":[\"serde\",\"dep:serde_json\"],\"deserialize_postcard_1\":[\"serde\",\"dep:postcard\"],\"export\":[\"serde\",\"dep:erased-serde\",\"dep:databake\",\"std\",\"sync\",\"dep:postcard\",\"zerovec/databake\"],\"logging\":[\"dep:log\"],\"serde\":[\"dep:serde\",\"yoke/serde\"],\"std\":[\"alloc\"],\"sync\":[],\"zerotrie\":[]}}", + "icu_provider_2.2.0": "{\"dependencies\":[{\"name\":\"bincode\",\"optional\":true,\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"name\":\"erased-serde\",\"optional\":true,\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"icu_locale_core\",\"req\":\"^2.2.0\"},{\"default_features\":false,\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"},{\"default_features\":false,\"name\":\"postcard\",\"optional\":true,\"req\":\"^1.0.3\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.45\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"optional\":true,\"req\":\"^1.2.0\"},{\"default_features\":false,\"name\":\"writeable\",\"optional\":true,\"req\":\"^0.6.1\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"yoke\",\"req\":\"^0.8.2\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"zerofrom\",\"req\":\"^0.1.6\"},{\"default_features\":false,\"name\":\"zerotrie\",\"optional\":true,\"req\":\"^0.2.4\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"zerovec\",\"req\":\"^0.11.6\"}],\"features\":{\"alloc\":[\"icu_locale_core/alloc\",\"serde?/alloc\",\"yoke/alloc\",\"zerofrom/alloc\",\"zerovec/alloc\",\"zerotrie?/alloc\",\"dep:stable_deref_trait\",\"dep:writeable\"],\"baked\":[\"dep:zerotrie\",\"dep:writeable\"],\"deserialize_bincode_1\":[\"serde\",\"dep:bincode\",\"std\"],\"deserialize_json\":[\"serde\",\"dep:serde_json\"],\"deserialize_postcard_1\":[\"serde\",\"dep:postcard\"],\"export\":[\"serde\",\"dep:erased-serde\",\"dep:databake\",\"std\",\"sync\",\"dep:postcard\",\"zerovec/databake\"],\"logging\":[\"dep:log\"],\"serde\":[\"dep:serde\",\"yoke/serde\"],\"std\":[\"alloc\"],\"sync\":[],\"zerotrie\":[]}}", "id-arena_2.3.0": "{\"dependencies\":[{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.0.3\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "ident_case_1.0.1": "{\"dependencies\":[],\"features\":{}}", "idna_1.1.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.3\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1\"},{\"name\":\"idna_adapter\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"const_generics\"],\"name\":\"smallvec\",\"req\":\"^1.13.1\"},{\"kind\":\"dev\",\"name\":\"tester\",\"req\":\"^0.9\"},{\"name\":\"utf8_iter\",\"req\":\"^1.0.4\"}],\"features\":{\"alloc\":[],\"compiled_data\":[\"idna_adapter/compiled_data\"],\"default\":[\"std\",\"compiled_data\"],\"std\":[\"alloc\"]}}", @@ -1137,10 +1153,15 @@ "jiff-tzdb-platform_0.1.3": "{\"dependencies\":[{\"name\":\"jiff-tzdb\",\"req\":\"^0.1.4\"}],\"features\":{}}", "jiff-tzdb_0.1.6": "{\"dependencies\":[],\"features\":{}}", "jiff_0.2.23": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.81\"},{\"features\":[\"serde\"],\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4.38\"},{\"kind\":\"dev\",\"name\":\"chrono-tz\",\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"hifitime\",\"req\":\"^3.9.0\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"humantime\",\"req\":\"^2.1.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.39.0\"},{\"name\":\"jiff-static\",\"req\":\"=0.2.23\",\"target\":\"cfg(any())\"},{\"name\":\"jiff-static\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"jiff-tzdb\",\"optional\":true,\"req\":\"^0.1.6\"},{\"name\":\"jiff-tzdb-platform\",\"optional\":true,\"req\":\"^0.1.3\",\"target\":\"cfg(any(windows, target_family = \\\"wasm\\\"))\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3.50\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.21\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.21\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"req\":\"^1.10.0\",\"target\":\"cfg(not(target_has_atomic = \\\"ptr\\\"))\"},{\"default_features\":false,\"name\":\"portable-atomic-util\",\"req\":\"^0.2.4\",\"target\":\"cfg(not(target_has_atomic = \\\"ptr\\\"))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.203\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.117\"},{\"kind\":\"dev\",\"name\":\"serde_yaml\",\"req\":\"^0.9.34\"},{\"kind\":\"dev\",\"name\":\"tabwriter\",\"req\":\"^1.4.0\"},{\"features\":[\"local-offset\",\"macros\",\"parsing\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.36\"},{\"kind\":\"dev\",\"name\":\"time-tz\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"tzfile\",\"req\":\"^0.1.3\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5.0\"},{\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2.70\",\"target\":\"cfg(all(any(target_arch = \\\"wasm32\\\", target_arch = \\\"wasm64\\\"), target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"features\":[\"Win32_Foundation\",\"Win32_System_Time\"],\"name\":\"windows-sys\",\"optional\":true,\"req\":\">=0.52.0, <=0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"alloc\":[\"serde_core?/alloc\",\"portable-atomic-util/alloc\"],\"default\":[\"std\",\"tz-system\",\"tz-fat\",\"tzdb-bundle-platform\",\"tzdb-zoneinfo\",\"tzdb-concatenated\",\"perf-inline\"],\"js\":[\"dep:wasm-bindgen\",\"dep:js-sys\"],\"logging\":[\"dep:log\"],\"perf-inline\":[],\"serde\":[\"dep:serde_core\"],\"static\":[\"static-tz\",\"jiff-static?/tzdb\"],\"static-tz\":[\"dep:jiff-static\"],\"std\":[\"alloc\",\"log?/std\",\"serde_core?/std\"],\"tz-fat\":[\"jiff-static?/tz-fat\"],\"tz-system\":[\"std\",\"dep:windows-sys\"],\"tzdb-bundle-always\":[\"dep:jiff-tzdb\",\"alloc\"],\"tzdb-bundle-platform\":[\"dep:jiff-tzdb-platform\",\"alloc\"],\"tzdb-concatenated\":[\"std\"],\"tzdb-zoneinfo\":[\"std\"]}}", + "jni-macros_0.22.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"javac\",\"req\":\"^0.1.0\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"kind\":\"build\",\"name\":\"rustc_version\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"rusty-fork\",\"req\":\"^0.3.0\"},{\"name\":\"simd_cesu8\",\"req\":\"^1.0.1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"kind\":\"dev\",\"name\":\"thiserror\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1\"}],\"features\":{}}", + "jni-sys-macros_0.4.1": "{\"dependencies\":[{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", "jni-sys_0.3.0": "{\"dependencies\":[],\"features\":{}}", + "jni-sys_0.4.1": "{\"dependencies\":[{\"name\":\"jni-sys-macros\",\"req\":\"^0.4.1\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1\"}],\"features\":{}}", "jni_0.21.1": "{\"dependencies\":[{\"name\":\"cesu8\",\"req\":\"^1.1.0\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"combine\",\"req\":\"^4.1.0\"},{\"name\":\"java-locator\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"jni-sys\",\"req\":\"^0.3.0\"},{\"name\":\"libloading\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"log\",\"req\":\"^0.4.4\"},{\"name\":\"thiserror\",\"req\":\"^1.0.20\"},{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rusty-fork\",\"req\":\"^0.3.0\"},{\"kind\":\"build\",\"name\":\"walkdir\",\"req\":\"^2\"},{\"features\":[\"Win32_Globalization\"],\"name\":\"windows-sys\",\"req\":\"^0.45.0\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"bytemuck\",\"req\":\"^1.13.0\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[],\"invocation\":[\"java-locator\",\"libloading\"]}}", + "jni_0.22.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"bytemuck\",\"req\":\"^1.13.0\",\"target\":\"cfg(windows)\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"combine\",\"req\":\"^4.1.0\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.7\"},{\"name\":\"java-locator\",\"optional\":true,\"req\":\"^0.1.3\",\"target\":\"cfg(not(target_os = \\\"android\\\"))\"},{\"kind\":\"dev\",\"name\":\"javac\",\"req\":\"^0.1.0\"},{\"name\":\"jni-macros\",\"req\":\"=0.22.4\"},{\"name\":\"jni-sys\",\"req\":\"^0.4.1\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"name\":\"libloading\",\"optional\":true,\"req\":\"^0.8\",\"target\":\"cfg(not(target_os = \\\"android\\\"))\"},{\"name\":\"log\",\"req\":\"^0.4.4\"},{\"kind\":\"dev\",\"name\":\"rusty-fork\",\"req\":\"^0.3.0\"},{\"name\":\"simd_cesu8\",\"req\":\"^1.1.1\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1\"},{\"kind\":\"build\",\"name\":\"walkdir\",\"req\":\"^2\"},{\"name\":\"windows-link\",\"req\":\"^0.2\",\"target\":\"cfg(windows)\"},{\"features\":[\"Win32_System_Threading\",\"Win32_Foundation\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"_cfg_test\":[],\"default\":[],\"invocation\":[\"dep:java-locator\",\"dep:libloading\"]}}", "jobserver_0.1.34": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"getrandom\",\"req\":\"^0.3.2\",\"target\":\"cfg(windows)\"},{\"name\":\"libc\",\"req\":\"^0.2.171\",\"target\":\"cfg(unix)\"},{\"features\":[\"fs\"],\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.28.0\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.1\"}],\"features\":{}}", "js-sys_0.3.85": "{\"dependencies\":[{\"default_features\":false,\"name\":\"once_cell\",\"req\":\"^1.12\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"req\":\"=0.2.108\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"wasm-bindgen/std\"]}}", + "jsonptr_0.7.1": "{\"dependencies\":[{\"features\":[\"fancy\"],\"name\":\"miette\",\"optional\":true,\"req\":\"^7.4.0\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0.0\"},{\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.203\"},{\"features\":[\"alloc\"],\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.119\"},{\"name\":\"syn\",\"optional\":true,\"req\":\"^1.0.109\",\"target\":\"cfg(any())\"},{\"name\":\"toml\",\"optional\":true,\"req\":\"^0.8\"}],\"features\":{\"assign\":[],\"default\":[\"std\",\"serde\",\"json\",\"resolve\",\"assign\",\"delete\"],\"delete\":[\"resolve\"],\"json\":[\"dep:serde_json\",\"serde\"],\"miette\":[\"dep:miette\",\"std\"],\"resolve\":[],\"std\":[\"serde/std\",\"serde_json?/std\"],\"toml\":[\"dep:toml\",\"serde\",\"std\"]}}", "jsonwebtoken_9.3.1": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\")))))\"},{\"name\":\"js-sys\",\"req\":\"^0.3\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"pem\",\"optional\":true,\"req\":\"^3\"},{\"features\":[\"std\"],\"name\":\"ring\",\"req\":\"^0.17.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"std\",\"wasm32_unknown_unknown_js\"],\"name\":\"ring\",\"req\":\"^0.17.4\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"simple_asn1\",\"optional\":true,\"req\":\"^0.6\"},{\"features\":[\"wasm-bindgen\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\")))))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.1\"}],\"features\":{\"default\":[\"use_pem\"],\"use_pem\":[\"pem\",\"simple_asn1\"]}}", "keyring_3.6.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"name\":\"byteorder\",\"optional\":true,\"req\":\"^1.2\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"features\":[\"derive\",\"wrap_help\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4\"},{\"name\":\"dbus-secret-service\",\"optional\":true,\"req\":\"^4.0.0-rc.1\",\"target\":\"cfg(target_os = \\\"openbsd\\\")\"},{\"name\":\"dbus-secret-service\",\"optional\":true,\"req\":\"^4.0.0-rc.2\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"dbus-secret-service\",\"optional\":true,\"req\":\"^4.0.1\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11.5\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2\"},{\"features\":[\"std\"],\"name\":\"linux-keyutils\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"log\",\"req\":\"^0.4.22\"},{\"name\":\"openssl\",\"optional\":true,\"req\":\"^0.10.66\"},{\"kind\":\"dev\",\"name\":\"rpassword\",\"req\":\"^7\"},{\"kind\":\"dev\",\"name\":\"rprompt\",\"req\":\"^2\"},{\"name\":\"secret-service\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"name\":\"secret-service\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"secret-service\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"openbsd\\\")\"},{\"name\":\"security-framework\",\"optional\":true,\"req\":\"^2\",\"target\":\"cfg(target_os = \\\"ios\\\")\"},{\"name\":\"security-framework\",\"optional\":true,\"req\":\"^3\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"whoami\",\"req\":\"^1.5\"},{\"features\":[\"Win32_Foundation\",\"Win32_Security_Credentials\"],\"name\":\"windows-sys\",\"optional\":true,\"req\":\"^0.60\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"name\":\"zbus\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"name\":\"zbus\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"zbus\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"openbsd\\\")\"},{\"name\":\"zeroize\",\"req\":\"^1.8.1\",\"target\":\"cfg(target_os = \\\"windows\\\")\"}],\"features\":{\"apple-native\":[\"dep:security-framework\"],\"async-io\":[\"zbus?/async-io\"],\"async-secret-service\":[\"dep:secret-service\",\"dep:zbus\"],\"crypto-openssl\":[\"dbus-secret-service?/crypto-openssl\",\"secret-service?/crypto-openssl\"],\"crypto-rust\":[\"dbus-secret-service?/crypto-rust\",\"secret-service?/crypto-rust\"],\"linux-native\":[\"dep:linux-keyutils\"],\"linux-native-async-persistent\":[\"linux-native\",\"async-secret-service\"],\"linux-native-sync-persistent\":[\"linux-native\",\"sync-secret-service\"],\"sync-secret-service\":[\"dep:dbus-secret-service\"],\"tokio\":[\"zbus?/tokio\"],\"vendored\":[\"dbus-secret-service?/vendored\",\"openssl?/vendored\"],\"windows-native\":[\"dep:windows-sys\",\"dep:byteorder\"]}}", "kqueue-sys_1.0.4": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^1.2.1\"},{\"name\":\"libc\",\"req\":\"^0.2.74\"}],\"features\":{}}", @@ -1160,7 +1181,7 @@ "libm_0.2.16": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"no-panic\",\"req\":\"^0.1.35\"}],\"features\":{\"arch\":[],\"default\":[\"arch\"],\"force-soft-floats\":[],\"unstable\":[\"unstable-intrinsics\",\"unstable-float\"],\"unstable-float\":[],\"unstable-intrinsics\":[],\"unstable-public-internals\":[]}}", "libredox_0.1.12": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"name\":\"ioslice\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"redox_syscall\",\"optional\":true,\"req\":\"^0.7\"}],\"features\":{\"call\":[],\"default\":[\"call\",\"std\",\"redox_syscall\"],\"mkns\":[\"ioslice\"],\"std\":[]}}", "libredox_0.1.14": "{\"dependencies\":[{\"name\":\"bitflags\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"ioslice\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"plain\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"redox_syscall\",\"optional\":true,\"req\":\"^0.7\"}],\"features\":{\"base\":[\"libc\"],\"call\":[\"base\"],\"default\":[\"base\",\"call\",\"std\",\"redox_syscall\",\"protocol\"],\"mkns\":[\"ioslice\"],\"protocol\":[\"plain\",\"bitflags\",\"redox_syscall\"],\"std\":[\"base\"]}}", - "libsqlite3-sys_0.30.1": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"runtime\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.69\"},{\"kind\":\"build\",\"name\":\"cc\",\"optional\":true,\"req\":\"^1.1.6\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.103\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"optional\":true,\"req\":\"^0.3.19\"},{\"kind\":\"build\",\"name\":\"prettyplease\",\"optional\":true,\"req\":\"^0.2.20\"},{\"default_features\":false,\"kind\":\"build\",\"name\":\"quote\",\"optional\":true,\"req\":\"^1.0.36\"},{\"features\":[\"full\",\"extra-traits\",\"visit-mut\"],\"kind\":\"build\",\"name\":\"syn\",\"optional\":true,\"req\":\"^2.0.72\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"optional\":true,\"req\":\"^0.2.15\"}],\"features\":{\"buildtime_bindgen\":[\"bindgen\",\"pkg-config\",\"vcpkg\"],\"bundled\":[\"cc\",\"bundled_bindings\"],\"bundled-sqlcipher\":[\"bundled\"],\"bundled-sqlcipher-vendored-openssl\":[\"bundled-sqlcipher\",\"openssl-sys/vendored\"],\"bundled-windows\":[\"cc\",\"bundled_bindings\"],\"bundled_bindings\":[],\"default\":[\"min_sqlite_version_3_14_0\"],\"in_gecko\":[],\"loadable_extension\":[\"prettyplease\",\"quote\",\"syn\"],\"min_sqlite_version_3_14_0\":[\"pkg-config\",\"vcpkg\"],\"preupdate_hook\":[\"buildtime_bindgen\"],\"session\":[\"preupdate_hook\",\"buildtime_bindgen\"],\"sqlcipher\":[],\"unlock_notify\":[],\"wasm32-wasi-vfs\":[],\"with-asan\":[]}}", + "libsqlite3-sys_0.37.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"runtime\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.72\"},{\"kind\":\"build\",\"name\":\"cc\",\"optional\":true,\"req\":\"^1.2.27\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.103\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"optional\":true,\"req\":\"^0.3.19\"},{\"kind\":\"build\",\"name\":\"prettyplease\",\"optional\":true,\"req\":\"^0.2.20\"},{\"default_features\":false,\"kind\":\"build\",\"name\":\"quote\",\"optional\":true,\"req\":\"^1.0.36\"},{\"features\":[\"full\",\"extra-traits\",\"visit-mut\"],\"kind\":\"build\",\"name\":\"syn\",\"optional\":true,\"req\":\"^2.0.89\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"optional\":true,\"req\":\"^0.2.15\"}],\"features\":{\"buildtime_bindgen\":[\"bindgen\",\"pkg-config\",\"vcpkg\"],\"bundled\":[\"cc\",\"bundled_bindings\"],\"bundled-sqlcipher\":[\"bundled\"],\"bundled-sqlcipher-vendored-openssl\":[\"bundled-sqlcipher\",\"openssl-sys/vendored\"],\"bundled-windows\":[\"cc\",\"bundled_bindings\"],\"bundled_bindings\":[],\"column_metadata\":[],\"default\":[\"min_sqlite_version_3_34_1\"],\"in_gecko\":[],\"loadable_extension\":[\"prettyplease\",\"quote\",\"syn\"],\"min_sqlite_version_3_34_1\":[\"pkg-config\",\"vcpkg\"],\"preupdate_hook\":[\"buildtime_bindgen\"],\"session\":[\"preupdate_hook\",\"buildtime_bindgen\"],\"sqlcipher\":[],\"unlock_notify\":[],\"wasm32-wasi-vfs\":[],\"with-asan\":[]}}", "libssh2-sys_0.3.1": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.25\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"libc\"],\"name\":\"libz-sys\",\"req\":\"^1.1.0\"},{\"name\":\"openssl-sys\",\"req\":\"^0.9.35\",\"target\":\"cfg(unix)\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.35\",\"target\":\"cfg(windows)\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.11\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2\",\"target\":\"cfg(target_env = \\\"msvc\\\")\"}],\"features\":{\"openssl-on-win32\":[\"openssl-sys\"],\"vendored-openssl\":[\"openssl-sys/vendored\"],\"zlib-ng-compat\":[\"libz-sys/zlib-ng\"]}}", "libz-sys_1.1.23": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.98\"},{\"kind\":\"build\",\"name\":\"cmake\",\"optional\":true,\"req\":\"^0.1.50\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.43\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2.11\"}],\"features\":{\"asm\":[],\"default\":[\"libc\",\"stock-zlib\"],\"static\":[],\"stock-zlib\":[],\"zlib-ng\":[\"libc\",\"cmake\"],\"zlib-ng-no-cmake-experimental-community-maintained\":[\"libc\"]}}", "libz-sys_1.1.25": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.98\"},{\"kind\":\"build\",\"name\":\"cmake\",\"optional\":true,\"req\":\"^0.1.50\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.43\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2.11\"}],\"features\":{\"asm\":[],\"default\":[\"libc\",\"stock-zlib\"],\"static\":[],\"stock-zlib\":[],\"zlib-ng\":[\"libc\",\"cmake\"],\"zlib-ng-no-cmake-experimental-community-maintained\":[\"libc\"]}}", @@ -1190,7 +1211,7 @@ "matchit_0.8.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"actix-router\",\"req\":\"^0.2.7\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.4\"},{\"kind\":\"dev\",\"name\":\"gonzales\",\"req\":\"^0.0.3-beta\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"path-tree\",\"req\":\"^0.2.2\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.5.4\"},{\"kind\":\"dev\",\"name\":\"route-recognizer\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"routefinder\",\"req\":\"^0.5.2\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"make\",\"util\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.4\"}],\"features\":{\"__test_helpers\":[],\"default\":[]}}", "matchit_0.9.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"actix-router\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"gonzales\",\"req\":\"^0.0.3-beta\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"features\":[\"http1\",\"server\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"path-tree\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"route-recognizer\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"routefinder\",\"req\":\"^0.5\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"make\",\"util\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.5.2\"},{\"kind\":\"dev\",\"name\":\"wayfind\",\"req\":\"^0.8\"}],\"features\":{\"__test_helpers\":[],\"default\":[]}}", "maybe-async_0.2.10": "{\"dependencies\":[{\"features\":[\"attributes\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"features\":[\"visit-mut\",\"full\"],\"name\":\"syn\",\"req\":\"^2.0\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1\"}],\"features\":{\"default\":[],\"is_sync\":[]}}", - "md-5_0.10.6": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"},{\"name\":\"md5-asm\",\"optional\":true,\"req\":\"^0.5\",\"target\":\"cfg(any(target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"}],\"features\":{\"asm\":[\"md5-asm\"],\"default\":[\"std\"],\"force-soft\":[],\"loongarch64_asm\":[],\"oid\":[\"digest/oid\"],\"std\":[\"digest/std\"]}}", + "md-5_0.11.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"digest\",\"req\":\"^0.11\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"}],\"features\":{\"alloc\":[\"digest/alloc\"],\"default\":[\"alloc\",\"oid\"],\"oid\":[\"digest/oid\"],\"zeroize\":[\"digest/zeroize\"]}}", "md5_0.8.0": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "memchr_2.7.6": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.20\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"libc\":[],\"logging\":[\"dep:log\"],\"rustc-dep-of-std\":[\"core\"],\"std\":[\"alloc\"],\"use_std\":[\"std\"]}}", "memchr_2.8.0": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.20\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"libc\":[],\"logging\":[\"dep:log\"],\"rustc-dep-of-std\":[\"core\"],\"std\":[\"alloc\"],\"use_std\":[\"std\"]}}", @@ -1222,7 +1243,6 @@ "notify-types_2.1.0": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.34.0\"},{\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.26.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.89\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.39\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1.1.0\"}],\"features\":{\"serde\":[\"dep:serde\",\"bitflags/serde\"],\"serialization-compat-6\":[]}}", "notify_8.2.0": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.7.0\",\"target\":\"cfg(target_os=\\\"macos\\\")\"},{\"name\":\"crossbeam-channel\",\"optional\":true,\"req\":\"^0.5.0\"},{\"name\":\"flume\",\"optional\":true,\"req\":\"^0.11.1\"},{\"name\":\"fsevent-sys\",\"optional\":true,\"req\":\"^4.0.0\",\"target\":\"cfg(target_os=\\\"macos\\\")\"},{\"default_features\":false,\"name\":\"inotify\",\"req\":\"^0.11.0\",\"target\":\"cfg(any(target_os=\\\"linux\\\", target_os=\\\"android\\\"))\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.34.0\"},{\"name\":\"kqueue\",\"req\":\"^1.1.1\",\"target\":\"cfg(any(target_os=\\\"freebsd\\\", target_os=\\\"openbsd\\\", target_os = \\\"netbsd\\\", target_os = \\\"dragonflybsd\\\", target_os = \\\"ios\\\"))\"},{\"name\":\"kqueue\",\"optional\":true,\"req\":\"^1.1.1\",\"target\":\"cfg(target_os=\\\"macos\\\")\"},{\"name\":\"libc\",\"req\":\"^0.2.4\"},{\"name\":\"log\",\"req\":\"^0.4.17\"},{\"features\":[\"os-ext\"],\"name\":\"mio\",\"req\":\"^1.0\",\"target\":\"cfg(any(target_os=\\\"freebsd\\\", target_os=\\\"openbsd\\\", target_os = \\\"netbsd\\\", target_os = \\\"dragonflybsd\\\", target_os = \\\"ios\\\"))\"},{\"features\":[\"os-ext\"],\"name\":\"mio\",\"req\":\"^1.0\",\"target\":\"cfg(any(target_os=\\\"linux\\\", target_os=\\\"android\\\"))\"},{\"features\":[\"os-ext\"],\"name\":\"mio\",\"optional\":true,\"req\":\"^1.0\",\"target\":\"cfg(target_os=\\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.29.0\"},{\"name\":\"notify-types\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.39\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.0\"},{\"kind\":\"dev\",\"name\":\"trash\",\"req\":\"^5.2.2\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"name\":\"walkdir\",\"req\":\"^2.4.0\"},{\"features\":[\"Win32_System_Threading\",\"Win32_Foundation\",\"Win32_Storage_FileSystem\",\"Win32_Security\",\"Win32_System_WindowsProgramming\",\"Win32_System_IO\"],\"name\":\"windows-sys\",\"req\":\"^0.60.1\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"macos_fsevent\"],\"macos_fsevent\":[\"fsevent-sys\"],\"macos_kqueue\":[\"kqueue\",\"mio\"],\"serde\":[\"notify-types/serde\"],\"serialization-compat-6\":[\"notify-types/serialization-compat-6\"]}}", "nu-ansi-term_0.50.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.3\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.152\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.94\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Console\",\"Win32_Storage_FileSystem\",\"Win32_Security\"],\"name\":\"windows\",\"package\":\"windows-sys\",\"req\":\">=0.59, <=0.61\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"std\"],\"derive_serde_style\":[\"serde\"],\"gnu_legacy\":[],\"std\":[]}}", - "num-bigint-dig_0.8.6": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"spin_no_std\"],\"name\":\"lazy_static\",\"req\":\"^1.2.0\"},{\"name\":\"libm\",\"req\":\"^0.2.1\"},{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"num-integer\",\"req\":\"^0.1.39\"},{\"default_features\":false,\"name\":\"num-iter\",\"req\":\"^0.1.37\"},{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"num-traits\",\"req\":\"^0.2.4\"},{\"default_features\":false,\"name\":\"rand\",\"optional\":true,\"req\":\"^0.8.3\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand_chacha\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"rand_isaac\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"rand_xorshift\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"smallvec\",\"req\":\"^1.10.0\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.5\"}],\"features\":{\"default\":[\"std\",\"u64_digit\"],\"fuzz\":[\"arbitrary\",\"smallvec/arbitrary\"],\"i128\":[],\"nightly\":[],\"prime\":[\"rand/std_rng\"],\"std\":[\"num-integer/std\",\"num-traits/std\",\"smallvec/write\",\"rand/std\",\"serde/std\"],\"u64_digit\":[]}}", "num-bigint_0.4.6": "{\"dependencies\":[{\"default_features\":false,\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"num-integer\",\"req\":\"^0.1.46\"},{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"num-traits\",\"req\":\"^0.2.18\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"rand\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"default\":[\"std\"],\"quickcheck\":[\"dep:quickcheck\"],\"rand\":[\"dep:rand\"],\"serde\":[\"dep:serde\"],\"std\":[\"num-integer/std\",\"num-traits/std\"]}}", "num-complex_0.4.6": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bytecheck\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"bytemuck\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"i128\"],\"name\":\"num-traits\",\"req\":\"^0.2.18\"},{\"default_features\":false,\"name\":\"rand\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"rkyv\",\"optional\":true,\"req\":\"^0.7\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"bytecheck\":[\"dep:bytecheck\"],\"bytemuck\":[\"dep:bytemuck\"],\"default\":[\"std\"],\"libm\":[\"num-traits/libm\"],\"rand\":[\"dep:rand\"],\"rkyv\":[\"dep:rkyv\"],\"serde\":[\"dep:serde\"],\"std\":[\"num-traits/std\"]}}", "num-conv_0.2.0": "{\"dependencies\":[],\"features\":{}}", @@ -1311,7 +1331,6 @@ "pin-project_1.1.11": "{\"dependencies\":[{\"name\":\"pin-project-internal\",\"req\":\"=1.1.11\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"}],\"features\":{}}", "pin-utils_0.1.0": "{\"dependencies\":[],\"features\":{}}", "piper_0.2.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-channel\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"async-executor\",\"req\":\"^1.5.1\"},{\"kind\":\"dev\",\"name\":\"async-io\",\"req\":\"^2.0.0\"},{\"name\":\"atomic-waker\",\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"cargo_bench_support\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4.0\"},{\"kind\":\"dev\",\"name\":\"easy-parallel\",\"req\":\"^3.2.0\"},{\"default_features\":false,\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"name\":\"futures-io\",\"optional\":true,\"req\":\"^0.3.28\"},{\"kind\":\"dev\",\"name\":\"futures-lite\",\"req\":\"^2.0.0\"},{\"features\":[\"alloc\"],\"name\":\"portable-atomic-util\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"portable_atomic_crate\",\"optional\":true,\"package\":\"portable-atomic\",\"req\":\"^1.2.0\"}],\"features\":{\"default\":[\"std\"],\"portable-atomic\":[\"atomic-waker/portable-atomic\",\"portable_atomic_crate\",\"portable-atomic-util\"],\"std\":[\"fastrand/std\",\"futures-io\"]}}", - "pkcs1_0.7.5": "{\"dependencies\":[{\"features\":[\"db\"],\"kind\":\"dev\",\"name\":\"const-oid\",\"req\":\"^0.9\"},{\"features\":[\"oid\"],\"name\":\"der\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"pkcs8\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"spki\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"alloc\":[\"der/alloc\",\"zeroize\",\"pkcs8?/alloc\"],\"pem\":[\"alloc\",\"der/pem\",\"pkcs8?/pem\"],\"std\":[\"der/std\",\"alloc\"],\"zeroize\":[\"der/zeroize\"]}}", "pkcs8_0.10.2": "{\"dependencies\":[{\"features\":[\"oid\"],\"name\":\"der\",\"req\":\"^0.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"},{\"name\":\"pkcs5\",\"optional\":true,\"req\":\"^0.7\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"spki\",\"req\":\"^0.7.1\"},{\"default_features\":false,\"name\":\"subtle\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"3des\":[\"encryption\",\"pkcs5/3des\"],\"alloc\":[\"der/alloc\",\"der/zeroize\",\"spki/alloc\"],\"des-insecure\":[\"encryption\",\"pkcs5/des-insecure\"],\"encryption\":[\"alloc\",\"pkcs5/alloc\",\"pkcs5/pbes2\",\"rand_core\"],\"getrandom\":[\"rand_core/getrandom\"],\"pem\":[\"alloc\",\"der/pem\",\"spki/pem\"],\"sha1-insecure\":[\"encryption\",\"pkcs5/sha1-insecure\"],\"std\":[\"alloc\",\"der/std\",\"spki/std\"]}}", "pkg-config_0.3.32": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"}],\"features\":{}}", "plain_0.2.3": "{\"dependencies\":[],\"features\":{}}", @@ -1387,10 +1406,12 @@ "rama-udp_0.3.0-alpha.4": "{\"dependencies\":[{\"name\":\"rama-core\",\"req\":\"^0.3.0-alpha.4\"},{\"name\":\"rama-net\",\"req\":\"^0.3.0-alpha.4\"},{\"features\":[\"macros\",\"net\"],\"name\":\"tokio\",\"req\":\"^1.48\"},{\"features\":[\"net\"],\"name\":\"tokio-util\",\"req\":\"^0.7\"}],\"features\":{\"default\":[]}}", "rama-unix_0.3.0-alpha.4": "{\"dependencies\":[{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"name\":\"rama-core\",\"req\":\"^0.3.0-alpha.4\"},{\"name\":\"rama-net\",\"req\":\"^0.3.0-alpha.4\"},{\"features\":[\"macros\",\"net\"],\"name\":\"tokio\",\"req\":\"^1.48\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.48\"}],\"features\":{\"default\":[]}}", "rama-utils_0.3.0-alpha.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.8\"},{\"name\":\"const_format\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"name\":\"parking_lot\",\"req\":\"^0.12\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0\"},{\"name\":\"rama-macros\",\"req\":\"^0.3.0-alpha.4\"},{\"name\":\"regex\",\"req\":\"^1.12\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1\"},{\"features\":[\"write\",\"serde\",\"const_generics\",\"const_new\"],\"name\":\"smallvec\",\"req\":\"^1.15\"},{\"name\":\"smol_str\",\"req\":\"^0.3\"},{\"features\":[\"time\",\"macros\"],\"name\":\"tokio\",\"req\":\"^1.48\"},{\"kind\":\"dev\",\"name\":\"tokio-test\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0\"},{\"name\":\"wildcard\",\"req\":\"^0.3\"}],\"features\":{}}", + "rand_0.10.1": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"rng\"],\"name\":\"chacha20\",\"optional\":true,\"req\":\"^0.10.0\"},{\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.1.3\"},{\"default_features\":false,\"name\":\"rand_core\",\"req\":\"^0.10.0\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.7\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.103\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.140\"}],\"features\":{\"alloc\":[],\"chacha\":[\"dep:chacha20\"],\"default\":[\"std\",\"std_rng\",\"sys_rng\",\"thread_rng\"],\"log\":[],\"serde\":[\"dep:serde\"],\"simd_support\":[],\"std\":[\"alloc\",\"getrandom?/std\"],\"std_rng\":[\"dep:chacha20\"],\"sys_rng\":[\"dep:getrandom\",\"getrandom/sys_rng\"],\"thread_rng\":[\"std\",\"std_rng\",\"sys_rng\"],\"unbiased\":[]}}", "rand_0.8.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.2.1\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.22\",\"target\":\"cfg(unix)\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.4\"},{\"features\":[\"into_bits\"],\"name\":\"packed_simd\",\"optional\":true,\"package\":\"packed_simd_2\",\"req\":\"^0.3.7\"},{\"default_features\":false,\"name\":\"rand_chacha\",\"optional\":true,\"req\":\"^0.3.0\"},{\"name\":\"rand_core\",\"req\":\"^0.6.0\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.3.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.103\"}],\"features\":{\"alloc\":[\"rand_core/alloc\"],\"default\":[\"std\",\"std_rng\"],\"getrandom\":[\"rand_core/getrandom\"],\"min_const_gen\":[],\"nightly\":[],\"serde1\":[\"serde\",\"rand_core/serde1\"],\"simd_support\":[\"packed_simd\"],\"small_rng\":[],\"std\":[\"rand_core/std\",\"rand_chacha/std\",\"alloc\",\"getrandom\",\"libc\"],\"std_rng\":[\"rand_chacha\"]}}", "rand_0.9.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.2.1\"},{\"default_features\":false,\"name\":\"rand_chacha\",\"optional\":true,\"req\":\"^0.9.0\"},{\"default_features\":false,\"name\":\"rand_core\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.7\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.103\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.140\"}],\"features\":{\"alloc\":[],\"default\":[\"std\",\"std_rng\",\"os_rng\",\"small_rng\",\"thread_rng\"],\"log\":[],\"nightly\":[],\"os_rng\":[\"rand_core/os_rng\"],\"serde\":[\"dep:serde\",\"rand_core/serde\"],\"simd_support\":[],\"small_rng\":[],\"std\":[\"rand_core/std\",\"rand_chacha?/std\",\"alloc\"],\"std_rng\":[\"dep:rand_chacha\"],\"thread_rng\":[\"std\",\"std_rng\",\"os_rng\"],\"unbiased\":[]}}", "rand_chacha_0.3.1": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"simd\"],\"name\":\"ppv-lite86\",\"req\":\"^0.2.8\"},{\"name\":\"rand_core\",\"req\":\"^0.6.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"serde1\":[\"serde\"],\"simd\":[],\"std\":[\"ppv-lite86/std\"]}}", "rand_chacha_0.9.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"simd\"],\"name\":\"ppv-lite86\",\"req\":\"^0.2.14\"},{\"name\":\"rand_core\",\"req\":\"^0.9.0\"},{\"features\":[\"os_rng\"],\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.9.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"os_rng\":[\"rand_core/os_rng\"],\"serde\":[\"dep:serde\"],\"std\":[\"ppv-lite86/std\",\"rand_core/std\"]}}", + "rand_core_0.10.1": "{\"dependencies\":[],\"features\":{}}", "rand_core_0.6.4": "{\"dependencies\":[{\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.2\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[],\"serde1\":[\"serde\"],\"std\":[\"alloc\",\"getrandom\",\"getrandom/std\"]}}", "rand_core_0.9.5": "{\"dependencies\":[{\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"os_rng\":[\"dep:getrandom\"],\"serde\":[\"dep:serde\"],\"std\":[\"getrandom?/std\"]}}", "rand_xorshift_0.4.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1\"},{\"name\":\"rand_core\",\"req\":\"^0.9.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.118\"}],\"features\":{\"serde\":[\"dep:serde\"]}}", @@ -1413,13 +1434,13 @@ "regex-syntax_0.8.8": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.3.0\"}],\"features\":{\"arbitrary\":[\"dep:arbitrary\"],\"default\":[\"std\",\"unicode\"],\"std\":[],\"unicode\":[\"unicode-age\",\"unicode-bool\",\"unicode-case\",\"unicode-gencat\",\"unicode-perl\",\"unicode-script\",\"unicode-segment\"],\"unicode-age\":[],\"unicode-bool\":[],\"unicode-case\":[],\"unicode-gencat\":[],\"unicode-perl\":[],\"unicode-script\":[],\"unicode-segment\":[]}}", "regex_1.12.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aho-corasick\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.69\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"atty\",\"humantime\",\"termcolor\"],\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.9.3\"},{\"default_features\":false,\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.6.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"features\":[\"alloc\",\"syntax\",\"meta\",\"nfa-pikevm\"],\"name\":\"regex-automata\",\"req\":\"^0.4.12\"},{\"default_features\":false,\"name\":\"regex-syntax\",\"req\":\"^0.8.5\"},{\"kind\":\"dev\",\"name\":\"regex-test\",\"req\":\"^0.1.0\"}],\"features\":{\"default\":[\"std\",\"perf\",\"unicode\",\"regex-syntax/default\"],\"logging\":[\"aho-corasick?/logging\",\"memchr?/logging\",\"regex-automata/logging\"],\"pattern\":[],\"perf\":[\"perf-cache\",\"perf-dfa\",\"perf-onepass\",\"perf-backtrack\",\"perf-inline\",\"perf-literal\"],\"perf-backtrack\":[\"regex-automata/nfa-backtrack\"],\"perf-cache\":[],\"perf-dfa\":[\"regex-automata/hybrid\"],\"perf-dfa-full\":[\"regex-automata/dfa-build\",\"regex-automata/dfa-search\"],\"perf-inline\":[\"regex-automata/perf-inline\"],\"perf-literal\":[\"dep:aho-corasick\",\"dep:memchr\",\"regex-automata/perf-literal\"],\"perf-onepass\":[\"regex-automata/dfa-onepass\"],\"std\":[\"aho-corasick?/std\",\"memchr?/std\",\"regex-automata/std\",\"regex-syntax/std\"],\"unicode\":[\"unicode-age\",\"unicode-bool\",\"unicode-case\",\"unicode-gencat\",\"unicode-perl\",\"unicode-script\",\"unicode-segment\",\"regex-automata/unicode\",\"regex-syntax/unicode\"],\"unicode-age\":[\"regex-automata/unicode-age\",\"regex-syntax/unicode-age\"],\"unicode-bool\":[\"regex-automata/unicode-bool\",\"regex-syntax/unicode-bool\"],\"unicode-case\":[\"regex-automata/unicode-case\",\"regex-syntax/unicode-case\"],\"unicode-gencat\":[\"regex-automata/unicode-gencat\",\"regex-syntax/unicode-gencat\"],\"unicode-perl\":[\"regex-automata/unicode-perl\",\"regex-automata/unicode-word-boundary\",\"regex-syntax/unicode-perl\"],\"unicode-script\":[\"regex-automata/unicode-script\",\"regex-syntax/unicode-script\"],\"unicode-segment\":[\"regex-automata/unicode-segment\",\"regex-syntax/unicode-segment\"],\"unstable\":[\"pattern\"],\"use_std\":[\"std\"]}}", "reqwest_0.12.28": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"brotli_crate\",\"package\":\"brotli\",\"req\":\"^8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"bytes\",\"req\":\"^1.2\"},{\"name\":\"cookie_crate\",\"optional\":true,\"package\":\"cookie\",\"req\":\"^0.18.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"cookie_store\",\"optional\":true,\"req\":\"^0.22.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"encoding_rs\",\"optional\":true,\"req\":\"^0.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0.13\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.28\"},{\"default_features\":false,\"features\":[\"std\",\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.28\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h2\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h3\",\"optional\":true,\"req\":\"^0.0.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"h3-quinn\",\"optional\":true,\"req\":\"^0.0.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"tokio\"],\"name\":\"hickory-resolver\",\"optional\":true,\"req\":\"^0.25\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"http\",\"req\":\"^1.1\"},{\"name\":\"http-body\",\"req\":\"^1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.2\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"client\"],\"name\":\"hyper\",\"req\":\"^1.1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"http1\",\"http2\",\"client\",\"server\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"http1\",\"tls12\"],\"name\":\"hyper-rustls\",\"optional\":true,\"req\":\"^0.27.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"hyper-tls\",\"optional\":true,\"req\":\"^0.6\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"client\",\"client-legacy\",\"client-proxy\",\"tokio\"],\"name\":\"hyper-util\",\"req\":\"^0.1.12\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"http1\",\"http2\",\"client\",\"client-legacy\",\"server-auto\",\"server-graceful\",\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.12\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"js-sys\",\"req\":\"^0.3.77\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0\"},{\"name\":\"log\",\"req\":\"^0.4.17\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"mime\",\"optional\":true,\"req\":\"^0.3.16\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"mime_guess\",\"optional\":true,\"req\":\"^2.0\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.10\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.0\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.18\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"rustls\",\"runtime-tokio\"],\"name\":\"quinn\",\"optional\":true,\"req\":\"^0.11.1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"std\"],\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.9.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"serde\",\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"serde_json\",\"req\":\"^1.0\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_urlencoded\",\"req\":\"^0.7.1\"},{\"features\":[\"futures\"],\"name\":\"sync_wrapper\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"net\",\"time\"],\"name\":\"tokio\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"tokio-native-tls\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"tls12\"],\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"io\"],\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.9\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"retry\",\"timeout\",\"util\"],\"name\":\"tower\",\"req\":\"^0.5.2\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"limit\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.5.2\"},{\"default_features\":false,\"features\":[\"follow-redirect\"],\"name\":\"tower-http\",\"req\":\"^0.6.8\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"tower-service\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"name\":\"url\",\"req\":\"^2.4\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"serde-serialize\"],\"kind\":\"dev\",\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4.18\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"wasm-streams\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"AbortController\",\"AbortSignal\",\"Headers\",\"Request\",\"RequestInit\",\"RequestMode\",\"Response\",\"Window\",\"FormData\",\"Blob\",\"BlobPropertyBag\",\"ServiceWorkerGlobalScope\",\"RequestCredentials\",\"File\",\"ReadableStream\",\"RequestCache\"],\"name\":\"web-sys\",\"req\":\"^0.3.28\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"kind\":\"dev\",\"name\":\"zstd_crate\",\"package\":\"zstd\",\"req\":\"^0.13\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"}],\"features\":{\"__rustls\":[\"dep:hyper-rustls\",\"dep:tokio-rustls\",\"dep:rustls\",\"__tls\"],\"__rustls-ring\":[\"hyper-rustls?/ring\",\"tokio-rustls?/ring\",\"rustls?/ring\",\"quinn?/ring\"],\"__tls\":[\"dep:rustls-pki-types\",\"tokio/io-util\"],\"blocking\":[\"dep:futures-channel\",\"futures-channel?/sink\",\"dep:futures-util\",\"futures-util?/io\",\"futures-util?/sink\",\"tokio/sync\"],\"brotli\":[\"tower-http/decompression-br\"],\"charset\":[\"dep:encoding_rs\",\"dep:mime\"],\"cookies\":[\"dep:cookie_crate\",\"dep:cookie_store\"],\"default\":[\"default-tls\",\"charset\",\"http2\",\"system-proxy\"],\"default-tls\":[\"dep:hyper-tls\",\"dep:native-tls-crate\",\"__tls\",\"dep:tokio-native-tls\"],\"deflate\":[\"tower-http/decompression-deflate\"],\"gzip\":[\"tower-http/decompression-gzip\"],\"hickory-dns\":[\"dep:hickory-resolver\",\"dep:once_cell\"],\"http2\":[\"h2\",\"hyper/http2\",\"hyper-util/http2\",\"hyper-rustls?/http2\"],\"http3\":[\"rustls-tls-manual-roots\",\"dep:h3\",\"dep:h3-quinn\",\"dep:quinn\",\"tokio/macros\"],\"json\":[\"dep:serde_json\"],\"macos-system-configuration\":[\"system-proxy\"],\"multipart\":[\"dep:mime_guess\",\"dep:futures-util\"],\"native-tls\":[\"default-tls\"],\"native-tls-alpn\":[\"native-tls\",\"native-tls-crate?/alpn\",\"hyper-tls?/alpn\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate?/vendored\"],\"rustls-tls\":[\"rustls-tls-webpki-roots\"],\"rustls-tls-manual-roots\":[\"rustls-tls-manual-roots-no-provider\",\"__rustls-ring\"],\"rustls-tls-manual-roots-no-provider\":[\"__rustls\"],\"rustls-tls-native-roots\":[\"rustls-tls-native-roots-no-provider\",\"__rustls-ring\"],\"rustls-tls-native-roots-no-provider\":[\"dep:rustls-native-certs\",\"hyper-rustls?/native-tokio\",\"__rustls\"],\"rustls-tls-no-provider\":[\"rustls-tls-manual-roots-no-provider\"],\"rustls-tls-webpki-roots\":[\"rustls-tls-webpki-roots-no-provider\",\"__rustls-ring\"],\"rustls-tls-webpki-roots-no-provider\":[\"dep:webpki-roots\",\"hyper-rustls?/webpki-tokio\",\"__rustls\"],\"socks\":[],\"stream\":[\"tokio/fs\",\"dep:futures-util\",\"dep:tokio-util\",\"dep:wasm-streams\"],\"system-proxy\":[\"hyper-util/client-proxy-system\"],\"trust-dns\":[],\"zstd\":[\"tower-http/decompression-zstd\"]}}", - "resb_0.1.1": "{\"dependencies\":[{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"},{\"name\":\"nom\",\"optional\":true,\"req\":\"^7.0.0\"},{\"default_features\":false,\"name\":\"potential_utf\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"req\":\"^1.0.220\"}],\"features\":{\"default\":[],\"logging\":[\"dep:log\"],\"serialize\":[\"std\"],\"std\":[],\"text\":[\"dep:indexmap\",\"dep:nom\",\"std\"]}}", + "reqwest_0.13.4": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"brotli_crate\",\"package\":\"brotli\",\"req\":\"^8\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"bytes\",\"req\":\"^1.2\"},{\"name\":\"cookie_crate\",\"optional\":true,\"package\":\"cookie\",\"req\":\"^0.18.0\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"cookie_store\",\"optional\":true,\"req\":\"^0.22.0\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"encoding_rs\",\"optional\":true,\"req\":\"^0.8\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0.13\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"futures-channel\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.28\"},{\"default_features\":false,\"name\":\"futures-util\",\"optional\":true,\"req\":\"^0.3.28\"},{\"default_features\":false,\"features\":[\"std\",\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.28\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"h2\",\"optional\":true,\"req\":\"^0.4\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"h3\",\"optional\":true,\"req\":\"^0.0.8\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"h3-quinn\",\"optional\":true,\"req\":\"^0.0.10\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"features\":[\"tokio\"],\"name\":\"hickory-resolver\",\"optional\":true,\"req\":\"^0.26\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"http\",\"req\":\"^1.1\"},{\"name\":\"http-body\",\"req\":\"^1\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"http-body-util\",\"req\":\"^0.1.2\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"features\":[\"http1\",\"client\"],\"name\":\"hyper\",\"req\":\"^1.1\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"features\":[\"http1\",\"http2\",\"client\",\"server\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.1.0\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"features\":[\"http1\",\"tls12\"],\"name\":\"hyper-rustls\",\"optional\":true,\"req\":\"^0.27.0\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"hyper-tls\",\"optional\":true,\"req\":\"^0.6\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"features\":[\"http1\",\"client\",\"client-legacy\",\"client-proxy\",\"tokio\"],\"name\":\"hyper-util\",\"req\":\"^0.1.12\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"features\":[\"http1\",\"http2\",\"client\",\"client-legacy\",\"server-auto\",\"server-graceful\",\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.12\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"js-sys\",\"req\":\"^0.3.77\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0\"},{\"name\":\"log\",\"req\":\"^0.4.17\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"mime\",\"optional\":true,\"req\":\"^0.3.16\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"name\":\"mime_guess\",\"optional\":true,\"req\":\"^2.0\"},{\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\",\"req\":\"^0.2.16\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.0\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.18\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.11\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"features\":[\"runtime-tokio\"],\"name\":\"quinn\",\"optional\":true,\"req\":\"^0.11.1\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"features\":[\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.4\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"features\":[\"std\"],\"name\":\"rustls-pki-types\",\"optional\":true,\"req\":\"^1.9.0\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"rustls-platform-verifier\",\"optional\":true,\"req\":\">=0.6.0, <0.8.0\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_urlencoded\",\"optional\":true,\"req\":\"^0.7.1\"},{\"features\":[\"futures\"],\"name\":\"sync_wrapper\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"net\",\"time\"],\"name\":\"tokio\",\"req\":\"^1.0\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.0\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"tokio-native-tls\",\"optional\":true,\"req\":\"^0.3.0\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"features\":[\"tls12\"],\"name\":\"tokio-rustls\",\"optional\":true,\"req\":\"^0.26\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"features\":[\"io\"],\"name\":\"tokio-util\",\"optional\":true,\"req\":\"^0.7.9\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"features\":[\"retry\",\"timeout\",\"util\"],\"name\":\"tower\",\"req\":\"^0.5.2\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"features\":[\"limit\"],\"kind\":\"dev\",\"name\":\"tower\",\"req\":\"^0.5.2\"},{\"default_features\":false,\"features\":[\"follow-redirect\"],\"name\":\"tower-http\",\"req\":\"^0.6.8\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"tower-service\",\"req\":\"^0.3\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"name\":\"url\",\"req\":\"^2.4\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"features\":[\"serde-serialize\"],\"kind\":\"dev\",\"name\":\"wasm-bindgen\",\"req\":\"^0.2.89\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4.18\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"name\":\"wasm-streams\",\"optional\":true,\"req\":\"^0.5\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"features\":[\"AbortController\",\"AbortSignal\",\"Headers\",\"Request\",\"RequestInit\",\"RequestMode\",\"Response\",\"Window\",\"FormData\",\"Blob\",\"BlobPropertyBag\",\"ServiceWorkerGlobalScope\",\"RequestCredentials\",\"File\",\"ReadableStream\",\"RequestCache\"],\"name\":\"web-sys\",\"req\":\"^0.3.28\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"zstd_crate\",\"package\":\"zstd\",\"req\":\"^0.13\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"}],\"features\":{\"__native-tls\":[\"dep:hyper-tls\",\"dep:native-tls-crate\",\"__tls\",\"dep:tokio-native-tls\"],\"__native-tls-alpn\":[\"native-tls-crate?/alpn\",\"hyper-tls?/alpn\"],\"__rustls\":[\"dep:hyper-rustls\",\"dep:tokio-rustls\",\"dep:rustls\",\"__tls\"],\"__rustls-aws-lc-rs\":[\"hyper-rustls?/aws-lc-rs\",\"tokio-rustls?/aws-lc-rs\",\"rustls?/aws-lc-rs\",\"quinn?/rustls-aws-lc-rs\"],\"__tls\":[\"dep:rustls-pki-types\",\"tokio/io-util\"],\"blocking\":[\"dep:futures-channel\",\"futures-channel?/sink\",\"dep:futures-util\",\"futures-util?/io\",\"futures-util?/sink\",\"tokio/sync\"],\"brotli\":[\"tower-http/decompression-br\"],\"charset\":[\"dep:encoding_rs\",\"dep:mime\"],\"cookies\":[\"dep:cookie_crate\",\"dep:cookie_store\"],\"default\":[\"default-tls\",\"charset\",\"http2\",\"system-proxy\"],\"default-tls\":[\"rustls\"],\"deflate\":[\"tower-http/decompression-deflate\"],\"form\":[\"dep:serde\",\"dep:serde_urlencoded\"],\"gzip\":[\"tower-http/decompression-gzip\"],\"hickory-dns\":[\"dep:hickory-resolver\",\"dep:once_cell\"],\"http2\":[\"dep:h2\",\"hyper/http2\",\"hyper-util/http2\",\"hyper-rustls?/http2\"],\"http3\":[\"rustls\",\"dep:h3\",\"dep:h3-quinn\",\"dep:quinn\",\"tokio/macros\"],\"json\":[\"dep:serde\",\"dep:serde_json\"],\"multipart\":[\"dep:mime_guess\",\"dep:futures-util\"],\"native-tls\":[\"__native-tls\",\"__native-tls-alpn\"],\"native-tls-no-alpn\":[\"__native-tls\"],\"native-tls-vendored\":[\"__native-tls\",\"native-tls-crate?/vendored\",\"__native-tls-alpn\"],\"native-tls-vendored-no-alpn\":[\"__native-tls\",\"native-tls-crate?/vendored\"],\"query\":[\"dep:serde\",\"dep:serde_urlencoded\"],\"rustls\":[\"__rustls-aws-lc-rs\",\"dep:rustls-platform-verifier\",\"__rustls\"],\"rustls-no-provider\":[\"dep:rustls-platform-verifier\",\"__rustls\"],\"socks\":[],\"stream\":[\"tokio/fs\",\"dep:futures-util\",\"dep:tokio-util\",\"dep:wasm-streams\"],\"system-proxy\":[\"hyper-util/client-proxy-system\"],\"zstd\":[\"tower-http/decompression-zstd\"]}}", + "resb_0.1.2": "{\"dependencies\":[{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.17\"},{\"name\":\"nom\",\"optional\":true,\"req\":\"^7.0.0\"},{\"default_features\":false,\"name\":\"potential_utf\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde_core\",\"req\":\"^1.0.220\"}],\"features\":{\"default\":[],\"logging\":[\"dep:log\"],\"serialize\":[\"std\"],\"std\":[],\"text\":[\"dep:indexmap\",\"dep:nom\",\"std\"]}}", "resolv-conf_0.7.6": "{\"dependencies\":[],\"features\":{\"system\":[]}}", "rfc6979_0.4.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"reset\"],\"name\":\"hmac\",\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2\"}],\"features\":{}}", "ring_0.17.14": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.2.8\"},{\"default_features\":false,\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"getrandom\",\"req\":\"^0.2.10\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(all(any(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), all(target_arch = \\\"arm\\\", target_endian = \\\"little\\\")), any(target_os = \\\"android\\\", target_os = \\\"linux\\\")))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), target_vendor = \\\"apple\\\", any(target_os = \\\"ios\\\", target_os = \\\"macos\\\", target_os = \\\"tvos\\\", target_os = \\\"visionos\\\", target_os = \\\"watchos\\\")))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(any(unix, windows, target_os = \\\"wasi\\\"))\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.37\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Threading\"],\"name\":\"windows-sys\",\"req\":\"^0.52\",\"target\":\"cfg(all(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), target_os = \\\"windows\\\"))\"}],\"features\":{\"alloc\":[],\"default\":[\"alloc\",\"dev_urandom_fallback\"],\"dev_urandom_fallback\":[],\"less-safe-getrandom-custom-or-rdrand\":[],\"less-safe-getrandom-espidf\":[],\"slow_tests\":[],\"std\":[\"alloc\"],\"test_logging\":[],\"unstable-testing-arm-no-hw\":[],\"unstable-testing-arm-no-neon\":[],\"wasm32_unknown_unknown_js\":[\"getrandom/js\"]}}", - "rmcp-macros_0.15.0": "{\"dependencies\":[{\"name\":\"darling\",\"req\":\"^0.23\"},{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", - "rmcp_0.15.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"async-trait\",\"req\":\"^0.1.89\"},{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1\"},{\"name\":\"axum\",\"optional\":true,\"req\":\"^0.8\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"serde\",\"clock\",\"std\",\"oldtime\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"serde\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"reqwest\"],\"name\":\"oauth2\",\"optional\":true,\"req\":\"^5.0\"},{\"name\":\"pastey\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"tokio1\"],\"name\":\"process-wrap\",\"optional\":true,\"req\":\"^9.0\"},{\"name\":\"rand\",\"optional\":true,\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"json\",\"stream\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"rmcp-macros\",\"optional\":true,\"req\":\"^0.15.0\"},{\"features\":[\"chrono04\"],\"name\":\"schemars\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"chrono04\"],\"kind\":\"dev\",\"name\":\"schemars\",\"req\":\"^1.1.0\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"sse-stream\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\",\"macros\",\"rt\",\"time\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"name\":\"tower-service\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\",\"std\",\"fmt\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.4\"},{\"features\":[\"v4\"],\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"__reqwest\":[\"dep:reqwest\"],\"auth\":[\"dep:oauth2\",\"__reqwest\",\"dep:url\"],\"client\":[\"dep:tokio-stream\"],\"client-side-sse\":[\"dep:sse-stream\",\"dep:http\"],\"default\":[\"base64\",\"macros\",\"server\"],\"elicitation\":[\"dep:url\"],\"macros\":[\"dep:rmcp-macros\",\"dep:pastey\"],\"reqwest\":[\"__reqwest\",\"reqwest?/rustls-tls\"],\"reqwest-native-tls\":[\"__reqwest\",\"reqwest?/native-tls\"],\"reqwest-tls-no-provider\":[\"__reqwest\",\"reqwest?/rustls-tls-no-provider\"],\"schemars\":[\"dep:schemars\"],\"server\":[\"transport-async-rw\",\"dep:schemars\",\"dep:pastey\"],\"server-side-http\":[\"uuid\",\"dep:rand\",\"dep:tokio-stream\",\"dep:http\",\"dep:http-body\",\"dep:http-body-util\",\"dep:bytes\",\"dep:sse-stream\",\"dep:axum\",\"tower\"],\"tower\":[\"dep:tower-service\"],\"transport-async-rw\":[\"tokio/io-util\",\"tokio-util/codec\"],\"transport-child-process\":[\"transport-async-rw\",\"tokio/process\",\"dep:process-wrap\"],\"transport-io\":[\"transport-async-rw\",\"tokio/io-std\"],\"transport-streamable-http-client\":[\"client-side-sse\",\"transport-worker\"],\"transport-streamable-http-client-reqwest\":[\"transport-streamable-http-client\",\"__reqwest\"],\"transport-streamable-http-server\":[\"transport-streamable-http-server-session\",\"server-side-http\",\"transport-worker\"],\"transport-streamable-http-server-session\":[\"transport-async-rw\",\"dep:tokio-stream\"],\"transport-worker\":[\"dep:tokio-stream\"]}}", - "rsa_0.9.10": "{\"dependencies\":[{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"base64ct\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"const-oid\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"alloc\",\"oid\"],\"name\":\"digest\",\"req\":\"^0.10.5\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4.1\"},{\"default_features\":false,\"features\":[\"i128\",\"prime\",\"zeroize\"],\"name\":\"num-bigint\",\"package\":\"num-bigint-dig\",\"req\":\"^0.8.6\"},{\"default_features\":false,\"name\":\"num-integer\",\"req\":\"^0.1.39\"},{\"default_features\":false,\"features\":[\"libm\"],\"name\":\"num-traits\",\"req\":\"^0.2.9\"},{\"default_features\":false,\"features\":[\"alloc\",\"pkcs8\"],\"name\":\"pkcs1\",\"req\":\"^0.7.5\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"pkcs8\",\"req\":\"^0.10.2\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand_chacha\",\"req\":\"^0.3\"},{\"default_features\":false,\"name\":\"rand_core\",\"req\":\"^0.6.4\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"rand_core\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"rand_xorshift\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.184\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.89\"},{\"default_features\":false,\"features\":[\"oid\"],\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10.5\"},{\"default_features\":false,\"features\":[\"oid\"],\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.10.5\"},{\"default_features\":false,\"features\":[\"oid\"],\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10.6\"},{\"default_features\":false,\"features\":[\"oid\"],\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10.6\"},{\"default_features\":false,\"features\":[\"oid\"],\"kind\":\"dev\",\"name\":\"sha3\",\"req\":\"^0.10.7\"},{\"default_features\":false,\"features\":[\"alloc\",\"digest\",\"rand_core\"],\"name\":\"signature\",\"req\":\">2.0, <2.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"spki\",\"req\":\"^0.7.3\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.1.1\"},{\"features\":[\"alloc\"],\"name\":\"zeroize\",\"req\":\"^1.5\"}],\"features\":{\"default\":[\"std\",\"pem\",\"u64_digit\"],\"getrandom\":[\"rand_core/getrandom\"],\"hazmat\":[],\"nightly\":[\"num-bigint/nightly\"],\"pem\":[\"pkcs1/pem\",\"pkcs8/pem\"],\"pkcs5\":[\"pkcs8/encryption\"],\"serde\":[\"dep:serde\",\"num-bigint/serde\"],\"std\":[\"digest/std\",\"pkcs1/std\",\"pkcs8/std\",\"rand_core/std\",\"signature/std\"],\"u64_digit\":[\"num-bigint/u64_digit\"]}}", + "rmcp-macros_1.7.0": "{\"dependencies\":[{\"name\":\"darling\",\"req\":\"^0.23\"},{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{\"local\":[]}}", + "rmcp_1.7.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"async-trait\",\"req\":\"^0.1.89\"},{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"http1\",\"tokio\"],\"kind\":\"dev\",\"name\":\"axum\",\"req\":\"^0.8\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"serde\",\"clock\",\"std\",\"oldtime\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"features\":[\"serde\",\"now\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1\"},{\"features\":[\"client\",\"http1\"],\"name\":\"hyper\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"server\",\"http1\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1\"},{\"features\":[\"tokio\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"jsonwebtoken\",\"optional\":true,\"req\":\"^10\"},{\"default_features\":false,\"name\":\"oauth2\",\"optional\":true,\"req\":\"^5.0\"},{\"name\":\"pastey\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"tokio1\"],\"name\":\"process-wrap\",\"optional\":true,\"req\":\"^9.0\"},{\"name\":\"rand\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"features\":[\"json\",\"stream\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.13.2\"},{\"name\":\"rmcp-macros\",\"optional\":true,\"req\":\"^1.7.0\"},{\"features\":[\"chrono04\"],\"name\":\"schemars\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"chrono04\"],\"kind\":\"dev\",\"name\":\"schemars\",\"req\":\"^1.1.0\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"sse-stream\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\",\"macros\",\"rt\",\"time\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"name\":\"tower-service\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\",\"std\",\"fmt\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.4\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2.4\"},{\"features\":[\"v4\"],\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"which\",\"optional\":true,\"req\":\"^8\"}],\"features\":{\"__reqwest\":[\"dep:reqwest\"],\"auth\":[\"dep:oauth2\",\"__reqwest\",\"dep:url\"],\"auth-client-credentials-jwt\":[\"auth\",\"dep:jsonwebtoken\",\"uuid\"],\"client\":[\"dep:tokio-stream\"],\"client-side-sse\":[\"dep:sse-stream\",\"dep:http\"],\"default\":[\"base64\",\"macros\",\"server\"],\"elicitation\":[\"dep:url\"],\"local\":[\"rmcp-macros?/local\"],\"macros\":[\"dep:rmcp-macros\",\"dep:pastey\"],\"reqwest\":[\"__reqwest\",\"reqwest?/rustls\"],\"reqwest-native-tls\":[\"__reqwest\",\"reqwest?/native-tls\"],\"reqwest-tls-no-provider\":[\"__reqwest\",\"reqwest?/rustls-no-provider\"],\"schemars\":[\"dep:schemars\"],\"server\":[\"transport-async-rw\",\"dep:schemars\",\"dep:pastey\"],\"server-side-http\":[\"uuid\",\"dep:rand\",\"dep:tokio-stream\",\"dep:http\",\"dep:http-body\",\"dep:http-body-util\",\"dep:bytes\",\"dep:sse-stream\",\"tower\"],\"tower\":[\"dep:tower-service\"],\"transport-async-rw\":[\"tokio/io-util\",\"tokio-util/codec\"],\"transport-child-process\":[\"transport-async-rw\",\"tokio/process\",\"dep:process-wrap\"],\"transport-io\":[\"transport-async-rw\",\"tokio/io-std\"],\"transport-streamable-http-client\":[\"client-side-sse\",\"transport-worker\"],\"transport-streamable-http-client-reqwest\":[\"transport-streamable-http-client\",\"__reqwest\"],\"transport-streamable-http-client-unix-socket\":[\"transport-streamable-http-client\",\"dep:hyper\",\"dep:hyper-util\",\"dep:http-body-util\",\"dep:http\",\"dep:bytes\",\"tokio/net\"],\"transport-streamable-http-server\":[\"transport-streamable-http-server-session\",\"server-side-http\",\"transport-worker\"],\"transport-streamable-http-server-session\":[\"transport-async-rw\",\"dep:tokio-stream\"],\"transport-worker\":[\"dep:tokio-stream\"],\"which-command\":[\"transport-child-process\",\"dep:which\"]}}", "rtrb_0.3.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "rust-embed-impl_8.11.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"rust-embed-utils\",\"req\":\"^8.11.0\"},{\"name\":\"shellexpand\",\"optional\":true,\"req\":\"^3\"},{\"default_features\":false,\"features\":[\"derive\",\"parsing\",\"proc-macro\",\"printing\"],\"name\":\"syn\",\"req\":\"^2\"},{\"name\":\"walkdir\",\"req\":\"^2.3.1\"}],\"features\":{\"compression\":[],\"debug-embed\":[],\"deterministic-timestamps\":[],\"include-exclude\":[\"rust-embed-utils/include-exclude\"],\"interpolate-folder-path\":[\"shellexpand\"],\"mime-guess\":[\"rust-embed-utils/mime-guess\"]}}", "rust-embed-utils_8.11.0": "{\"dependencies\":[{\"name\":\"globset\",\"optional\":true,\"req\":\"^0.4.8\"},{\"name\":\"mime_guess\",\"optional\":true,\"req\":\"^2.0.4\"},{\"name\":\"sha2\",\"req\":\"^0.10.5\"},{\"name\":\"walkdir\",\"req\":\"^2.3.1\"}],\"features\":{\"debug-embed\":[],\"include-exclude\":[\"globset\"],\"mime-guess\":[\"mime_guess\"]}}", @@ -1436,6 +1457,8 @@ "rustix_1.1.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4.0\"},{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(all(criterion, not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"flate2\",\"req\":\"^1.0\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.182\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.182\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.171\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(windows), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(windows)\"},{\"default_features\":false,\"name\":\"libc_errno\",\"optional\":true,\"package\":\"errno\",\"req\":\"^0.3.10\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc_errno\",\"package\":\"errno\",\"req\":\"^0.3.10\"},{\"default_features\":false,\"features\":[\"general\",\"ioctl\",\"no_std\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.12\",\"target\":\"cfg(all(any(target_os = \\\"linux\\\", target_os = \\\"android\\\"), any(rustix_use_libc, miri, not(all(target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\")))))))\"},{\"default_features\":false,\"features\":[\"auxvec\",\"general\",\"errno\",\"ioctl\",\"no_std\",\"elf\"],\"name\":\"linux-raw-sys\",\"req\":\"^0.12\",\"target\":\"cfg(all(not(rustix_use_libc), not(miri), target_os = \\\"linux\\\", any(target_endian = \\\"little\\\", any(target_arch = \\\"s390x\\\", target_arch = \\\"powerpc\\\")), any(target_arch = \\\"arm\\\", all(target_arch = \\\"aarch64\\\", target_pointer_width = \\\"64\\\"), target_arch = \\\"riscv64\\\", all(rustix_use_experimental_asm, target_arch = \\\"powerpc\\\"), all(rustix_use_experimental_asm, target_arch = \\\"powerpc64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"s390x\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips32r6\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64\\\"), all(rustix_use_experimental_asm, target_arch = \\\"mips64r6\\\"), target_arch = \\\"x86\\\", all(target_arch = \\\"x86_64\\\", target_pointer_width = \\\"64\\\"))))\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.20.3\",\"target\":\"cfg(windows)\"},{\"name\":\"rustc-std-workspace-alloc\",\"optional\":true,\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^2.0.0\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"all-apis\":[\"event\",\"fs\",\"io_uring\",\"mm\",\"mount\",\"net\",\"param\",\"pipe\",\"process\",\"pty\",\"rand\",\"runtime\",\"shm\",\"stdio\",\"system\",\"termios\",\"thread\",\"time\"],\"alloc\":[],\"default\":[\"std\"],\"event\":[],\"fs\":[],\"io_uring\":[\"event\",\"fs\",\"net\",\"thread\",\"linux-raw-sys/io_uring\"],\"linux_4_11\":[],\"linux_5_1\":[\"linux_4_11\"],\"linux_5_11\":[\"linux_5_1\"],\"linux_latest\":[\"linux_5_11\"],\"mm\":[],\"mount\":[],\"net\":[\"linux-raw-sys/net\",\"linux-raw-sys/netlink\",\"linux-raw-sys/if_ether\",\"linux-raw-sys/xdp\"],\"param\":[],\"pipe\":[],\"process\":[\"linux-raw-sys/prctl\"],\"pty\":[\"fs\"],\"rand\":[],\"runtime\":[\"linux-raw-sys/prctl\"],\"rustc-dep-of-std\":[\"core\",\"rustc-std-workspace-alloc\",\"linux-raw-sys/rustc-dep-of-std\",\"bitflags/rustc-dep-of-std\"],\"shm\":[\"fs\"],\"std\":[\"bitflags/std\",\"alloc\",\"libc?/std\",\"libc_errno?/std\"],\"stdio\":[],\"system\":[\"linux-raw-sys/system\"],\"termios\":[],\"thread\":[\"linux-raw-sys/prctl\"],\"time\":[],\"try_close\":[],\"use-explicitly-provided-auxv\":[],\"use-libc\":[\"libc_errno\",\"libc\"],\"use-libc-auxv\":[]}}", "rustls-native-certs_0.8.3": "{\"dependencies\":[{\"name\":\"openssl-probe\",\"req\":\"^0.2\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"features\":[\"std\"],\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.10\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"kind\":\"dev\",\"name\":\"rustls-webpki\",\"req\":\"^0.103\"},{\"name\":\"schannel\",\"req\":\"^0.1\",\"target\":\"cfg(windows)\"},{\"name\":\"security-framework\",\"req\":\"^3\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.5\"},{\"kind\":\"dev\",\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18\"}],\"features\":{}}", "rustls-pki-types_1.14.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"crabgrind\",\"req\":\"=0.1.9\",\"target\":\"cfg(all(target_os = \\\"linux\\\", target_arch = \\\"x86_64\\\"))\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"alloc\":[\"dep:zeroize\"],\"default\":[\"alloc\"],\"std\":[\"alloc\"],\"web\":[\"web-time\"]}}", + "rustls-platform-verifier-android_0.1.1": "{\"dependencies\":[],\"features\":{}}", + "rustls-platform-verifier_0.7.0": "{\"dependencies\":[{\"name\":\"android_logger\",\"optional\":true,\"req\":\"^0.15\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"core-foundation\",\"req\":\"^0.10\",\"target\":\"cfg(any(target_vendor = \\\"apple\\\"))\"},{\"name\":\"core-foundation-sys\",\"req\":\"^0.8\",\"target\":\"cfg(any(target_vendor = \\\"apple\\\"))\"},{\"default_features\":false,\"name\":\"jni\",\"req\":\"^0.22\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"default_features\":false,\"name\":\"jni\",\"optional\":true,\"req\":\"^0.22.4\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"once_cell\",\"req\":\"^1.9\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"once_cell\",\"optional\":true,\"req\":\"^1.9\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"req\":\"^0.23.27\"},{\"default_features\":false,\"features\":[\"ring\"],\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"name\":\"rustls-native-certs\",\"req\":\"^0.8\",\"target\":\"cfg(all(unix, not(target_os = \\\"android\\\"), not(target_vendor = \\\"apple\\\"), not(target_arch = \\\"wasm32\\\")))\"},{\"name\":\"rustls-platform-verifier-android\",\"req\":\"^0.1.0\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"security-framework\",\"req\":\"^3.5.0\",\"target\":\"cfg(any(target_vendor = \\\"apple\\\"))\"},{\"name\":\"security-framework-sys\",\"req\":\"^2.15\",\"target\":\"cfg(any(target_vendor = \\\"apple\\\"))\"},{\"default_features\":false,\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103\",\"target\":\"cfg(all(unix, not(target_os = \\\"android\\\"), not(target_vendor = \\\"apple\\\"), not(target_arch = \\\"wasm32\\\")))\"},{\"default_features\":false,\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"default_features\":false,\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"webpki-root-certs\",\"req\":\"^1\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"kind\":\"dev\",\"name\":\"webpki-root-certs\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"Win32_Foundation\",\"Win32_Security_Cryptography\"],\"name\":\"windows-sys\",\"req\":\">=0.52.0, <0.62.0\",\"target\":\"cfg(windows)\"}],\"features\":{\"cert-logging\":[\"base64\"],\"dbg\":[],\"docsrs\":[\"jni\",\"once_cell\"],\"ffi-testing\":[\"android_logger\",\"rustls/ring\"]}}", "rustls-webpki_0.103.13": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"kind\":\"dev\",\"name\":\"bzip2\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.17.2\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"aws_lc_rs\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14.2\"},{\"default_features\":false,\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.18.1\"}],\"features\":{\"alloc\":[\"ring?/alloc\",\"pki-types/alloc\"],\"aws-lc-rs\":[\"dep:aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/prebuilt-nasm\"],\"aws-lc-rs-fips\":[\"dep:aws-lc-rs\",\"aws-lc-rs/fips\"],\"aws-lc-rs-unstable\":[\"aws-lc-rs\",\"aws-lc-rs/unstable\"],\"default\":[\"std\"],\"ring\":[\"dep:ring\"],\"std\":[\"alloc\",\"pki-types/std\"]}}", "rustls_0.23.36": "{\"dependencies\":[{\"default_features\":false,\"name\":\"aws-lc-rs\",\"optional\":true,\"req\":\"^1.14\"},{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"brotli\",\"optional\":true,\"req\":\"^8\"},{\"name\":\"brotli-decompressor\",\"optional\":true,\"req\":\"^5.0.0\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"default-hasher\",\"inline-more\"],\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.8\"},{\"kind\":\"dev\",\"name\":\"macro_rules_attribute\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4.4\"},{\"default_features\":false,\"features\":[\"alloc\",\"race\"],\"name\":\"once_cell\",\"req\":\"^1.16\"},{\"features\":[\"alloc\"],\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"pem\",\"aws_lc_rs\"],\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14\"},{\"name\":\"ring\",\"optional\":true,\"req\":\"^0.17\"},{\"kind\":\"build\",\"name\":\"rustversion\",\"optional\":true,\"req\":\"^1.0.6\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2.5.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.6\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103.5\"},{\"kind\":\"dev\",\"name\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17\"},{\"name\":\"zeroize\",\"req\":\"^1.8\"},{\"name\":\"zlib-rs\",\"optional\":true,\"req\":\"^0.5\"}],\"features\":{\"aws-lc-rs\":[\"aws_lc_rs\"],\"aws_lc_rs\":[\"dep:aws-lc-rs\",\"webpki/aws-lc-rs\",\"aws-lc-rs/aws-lc-sys\",\"aws-lc-rs/prebuilt-nasm\"],\"brotli\":[\"dep:brotli\",\"dep:brotli-decompressor\",\"std\"],\"custom-provider\":[],\"default\":[\"aws_lc_rs\",\"logging\",\"prefer-post-quantum\",\"std\",\"tls12\"],\"fips\":[\"aws_lc_rs\",\"aws-lc-rs?/fips\",\"webpki/aws-lc-rs-fips\"],\"logging\":[\"log\"],\"prefer-post-quantum\":[\"aws_lc_rs\"],\"read_buf\":[\"rustversion\",\"std\"],\"ring\":[\"dep:ring\",\"webpki/ring\"],\"std\":[\"webpki/std\",\"pki-types/std\",\"once_cell/std\"],\"tls12\":[],\"zlib\":[\"dep:zlib-rs\"]}}", "rustversion_1.0.22": "{\"dependencies\":[{\"features\":[\"diff\"],\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.49\"}],\"features\":{}}", @@ -1496,8 +1519,10 @@ "serial_test_derive_3.3.1": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\">=0.6.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"prettyplease\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"proc-macro\"],\"name\":\"proc-macro2\",\"req\":\"^1.0.60\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"full\",\"printing\",\"parsing\",\"clone-impls\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{\"async\":[],\"default\":[],\"file_locks\":[],\"test_logging\":[]}}", "sha1-checked_0.10.0": "{\"dependencies\":[{\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"default_features\":false,\"features\":[\"compress\"],\"name\":\"sha1\",\"req\":\"^0.10.6\"},{\"default_features\":false,\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.7\"}],\"features\":{\"default\":[\"oid\",\"std\"],\"oid\":[\"digest/oid\",\"sha1/oid\"],\"std\":[\"digest/std\",\"sha1/std\"],\"zeroize\":[\"dep:zeroize\"]}}", "sha1_0.10.6": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"},{\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"},{\"name\":\"sha1-asm\",\"optional\":true,\"req\":\"^0.5\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"}],\"features\":{\"asm\":[\"sha1-asm\"],\"compress\":[],\"default\":[\"std\"],\"force-soft\":[],\"loongarch64_asm\":[],\"oid\":[\"digest/oid\"],\"std\":[\"digest/std\"]}}", + "sha1_0.11.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"cpufeatures\",\"req\":\"^0.3\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86\\\", target_arch = \\\"x86_64\\\"))\"},{\"name\":\"digest\",\"req\":\"^0.11\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"}],\"features\":{\"alloc\":[\"digest/alloc\"],\"default\":[\"alloc\",\"oid\"],\"oid\":[\"digest/oid\"],\"zeroize\":[\"digest/zeroize\"]}}", "sha1_smol_1.0.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"openssl\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.4\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"alloc\":[],\"std\":[\"alloc\"]}}", "sha2_0.10.9": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"cpufeatures\",\"req\":\"^0.2\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"},{\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.10.7\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.2.2\"},{\"name\":\"sha2-asm\",\"optional\":true,\"req\":\"^0.6.1\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"}],\"features\":{\"asm\":[\"sha2-asm\"],\"asm-aarch64\":[\"asm\"],\"compress\":[],\"default\":[\"std\"],\"force-soft\":[],\"force-soft-compact\":[],\"loongarch64_asm\":[],\"oid\":[\"digest/oid\"],\"std\":[\"digest/std\"]}}", + "sha2_0.11.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\"},{\"name\":\"cpufeatures\",\"req\":\"^0.3\",\"target\":\"cfg(any(target_arch = \\\"aarch64\\\", target_arch = \\\"x86_64\\\", target_arch = \\\"x86\\\"))\"},{\"name\":\"digest\",\"req\":\"^0.11\"},{\"features\":[\"dev\"],\"kind\":\"dev\",\"name\":\"digest\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^1\"}],\"features\":{\"alloc\":[\"digest/alloc\"],\"default\":[\"alloc\",\"oid\"],\"oid\":[\"digest/oid\"],\"zeroize\":[\"digest/zeroize\"]}}", "sharded-slab_0.1.7": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"indexmap\",\"req\":\"^1\"},{\"name\":\"lazy_static\",\"req\":\"^1\"},{\"features\":[\"checkpoint\"],\"name\":\"loom\",\"optional\":true,\"req\":\"^0.5\",\"target\":\"cfg(loom)\"},{\"features\":[\"checkpoint\"],\"kind\":\"dev\",\"name\":\"loom\",\"req\":\"^0.5\",\"target\":\"cfg(loom)\"},{\"kind\":\"dev\",\"name\":\"memory-stats\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"slab\",\"req\":\"^0.4.2\"}],\"features\":{}}", "shared_library_0.1.9": "{\"dependencies\":[{\"name\":\"lazy_static\",\"req\":\"^1\"},{\"name\":\"libc\",\"req\":\"^0.2\"}],\"features\":{}}", "shell-words_1.1.1": "{\"dependencies\":[],\"features\":{\"default\":[\"std\"],\"std\":[]}}", @@ -1507,6 +1532,7 @@ "signal-hook_0.3.18": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^0.7\"},{\"name\":\"signal-hook-registry\",\"req\":\"^1.4\"}],\"features\":{\"channel\":[],\"default\":[\"channel\",\"iterator\"],\"extended-siginfo\":[\"channel\",\"iterator\",\"extended-siginfo-raw\"],\"extended-siginfo-raw\":[\"cc\"],\"iterator\":[\"channel\"]}}", "signature_2.2.0": "{\"dependencies\":[{\"name\":\"derive\",\"optional\":true,\"package\":\"signature_derive\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"digest\",\"optional\":true,\"req\":\"^0.10.6\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"rand_core\",\"optional\":true,\"req\":\"^0.6.4\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"}],\"features\":{\"alloc\":[],\"std\":[\"alloc\",\"rand_core?/std\"]}}", "simd-adler32_0.3.8": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"adler\",\"req\":\"^1.0.2\"},{\"kind\":\"dev\",\"name\":\"adler32\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"const-generics\":[],\"default\":[\"std\",\"const-generics\"],\"nightly\":[],\"std\":[]}}", + "simd_cesu8_1.1.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"cesu8\",\"req\":\"^1.1.0\"},{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"build\",\"name\":\"rustc_version\",\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"simdutf8\",\"req\":\"^0.1.4\"}],\"features\":{\"bench\":[],\"default\":[\"std\"],\"nightly\":[],\"std\":[\"simdutf8/std\"]}}", "simdutf8_0.1.5": "{\"dependencies\":[],\"features\":{\"aarch64_neon\":[],\"aarch64_neon_prefetch\":[],\"default\":[\"std\"],\"hints\":[],\"public_imp\":[],\"std\":[]}}", "similar_2.7.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bstr\",\"optional\":true,\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"console\",\"req\":\"^0.15.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.10.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.130\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.68\"},{\"name\":\"unicode-segmentation\",\"optional\":true,\"req\":\"^1.7.1\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1.1\"}],\"features\":{\"bytes\":[\"bstr\",\"text\"],\"default\":[\"text\"],\"inline\":[\"text\"],\"text\":[],\"unicode\":[\"text\",\"unicode-segmentation\",\"bstr?/unicode\",\"bstr?/std\"],\"wasm32_web_time\":[\"web-time\"]}}", "simple_asn1_0.6.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"default_features\":false,\"name\":\"thiserror\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"formatting\",\"macros\",\"parsing\"],\"name\":\"time\",\"req\":\"^0.3.47\"},{\"default_features\":false,\"features\":[\"formatting\",\"macros\",\"parsing\",\"quickcheck\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\"}],\"features\":{}}", @@ -1520,13 +1546,13 @@ "socket2_0.6.3": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.172\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\"))\"},{\"features\":[\"Win32_Foundation\",\"Win32_Networking_WinSock\",\"Win32_System_IO\",\"Win32_System_Threading\",\"Win32_System_WindowsProgramming\"],\"name\":\"windows-sys\",\"req\":\">=0.60, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"all\":[]}}", "spin_0.9.8": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\"},{\"name\":\"lock_api_crate\",\"optional\":true,\"package\":\"lock_api\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"portable-atomic\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"barrier\":[\"mutex\"],\"default\":[\"lock_api\",\"mutex\",\"spin_mutex\",\"rwlock\",\"once\",\"lazy\",\"barrier\"],\"fair_mutex\":[\"mutex\"],\"lazy\":[\"once\"],\"lock_api\":[\"lock_api_crate\"],\"mutex\":[],\"once\":[],\"portable_atomic\":[\"portable-atomic\"],\"rwlock\":[],\"spin_mutex\":[\"mutex\"],\"std\":[],\"ticket_mutex\":[\"mutex\"],\"use_ticket_mutex\":[\"mutex\",\"ticket_mutex\"]}}", "spki_0.7.3": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.2\"},{\"default_features\":false,\"name\":\"base64ct\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"oid\"],\"name\":\"der\",\"req\":\"^0.7.2\"},{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"}],\"features\":{\"alloc\":[\"base64ct?/alloc\",\"der/alloc\"],\"arbitrary\":[\"std\",\"dep:arbitrary\",\"der/arbitrary\"],\"base64\":[\"dep:base64ct\"],\"fingerprint\":[\"sha2\"],\"pem\":[\"alloc\",\"der/pem\"],\"std\":[\"der/std\",\"alloc\"]}}", - "sqlx-core_0.8.6": "{\"dependencies\":[{\"name\":\"async-io\",\"optional\":true,\"req\":\"^1.9.0\"},{\"name\":\"async-std\",\"optional\":true,\"req\":\"^1.12\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"base64\",\"req\":\"^0.22.0\"},{\"name\":\"bigdecimal\",\"optional\":true,\"req\":\"^0.4.0\"},{\"name\":\"bit-vec\",\"optional\":true,\"req\":\"^0.6.3\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"bstr\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"bytes\",\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"name\":\"crc\",\"optional\":true,\"req\":\"^3\"},{\"name\":\"crossbeam-queue\",\"req\":\"^0.3.2\"},{\"name\":\"either\",\"req\":\"^1.6.1\"},{\"name\":\"event-listener\",\"req\":\"^5.2.0\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.19\"},{\"name\":\"futures-intrusive\",\"req\":\"^0.5.0\"},{\"name\":\"futures-io\",\"req\":\"^0.3.24\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\",\"io\"],\"name\":\"futures-util\",\"req\":\"^0.3.19\"},{\"name\":\"hashbrown\",\"req\":\"^0.15.0\"},{\"name\":\"hashlink\",\"req\":\"^0.10.0\"},{\"name\":\"indexmap\",\"req\":\"^2.0\"},{\"name\":\"ipnet\",\"optional\":true,\"req\":\"^2.3.0\"},{\"name\":\"ipnetwork\",\"optional\":true,\"req\":\"^0.20.0\"},{\"default_features\":false,\"name\":\"log\",\"req\":\"^0.4.18\"},{\"name\":\"mac_address\",\"optional\":true,\"req\":\"^1.1.5\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.4.1\"},{\"name\":\"native-tls\",\"optional\":true,\"req\":\"^0.2.10\"},{\"name\":\"once_cell\",\"req\":\"^1.9.0\"},{\"name\":\"percent-encoding\",\"req\":\"^2.1.0\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.5.5\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rust_decimal\",\"optional\":true,\"req\":\"^1.26.1\"},{\"default_features\":false,\"features\":[\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.15\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8.0\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.132\"},{\"features\":[\"raw_value\"],\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.73\"},{\"default_features\":false,\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10.0\"},{\"name\":\"smallvec\",\"req\":\"^1.7.0\"},{\"default_features\":false,\"features\":[\"postgres\",\"sqlite\",\"mysql\",\"migrate\",\"macros\",\"time\",\"uuid\"],\"kind\":\"dev\",\"name\":\"sqlx\",\"req\":\"=0.8.6\"},{\"name\":\"thiserror\",\"req\":\"^2.0.0\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.36\"},{\"default_features\":false,\"features\":[\"time\",\"net\",\"sync\",\"fs\",\"io-util\",\"rt\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"fs\"],\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1.8\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"url\",\"req\":\"^2.2.2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.1.2\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^0.26\"}],\"features\":{\"_rt-async-std\":[\"async-std\",\"async-io\"],\"_rt-tokio\":[\"tokio\",\"tokio-stream\"],\"_tls-native-tls\":[\"native-tls\"],\"_tls-none\":[],\"_tls-rustls\":[\"rustls\"],\"_tls-rustls-aws-lc-rs\":[\"_tls-rustls\",\"rustls/aws-lc-rs\",\"webpki-roots\"],\"_tls-rustls-ring-native-roots\":[\"_tls-rustls\",\"rustls/ring\",\"rustls-native-certs\"],\"_tls-rustls-ring-webpki\":[\"_tls-rustls\",\"rustls/ring\",\"webpki-roots\"],\"any\":[],\"default\":[],\"json\":[\"serde\",\"serde_json\"],\"migrate\":[\"sha2\",\"crc\"],\"offline\":[\"serde\",\"either/serde\"]}}", - "sqlx-macros-core_0.8.6": "{\"dependencies\":[{\"name\":\"async-std\",\"optional\":true,\"req\":\"^1.12\"},{\"default_features\":false,\"name\":\"dotenvy\",\"req\":\"^0.15.7\"},{\"name\":\"either\",\"req\":\"^1.6.1\"},{\"name\":\"heck\",\"req\":\"^0.5\"},{\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"once_cell\",\"req\":\"^1.9.0\"},{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.79\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0.26\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.132\"},{\"name\":\"serde_json\",\"req\":\"^1.0.73\"},{\"name\":\"sha2\",\"req\":\"^0.10.0\"},{\"features\":[\"offline\"],\"name\":\"sqlx-core\",\"req\":\"=0.8.6\"},{\"features\":[\"offline\",\"migrate\"],\"name\":\"sqlx-mysql\",\"optional\":true,\"req\":\"=0.8.6\"},{\"features\":[\"offline\",\"migrate\"],\"name\":\"sqlx-postgres\",\"optional\":true,\"req\":\"=0.8.6\"},{\"features\":[\"offline\",\"migrate\"],\"name\":\"sqlx-sqlite\",\"optional\":true,\"req\":\"=0.8.6\"},{\"default_features\":false,\"features\":[\"full\",\"derive\",\"parsing\",\"printing\",\"clone-impls\"],\"name\":\"syn\",\"req\":\"^2.0.52\"},{\"default_features\":false,\"features\":[\"time\",\"net\",\"sync\",\"fs\",\"io-util\",\"rt\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"url\",\"req\":\"^2.2.2\"}],\"features\":{\"_rt-async-std\":[\"async-std\",\"sqlx-core/_rt-async-std\"],\"_rt-tokio\":[\"tokio\",\"sqlx-core/_rt-tokio\"],\"_sqlite\":[],\"_tls-native-tls\":[\"sqlx-core/_tls-native-tls\"],\"_tls-rustls-aws-lc-rs\":[\"sqlx-core/_tls-rustls-aws-lc-rs\"],\"_tls-rustls-ring-native-roots\":[\"sqlx-core/_tls-rustls-ring-native-roots\"],\"_tls-rustls-ring-webpki\":[\"sqlx-core/_tls-rustls-ring-webpki\"],\"bigdecimal\":[\"sqlx-core/bigdecimal\",\"sqlx-mysql?/bigdecimal\",\"sqlx-postgres?/bigdecimal\"],\"bit-vec\":[\"sqlx-core/bit-vec\",\"sqlx-postgres?/bit-vec\"],\"chrono\":[\"sqlx-core/chrono\",\"sqlx-mysql?/chrono\",\"sqlx-postgres?/chrono\",\"sqlx-sqlite?/chrono\"],\"default\":[],\"derive\":[],\"ipnet\":[\"sqlx-core/ipnet\",\"sqlx-postgres?/ipnet\"],\"ipnetwork\":[\"sqlx-core/ipnetwork\",\"sqlx-postgres?/ipnetwork\"],\"json\":[\"sqlx-core/json\",\"sqlx-mysql?/json\",\"sqlx-postgres?/json\",\"sqlx-sqlite?/json\"],\"mac_address\":[\"sqlx-core/mac_address\",\"sqlx-postgres?/mac_address\"],\"macros\":[],\"migrate\":[\"sqlx-core/migrate\"],\"mysql\":[\"sqlx-mysql\"],\"postgres\":[\"sqlx-postgres\"],\"rust_decimal\":[\"sqlx-core/rust_decimal\",\"sqlx-mysql?/rust_decimal\",\"sqlx-postgres?/rust_decimal\"],\"sqlite\":[\"_sqlite\",\"sqlx-sqlite/bundled\"],\"sqlite-unbundled\":[\"_sqlite\",\"sqlx-sqlite/unbundled\"],\"time\":[\"sqlx-core/time\",\"sqlx-mysql?/time\",\"sqlx-postgres?/time\",\"sqlx-sqlite?/time\"],\"uuid\":[\"sqlx-core/uuid\",\"sqlx-mysql?/uuid\",\"sqlx-postgres?/uuid\",\"sqlx-sqlite?/uuid\"]}}", - "sqlx-macros_0.8.6": "{\"dependencies\":[{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.36\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0.26\"},{\"features\":[\"any\"],\"name\":\"sqlx-core\",\"req\":\"=0.8.6\"},{\"name\":\"sqlx-macros-core\",\"req\":\"=0.8.6\"},{\"default_features\":false,\"features\":[\"parsing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0.52\"}],\"features\":{\"_rt-async-std\":[\"sqlx-macros-core/_rt-async-std\"],\"_rt-tokio\":[\"sqlx-macros-core/_rt-tokio\"],\"_tls-native-tls\":[\"sqlx-macros-core/_tls-native-tls\"],\"_tls-rustls-aws-lc-rs\":[\"sqlx-macros-core/_tls-rustls-aws-lc-rs\"],\"_tls-rustls-ring-native-roots\":[\"sqlx-macros-core/_tls-rustls-ring-native-roots\"],\"_tls-rustls-ring-webpki\":[\"sqlx-macros-core/_tls-rustls-ring-webpki\"],\"bigdecimal\":[\"sqlx-macros-core/bigdecimal\"],\"bit-vec\":[\"sqlx-macros-core/bit-vec\"],\"chrono\":[\"sqlx-macros-core/chrono\"],\"default\":[],\"derive\":[\"sqlx-macros-core/derive\"],\"ipnet\":[\"sqlx-macros-core/ipnet\"],\"ipnetwork\":[\"sqlx-macros-core/ipnetwork\"],\"json\":[\"sqlx-macros-core/json\"],\"mac_address\":[\"sqlx-macros-core/mac_address\"],\"macros\":[\"sqlx-macros-core/macros\"],\"migrate\":[\"sqlx-macros-core/migrate\"],\"mysql\":[\"sqlx-macros-core/mysql\"],\"postgres\":[\"sqlx-macros-core/postgres\"],\"rust_decimal\":[\"sqlx-macros-core/rust_decimal\"],\"sqlite\":[\"sqlx-macros-core/sqlite\"],\"sqlite-unbundled\":[\"sqlx-macros-core/sqlite-unbundled\"],\"time\":[\"sqlx-macros-core/time\"],\"uuid\":[\"sqlx-macros-core/uuid\"]}}", - "sqlx-mysql_0.8.6": "{\"dependencies\":[{\"name\":\"atoi\",\"req\":\"^2.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"base64\",\"req\":\"^0.22.0\"},{\"name\":\"bigdecimal\",\"optional\":true,\"req\":\"^0.4.0\"},{\"default_features\":false,\"features\":[\"serde\"],\"name\":\"bitflags\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"byteorder\",\"req\":\"^1.4.3\"},{\"name\":\"bytes\",\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"std\",\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"name\":\"crc\",\"req\":\"^3.0.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"digest\",\"req\":\"^0.10.0\"},{\"name\":\"dotenvy\",\"req\":\"^0.15.5\"},{\"name\":\"either\",\"req\":\"^1.6.1\"},{\"default_features\":false,\"features\":[\"sink\",\"alloc\",\"std\"],\"name\":\"futures-channel\",\"req\":\"^0.3.19\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.19\"},{\"name\":\"futures-io\",\"req\":\"^0.3.24\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\",\"io\"],\"name\":\"futures-util\",\"req\":\"^0.3.19\"},{\"default_features\":false,\"name\":\"generic-array\",\"req\":\"^0.14.4\"},{\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"hkdf\",\"req\":\"^0.12.0\"},{\"default_features\":false,\"name\":\"hmac\",\"req\":\"^0.12.0\"},{\"name\":\"itoa\",\"req\":\"^1.0.1\"},{\"name\":\"log\",\"req\":\"^0.4.18\"},{\"default_features\":false,\"name\":\"md-5\",\"req\":\"^0.10.0\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.4.1\"},{\"name\":\"once_cell\",\"req\":\"^1.9.0\"},{\"name\":\"percent-encoding\",\"req\":\"^2.1.0\"},{\"default_features\":false,\"features\":[\"std\",\"std_rng\"],\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"name\":\"rsa\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rust_decimal\",\"optional\":true,\"req\":\"^1.26.1\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.144\"},{\"default_features\":false,\"name\":\"sha1\",\"req\":\"^0.10.1\"},{\"default_features\":false,\"name\":\"sha2\",\"req\":\"^0.10.0\"},{\"name\":\"smallvec\",\"req\":\"^1.7.0\"},{\"default_features\":false,\"features\":[\"mysql\"],\"kind\":\"dev\",\"name\":\"sqlx\",\"req\":\"=0.8.6\"},{\"name\":\"sqlx-core\",\"req\":\"=0.8.6\"},{\"name\":\"stringprep\",\"req\":\"^0.1.2\"},{\"name\":\"thiserror\",\"req\":\"^2.0.0\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.36\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.1.2\"},{\"default_features\":false,\"name\":\"whoami\",\"req\":\"^1.2.1\"}],\"features\":{\"any\":[\"sqlx-core/any\"],\"bigdecimal\":[\"dep:bigdecimal\",\"sqlx-core/bigdecimal\"],\"chrono\":[\"dep:chrono\",\"sqlx-core/chrono\"],\"json\":[\"sqlx-core/json\",\"serde\"],\"migrate\":[\"sqlx-core/migrate\"],\"offline\":[\"sqlx-core/offline\",\"serde/derive\"],\"rust_decimal\":[\"dep:rust_decimal\",\"rust_decimal/maths\",\"sqlx-core/rust_decimal\"],\"time\":[\"dep:time\",\"sqlx-core/time\"],\"uuid\":[\"dep:uuid\",\"sqlx-core/uuid\"]}}", - "sqlx-postgres_0.8.6": "{\"dependencies\":[{\"name\":\"atoi\",\"req\":\"^2.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"base64\",\"req\":\"^0.22.0\"},{\"name\":\"bigdecimal\",\"optional\":true,\"req\":\"^0.4.0\"},{\"name\":\"bit-vec\",\"optional\":true,\"req\":\"^0.6.3\"},{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"byteorder\",\"req\":\"^1.4.3\"},{\"default_features\":false,\"features\":[\"std\",\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"name\":\"crc\",\"req\":\"^3.0.0\"},{\"default_features\":false,\"name\":\"dotenvy\",\"req\":\"^0.15.7\"},{\"name\":\"etcetera\",\"req\":\"^0.8.0\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"default_features\":false,\"features\":[\"sink\",\"alloc\",\"std\"],\"name\":\"futures-channel\",\"req\":\"^0.3.19\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.19\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\",\"io\"],\"name\":\"futures-util\",\"req\":\"^0.3.19\"},{\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"hkdf\",\"req\":\"^0.12.0\"},{\"default_features\":false,\"features\":[\"reset\"],\"name\":\"hmac\",\"req\":\"^0.12.0\"},{\"name\":\"home\",\"req\":\"^0.5.5\"},{\"name\":\"ipnet\",\"optional\":true,\"req\":\"^2.3.0\"},{\"name\":\"ipnetwork\",\"optional\":true,\"req\":\"^0.20.0\"},{\"name\":\"itoa\",\"req\":\"^1.0.1\"},{\"name\":\"log\",\"req\":\"^0.4.18\"},{\"name\":\"mac_address\",\"optional\":true,\"req\":\"^1.1.5\"},{\"default_features\":false,\"name\":\"md-5\",\"req\":\"^0.10.0\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.4.1\"},{\"name\":\"num-bigint\",\"optional\":true,\"req\":\"^0.4.3\"},{\"name\":\"once_cell\",\"req\":\"^1.9.0\"},{\"default_features\":false,\"features\":[\"std\",\"std_rng\"],\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rust_decimal\",\"optional\":true,\"req\":\"^1.26.1\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.144\"},{\"features\":[\"raw_value\"],\"name\":\"serde_json\",\"req\":\"^1.0.85\"},{\"default_features\":false,\"name\":\"sha2\",\"req\":\"^0.10.0\"},{\"features\":[\"serde\"],\"name\":\"smallvec\",\"req\":\"^1.7.0\"},{\"default_features\":false,\"features\":[\"postgres\",\"derive\"],\"kind\":\"dev\",\"name\":\"sqlx\",\"req\":\"=0.8.6\"},{\"features\":[\"json\"],\"name\":\"sqlx-core\",\"req\":\"=0.8.6\"},{\"name\":\"stringprep\",\"req\":\"^0.1.2\"},{\"name\":\"thiserror\",\"req\":\"^2.0.0\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.36\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.1.2\"},{\"default_features\":false,\"name\":\"whoami\",\"req\":\"^1.2.1\"}],\"features\":{\"any\":[\"sqlx-core/any\"],\"bigdecimal\":[\"dep:bigdecimal\",\"dep:num-bigint\",\"sqlx-core/bigdecimal\"],\"bit-vec\":[\"dep:bit-vec\",\"sqlx-core/bit-vec\"],\"chrono\":[\"dep:chrono\",\"sqlx-core/chrono\"],\"ipnet\":[\"dep:ipnet\",\"sqlx-core/ipnet\"],\"ipnetwork\":[\"dep:ipnetwork\",\"sqlx-core/ipnetwork\"],\"json\":[\"sqlx-core/json\"],\"mac_address\":[\"dep:mac_address\",\"sqlx-core/mac_address\"],\"migrate\":[\"sqlx-core/migrate\"],\"offline\":[\"sqlx-core/offline\"],\"rust_decimal\":[\"dep:rust_decimal\",\"rust_decimal/maths\",\"sqlx-core/rust_decimal\"],\"time\":[\"dep:time\",\"sqlx-core/time\"],\"uuid\":[\"dep:uuid\",\"sqlx-core/uuid\"]}}", - "sqlx-sqlite_0.8.6": "{\"dependencies\":[{\"name\":\"atoi\",\"req\":\"^2.0\"},{\"default_features\":false,\"features\":[\"std\",\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"default_features\":false,\"features\":[\"async\"],\"name\":\"flume\",\"req\":\"^0.11.0\"},{\"default_features\":false,\"features\":[\"sink\",\"alloc\",\"std\"],\"name\":\"futures-channel\",\"req\":\"^0.3.19\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.19\"},{\"name\":\"futures-executor\",\"req\":\"^0.3.19\"},{\"name\":\"futures-intrusive\",\"req\":\"^0.5.0\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\"],\"name\":\"futures-util\",\"req\":\"^0.3.19\"},{\"default_features\":false,\"features\":[\"pkg-config\",\"vcpkg\",\"unlock_notify\"],\"name\":\"libsqlite3-sys\",\"req\":\"^0.30.1\"},{\"name\":\"log\",\"req\":\"^0.4.18\"},{\"name\":\"percent-encoding\",\"req\":\"^2.1.0\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.5.5\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.145\"},{\"name\":\"serde_urlencoded\",\"req\":\"^0.7\"},{\"default_features\":false,\"features\":[\"macros\",\"runtime-tokio\",\"tls-none\",\"sqlite\"],\"kind\":\"dev\",\"name\":\"sqlx\",\"req\":\"=0.8.6\"},{\"name\":\"sqlx-core\",\"req\":\"=0.8.6\"},{\"name\":\"thiserror\",\"req\":\"^2.0.0\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.36\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"url\",\"req\":\"^2.2.2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.1.2\"}],\"features\":{\"any\":[\"sqlx-core/any\"],\"bundled\":[\"libsqlite3-sys/bundled\"],\"chrono\":[\"dep:chrono\",\"sqlx-core/chrono\"],\"json\":[\"sqlx-core/json\",\"serde\"],\"migrate\":[\"sqlx-core/migrate\"],\"offline\":[\"sqlx-core/offline\",\"serde\"],\"preupdate-hook\":[\"libsqlite3-sys/preupdate_hook\"],\"regexp\":[\"dep:regex\"],\"time\":[\"dep:time\",\"sqlx-core/time\"],\"unbundled\":[\"libsqlite3-sys/buildtime_bindgen\"],\"uuid\":[\"dep:uuid\",\"sqlx-core/uuid\"]}}", - "sqlx_0.8.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.52\"},{\"features\":[\"attributes\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.12\"},{\"features\":[\"async_tokio\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"dotenvy\",\"req\":\"^0.15.0\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"futures\",\"req\":\"^0.3.19\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"libsqlite3-sys\",\"req\":\"^0.30.1\"},{\"features\":[\"bundled-sqlcipher\"],\"kind\":\"dev\",\"name\":\"libsqlite3-sys\",\"req\":\"^0.30.1\",\"target\":\"cfg(sqlite_test_sqlcipher)\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.6\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"kind\":\"dev\",\"name\":\"rand_xoshiro\",\"req\":\"^0.6.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.132\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.73\"},{\"features\":[\"offline\",\"migrate\"],\"name\":\"sqlx-core\",\"req\":\"=0.8.6\"},{\"name\":\"sqlx-macros\",\"optional\":true,\"req\":\"=0.8.6\"},{\"name\":\"sqlx-mysql\",\"optional\":true,\"req\":\"=0.8.6\"},{\"name\":\"sqlx-postgres\",\"optional\":true,\"req\":\"=0.8.6\"},{\"name\":\"sqlx-sqlite\",\"optional\":true,\"req\":\"=0.8.6\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.1\"},{\"kind\":\"dev\",\"name\":\"time_\",\"package\":\"time\",\"req\":\"^0.3.2\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.15.0\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.53\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2.2.2\"}],\"features\":{\"_rt-async-std\":[],\"_rt-tokio\":[],\"_sqlite\":[],\"_unstable-all-types\":[\"bigdecimal\",\"rust_decimal\",\"json\",\"time\",\"chrono\",\"ipnet\",\"ipnetwork\",\"mac_address\",\"uuid\",\"bit-vec\",\"bstr\"],\"all-databases\":[\"mysql\",\"sqlite\",\"postgres\",\"any\"],\"any\":[\"sqlx-core/any\",\"sqlx-mysql?/any\",\"sqlx-postgres?/any\",\"sqlx-sqlite?/any\"],\"bigdecimal\":[\"sqlx-core/bigdecimal\",\"sqlx-macros?/bigdecimal\",\"sqlx-mysql?/bigdecimal\",\"sqlx-postgres?/bigdecimal\"],\"bit-vec\":[\"sqlx-core/bit-vec\",\"sqlx-macros?/bit-vec\",\"sqlx-postgres?/bit-vec\"],\"bstr\":[\"sqlx-core/bstr\"],\"chrono\":[\"sqlx-core/chrono\",\"sqlx-macros?/chrono\",\"sqlx-mysql?/chrono\",\"sqlx-postgres?/chrono\",\"sqlx-sqlite?/chrono\"],\"default\":[\"any\",\"macros\",\"migrate\",\"json\"],\"derive\":[\"sqlx-macros/derive\"],\"ipnet\":[\"sqlx-core/ipnet\",\"sqlx-macros?/ipnet\",\"sqlx-postgres?/ipnet\"],\"ipnetwork\":[\"sqlx-core/ipnetwork\",\"sqlx-macros?/ipnetwork\",\"sqlx-postgres?/ipnetwork\"],\"json\":[\"sqlx-core/json\",\"sqlx-macros?/json\",\"sqlx-mysql?/json\",\"sqlx-postgres?/json\",\"sqlx-sqlite?/json\"],\"mac_address\":[\"sqlx-core/mac_address\",\"sqlx-macros?/mac_address\",\"sqlx-postgres?/mac_address\"],\"macros\":[\"derive\",\"sqlx-macros/macros\"],\"migrate\":[\"sqlx-core/migrate\",\"sqlx-macros?/migrate\",\"sqlx-mysql?/migrate\",\"sqlx-postgres?/migrate\",\"sqlx-sqlite?/migrate\"],\"mysql\":[\"sqlx-mysql\",\"sqlx-macros?/mysql\"],\"postgres\":[\"sqlx-postgres\",\"sqlx-macros?/postgres\"],\"regexp\":[\"sqlx-sqlite?/regexp\"],\"runtime-async-std\":[\"_rt-async-std\",\"sqlx-core/_rt-async-std\",\"sqlx-macros?/_rt-async-std\"],\"runtime-async-std-native-tls\":[\"runtime-async-std\",\"tls-native-tls\"],\"runtime-async-std-rustls\":[\"runtime-async-std\",\"tls-rustls-ring\"],\"runtime-tokio\":[\"_rt-tokio\",\"sqlx-core/_rt-tokio\",\"sqlx-macros?/_rt-tokio\"],\"runtime-tokio-native-tls\":[\"runtime-tokio\",\"tls-native-tls\"],\"runtime-tokio-rustls\":[\"runtime-tokio\",\"tls-rustls-ring\"],\"rust_decimal\":[\"sqlx-core/rust_decimal\",\"sqlx-macros?/rust_decimal\",\"sqlx-mysql?/rust_decimal\",\"sqlx-postgres?/rust_decimal\"],\"sqlite\":[\"_sqlite\",\"sqlx-sqlite/bundled\",\"sqlx-macros?/sqlite\"],\"sqlite-preupdate-hook\":[\"sqlx-sqlite/preupdate-hook\"],\"sqlite-unbundled\":[\"_sqlite\",\"sqlx-sqlite/unbundled\",\"sqlx-macros?/sqlite-unbundled\"],\"time\":[\"sqlx-core/time\",\"sqlx-macros?/time\",\"sqlx-mysql?/time\",\"sqlx-postgres?/time\",\"sqlx-sqlite?/time\"],\"tls-native-tls\":[\"sqlx-core/_tls-native-tls\",\"sqlx-macros?/_tls-native-tls\"],\"tls-none\":[],\"tls-rustls\":[\"tls-rustls-ring\"],\"tls-rustls-aws-lc-rs\":[\"sqlx-core/_tls-rustls-aws-lc-rs\",\"sqlx-macros?/_tls-rustls-aws-lc-rs\"],\"tls-rustls-ring\":[\"tls-rustls-ring-webpki\"],\"tls-rustls-ring-native-roots\":[\"sqlx-core/_tls-rustls-ring-native-roots\",\"sqlx-macros?/_tls-rustls-ring-native-roots\"],\"tls-rustls-ring-webpki\":[\"sqlx-core/_tls-rustls-ring-webpki\",\"sqlx-macros?/_tls-rustls-ring-webpki\"],\"uuid\":[\"sqlx-core/uuid\",\"sqlx-macros?/uuid\",\"sqlx-mysql?/uuid\",\"sqlx-postgres?/uuid\",\"sqlx-sqlite?/uuid\"]}}", + "sqlx-core_0.9.0": "{\"dependencies\":[{\"name\":\"async-fs\",\"optional\":true,\"req\":\"^2.1\"},{\"default_features\":false,\"features\":[\"async-io\"],\"name\":\"async-global-executor\",\"optional\":true,\"req\":\"^3.1\"},{\"name\":\"async-io\",\"optional\":true,\"req\":\"^2.4.1\"},{\"name\":\"async-std\",\"optional\":true,\"req\":\"^1.13\"},{\"name\":\"async-task\",\"optional\":true,\"req\":\"^4.7.1\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"base64\",\"req\":\"^0.22.1\"},{\"name\":\"bigdecimal\",\"optional\":true,\"req\":\"^0.4.0\"},{\"name\":\"bit-vec\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"bstr\",\"optional\":true,\"req\":\"^1.0.1\"},{\"name\":\"bytes\",\"req\":\"^1.2.0\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"std\",\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"name\":\"crc\",\"optional\":true,\"req\":\"^3\"},{\"name\":\"crossbeam-queue\",\"req\":\"^0.3.2\"},{\"name\":\"either\",\"req\":\"^1.6.1\"},{\"name\":\"event-listener\",\"req\":\"^5.2.0\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.32\"},{\"name\":\"futures-intrusive\",\"req\":\"^0.5.0\"},{\"name\":\"futures-io\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\",\"io\"],\"name\":\"futures-util\",\"req\":\"^0.3.32\"},{\"name\":\"hashbrown\",\"req\":\"^0.16.0\"},{\"name\":\"hashlink\",\"req\":\"^0.11.0\"},{\"name\":\"indexmap\",\"req\":\"^2.0\"},{\"name\":\"ipnet\",\"optional\":true,\"req\":\"^2.3.0\"},{\"name\":\"ipnetwork\",\"optional\":true,\"req\":\"^0.21.1\"},{\"default_features\":false,\"name\":\"log\",\"req\":\"^0.4.18\"},{\"name\":\"mac_address\",\"optional\":true,\"req\":\"^1.1.5\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.5.0\"},{\"name\":\"native-tls\",\"optional\":true,\"req\":\"^0.2.10\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rust_decimal\",\"optional\":true,\"req\":\"^1.36.0\"},{\"default_features\":false,\"features\":[\"std\",\"tls12\"],\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.23.24\"},{\"name\":\"rustls-native-certs\",\"optional\":true,\"req\":\"^0.8.0\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.219\"},{\"features\":[\"raw_value\"],\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.142\"},{\"default_features\":false,\"name\":\"sha2\",\"optional\":true,\"req\":\"^0.10.0\"},{\"name\":\"smallvec\",\"req\":\"^1.13.1\"},{\"default_features\":false,\"name\":\"smol\",\"optional\":true,\"req\":\"^2.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"thiserror\",\"req\":\"^2.0.18\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.47\"},{\"default_features\":false,\"features\":[\"time\",\"net\",\"sync\",\"fs\",\"io-util\",\"rt\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.25.0\"},{\"features\":[\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.25.0\"},{\"features\":[\"fs\"],\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1.8\"},{\"name\":\"toml\",\"optional\":true,\"req\":\"^0.8.16\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"url\",\"req\":\"^2.2.2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.12.1\"},{\"name\":\"webpki-roots\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"_rt-async-global-executor\":[\"async-global-executor\",\"_rt-async-io\",\"_rt-async-task\"],\"_rt-async-io\":[\"async-io\",\"async-fs\"],\"_rt-async-std\":[\"async-std\",\"_rt-async-io\"],\"_rt-async-task\":[\"async-task\"],\"_rt-smol\":[\"smol\",\"_rt-async-io\",\"_rt-async-task\"],\"_rt-tokio\":[\"tokio\",\"tokio-stream\"],\"_tls-native-tls\":[\"native-tls\"],\"_tls-none\":[],\"_tls-rustls\":[\"rustls\"],\"_tls-rustls-aws-lc-rs\":[\"_tls-rustls\",\"rustls/aws-lc-rs\",\"webpki-roots\"],\"_tls-rustls-ring-native-roots\":[\"_tls-rustls\",\"rustls/ring\",\"rustls-native-certs\"],\"_tls-rustls-ring-webpki\":[\"_tls-rustls\",\"rustls/ring\",\"webpki-roots\"],\"_unstable-doc\":[\"sqlx-toml\"],\"any\":[],\"default\":[],\"json\":[\"serde\",\"serde_json\"],\"migrate\":[\"sha2\",\"crc\"],\"offline\":[\"serde\",\"either/serde\"],\"sqlx-toml\":[\"serde\",\"toml/parse\"]}}", + "sqlx-macros-core_0.9.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"async-io\"],\"name\":\"async-global-executor\",\"optional\":true,\"req\":\"^3.1\"},{\"name\":\"async-std\",\"optional\":true,\"req\":\"^1.13\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"dotenvy\",\"req\":\"^0.15.7\"},{\"name\":\"either\",\"req\":\"^1.6.1\"},{\"name\":\"heck\",\"req\":\"^0.5\"},{\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.83\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.219\"},{\"name\":\"serde_json\",\"req\":\"^1.0.142\"},{\"name\":\"sha2\",\"req\":\"^0.10.0\"},{\"default_features\":false,\"name\":\"smol\",\"optional\":true,\"req\":\"^2.0\"},{\"features\":[\"offline\"],\"name\":\"sqlx-core\",\"req\":\"=0.9.0\"},{\"default_features\":false,\"features\":[\"offline\",\"migrate\"],\"name\":\"sqlx-mysql\",\"optional\":true,\"req\":\"=0.9.0\"},{\"features\":[\"offline\",\"migrate\"],\"name\":\"sqlx-postgres\",\"optional\":true,\"req\":\"=0.9.0\"},{\"features\":[\"offline\",\"migrate\"],\"name\":\"sqlx-sqlite\",\"optional\":true,\"req\":\"=0.9.0\"},{\"default_features\":false,\"features\":[\"full\",\"derive\",\"parsing\",\"printing\",\"clone-impls\"],\"name\":\"syn\",\"req\":\"^2.0.87\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"thiserror\",\"optional\":true,\"req\":\"^2.0.18\"},{\"default_features\":false,\"features\":[\"time\",\"net\",\"sync\",\"fs\",\"io-util\",\"rt\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.25.0\"},{\"name\":\"url\",\"req\":\"^2.2.2\"}],\"features\":{\"_rt-async-global-executor\":[\"async-global-executor\",\"sqlx-core/_rt-async-global-executor\"],\"_rt-async-std\":[\"async-std\",\"sqlx-core/_rt-async-std\"],\"_rt-smol\":[\"smol\",\"sqlx-core/_rt-smol\"],\"_rt-tokio\":[\"tokio\",\"sqlx-core/_rt-tokio\"],\"_sqlite\":[],\"_tls-native-tls\":[\"sqlx-core/_tls-native-tls\"],\"_tls-rustls-aws-lc-rs\":[\"sqlx-core/_tls-rustls-aws-lc-rs\"],\"_tls-rustls-ring-native-roots\":[\"sqlx-core/_tls-rustls-ring-native-roots\"],\"_tls-rustls-ring-webpki\":[\"sqlx-core/_tls-rustls-ring-webpki\"],\"bigdecimal\":[\"sqlx-core/bigdecimal\",\"sqlx-mysql?/bigdecimal\",\"sqlx-postgres?/bigdecimal\"],\"bit-vec\":[\"sqlx-core/bit-vec\",\"sqlx-postgres?/bit-vec\"],\"chrono\":[\"sqlx-core/chrono\",\"sqlx-mysql?/chrono\",\"sqlx-postgres?/chrono\",\"sqlx-sqlite?/chrono\"],\"default\":[],\"derive\":[],\"ipnet\":[\"sqlx-core/ipnet\",\"sqlx-postgres?/ipnet\"],\"ipnetwork\":[\"sqlx-core/ipnetwork\",\"sqlx-postgres?/ipnetwork\"],\"json\":[\"sqlx-core/json\",\"sqlx-mysql?/json\",\"sqlx-postgres?/json\",\"sqlx-sqlite?/json\"],\"mac_address\":[\"sqlx-core/mac_address\",\"sqlx-postgres?/mac_address\"],\"macros\":[\"thiserror\"],\"migrate\":[\"sqlx-core/migrate\"],\"mysql\":[\"sqlx-mysql\"],\"mysql-rsa\":[\"mysql\",\"sqlx-mysql/rsa\"],\"postgres\":[\"sqlx-postgres\"],\"rust_decimal\":[\"sqlx-core/rust_decimal\",\"sqlx-mysql?/rust_decimal\",\"sqlx-postgres?/rust_decimal\"],\"sqlite\":[\"_sqlite\",\"sqlx-sqlite/bundled\"],\"sqlite-load-extension\":[\"sqlx-sqlite/load-extension\"],\"sqlite-unbundled\":[\"_sqlite\",\"sqlx-sqlite/unbundled\"],\"sqlx-toml\":[\"sqlx-core/sqlx-toml\",\"sqlx-sqlite?/sqlx-toml\"],\"time\":[\"sqlx-core/time\",\"sqlx-mysql?/time\",\"sqlx-postgres?/time\",\"sqlx-sqlite?/time\"],\"uuid\":[\"sqlx-core/uuid\",\"sqlx-mysql?/uuid\",\"sqlx-postgres?/uuid\",\"sqlx-sqlite?/uuid\"]}}", + "sqlx-macros_0.9.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"proc-macro2\",\"req\":\"^1.0.83\"},{\"default_features\":false,\"name\":\"quote\",\"req\":\"^1.0.35\"},{\"features\":[\"any\"],\"name\":\"sqlx-core\",\"req\":\"=0.9.0\"},{\"name\":\"sqlx-macros-core\",\"req\":\"=0.9.0\"},{\"default_features\":false,\"features\":[\"parsing\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0.87\"}],\"features\":{\"_rt-async-global-executor\":[\"sqlx-macros-core/_rt-async-global-executor\"],\"_rt-async-std\":[\"sqlx-macros-core/_rt-async-std\"],\"_rt-smol\":[\"sqlx-macros-core/_rt-smol\"],\"_rt-tokio\":[\"sqlx-macros-core/_rt-tokio\"],\"_tls-native-tls\":[\"sqlx-macros-core/_tls-native-tls\"],\"_tls-rustls-aws-lc-rs\":[\"sqlx-macros-core/_tls-rustls-aws-lc-rs\"],\"_tls-rustls-ring-native-roots\":[\"sqlx-macros-core/_tls-rustls-ring-native-roots\"],\"_tls-rustls-ring-webpki\":[\"sqlx-macros-core/_tls-rustls-ring-webpki\"],\"bigdecimal\":[\"sqlx-macros-core/bigdecimal\"],\"bit-vec\":[\"sqlx-macros-core/bit-vec\"],\"chrono\":[\"sqlx-macros-core/chrono\"],\"default\":[],\"derive\":[\"sqlx-macros-core/derive\"],\"ipnet\":[\"sqlx-macros-core/ipnet\"],\"ipnetwork\":[\"sqlx-macros-core/ipnetwork\"],\"json\":[\"sqlx-macros-core/json\"],\"mac_address\":[\"sqlx-macros-core/mac_address\"],\"macros\":[\"sqlx-macros-core/macros\"],\"migrate\":[\"sqlx-macros-core/migrate\"],\"mysql\":[\"sqlx-macros-core/mysql\"],\"mysql-rsa\":[\"sqlx-macros-core/mysql-rsa\"],\"postgres\":[\"sqlx-macros-core/postgres\"],\"rust_decimal\":[\"sqlx-macros-core/rust_decimal\"],\"sqlite\":[\"sqlx-macros-core/sqlite\"],\"sqlite-load-extension\":[\"sqlx-macros-core/sqlite-load-extension\"],\"sqlite-unbundled\":[\"sqlx-macros-core/sqlite-unbundled\"],\"sqlx-toml\":[\"sqlx-macros-core/sqlx-toml\"],\"time\":[\"sqlx-macros-core/time\"],\"uuid\":[\"sqlx-macros-core/uuid\"]}}", + "sqlx-mysql_0.9.0": "{\"dependencies\":[{\"name\":\"bigdecimal\",\"optional\":true,\"req\":\"^0.4.0\"},{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"byteorder\",\"req\":\"^1.4.3\"},{\"name\":\"bytes\",\"req\":\"^1.2.0\"},{\"default_features\":false,\"features\":[\"std\",\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"name\":\"crc\",\"optional\":true,\"req\":\"^3.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"digest\",\"req\":\"^0.11.2\"},{\"default_features\":false,\"name\":\"dotenvy\",\"req\":\"^0.15.7\"},{\"name\":\"either\",\"req\":\"^1.6.1\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\",\"io\"],\"name\":\"futures-util\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"generic-array\",\"req\":\"^0.14.4\"},{\"name\":\"log\",\"req\":\"^0.4.18\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3.0\"},{\"default_features\":false,\"features\":[\"thread_rng\"],\"name\":\"rand\",\"optional\":true,\"req\":\"^0.10.1\"},{\"name\":\"rsa\",\"optional\":true,\"req\":\"^0.10.0-rc.18\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rust_decimal\",\"optional\":true,\"req\":\"^1.36.0\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.219\"},{\"default_features\":false,\"name\":\"sha1\",\"req\":\"^0.11.0\"},{\"default_features\":false,\"name\":\"sha2\",\"req\":\"^0.11.0\"},{\"name\":\"sqlx-core\",\"req\":\"=0.9.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"thiserror\",\"req\":\"^2.0.18\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.47\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.12.1\"}],\"features\":{\"any\":[\"sqlx-core/any\"],\"bigdecimal\":[\"dep:bigdecimal\",\"sqlx-core/bigdecimal\"],\"chrono\":[\"dep:chrono\",\"sqlx-core/chrono\"],\"default\":[],\"json\":[\"sqlx-core/json\",\"serde\"],\"migrate\":[\"sqlx-core/migrate\",\"dep:crc\"],\"offline\":[\"sqlx-core/offline\",\"serde/derive\",\"bitflags/serde\"],\"rsa\":[\"dep:rand\",\"dep:rsa\"],\"rust_decimal\":[\"dep:rust_decimal\",\"rust_decimal/maths\",\"sqlx-core/rust_decimal\"],\"time\":[\"dep:time\",\"sqlx-core/time\"],\"uuid\":[\"dep:uuid\",\"sqlx-core/uuid\"]}}", + "sqlx-postgres_0.9.0": "{\"dependencies\":[{\"name\":\"atoi\",\"req\":\"^2.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"base64\",\"req\":\"^0.22.1\"},{\"name\":\"bigdecimal\",\"optional\":true,\"req\":\"^0.4.0\"},{\"name\":\"bit-vec\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"bitflags\",\"req\":\"^2.4\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"byteorder\",\"req\":\"^1.4.3\"},{\"default_features\":false,\"features\":[\"std\",\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"name\":\"crc\",\"optional\":true,\"req\":\"^3.0.0\"},{\"default_features\":false,\"name\":\"dotenvy\",\"req\":\"^0.15.7\"},{\"name\":\"etcetera\",\"req\":\"^0.11.0\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"default_features\":false,\"features\":[\"sink\",\"alloc\",\"std\"],\"name\":\"futures-channel\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\",\"io\"],\"name\":\"futures-util\",\"req\":\"^0.3.32\"},{\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"hkdf\",\"req\":\"^0.13.0\"},{\"default_features\":false,\"name\":\"hmac\",\"req\":\"^0.13.0\"},{\"name\":\"ipnet\",\"optional\":true,\"req\":\"^2.3.0\"},{\"name\":\"ipnetwork\",\"optional\":true,\"req\":\"^0.21.1\"},{\"name\":\"itoa\",\"req\":\"^1.0.5\"},{\"name\":\"log\",\"req\":\"^0.4.18\"},{\"name\":\"mac_address\",\"optional\":true,\"req\":\"^1.1.5\"},{\"default_features\":false,\"name\":\"md-5\",\"req\":\"^0.11.0\"},{\"default_features\":false,\"name\":\"memchr\",\"req\":\"^2.5.0\"},{\"name\":\"num-bigint\",\"optional\":true,\"req\":\"^0.4.3\"},{\"default_features\":false,\"features\":[\"thread_rng\"],\"name\":\"rand\",\"req\":\"^0.10.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rust_decimal\",\"optional\":true,\"req\":\"^1.36.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.219\"},{\"features\":[\"raw_value\"],\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.142\"},{\"default_features\":false,\"name\":\"sha2\",\"req\":\"^0.11.0\"},{\"name\":\"smallvec\",\"req\":\"^1.13.1\"},{\"name\":\"sqlx-core\",\"req\":\"=0.9.0\"},{\"name\":\"stringprep\",\"req\":\"^0.1.2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"thiserror\",\"req\":\"^2.0.18\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.47\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.12.1\"},{\"default_features\":false,\"name\":\"whoami\",\"req\":\"^2.0.2\"}],\"features\":{\"any\":[\"sqlx-core/any\"],\"bigdecimal\":[\"dep:bigdecimal\",\"dep:num-bigint\",\"sqlx-core/bigdecimal\"],\"bit-vec\":[\"dep:bit-vec\",\"sqlx-core/bit-vec\"],\"chrono\":[\"dep:chrono\",\"sqlx-core/chrono\"],\"ipnet\":[\"dep:ipnet\",\"sqlx-core/ipnet\"],\"ipnetwork\":[\"dep:ipnetwork\",\"sqlx-core/ipnetwork\"],\"json\":[\"dep:serde\",\"dep:serde_json\",\"sqlx-core/json\"],\"mac_address\":[\"dep:mac_address\",\"sqlx-core/mac_address\"],\"migrate\":[\"sqlx-core/migrate\",\"dep:crc\"],\"offline\":[\"json\",\"sqlx-core/offline\",\"smallvec/serde\"],\"rust_decimal\":[\"dep:rust_decimal\",\"rust_decimal/maths\",\"sqlx-core/rust_decimal\"],\"time\":[\"dep:time\",\"sqlx-core/time\"],\"uuid\":[\"dep:uuid\",\"sqlx-core/uuid\"]}}", + "sqlx-sqlite_0.9.0": "{\"dependencies\":[{\"name\":\"atoi\",\"req\":\"^2.0\"},{\"default_features\":false,\"features\":[\"std\",\"clock\"],\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.34\"},{\"default_features\":false,\"features\":[\"async\"],\"name\":\"flume\",\"req\":\"^0.12.0\"},{\"name\":\"form_urlencoded\",\"req\":\"^1.2.2\"},{\"default_features\":false,\"features\":[\"sink\",\"alloc\",\"std\"],\"name\":\"futures-channel\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"name\":\"futures-core\",\"req\":\"^0.3.32\"},{\"name\":\"futures-executor\",\"req\":\"^0.3.32\"},{\"name\":\"futures-intrusive\",\"req\":\"^0.5.0\"},{\"default_features\":false,\"features\":[\"alloc\",\"sink\"],\"name\":\"futures-util\",\"req\":\"^0.3.32\"},{\"default_features\":false,\"features\":[\"pkg-config\",\"vcpkg\"],\"name\":\"libsqlite3-sys\",\"req\":\">=0.30.1, <0.38.0\"},{\"name\":\"log\",\"req\":\"^0.4.18\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3.0\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.6.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.219\"},{\"name\":\"sqlx-core\",\"req\":\"=0.9.0\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"thiserror\",\"req\":\"^2.0.18\"},{\"features\":[\"formatting\",\"parsing\",\"macros\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.47\"},{\"features\":[\"log\"],\"name\":\"tracing\",\"req\":\"^0.1.37\"},{\"name\":\"url\",\"req\":\"^2.2.2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1.12.1\"}],\"features\":{\"_unstable-all-sqlite-features\":[\"deserialize\",\"load-extension\",\"preupdate-hook\",\"unlock-notify\"],\"_unstable-all-types\":[\"json\",\"chrono\",\"time\",\"uuid\"],\"_unstable-docs\":[\"bundled\",\"any\",\"_unstable-all-types\",\"_unstable-all-sqlite-features\"],\"any\":[\"sqlx-core/any\"],\"bigdecimal\":[],\"bundled\":[\"libsqlite3-sys/bundled\"],\"chrono\":[\"dep:chrono\",\"sqlx-core/chrono\"],\"deserialize\":[],\"json\":[\"sqlx-core/json\",\"serde\"],\"load-extension\":[],\"migrate\":[\"sqlx-core/migrate\"],\"offline\":[\"sqlx-core/offline\",\"serde\"],\"preupdate-hook\":[\"libsqlite3-sys/preupdate_hook\"],\"regexp\":[\"dep:regex\"],\"rust_decimal\":[],\"sqlx-toml\":[\"sqlx-core/sqlx-toml\"],\"time\":[\"dep:time\",\"sqlx-core/time\"],\"unbundled\":[\"libsqlite3-sys/buildtime_bindgen\"],\"unlock-notify\":[\"libsqlite3-sys/unlock_notify\"],\"uuid\":[\"dep:uuid\",\"sqlx-core/uuid\"]}}", + "sqlx_0.9.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.58\"},{\"features\":[\"attributes\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.13\"},{\"features\":[\"async_tokio\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"dotenvy\",\"req\":\"^0.15.7\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.32\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"libsqlite3-sys\",\"req\":\"^0.37.0\"},{\"features\":[\"bundled-sqlcipher\"],\"kind\":\"dev\",\"name\":\"libsqlite3-sys\",\"req\":\"^0.37.0\",\"target\":\"cfg(sqlite_test_sqlcipher)\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.6\"},{\"default_features\":false,\"features\":[\"thread_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10.1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.219\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.142\"},{\"features\":[\"migrate\"],\"name\":\"sqlx-core\",\"req\":\"=0.9.0\"},{\"name\":\"sqlx-macros\",\"optional\":true,\"req\":\"=0.9.0\"},{\"default_features\":false,\"name\":\"sqlx-mysql\",\"optional\":true,\"req\":\"=0.9.0\"},{\"name\":\"sqlx-postgres\",\"optional\":true,\"req\":\"=0.9.0\"},{\"name\":\"sqlx-sqlite\",\"optional\":true,\"req\":\"=0.9.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.1\"},{\"kind\":\"dev\",\"name\":\"time_\",\"package\":\"time\",\"req\":\"^0.3.47\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.25.0\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.53\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2.2.2\"}],\"features\":{\"_rt-async-global-executor\":[],\"_rt-async-std\":[],\"_rt-smol\":[],\"_rt-tokio\":[],\"_sqlite\":[],\"_unstable-all-types\":[\"bigdecimal\",\"rust_decimal\",\"json\",\"time\",\"chrono\",\"ipnet\",\"ipnetwork\",\"mac_address\",\"uuid\",\"bit-vec\",\"bstr\"],\"_unstable-docs\":[\"all-databases\",\"_unstable-all-types\",\"sqlx-sqlite/_unstable-docs\"],\"all-databases\":[\"mysql\",\"sqlite\",\"postgres\",\"any\"],\"any\":[\"sqlx-core/any\",\"sqlx-mysql?/any\",\"sqlx-postgres?/any\",\"sqlx-sqlite?/any\"],\"bigdecimal\":[\"sqlx-core/bigdecimal\",\"sqlx-macros?/bigdecimal\",\"sqlx-mysql?/bigdecimal\",\"sqlx-postgres?/bigdecimal\"],\"bit-vec\":[\"sqlx-core/bit-vec\",\"sqlx-macros?/bit-vec\",\"sqlx-postgres?/bit-vec\"],\"bstr\":[\"sqlx-core/bstr\"],\"chrono\":[\"sqlx-core/chrono\",\"sqlx-macros?/chrono\",\"sqlx-mysql?/chrono\",\"sqlx-postgres?/chrono\",\"sqlx-sqlite?/chrono\"],\"default\":[\"any\",\"macros\",\"migrate\",\"json\"],\"derive\":[\"sqlx-macros/derive\"],\"ipnet\":[\"sqlx-core/ipnet\",\"sqlx-macros?/ipnet\",\"sqlx-postgres?/ipnet\"],\"ipnetwork\":[\"sqlx-core/ipnetwork\",\"sqlx-macros?/ipnetwork\",\"sqlx-postgres?/ipnetwork\"],\"json\":[\"sqlx-core/json\",\"sqlx-macros?/json\",\"sqlx-mysql?/json\",\"sqlx-postgres?/json\",\"sqlx-sqlite?/json\"],\"mac_address\":[\"sqlx-core/mac_address\",\"sqlx-macros?/mac_address\",\"sqlx-postgres?/mac_address\"],\"macros\":[\"derive\",\"sqlx-macros/macros\",\"sqlx-core/offline\",\"sqlx-mysql?/offline\",\"sqlx-postgres?/offline\",\"sqlx-sqlite?/offline\"],\"migrate\":[\"sqlx-core/migrate\",\"sqlx-macros?/migrate\",\"sqlx-mysql?/migrate\",\"sqlx-postgres?/migrate\",\"sqlx-sqlite?/migrate\"],\"mysql\":[\"sqlx-mysql\",\"sqlx-macros?/mysql\"],\"mysql-rsa\":[\"mysql\",\"sqlx-mysql/rsa\",\"sqlx-macros?/mysql-rsa\"],\"postgres\":[\"sqlx-postgres\",\"sqlx-macros?/postgres\"],\"regexp\":[\"sqlx-sqlite?/regexp\"],\"runtime-async-global-executor\":[\"_rt-async-global-executor\",\"sqlx-core/_rt-async-global-executor\",\"sqlx-macros?/_rt-async-global-executor\"],\"runtime-async-std\":[\"_rt-async-std\",\"sqlx-core/_rt-async-std\",\"sqlx-macros?/_rt-async-std\"],\"runtime-smol\":[\"_rt-smol\",\"sqlx-core/_rt-smol\",\"sqlx-macros?/_rt-smol\"],\"runtime-tokio\":[\"_rt-tokio\",\"sqlx-core/_rt-tokio\",\"sqlx-macros?/_rt-tokio\"],\"rust_decimal\":[\"sqlx-core/rust_decimal\",\"sqlx-macros?/rust_decimal\",\"sqlx-mysql?/rust_decimal\",\"sqlx-postgres?/rust_decimal\"],\"sqlite\":[\"sqlite-bundled\",\"sqlite-deserialize\",\"sqlite-load-extension\",\"sqlite-unlock-notify\"],\"sqlite-bundled\":[\"_sqlite\",\"sqlx-sqlite/bundled\",\"sqlx-macros?/sqlite\"],\"sqlite-deserialize\":[\"sqlx-sqlite/deserialize\"],\"sqlite-load-extension\":[\"sqlx-sqlite/load-extension\",\"sqlx-macros?/sqlite-load-extension\"],\"sqlite-preupdate-hook\":[\"sqlx-sqlite/preupdate-hook\"],\"sqlite-unbundled\":[\"_sqlite\",\"sqlx-sqlite/unbundled\",\"sqlx-macros?/sqlite-unbundled\"],\"sqlite-unlock-notify\":[\"sqlx-sqlite/unlock-notify\"],\"sqlx-toml\":[\"sqlx-core/sqlx-toml\",\"sqlx-macros?/sqlx-toml\",\"sqlx-sqlite?/sqlx-toml\"],\"time\":[\"sqlx-core/time\",\"sqlx-macros?/time\",\"sqlx-mysql?/time\",\"sqlx-postgres?/time\",\"sqlx-sqlite?/time\"],\"tls-native-tls\":[\"sqlx-core/_tls-native-tls\",\"sqlx-macros?/_tls-native-tls\"],\"tls-none\":[],\"tls-rustls\":[\"tls-rustls-ring\"],\"tls-rustls-aws-lc-rs\":[\"sqlx-core/_tls-rustls-aws-lc-rs\",\"sqlx-macros?/_tls-rustls-aws-lc-rs\"],\"tls-rustls-ring\":[\"tls-rustls-ring-webpki\"],\"tls-rustls-ring-native-roots\":[\"sqlx-core/_tls-rustls-ring-native-roots\",\"sqlx-macros?/_tls-rustls-ring-native-roots\"],\"tls-rustls-ring-webpki\":[\"sqlx-core/_tls-rustls-ring-webpki\",\"sqlx-macros?/_tls-rustls-ring-webpki\"],\"uuid\":[\"sqlx-core/uuid\",\"sqlx-macros?/uuid\",\"sqlx-mysql?/uuid\",\"sqlx-postgres?/uuid\",\"sqlx-sqlite?/uuid\"]}}", "sse-stream_0.2.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1\"},{\"features\":[\"tracing\"],\"kind\":\"dev\",\"name\":\"axum\",\"req\":\"^0.8\"},{\"name\":\"bytes\",\"req\":\"^1\"},{\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"http-body\",\"req\":\"^1\"},{\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"features\":[\"client\",\"http1\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"stream\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.12\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"io\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\",\"std\",\"fmt\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"}],\"features\":{\"default\":[],\"tracing\":[\"dep:tracing\"]}}", "stable_deref_trait_1.2.1": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", "starlark_0.13.0": "{\"dependencies\":[{\"features\":[\"bumpalo\",\"num-bigint\"],\"name\":\"allocative\",\"req\":\"^0.3.4\"},{\"name\":\"anyhow\",\"req\":\"^1.0.65\"},{\"name\":\"bumpalo\",\"req\":\"^3.8\"},{\"name\":\"cmp_any\",\"req\":\"^0.8.1\"},{\"name\":\"debugserver-types\",\"req\":\"^0.5.0\"},{\"name\":\"derivative\",\"req\":\"^2.2\"},{\"features\":[\"full\"],\"name\":\"derive_more\",\"req\":\"^1.0.0\"},{\"name\":\"display_container\",\"req\":\"^0.9.0\"},{\"name\":\"dupe\",\"req\":\"^0.9.0\"},{\"name\":\"either\",\"req\":\"^1.8\"},{\"name\":\"erased-serde\",\"req\":\"^0.3.12\"},{\"features\":[\"raw\"],\"name\":\"hashbrown\",\"req\":\"^0.14.3\"},{\"name\":\"inventory\",\"req\":\"^0.3.8\"},{\"name\":\"itertools\",\"req\":\"^0.13.0\"},{\"name\":\"maplit\",\"req\":\"^1.0.2\"},{\"name\":\"memoffset\",\"req\":\"^0.6.4\"},{\"name\":\"num-bigint\",\"req\":\"^0.4.3\"},{\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"name\":\"once_cell\",\"req\":\"^1.8\"},{\"name\":\"paste\",\"req\":\"^1.0\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"name\":\"ref-cast\",\"req\":\"^1.0.18\"},{\"name\":\"regex\",\"req\":\"^1.5.4\"},{\"name\":\"rustyline\",\"req\":\"^14.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"starlark_derive\",\"req\":\"^0.13.0\"},{\"name\":\"starlark_map\",\"req\":\"^0.13.0\"},{\"name\":\"starlark_syntax\",\"req\":\"^0.13.0\"},{\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"name\":\"strsim\",\"req\":\"^0.10.0\"},{\"name\":\"textwrap\",\"req\":\"^0.11\"},{\"name\":\"thiserror\",\"req\":\"^1.0.36\"}],\"features\":{}}", @@ -1564,8 +1590,8 @@ "tar_0.4.45": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"astral-tokio-tar\",\"req\":\"^0.5\"},{\"name\":\"filetime\",\"req\":\"^0.2.8\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"name\":\"xattr\",\"optional\":true,\"req\":\"^1.1.3\",\"target\":\"cfg(unix)\"}],\"features\":{\"default\":[\"xattr\"]}}", "target-lexicon_0.13.3": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"arch_z80\":[],\"arch_zkasm\":[],\"default\":[],\"serde_support\":[\"serde\",\"std\"],\"std\":[]}}", "tempfile_3.27.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"name\":\"fastrand\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"name\":\"getrandom\",\"optional\":true,\"req\":\">=0.3.0, <0.5\",\"target\":\"cfg(any(unix, windows, target_os = \\\"wasi\\\"))\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"once_cell\",\"req\":\"^1.19.0\"},{\"features\":[\"fs\"],\"name\":\"rustix\",\"req\":\"^1.1.4\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\"))\"},{\"features\":[\"Win32_Storage_FileSystem\",\"Win32_Foundation\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"getrandom\"],\"nightly\":[]}}", - "temporal_capi_0.1.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"diplomat\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"name\":\"diplomat-runtime\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"features\":[\"unstable\"],\"name\":\"icu_calendar\",\"req\":\"^2.1.0\"},{\"name\":\"icu_locale\",\"req\":\"^2.1.0\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.19\"},{\"default_features\":false,\"name\":\"temporal_rs\",\"req\":\"^0.1.2\"},{\"name\":\"timezone_provider\",\"req\":\"^0.1.2\"},{\"name\":\"writeable\",\"req\":\"^0.6.0\"},{\"name\":\"zoneinfo64\",\"optional\":true,\"req\":\"^0.2.0\"}],\"features\":{\"compiled_data\":[\"temporal_rs/compiled_data\"],\"zoneinfo64\":[\"dep:zoneinfo64\",\"timezone_provider/zoneinfo64\"]}}", - "temporal_rs_0.1.2": "{\"dependencies\":[{\"name\":\"core_maths\",\"req\":\"^0.1.1\"},{\"name\":\"iana-time-zone\",\"optional\":true,\"req\":\"^0.1.64\"},{\"default_features\":false,\"features\":[\"unstable\",\"compiled_data\"],\"name\":\"icu_calendar\",\"req\":\"^2.1.0\"},{\"name\":\"icu_locale\",\"req\":\"^2.1.0\"},{\"features\":[\"duration\"],\"name\":\"ixdtf\",\"req\":\"^0.6.4\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.28\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.19\"},{\"name\":\"timezone_provider\",\"req\":\"^0.1.2\"},{\"name\":\"tinystr\",\"req\":\"^0.8.0\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1.1.0\"},{\"name\":\"writeable\",\"req\":\"^0.6.0\"}],\"features\":{\"compiled_data\":[\"tzdb\"],\"default\":[\"sys\"],\"float64_representable_durations\":[],\"log\":[\"dep:log\"],\"std\":[],\"sys\":[\"std\",\"compiled_data\",\"dep:web-time\",\"dep:iana-time-zone\"],\"tzdb\":[\"std\",\"timezone_provider/tzif\"]}}", + "temporal_capi_0.2.3": "{\"dependencies\":[{\"default_features\":false,\"name\":\"diplomat\",\"req\":\"^0.15.0\"},{\"default_features\":false,\"name\":\"diplomat-runtime\",\"req\":\"^0.15.0\"},{\"default_features\":false,\"name\":\"icu_calendar\",\"req\":\"^2.2.1\"},{\"name\":\"icu_locale_core\",\"req\":\"^2.1.0\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.19\"},{\"default_features\":false,\"name\":\"temporal_rs\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"name\":\"timezone_provider\",\"req\":\"^0.2.3\"},{\"name\":\"writeable\",\"req\":\"^0.6.0\"},{\"name\":\"zoneinfo64\",\"optional\":true,\"req\":\"^0.3.0\"}],\"features\":{\"compiled_data\":[\"temporal_rs/compiled_data\"],\"zoneinfo64\":[\"dep:zoneinfo64\",\"timezone_provider/zoneinfo64\"]}}", + "temporal_rs_0.2.3": "{\"dependencies\":[{\"name\":\"calendrical_calculations\",\"req\":\"^0.2.4\"},{\"name\":\"core_maths\",\"req\":\"^0.1.1\"},{\"name\":\"iana-time-zone\",\"optional\":true,\"req\":\"^0.1.64\"},{\"default_features\":false,\"features\":[\"compiled_data\"],\"name\":\"icu_calendar\",\"req\":\"^2.2.1\"},{\"name\":\"icu_locale_core\",\"req\":\"^2.1.0\"},{\"features\":[\"duration\"],\"name\":\"ixdtf\",\"req\":\"^0.6.4\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.28\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.19\"},{\"default_features\":false,\"name\":\"timezone_provider\",\"req\":\"^0.2.3\"},{\"name\":\"tinystr\",\"req\":\"^0.8.0\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1.1.0\"},{\"name\":\"writeable\",\"req\":\"^0.6.0\"}],\"features\":{\"compiled_data\":[\"tzdb\"],\"default\":[\"sys-local\"],\"float64_representable_durations\":[],\"log\":[\"dep:log\"],\"std\":[],\"sys\":[\"std\",\"compiled_data\",\"dep:web-time\"],\"sys-local\":[\"sys\",\"dep:iana-time-zone\"],\"tzdb\":[\"std\",\"timezone_provider/tzif\"]}}", "term_0.7.0": "{\"dependencies\":[{\"name\":\"dirs-next\",\"req\":\"^2\"},{\"name\":\"rustversion\",\"req\":\"^1\",\"target\":\"cfg(windows)\"},{\"features\":[\"consoleapi\",\"wincon\",\"handleapi\",\"fileapi\"],\"name\":\"winapi\",\"req\":\"^0.3\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[]}}", "termcolor_1.4.1": "{\"dependencies\":[{\"name\":\"winapi-util\",\"req\":\"^0.1.3\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "terminal_size_0.4.3": "{\"dependencies\":[{\"features\":[\"termios\"],\"name\":\"rustix\",\"req\":\"^1.0.1\",\"target\":\"cfg(unix)\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Console\"],\"name\":\"windows-sys\",\"req\":\"^0.60.0\",\"target\":\"cfg(windows)\"}],\"features\":{}}", @@ -1587,10 +1613,11 @@ "time-core_0.1.8": "{\"dependencies\":[],\"features\":{\"large-dates\":[]}}", "time-macros_0.2.27": "{\"dependencies\":[{\"name\":\"num-conv\",\"req\":\"^0.2.0\"},{\"name\":\"time-core\",\"req\":\"=0.1.8\"}],\"features\":{\"formatting\":[],\"large-dates\":[],\"parsing\":[],\"serde\":[]}}", "time_0.3.47": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8.1\",\"target\":\"cfg(bench)\"},{\"features\":[\"powerfmt\"],\"name\":\"deranged\",\"req\":\"^0.5.2\"},{\"name\":\"itoa\",\"optional\":true,\"req\":\"^1.0.1\"},{\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3.58\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.98\",\"target\":\"cfg(target_family = \\\"unix\\\")\"},{\"name\":\"num-conv\",\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"num-conv\",\"req\":\"^0.2.0\"},{\"name\":\"num_threads\",\"optional\":true,\"req\":\"^0.1.2\",\"target\":\"cfg(target_family = \\\"unix\\\")\"},{\"default_features\":false,\"name\":\"powerfmt\",\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"name\":\"rand08\",\"optional\":true,\"package\":\"rand\",\"req\":\"^0.8.4\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"rand08\",\"package\":\"rand\",\"req\":\"^0.8.4\"},{\"default_features\":false,\"name\":\"rand09\",\"optional\":true,\"package\":\"rand\",\"req\":\"^0.9.2\"},{\"default_features\":false,\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand09\",\"package\":\"rand\",\"req\":\"^0.9.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"rstest\",\"req\":\"^0.26.1\"},{\"kind\":\"dev\",\"name\":\"rstest_reuse\",\"req\":\"^0.7.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.184\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.68\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.126\"},{\"name\":\"time-core\",\"req\":\"=0.1.8\"},{\"name\":\"time-macros\",\"optional\":true,\"req\":\"=0.2.27\"},{\"kind\":\"dev\",\"name\":\"time-macros\",\"req\":\"=0.2.27\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.102\",\"target\":\"cfg(__ui_tests)\"}],\"features\":{\"alloc\":[\"serde_core?/alloc\"],\"default\":[\"std\"],\"formatting\":[\"dep:itoa\",\"std\",\"time-macros?/formatting\"],\"large-dates\":[\"time-core/large-dates\",\"time-macros?/large-dates\"],\"local-offset\":[\"std\",\"dep:libc\",\"dep:num_threads\"],\"macros\":[\"dep:time-macros\"],\"parsing\":[\"time-macros?/parsing\"],\"quickcheck\":[\"dep:quickcheck\",\"alloc\",\"deranged/quickcheck\"],\"rand\":[\"rand08\",\"rand09\"],\"rand08\":[\"dep:rand08\",\"deranged/rand08\"],\"rand09\":[\"dep:rand09\",\"deranged/rand09\"],\"serde\":[\"dep:serde_core\",\"time-macros?/serde\",\"deranged/serde\"],\"serde-human-readable\":[\"serde\",\"formatting\",\"parsing\"],\"serde-well-known\":[\"serde\",\"formatting\",\"parsing\"],\"std\":[\"alloc\"],\"wasm-bindgen\":[\"dep:js-sys\"]}}", - "timezone_provider_0.1.2": "{\"dependencies\":[{\"name\":\"combine\",\"optional\":true,\"req\":\"^4.6.7\"},{\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"jiff-tzdb\",\"optional\":true,\"req\":\"^0.1.4\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.225\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.145\"},{\"features\":[\"zerovec\"],\"name\":\"tinystr\",\"req\":\"^0.8.0\"},{\"name\":\"tzif\",\"optional\":true,\"req\":\"^0.4.0\"},{\"features\":[\"derive\"],\"name\":\"yoke\",\"optional\":true,\"req\":\"^0.8.0\"},{\"name\":\"zerotrie\",\"req\":\"^0.2.0\"},{\"features\":[\"derive\",\"alloc\"],\"name\":\"zerovec\",\"req\":\"^0.11.0\"},{\"name\":\"zoneinfo64\",\"optional\":true,\"req\":\"^0.2.0\"},{\"features\":[\"std\"],\"name\":\"zoneinfo_rs\",\"optional\":true,\"req\":\"^0.0.18\"}],\"features\":{\"datagen\":[\"std\",\"dep:serde\",\"dep:databake\",\"dep:yoke\",\"dep:serde_json\",\"tinystr/serde\",\"tinystr/databake\",\"zerotrie/serde\",\"zerotrie/databake\",\"zerovec/serde\",\"zerovec/databake\",\"zerovec/derive\",\"dep:zoneinfo_rs\",\"experimental_tzif\"],\"default\":[],\"experimental_tzif\":[],\"std\":[],\"tzif\":[\"dep:tzif\",\"dep:jiff-tzdb\",\"dep:combine\",\"std\"],\"zoneinfo64\":[\"dep:zoneinfo64\"]}}", + "timezone_provider_0.2.3": "{\"dependencies\":[{\"name\":\"combine\",\"optional\":true,\"req\":\"^4.6.7\"},{\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"features\":[\"compiled_data\"],\"kind\":\"dev\",\"name\":\"icu_time\",\"req\":\"^2\"},{\"name\":\"jiff-tzdb\",\"optional\":true,\"req\":\"^0.1.4\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.225\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0.145\"},{\"features\":[\"zerovec\"],\"name\":\"tinystr\",\"req\":\"^0.8.0\"},{\"name\":\"tzif\",\"optional\":true,\"req\":\"^0.4.0\"},{\"features\":[\"derive\"],\"name\":\"yoke\",\"optional\":true,\"req\":\"^0.8.0\"},{\"name\":\"zerofrom\",\"optional\":true,\"req\":\"^0.1.6\"},{\"name\":\"zerotrie\",\"req\":\"^0.2.0\"},{\"features\":[\"derive\",\"alloc\"],\"name\":\"zerovec\",\"req\":\"^0.11.0\"},{\"name\":\"zoneinfo64\",\"optional\":true,\"req\":\"^0.3.0\"},{\"default_features\":false,\"features\":[\"std\",\"unstable\"],\"name\":\"zoneinfo_rs\",\"optional\":true,\"req\":\"^0.1.0\"}],\"features\":{\"datagen\":[\"std\",\"dep:serde\",\"dep:databake\",\"dep:yoke\",\"dep:serde_json\",\"tinystr/serde\",\"tinystr/databake\",\"zerotrie/serde\",\"zerotrie/databake\",\"zerovec/serde\",\"zerovec/databake\",\"zerovec/derive\",\"dep:zoneinfo_rs\",\"experimental_tzif\"],\"default\":[],\"experimental_tzif\":[\"dep:zerofrom\",\"zerofrom/derive\"],\"std\":[],\"tzif\":[\"dep:tzif\",\"dep:jiff-tzdb\",\"dep:combine\",\"std\"],\"zoneinfo64\":[\"dep:zoneinfo64\"]}}", "tiny-keccak_2.0.2": "{\"dependencies\":[{\"name\":\"crunchy\",\"req\":\"^0.2.2\"}],\"features\":{\"cshake\":[],\"default\":[],\"fips202\":[\"keccak\",\"shake\",\"sha3\"],\"k12\":[],\"keccak\":[],\"kmac\":[\"cshake\"],\"parallel_hash\":[\"cshake\"],\"sha3\":[],\"shake\":[],\"sp800\":[\"cshake\",\"kmac\",\"tuple_hash\"],\"tuple_hash\":[\"cshake\"]}}", "tiny_http_0.12.0": "{\"dependencies\":[{\"name\":\"ascii\",\"req\":\"^1.0\"},{\"name\":\"chunked_transfer\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"fdlimit\",\"req\":\"^0.1\"},{\"name\":\"httpdate\",\"req\":\"^1.0.2\"},{\"name\":\"log\",\"req\":\"^0.4.4\"},{\"name\":\"openssl\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"rustc-serialize\",\"req\":\"^0.3\"},{\"name\":\"rustls\",\"optional\":true,\"req\":\"^0.20\"},{\"name\":\"rustls-pemfile\",\"optional\":true,\"req\":\"^0.2.1\"},{\"kind\":\"dev\",\"name\":\"sha1\",\"req\":\"^0.6.0\"},{\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"default\":[],\"ssl\":[\"ssl-openssl\"],\"ssl-openssl\":[\"openssl\",\"zeroize\"],\"ssl-rustls\":[\"rustls\",\"rustls-pemfile\",\"zeroize\"]}}", "tinystr_0.8.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"features\":[\"use-std\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"name\":\"zerovec\",\"optional\":true,\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"serde_core?/alloc\",\"zerovec?/alloc\"],\"databake\":[\"dep:databake\"],\"default\":[\"alloc\"],\"serde\":[\"dep:serde_core\"],\"std\":[],\"zerovec\":[\"dep:zerovec\"]}}", + "tinystr_0.8.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"features\":[\"use-std\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"name\":\"zerovec\",\"optional\":true,\"req\":\"^0.11.6\"}],\"features\":{\"alloc\":[\"serde_core?/alloc\",\"zerovec?/alloc\"],\"databake\":[\"dep:databake\"],\"default\":[\"alloc\"],\"serde\":[\"dep:serde_core\"],\"std\":[],\"zerovec\":[\"dep:zerovec\"]}}", "tinyvec_1.10.0": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"borsh\",\"optional\":true,\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"debugger_test\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"debugger_test_parser\",\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"generic-array\",\"optional\":true,\"req\":\"^1.1.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"smallvec\",\"req\":\"^1\"},{\"name\":\"tinyvec_macros\",\"optional\":true,\"req\":\"^0.1\"}],\"features\":{\"alloc\":[\"tinyvec_macros\"],\"debugger_visualizer\":[],\"default\":[],\"experimental_write_impl\":[],\"grab_spare_slice\":[],\"latest_stable_rust\":[\"rustc_1_61\"],\"nightly_slice_partition_dedup\":[],\"real_blackbox\":[\"criterion/real_blackbox\"],\"rustc_1_40\":[],\"rustc_1_55\":[],\"rustc_1_57\":[],\"rustc_1_61\":[\"rustc_1_57\"],\"std\":[\"alloc\"]}}", "tinyvec_macros_0.1.1": "{\"dependencies\":[],\"features\":{}}", "tokio-graceful_0.2.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bytes\",\"req\":\"^1\",\"target\":\"cfg(not(loom))\"},{\"kind\":\"dev\",\"name\":\"http-body-util\",\"req\":\"^0.1\",\"target\":\"cfg(not(loom))\"},{\"features\":[\"server\",\"http1\",\"http2\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1.0.1\",\"target\":\"cfg(not(loom))\"},{\"features\":[\"server\",\"server-auto\",\"http1\",\"http2\",\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1.1\",\"target\":\"cfg(not(loom))\"},{\"features\":[\"futures\",\"checkpoint\"],\"name\":\"loom\",\"req\":\"^0.7\",\"target\":\"cfg(loom)\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"name\":\"slab\",\"req\":\"^0.4\"},{\"features\":[\"rt\",\"signal\",\"sync\",\"macros\",\"time\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"net\",\"rt-multi-thread\",\"io-util\",\"test-util\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"}],\"features\":{}}", @@ -1645,7 +1672,7 @@ "ts-rs_11.1.0": "{\"dependencies\":[{\"features\":[\"serde\"],\"name\":\"bigdecimal\",\"optional\":true,\"req\":\">=0.0.13, <0.5\"},{\"name\":\"bson\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"serde\"],\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4\"},{\"name\":\"dprint-plugin-typescript\",\"optional\":true,\"req\":\"=0.95\"},{\"name\":\"heapless\",\"optional\":true,\"req\":\">=0.7, <0.9\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"ordered-float\",\"optional\":true,\"req\":\">=3, <6\"},{\"name\":\"semver\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"smol_str\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"sync\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.40\"},{\"name\":\"ts-rs-macros\",\"req\":\"=11.1.0\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"bigdecimal-impl\":[\"bigdecimal\"],\"bson-uuid-impl\":[\"bson\"],\"bytes-impl\":[\"bytes\"],\"chrono-impl\":[\"chrono\"],\"default\":[\"serde-compat\"],\"format\":[\"dprint-plugin-typescript\"],\"heapless-impl\":[\"heapless\"],\"import-esm\":[],\"indexmap-impl\":[\"indexmap\"],\"no-serde-warnings\":[\"ts-rs-macros/no-serde-warnings\"],\"ordered-float-impl\":[\"ordered-float\"],\"semver-impl\":[\"semver\"],\"serde-compat\":[\"ts-rs-macros/serde-compat\"],\"serde-json-impl\":[\"serde_json\"],\"smol_str-impl\":[\"smol_str\"],\"tokio-impl\":[\"tokio\"],\"url-impl\":[\"url\"],\"uuid-impl\":[\"uuid\"]}}", "two-face_0.5.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"cargo-lock\",\"req\":\"^10.1.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.44.3\"},{\"default_features\":false,\"features\":[\"read\"],\"kind\":\"dev\",\"name\":\"object\",\"req\":\"^0.36.7\"},{\"name\":\"serde\",\"req\":\"^1.0.228\"},{\"name\":\"serde_derive\",\"req\":\"^1.0.228\"},{\"kind\":\"dev\",\"name\":\"similar\",\"req\":\"^2.7.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"strum\",\"req\":\"^0.26.3\"},{\"default_features\":false,\"features\":[\"dump-load\",\"parsing\"],\"name\":\"syntect\",\"req\":\"^5.3.0\"},{\"default_features\":false,\"features\":[\"html\"],\"kind\":\"dev\",\"name\":\"syntect\",\"req\":\"^5.3.0\"},{\"kind\":\"dev\",\"name\":\"toml\",\"req\":\"^0.8.23\"},{\"default_features\":false,\"features\":[\"std\",\"xxhash64\"],\"kind\":\"dev\",\"name\":\"twox-hash\",\"req\":\"^2.1.2\"}],\"features\":{\"default\":[\"syntect-onig\"],\"syntect-default-fancy\":[\"syntect-fancy\",\"syntect/default-fancy\"],\"syntect-default-onig\":[\"syntect-onig\",\"syntect/default-onig\"],\"syntect-fancy\":[\"syntect/regex-fancy\"],\"syntect-onig\":[\"syntect/regex-onig\"]}}", "type-map_0.5.1": "{\"dependencies\":[{\"name\":\"rustc-hash\",\"req\":\"^2\"}],\"features\":{}}", - "typenum_1.19.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"scale-info\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"const-generics\":[],\"force_unix_path_separator\":[],\"i128\":[],\"no_std\":[],\"scale_info\":[\"scale-info/derive\"],\"strict\":[]}}", + "typenum_1.20.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"scale-info\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"const-generics\":[],\"i128\":[],\"scale_info\":[\"scale-info/derive\"],\"strict\":[]}}", "uds_windows_1.1.0": "{\"dependencies\":[{\"name\":\"memoffset\",\"req\":\"^0.9.0\"},{\"name\":\"tempfile\",\"req\":\"^3\",\"target\":\"cfg(windows)\"},{\"features\":[\"winsock2\",\"ws2def\",\"minwinbase\",\"ntdef\",\"processthreadsapi\",\"handleapi\",\"ws2tcpip\",\"winbase\"],\"name\":\"winapi\",\"req\":\"^0.3.9\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "uname_0.1.1": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2\"}],\"features\":{}}", "unarray_0.1.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"test-strategy\",\"req\":\"^0.2\"}],\"features\":{}}", @@ -1677,7 +1704,7 @@ "utf8_iter_1.0.4": "{\"dependencies\":[],\"features\":{}}", "utf8parse_0.2.2": "{\"dependencies\":[],\"features\":{\"default\":[],\"nightly\":[]}}", "uuid_1.20.0": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.1.3\"},{\"default_features\":false,\"name\":\"atomic\",\"optional\":true,\"req\":\"^0.6\"},{\"default_features\":false,\"name\":\"borsh\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"borsh-derive\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"name\":\"bytemuck\",\"optional\":true,\"req\":\"^1.20.0\"},{\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"default_features\":false,\"name\":\"js-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"), target_feature = \\\"atomics\\\"))\"},{\"default_features\":false,\"name\":\"md-5\",\"optional\":true,\"req\":\"^0.10\"},{\"name\":\"rand\",\"optional\":true,\"req\":\"^0.9\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\"))))\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.221\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1.0.221\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1.0.56\"},{\"default_features\":false,\"name\":\"sha1_smol\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"slog\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.52\"},{\"name\":\"uuid-rng-internal-lib\",\"optional\":true,\"package\":\"uuid-rng-internal\",\"req\":\"^1.20.0\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"default_features\":false,\"features\":[\"msrv\"],\"name\":\"wasm-bindgen\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen\",\"req\":\"^0.2\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", any(target_os = \\\"unknown\\\", target_os = \\\"none\\\")))\"},{\"features\":[\"derive\"],\"name\":\"zerocopy\",\"optional\":true,\"req\":\"^0.8\"}],\"features\":{\"atomic\":[\"dep:atomic\"],\"borsh\":[\"dep:borsh\",\"dep:borsh-derive\"],\"default\":[\"std\"],\"fast-rng\":[\"rng\",\"dep:rand\"],\"js\":[\"dep:wasm-bindgen\",\"dep:js-sys\"],\"macro-diagnostics\":[],\"md5\":[\"dep:md-5\"],\"rng\":[\"dep:getrandom\"],\"rng-getrandom\":[\"rng\",\"dep:getrandom\",\"uuid-rng-internal-lib\",\"uuid-rng-internal-lib/getrandom\"],\"rng-rand\":[\"rng\",\"dep:rand\",\"uuid-rng-internal-lib\",\"uuid-rng-internal-lib/rand\"],\"serde\":[\"dep:serde_core\"],\"sha1\":[\"dep:sha1_smol\"],\"std\":[\"wasm-bindgen?/std\",\"js-sys?/std\"],\"v1\":[\"atomic\"],\"v3\":[\"md5\"],\"v4\":[\"rng\"],\"v5\":[\"sha1\"],\"v6\":[\"atomic\"],\"v7\":[\"rng\"],\"v8\":[]}}", - "v8_146.4.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"align-data\",\"req\":\"^0.1.0\"},{\"kind\":\"build\",\"name\":\"bindgen\",\"req\":\"^0.72\"},{\"kind\":\"dev\",\"name\":\"bindgen\",\"req\":\"^0.72\"},{\"name\":\"bitflags\",\"req\":\"^2.5\"},{\"kind\":\"dev\",\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"build\",\"name\":\"fslock\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"fslock\",\"req\":\"^0.2\"},{\"kind\":\"build\",\"name\":\"gzip-header\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"gzip-header\",\"req\":\"^1.0.0\"},{\"kind\":\"build\",\"name\":\"home\",\"req\":\"^0\"},{\"kind\":\"dev\",\"name\":\"home\",\"req\":\"^0\"},{\"kind\":\"build\",\"name\":\"miniz_oxide\",\"req\":\"^0.8.8\"},{\"kind\":\"dev\",\"name\":\"miniz_oxide\",\"req\":\"^0.8.8\"},{\"name\":\"paste\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"features\":[\"zoneinfo64\"],\"name\":\"temporal_capi\",\"req\":\"^0.1.2\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.96\"},{\"kind\":\"build\",\"name\":\"which\",\"req\":\"^6\"},{\"kind\":\"dev\",\"name\":\"which\",\"req\":\"^6\"}],\"features\":{\"default\":[\"use_custom_libcxx\"],\"use_custom_libcxx\":[],\"v8_enable_pointer_compression\":[],\"v8_enable_sandbox\":[\"v8_enable_pointer_compression\"],\"v8_enable_v8_checks\":[]}}", + "v8_147.4.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"align-data\",\"req\":\"^0.1.0\"},{\"kind\":\"build\",\"name\":\"bindgen\",\"req\":\"^0.72\"},{\"kind\":\"dev\",\"name\":\"bindgen\",\"req\":\"^0.72\"},{\"name\":\"bitflags\",\"req\":\"^2.5\"},{\"kind\":\"dev\",\"name\":\"bytes\",\"req\":\"^1\"},{\"kind\":\"build\",\"name\":\"fslock\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"fslock\",\"req\":\"^0.2\"},{\"kind\":\"build\",\"name\":\"gzip-header\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"gzip-header\",\"req\":\"^1.0.0\"},{\"kind\":\"build\",\"name\":\"home\",\"req\":\"^0\"},{\"kind\":\"dev\",\"name\":\"home\",\"req\":\"^0\"},{\"kind\":\"build\",\"name\":\"miniz_oxide\",\"req\":\"^0.8.8\"},{\"kind\":\"dev\",\"name\":\"miniz_oxide\",\"req\":\"^0.8.8\"},{\"name\":\"paste\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1\"},{\"features\":[\"zoneinfo64\"],\"name\":\"temporal_capi\",\"req\":\"^0.2.3\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.96\"},{\"kind\":\"build\",\"name\":\"which\",\"req\":\"^6\"},{\"kind\":\"dev\",\"name\":\"which\",\"req\":\"^6\"}],\"features\":{\"default\":[\"use_custom_libcxx\"],\"simdutf\":[],\"use_custom_libcxx\":[],\"v8_enable_pointer_compression\":[],\"v8_enable_sandbox\":[\"v8_enable_pointer_compression\"],\"v8_enable_v8_checks\":[]}}", "valuable_0.1.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"name\":\"valuable-derive\",\"optional\":true,\"req\":\"=0.1.1\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"derive\":[\"valuable-derive\"],\"std\":[\"alloc\"]}}", "vcpkg_0.2.15": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempdir\",\"req\":\"^0.3.7\"}],\"features\":{}}", "version-compare_0.2.1": "{\"dependencies\":[],\"features\":{}}", @@ -1700,6 +1727,7 @@ "wasm-encoder_0.244.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.58\"},{\"default_features\":false,\"name\":\"leb128fmt\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.2.0\"},{\"default_features\":false,\"features\":[\"simd\",\"simd\"],\"name\":\"wasmparser\",\"optional\":true,\"req\":\"^0.244.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"wasmprinter\",\"req\":\"^0.244.0\"}],\"features\":{\"component-model\":[\"wasmparser?/component-model\"],\"default\":[\"std\",\"component-model\"],\"std\":[\"wasmparser?/std\"]}}", "wasm-metadata_0.244.0": "{\"dependencies\":[{\"name\":\"anyhow\",\"req\":\"^1.0.58\"},{\"name\":\"auditable-serde\",\"optional\":true,\"req\":\"^0.8.0\"},{\"features\":[\"derive\"],\"name\":\"clap\",\"optional\":true,\"req\":\"^4.0.0\"},{\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"serde\"],\"name\":\"indexmap\",\"req\":\"^2.7.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.166\"},{\"name\":\"serde_derive\",\"optional\":true,\"req\":\"^1.0.166\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"spdx\",\"optional\":true,\"req\":\"^0.10.1\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"features\":[\"std\",\"component-model\"],\"name\":\"wasm-encoder\",\"req\":\"^0.244.0\"},{\"default_features\":false,\"features\":[\"simd\",\"std\",\"component-model\",\"hash-collections\"],\"name\":\"wasmparser\",\"req\":\"^0.244.0\"}],\"features\":{\"default\":[\"oci\",\"serde\"],\"oci\":[\"dep:auditable-serde\",\"dep:flate2\",\"dep:url\",\"dep:spdx\",\"dep:serde_json\",\"serde\"],\"serde\":[\"dep:serde_derive\",\"dep:serde\"]}}", "wasm-streams_0.4.2": "{\"dependencies\":[{\"features\":[\"io\",\"sink\"],\"name\":\"futures-util\",\"req\":\"^0.3.31\"},{\"features\":[\"futures\"],\"kind\":\"dev\",\"name\":\"gloo-timers\",\"req\":\"^0.3.0\"},{\"name\":\"js-sys\",\"req\":\"^0.3.72\"},{\"kind\":\"dev\",\"name\":\"pin-project\",\"req\":\"^1\"},{\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.95\"},{\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4.45\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.45\"},{\"features\":[\"AbortSignal\",\"QueuingStrategy\",\"ReadableStream\",\"ReadableStreamType\",\"ReadableWritablePair\",\"ReadableStreamByobReader\",\"ReadableStreamReaderMode\",\"ReadableStreamReadResult\",\"ReadableStreamByobRequest\",\"ReadableStreamDefaultReader\",\"ReadableByteStreamController\",\"ReadableStreamGetReaderOptions\",\"ReadableStreamDefaultController\",\"StreamPipeOptions\",\"TransformStream\",\"TransformStreamDefaultController\",\"Transformer\",\"UnderlyingSink\",\"UnderlyingSource\",\"WritableStream\",\"WritableStreamDefaultController\",\"WritableStreamDefaultWriter\"],\"name\":\"web-sys\",\"req\":\"^0.3.72\"},{\"features\":[\"console\",\"AbortSignal\",\"ErrorEvent\",\"PromiseRejectionEvent\",\"Response\",\"ReadableStream\",\"Window\"],\"kind\":\"dev\",\"name\":\"web-sys\",\"req\":\"^0.3.72\"}],\"features\":{}}", + "wasm-streams_0.5.0": "{\"dependencies\":[{\"features\":[\"io\",\"sink\"],\"name\":\"futures-util\",\"req\":\"^0.3.31\"},{\"features\":[\"futures\"],\"kind\":\"dev\",\"name\":\"gloo-timers\",\"req\":\"^0.3.0\"},{\"name\":\"js-sys\",\"req\":\"^0.3.85\"},{\"kind\":\"dev\",\"name\":\"pin-project\",\"req\":\"^1\"},{\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"wasm-bindgen\",\"req\":\"^0.2.108\"},{\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4.58\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.58\"},{\"features\":[\"AbortSignal\",\"QueuingStrategy\",\"ReadableStream\",\"ReadableStreamType\",\"ReadableWritablePair\",\"ReadableStreamByobReader\",\"ReadableStreamReaderMode\",\"ReadableStreamReadResult\",\"ReadableStreamByobRequest\",\"ReadableStreamDefaultReader\",\"ReadableByteStreamController\",\"ReadableStreamGetReaderOptions\",\"ReadableStreamDefaultController\",\"StreamPipeOptions\",\"TransformStream\",\"TransformStreamDefaultController\",\"Transformer\",\"UnderlyingSink\",\"UnderlyingSource\",\"WritableStream\",\"WritableStreamDefaultController\",\"WritableStreamDefaultWriter\"],\"name\":\"web-sys\",\"req\":\"^0.3.85\"},{\"features\":[\"console\",\"AbortSignal\",\"ErrorEvent\",\"PromiseRejectionEvent\",\"Response\",\"ReadableStream\",\"Window\"],\"kind\":\"dev\",\"name\":\"web-sys\",\"req\":\"^0.3.85\"}],\"features\":{}}", "wasmparser_0.244.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.58\"},{\"name\":\"bitflags\",\"req\":\"^2.4.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11\"},{\"default_features\":false,\"features\":[\"default-hasher\"],\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15.2\"},{\"default_features\":false,\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2.7.0\"},{\"kind\":\"dev\",\"name\":\"log\",\"req\":\"^0.4.17\"},{\"kind\":\"dev\",\"name\":\"once_cell\",\"req\":\"^1.13.0\"},{\"kind\":\"dev\",\"name\":\"rayon\",\"req\":\"^1.3\"},{\"default_features\":false,\"name\":\"semver\",\"optional\":true,\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.166\"}],\"features\":{\"component-model\":[\"dep:semver\"],\"default\":[\"std\",\"validate\",\"serde\",\"features\",\"component-model\",\"hash-collections\",\"simd\"],\"features\":[],\"hash-collections\":[\"dep:hashbrown\",\"dep:indexmap\"],\"prefer-btree-collections\":[],\"serde\":[\"dep:serde\",\"indexmap?/serde\",\"hashbrown?/serde\"],\"simd\":[],\"std\":[\"indexmap?/std\"],\"validate\":[]}}", "wayland-backend_0.3.12": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"concat-idents\",\"req\":\"^1.1\"},{\"name\":\"downcast-rs\",\"req\":\"^1.2\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.10\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"raw-window-handle\",\"optional\":true,\"req\":\"^0.5.0\"},{\"features\":[\"event\",\"fs\",\"net\",\"process\"],\"name\":\"rustix\",\"req\":\"^1.0.2\"},{\"name\":\"rwh_06\",\"optional\":true,\"package\":\"raw-window-handle\",\"req\":\"^0.6.0\"},{\"name\":\"scoped-tls\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"scoped-tls\",\"req\":\"^1.0\"},{\"features\":[\"union\",\"const_generics\",\"const_new\"],\"name\":\"smallvec\",\"req\":\"^1.9\"},{\"name\":\"wayland-sys\",\"req\":\"^0.31.8\"}],\"features\":{\"client_system\":[\"wayland-sys/client\",\"dep:scoped-tls\"],\"dlopen\":[\"wayland-sys/dlopen\"],\"server_system\":[\"wayland-sys/server\",\"dep:scoped-tls\"]}}", "wayland-client_0.31.12": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"futures-channel\",\"req\":\"^0.3.16\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"event\"],\"name\":\"rustix\",\"req\":\"^1.0.2\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.2\"},{\"name\":\"wayland-backend\",\"req\":\"^0.3.12\"},{\"name\":\"wayland-scanner\",\"req\":\"^0.31.8\"}],\"features\":{}}", @@ -1711,12 +1739,12 @@ "web-time_1.1.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-channel\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_feature = \\\"atomics\\\"))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_feature = \\\"atomics\\\"))\"},{\"features\":[\"js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"name\":\"js-sys\",\"req\":\"^0.3.20\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"macro\"],\"kind\":\"dev\",\"name\":\"pollster\",\"req\":\"^0.3\",\"target\":\"cfg(not(target_family = \\\"wasm\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"req\":\"^0.2.70\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-futures\",\"req\":\"^0.4\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"},{\"features\":[\"WorkerGlobalScope\"],\"kind\":\"dev\",\"name\":\"web-sys\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_feature = \\\"atomics\\\"))\"},{\"features\":[\"CssStyleDeclaration\",\"Document\",\"Element\",\"HtmlTableElement\",\"HtmlTableRowElement\",\"Performance\",\"Window\"],\"kind\":\"dev\",\"name\":\"web-sys\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"serde\":[\"dep:serde\"]}}", "webbrowser_1.0.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"actix-files\",\"req\":\"^0.6\"},{\"kind\":\"dev\",\"name\":\"actix-web\",\"req\":\"^4\"},{\"name\":\"core-foundation\",\"req\":\"^0.10\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"crossbeam-channel\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.9.0\"},{\"name\":\"jni\",\"req\":\"^0.21\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"ndk-context\",\"req\":\"^0.1\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"kind\":\"dev\",\"name\":\"ndk-glue\",\"req\":\">=0.3, <=0.7\",\"target\":\"cfg(target_os = \\\"android\\\")\"},{\"name\":\"objc2\",\"req\":\"^0.6\",\"target\":\"cfg(any(target_os = \\\"ios\\\", target_os = \\\"tvos\\\", target_os = \\\"visionos\\\"))\"},{\"default_features\":false,\"features\":[\"std\",\"NSDictionary\",\"NSString\",\"NSURL\"],\"name\":\"objc2-foundation\",\"req\":\"^0.3\",\"target\":\"cfg(any(target_os = \\\"ios\\\", target_os = \\\"tvos\\\", target_os = \\\"visionos\\\"))\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"serial_test\",\"req\":\"^0.10\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"url\",\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"urlencoding\",\"req\":\"^2.1\"},{\"features\":[\"Window\"],\"name\":\"web-sys\",\"req\":\"^0.3\",\"target\":\"cfg(target_family = \\\"wasm\\\")\"}],\"features\":{\"disable-wsl\":[],\"hardened\":[],\"wasm-console\":[\"web-sys/console\"]}}", "webpki-root-certs_1.0.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"percent-encoding\",\"req\":\"^2.3\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17.0\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17.0\"}],\"features\":{}}", - "webpki-roots_0.26.11": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"name\":\"parent\",\"package\":\"webpki-roots\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"percent-encoding\",\"req\":\"^2.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.13\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.102\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"yasna\",\"req\":\"^0.5.2\"}],\"features\":{}}", "webpki-roots_1.0.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"percent-encoding\",\"req\":\"^2.3\"},{\"default_features\":false,\"name\":\"pki-types\",\"package\":\"rustls-pki-types\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"rcgen\",\"req\":\"^0.14.3\"},{\"kind\":\"dev\",\"name\":\"ring\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"rustls\",\"req\":\"^0.23\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"webpki\",\"package\":\"rustls-webpki\",\"req\":\"^0.103\"},{\"kind\":\"dev\",\"name\":\"x509-parser\",\"req\":\"^0.17.0\"},{\"kind\":\"dev\",\"name\":\"yasna\",\"req\":\"^0.5.2\"}],\"features\":{}}", "weezl_0.1.12": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"futures\",\"optional\":true,\"req\":\"^0.3.12\"},{\"default_features\":false,\"features\":[\"macros\",\"io-util\",\"net\",\"rt\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"compat\"],\"kind\":\"dev\",\"name\":\"tokio-util\",\"req\":\"^0.6.2\"}],\"features\":{\"alloc\":[],\"async\":[\"futures\",\"std\"],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", "which_6.0.3": "{\"dependencies\":[{\"name\":\"either\",\"req\":\"^1.9.0\"},{\"name\":\"home\",\"req\":\"^0.5.9\",\"target\":\"cfg(any(windows, unix, target_os = \\\"redox\\\"))\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.10.2\"},{\"default_features\":false,\"features\":[\"fs\",\"std\"],\"name\":\"rustix\",\"req\":\"^0.38.30\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\", target_os = \\\"redox\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.9.0\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.40\"},{\"features\":[\"kernel\"],\"name\":\"winsafe\",\"req\":\"^0.0.19\",\"target\":\"cfg(windows)\"}],\"features\":{\"regex\":[\"dep:regex\"],\"tracing\":[\"dep:tracing\"]}}", "which_8.0.0": "{\"dependencies\":[{\"name\":\"env_home\",\"optional\":true,\"req\":\"^0.1.0\",\"target\":\"cfg(any(windows, unix, target_os = \\\"redox\\\"))\"},{\"name\":\"regex\",\"optional\":true,\"req\":\"^1.10.2\"},{\"default_features\":false,\"features\":[\"fs\",\"std\"],\"name\":\"rustix\",\"optional\":true,\"req\":\"^1.0.5\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\", target_os = \\\"redox\\\"))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.9.0\"},{\"default_features\":false,\"name\":\"tracing\",\"optional\":true,\"req\":\"^0.1.40\"},{\"features\":[\"kernel\"],\"name\":\"winsafe\",\"optional\":true,\"req\":\"^0.0.19\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"real-sys\"],\"real-sys\":[\"dep:env_home\",\"dep:rustix\",\"dep:winsafe\"],\"regex\":[\"dep:regex\"],\"tracing\":[\"dep:tracing\"]}}", "whoami_1.6.1": "{\"dependencies\":[{\"name\":\"libredox\",\"req\":\"^0.1.1\",\"target\":\"cfg(all(target_os = \\\"redox\\\", not(target_arch = \\\"wasm32\\\")))\"},{\"name\":\"wasite\",\"req\":\"^0.1\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"wasi\\\"))\"},{\"features\":[\"Navigator\",\"Document\",\"Window\",\"Location\"],\"name\":\"web-sys\",\"optional\":true,\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(target_os = \\\"wasi\\\"), not(daku)))\"}],\"features\":{\"default\":[\"web\"],\"web\":[\"web-sys\"]}}", + "whoami_2.1.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.186\",\"target\":\"cfg(all(any(target_vendor = \\\"apple\\\", target_os = \\\"linux\\\", target_os = \\\"dragonfly\\\", target_os = \\\"freebsd\\\", target_os = \\\"netbsd\\\", target_os = \\\"openbsd\\\", target_os = \\\"illumos\\\", target_os = \\\"hurd\\\"), not(target_arch = \\\"wasm32\\\")))\"},{\"default_features\":false,\"features\":[\"call\"],\"name\":\"libredox\",\"optional\":true,\"req\":\"^0.1.16\",\"target\":\"cfg(all(target_os = \\\"redox\\\", not(target_arch = \\\"wasm32\\\")))\"},{\"default_features\":false,\"features\":[\"SCDynamicStore\",\"SCDynamicStoreCopySpecific\"],\"name\":\"objc2-system-configuration\",\"optional\":true,\"req\":\"^0.3.2\",\"target\":\"cfg(target_vendor = \\\"apple\\\")\"},{\"default_features\":false,\"name\":\"wasite\",\"optional\":true,\"req\":\"^1.0.2\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"wasi\\\"))\"},{\"default_features\":false,\"features\":[\"Navigator\",\"Document\",\"Window\",\"Location\"],\"name\":\"web-sys\",\"optional\":true,\"req\":\"^0.3.85\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(target_os = \\\"wasi\\\"), not(daku)))\"}],\"features\":{\"default\":[\"std\",\"wasi-wasite\",\"wasm-web\"],\"force-stub\":[],\"std\":[\"dep:libc\",\"dep:libredox\",\"dep:objc2-system-configuration\",\"libc?/std\",\"web-sys?/std\"],\"wasi-wasite\":[\"dep:wasite\"],\"wasm-web\":[\"dep:web-sys\"]}}", "widestring_1.2.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"debugger_test\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"debugger_test_parser\",\"req\":\"^0.1\"},{\"features\":[\"Win32_System_Diagnostics_Debug\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.59\"}],\"features\":{\"alloc\":[],\"debugger_visualizer\":[\"alloc\"],\"default\":[\"std\"],\"std\":[\"alloc\"]}}", "wildcard_0.3.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"quickcheck_macros\",\"req\":\"^1.0.0\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.10.5\"},{\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.203\"},{\"default_features\":false,\"name\":\"thiserror\",\"req\":\"^2.0.3\"},{\"kind\":\"dev\",\"name\":\"toml\",\"req\":\"^0.8.14\"},{\"kind\":\"dev\",\"name\":\"wildmatch\",\"req\":\"^2.3.4\"}],\"features\":{\"fatal-warnings\":[]}}", "wildmatch_2.6.1": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"glob\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"ntest\",\"req\":\"^0.9.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.10.2\"},{\"kind\":\"dev\",\"name\":\"regex-lite\",\"req\":\"^0.1.5\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"serde\":[\"dep:serde\"]}}", @@ -1789,7 +1817,6 @@ "winnow_0.7.15": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"annotate-snippets\",\"req\":\"^0.11.4\"},{\"name\":\"anstream\",\"optional\":true,\"req\":\"^0.6.15\"},{\"name\":\"anstyle\",\"optional\":true,\"req\":\"^1.0.8\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.100\"},{\"kind\":\"dev\",\"name\":\"automod\",\"req\":\"^1.0.15\"},{\"kind\":\"dev\",\"name\":\"circular\",\"req\":\"^0.3.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.1\"},{\"name\":\"is_terminal_polyfill\",\"optional\":true,\"req\":\"^1.48.1\"},{\"kind\":\"dev\",\"name\":\"lexopt\",\"req\":\"^0.3.1\"},{\"default_features\":false,\"name\":\"memchr\",\"optional\":true,\"req\":\"^2.7\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.6.0\"},{\"kind\":\"dev\",\"name\":\"rustc-hash\",\"req\":\"^2.1.1\"},{\"features\":[\"examples\"],\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6.21\"},{\"kind\":\"dev\",\"name\":\"term-transcript\",\"req\":\"^0.2.0\"},{\"name\":\"terminal_size\",\"optional\":true,\"req\":\"^0.4.3\"}],\"features\":{\"alloc\":[],\"debug\":[\"std\",\"dep:anstream\",\"dep:anstyle\",\"dep:is_terminal_polyfill\",\"dep:terminal_size\"],\"default\":[\"std\"],\"simd\":[\"dep:memchr\"],\"std\":[\"alloc\",\"memchr?/std\"],\"unstable-doc\":[\"alloc\",\"std\",\"simd\",\"unstable-recover\"],\"unstable-recover\":[]}}", "winreg_0.10.1": "{\"dependencies\":[{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.6\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.3\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"~3.0\"},{\"features\":[\"impl-default\",\"impl-debug\",\"minwindef\",\"minwinbase\",\"timezoneapi\",\"winerror\",\"winnt\",\"winreg\",\"handleapi\"],\"name\":\"winapi\",\"req\":\"^0.3.9\"}],\"features\":{\"serialization-serde\":[\"transactions\",\"serde\"],\"transactions\":[\"winapi/ktmw32\"]}}", "winreg_0.50.0": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4.6\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.3\"},{\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_bytes\",\"req\":\"^0.11\"},{\"kind\":\"dev\",\"name\":\"serde_derive\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"~3.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Time\",\"Win32_System_Registry\",\"Win32_Security\",\"Win32_Storage_FileSystem\",\"Win32_System_Diagnostics_Debug\"],\"name\":\"windows-sys\",\"req\":\"^0.48.0\"}],\"features\":{\"serialization-serde\":[\"transactions\",\"serde\"],\"transactions\":[]}}", - "winres_0.1.12": "{\"dependencies\":[{\"name\":\"toml\",\"req\":\"^0.5\"},{\"features\":[\"winnt\"],\"kind\":\"dev\",\"name\":\"winapi\",\"req\":\"^0.3\"}],\"features\":{}}", "winsafe_0.0.19": "{\"dependencies\":[],\"features\":{\"comctl\":[\"ole\"],\"dshow\":[\"oleaut\"],\"dwm\":[\"uxtheme\"],\"dxgi\":[\"ole\"],\"gdi\":[\"user\"],\"gui\":[\"comctl\",\"shell\",\"uxtheme\"],\"kernel\":[],\"mf\":[\"oleaut\"],\"ole\":[\"user\"],\"oleaut\":[\"ole\"],\"shell\":[\"oleaut\"],\"taskschd\":[\"oleaut\"],\"user\":[\"kernel\"],\"uxtheme\":[\"gdi\",\"ole\"],\"version\":[\"kernel\"]}}", "winsplit_0.1.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.3\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "wiremock_0.6.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"actix-rt\",\"req\":\"^2.10.0\"},{\"name\":\"assert-json-diff\",\"req\":\"^2.0.2\"},{\"features\":[\"attributes\",\"tokio1\"],\"kind\":\"dev\",\"name\":\"async-std\",\"req\":\"^1.13.2\"},{\"name\":\"base64\",\"req\":\"^0.22\"},{\"name\":\"deadpool\",\"req\":\"^0.12.2\"},{\"name\":\"futures\",\"req\":\"^0.3.31\"},{\"name\":\"http\",\"req\":\"^1.3\"},{\"name\":\"http-body-util\",\"req\":\"^0.1\"},{\"features\":[\"full\"],\"name\":\"hyper\",\"req\":\"^1.7\"},{\"features\":[\"tokio\",\"server\",\"http1\",\"http2\"],\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"log\",\"req\":\"^0.4\"},{\"name\":\"once_cell\",\"req\":\"^1\"},{\"name\":\"regex\",\"req\":\"^1\"},{\"features\":[\"json\"],\"kind\":\"dev\",\"name\":\"reqwest\",\"req\":\"^0.12.23\"},{\"name\":\"serde\",\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1\"},{\"features\":[\"rt\",\"macros\",\"net\"],\"name\":\"tokio\",\"req\":\"^1.47.1\"},{\"features\":[\"macros\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.47.1\"},{\"name\":\"url\",\"req\":\"^2.5\"}],\"features\":{}}", @@ -1813,7 +1840,9 @@ "yansi_1.0.1": "{\"dependencies\":[{\"name\":\"is-terminal\",\"optional\":true,\"req\":\"^0.4.11\"}],\"features\":{\"_nightly\":[],\"alloc\":[],\"default\":[\"std\"],\"detect-env\":[\"std\"],\"detect-tty\":[\"is-terminal\",\"std\"],\"hyperlink\":[\"std\"],\"std\":[\"alloc\"]}}", "yasna_0.5.2": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\"],\"name\":\"bit-vec\",\"optional\":true,\"req\":\"^0.6.1\"},{\"name\":\"num-bigint\",\"optional\":true,\"req\":\"^0.4\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.1\"}],\"features\":{\"default\":[],\"std\":[]}}", "yoke-derive_0.8.1": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.61\"},{\"name\":\"quote\",\"req\":\"^1.0.28\"},{\"features\":[\"fold\"],\"name\":\"syn\",\"req\":\"^2.0.21\"},{\"name\":\"synstructure\",\"req\":\"^0.13.0\"}],\"features\":{}}", + "yoke-derive_0.8.2": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.61\"},{\"name\":\"quote\",\"req\":\"^1.0.44\"},{\"features\":[\"fold\"],\"name\":\"syn\",\"req\":\"^2.0.21\"},{\"name\":\"synstructure\",\"req\":\"^0.13.0\"}],\"features\":{}}", "yoke_0.8.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"req\":\"^1.2.0\"},{\"default_features\":false,\"name\":\"yoke-derive\",\"optional\":true,\"req\":\"^0.8.0\"},{\"default_features\":false,\"name\":\"zerofrom\",\"optional\":true,\"req\":\"^0.1.3\"}],\"features\":{\"alloc\":[\"stable_deref_trait/alloc\",\"zerofrom/alloc\"],\"default\":[\"alloc\",\"zerofrom\"],\"derive\":[\"dep:yoke-derive\",\"zerofrom/derive\"],\"serde\":[],\"zerofrom\":[\"dep:zerofrom\"]}}", + "yoke_0.8.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"stable_deref_trait\",\"req\":\"^1.2.0\"},{\"default_features\":false,\"name\":\"yoke-derive\",\"optional\":true,\"req\":\"^0.8.2\"},{\"default_features\":false,\"name\":\"zerofrom\",\"optional\":true,\"req\":\"^0.1.6\"}],\"features\":{\"alloc\":[\"stable_deref_trait/alloc\",\"zerofrom/alloc\"],\"default\":[\"alloc\",\"zerofrom\"],\"derive\":[\"dep:yoke-derive\",\"zerofrom/derive\"],\"serde\":[],\"zerofrom\":[\"dep:zerofrom\"]}}", "zbus_4.4.0": "{\"dependencies\":[{\"name\":\"async-broadcast\",\"req\":\"^0.7.0\"},{\"name\":\"async-executor\",\"optional\":true,\"req\":\"^1.11.0\"},{\"name\":\"async-fs\",\"optional\":true,\"req\":\"^2.1.2\"},{\"name\":\"async-io\",\"optional\":true,\"req\":\"^2.3.2\"},{\"name\":\"async-lock\",\"optional\":true,\"req\":\"^3.3.0\"},{\"name\":\"async-process\",\"req\":\"^2.2.2\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"name\":\"async-recursion\",\"req\":\"^1.1.1\",\"target\":\"cfg(any(target_os = \\\"macos\\\", windows))\"},{\"name\":\"async-task\",\"optional\":true,\"req\":\"^4.7.1\"},{\"name\":\"async-trait\",\"req\":\"^0.1.80\"},{\"name\":\"blocking\",\"optional\":true,\"req\":\"^1.6.0\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3.3\"},{\"features\":[\"serde\"],\"name\":\"enumflags2\",\"req\":\"^0.7.9\"},{\"name\":\"event-listener\",\"req\":\"^5.3.0\"},{\"name\":\"futures-core\",\"req\":\"^0.3.30\"},{\"name\":\"futures-sink\",\"req\":\"^0.3.30\"},{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"req\":\"^0.3.30\"},{\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.30\"},{\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"default_features\":false,\"features\":[\"socket\",\"uio\",\"user\"],\"name\":\"nix\",\"req\":\"^0.29\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"ntest\",\"req\":\"^0.9.2\"},{\"name\":\"ordered-stream\",\"req\":\"^0.2\"},{\"name\":\"rand\",\"req\":\"^0.8.5\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.200\"},{\"name\":\"serde_repr\",\"req\":\"^0.1.19\"},{\"features\":[\"std\"],\"name\":\"sha1\",\"req\":\"^0.10.6\"},{\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.1\"},{\"default_features\":false,\"features\":[\"trace\"],\"kind\":\"dev\",\"name\":\"test-log\",\"req\":\"^0.2.16\"},{\"features\":[\"rt\",\"net\",\"time\",\"fs\",\"io-util\",\"process\",\"sync\",\"tracing\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1.37.0\"},{\"features\":[\"macros\",\"rt-multi-thread\",\"fs\",\"io-util\",\"net\",\"sync\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.37.0\"},{\"name\":\"tokio-vsock\",\"optional\":true,\"req\":\"^0.4\"},{\"name\":\"tracing\",\"req\":\"^0.1.40\"},{\"default_features\":false,\"features\":[\"env-filter\",\"fmt\",\"ansi\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.18\"},{\"name\":\"uds_windows\",\"req\":\"^1.1.0\",\"target\":\"cfg(windows)\"},{\"name\":\"vsock\",\"optional\":true,\"req\":\"^0.5.0\"},{\"features\":[\"Win32_Foundation\",\"Win32_Security_Authorization\",\"Win32_System_Memory\",\"Win32_Networking\",\"Win32_Networking_WinSock\",\"Win32_NetworkManagement\",\"Win32_NetworkManagement_IpHelper\",\"Win32_System_Threading\"],\"name\":\"windows-sys\",\"req\":\"^0.52\",\"target\":\"cfg(windows)\"},{\"name\":\"xdg-home\",\"req\":\"^1.1.0\"},{\"name\":\"zbus_macros\",\"req\":\"=4.4.0\"},{\"name\":\"zbus_names\",\"req\":\"^3.0\"},{\"kind\":\"dev\",\"name\":\"zbus_xml\",\"req\":\"^4.0.0\"},{\"default_features\":false,\"features\":[\"enumflags2\"],\"name\":\"zvariant\",\"req\":\"^4.2.0\"}],\"features\":{\"async-io\":[\"dep:async-io\",\"async-executor\",\"async-task\",\"async-lock\",\"async-fs\",\"blocking\",\"futures-util/io\"],\"bus-impl\":[\"p2p\"],\"chrono\":[\"zvariant/chrono\"],\"default\":[\"async-io\"],\"heapless\":[\"zvariant/heapless\"],\"option-as-array\":[\"zvariant/option-as-array\"],\"p2p\":[],\"time\":[\"zvariant/time\"],\"tokio\":[\"dep:tokio\"],\"tokio-vsock\":[\"dep:tokio-vsock\",\"tokio\"],\"url\":[\"zvariant/url\"],\"uuid\":[\"zvariant/uuid\"],\"vsock\":[\"dep:vsock\",\"dep:async-io\"]}}", "zbus_macros_4.4.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"async-io\",\"req\":\"^2.3.2\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.30\"},{\"name\":\"proc-macro-crate\",\"req\":\"^3.1.0\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.81\"},{\"name\":\"quote\",\"req\":\"^1.0.36\"},{\"kind\":\"dev\",\"name\":\"rustversion\",\"req\":\"^1.0.15\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.200\"},{\"features\":[\"extra-traits\",\"fold\",\"full\"],\"name\":\"syn\",\"req\":\"^2.0.64\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1.0.93\"},{\"name\":\"zvariant_utils\",\"req\":\"=2.1.0\"}],\"features\":{}}", "zbus_names_3.0.0": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"static_assertions\",\"req\":\"^1.1.0\"},{\"default_features\":false,\"features\":[\"enumflags2\"],\"name\":\"zvariant\",\"req\":\"^4.0.0\"}],\"features\":{}}", @@ -1826,14 +1855,17 @@ "zeroize_1.8.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"zeroize_derive\",\"optional\":true,\"req\":\"^1.3\"}],\"features\":{\"aarch64\":[],\"alloc\":[],\"default\":[\"alloc\"],\"derive\":[\"zeroize_derive\"],\"simd\":[],\"std\":[\"alloc\"]}}", "zeroize_derive_1.4.3": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\",\"extra-traits\",\"visit\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", "zerotrie_0.2.3": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"litemap\",\"optional\":true,\"req\":\"^0.8.0\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"yoke\",\"optional\":true,\"req\":\"^0.8.0\"},{\"default_features\":false,\"name\":\"zerofrom\",\"optional\":true,\"req\":\"^0.1.3\"},{\"default_features\":false,\"name\":\"zerovec\",\"optional\":true,\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[],\"databake\":[\"dep:databake\",\"zerovec?/databake\"],\"default\":[],\"litemap\":[\"dep:litemap\",\"alloc\"],\"serde\":[\"dep:serde_core\",\"dep:litemap\",\"alloc\",\"litemap/serde\",\"zerovec?/serde\"],\"yoke\":[\"dep:yoke\"],\"zerofrom\":[\"dep:zerofrom\"]}}", + "zerotrie_0.2.4": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2.3\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"icu_locale_core\",\"req\":\"^2.2.0\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"litemap\",\"optional\":true,\"req\":\"^0.8.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rmp-serde\",\"req\":\"^1.2.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"default_features\":false,\"name\":\"serde_core\",\"optional\":true,\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"yoke\",\"optional\":true,\"req\":\"^0.8.2\"},{\"default_features\":false,\"name\":\"zerofrom\",\"optional\":true,\"req\":\"^0.1.6\"},{\"default_features\":false,\"name\":\"zerovec\",\"optional\":true,\"req\":\"^0.11.6\"}],\"features\":{\"alloc\":[\"zerovec?/alloc\"],\"databake\":[\"dep:databake\",\"zerovec?/databake\"],\"default\":[],\"dense\":[\"dep:zerovec\"],\"litemap\":[\"dep:litemap\",\"alloc\"],\"serde\":[\"dep:serde_core\",\"dep:litemap\",\"alloc\",\"litemap/serde\",\"zerovec?/serde\"],\"yoke\":[\"dep:yoke\"],\"zerofrom\":[\"dep:zerofrom\"],\"zerovec\":[\"dep:zerovec\"]}}", "zerovec-derive_0.11.2": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.61\"},{\"name\":\"quote\",\"req\":\"^1.0.28\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"features\":[\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0.21\"}],\"features\":{}}", + "zerovec-derive_0.11.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0.61\"},{\"name\":\"quote\",\"req\":\"^1.0.44\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"features\":[\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0.21\"}],\"features\":{}}", "zerovec_0.11.5": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"xxhash64\"],\"name\":\"twox-hash\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"yoke\",\"optional\":true,\"req\":\"^0.8.0\"},{\"default_features\":false,\"name\":\"zerofrom\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"name\":\"zerovec-derive\",\"optional\":true,\"req\":\"^0.11.1\"}],\"features\":{\"alloc\":[\"serde?/alloc\"],\"databake\":[\"dep:databake\"],\"derive\":[\"dep:zerovec-derive\"],\"hashmap\":[\"dep:twox-hash\",\"alloc\"],\"serde\":[\"dep:serde\"],\"std\":[],\"yoke\":[\"dep:yoke\"]}}", + "zerovec_0.11.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"features\":[\"wasm_js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"iai\",\"req\":\"^0.1.1\"},{\"features\":[\"json\"],\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.43.2\"},{\"default_features\":false,\"features\":[\"use-std\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_distr\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rmp-serde\",\"req\":\"^1.2.0\"},{\"default_features\":false,\"name\":\"schemars\",\"optional\":true,\"req\":\"^1.0.4\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"features\":[\"xxhash64\"],\"name\":\"twox-hash\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"yoke\",\"optional\":true,\"req\":\"^0.8.2\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"yoke\",\"req\":\"^0.8.2\"},{\"default_features\":false,\"name\":\"zerofrom\",\"req\":\"^0.1.6\"},{\"default_features\":false,\"name\":\"zerovec-derive\",\"optional\":true,\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"serde?/alloc\"],\"databake\":[\"dep:databake\"],\"derive\":[\"dep:zerovec-derive\"],\"hashmap\":[\"dep:twox-hash\",\"alloc\"],\"schemars\":[\"dep:schemars\",\"alloc\"],\"serde\":[\"dep:serde\"],\"std\":[],\"yoke\":[\"dep:yoke\"]}}", "zip_0.6.6": "{\"dependencies\":[{\"name\":\"aes\",\"optional\":true,\"req\":\"^0.8.2\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"byteorder\",\"req\":\"^1.4.3\"},{\"name\":\"bzip2\",\"optional\":true,\"req\":\"^0.4.3\"},{\"name\":\"constant_time_eq\",\"optional\":true,\"req\":\"^0.1.5\"},{\"name\":\"crc32fast\",\"req\":\"^1.3.2\"},{\"name\":\"crossbeam-utils\",\"req\":\"^0.8.8\",\"target\":\"cfg(any(all(target_arch = \\\"arm\\\", target_pointer_width = \\\"32\\\"), target_arch = \\\"mips\\\", target_arch = \\\"powerpc\\\"))\"},{\"default_features\":false,\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.0.23\"},{\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2.5\"},{\"features\":[\"reset\"],\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12.1\"},{\"name\":\"pbkdf2\",\"optional\":true,\"req\":\"^0.11.0\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.7\"},{\"features\":[\"formatting\",\"macros\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.7\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3.2\"},{\"name\":\"zstd\",\"optional\":true,\"req\":\"^0.11.2\"}],\"features\":{\"aes-crypto\":[\"aes\",\"constant_time_eq\",\"hmac\",\"pbkdf2\",\"sha1\"],\"default\":[\"aes-crypto\",\"bzip2\",\"deflate\",\"time\",\"zstd\"],\"deflate\":[\"flate2/rust_backend\"],\"deflate-miniz\":[\"flate2/default\"],\"deflate-zlib\":[\"flate2/zlib\"],\"unreserved\":[]}}", "zip_2.4.2": "{\"dependencies\":[{\"name\":\"aes\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.95\"},{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"req\":\"^1.4.1\",\"target\":\"cfg(fuzzing)\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"bzip2\",\"optional\":true,\"req\":\"^0.5.0\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"=4.4.18\"},{\"name\":\"constant_time_eq\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"crc32fast\",\"req\":\"^1.4\"},{\"name\":\"crossbeam-utils\",\"req\":\"^0.8.21\",\"target\":\"cfg(any(all(target_arch = \\\"arm\\\", target_pointer_width = \\\"32\\\"), target_arch = \\\"mips\\\", target_arch = \\\"powerpc\\\"))\"},{\"name\":\"deflate64\",\"optional\":true,\"req\":\"^0.1.9\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"wasm_js\",\"std\"],\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3.1\"},{\"features\":[\"wasm_js\",\"std\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.3.1\"},{\"features\":[\"reset\"],\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"indexmap\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"lzma-rs\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"memchr\",\"req\":\"^2.7\"},{\"default_features\":false,\"name\":\"nt-time\",\"optional\":true,\"req\":\"^0.10.6\"},{\"name\":\"pbkdf2\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.15\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.37\"},{\"default_features\":false,\"features\":[\"formatting\",\"macros\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.37\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5\"},{\"name\":\"xz2\",\"optional\":true,\"req\":\"^0.1.7\"},{\"features\":[\"zeroize_derive\"],\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.8\"},{\"name\":\"zopfli\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"zstd\",\"optional\":true,\"req\":\"^0.13\"}],\"features\":{\"_all-features\":[],\"_deflate-any\":[],\"aes-crypto\":[\"aes\",\"constant_time_eq\",\"hmac\",\"pbkdf2\",\"sha1\",\"getrandom\",\"zeroize\"],\"chrono\":[\"chrono/default\"],\"default\":[\"aes-crypto\",\"bzip2\",\"deflate64\",\"deflate\",\"lzma\",\"time\",\"zstd\",\"xz\"],\"deflate\":[\"flate2/rust_backend\",\"deflate-zopfli\",\"deflate-flate2\"],\"deflate-flate2\":[\"_deflate-any\"],\"deflate-miniz\":[\"deflate\",\"deflate-flate2\"],\"deflate-zlib\":[\"flate2/zlib\",\"deflate-flate2\"],\"deflate-zlib-ng\":[\"flate2/zlib-ng\",\"deflate-flate2\"],\"deflate-zopfli\":[\"zopfli\",\"_deflate-any\"],\"lzma\":[\"lzma-rs/stream\"],\"nt-time\":[\"dep:nt-time\"],\"unreserved\":[],\"xz\":[\"dep:xz2\"]}}", "zlib-rs_0.6.3": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"crc32fast\",\"req\":\"^1.3.2\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.1\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1.0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"}],\"features\":{\"ZLIB_DEBUG\":[],\"__internal-api\":[],\"__internal-fuzz\":[\"arbitrary\"],\"__internal-fuzz-disable-checksum\":[],\"__internal-test\":[\"quickcheck\"],\"avx512\":[\"vpclmulqdq\"],\"c-allocator\":[],\"default\":[\"std\",\"c-allocator\"],\"rust-allocator\":[],\"std\":[\"rust-allocator\"],\"vpclmulqdq\":[]}}", "zmij_1.0.19": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"name\":\"no-panic\",\"optional\":true,\"req\":\"^0.1.36\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"num-integer\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"opt-level\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"ryu\",\"req\":\"^1\"}],\"features\":{}}", "zmij_1.0.21": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"name\":\"no-panic\",\"optional\":true,\"req\":\"^0.1.36\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"num-integer\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"opt-level\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"ryu\",\"req\":\"^1\"}],\"features\":{}}", - "zoneinfo64_0.2.1": "{\"dependencies\":[{\"default_features\":false,\"name\":\"calendrical_calculations\",\"req\":\"^0.2.3\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"chrono-tz\",\"req\":\"^0.10.4\"},{\"default_features\":false,\"name\":\"icu_locale_core\",\"req\":\"^2.1.0\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"features\":[\"tzdb-bundle-always\",\"std\"],\"kind\":\"dev\",\"name\":\"jiff\",\"req\":\"^0.2.15\"},{\"default_features\":false,\"name\":\"potential_utf\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"name\":\"resb\",\"req\":\"^0.1.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.220\"}],\"features\":{\"chrono\":[\"dep:chrono\"]}}", + "zoneinfo64_0.3.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"calendrical_calculations\",\"req\":\"^0.2.4\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"chrono-tz\",\"req\":\"^0.10.4\"},{\"default_features\":false,\"name\":\"icu_locale_core\",\"req\":\"^2.2.0\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"features\":[\"tzdb-bundle-always\",\"std\"],\"kind\":\"dev\",\"name\":\"jiff\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"potential_utf\",\"req\":\"^0.1.3\"},{\"default_features\":false,\"name\":\"resb\",\"req\":\"^0.1.2\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0.220\"}],\"features\":{\"chrono\":[\"dep:chrono\"]}}", "zopfli_0.8.3": "{\"dependencies\":[{\"name\":\"bumpalo\",\"req\":\"^3.19.0\"},{\"default_features\":false,\"name\":\"crc32fast\",\"optional\":true,\"req\":\"^1.5.0\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.28\"},{\"kind\":\"dev\",\"name\":\"miniz_oxide\",\"req\":\"^0.8.9\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.7.0\"},{\"kind\":\"dev\",\"name\":\"proptest-derive\",\"req\":\"^0.6.0\"},{\"default_features\":false,\"name\":\"simd-adler32\",\"optional\":true,\"req\":\"^0.3.7\"}],\"features\":{\"default\":[\"gzip\",\"std\",\"zlib\"],\"gzip\":[\"dep:crc32fast\"],\"nightly\":[\"crc32fast?/nightly\"],\"std\":[\"crc32fast?/std\",\"dep:log\",\"simd-adler32?/std\"],\"zlib\":[\"dep:simd-adler32\"]}}", "zstd-safe_5.0.2+zstd.1.5.2": "{\"dependencies\":[{\"name\":\"libc\",\"req\":\"^0.2.21\"},{\"default_features\":false,\"name\":\"zstd-sys\",\"req\":\"^2.0.1\"}],\"features\":{\"arrays\":[],\"bindgen\":[\"zstd-sys/bindgen\"],\"debug\":[\"zstd-sys/debug\"],\"default\":[\"legacy\",\"arrays\",\"zdict_builder\"],\"doc-cfg\":[],\"experimental\":[\"zstd-sys/experimental\"],\"legacy\":[\"zstd-sys/legacy\"],\"no_asm\":[\"zstd-sys/no_asm\"],\"pkg-config\":[\"zstd-sys/pkg-config\"],\"std\":[\"zstd-sys/std\"],\"thin\":[\"zstd-sys/thin\"],\"zdict_builder\":[\"zstd-sys/zdict_builder\"],\"zstdmt\":[\"zstd-sys/zstdmt\"]}}", "zstd-safe_7.2.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"zstd-sys\",\"req\":\"^2.0.15\"}],\"features\":{\"arrays\":[],\"bindgen\":[\"zstd-sys/bindgen\"],\"debug\":[\"zstd-sys/debug\"],\"default\":[\"legacy\",\"arrays\",\"zdict_builder\"],\"doc-cfg\":[],\"experimental\":[\"zstd-sys/experimental\"],\"fat-lto\":[\"zstd-sys/fat-lto\"],\"legacy\":[\"zstd-sys/legacy\"],\"no_asm\":[\"zstd-sys/no_asm\"],\"pkg-config\":[\"zstd-sys/pkg-config\"],\"seekable\":[\"zstd-sys/seekable\"],\"std\":[\"zstd-sys/std\"],\"thin\":[\"zstd-sys/thin\"],\"thin-lto\":[\"zstd-sys/thin-lto\"],\"zdict_builder\":[\"zstd-sys/zdict_builder\"],\"zstdmt\":[\"zstd-sys/zstdmt\"]}}", @@ -1849,112 +1881,112 @@ "zvariant_utils_2.1.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0.81\"},{\"name\":\"quote\",\"req\":\"^1.0.36\"},{\"features\":[\"extra-traits\",\"full\"],\"name\":\"syn\",\"req\":\"^2.0.64\"}],\"features\":{}}" }, "@@rules_rs+//rs/experimental/toolchains:module_extension.bzl%toolchains": { - "cargo-1.93.0-aarch64-apple-darwin.tar.xz": "6443909350322ad07f09bb5edfd9ff29268e6fe88c7d78bfba7a5e254248dc25", - "cargo-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "387832b989c8eb96c9ebd66402a87962167633bc6d91a49ffb8c7903c45f3476", - "cargo-1.93.0-aarch64-pc-windows-msvc.tar.xz": "155bff7a16aa7054e7ed7c3a82e362d4b302b3882d751b823e06ff63ae3f103d", - "cargo-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "5998940b8b97286bb67facb1a85535eeb3d4d7a61e36a85e386e5c0c5cfe5266", - "cargo-1.93.0-x86_64-apple-darwin.tar.xz": "95a47c5ed797c35419908f04188d8b7de09946e71073c4b72632b16f5b10dfae", - "cargo-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "f19766837559f90476508140cb95cc708220012ec00a854fa9f99187b1f246b6", - "cargo-1.93.0-x86_64-pc-windows-msvc.tar.xz": "e59c5e2baa9ec17261f2cda6676ebf7b68b21a860e3f7451c4d964728951da75", - "cargo-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "c23de3ae709ff33eed5e4ae59d1f9bcd75fa4dbaa9fb92f7b06bfb534b8db880", - "clippy-1.93.0-aarch64-apple-darwin.tar.xz": "0b6e943a8d12be0e68575acf59c9ea102daf795055fcbbf862b0bfd35ec40039", - "clippy-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "296949f49be2de77ce9d1c5023a5f0a58e28e329eec03642a3da0e175a67beeb", - "clippy-1.93.0-aarch64-pc-windows-msvc.tar.xz": "07bcf2edb88cdf5ead2f02e4a8493e9b0ef935a31253fac6f9f3378d8023f113", - "clippy-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "872ae6d68d625946d281b91d928332e6b74f6ab269b6af842338df4338805a60", - "clippy-1.93.0-x86_64-apple-darwin.tar.xz": "e6d0b1afb9607c14a1172d09ee194a032bbb3e48af913d55c5a473e0559eddde", - "clippy-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "b6f1f7264ed6943c59dedfb9531fbadcc3c0fcf273c940a63d58898b14a1060f", - "clippy-1.93.0-x86_64-pc-windows-msvc.tar.xz": "25fb103390bf392980b4689ac09b2ec2ab4beefb7022a983215b613ad05eab57", - "clippy-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "793108977514b15c0f45ade28ae35c58b05370cb0f22e89bd98fdfa61eabf55d", - "rust-analyzer-1.93.0-aarch64-apple-darwin.tar.xz": "8a09a46d45277678b2d112eef61736e03e78dfa6c506e187df176b904659e5a7", - "rust-analyzer-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "1fb48fadbaea6da36cfcd6b72122eeaa6ab025c9c82441777cf913108f73c115", - "rust-analyzer-1.93.0-aarch64-pc-windows-msvc.tar.xz": "558100ab62fe22d552be43c1bf166fef057ff2f3eb0d786cf5fe509a08701f37", - "rust-analyzer-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "b2e7890cf5953f572eb05d7328c086fa60d4f16e5d301d35c52b3f58b88a8316", - "rust-analyzer-1.93.0-x86_64-apple-darwin.tar.xz": "3cb7975d4fd17840e2980c9d864755f801ee9e7594de886a5816c73b07594508", - "rust-analyzer-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "4471db6393b7380624899482aae99612fd1cf509ae36d0c68292077c2104127d", - "rust-analyzer-1.93.0-x86_64-pc-windows-msvc.tar.xz": "f4c1f8f120f48974cb1e0cea01a955ce52cdfa0f1db0355c5fb5a2deeca8188a", - "rust-analyzer-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "33fcd377be3b5ffdd95977c3d0219f63725c18b6c8b53fb5be0418962a84738c", - "rust-src-1.93.0.tar.xz": "0e7b9acd5debfeffef3741dc8f6edf137d70426a0027d6b190cc8cfb0a1ac23c", - "rust-std-1.93.0-aarch64-apple-darwin.tar.xz": "8603c63715349636ed85b4fe716c4e827a727918c840e54aff5b243cedadf19b", - "rust-std-1.93.0-aarch64-apple-ios-macabi.tar.xz": "24d47e615ce101869ff452a572a6b77ed7cf70f2454d0b50892ac849e8c7ac4d", - "rust-std-1.93.0-aarch64-apple-ios-sim.tar.xz": "d1d5e2d1b79206f2cc9fb7f6a2958cfe0f4bbc9147fda8dbc3608aa4be5e6816", - "rust-std-1.93.0-aarch64-apple-ios.tar.xz": "49228e70387773a71cf144509baf39979ab2cdb604340fff64b483ab41f61617", - "rust-std-1.93.0-aarch64-linux-android.tar.xz": "59c16648d9a29c07f63a1749cae6b73078f20fef1206c5e0f81c784ae8796cdb", - "rust-std-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "9a270d50eaaacc7cb1925565a8b65ff831686aa1592b7034bb9848d7f2a9738d", - "rust-std-1.93.0-aarch64-pc-windows-msvc.tar.xz": "f7bd3d25baf3643c8769b8c4d2e6cde45bb25042fac698e0daf19fc9f58f8568", - "rust-std-1.93.0-aarch64-unknown-fuchsia.tar.xz": "d1e46c443a9607603c810942e99a95a1acfb105d1312426b468ff68febaabf77", - "rust-std-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "84e82ff52c39c64dfd0e1c2d58fd3d5309d1d2502378131544c0d486b44af20a", - "rust-std-1.93.0-aarch64-unknown-linux-musl.tar.xz": "bab885a87da586040064064bd1c314d707164d8dc0fefee39d59be7f15ce6f7d", - "rust-std-1.93.0-aarch64-unknown-none-softfloat.tar.xz": "0f6305daf658a7d6c0efd075859cb60432c13b82e7ecee0d097074e4e1873391", - "rust-std-1.93.0-aarch64-unknown-none.tar.xz": "3cf1aa3309a8903e89bb20537113155ca4e88844c8cc9c34c43865d5ce5a6192", - "rust-std-1.93.0-aarch64-unknown-uefi.tar.xz": "317b0af124e0e124bd76b8e5a2fb0c600279177d0bed9c841a3202df2d0f7f8e", - "rust-std-1.93.0-arm-linux-androideabi.tar.xz": "d010b26fc88e28a93cc94ea6ca5d2c90efed7f9846fae1e40db7c183b50575e2", - "rust-std-1.93.0-arm-unknown-linux-gnueabi.tar.xz": "deedc54ffce099781986eed4aec0843866f1bf72904ab0f5cdb115b9c7af540e", - "rust-std-1.93.0-arm-unknown-linux-gnueabihf.tar.xz": "89e44e042bc1241b3999191c385fec8433d60a5a9fc373583cd3b2d9408d5074", - "rust-std-1.93.0-arm-unknown-linux-musleabi.tar.xz": "641a17acb5104637d4dc9c4be022a7927ae721eb08759fea96ecfaf5c60be4dc", - "rust-std-1.93.0-arm-unknown-linux-musleabihf.tar.xz": "94a92b454bf3b0aab046b257f555ccb08f16dc2dc281bea6a4ef17ea8f58cbdc", - "rust-std-1.93.0-armv7-linux-androideabi.tar.xz": "e295f26bb219a7a4ebb5c2e07fedfebb075be6830aaf910c742a57cd21018b6d", - "rust-std-1.93.0-armv7-unknown-linux-gnueabi.tar.xz": "8a7bd5227c78294864095edb07837ff32ff6c07cd1a4a418f9bcc3ebd7e79874", - "rust-std-1.93.0-armv7-unknown-linux-gnueabihf.tar.xz": "f015f9b2d588454a9dc62942ab2e800d82c602e4eab6f89f8213419491bcd203", - "rust-std-1.93.0-armv7-unknown-linux-musleabi.tar.xz": "d15d24c9fb7c15243e1341cea53590002df271060118914bd0efcda8ccbd0731", - "rust-std-1.93.0-armv7-unknown-linux-musleabihf.tar.xz": "a2e5ec22ed35fb51a503d1e10b37447b0fa7333f079585bc0b6a2eb599de43f3", - "rust-std-1.93.0-i686-linux-android.tar.xz": "68fd86f62dd63221717d1741210f0f5cf75da7a1e32eed5a46b1e67c9d9430e1", - "rust-std-1.93.0-i686-pc-windows-gnu.tar.xz": "cb613d5d1eb245e8a1f4c0b25f93c2997cd06c1cc3fc202155f2997aebf44d4d", - "rust-std-1.93.0-i686-pc-windows-gnullvm.tar.xz": "0f713dc252a6de706519fe6cdaab6d66aaf1b555133b536cc0ab28061aa4269c", - "rust-std-1.93.0-i686-pc-windows-msvc.tar.xz": "33dc1951e2dc21bd05361160d52f496eecf48e0b95df5083172698b1cd5b9a3f", - "rust-std-1.93.0-i686-unknown-freebsd.tar.xz": "67718aae1381879fdcca5699051eb87e0cda3d2fd0fe75e306ba0948b79df7db", - "rust-std-1.93.0-i686-unknown-linux-gnu.tar.xz": "b8b7020a61418b95c1ea26badaf8db6979778e28dbadddc81fb5010fe27c935b", - "rust-std-1.93.0-i686-unknown-linux-musl.tar.xz": "867e54b3e89dc0b6d2b7a538a80443add6c3990bb4bd2260dea2ed98a0dc9870", - "rust-std-1.93.0-i686-unknown-uefi.tar.xz": "929fd484b08d5b2077ff864f5f2d24b51a78f1b6e837b9eab9d7e8fb7f31adce", - "rust-std-1.93.0-powerpc-unknown-linux-gnu.tar.xz": "e851c0fa3e726ce3f7139c5803198a1aa9723594394734ac9e373c93d92e5ea3", - "rust-std-1.93.0-powerpc64-unknown-linux-gnu.tar.xz": "f729bb7d95705e12a92eb072e494b93d8822ca40aa4802ca780b0cf33b56d401", - "rust-std-1.93.0-powerpc64le-unknown-linux-gnu.tar.xz": "d209ac698a69ca9b9035adb97a0ed8e60a08db52960198c3e03b9ee714c1a46b", - "rust-std-1.93.0-powerpc64le-unknown-linux-musl.tar.xz": "34b98d5eca2fdbd6ba41b0faf14160ef1ebd038f6ecaa264d318ad33263e1cf1", - "rust-std-1.93.0-riscv32imc-unknown-none-elf.tar.xz": "71af84c81241cbc7811b267927990be025f30d7d3dc55df4b56da7ac250f7c78", - "rust-std-1.93.0-riscv64gc-unknown-linux-gnu.tar.xz": "b769fb6c9f3e0419a6bd0b7b79f9191bbd7a48a9f243b23eb7d135711aa6de1b", - "rust-std-1.93.0-riscv64gc-unknown-linux-musl.tar.xz": "a7ced602573d814d875d69022e026c1ccb520b4b2de9d430ddfd0966ec6c9643", - "rust-std-1.93.0-riscv64gc-unknown-none-elf.tar.xz": "842f72913f288a0c76601438e67ccd88c816dbf187587928e48bf8b9ce74cbf3", - "rust-std-1.93.0-s390x-unknown-linux-gnu.tar.xz": "41a65db45a288eb3eedb187b366f132d5b3615767de7ce994b123b342ac7a848", - "rust-std-1.93.0-thumbv6m-none-eabi.tar.xz": "be3f8aad5680dabb203300847dcbbabc15729170ba5c3a9c499efae4df410a9e", - "rust-std-1.93.0-thumbv7em-none-eabi.tar.xz": "8f93eefca39c0da417feddab64775f862c72bbe80da11914dcf47babef8f1644", - "rust-std-1.93.0-thumbv7em-none-eabihf.tar.xz": "a3b6914b966ac93dbe7531016d5f570b189445603c43614a60e0b9ea12674bd3", - "rust-std-1.93.0-thumbv7m-none-eabi.tar.xz": "cfa6227214f3ae58c06b36547c5bd6f0f6787764afa48cfa4ff3488264deab6c", - "rust-std-1.93.0-thumbv8m.main-none-eabi.tar.xz": "5f24df0aa8322561125575e365be7ad13a5bb26cf73c7fc9a3f4bcfa58e0febc", - "rust-std-1.93.0-thumbv8m.main-none-eabihf.tar.xz": "906b07580be2df277cced2b56bc03cb565b758c382bf3e82cbd8375b459815dd", - "rust-std-1.93.0-wasm32-unknown-emscripten.tar.xz": "63cdbb1ea7f353060539c00f7346f4f5fb0d6f09899cacddc1f172ef07c4af8b", - "rust-std-1.93.0-wasm32-unknown-unknown.tar.xz": "3100cb920ddac646943243f0eddd331128836b9161dd5f7b0a6c76375d39cc5e", - "rust-std-1.93.0-wasm32-wasip1-threads.tar.xz": "439c65dea31e855f0258632b6d19435ba8a80561297fa6dc6be48048c5cd1871", - "rust-std-1.93.0-wasm32-wasip1.tar.xz": "075de970ef865678dad258f1566d7cfe76a594698e9bf93dd69fa5cfdfcf1a6f", - "rust-std-1.93.0-wasm32-wasip2.tar.xz": "0ef01bb552036ab44456f5505015b13c88d3694629804d7af46452c8b0a48f8c", - "rust-std-1.93.0-x86_64-apple-darwin.tar.xz": "f112d41c8a31794f0f561d37fe77010ed0b405fa70284a2910891869d8c52418", - "rust-std-1.93.0-x86_64-apple-ios-macabi.tar.xz": "a543dd545747d372d973ace8b485a13603ce96c110c7ae734d605e45f6e162c5", - "rust-std-1.93.0-x86_64-apple-ios.tar.xz": "e151013b9bc5990e178285a33e62bae7700d8c48c06e97771abb1643aa907d75", - "rust-std-1.93.0-x86_64-linux-android.tar.xz": "dc05ca79d9fecc5ce3643adb9c6f89fd35c8e1d7146bf9b62e30bad41f9fb6a7", - "rust-std-1.93.0-x86_64-pc-windows-gnu.tar.xz": "a07c6ab596fad15ca7acd63ee7f2a5fea93fd421179252067e309c2aa0b2021b", - "rust-std-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "ef6cf0977bc5aa4bbd594afb9df4ba76fdd4f0fc5685cddbefff49ceed202a91", - "rust-std-1.93.0-x86_64-pc-windows-msvc.tar.xz": "2593e29af0b8def34ceb1185b8e85bd93a9e0d3b0c108d704c1b31370c50a48c", - "rust-std-1.93.0-x86_64-unknown-freebsd.tar.xz": "51b2feaff7c2d28633504ed92ab442a55d112e6a2bf09c91188f00dbaf03378a", - "rust-std-1.93.0-x86_64-unknown-fuchsia.tar.xz": "41f0f3eb96cedfc13ab5fd4f15065063f262d035c1f71d96c42587acdacdbabe", - "rust-std-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "a849a418d0f27e69573e41763c395e924a0b98c16fcdc55599c1c79c27c1c777", - "rust-std-1.93.0-x86_64-unknown-linux-musl.tar.xz": "874658d2ced1ed2b9bf66c148b78a2e10cad475d0a4db32e68a08900905b89b8", - "rust-std-1.93.0-x86_64-unknown-netbsd.tar.xz": "aad63193af89772031f9a85f193afc0b15f8e6d4a9a4983c5f5d3802f69a89e8", - "rust-std-1.93.0-x86_64-unknown-none.tar.xz": "01dcca7ae4b7e82fbfa399adb5e160afaa13143e5a17e1e0737c38cf07365fb3", - "rust-std-1.93.0-x86_64-unknown-uefi.tar.xz": "ec4e439d9485ce752b56999e8e41ed82373fc833a005cf2531c6f7ef7e785392", - "rustc-1.93.0-aarch64-apple-darwin.tar.xz": "092be03c02b44c405dab1232541c84f32b2d9e8295747568c3d531dd137221dc", - "rustc-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "d3bc0cdaf157e20b1f23e510b5e3c4c6e9117d08f5284c04dee60aecff1bc851", - "rustc-1.93.0-aarch64-pc-windows-msvc.tar.xz": "a3ac1a8e411de8470f71b366f89d187718c431526912b181692ed0a18c56c7ad", - "rustc-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "1a9045695892ec08d8e9751bf7cf7db71fe27a6202dd12ce13aca48d0602dbde", - "rustc-1.93.0-x86_64-apple-darwin.tar.xz": "594bb293f0a4f444656cf8dec2149fcb979c606260efee9e09bcf8c9c6ed6ae7", - "rustc-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "0cdaa8de66f5ce21d1ea73917efc5c64f408bda49f678ddde19465ced9d5ec63", - "rustc-1.93.0-x86_64-pc-windows-msvc.tar.xz": "fa17677eee0d83eb055b309953184bf87ba634923d8897f860cda65d55c6e350", - "rustc-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "00c6e6740ea6a795e33568cd7514855d58408a1180cd820284a7bbf7c46af715", - "rustfmt-1.93.0-aarch64-apple-darwin.tar.xz": "0dd1faedf0768ef362f4aae4424b34e8266f2b9cf5e76ea4fcaf780220b363a0", - "rustfmt-1.93.0-aarch64-pc-windows-gnullvm.tar.xz": "5888827e7fbd7d59930870b4856fce8d6d8fca5e02f6535f8ae3d7ad0ccf2d4a", - "rustfmt-1.93.0-aarch64-pc-windows-msvc.tar.xz": "24eed108489567133bbfe40c8eacda1567be55fae4c526911b39eb33eb27a6cb", - "rustfmt-1.93.0-aarch64-unknown-linux-gnu.tar.xz": "92e1acb45ae642136258b4dabb39302af2d53c83e56ebd5858bc969f9e5c141a", - "rustfmt-1.93.0-x86_64-apple-darwin.tar.xz": "c8453b4c5758eb39423042ffa9c23ed6128cbed2b15b581e5e1192c9cc0b1d4e", - "rustfmt-1.93.0-x86_64-pc-windows-gnullvm.tar.xz": "47167e9e78db9be4503a060dee02f4df2cda252da32175dbf44331f965a747b9", - "rustfmt-1.93.0-x86_64-pc-windows-msvc.tar.xz": "5becc7c2dba4b9ab5199012cad30829235a7f7fb5d85a238697e8f0e44cbd9af", - "rustfmt-1.93.0-x86_64-unknown-linux-gnu.tar.xz": "7f81f6c17d11a7fda5b4e1b111942fb3b23d30dcec767e13e340ebfb762a5e33" + "cargo-1.95.0-aarch64-apple-darwin.tar.xz": "6c2ffed8e1ac9cf4dc9e80f282a869a6b237a153e7c55cca039d33de29d80aaf", + "cargo-1.95.0-aarch64-pc-windows-gnullvm.tar.xz": "14683a8e0b0ee8afdd7e2896fc8e91a7ff0ba55c2e20912e639eea498f1e1d10", + "cargo-1.95.0-aarch64-pc-windows-msvc.tar.xz": "e645b30fa035a18aa12d28b699052014c7efa9dd4a33dabd223f0d16b5fa28e8", + "cargo-1.95.0-aarch64-unknown-linux-gnu.tar.xz": "7c070aeba9bbf12073646995a03f36c346bb5f541d0078ba6d9dc2a7adaaf6af", + "cargo-1.95.0-x86_64-apple-darwin.tar.xz": "e2e1131ade2dddc0d779e0ab3a6a990085c7a654951235742823c3a1ce0f190f", + "cargo-1.95.0-x86_64-pc-windows-gnullvm.tar.xz": "239c098b9878ad01ad5e0feeee377e6ee3311bc1534b1fb5eb630489d463bab2", + "cargo-1.95.0-x86_64-pc-windows-msvc.tar.xz": "cab2606cb2d0aa31c55d50512fe07a9f15e893227566fbeb448306760cd0d2bf", + "cargo-1.95.0-x86_64-unknown-linux-gnu.tar.xz": "e74edd2cf7d0f1f1383b4f00eb90c843750bc489e2ccf7214e6476678a907425", + "clippy-1.95.0-aarch64-apple-darwin.tar.xz": "fd183baa023d0c4e0c5b8184226e2d4c85126adf156cb1f3a726ec593bba8d62", + "clippy-1.95.0-aarch64-pc-windows-gnullvm.tar.xz": "b81d0fe05c4ec514aefaffdf0649b175a2f82572163202c17531358f196b6168", + "clippy-1.95.0-aarch64-pc-windows-msvc.tar.xz": "44c1b7ada72aa8f3fcaceb37a3899665bc9b160c2fea77879c8ecb65a9e97eba", + "clippy-1.95.0-aarch64-unknown-linux-gnu.tar.xz": "fb021e0c0fc2238be9266d7614f4a26bc372544c4cba3528d729ab24ad229fc9", + "clippy-1.95.0-x86_64-apple-darwin.tar.xz": "e47367f6b1489d74cbba93b387310adcb82e27a51e44b2c6ff543eb4f199fe32", + "clippy-1.95.0-x86_64-pc-windows-gnullvm.tar.xz": "9358dfc3b831a5f4b3a3a0016da734b4ef5e78c84ca8f148f56a9c126515ebfa", + "clippy-1.95.0-x86_64-pc-windows-msvc.tar.xz": "ddc151d6f58c6658b7380292ecaef36e62d063bbdbf7f5802669810575bb5b75", + "clippy-1.95.0-x86_64-unknown-linux-gnu.tar.xz": "ac779bc9839dd47180806b133e4e2563c4a34716284cd5b8fede8ef289f452ca", + "rust-analyzer-1.95.0-aarch64-apple-darwin.tar.xz": "11231fc6574301b94bd379af4ef409caef7c65b877bcecf2b227dc0d74aa0ec7", + "rust-analyzer-1.95.0-aarch64-pc-windows-gnullvm.tar.xz": "6b2c0820957fdf0e3026f51fe85ad3cef94d5948e29cc83d2221f65dafc7a16b", + "rust-analyzer-1.95.0-aarch64-pc-windows-msvc.tar.xz": "92958624f23d4b0980748ac9e6d67f6f67a868f8224e8c6240e3f84145e2d805", + "rust-analyzer-1.95.0-aarch64-unknown-linux-gnu.tar.xz": "b37e5b9aad624e54228254f98a710ee19ad464fe7ada93ef12e20c87886a0047", + "rust-analyzer-1.95.0-x86_64-apple-darwin.tar.xz": "6cd111900e13fd19b188c5d8844b34136af3967066c0ea2914ce5c3508296c85", + "rust-analyzer-1.95.0-x86_64-pc-windows-gnullvm.tar.xz": "01bd05a6b990ad37907ff26e8c285c5ba8b7e674fabd0e264fc7f7aa04d963c5", + "rust-analyzer-1.95.0-x86_64-pc-windows-msvc.tar.xz": "ba58e349f5e8b0ef13735c48d4ad8d8c7664472f8403f3c9d97b291bd54a7638", + "rust-analyzer-1.95.0-x86_64-unknown-linux-gnu.tar.xz": "a9d71c6e7427c45afcd846a8b34a3e3301ae7a0e91a2bcf929326af77a7dc68e", + "rust-src-1.95.0.tar.xz": "67b09138c8db96afc4bbfc69ea771ac9a091fd777698acb43f6dfd9fb7dea363", + "rust-std-1.95.0-aarch64-apple-darwin.tar.xz": "9b30089b0f767cb91b2190ffec55a9beeb2a21a1405d8da0f664d7e09d08e6d8", + "rust-std-1.95.0-aarch64-apple-ios-macabi.tar.xz": "0e1760828f4e0fa1cde0061ba5680619dcc1cdcafec9242cc18dc4547c73b1cd", + "rust-std-1.95.0-aarch64-apple-ios-sim.tar.xz": "4bfe5b0c74c10d121a8ac60f1833c7714b963f9130f6256ca313d94405267deb", + "rust-std-1.95.0-aarch64-apple-ios.tar.xz": "6fcc42d8dbba4910a128ffa32d62a730339a7e3882a90341a881f2edf66ff55a", + "rust-std-1.95.0-aarch64-linux-android.tar.xz": "de5e8fa5d955809891eea77682811fc90be705f78883bd94071e98f5a738d05b", + "rust-std-1.95.0-aarch64-pc-windows-gnullvm.tar.xz": "93d810b8872771afe04f66aa30a4eee48736aca693186e4c7cc2766e1a82e340", + "rust-std-1.95.0-aarch64-pc-windows-msvc.tar.xz": "be21b5a8a71c49b4dcbc19956233b0de7bfda3ee3c8a199148299f867e95cb42", + "rust-std-1.95.0-aarch64-unknown-fuchsia.tar.xz": "b311a0523f75e031d683d983515edb9baaf22a843dbd44ce2a30a3204752f592", + "rust-std-1.95.0-aarch64-unknown-linux-gnu.tar.xz": "3a21b271b1ff973b94d69b25e7a39992f9fbcae1ab6d9475844a23e6ad3908ac", + "rust-std-1.95.0-aarch64-unknown-linux-musl.tar.xz": "f6710416ed9a7d5cf2a15efa761eb79a1deeb43f9961bbe05cc97bec4ef9064a", + "rust-std-1.95.0-aarch64-unknown-none-softfloat.tar.xz": "54d691468e25e7989b022a171337beadf78b5202877b312b75182b7f93efbb8b", + "rust-std-1.95.0-aarch64-unknown-none.tar.xz": "2b0c986dc9902866311f1fe2d44bc2bd84479d2ac84ed7ada76a5eb7ba37080f", + "rust-std-1.95.0-aarch64-unknown-uefi.tar.xz": "ca657564103024d345ca32e8e4ade7ebb395a51163d2897d9e5f8373c025e49e", + "rust-std-1.95.0-arm-linux-androideabi.tar.xz": "12a9c5fa24608159c2b2bd50abc0c6d0add407c0258cee894c2f61c07051a9c4", + "rust-std-1.95.0-arm-unknown-linux-gnueabi.tar.xz": "f6cd592dacdf41f724ee90a2f34db028e37ca2a7fb26fa86e93e8fd68e24d066", + "rust-std-1.95.0-arm-unknown-linux-gnueabihf.tar.xz": "fda8408ea17881c6529e27e58672d6c628f786cad557fac92856077e7a610239", + "rust-std-1.95.0-arm-unknown-linux-musleabi.tar.xz": "62f21fabc209fd0de53156764fc426da74c59525bb60cb4c1c3ffd1be0bbe00b", + "rust-std-1.95.0-arm-unknown-linux-musleabihf.tar.xz": "2842ce67f7a4c68c6e8b30ad3bb36484fb745edcc2694b2a36bf0609cf758044", + "rust-std-1.95.0-armv7-linux-androideabi.tar.xz": "61e95e144986c52ff9fa2fe3c249a68b2bf268adb2a2eeb81d80c180e43027f1", + "rust-std-1.95.0-armv7-unknown-linux-gnueabi.tar.xz": "e23454d6ca7fc3f5eb7cf9241572765176d86e9f45d4f394de31a5fd794e523f", + "rust-std-1.95.0-armv7-unknown-linux-gnueabihf.tar.xz": "bd319e18ca2dad0450f76277874d56356330da536be8cf271509f8e6f28ac6de", + "rust-std-1.95.0-armv7-unknown-linux-musleabi.tar.xz": "a49bc987e0531800f92825ce61402b5734ea747caa8970d3373506742ed7daa6", + "rust-std-1.95.0-armv7-unknown-linux-musleabihf.tar.xz": "77f9aaff4669c076edc96cdb99dc21749d2c692c862232b52176572c289fe671", + "rust-std-1.95.0-i686-linux-android.tar.xz": "e7b5e18d1d4119c7c1454ee8427933f27aeff9e81a22248c2829f5f299d3a937", + "rust-std-1.95.0-i686-pc-windows-gnu.tar.xz": "2e98fb94fc500690d7e72e071ce29dd98790ff518163963f5c82c32afba9231d", + "rust-std-1.95.0-i686-pc-windows-gnullvm.tar.xz": "b429a8c456d6a1815ece064102bd6a25f67d46db4f6d7b79dbe7d1b9d1f78e0f", + "rust-std-1.95.0-i686-pc-windows-msvc.tar.xz": "6206fd7e8bd119e9d1ba1c425ad25c282512eb0d5659f2dcb4be224b84715706", + "rust-std-1.95.0-i686-unknown-freebsd.tar.xz": "3b13b3ccecf482c0da3e94f19e0aa5e8e375622f4e09b30e6dae2b7638bee63f", + "rust-std-1.95.0-i686-unknown-linux-gnu.tar.xz": "527c5d5249a7f77b48d3c9da3ac512d27b47f43d08dbe3c6f82a3d5b35d8aa27", + "rust-std-1.95.0-i686-unknown-linux-musl.tar.xz": "af4d3e7aabb63d39a7a2ff5435cc993b65ff38a2d2e23f1967e519037a1b0455", + "rust-std-1.95.0-i686-unknown-uefi.tar.xz": "3233985273616ec36861f2d50b4a025c903b2bb8c45b171c0ae9e2de8342125b", + "rust-std-1.95.0-powerpc-unknown-linux-gnu.tar.xz": "59e0abbaa246502521e37c55b8d6cf88d5b8a697b0c70c61ec189937308f7246", + "rust-std-1.95.0-powerpc64-unknown-linux-gnu.tar.xz": "cc7fb9aa289ff1756502ae16a05e2885289165f01ed94a7c2db6576b3dae74a6", + "rust-std-1.95.0-powerpc64le-unknown-linux-gnu.tar.xz": "2370d9266051a0b23346d42e43a00f91b2daff22a963fb03e28ae50cb0b76c50", + "rust-std-1.95.0-powerpc64le-unknown-linux-musl.tar.xz": "0362ba4ecfe0bb508f0d2b064c6fbbfe72604d5ae1989d6a8a7b4fe5ff1889f1", + "rust-std-1.95.0-riscv32imc-unknown-none-elf.tar.xz": "04befebf3b15372dacb7e6c0fcdab842c17a63ea58bf2f4cdccd7182ae9195c6", + "rust-std-1.95.0-riscv64gc-unknown-linux-gnu.tar.xz": "50fe7869e166bb4c990a0e1664366b1ffdbe669664b7663cd03c079bd0efdcac", + "rust-std-1.95.0-riscv64gc-unknown-linux-musl.tar.xz": "e01bdbf5d6fa3e529671d49e87ba81dc9612101144f3ee5a0e1de3c48f27b47c", + "rust-std-1.95.0-riscv64gc-unknown-none-elf.tar.xz": "a4cb7a1527f3b56a39464e5ca2b174a27b708b26d78e604735eb5ffd9ee4d20b", + "rust-std-1.95.0-s390x-unknown-linux-gnu.tar.xz": "31978c1286afff9a0bb7f01c2ae4a39f40727b6100a82b6d934f146b06cde510", + "rust-std-1.95.0-thumbv6m-none-eabi.tar.xz": "602ec023c4615fc1c2d78b688554d42fa525e07e861c052f406fd7a607e5d5ee", + "rust-std-1.95.0-thumbv7em-none-eabi.tar.xz": "fb671966ba9aede333956ed43fcfe114ec890ca6e70369c9f3219871ee3ae8ae", + "rust-std-1.95.0-thumbv7em-none-eabihf.tar.xz": "fa3d189c09b64d818ad65a3fec1ce1c7d7b3908aea6fc4607a3fcc05067cad81", + "rust-std-1.95.0-thumbv7m-none-eabi.tar.xz": "e9e39f483ad4c1ce55fa4f508009e8d7d7ef25efe6592f7bfbabc159a24658ff", + "rust-std-1.95.0-thumbv8m.main-none-eabi.tar.xz": "3b64bffb193b37e83dc7b169add55f0ef3298220f8475468be4bb87276a68105", + "rust-std-1.95.0-thumbv8m.main-none-eabihf.tar.xz": "25ec92187d3a45fb1e397b0ba6000341d872523925a99a48b1978bdf1e6038cb", + "rust-std-1.95.0-wasm32-unknown-emscripten.tar.xz": "967b92a8682e8b8a1a459d776b36e41c906cdcac1008fef70e4800fec40d6864", + "rust-std-1.95.0-wasm32-unknown-unknown.tar.xz": "5587b89ff69623d09e476439d44a24453b4e4ea3d5e0b53a5c0a935151ff3fd1", + "rust-std-1.95.0-wasm32-wasip1-threads.tar.xz": "9079935a00a3c3aaf284957bbe82972983ce2708019687cec1f4988c30c1e0f3", + "rust-std-1.95.0-wasm32-wasip1.tar.xz": "86e5b6d98c7520bb9c3ad4f8cbbbf14beaf230b0f06b437db398e3c4f7dae43e", + "rust-std-1.95.0-wasm32-wasip2.tar.xz": "68146eb4c887431379966efa21d75dc957f18bd1166239c1deaa53fe38cb9ab4", + "rust-std-1.95.0-x86_64-apple-darwin.tar.xz": "2be13c14122b8d4d09b7f7c434fca9ae7215ec72049944189c88c4d9128ce504", + "rust-std-1.95.0-x86_64-apple-ios-macabi.tar.xz": "60b92e51e87f84046e0b19ccedc88c45b4b62c3ab10351f8473453f341c894f1", + "rust-std-1.95.0-x86_64-apple-ios.tar.xz": "11abb7b1c92b5a88b8c3ba21ee596a1be7dee5e817b336f26b4968d8ed5513ad", + "rust-std-1.95.0-x86_64-linux-android.tar.xz": "77b8e2be4a6e784a63cd77de944864c8044ddf4d5c7d56f663ada8a38a8319c4", + "rust-std-1.95.0-x86_64-pc-windows-gnu.tar.xz": "f57e045016a04130125fb43295d95f9ad2bebc296150eadb031dbf5167ad12bd", + "rust-std-1.95.0-x86_64-pc-windows-gnullvm.tar.xz": "3c52a0e34e0b4abe4439cabd77383408f5f9c80b6e249fbc0855df424b45008d", + "rust-std-1.95.0-x86_64-pc-windows-msvc.tar.xz": "7c659bdc88646e7e1befa370881bd311be87b26f006933a28b40dcab2f7cc832", + "rust-std-1.95.0-x86_64-unknown-freebsd.tar.xz": "dfe913c2f477172db10d3723d9f5c536d8b6f42776c979cc855820d8249adec3", + "rust-std-1.95.0-x86_64-unknown-fuchsia.tar.xz": "faa2bc09a3992f1d81b538121d64a3a396f4ec666e665c79d2ab47461c2d3206", + "rust-std-1.95.0-x86_64-unknown-linux-gnu.tar.xz": "047ea7098803d3500fa1072e9cee5392697e21525559e4458128a2bf874aa382", + "rust-std-1.95.0-x86_64-unknown-linux-musl.tar.xz": "aee540abf132920f791ef781489851a078d69dff493fb628d49c1d573f92bb3a", + "rust-std-1.95.0-x86_64-unknown-netbsd.tar.xz": "7a82b71c53f20cb147a340819fcab645da220b312a96194531e631ee99783a7d", + "rust-std-1.95.0-x86_64-unknown-none.tar.xz": "7c151c0e7bf3b0b4d7136774cd3686e5f691b761b648b17e83af58e7669d3e01", + "rust-std-1.95.0-x86_64-unknown-uefi.tar.xz": "4cc55629480aa8ab5b39eb6b7458433b48461d6626fdea0330fb88e23af818ea", + "rustc-1.95.0-aarch64-apple-darwin.tar.xz": "149e85a285b6eba58eb6c8bdf7deb1b93763890598e62cb635a712e3a8454f04", + "rustc-1.95.0-aarch64-pc-windows-gnullvm.tar.xz": "758e729faabd8dabfab584d2bead59ca4fbcf1125ffc3c43a69332d6d9f2316b", + "rustc-1.95.0-aarch64-pc-windows-msvc.tar.xz": "0dbec9739b93427ccdd3948c3b1f83cec42e4c9545d930a8d1e1464ff4092c5f", + "rustc-1.95.0-aarch64-unknown-linux-gnu.tar.xz": "0fe3689eeaed603e5ef24572d11597d3edadaefd2cb181674ad621260f2501d2", + "rustc-1.95.0-x86_64-apple-darwin.tar.xz": "33db457715446a69ed6f69f78f5fbb9ca8e17a16585d1d7a0060479bfe4c7afc", + "rustc-1.95.0-x86_64-pc-windows-gnullvm.tar.xz": "44ffbe057bb8f967087a1ad549a7139e9e5017d3aab396f42a6393f897e39531", + "rustc-1.95.0-x86_64-pc-windows-msvc.tar.xz": "4cb1f3b578adc6541cbe13a6f85f1fd8c0ce643d90b506a36dee24c680864c67", + "rustc-1.95.0-x86_64-unknown-linux-gnu.tar.xz": "8426a3d170a5879f5682f5fbdd024a1779b3951e7baba685af2d6dc32a6dfc15", + "rustfmt-1.95.0-aarch64-apple-darwin.tar.xz": "c54af79adfdc790d27fc56e24407e370c80be5a89ad537eef9fbd45d3c3e28e8", + "rustfmt-1.95.0-aarch64-pc-windows-gnullvm.tar.xz": "85b690b664ee4f03521c6bc3f1af8fd7e6efae5851a8a7ff18965594484688e2", + "rustfmt-1.95.0-aarch64-pc-windows-msvc.tar.xz": "a873c048743e6da29e09a8b55c774ee113f8f6ae4fd57d988d304a6453801b34", + "rustfmt-1.95.0-aarch64-unknown-linux-gnu.tar.xz": "64cce868f0f3d29f1524e11e9bab01ac9d538a31665fea1cd6b78af46a1c0a41", + "rustfmt-1.95.0-x86_64-apple-darwin.tar.xz": "5f7228f40a160e80d260e74e068d6fec8627aa02f1f5ae29d2019b9347076401", + "rustfmt-1.95.0-x86_64-pc-windows-gnullvm.tar.xz": "74eb0bb0227af0df81a5df9cbf8384e5e038c4cd575152ecc27dfa891ff3756a", + "rustfmt-1.95.0-x86_64-pc-windows-msvc.tar.xz": "8bcf91606e36b8a0164efafde50709cd7a3c02143a2edaa81dbf3dccd6ed8f4c", + "rustfmt-1.95.0-x86_64-unknown-linux-gnu.tar.xz": "f1b2a7301513ffdd95ebf22ebbdd932e4d17fc806f748d93924740d0297b1396" } } } diff --git a/README.md b/README.md index 5cc7fd4953..77c8d2199c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ -

npm i -g @openai/codex
or brew install --cask codex

Codex CLI is a coding agent from OpenAI that runs locally on your computer.

Codex CLI splash @@ -14,7 +13,19 @@ If you want Codex in your code editor (VS Code, Cursor, Windsurf), + path.join(vendorRoot, targetTriple, "bin", codexBinaryName); +const legacyBinaryPath = (vendorRoot) => + path.join(vendorRoot, targetTriple, "codex", codexBinaryName); -let vendorRoot; -try { - const packageJsonPath = require.resolve(`${platformPackage}/package.json`); - vendorRoot = path.join(path.dirname(packageJsonPath), "vendor"); -} catch { - if (existsSync(localBinaryPath)) { - vendorRoot = localVendorRoot; - } else { - const packageManager = detectPackageManager(); - const updateCommand = - packageManager === "bun" - ? "bun install -g @openai/codex@latest" - : "npm install -g @openai/codex@latest"; - throw new Error( - `Missing optional dependency ${platformPackage}. Reinstall Codex: ${updateCommand}`, - ); +function resolveNativePackage(vendorRoot) { + const packageRoot = path.join(vendorRoot, targetTriple); + const binaryPath = packageBinaryPath(vendorRoot); + if (existsSync(binaryPath)) { + return { + binaryPath, + pathDir: path.join(packageRoot, "codex-path"), + }; } + + const legacyPath = legacyBinaryPath(vendorRoot); + if (existsSync(legacyPath)) { + return { + binaryPath: legacyPath, + pathDir: path.join(packageRoot, "path"), + }; + } + + return null; } -if (!vendorRoot) { +let nativePackage; +try { + const packageJsonPath = require.resolve(`${platformPackage}/package.json`); + nativePackage = resolveNativePackage( + path.join(path.dirname(packageJsonPath), "vendor"), + ); +} catch { + nativePackage = resolveNativePackage(localVendorRoot); +} + +if (!nativePackage) { const packageManager = detectPackageManager(); const updateCommand = packageManager === "bun" @@ -114,8 +124,7 @@ if (!vendorRoot) { ); } -const archRoot = path.join(vendorRoot, targetTriple); -const binaryPath = path.join(archRoot, "codex", codexBinaryName); +const { binaryPath, pathDir } = nativePackage; // Use an asynchronous spawn instead of spawnSync so that Node is able to // respond to signals (e.g. Ctrl-C / SIGINT) while the native binary is @@ -159,7 +168,6 @@ function detectPackageManager() { } const additionalDirs = []; -const pathDir = path.join(archRoot, "path"); if (existsSync(pathDir)) { additionalDirs.push(pathDir); } diff --git a/codex-cli/package.json b/codex-cli/package.json index 5fbac8300b..fd122128e5 100644 --- a/codex-cli/package.json +++ b/codex-cli/package.json @@ -1,6 +1,7 @@ { "name": "@openai/codex", "version": "0.0.0-dev", + "description": "Codex CLI is a coding agent from OpenAI that runs locally on your computer.", "license": "Apache-2.0", "bin": { "codex": "bin/codex.js" @@ -10,8 +11,7 @@ "node": ">=16" }, "files": [ - "bin", - "vendor" + "bin/codex.js" ], "repository": { "type": "git", diff --git a/codex-cli/scripts/README.md b/codex-cli/scripts/README.md index ca0d54b544..65923151a0 100644 --- a/codex-cli/scripts/README.md +++ b/codex-cli/scripts/README.md @@ -11,13 +11,13 @@ example, to stage the CLI, responses proxy, and SDK packages for version `0.6.0` --package codex-sdk ``` -This downloads the native artifacts once, hydrates `vendor/` for each package, and writes -tarballs to `dist/npm/`. +This downloads the required native package archive artifacts, hydrates `vendor/` for +each package, and writes tarballs to `dist/npm/`. When `--package codex` is provided, the staging helper builds the lightweight `@openai/codex` meta package plus all platform-native `@openai/codex` variants that are later published under platform-specific dist-tags. -If you need to invoke `build_npm_package.py` directly, run -`codex-cli/scripts/install_native_deps.py` first and pass `--vendor-src` pointing to the -directory that contains the populated `vendor/` tree. +Direct `build_npm_package.py` invocations are still useful for package-specific +debugging, but native packages expect `--vendor-src` to point at a prehydrated +`vendor/` tree. Release packaging should use `scripts/stage_npm_packages.py`. diff --git a/codex-cli/scripts/build_npm_package.py b/codex-cli/scripts/build_npm_package.py index c38fed68d9..60f6ca7a9d 100755 --- a/codex-cli/scripts/build_npm_package.py +++ b/codex-cli/scripts/build_npm_package.py @@ -3,6 +3,7 @@ import argparse import json +import os import shutil import subprocess import sys @@ -15,6 +16,7 @@ REPO_ROOT = CODEX_CLI_ROOT.parent RESPONSES_API_PROXY_NPM_ROOT = REPO_ROOT / "codex-rs" / "responses-api-proxy" / "npm" CODEX_SDK_ROOT = REPO_ROOT / "sdk" / "typescript" CODEX_NPM_NAME = "@openai/codex" +CODEX_PACKAGE_COMPONENT = "codex-package" # `npm_name` is the local optional-dependency alias consumed by `bin/codex.js`. # The underlying package published to npm is always `@openai/codex`. @@ -69,12 +71,12 @@ PACKAGE_EXPANSIONS: dict[str, list[str]] = { PACKAGE_NATIVE_COMPONENTS: dict[str, list[str]] = { "codex": [], - "codex-linux-x64": ["bwrap", "codex", "rg"], - "codex-linux-arm64": ["bwrap", "codex", "rg"], - "codex-darwin-x64": ["codex", "rg"], - "codex-darwin-arm64": ["codex", "rg"], - "codex-win32-x64": ["codex", "rg", "codex-windows-sandbox-setup", "codex-command-runner"], - "codex-win32-arm64": ["codex", "rg", "codex-windows-sandbox-setup", "codex-command-runner"], + "codex-linux-x64": [CODEX_PACKAGE_COMPONENT], + "codex-linux-arm64": [CODEX_PACKAGE_COMPONENT], + "codex-darwin-x64": [CODEX_PACKAGE_COMPONENT], + "codex-darwin-arm64": [CODEX_PACKAGE_COMPONENT], + "codex-win32-x64": [CODEX_PACKAGE_COMPONENT], + "codex-win32-arm64": [CODEX_PACKAGE_COMPONENT], "codex-responses-api-proxy": ["codex-responses-api-proxy"], "codex-sdk": [], } @@ -86,16 +88,6 @@ PACKAGE_TARGET_FILTERS: dict[str, str] = { PACKAGE_CHOICES = tuple(PACKAGE_NATIVE_COMPONENTS) -COMPONENT_DEST_DIR: dict[str, str] = { - "bwrap": "codex-resources", - "codex": "codex", - "codex-responses-api-proxy": "codex-responses-api-proxy", - "codex-windows-sandbox-setup": "codex", - "codex-command-runner": "codex", - "rg": "path", -} - - def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Build or stage the Codex CLI npm package.") parser.add_argument( @@ -138,16 +130,6 @@ def parse_args() -> argparse.Namespace: type=Path, help="Directory containing pre-installed native binaries to bundle (vendor root).", ) - parser.add_argument( - "--allow-missing-native-component", - dest="allow_missing_native_components", - action="append", - default=[], - help=( - "Native component that may be absent from --vendor-src. Intended for CI " - "compatibility with older artifact workflows; releases should not use this." - ), - ) return parser.parse_args() @@ -188,7 +170,6 @@ def main() -> int: staging_dir, native_components, target_filter={target_filter} if target_filter else None, - allow_missing_components=set(args.allow_missing_native_components), ) if release_version: @@ -253,9 +234,6 @@ def stage_sources(staging_dir: Path, version: str, package: str) -> None: bin_dir = staging_dir / "bin" bin_dir.mkdir(parents=True, exist_ok=True) shutil.copy2(CODEX_CLI_ROOT / "bin" / "codex.js", bin_dir / "codex.js") - rg_manifest = CODEX_CLI_ROOT / "bin" / "rg" - if rg_manifest.exists(): - shutil.copy2(rg_manifest, bin_dir / "rg") readme_src = REPO_ROOT / "README.md" if readme_src.exists(): @@ -314,7 +292,7 @@ def stage_sources(staging_dir: Path, version: str, package: str) -> None: package_json["version"] = version if package == "codex": - package_json["files"] = ["bin"] + package_json["files"] = ["bin/codex.js"] package_json["optionalDependencies"] = { CODEX_PLATFORM_PACKAGES[platform_package]["npm_name"]: ( f"npm:{CODEX_NPM_NAME}@" @@ -347,7 +325,7 @@ def compute_platform_package_version(version: str, platform_tag: str) -> str: def run_command(cmd: list[str], cwd: Path | None = None) -> None: - print("+", " ".join(cmd)) + print("+", " ".join(cmd), flush=True) subprocess.run(cmd, cwd=cwd, check=True) @@ -377,14 +355,12 @@ def copy_native_binaries( staging_dir: Path, components: list[str], target_filter: set[str] | None = None, - allow_missing_components: set[str] | None = None, ) -> None: vendor_src = vendor_src.resolve() if not vendor_src.exists(): raise RuntimeError(f"Vendor source directory not found: {vendor_src}") - components_set = {component for component in components if component in COMPONENT_DEST_DIR} - allow_missing_components = allow_missing_components or set() + components_set = set(components) if not components_set: return @@ -402,24 +378,25 @@ def copy_native_binaries( if target_filter is not None and target_dir.name not in target_filter: continue - dest_target_dir = vendor_dest / target_dir.name - dest_target_dir.mkdir(parents=True, exist_ok=True) copied_targets.add(target_dir.name) - for component in components_set: - dest_dir_name = COMPONENT_DEST_DIR.get(component) - if dest_dir_name is None: - continue + dest_target_dir = vendor_dest / target_dir.name - src_component_dir = target_dir / dest_dir_name + if CODEX_PACKAGE_COMPONENT in components_set: + if dest_target_dir.exists(): + shutil.rmtree(dest_target_dir) + shutil.copytree(target_dir, dest_target_dir) + else: + dest_target_dir.mkdir(parents=True, exist_ok=True) + + for component in sorted(components_set - {CODEX_PACKAGE_COMPONENT}): + src_component_dir = target_dir / component if not src_component_dir.exists(): - if component in allow_missing_components: - continue raise RuntimeError( f"Missing native component '{component}' in vendor source: {src_component_dir}" ) - dest_component_dir = dest_target_dir / dest_dir_name + dest_component_dir = dest_target_dir / component if dest_component_dir.exists(): shutil.rmtree(dest_component_dir) shutil.copytree(src_component_dir, dest_component_dir) @@ -430,16 +407,23 @@ def copy_native_binaries( missing_list = ", ".join(missing_targets) raise RuntimeError(f"Missing target directories in vendor source: {missing_list}") - def run_npm_pack(staging_dir: Path, output_path: Path) -> Path: output_path = output_path.resolve() output_path.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix="codex-npm-pack-") as pack_dir_str: pack_dir = Path(pack_dir_str) + npm_cache_dir = pack_dir / "npm-cache" + npm_logs_dir = pack_dir / "npm-logs" + npm_cache_dir.mkdir() + npm_logs_dir.mkdir() + env = os.environ.copy() + env["NPM_CONFIG_CACHE"] = str(npm_cache_dir) + env["NPM_CONFIG_LOGS_DIR"] = str(npm_logs_dir) stdout = subprocess.check_output( ["npm", "pack", "--json", "--pack-destination", str(pack_dir)], cwd=staging_dir, + env=env, text=True, ) try: diff --git a/codex-cli/scripts/install_native_deps.py b/codex-cli/scripts/install_native_deps.py deleted file mode 100755 index 79f596fbaf..0000000000 --- a/codex-cli/scripts/install_native_deps.py +++ /dev/null @@ -1,483 +0,0 @@ -#!/usr/bin/env python3 -"""Install Codex native binaries (Rust CLI, bwrap, and ripgrep helpers).""" - -import argparse -from contextlib import contextmanager -import json -import os -import shutil -import subprocess -import tarfile -import tempfile -import zipfile -from dataclasses import dataclass -from concurrent.futures import ThreadPoolExecutor, as_completed -from pathlib import Path -import sys -from typing import Iterable, Sequence -from urllib.parse import urlparse -from urllib.request import urlopen - -SCRIPT_DIR = Path(__file__).resolve().parent -CODEX_CLI_ROOT = SCRIPT_DIR.parent -DEFAULT_WORKFLOW_URL = "https://github.com/openai/codex/actions/runs/17952349351" # rust-v0.40.0 -VENDOR_DIR_NAME = "vendor" -RG_MANIFEST = CODEX_CLI_ROOT / "bin" / "rg" -BINARY_TARGETS = ( - "x86_64-unknown-linux-musl", - "aarch64-unknown-linux-musl", - "x86_64-apple-darwin", - "aarch64-apple-darwin", - "x86_64-pc-windows-msvc", - "aarch64-pc-windows-msvc", -) - - -@dataclass(frozen=True) -class BinaryComponent: - artifact_prefix: str # matches the artifact filename prefix (e.g. codex-.zst) - dest_dir: str # directory under vendor// where the binary is installed - binary_basename: str # executable name inside dest_dir (before optional .exe) - targets: tuple[str, ...] | None = None # limit installation to specific targets - - -WINDOWS_TARGETS = tuple(target for target in BINARY_TARGETS if "windows" in target) -LINUX_TARGETS = tuple(target for target in BINARY_TARGETS if "linux" in target) - -BINARY_COMPONENTS = { - "bwrap": BinaryComponent( - artifact_prefix="bwrap", - dest_dir="codex-resources", - binary_basename="bwrap", - targets=LINUX_TARGETS, - ), - "codex": BinaryComponent( - artifact_prefix="codex", - dest_dir="codex", - binary_basename="codex", - ), - "codex-responses-api-proxy": BinaryComponent( - artifact_prefix="codex-responses-api-proxy", - dest_dir="codex-responses-api-proxy", - binary_basename="codex-responses-api-proxy", - ), - "codex-windows-sandbox-setup": BinaryComponent( - artifact_prefix="codex-windows-sandbox-setup", - dest_dir="codex", - binary_basename="codex-windows-sandbox-setup", - targets=WINDOWS_TARGETS, - ), - "codex-command-runner": BinaryComponent( - artifact_prefix="codex-command-runner", - dest_dir="codex", - binary_basename="codex-command-runner", - targets=WINDOWS_TARGETS, - ), -} - -RG_TARGET_PLATFORM_PAIRS: list[tuple[str, str]] = [ - ("x86_64-unknown-linux-musl", "linux-x86_64"), - ("aarch64-unknown-linux-musl", "linux-aarch64"), - ("x86_64-apple-darwin", "macos-x86_64"), - ("aarch64-apple-darwin", "macos-aarch64"), - ("x86_64-pc-windows-msvc", "windows-x86_64"), - ("aarch64-pc-windows-msvc", "windows-aarch64"), -] -RG_TARGET_TO_PLATFORM = {target: platform for target, platform in RG_TARGET_PLATFORM_PAIRS} -DEFAULT_RG_TARGETS = [target for target, _ in RG_TARGET_PLATFORM_PAIRS] - -# urllib.request.urlopen() defaults to no timeout (can hang indefinitely), which is painful in CI. -DOWNLOAD_TIMEOUT_SECS = 60 - - -def _gha_enabled() -> bool: - # GitHub Actions supports "workflow commands" (e.g. ::group:: / ::error::) that make logs - # much easier to scan: groups collapse noisy sections and error annotations surface the - # failure in the UI without changing the actual exception/traceback output. - return os.environ.get("GITHUB_ACTIONS") == "true" - - -def _gha_escape(value: str) -> str: - # Workflow commands require percent/newline escaping. - return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") - - -def _gha_error(*, title: str, message: str) -> None: - # Emit a GitHub Actions error annotation. This does not replace stdout/stderr logs; it just - # adds a prominent summary line to the job UI so the root cause is easier to spot. - if not _gha_enabled(): - return - print( - f"::error title={_gha_escape(title)}::{_gha_escape(message)}", - flush=True, - ) - - -@contextmanager -def _gha_group(title: str): - # Wrap a block in a collapsible log group on GitHub Actions. Outside of GHA this is a no-op - # so local output remains unchanged. - if _gha_enabled(): - print(f"::group::{_gha_escape(title)}", flush=True) - try: - yield - finally: - if _gha_enabled(): - print("::endgroup::", flush=True) - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Install native Codex binaries.") - parser.add_argument( - "--workflow-url", - help=( - "GitHub Actions workflow URL that produced the artifacts. Defaults to a " - "known good run when omitted." - ), - ) - parser.add_argument( - "--component", - dest="components", - action="append", - choices=tuple(list(BINARY_COMPONENTS) + ["rg"]), - help=( - "Limit installation to the specified components." - " May be repeated. Defaults to bwrap, codex, codex-windows-sandbox-setup," - " codex-command-runner, and rg." - ), - ) - parser.add_argument( - "root", - nargs="?", - type=Path, - help=( - "Directory containing package.json for the staged package. If omitted, the " - "repository checkout is used." - ), - ) - return parser.parse_args() - - -def main() -> int: - args = parse_args() - - codex_cli_root = (args.root or CODEX_CLI_ROOT).resolve() - vendor_dir = codex_cli_root / VENDOR_DIR_NAME - vendor_dir.mkdir(parents=True, exist_ok=True) - - components = args.components or [ - "bwrap", - "codex", - "codex-windows-sandbox-setup", - "codex-command-runner", - "rg", - ] - - workflow_url = (args.workflow_url or DEFAULT_WORKFLOW_URL).strip() - if not workflow_url: - workflow_url = DEFAULT_WORKFLOW_URL - - workflow_id = workflow_url.rstrip("/").split("/")[-1] - print(f"Downloading native artifacts from workflow {workflow_id}...") - - with _gha_group(f"Download native artifacts from workflow {workflow_id}"): - with tempfile.TemporaryDirectory(prefix="codex-native-artifacts-") as artifacts_dir_str: - artifacts_dir = Path(artifacts_dir_str) - _download_artifacts(workflow_id, artifacts_dir) - install_binary_components( - artifacts_dir, - vendor_dir, - [BINARY_COMPONENTS[name] for name in components if name in BINARY_COMPONENTS], - ) - - if "rg" in components: - with _gha_group("Fetch ripgrep binaries"): - print("Fetching ripgrep binaries...") - fetch_rg(vendor_dir, DEFAULT_RG_TARGETS, manifest_path=RG_MANIFEST) - - print(f"Installed native dependencies into {vendor_dir}") - return 0 - - -def fetch_rg( - vendor_dir: Path, - targets: Sequence[str] | None = None, - *, - manifest_path: Path, -) -> list[Path]: - """Download ripgrep binaries described by the DotSlash manifest.""" - - if targets is None: - targets = DEFAULT_RG_TARGETS - - if not manifest_path.exists(): - raise FileNotFoundError(f"DotSlash manifest not found: {manifest_path}") - - manifest = _load_manifest(manifest_path) - platforms = manifest.get("platforms", {}) - - vendor_dir.mkdir(parents=True, exist_ok=True) - - targets = list(targets) - if not targets: - return [] - - task_configs: list[tuple[str, str, dict]] = [] - for target in targets: - platform_key = RG_TARGET_TO_PLATFORM.get(target) - if platform_key is None: - raise ValueError(f"Unsupported ripgrep target '{target}'.") - - platform_info = platforms.get(platform_key) - if platform_info is None: - raise RuntimeError(f"Platform '{platform_key}' not found in manifest {manifest_path}.") - - task_configs.append((target, platform_key, platform_info)) - - results: dict[str, Path] = {} - max_workers = min(len(task_configs), max(1, (os.cpu_count() or 1))) - - print("Installing ripgrep binaries for targets: " + ", ".join(targets)) - - with ThreadPoolExecutor(max_workers=max_workers) as executor: - future_map = { - executor.submit( - _fetch_single_rg, - vendor_dir, - target, - platform_key, - platform_info, - manifest_path, - ): target - for target, platform_key, platform_info in task_configs - } - - for future in as_completed(future_map): - target = future_map[future] - try: - results[target] = future.result() - except Exception as exc: - _gha_error( - title="ripgrep install failed", - message=f"target={target} error={exc!r}", - ) - raise RuntimeError(f"Failed to install ripgrep for target {target}.") from exc - print(f" installed ripgrep for {target}") - - return [results[target] for target in targets] - - -def _download_artifacts(workflow_id: str, dest_dir: Path) -> None: - cmd = [ - "gh", - "run", - "download", - "--dir", - str(dest_dir), - "--repo", - "openai/codex", - workflow_id, - ] - subprocess.check_call(cmd) - - -def install_binary_components( - artifacts_dir: Path, - vendor_dir: Path, - selected_components: Sequence[BinaryComponent], -) -> None: - if not selected_components: - return - - for component in selected_components: - component_targets = list(component.targets or BINARY_TARGETS) - - print( - f"Installing {component.binary_basename} binaries for targets: " - + ", ".join(component_targets) - ) - max_workers = min(len(component_targets), max(1, (os.cpu_count() or 1))) - with ThreadPoolExecutor(max_workers=max_workers) as executor: - futures = { - executor.submit( - _install_single_binary, - artifacts_dir, - vendor_dir, - target, - component, - ): target - for target in component_targets - } - for future in as_completed(futures): - installed_path = future.result() - print(f" installed {installed_path}") - - -def _install_single_binary( - artifacts_dir: Path, - vendor_dir: Path, - target: str, - component: BinaryComponent, -) -> Path: - artifact_subdir = artifacts_dir / target - archive_name = _archive_name_for_target(component.artifact_prefix, target) - archive_path = artifact_subdir / archive_name - if not archive_path.exists(): - raise FileNotFoundError(f"Expected artifact not found: {archive_path}") - - dest_dir = vendor_dir / target / component.dest_dir - dest_dir.mkdir(parents=True, exist_ok=True) - - binary_name = ( - f"{component.binary_basename}.exe" if "windows" in target else component.binary_basename - ) - dest = dest_dir / binary_name - dest.unlink(missing_ok=True) - extract_archive(archive_path, "zst", None, dest) - if "windows" not in target: - dest.chmod(0o755) - return dest - - -def _archive_name_for_target(artifact_prefix: str, target: str) -> str: - if "windows" in target: - return f"{artifact_prefix}-{target}.exe.zst" - return f"{artifact_prefix}-{target}.zst" - - -def _fetch_single_rg( - vendor_dir: Path, - target: str, - platform_key: str, - platform_info: dict, - manifest_path: Path, -) -> Path: - providers = platform_info.get("providers", []) - if not providers: - raise RuntimeError(f"No providers listed for platform '{platform_key}' in {manifest_path}.") - - url = providers[0]["url"] - archive_format = platform_info.get("format", "zst") - archive_member = platform_info.get("path") - digest = platform_info.get("digest") - expected_size = platform_info.get("size") - - dest_dir = vendor_dir / target / "path" - dest_dir.mkdir(parents=True, exist_ok=True) - - is_windows = platform_key.startswith("win") - binary_name = "rg.exe" if is_windows else "rg" - dest = dest_dir / binary_name - - with tempfile.TemporaryDirectory() as tmp_dir_str: - tmp_dir = Path(tmp_dir_str) - archive_filename = os.path.basename(urlparse(url).path) - download_path = tmp_dir / archive_filename - print( - f" downloading ripgrep for {target} ({platform_key}) from {url}", - flush=True, - ) - try: - _download_file(url, download_path) - except Exception as exc: - _gha_error( - title="ripgrep download failed", - message=f"target={target} platform={platform_key} url={url} error={exc!r}", - ) - raise RuntimeError( - "Failed to download ripgrep " - f"(target={target}, platform={platform_key}, format={archive_format}, " - f"expected_size={expected_size!r}, digest={digest!r}, url={url}, dest={download_path})." - ) from exc - - dest.unlink(missing_ok=True) - try: - extract_archive(download_path, archive_format, archive_member, dest) - except Exception as exc: - raise RuntimeError( - "Failed to extract ripgrep " - f"(target={target}, platform={platform_key}, format={archive_format}, " - f"member={archive_member!r}, url={url}, archive={download_path})." - ) from exc - - if not is_windows: - dest.chmod(0o755) - - return dest - - -def _download_file(url: str, dest: Path) -> None: - dest.parent.mkdir(parents=True, exist_ok=True) - dest.unlink(missing_ok=True) - - with urlopen(url, timeout=DOWNLOAD_TIMEOUT_SECS) as response, open(dest, "wb") as out: - shutil.copyfileobj(response, out) - - -def extract_archive( - archive_path: Path, - archive_format: str, - archive_member: str | None, - dest: Path, -) -> None: - dest.parent.mkdir(parents=True, exist_ok=True) - - if archive_format == "zst": - output_path = archive_path.parent / dest.name - subprocess.check_call( - ["zstd", "-f", "-d", str(archive_path), "-o", str(output_path)] - ) - shutil.move(str(output_path), dest) - return - - if archive_format == "tar.gz": - if not archive_member: - raise RuntimeError("Missing 'path' for tar.gz archive in DotSlash manifest.") - with tarfile.open(archive_path, "r:gz") as tar: - try: - member = tar.getmember(archive_member) - except KeyError as exc: - raise RuntimeError( - f"Entry '{archive_member}' not found in archive {archive_path}." - ) from exc - tar.extract(member, path=archive_path.parent, filter="data") - extracted = archive_path.parent / archive_member - shutil.move(str(extracted), dest) - return - - if archive_format == "zip": - if not archive_member: - raise RuntimeError("Missing 'path' for zip archive in DotSlash manifest.") - with zipfile.ZipFile(archive_path) as archive: - try: - with archive.open(archive_member) as src, open(dest, "wb") as out: - shutil.copyfileobj(src, out) - except KeyError as exc: - raise RuntimeError( - f"Entry '{archive_member}' not found in archive {archive_path}." - ) from exc - return - - raise RuntimeError(f"Unsupported archive format '{archive_format}'.") - - -def _load_manifest(manifest_path: Path) -> dict: - cmd = ["dotslash", "--", "parse", str(manifest_path)] - stdout = subprocess.check_output(cmd, text=True) - try: - manifest = json.loads(stdout) - except json.JSONDecodeError as exc: - raise RuntimeError(f"Invalid DotSlash manifest output from {manifest_path}.") from exc - - if not isinstance(manifest, dict): - raise RuntimeError( - f"Unexpected DotSlash manifest structure for {manifest_path}: {type(manifest)!r}" - ) - - return manifest - - -if __name__ == "__main__": - import sys - - sys.exit(main()) diff --git a/codex-rs/.cargo/config.toml b/codex-rs/.cargo/config.toml index 5d5eb8fd6f..8a6f2c11ee 100644 --- a/codex-rs/.cargo/config.toml +++ b/codex-rs/.cargo/config.toml @@ -1,5 +1,5 @@ [target.'cfg(all(windows, target_env = "msvc"))'] -rustflags = ["-C", "link-arg=/STACK:8388608"] +rustflags = ["-C", "link-arg=/STACK:8388608", "-C", "target-feature=+crt-static"] # MSVC emits a warning about code that may trip "Cortex-A53 MPCore processor bug #843419" (see # https://developer.arm.com/documentation/epm048406/latest) which is sometimes emitted by LLVM. diff --git a/codex-rs/.config/nextest.toml b/codex-rs/.config/nextest.toml index 86d00e4637..01f4a98dc8 100644 --- a/codex-rs/.config/nextest.toml +++ b/codex-rs/.config/nextest.toml @@ -1,6 +1,12 @@ [profile.default] -# Do not increase, fix your test instead -slow-timeout = { period = "15s", terminate-after = 2 } +# Retry once so one transient failure does not fail full-CI outright. +# 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] +path = "junit.xml" [test-groups.app_server_protocol_codegen] max-threads = 1 @@ -14,6 +20,9 @@ max-threads = 1 [test-groups.windows_sandbox_legacy_sessions] max-threads = 1 +[test-groups.windows_process_heavy] +max-threads = 2 + [[profile.default.overrides]] # Do not add new tests here filter = 'test(rmcp_client) | test(humanlike_typing_1000_chars_appears_live_no_placeholder)' @@ -44,3 +53,18 @@ test-group = 'core_apply_patch_cli_integration' # Serialize them to avoid exhausting Windows session/global desktop resources in CI. filter = 'package(codex-windows-sandbox) & test(legacy_)' test-group = 'windows_sandbox_legacy_sessions' + +[[profile.default.overrides]] +# This Codex-home startup path still exceeded the broader Windows-heavy ceiling +# in both Windows full-CI lanes after contention was reduced. +platform = 'cfg(windows)' +filter = 'test(start_thread_uses_all_default_environments_from_codex_home)' +slow-timeout = { period = "1m", terminate-after = 2 } + +[[profile.default.overrides]] +# These Windows-heavy tests spawn subprocesses, session files, or JSON-RPC +# clients and have been the dominant source of 30s full-CI timeouts. +platform = 'cfg(windows)' +filter = 'test(suite::resume::) | test(suite::cli_stream::) | test(suite::auth_env::) | test(start_thread_uses_all_default_environments_from_codex_home) | test(connect_stdio_command_initializes_json_rpc_client_on_windows)' +test-group = 'windows_process_heavy' +slow-timeout = { period = "45s", terminate-after = 2 } diff --git a/codex-rs/.github/workflows/cargo-audit.yml b/codex-rs/.github/workflows/cargo-audit.yml index 0c41471b65..1d935d61e8 100644 --- a/codex-rs/.github/workflows/cargo-audit.yml +++ b/codex-rs/.github/workflows/cargo-audit.yml @@ -17,7 +17,7 @@ jobs: working-directory: codex-rs steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 - name: Install cargo-audit uses: taiki-e/install-action@v2 with: diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 4d1f66008f..745feb93be 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -181,7 +181,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -193,7 +193,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -207,7 +207,7 @@ dependencies = [ "bech32", "chacha20poly1305", "cookie-factory", - "hmac", + "hmac 0.12.1", "i18n-embed", "i18n-embed-fl", "lazy_static", @@ -216,7 +216,7 @@ dependencies = [ "rand 0.8.5", "rust-embed", "scrypt", - "sha2", + "sha2 0.10.9", "subtle", "x25519-dalek", "zeroize", @@ -231,12 +231,12 @@ dependencies = [ "base64 0.21.7", "chacha20poly1305", "cookie-factory", - "hkdf", + "hkdf 0.12.4", "io_tee", "nom 7.1.3", "rand 0.8.5", "secrecy", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -776,7 +776,7 @@ dependencies = [ "p256", "rand 0.8.5", "ring", - "sha2", + "sha2 0.10.9", "time", "tokio", "tracing", @@ -946,11 +946,11 @@ dependencies = [ "bytes", "form_urlencoded", "hex", - "hmac", + "hmac 0.12.1", "http 0.2.12", "http 1.4.0", "percent-encoding", - "sha2", + "sha2 0.10.9", "time", "tracing", ] @@ -1147,7 +1147,6 @@ dependencies = [ "axum-core", "base64 0.22.1", "bytes", - "form_urlencoded", "futures-util", "http 1.4.0", "http-body 1.0.1", @@ -1163,15 +1162,13 @@ dependencies = [ "serde_core", "serde_json", "serde_path_to_error", - "serde_urlencoded", - "sha1", + "sha1 0.10.6", "sync_wrapper", "tokio", "tokio-tungstenite", "tower", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -1190,7 +1187,6 @@ dependencies = [ "sync_wrapper", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -1328,7 +1324,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -1340,6 +1336,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-padding" version = "0.3.3" @@ -1518,9 +1523,9 @@ checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" [[package]] name = "calendrical_calculations" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a0b39595c6ee54a8d0900204ba4c401d0ab4eb45adaf07178e8d017541529e7" +checksum = "5abbd6eeda6885048d357edc66748eea6e0268e3dd11f326fff5bd248d779c26" dependencies = [ "core_maths", "displaydoc", @@ -1613,7 +1618,18 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] @@ -1623,7 +1639,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", - "chacha20", + "chacha20 0.9.1", "cipher", "poly1305", "zeroize", @@ -1666,7 +1682,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", "zeroize", ] @@ -1759,6 +1775,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + [[package]] name = "cmp_any" version = "0.8.1" @@ -1804,10 +1826,10 @@ dependencies = [ "jsonwebtoken", "pretty_assertions", "rand 0.9.3", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -1825,7 +1847,7 @@ dependencies = [ "pretty_assertions", "serde", "serde_json", - "sha1", + "sha1 0.10.6", "tokio", "tracing", ] @@ -1858,7 +1880,8 @@ dependencies = [ "http 1.4.0", "pretty_assertions", "regex-lite", - "reqwest", + "reqwest 0.12.28", + "schemars 0.8.22", "serde", "serde_json", "tempfile", @@ -1905,8 +1928,10 @@ dependencies = [ "codex-git-utils", "codex-guardian", "codex-hooks", + "codex-image-generation-extension", "codex-login", "codex-mcp", + "codex-memories-extension", "codex-memories-write", "codex-model-provider", "codex-model-provider-info", @@ -1926,19 +1951,21 @@ dependencies = [ "codex-utils-cli", "codex-utils-json-to-toml", "codex-utils-pty", + "codex-web-search-extension", + "codex-windows-sandbox", "core_test_support", "flate2", "futures", - "hmac", + "hmac 0.12.1", "opentelemetry", "opentelemetry_sdk", "pretty_assertions", - "reqwest", + "reqwest 0.12.28", "rmcp", "serde", "serde_json", "serial_test", - "sha2", + "sha2 0.10.9", "shlex", "tar", "tempfile", @@ -1996,10 +2023,10 @@ dependencies = [ "futures", "libc", "pretty_assertions", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "tokio", "tokio-tungstenite", @@ -2075,13 +2102,13 @@ dependencies = [ "constant_time_eq 0.3.1", "futures", "gethostname", - "hmac", + "hmac 0.12.1", "jsonwebtoken", "owo-colors", "pretty_assertions", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "time", "tokio", @@ -2165,7 +2192,7 @@ dependencies = [ "codex-model-provider", "codex-protocol", "pretty_assertions", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", ] @@ -2235,6 +2262,7 @@ dependencies = [ "codex-exec-server", "codex-execpolicy", "codex-features", + "codex-git-utils", "codex-install-context", "codex-login", "codex-mcp", @@ -2246,6 +2274,7 @@ dependencies = [ "codex-protocol", "codex-responses-api-proxy", "codex-rmcp-client", + "codex-rollout", "codex-rollout-trace", "codex-sandboxing", "codex-state", @@ -2259,7 +2288,9 @@ dependencies = [ "codex-windows-sandbox", "crossterm", "http 1.4.0", + "insta", "libc", + "os_info", "owo-colors", "predicates", "pretty_assertions", @@ -2268,12 +2299,17 @@ dependencies = [ "serde_json", "sqlx", "supports-color 3.0.2", + "sys-locale", "tempfile", "tokio", "toml 0.9.11+spec-1.1.0", "tracing", "tracing-appender", "tracing-subscriber", + "unicode-segmentation", + "url", + "which 8.0.0", + "windows-sys 0.52.0", ] [[package]] @@ -2292,7 +2328,7 @@ dependencies = [ "pretty_assertions", "rand 0.9.3", "rcgen", - "reqwest", + "reqwest 0.12.28", "rustls", "rustls-native-certs", "rustls-pki-types", @@ -2320,11 +2356,12 @@ dependencies = [ "codex-login", "codex-otel", "codex-protocol", - "hmac", + "codex-utils-absolute-path", + "hmac 0.12.1", "pretty_assertions", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "thiserror 2.0.18", "tokio", @@ -2353,7 +2390,7 @@ dependencies = [ "owo-colors", "pretty_assertions", "ratatui", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "supports-color 3.0.2", @@ -2393,8 +2430,6 @@ dependencies = [ name = "codex-code-mode" version = "0.0.0" dependencies = [ - "async-channel", - "async-trait", "codex-protocol", "deno_core_icudata", "pretty_assertions", @@ -2432,6 +2467,7 @@ dependencies = [ "dunce", "futures", "gethostname", + "indexmap 2.13.0", "libc", "multimap", "pretty_assertions", @@ -2441,7 +2477,7 @@ dependencies = [ "serde_ignored", "serde_json", "serde_path_to_error", - "sha2", + "sha2 0.10.9", "tempfile", "thiserror 2.0.18", "tokio", @@ -2466,7 +2502,7 @@ dependencies = [ "pretty_assertions", "serde", "serde_json", - "sha1", + "sha1 0.10.6", "tempfile", "tokio", "tracing", @@ -2504,6 +2540,7 @@ dependencies = [ "codex-feedback", "codex-git-utils", "codex-hooks", + "codex-install-context", "codex-login", "codex-mcp", "codex-memories-read", @@ -2513,6 +2550,7 @@ dependencies = [ "codex-network-proxy", "codex-otel", "codex-plugin", + "codex-prompts", "codex-protocol", "codex-response-debug-context", "codex-rmcp-client", @@ -2537,7 +2575,6 @@ dependencies = [ "codex-utils-pty", "codex-utils-stream-parser", "codex-utils-string", - "codex-utils-template", "codex-windows-sandbox", "core_test_support", "csv", @@ -2561,12 +2598,12 @@ dependencies = [ "pretty_assertions", "rand 0.9.3", "regex-lite", - "reqwest", + "reqwest 0.12.28", "rmcp", "serde", "serde_json", "serial_test", - "sha1", + "sha1 0.10.6", "shlex", "similar", "tempfile", @@ -2586,7 +2623,7 @@ dependencies = [ "uuid", "walkdir", "which 8.0.0", - "whoami", + "whoami 1.6.1", "wiremock", "zstd 0.13.3", ] @@ -2632,9 +2669,11 @@ dependencies = [ "codex-utils-plugins", "dirs", "flate2", + "indexmap 2.13.0", "libc", "pretty_assertions", - "reqwest", + "reqwest 0.12.28", + "semver", "serde", "serde_json", "tar", @@ -2679,18 +2718,6 @@ dependencies = [ "zip 2.4.2", ] -[[package]] -name = "codex-debug-client" -version = "0.0.0" -dependencies = [ - "anyhow", - "clap", - "codex-app-server-protocol", - "pretty_assertions", - "serde", - "serde_json", -] - [[package]] name = "codex-exec" version = "0.0.0" @@ -2715,6 +2742,7 @@ dependencies = [ "codex-utils-cargo-bin", "codex-utils-cli", "codex-utils-oss", + "codex-utils-sandbox-summary", "core_test_support", "libc", "opentelemetry", @@ -2746,6 +2774,7 @@ dependencies = [ "axum", "base64 0.22.1", "bytes", + "codex-api", "codex-app-server-protocol", "codex-client", "codex-file-system", @@ -2757,9 +2786,10 @@ dependencies = [ "codex-utils-rustls-provider", "ctor 0.6.3", "futures", + "http 1.4.0", "pretty_assertions", "prost 0.14.3", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "serial_test", @@ -2825,6 +2855,7 @@ dependencies = [ name = "codex-extension-api" version = "0.0.0" dependencies = [ + "async-trait", "codex-protocol", "codex-tools", ] @@ -2851,7 +2882,7 @@ dependencies = [ "codex-utils-output-truncation", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", ] @@ -2942,10 +2973,33 @@ dependencies = [ "walkdir", ] +[[package]] +name = "codex-goal-extension" +version = "0.0.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "codex-core", + "codex-extension-api", + "codex-otel", + "codex-protocol", + "codex-state", + "codex-tools", + "codex-utils-template", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", +] + [[package]] name = "codex-guardian" version = "0.0.0" dependencies = [ + "async-trait", "codex-core", "codex-extension-api", "codex-protocol", @@ -2974,10 +3028,32 @@ dependencies = [ "uuid", ] +[[package]] +name = "codex-image-generation-extension" +version = "0.0.0" +dependencies = [ + "async-trait", + "codex-api", + "codex-core", + "codex-extension-api", + "codex-features", + "codex-login", + "codex-model-provider", + "codex-model-provider-info", + "codex-protocol", + "codex-tools", + "http 1.4.0", + "pretty_assertions", + "schemars 0.8.22", + "serde", + "serde_json", +] + [[package]] name = "codex-install-context" version = "0.0.0" dependencies = [ + "codex-utils-absolute-path", "codex-utils-home-dir", "pretty_assertions", "tempfile", @@ -2997,6 +3073,7 @@ version = "0.0.0" dependencies = [ "clap", "codex-core", + "codex-install-context", "codex-process-hardening", "codex-protocol", "codex-sandboxing", @@ -3008,7 +3085,7 @@ dependencies = [ "seccompiler", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "tokio", "url", @@ -3020,7 +3097,7 @@ version = "0.0.0" dependencies = [ "codex-core", "codex-model-provider-info", - "reqwest", + "reqwest 0.12.28", "serde_json", "tokio", "tracing", @@ -3054,11 +3131,11 @@ dependencies = [ "pretty_assertions", "rand 0.9.3", "regex-lite", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "serial_test", - "sha2", + "sha2 0.10.9", "tempfile", "thiserror 2.0.18", "tiny_http", @@ -3093,7 +3170,7 @@ dependencies = [ "rmcp", "serde", "serde_json", - "sha1", + "sha1 0.10.6", "tempfile", "thiserror 2.0.18", "tokio", @@ -3142,10 +3219,11 @@ dependencies = [ "codex-core", "codex-extension-api", "codex-features", - "codex-memories-read", + "codex-otel", "codex-tools", "codex-utils-absolute-path", "codex-utils-output-truncation", + "codex-utils-template", "pretty_assertions", "schemars 0.8.22", "serde", @@ -3155,23 +3233,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "codex-memories-mcp" -version = "0.0.0" -dependencies = [ - "anyhow", - "codex-utils-absolute-path", - "codex-utils-output-truncation", - "pretty_assertions", - "rmcp", - "schemars 0.8.22", - "serde", - "serde_json", - "tempfile", - "thiserror 2.0.18", - "tokio", -] - [[package]] name = "codex-memories-read" version = "0.0.0" @@ -3179,11 +3240,7 @@ dependencies = [ "codex-protocol", "codex-shell-command", "codex-utils-absolute-path", - "codex-utils-output-truncation", - "codex-utils-template", "pretty_assertions", - "tempfile", - "tokio", ] [[package]] @@ -3338,7 +3395,7 @@ dependencies = [ "codex-model-provider-info", "futures", "pretty_assertions", - "reqwest", + "reqwest 0.12.28", "semver", "serde_json", "tokio", @@ -3366,7 +3423,7 @@ dependencies = [ "opentelemetry_sdk", "os_info", "pretty_assertions", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "strum_macros 0.28.0", @@ -3396,6 +3453,19 @@ dependencies = [ "pretty_assertions", ] +[[package]] +name = "codex-prompts" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-execpolicy", + "codex-git-utils", + "codex-protocol", + "codex-utils-absolute-path", + "codex-utils-template", + "pretty_assertions", +] + [[package]] name = "codex-protocol" version = "0.0.0" @@ -3418,7 +3488,7 @@ dependencies = [ "landlock", "pretty_assertions", "quick-xml", - "reqwest", + "reqwest 0.12.28", "schemars 0.8.22", "seccompiler", "serde", @@ -3466,7 +3536,7 @@ dependencies = [ "ctor 0.6.3", "libc", "pretty_assertions", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "tiny_http", @@ -3494,12 +3564,12 @@ dependencies = [ "keyring", "oauth2", "pretty_assertions", - "reqwest", + "reqwest 0.13.4", "rmcp", "serde", "serde_json", "serial_test", - "sha2", + "sha2 0.10.9", "sse-stream", "tempfile", "thiserror 2.0.18", @@ -3527,6 +3597,7 @@ dependencies = [ "codex-utils-path", "codex-utils-string", "pretty_assertions", + "regex", "serde", "serde_json", "tempfile", @@ -3534,6 +3605,7 @@ dependencies = [ "tokio", "tracing", "uuid", + "zstd 0.13.3", ] [[package]] @@ -3589,7 +3661,7 @@ dependencies = [ "schemars 0.8.22", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "tracing", ] @@ -3713,6 +3785,7 @@ dependencies = [ "async-trait", "chrono", "codex-git-utils", + "codex-install-context", "codex-protocol", "codex-rollout", "codex-state", @@ -3737,14 +3810,18 @@ dependencies = [ "codex-features", "codex-protocol", "codex-utils-absolute-path", + "codex-utils-cargo-bin", + "codex-utils-output-truncation", "codex-utils-pty", "codex-utils-string", + "jsonptr", "pretty_assertions", "rmcp", "serde", "serde_json", "thiserror 2.0.18", "tracing", + "urlencoding", ] [[package]] @@ -3761,7 +3838,6 @@ dependencies = [ "codex-app-server-client", "codex-app-server-protocol", "codex-arg0", - "codex-chatgpt", "codex-cli", "codex-cloud-requirements", "codex-config", @@ -3823,12 +3899,12 @@ dependencies = [ "ratatui", "ratatui-macros", "regex-lite", - "reqwest", + "reqwest 0.12.28", "rmcp", "serde", "serde_json", "serial_test", - "sha2", + "sha2 0.10.9", "shlex", "strum 0.27.2", "strum_macros 0.28.0", @@ -3896,7 +3972,7 @@ name = "codex-utils-cache" version = "0.0.0" dependencies = [ "lru 0.16.3", - "sha1", + "sha1 0.10.6", "tokio", ] @@ -3945,6 +4021,7 @@ version = "0.0.0" dependencies = [ "base64 0.22.1", "codex-utils-cache", + "divan", "image", "mime_guess", "thiserror 2.0.18", @@ -4089,6 +4166,27 @@ dependencies = [ "v8", ] +[[package]] +name = "codex-web-search-extension" +version = "0.0.0" +dependencies = [ + "async-trait", + "codex-api", + "codex-core", + "codex-extension-api", + "codex-features", + "codex-login", + "codex-model-provider", + "codex-model-provider-info", + "codex-protocol", + "codex-tools", + "http 1.4.0", + "pretty_assertions", + "schemars 0.8.22", + "serde_json", + "url", +] + [[package]] name = "codex-windows-sandbox" version = "0.0.0" @@ -4110,9 +4208,9 @@ dependencies = [ "serde_json", "tempfile", "tokio", + "tracing-appender", "windows 0.58.0", "windows-sys 0.52.0", - "winres", ] [[package]] @@ -4187,6 +4285,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "condtype" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf0a07a401f374238ab8e2f11a104d2851bf9ce711ec69804834de8af45c7af" + [[package]] name = "console" version = "0.15.11" @@ -4206,7 +4310,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3bb320cac8a0750d7f25280aa97b09c26edfe161164238ecbbb31092b079e735" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "proptest", "serde_core", ] @@ -4367,7 +4471,7 @@ dependencies = [ "opentelemetry_sdk", "pretty_assertions", "regex-lite", - "reqwest", + "reqwest 0.12.28", "serde_json", "shlex", "similar", @@ -4412,7 +4516,7 @@ dependencies = [ "core-foundation-sys", "coreaudio-rs", "dasp_sample", - "jni", + "jni 0.21.1", "js-sys", "libc", "mach2", @@ -4434,6 +4538,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc" version = "3.4.0" @@ -4561,6 +4674,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "crypto_box" version = "0.9.1" @@ -4638,6 +4760,15 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -4645,9 +4776,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", - "digest", + "digest 0.10.7", "fiat-crypto", "rustc_version", "subtle", @@ -4879,10 +5010,10 @@ dependencies = [ "cbc", "dbus", "fastrand", - "hkdf", + "hkdf 0.12.4", "num", "once_cell", - "sha2", + "sha2 0.10.9", "zeroize", ] @@ -5072,17 +5203,28 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", "const-oid", - "crypto-common", + "crypto-common 0.1.7", "subtle", ] [[package]] -name = "diplomat" -version = "0.14.0" +name = "digest" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9adb46b05e2f53dcf6a7dfc242e4ce9eb60c369b6b6eb10826a01e93167f59c6" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "diplomat" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7935649d00000f5c5d735448ad3dc07b9738160727017914cf42138b8e8e6611" dependencies = [ "diplomat_core", "proc-macro2", @@ -5092,15 +5234,15 @@ dependencies = [ [[package]] name = "diplomat-runtime" -version = "0.14.0" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0569bd3caaf13829da7ee4e83dbf9197a0e1ecd72772da6d08f0b4c9285c8d29" +checksum = "970ac38ad677632efcee6d517e783958da9bc78ec206d8d5e35b459ffc5e4864" [[package]] name = "diplomat_core" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51731530ed7f2d4495019abc7df3744f53338e69e2863a6a64ae91821c763df1" +checksum = "9cf41b94101a4bce993febaf0098092b0bb31deaf0ecaf6e0a2562465f61b383" dependencies = [ "proc-macro2", "quote", @@ -5183,6 +5325,31 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "divan" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a405457ec78b8fe08b0e32b4a3570ab5dff6dd16eb9e76a5ee0a9d9cbd898933" +dependencies = [ + "cfg-if", + "clap", + "condtype", + "divan-macros", + "libc", + "regex-lite", +] + +[[package]] +name = "divan-macros" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9556bc800956545d6420a640173e5ba7dfa82f38d3ea5a167eb555bc69ac3323" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "dns-lookup" version = "3.0.1" @@ -5270,7 +5437,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest", + "digest 0.10.7", "elliptic-curve", "rfc6979", "signature", @@ -5296,7 +5463,7 @@ dependencies = [ "curve25519-dalek", "ed25519", "serde", - "sha2", + "sha2 0.10.9", "subtle", "zeroize", ] @@ -5318,7 +5485,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest", + "digest 0.10.7", "ff", "generic-array", "group", @@ -5456,7 +5623,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5467,13 +5634,12 @@ checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" [[package]] name = "etcetera" -version = "0.8.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" dependencies = [ "cfg-if", - "home", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -5644,9 +5810,9 @@ dependencies = [ [[package]] name = "fixed_decimal" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35eabf480f94d69182677e37571d3be065822acfafd12f2f085db44fbbcc8e57" +checksum = "79c3c892f121fff406e5dd6b28c1b30096b95111c30701a899d4f2b18da6d1bd" dependencies = [ "displaydoc", "smallvec", @@ -5729,17 +5895,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "flume" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" -dependencies = [ - "futures-core", - "futures-sink", - "spin", -] - [[package]] name = "flume" version = "0.12.0" @@ -5846,9 +6001,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -5856,15 +6011,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -5884,9 +6039,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -5903,9 +6058,9 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", @@ -5914,21 +6069,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -5938,7 +6093,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -6018,11 +6172,25 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + [[package]] name = "gif" version = "0.14.1" @@ -7080,11 +7248,11 @@ dependencies = [ [[package]] name = "hashlink" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] @@ -7099,7 +7267,7 @@ dependencies = [ "http 1.4.0", "httpdate", "mime", - "sha1", + "sha1 0.10.6", ] [[package]] @@ -7197,7 +7365,16 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", ] [[package]] @@ -7206,7 +7383,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", ] [[package]] @@ -7302,6 +7488,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.8.1" @@ -7340,7 +7535,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.5", + "webpki-roots", ] [[package]] @@ -7489,9 +7684,9 @@ dependencies = [ [[package]] name = "icu_calendar" -version = "2.1.1" +version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f0e52e009b6b16ba9c0693578796f2dd4aaa59a7f8f920423706714a89ac4e" +checksum = "a2b2acc6263f494f1df50685b53ff8e57869e47d5c6fe39c23d518ae9a4f3e45" dependencies = [ "calendrical_calculations", "displaydoc", @@ -7505,18 +7700,19 @@ dependencies = [ [[package]] name = "icu_calendar_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527f04223b17edfe0bd43baf14a0cb1b017830db65f3950dc00224860a9a446d" +checksum = "118577bcf3a0fa7c6ac0a7d6e951814da84ee56b9b1f68fb4d8d10b08cefaf4d" [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -7524,14 +7720,16 @@ dependencies = [ [[package]] name = "icu_decimal" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a38c52231bc348f9b982c1868a2af3195199623007ba2c7650f432038f5b3e8e" +checksum = "288247df2e32aa776ac54fdd64de552149ac43cb840f2761811f0e8d09719dd4" dependencies = [ + "displaydoc", "fixed_decimal", "icu_decimal_data", "icu_locale", "icu_locale_core", + "icu_plurals", "icu_provider", "writeable", "zerovec", @@ -7539,15 +7737,15 @@ dependencies = [ [[package]] name = "icu_decimal_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2905b4044eab2dd848fe84199f9195567b63ab3a93094711501363f63546fef7" +checksum = "6f14a5ca9e8af29eef62064f269078424283d90dbaffeac5225addf62aaabc22" [[package]] name = "icu_locale" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "532b11722e350ab6bf916ba6eb0efe3ee54b932666afec989465f9243fe6dd60" +checksum = "d5a396343c7208121dc86e35623d3dfe19814a7613cfd14964994cdc9c9a2e26" dependencies = [ "icu_collections", "icu_locale_core", @@ -7560,9 +7758,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -7574,15 +7772,15 @@ dependencies = [ [[package]] name = "icu_locale_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c5f1d16b4c3a2642d3a719f18f6b06070ab0aef246a6418130c955ae08aa831" +checksum = "d5fdcc9ac77c6d74ff5cf6e65ef3181d6af32003b16fce3a77fb451d2f695993" [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -7594,15 +7792,34 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_plurals" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a50023f1d49ad5c4333380328a0d4a19e4b9d6d842ec06639affd5ba47c8103" +dependencies = [ + "fixed_decimal", + "icu_locale", + "icu_plurals_data", + "icu_provider", + "zerovec", +] + +[[package]] +name = "icu_plurals_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8485497155dc865f901decb93ecc20d3e467df67bfeceb91e3ba34e2b11e8e1d" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -7614,15 +7831,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -7635,6 +7852,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -8037,19 +8260,68 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys", + "jni-sys 0.3.0", "log", "thiserror 1.0.69", "walkdir", "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.114", +] + [[package]] name = "jni-sys" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.114", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -8070,6 +8342,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonptr" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" + [[package]] name = "jsonwebtoken" version = "9.3.1" @@ -8185,9 +8463,12 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" @@ -8233,9 +8514,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.30.1" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" dependencies = [ "cc", "pkg-config", @@ -8249,7 +8530,7 @@ source = "git+https://github.com/juberti-oai/rust-sdks.git?rev=e2d1d1d230c6fc9df dependencies = [ "cxx", "glib", - "jni", + "jni 0.21.1", "js-sys", "lazy_static", "livekit-protocol", @@ -8539,12 +8820,12 @@ dependencies = [ [[package]] name = "md-5" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest", + "digest 0.11.3", ] [[package]] @@ -8690,7 +8971,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" dependencies = [ "bitflags 2.10.0", - "jni-sys", + "jni-sys 0.3.0", "log", "ndk-sys", "num_enum", @@ -8709,7 +8990,7 @@ version = "0.5.0+25.2.9519653" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" dependencies = [ - "jni-sys", + "jni-sys 0.3.0", ] [[package]] @@ -8874,22 +9155,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.5", - "smallvec", - "zeroize", -] - [[package]] name = "num-complex" version = "0.4.6" @@ -8954,7 +9219,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", - "libm", ] [[package]] @@ -9009,11 +9273,11 @@ dependencies = [ "getrandom 0.2.17", "http 1.4.0", "rand 0.8.5", - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "serde_path_to_error", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", "url", ] @@ -9204,7 +9468,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" dependencies = [ - "jni", + "jni 0.21.1", "ndk", "ndk-context", "num-derive", @@ -9370,7 +9634,7 @@ dependencies = [ "bytes", "http 1.4.0", "opentelemetry", - "reqwest", + "reqwest 0.12.28", ] [[package]] @@ -9385,7 +9649,7 @@ dependencies = [ "opentelemetry-proto", "opentelemetry_sdk", "prost 0.14.3", - "reqwest", + "reqwest 0.12.28", "serde_json", "thiserror 2.0.18", "tokio", @@ -9500,7 +9764,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -9622,10 +9886,10 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ - "digest", - "hmac", + "digest 0.10.7", + "hmac 0.12.1", "password-hash", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -9634,8 +9898,8 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest", - "hmac", + "digest 0.10.7", + "hmac 0.12.1", ] [[package]] @@ -9736,17 +10000,6 @@ dependencies = [ "futures-io", ] -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] - [[package]] name = "pkcs8" version = "0.10.2" @@ -9809,7 +10062,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -10046,7 +10299,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" dependencies = [ "bytes", - "heck 0.5.0", + "heck 0.4.1", "itertools 0.11.0", "log", "multimap", @@ -10066,7 +10319,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck 0.5.0", + "heck 0.4.1", "itertools 0.14.0", "log", "multimap", @@ -10218,6 +10471,7 @@ version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", @@ -10262,6 +10516,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radix_trie" version = "0.2.1" @@ -10425,7 +10685,7 @@ dependencies = [ "rama-utils", "rand 0.9.3", "serde", - "sha1", + "sha1 0.10.6", ] [[package]] @@ -10478,7 +10738,7 @@ checksum = "b28ee9e1e5d39264414b71f5c33e7fbb66b382c3fac456fe0daad39cf5509933" dependencies = [ "ahash", "const_format", - "flume 0.12.0", + "flume", "hex", "ipnet", "itertools 0.14.0", @@ -10493,7 +10753,7 @@ dependencies = [ "rama-macros", "rama-utils", "serde", - "sha2", + "sha2 0.10.9", "socket2 0.6.2", "tokio", ] @@ -10546,7 +10806,7 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "webpki-roots 1.0.5", + "webpki-roots", "x509-parser", ] @@ -10613,6 +10873,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20 0.10.0", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -10651,6 +10922,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -10870,16 +11147,56 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.4.2", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", "web-sys", - "webpki-roots 1.0.5", ] [[package]] name = "resb" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a067ab3b5ca3b4dc307d0de9cf75f9f5e6ca9717b192b2f28a36c83e5de9e76" +checksum = "22d392791f3c6802a1905a509e9d1a6039cbbcb5e9e00e5a6d3661f7c874f390" dependencies = [ "potential_utf", "serde_core", @@ -10897,7 +11214,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac", + "hmac 0.12.1", "subtle", ] @@ -10917,12 +11234,11 @@ dependencies = [ [[package]] name = "rmcp" -version = "0.15.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bef41ebc9ebed2c1b1d90203e9d1756091e8a00bbc3107676151f39868ca0ee" +checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e" dependencies = [ "async-trait", - "axum", "base64 0.22.1", "bytes", "chrono", @@ -10934,8 +11250,8 @@ dependencies = [ "pastey", "pin-project-lite", "process-wrap", - "rand 0.9.3", - "reqwest", + "rand 0.10.1", + "reqwest 0.13.4", "rmcp-macros", "schemars 1.2.1", "serde", @@ -10953,9 +11269,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "0.15.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e88ad84b8b6237a934534a62b379a5be6388915663c0cc598ceb9b3292bbbfe" +checksum = "6aefac48c364756e97f04c0401ba3231e8607882c7c1d92da0437dc16307904d" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -10964,26 +11280,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid", - "digest", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "signature", - "spki", - "subtle", - "zeroize", -] - [[package]] name = "rtrb" version = "0.3.3" @@ -11025,7 +11321,7 @@ version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" dependencies = [ - "sha2", + "sha2 0.10.9", "walkdir", ] @@ -11139,6 +11435,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework 3.5.1", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -11351,7 +11674,7 @@ checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" dependencies = [ "pbkdf2 0.12.2", "salsa20", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -11402,12 +11725,12 @@ dependencies = [ "cbc", "futures-util", "generic-array", - "hkdf", + "hkdf 0.12.4", "num", "once_cell", "rand 0.8.5", "serde", - "sha2", + "sha2 0.10.9", "zbus", ] @@ -11476,7 +11799,7 @@ checksum = "2f925d575b468e88b079faf590a8dd0c9c99e2ec29e9bab663ceb8b45056312f" dependencies = [ "httpdate", "native-tls", - "reqwest", + "reqwest 0.12.28", "sentry-actix", "sentry-backtrace", "sentry-contexts", @@ -11798,8 +12121,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -11808,8 +12142,8 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89f599ac0c323ebb1c6082821a54962b839832b03984598375bff3975b804423" dependencies = [ - "digest", - "sha1", + "digest 0.10.7", + "sha1 0.10.6", ] [[package]] @@ -11825,8 +12159,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -11897,7 +12242,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] @@ -11907,6 +12252,16 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -12009,9 +12364,9 @@ dependencies = [ [[package]] name = "sqlx" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" dependencies = [ "sqlx-core", "sqlx-macros", @@ -12022,12 +12377,13 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" dependencies = [ "base64 0.22.1", "bytes", + "cfg-if", "chrono", "crc", "crossbeam-queue", @@ -12037,17 +12393,16 @@ dependencies = [ "futures-intrusive", "futures-io", "futures-util", - "hashbrown 0.15.5", + "hashbrown 0.16.1", "hashlink", "indexmap 2.13.0", "log", "memchr", - "once_cell", "percent-encoding", "rustls", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "thiserror 2.0.18", "time", @@ -12056,14 +12411,14 @@ dependencies = [ "tracing", "url", "uuid", - "webpki-roots 0.26.11", + "webpki-roots", ] [[package]] name = "sqlx-macros" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" dependencies = [ "proc-macro2", "quote", @@ -12074,79 +12429,64 @@ dependencies = [ [[package]] name = "sqlx-macros-core" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" dependencies = [ + "cfg-if", "dotenvy", "either", "heck 0.5.0", "hex", - "once_cell", "proc-macro2", "quote", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sqlx-core", "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", "syn 2.0.114", + "thiserror 2.0.18", "tokio", "url", ] [[package]] name = "sqlx-mysql" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" dependencies = [ - "atoi", - "base64 0.22.1", "bitflags 2.10.0", "byteorder", "bytes", "chrono", "crc", - "digest", + "digest 0.11.3", "dotenvy", "either", - "futures-channel", "futures-core", - "futures-io", "futures-util", "generic-array", - "hex", - "hkdf", - "hmac", - "itoa", "log", - "md-5", - "memchr", - "once_cell", "percent-encoding", - "rand 0.8.5", - "rsa", "serde", - "sha1", - "sha2", - "smallvec", + "sha1 0.11.0", + "sha2 0.11.0", "sqlx-core", - "stringprep", "thiserror 2.0.18", "time", "tracing", "uuid", - "whoami", ] [[package]] name = "sqlx-postgres" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", "base64 0.22.1", @@ -12160,18 +12500,16 @@ dependencies = [ "futures-core", "futures-util", "hex", - "hkdf", - "hmac", - "home", + "hkdf 0.13.0", + "hmac 0.13.0", "itoa", "log", "md-5", "memchr", - "once_cell", - "rand 0.8.5", + "rand 0.10.1", "serde", "serde_json", - "sha2", + "sha2 0.11.0", "smallvec", "sqlx-core", "stringprep", @@ -12179,18 +12517,19 @@ dependencies = [ "time", "tracing", "uuid", - "whoami", + "whoami 2.1.2", ] [[package]] name = "sqlx-sqlite" -version = "0.8.6" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" dependencies = [ "atoi", "chrono", - "flume 0.11.1", + "flume", + "form_urlencoded", "futures-channel", "futures-core", "futures-executor", @@ -12200,7 +12539,6 @@ dependencies = [ "log", "percent-encoding", "serde", - "serde_urlencoded", "sqlx-core", "thiserror 2.0.18", "time", @@ -12598,7 +12936,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.2", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -12606,14 +12944,14 @@ dependencies = [ [[package]] name = "temporal_capi" -version = "0.1.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a151e402c2bdb6a3a2a2f3f225eddaead2e7ce7dd5d3fa2090deb11b17aa4ed8" +checksum = "8a2a1f001e756a9f5f2d175a9965c4c0b3a054f09f30de3a75ab49765f2deb36" dependencies = [ "diplomat", "diplomat-runtime", "icu_calendar", - "icu_locale", + "icu_locale_core", "num-traits", "temporal_rs", "timezone_provider", @@ -12623,13 +12961,14 @@ dependencies = [ [[package]] name = "temporal_rs" -version = "0.1.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88afde3bd75d2fc68d77a914bece426aa08aa7649ffd0cdd4a11c3d4d33474d1" +checksum = "9a902a45282e5175186b21d355efc92564601efe6e2d92818dc9e333d50bd4de" dependencies = [ + "calendrical_calculations", "core_maths", "icu_calendar", - "icu_locale", + "icu_locale_core", "ixdtf", "num-traits", "timezone_provider", @@ -12846,9 +13185,9 @@ dependencies = [ [[package]] name = "timezone_provider" -version = "0.1.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9ba0000e9e73862f3e7ca1ff159e2ddf915c9d8bb11e38a7874760f445d993" +checksum = "c48f9b04628a2b813051e4dfe97c65281e49625eabd09ec343190e31e399a8c2" dependencies = [ "tinystr", "zerotrie", @@ -12879,9 +13218,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "serde_core", @@ -13430,7 +13769,7 @@ dependencies = [ "rand 0.9.3", "rustls", "rustls-pki-types", - "sha1", + "sha1 0.10.6", "thiserror 2.0.18", "utf-8", ] @@ -13457,9 +13796,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "uds_windows" @@ -13592,7 +13931,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -13695,9 +14034,9 @@ dependencies = [ [[package]] name = "v8" -version = "146.4.0" +version = "147.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d97bcac5cdc5a195a4813f1855a6bc658f240452aac36caa12fd6c6f16026ab1" +checksum = "2df8fffd507fb18ed000673a83d937f58e60fb07f3306b2274284125b15137cd" dependencies = [ "bindgen", "bitflags 2.10.0", @@ -13804,6 +14143,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasite" version = "0.1.0" @@ -13869,6 +14217,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.4.2" @@ -13882,6 +14252,31 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver", +] + [[package]] name = "wayland-backend" version = "0.3.12" @@ -13979,7 +14374,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00f1243ef785213e3a32fa0396093424a3a6ea566f9948497e5a2309261a4c97" dependencies = [ "core-foundation 0.10.1", - "jni", + "jni 0.21.1", "log", "ndk-context", "objc2", @@ -13997,15 +14392,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.5", -] - [[package]] name = "webpki-roots" version = "1.0.5" @@ -14037,7 +14423,7 @@ dependencies = [ "anyhow", "fs2", "regex", - "reqwest", + "reqwest 0.12.28", "scratch", "semver", "zip 0.6.6", @@ -14083,6 +14469,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" + [[package]] name = "widestring" version = "1.2.1" @@ -14674,15 +15066,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "winres" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c" -dependencies = [ - "toml 0.5.11", -] - [[package]] name = "winsafe" version = "0.0.19" @@ -14723,6 +15106,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.13.0", + "prettyplease", + "syn 2.0.114", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.114", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "wl-clipboard-rs" @@ -14847,9 +15312,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -14858,9 +15323,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -14895,7 +15360,7 @@ dependencies = [ "rand 0.8.5", "serde", "serde_repr", - "sha1", + "sha1 0.10.6", "static_assertions", "tracing", "uds_windows", @@ -14993,20 +15458,21 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", "zerofrom", + "zerovec", ] [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "serde", "yoke", @@ -15016,9 +15482,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", @@ -15038,9 +15504,9 @@ dependencies = [ "crc32fast", "crossbeam-utils", "flate2", - "hmac", + "hmac 0.12.1", "pbkdf2 0.11.0", - "sha1", + "sha1 0.10.6", "time", "zstd 0.11.2+zstd.1.5.2", ] @@ -15061,12 +15527,12 @@ dependencies = [ "displaydoc", "flate2", "getrandom 0.3.4", - "hmac", + "hmac 0.12.1", "indexmap 2.13.0", "lzma-rs", "memchr", "pbkdf2 0.12.2", - "sha1", + "sha1 0.10.6", "thiserror 2.0.18", "time", "xz2", @@ -15089,9 +15555,9 @@ checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" [[package]] name = "zoneinfo64" -version = "0.2.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb2e5597efbe7c421da8a7fd396b20b571704e787c21a272eecf35dfe9d386f0" +checksum = "ed6eb2607e906160c457fd573e9297e65029669906b9ac8fb1b5cd5e055f0705" dependencies = [ "calendrical_calculations", "icu_locale_core", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 14b1264a81..f0c9b601f9 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -14,7 +14,6 @@ members = [ "app-server-client", "app-server-protocol", "app-server-test-client", - "debug-client", "apply-patch", "arg0", "feedback", @@ -45,8 +44,11 @@ members = [ "execpolicy", "execpolicy-legacy", "ext/extension-api", + "ext/goal", "ext/guardian", + "ext/image-generation", "ext/memories", + "ext/web-search", "external-agent-migration", "external-agent-sessions", "keyring-store", @@ -57,7 +59,6 @@ members = [ "login", "codex-mcp", "mcp-server", - "memories/mcp", "memories/read", "memories/write", "model-provider-info", @@ -67,6 +68,7 @@ members = [ "process-hardening", "protocol", "realtime-webrtc", + "prompts", "rollout", "rollout-trace", "rmcp-client", @@ -162,7 +164,9 @@ codex-file-system = { path = "file-system" } codex-exec-server = { path = "exec-server" } codex-execpolicy = { path = "execpolicy" } codex-extension-api = { path = "ext/extension-api" } +codex-goal-extension = { path = "ext/goal" } codex-guardian = { path = "ext/guardian" } +codex-image-generation-extension = { path = "ext/image-generation" } codex-external-agent-migration = { path = "external-agent-migration" } codex-external-agent-sessions = { path = "external-agent-sessions" } codex-experimental-api-macros = { path = "codex-experimental-api-macros" } @@ -179,6 +183,7 @@ codex-lmstudio = { path = "lmstudio" } codex-login = { path = "login" } codex-message-history = { path = "message-history" } codex-memories-extension = { path = "ext/memories" } +codex-web-search-extension = { path = "ext/web-search" } codex-memories-read = { path = "memories/read" } codex-memories-write = { path = "memories/write" } codex-mcp = { path = "codex-mcp" } @@ -193,6 +198,7 @@ codex-model-provider = { path = "model-provider" } codex-process-hardening = { path = "process-hardening" } codex-protocol = { path = "protocol" } codex-realtime-webrtc = { path = "realtime-webrtc" } +codex-prompts = { path = "prompts" } codex-responses-api-proxy = { path = "responses-api-proxy" } codex-response-debug-context = { path = "response-debug-context" } codex-rmcp-client = { path = "rmcp-client" } @@ -273,6 +279,7 @@ deno_core_icudata = "0.77.0" derive_more = "2" diffy = "0.4.2" dirs = "6" +divan = "0.1.21" dns-lookup = "3.0.1" dotenvy = "0.15.7" dunce = "1.0.4" @@ -299,6 +306,7 @@ indexmap = "2.12.0" insta = "1.46.3" inventory = "0.3.19" itertools = "0.14.0" +jsonptr = { version = "0.7.1", default-features = false } jsonwebtoken = "9.3.1" keyring = { version = "3.6", default-features = false } landlock = "0.4.4" @@ -337,7 +345,7 @@ rcgen = { version = "0.14.7", default-features = false, features = [ regex = "1.12.3" regex-lite = "0.1.8" reqwest = { version = "0.12", features = ["cookies"] } -rmcp = { version = "0.15.0", default-features = false } +rmcp = { version = "1.7.0", default-features = false } runfiles = { git = "https://github.com/dzbarsky/rules_rust", rev = "b56cbaa8465e74127f1ea216f813cd377295ad81" } rustls = { version = "0.23", default-features = false, features = [ "ring", @@ -361,13 +369,14 @@ sha2 = "0.10" shlex = "1.3.0" similar = "2.7.0" socket2 = "0.6.1" -sqlx = { version = "0.8.6", default-features = false, features = [ +sqlx = { version = "0.9.0", default-features = false, features = [ "chrono", "json", "macros", "migrate", - "runtime-tokio-rustls", - "sqlite", + "runtime-tokio", + "tls-rustls", + "sqlite-bundled", "time", "uuid", ] } @@ -411,7 +420,7 @@ unicode-width = "0.2" url = "2" urlencoding = "2.1" uuid = "1" -v8 = "=146.4.0" +v8 = "=147.4.0" vt100 = "0.16.2" walkdir = "2.5.0" webbrowser = "1.0" @@ -470,7 +479,7 @@ unwrap_used = "deny" [workspace.metadata.cargo-shear] ignored = [ "codex-agent-graph-store", - "codex-memories-extension", + "codex-goal-extension", "icu_provider", "openssl-sys", "codex-v8-poc", diff --git a/codex-rs/README.md b/codex-rs/README.md index d219061a35..18bffd9f64 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -55,25 +55,20 @@ Use `codex exec --ephemeral ...` to run without persisting session rollout files ### Experimenting with the Codex Sandbox -To test to see what happens when a command is run under the sandbox provided by Codex, we provide the following subcommands in Codex CLI: +To test to see what happens when a command is run under the sandbox provided by Codex, use the `sandbox` subcommand in Codex CLI: ``` -# macOS -codex sandbox macos [--log-denials] [COMMAND]... +# Uses the sandbox implementation for the current host OS: +# Seatbelt on macOS, the Linux sandbox on Linux, and Windows restricted token on Windows. +codex sandbox [COMMAND]... -# Linux -codex sandbox linux [COMMAND]... - -# Windows -codex sandbox windows [COMMAND]... - -# Legacy aliases -codex debug seatbelt [--log-denials] [COMMAND]... -codex debug landlock [COMMAND]... +# macOS-only diagnostic option +codex sandbox --log-denials [COMMAND]... ``` -To try a writable legacy sandbox mode with these commands, pass an explicit config override such -as `-c 'sandbox_mode="workspace-write"'`. +`codex sandbox` also accepts `--profile NAME` (`-p NAME`) to layer +`$CODEX_HOME/NAME.config.toml` onto the base user config for the sandboxed +command. ### Selecting a sandbox policy via `--sandbox` @@ -90,7 +85,6 @@ codex --sandbox workspace-write codex --sandbox danger-full-access ``` -The same setting can be persisted in `~/.codex/config.toml` via the top-level `sandbox_mode = "MODE"` key, e.g. `sandbox_mode = "workspace-write"`. In `workspace-write`, Codex also includes `~/.codex/memories` in its writable roots so memory maintenance does not require an extra approval. ## Code Organization diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 5b4a72229b..b3f6ba22ce 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -138,7 +138,6 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::HookSource; -use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::ThreadSource; @@ -161,11 +160,13 @@ fn sample_thread_with_metadata( ephemeral: bool, source: AppServerSessionSource, thread_source: Option, + parent_thread_id: Option, ) -> Thread { Thread { id: thread_id.to_string(), session_id: format!("session-{thread_id}"), forked_from_id: None, + parent_thread_id, preview: "first prompt".to_string(), ephemeral, model_provider: "openai".to_string(), @@ -196,16 +197,17 @@ fn sample_thread_start_response( ephemeral, AppServerSessionSource::Exec, Some(AppServerThreadSource::User), + /*parent_thread_id*/ None, ), model: model.to_string(), model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + runtime_workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, - permission_profile: None, active_permission_profile: None, reasoning_effort: None, }) @@ -241,6 +243,7 @@ fn sample_thread_resume_response( model, AppServerSessionSource::Exec, Some(AppServerThreadSource::User), + /*parent_thread_id*/ None, ) } @@ -250,20 +253,28 @@ fn sample_thread_resume_response_with_source( model: &str, source: AppServerSessionSource, thread_source: Option, + parent_thread_id: Option, ) -> ClientResponsePayload { ClientResponsePayload::ThreadResume(ThreadResumeResponse { - thread: sample_thread_with_metadata(thread_id, ephemeral, source, thread_source), + thread: sample_thread_with_metadata( + thread_id, + ephemeral, + source, + thread_source, + parent_thread_id, + ), model: model.to_string(), model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + runtime_workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, - permission_profile: None, active_permission_profile: None, reasoning_effort: None, + initial_turns_page: None, }) } @@ -272,6 +283,7 @@ fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest request_id: RequestId::Integer(request_id), params: TurnStartParams { thread_id: thread_id.to_string(), + client_user_message_id: None, input: vec![ UserInput::Text { text: "hello".to_string(), @@ -279,6 +291,7 @@ fn sample_turn_start_request(thread_id: &str, request_id: i64) -> ClientRequest }, UserInput::Image { url: "https://example.com/a.png".to_string(), + detail: None, }, ], ..Default::default() @@ -366,9 +379,7 @@ fn sample_turn_resolved_config(thread_id: &str, turn_id: &str) -> TurnResolvedCo session_source: SessionSource::Exec, model: "gpt-5".to_string(), model_provider: "openai".to_string(), - permission_profile: CorePermissionProfile::from_legacy_sandbox_policy( - &SandboxPolicy::new_read_only_policy(), - ), + permission_profile: CorePermissionProfile::read_only(), permission_profile_cwd: PathBuf::from("/tmp"), reasoning_effort: None, reasoning_summary: None, @@ -392,6 +403,7 @@ fn sample_turn_steer_request( params: TurnSteerParams { thread_id: thread_id.to_string(), expected_turn_id: expected_turn_id.to_string(), + client_user_message_id: None, input: vec![ UserInput::Text { text: "more".to_string(), @@ -399,9 +411,11 @@ fn sample_turn_steer_request( }, UserInput::LocalImage { path: "/tmp/a.png".into(), + detail: None, }, ], responsesapi_client_metadata: None, + additional_context: None, }, } } @@ -1213,6 +1227,7 @@ fn compaction_event_serializes_expected_shape() { completed_at: 106, duration_ms: Some(6543), }, + "session-thread-1".to_string(), sample_app_server_client_metadata(), sample_runtime_metadata(), Some(ThreadSource::User), @@ -1229,6 +1244,7 @@ fn compaction_event_serializes_expected_shape() { "event_type": "codex_compaction_event", "event_params": { "thread_id": "thread-1", + "session_id": "session-thread-1", "turn_id": "turn-1", "app_server_client": { "product_client_id": DEFAULT_ORIGINATOR, @@ -1263,6 +1279,14 @@ fn compaction_event_serializes_expected_shape() { ); } +#[test] +fn compaction_implementation_serializes_remote_v2() { + let payload = serde_json::to_value(CompactionImplementation::ResponsesCompactionV2) + .expect("serialize compaction implementation"); + + assert_eq!(payload, json!("responses_compaction_v2")); +} + #[test] fn app_used_dedupe_is_keyed_by_turn_and_connector() { let (sender, _receiver) = mpsc::channel(1); @@ -1299,6 +1323,7 @@ fn thread_initialized_event_serializes_expected_shape() { event_type: "codex_thread_initialized", event_params: ThreadInitializedEventParams { thread_id: "thread-0".to_string(), + session_id: "session-thread-0".to_string(), app_server_client: CodexAppServerClientMetadata { product_client_id: DEFAULT_ORIGINATOR.to_string(), client_name: Some("codex-tui".to_string()), @@ -1330,6 +1355,7 @@ fn thread_initialized_event_serializes_expected_shape() { "event_type": "codex_thread_initialized", "event_params": { "thread_id": "thread-0", + "session_id": "session-thread-0", "app_server_client": { "product_client_id": DEFAULT_ORIGINATOR, "client_name": "codex-tui", @@ -1597,6 +1623,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize let payload = serde_json::to_value(&events).expect("serialize events"); assert_eq!(payload.as_array().expect("events array").len(), 1); assert_eq!(payload[0]["event_type"], "codex_thread_initialized"); + assert_eq!(payload[0]["event_params"]["session_id"], "session-thread-1"); assert_eq!( payload[0]["event_params"]["app_server_client"]["product_client_id"], DEFAULT_ORIGINATOR @@ -1739,6 +1766,7 @@ async fn compaction_event_ingests_custom_fact() { agent_role: None, }), Some(AppServerThreadSource::Subagent), + Some(parent_thread_id.to_string()), )), }, &mut events, @@ -1773,6 +1801,7 @@ async fn compaction_event_ingests_custom_fact() { let payload = serde_json::to_value(&events).expect("serialize events"); assert_eq!(payload.as_array().expect("events array").len(), 1); assert_eq!(payload[0]["event_type"], "codex_compaction_event"); + assert_eq!(payload[0]["event_params"]["session_id"], "session-thread-1"); assert_eq!(payload[0]["event_params"]["thread_id"], "thread-1"); assert_eq!(payload[0]["event_params"]["turn_id"], "turn-compact"); assert_eq!( @@ -1897,6 +1926,10 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() { let payload = serde_json::to_value(&events).expect("serialize events"); assert_eq!(payload.as_array().expect("events array").len(), 1); assert_eq!(payload[0]["event_type"], "codex_guardian_review"); + assert_eq!( + payload[0]["event_params"]["session_id"], + "session-thread-guardian" + ); assert_eq!(payload[0]["event_params"]["thread_id"], "thread-guardian"); assert_eq!(payload[0]["event_params"]["turn_id"], "turn-guardian"); assert_eq!(payload[0]["event_params"]["review_id"], "review-guardian"); @@ -2389,6 +2422,7 @@ async fn item_review_summaries_do_not_cross_threads_with_reused_item_ids() { fn subagent_thread_started_review_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( SubAgentThreadStartedInput { + session_id: "session-root".to_string(), thread_id: "thread-review".to_string(), parent_thread_id: None, product_client_id: "codex-tui".to_string(), @@ -2432,8 +2466,9 @@ fn subagent_thread_started_thread_spawn_serializes_parent_thread_id() { .expect("valid thread id"); let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( SubAgentThreadStartedInput { + session_id: "session-root".to_string(), thread_id: "thread-spawn".to_string(), - parent_thread_id: None, + parent_thread_id: Some(parent_thread_id.to_string()), product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), @@ -2451,18 +2486,21 @@ fn subagent_thread_started_thread_spawn_serializes_parent_thread_id() { )); let payload = serde_json::to_value(&event).expect("serialize thread spawn subagent event"); + assert_eq!(payload["event_params"]["thread_id"], "thread-spawn"); assert_eq!(payload["event_params"]["thread_source"], "subagent"); assert_eq!(payload["event_params"]["subagent_source"], "thread_spawn"); assert_eq!( payload["event_params"]["parent_thread_id"], "11111111-1111-1111-1111-111111111111" ); + assert_eq!(payload["event_params"]["session_id"], "session-root"); } #[test] fn subagent_thread_started_memory_consolidation_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( SubAgentThreadStartedInput { + session_id: "session-root".to_string(), thread_id: "thread-memory".to_string(), parent_thread_id: None, product_client_id: "codex-tui".to_string(), @@ -2488,6 +2526,7 @@ fn subagent_thread_started_memory_consolidation_serializes_expected_shape() { fn subagent_thread_started_other_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( SubAgentThreadStartedInput { + session_id: "session-root".to_string(), thread_id: "thread-guardian".to_string(), parent_thread_id: None, product_client_id: "codex-tui".to_string(), @@ -2507,10 +2546,14 @@ fn subagent_thread_started_other_serializes_expected_shape() { #[test] fn subagent_thread_started_other_serializes_explicit_parent_thread_id() { + let parent_thread_id = + codex_protocol::ThreadId::from_string("33333333-3333-4333-8333-333333333333") + .expect("valid thread id"); let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( SubAgentThreadStartedInput { + session_id: "session-root".to_string(), thread_id: "thread-guardian".to_string(), - parent_thread_id: Some("parent-thread-guardian".to_string()), + parent_thread_id: Some(parent_thread_id.to_string()), product_client_id: "codex-tui".to_string(), client_name: "codex-tui".to_string(), client_version: "1.0.0".to_string(), @@ -2525,7 +2568,7 @@ fn subagent_thread_started_other_serializes_explicit_parent_thread_id() { assert_eq!(payload["event_params"]["subagent_source"], "guardian"); assert_eq!( payload["event_params"]["parent_thread_id"], - "parent-thread-guardian" + "33333333-3333-4333-8333-333333333333" ); } @@ -2538,6 +2581,7 @@ async fn subagent_thread_started_publishes_without_initialize() { .ingest( AnalyticsFact::Custom(CustomAnalyticsFact::SubAgentThreadStarted( SubAgentThreadStartedInput { + session_id: "session-root".to_string(), thread_id: "thread-review".to_string(), parent_thread_id: None, product_client_id: "codex-tui".to_string(), @@ -2611,8 +2655,9 @@ async fn subagent_thread_started_inherits_parent_connection_for_new_thread() { .ingest( AnalyticsFact::Custom(CustomAnalyticsFact::SubAgentThreadStarted( SubAgentThreadStartedInput { + session_id: "session-root".to_string(), thread_id: "thread-review".to_string(), - parent_thread_id: None, + parent_thread_id: Some(parent_thread_id.to_string()), product_client_id: "parent-client".to_string(), client_name: "parent-client".to_string(), client_version: "1.0.0".to_string(), @@ -2658,6 +2703,8 @@ async fn subagent_thread_started_inherits_parent_connection_for_new_thread() { .await; let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!(payload[0]["event_params"]["session_id"], "session-root"); + assert_eq!(payload[0]["event_params"]["thread_id"], "thread-review"); assert_eq!( payload[0]["event_params"]["app_server_client"]["product_client_id"], "parent-client" @@ -2678,6 +2725,7 @@ async fn subagent_tool_items_inherit_parent_connection_metadata() { .ingest( AnalyticsFact::Custom(CustomAnalyticsFact::SubAgentThreadStarted( SubAgentThreadStartedInput { + session_id: "session-root".to_string(), thread_id: "thread-subagent".to_string(), parent_thread_id: Some("thread-1".to_string()), product_client_id: "codex-tui".to_string(), @@ -3183,6 +3231,7 @@ fn turn_event_serializes_expected_shape() { event_type: "codex_turn_event", event_params: crate::events::CodexTurnEventParams { thread_id: "thread-2".to_string(), + session_id: "session-thread-2".to_string(), turn_id: "turn-2".to_string(), app_server_client: sample_app_server_client_metadata(), runtime: sample_runtime_metadata(), @@ -3233,6 +3282,7 @@ fn turn_event_serializes_expected_shape() { "event_type": "codex_turn_event", "event_params": { "thread_id": "thread-2", + "session_id": "session-thread-2", "turn_id": "turn-2", "submission_type": null, "app_server_client": { @@ -3334,6 +3384,10 @@ async fn accepted_turn_steer_emits_expected_event() { let payload = serde_json::to_value(&out[0]).expect("serialize turn steer event"); assert_eq!(payload["event_type"], json!("codex_turn_steer_event")); assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!( + payload["event_params"]["session_id"], + json!("session-thread-2") + ); assert_eq!(payload["event_params"]["expected_turn_id"], json!("turn-2")); assert_eq!(payload["event_params"]["accepted_turn_id"], json!("turn-2")); assert_eq!(payload["event_params"]["num_input_images"], json!(1)); @@ -3551,6 +3605,10 @@ async fn turn_lifecycle_emits_turn_event() { let payload = serde_json::to_value(&out[0]).expect("serialize turn event"); assert_eq!(payload["event_type"], json!("codex_turn_event")); assert_eq!(payload["event_params"]["thread_id"], json!("thread-2")); + assert_eq!( + payload["event_params"]["session_id"], + json!("session-thread-2") + ); assert_eq!(payload["event_params"]["turn_id"], json!("turn-2")); assert_eq!( payload["event_params"]["app_server_client"], @@ -3629,6 +3687,7 @@ async fn turn_event_counts_completed_tool_items() { status: McpToolCallStatus::Completed, arguments: json!({}), mcp_app_resource_uri: None, + plugin_id: None, result: None, error: None, duration_ms: Some(2), diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index 71c46d808a..b7aa9f1c97 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -12,7 +12,6 @@ use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponsePayload; -use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::SessionSource as AppServerSessionSource; @@ -29,7 +28,6 @@ use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::TurnSteerResponse; -use codex_protocol::models::PermissionProfile as CorePermissionProfile; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use std::collections::HashSet; @@ -91,6 +89,7 @@ fn sample_turn_start_request() -> ClientRequest { request_id: RequestId::Integer(1), params: TurnStartParams { thread_id: "thread-1".to_string(), + client_user_message_id: None, input: Vec::new(), ..Default::default() }, @@ -103,8 +102,10 @@ fn sample_turn_steer_request() -> ClientRequest { params: TurnSteerParams { thread_id: "thread-1".to_string(), expected_turn_id: "turn-1".to_string(), + client_user_message_id: None, input: Vec::new(), responsesapi_client_metadata: None, + additional_context: None, }, } } @@ -123,6 +124,7 @@ fn sample_thread(thread_id: &str) -> Thread { id: thread_id.to_string(), session_id: format!("session-{thread_id}"), forked_from_id: None, + parent_thread_id: None, preview: "first prompt".to_string(), ephemeral: false, model_provider: "openai".to_string(), @@ -142,10 +144,6 @@ fn sample_thread(thread_id: &str) -> Thread { } } -fn sample_permission_profile() -> AppServerPermissionProfile { - CorePermissionProfile::Disabled.into() -} - fn sample_thread_start_response() -> ClientResponsePayload { ClientResponsePayload::ThreadStart(ThreadStartResponse { thread: sample_thread("thread-1"), @@ -153,11 +151,11 @@ fn sample_thread_start_response() -> ClientResponsePayload { model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + runtime_workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, - permission_profile: Some(sample_permission_profile()), active_permission_profile: None, reasoning_effort: None, }) @@ -170,13 +168,14 @@ fn sample_thread_resume_response() -> ClientResponsePayload { model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + runtime_workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, - permission_profile: Some(sample_permission_profile()), active_permission_profile: None, reasoning_effort: None, + initial_turns_page: None, }) } @@ -187,11 +186,11 @@ fn sample_thread_fork_response() -> ClientResponsePayload { model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + runtime_workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, - permission_profile: Some(sample_permission_profile()), active_permission_profile: None, reasoning_effort: None, }) diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index d107a5a367..fd52fefc1e 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -147,6 +147,7 @@ pub(crate) struct CodexRuntimeMetadata { #[derive(Serialize)] pub(crate) struct ThreadInitializedEventParams { pub(crate) thread_id: String, + pub(crate) session_id: String, pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, pub(crate) model: String, @@ -420,6 +421,7 @@ impl GuardianReviewAnalyticsResult { #[derive(Serialize)] pub(crate) struct GuardianReviewEventPayload { + pub(crate) session_id: String, pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, #[serde(flatten)] @@ -738,6 +740,7 @@ pub(crate) struct CodexHookRunEventRequest { #[derive(Serialize)] pub(crate) struct CodexCompactionEventParams { pub(crate) thread_id: String, + pub(crate) session_id: String, pub(crate) turn_id: String, pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, @@ -767,6 +770,7 @@ pub(crate) struct CodexCompactionEventRequest { #[derive(Serialize)] pub(crate) struct CodexTurnEventParams { pub(crate) thread_id: String, + pub(crate) session_id: String, pub(crate) turn_id: String, // TODO(rhan-oai): Populate once queued/default submission type is plumbed from // the turn/start callsites instead of always being reported as None. @@ -821,6 +825,7 @@ pub(crate) struct CodexTurnEventRequest { #[derive(Serialize)] pub(crate) struct CodexTurnSteerEventParams { pub(crate) thread_id: String, + pub(crate) session_id: String, pub(crate) expected_turn_id: Option, pub(crate) accepted_turn_id: Option, pub(crate) app_server_client: CodexAppServerClientMetadata, @@ -926,6 +931,7 @@ pub(crate) fn codex_plugin_metadata(plugin: PluginTelemetryMetadata) -> CodexPlu pub(crate) fn codex_compaction_event_params( input: CodexCompactionEvent, + session_id: String, app_server_client: CodexAppServerClientMetadata, runtime: CodexRuntimeMetadata, thread_source: Option, @@ -934,6 +940,7 @@ pub(crate) fn codex_compaction_event_params( ) -> CodexCompactionEventParams { CodexCompactionEventParams { thread_id: input.thread_id, + session_id, turn_id: input.turn_id, app_server_client, runtime, @@ -990,6 +997,8 @@ fn analytics_hook_event_name(event_name: HookEventName) -> &'static str { HookEventName::PostCompact => "PostCompact", HookEventName::SessionStart => "SessionStart", HookEventName::UserPromptSubmit => "UserPromptSubmit", + HookEventName::SubagentStart => "SubagentStart", + HookEventName::SubagentStop => "SubagentStop", HookEventName::Stop => "Stop", } } @@ -1003,6 +1012,7 @@ fn analytics_hook_source(source: HookSource) -> &'static str { HookSource::SessionFlags => "session_flags", HookSource::Plugin => "plugin", HookSource::CloudRequirements => "cloud_requirements", + HookSource::CloudManagedConfig => "cloud_managed_config", HookSource::LegacyManagedConfigFile => "legacy_managed_config_file", HookSource::LegacyManagedConfigMdm => "legacy_managed_config_mdm", HookSource::Unknown => "unknown", @@ -1024,6 +1034,7 @@ pub(crate) fn subagent_thread_started_event_request( ) -> ThreadInitializedEvent { let event_params = ThreadInitializedEventParams { thread_id: input.thread_id, + session_id: input.session_id, app_server_client: CodexAppServerClientMetadata { product_client_id: input.product_client_id, client_name: Some(input.client_name), @@ -1037,9 +1048,7 @@ pub(crate) fn subagent_thread_started_event_request( thread_source: Some(ThreadSource::Subagent), initialization_mode: ThreadInitializationMode::New, subagent_source: Some(subagent_source_name(&input.subagent_source)), - parent_thread_id: input - .parent_thread_id - .or_else(|| subagent_parent_thread_id(&input.subagent_source)), + parent_thread_id: input.parent_thread_id, created_at: input.created_at, }; ThreadInitializedEvent { @@ -1049,22 +1058,7 @@ pub(crate) fn subagent_thread_started_event_request( } pub(crate) fn subagent_source_name(subagent_source: &SubAgentSource) -> String { - match subagent_source { - SubAgentSource::Review => "review".to_string(), - SubAgentSource::Compact => "compact".to_string(), - SubAgentSource::ThreadSpawn { .. } => "thread_spawn".to_string(), - SubAgentSource::MemoryConsolidation => "memory_consolidation".to_string(), - SubAgentSource::Other(other) => other.clone(), - } -} - -pub(crate) fn subagent_parent_thread_id(subagent_source: &SubAgentSource) -> Option { - match subagent_source { - SubAgentSource::ThreadSpawn { - parent_thread_id, .. - } => Some(parent_thread_id.to_string()), - _ => None, - } + subagent_source.kind().to_string() } fn analytics_hook_status(status: HookRunStatus) -> HookRunStatus { diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index d7e2c069d6..9a85276712 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -199,6 +199,7 @@ pub struct AppInvocation { #[derive(Clone)] pub struct SubAgentThreadStartedInput { + pub session_id: String, pub thread_id: String, pub parent_thread_id: Option, pub product_client_id: String, @@ -229,6 +230,7 @@ pub enum CompactionReason { #[serde(rename_all = "snake_case")] pub enum CompactionImplementation { Responses, + ResponsesCompactionV2, ResponsesCompact, } diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d072720d10..f206393476 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -55,7 +55,6 @@ use crate::events::codex_hook_run_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::plugin_state_event_type; -use crate::events::subagent_parent_thread_id; use crate::events::subagent_source_name; use crate::events::subagent_thread_started_event_request; use crate::facts::AnalyticsFact; @@ -255,6 +254,7 @@ struct ItemReviewSummary { #[derive(Clone)] struct ThreadMetadataState { + session_id: String, thread_source: Option, initialization_mode: ThreadInitializationMode, subagent_source: Option, @@ -263,24 +263,24 @@ struct ThreadMetadataState { impl ThreadMetadataState { fn from_thread_metadata( + session_id: String, session_source: &SessionSource, thread_source: Option, + parent_thread_id: Option, initialization_mode: ThreadInitializationMode, ) -> Self { - let (subagent_source, parent_thread_id) = match session_source { - SessionSource::SubAgent(subagent_source) => ( - Some(subagent_source_name(subagent_source)), - subagent_parent_thread_id(subagent_source), - ), + let subagent_source = match session_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source_name(subagent_source)), SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec | SessionSource::Mcp | SessionSource::Custom(_) | SessionSource::Internal(_) - | SessionSource::Unknown => (None, None), + | SessionSource::Unknown => None, }; Self { + session_id, thread_source, initialization_mode, subagent_source, @@ -513,10 +513,7 @@ impl AnalyticsReducer { input: SubAgentThreadStartedInput, out: &mut Vec, ) { - let parent_thread_id = input - .parent_thread_id - .clone() - .or_else(|| subagent_parent_thread_id(&input.subagent_source)); + let parent_thread_id = input.parent_thread_id.clone(); let parent_connection_id = parent_thread_id .as_ref() .and_then(|parent_thread_id| self.threads.get(parent_thread_id)) @@ -525,6 +522,7 @@ impl AnalyticsReducer { thread_state .metadata .get_or_insert_with(|| ThreadMetadataState { + session_id: input.session_id.clone(), thread_source: Some(ThreadSource::Subagent), initialization_mode: ThreadInitializationMode::New, subagent_source: Some(subagent_source_name(&input.subagent_source)), @@ -543,8 +541,8 @@ impl AnalyticsReducer { input: GuardianReviewEventParams, out: &mut Vec, ) { - let Some(connection_state) = - self.thread_connection_or_warn(AnalyticsDropSite::guardian(&input)) + let Some((connection_state, thread_metadata)) = + self.thread_context_or_warn(AnalyticsDropSite::guardian(&input)) else { return; }; @@ -552,6 +550,7 @@ impl AnalyticsReducer { GuardianReviewEventRequest { event_type: "codex_guardian_review", event_params: GuardianReviewEventPayload { + session_id: thread_metadata.session_id.clone(), app_server_client: connection_state.app_server_client.clone(), runtime: connection_state.runtime.clone(), guardian_review: input, @@ -1231,13 +1230,17 @@ impl AnalyticsReducer { out: &mut Vec, ) { let session_source: SessionSource = thread.source.into(); + let session_id = thread.session_id; let thread_id = thread.id; + let parent_thread_id = thread.parent_thread_id; let Some(connection_state) = self.connections.get(&connection_id) else { return; }; let thread_metadata = ThreadMetadataState::from_thread_metadata( + session_id.clone(), &session_source, thread.thread_source.map(Into::into), + parent_thread_id, initialization_mode, ); self.threads.insert( @@ -1252,6 +1255,7 @@ impl AnalyticsReducer { event_type: "codex_thread_initialized", event_params: ThreadInitializedEventParams { thread_id, + session_id, app_server_client: connection_state.app_server_client.clone(), runtime: connection_state.runtime.clone(), model, @@ -1277,6 +1281,7 @@ impl AnalyticsReducer { event_type: "codex_compaction_event", event_params: codex_compaction_event_params( input, + thread_metadata.session_id.clone(), connection_state.app_server_client.clone(), connection_state.runtime.clone(), thread_metadata.thread_source, @@ -1379,6 +1384,7 @@ impl AnalyticsReducer { event_type: "codex_turn_steer_event", event_params: CodexTurnSteerEventParams { thread_id: pending_request.thread_id, + session_id: thread_metadata.session_id.clone(), expected_turn_id: Some(pending_request.expected_turn_id), accepted_turn_id, app_server_client: connection_state.app_server_client.clone(), @@ -2447,6 +2453,7 @@ fn codex_turn_event_params( let token_usage = turn_state.token_usage.clone(); CodexTurnEventParams { thread_id, + session_id: thread_metadata.session_id.clone(), turn_id, app_server_client, runtime, diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 49a7a3b800..746f0d8448 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -176,6 +176,7 @@ pub(crate) fn server_notification_requires_delivery(notification: &ServerNotific matches!( notification, ServerNotification::TurnCompleted(_) + | ServerNotification::ThreadSettingsUpdated(_) | ServerNotification::ItemCompleted(_) | ServerNotification::AgentMessageDelta(_) | ServerNotification::PlanDelta(_) @@ -1121,7 +1122,9 @@ mod tests { websocket, JSONRPCMessage::Response(JSONRPCResponse { id: request.id, - result: serde_json::json!({}), + result: serde_json::json!({ + "userAgent": "codex_cli_rs/9.8.7-test (Test OS; x86_64) rust", + }), }), ) .await; @@ -1456,6 +1459,7 @@ mod tests { .await .expect("remote client should connect"); + assert_eq!(client.server_version(), Some("9.8.7-test")); let response: GetAccountResponse = client .request_typed(ClientRequest::GetAccount { request_id: RequestId::Integer(1), @@ -2178,11 +2182,13 @@ mod tests { let environment_manager = Arc::new( EnvironmentManager::create_for_tests( Some("ws://127.0.0.1:8765".to_string()), - ExecServerRuntimePaths::new( - std::env::current_exe().expect("current exe"), - /*codex_linux_sandbox_exe*/ None, - ) - .expect("runtime paths"), + Some( + ExecServerRuntimePaths::new( + std::env::current_exe().expect("current exe"), + /*codex_linux_sandbox_exe*/ None, + ) + .expect("runtime paths"), + ), ) .await, ); diff --git a/codex-rs/app-server-client/src/remote.rs b/codex-rs/app-server-client/src/remote.rs index dd9c083e49..98020c041c 100644 --- a/codex-rs/app-server-client/src/remote.rs +++ b/codex-rs/app-server-client/src/remote.rs @@ -150,6 +150,7 @@ pub struct RemoteAppServerClient { command_tx: mpsc::Sender, event_rx: mpsc::UnboundedReceiver, pending_events: VecDeque, + server_version: Option, worker_handle: tokio::task::JoinHandle<()>, } @@ -180,6 +181,10 @@ impl RemoteAppServerClient { } } + pub fn server_version(&self) -> Option<&str> { + self.server_version.as_deref() + } + async fn connect_with_stream( channel_capacity: usize, endpoint: String, @@ -190,7 +195,7 @@ impl RemoteAppServerClient { S: AsyncRead + AsyncWrite + Unpin + Send + 'static, { let mut stream = stream; - let pending_events = initialize_remote_connection( + let (pending_events, server_version) = initialize_remote_connection( &mut stream, &endpoint, initialize_params, @@ -466,6 +471,7 @@ impl RemoteAppServerClient { command_tx, event_rx, pending_events: pending_events.into(), + server_version, worker_handle, }) } @@ -606,6 +612,7 @@ impl RemoteAppServerClient { command_tx, event_rx, pending_events: _pending_events, + server_version: _server_version, worker_handle, } = self; let mut worker_handle = worker_handle; @@ -793,12 +800,13 @@ async fn initialize_remote_connection( endpoint: &str, params: InitializeParams, initialize_timeout: Duration, -) -> IoResult> +) -> IoResult<(Vec, Option)> where S: AsyncRead + AsyncWrite + Unpin, { let initialize_request_id = RequestId::String("initialize".to_string()); let mut pending_events = Vec::new(); + let mut server_version = None; write_jsonrpc_message( stream, JSONRPCMessage::Request(jsonrpc_request_from_client_request( @@ -822,6 +830,14 @@ where })?; match message { JSONRPCMessage::Response(response) if response.id == initialize_request_id => { + server_version = response + .result + .get("userAgent") + .and_then(serde_json::Value::as_str) + .and_then(|user_agent| { + let (_, rest) = user_agent.split_once('/')?; + rest.split_whitespace().next().map(str::to_string) + }); break Ok(()); } JSONRPCMessage::Error(error) if error.id == initialize_request_id => { @@ -913,7 +929,7 @@ where ) .await?; - Ok(pending_events) + Ok((pending_events, server_version)) } fn app_server_event_from_notification(notification: JSONRPCNotification) -> Option { @@ -1007,6 +1023,7 @@ mod tests { command_tx, event_rx, pending_events: VecDeque::new(), + server_version: None, worker_handle, }; diff --git a/codex-rs/app-server-daemon/src/backend/mod.rs b/codex-rs/app-server-daemon/src/backend/mod.rs index 5e92dd5261..d40df07a0e 100644 --- a/codex-rs/app-server-daemon/src/backend/mod.rs +++ b/codex-rs/app-server-daemon/src/backend/mod.rs @@ -1,5 +1,6 @@ mod pid; +use std::path::Path; use std::path::PathBuf; use serde::Serialize; @@ -31,3 +32,15 @@ pub(crate) fn pid_backend(paths: BackendPaths) -> PidBackend { pub(crate) fn pid_update_loop_backend(paths: BackendPaths) -> PidBackend { PidBackend::new_update_loop(paths.codex_bin, paths.update_pid_file) } + +pub(crate) async fn append_stderr_log_tail_context(pid_file: &Path, context: &mut String) { + match pid::read_stderr_log_tail(pid_file).await { + Ok(Some(tail)) => tail.append_to_context(context), + Ok(None) => {} + Err(err) => { + context.push_str(&format!( + "\n\nFailed to read managed app-server stderr log: {err:#}" + )); + } + } +} diff --git a/codex-rs/app-server-daemon/src/backend/pid.rs b/codex-rs/app-server-daemon/src/backend/pid.rs index 7f8aa1660d..f5f4fc5b66 100644 --- a/codex-rs/app-server-daemon/src/backend/pid.rs +++ b/codex-rs/app-server-daemon/src/backend/pid.rs @@ -1,3 +1,4 @@ +use std::io::SeekFrom; use std::path::Path; use std::path::PathBuf; #[cfg(unix)] @@ -10,6 +11,8 @@ use anyhow::bail; use serde::Deserialize; use serde::Serialize; use tokio::fs; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncSeekExt; #[cfg(unix)] use tokio::process::Command; use tokio::time::sleep; @@ -18,6 +21,7 @@ const STOP_POLL_INTERVAL: Duration = Duration::from_millis(50); const STOP_GRACE_PERIOD: Duration = Duration::from_secs(60); const STOP_TIMEOUT: Duration = Duration::from_secs(70); const START_TIMEOUT: Duration = Duration::from_secs(10); +const STDERR_LOG_TAIL_BYTES: u64 = 4096; #[derive(Debug)] #[cfg_attr(not(unix), allow(dead_code))] @@ -35,6 +39,25 @@ struct PidRecord { process_start_time: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PidLogTail { + pub(crate) path: PathBuf, + pub(crate) contents: String, +} + +impl PidLogTail { + pub(crate) fn append_to_context(&self, context: &mut String) { + context.push_str(&format!( + "\n\nManaged app-server stderr ({}):", + self.path.display() + )); + for line in self.contents.lines() { + context.push_str("\n "); + context.push_str(line); + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] enum PidFileState { Missing, @@ -129,11 +152,18 @@ impl PidBackend { } }; let mut command = Command::new(&self.codex_bin); + let stderr_log = match self.open_stderr_log().await { + Ok(stderr_log) => stderr_log, + Err(err) => { + let _ = fs::remove_file(&self.pid_file).await; + return Err(err); + } + }; command .args(self.command_args()) .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::null()); + .stderr(Stdio::from(stderr_log.into_std().await)); #[cfg(unix)] { @@ -169,8 +199,11 @@ impl PidBackend { }, Err(err) => { let _ = self.terminate_process(pid); + let mut context = + format!("failed to record pid-managed app-server process {pid} startup"); + super::append_stderr_log_tail_context(&self.pid_file, &mut context).await; let _ = fs::remove_file(&self.pid_file).await; - return Err(err); + return Err(err).context(context); } }; let contents = serde_json::to_vec(&record).context("failed to serialize pid record")?; @@ -344,6 +377,23 @@ impl PidBackend { Ok(reservation_lock) } + #[cfg(unix)] + async fn open_stderr_log(&self) -> Result { + let stderr_log_file = stderr_log_file_for_pid_file(&self.pid_file); + fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&stderr_log_file) + .await + .with_context(|| { + format!( + "failed to open stderr log for pid-managed app server {}", + stderr_log_file.display() + ) + }) + } + #[cfg(unix)] fn command_args(&self) -> Vec<&'static str> { match self.command_kind { @@ -376,6 +426,56 @@ impl PidBackend { } } +pub(crate) async fn read_stderr_log_tail(pid_file: &Path) -> Result> { + let path = stderr_log_file_for_pid_file(pid_file); + let Some(contents) = read_log_tail(&path, STDERR_LOG_TAIL_BYTES).await? else { + return Ok(None); + }; + Ok(Some(PidLogTail { path, contents })) +} + +fn stderr_log_file_for_pid_file(pid_file: &Path) -> PathBuf { + pid_file.with_extension("stderr.log") +} + +async fn read_log_tail(path: &Path, byte_limit: u64) -> Result> { + let mut file = match fs::File::open(path).await { + Ok(file) => file, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => { + return Err(err) + .with_context(|| format!("failed to open stderr log {}", path.display())); + } + }; + let len = file + .metadata() + .await + .with_context(|| format!("failed to inspect stderr log {}", path.display()))? + .len(); + if len == 0 { + return Ok(None); + } + + let start = len.saturating_sub(byte_limit); + file.seek(SeekFrom::Start(start)) + .await + .with_context(|| format!("failed to seek stderr log {}", path.display()))?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .await + .with_context(|| format!("failed to read stderr log {}", path.display()))?; + if start > 0 + && let Some(newline_index) = bytes.iter().position(|byte| *byte == b'\n') + { + bytes.drain(..=newline_index); + } + let contents = String::from_utf8_lossy(&bytes).trim_end().to_string(); + if contents.is_empty() { + return Ok(None); + } + Ok(Some(contents)) +} + #[cfg(unix)] fn process_exists(pid: u32) -> bool { let Ok(pid) = libc::pid_t::try_from(pid) else { diff --git a/codex-rs/app-server-daemon/src/backend/pid_tests.rs b/codex-rs/app-server-daemon/src/backend/pid_tests.rs index 305c2a36a3..4c3a0e4414 100644 --- a/codex-rs/app-server-daemon/src/backend/pid_tests.rs +++ b/codex-rs/app-server-daemon/src/backend/pid_tests.rs @@ -6,7 +6,10 @@ use tempfile::TempDir; use super::PidBackend; use super::PidCommandKind; use super::PidFileState; +use super::PidLogTail; use super::PidRecord; +use super::read_stderr_log_tail; +use super::stderr_log_file_for_pid_file; use super::try_lock_file; #[tokio::test] @@ -170,3 +173,24 @@ fn app_server_remote_control_uses_runtime_flag() { vec!["app-server", "--remote-control", "--listen", "unix://"] ); } + +#[tokio::test] +async fn read_stderr_log_tail_returns_recent_complete_lines() { + let temp_dir = TempDir::new().expect("temp dir"); + let pid_file = temp_dir.path().join("app-server.pid"); + let log_file = stderr_log_file_for_pid_file(&pid_file); + let contents = format!("{}\nrecent error\nusage", "x".repeat(4100)); + tokio::fs::write(&log_file, contents) + .await + .expect("write stderr log"); + + assert_eq!( + read_stderr_log_tail(&pid_file) + .await + .expect("read stderr log"), + Some(PidLogTail { + path: log_file, + contents: "recent error\nusage".to_string(), + }) + ); +} diff --git a/codex-rs/app-server-daemon/src/client.rs b/codex-rs/app-server-daemon/src/client.rs index 44fccda394..8c771ba1f2 100644 --- a/codex-rs/app-server-daemon/src/client.rs +++ b/codex-rs/app-server-daemon/src/client.rs @@ -5,6 +5,7 @@ use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::InitializeResponse; use codex_app_server_protocol::JSONRPCMessage; @@ -14,12 +15,16 @@ use codex_app_server_protocol::RequestId; use codex_uds::UnixStream; use futures::SinkExt; use futures::StreamExt; +use tokio::io::AsyncRead; +use tokio::io::AsyncWrite; use tokio::time::timeout; +use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::client_async; use tokio_tungstenite::tungstenite::Message; -const PROBE_TIMEOUT: Duration = Duration::from_secs(2); +pub(crate) const CONTROL_SOCKET_RESPONSE_TIMEOUT: Duration = Duration::from_secs(2); const CLIENT_NAME: &str = "codex_app_server_daemon"; +const INITIALIZE_REQUEST_ID: RequestId = RequestId::Integer(1); #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ProbeInfo { @@ -27,7 +32,7 @@ pub(crate) struct ProbeInfo { } pub(crate) async fn probe(socket_path: &Path) -> Result { - timeout(PROBE_TIMEOUT, probe_inner(socket_path)) + timeout(CONTROL_SOCKET_RESPONSE_TIMEOUT, probe_inner(socket_path)) .await .with_context(|| { format!( @@ -38,54 +43,14 @@ pub(crate) async fn probe(socket_path: &Path) -> Result { } async fn probe_inner(socket_path: &Path) -> Result { - let stream = UnixStream::connect(socket_path) - .await - .with_context(|| format!("failed to connect to {}", socket_path.display()))?; - let (mut websocket, _response) = client_async("ws://localhost/", stream) - .await - .with_context(|| format!("failed to upgrade {}", socket_path.display()))?; - - let initialize = JSONRPCMessage::Request(JSONRPCRequest { - id: RequestId::Integer(1), - method: "initialize".to_string(), - params: Some(serde_json::to_value(InitializeParams { - client_info: ClientInfo { - name: CLIENT_NAME.to_string(), - title: Some("Codex App Server Daemon".to_string()), - version: env!("CARGO_PKG_VERSION").to_string(), - }, - capabilities: None, - })?), - trace: None, - }); - websocket - .send(Message::Text(serde_json::to_string(&initialize)?.into())) - .await - .context("failed to send initialize request")?; - - let response = loop { - let frame = websocket - .next() - .await - .ok_or_else(|| anyhow!("app-server closed before initialize response"))??; - let Message::Text(payload) = frame else { - continue; - }; - let message = serde_json::from_str::(&payload)?; - if let JSONRPCMessage::Response(response) = message - && response.id == RequestId::Integer(1) - { - break response; - } - }; - let initialize_response = serde_json::from_value::(response.result)?; + let mut websocket = connect(socket_path).await?; + let initialize_response = initialize(&mut websocket, /*experimental_api*/ false).await?; let initialized = JSONRPCMessage::Notification(JSONRPCNotification { method: "initialized".to_string(), params: None, }); - websocket - .send(Message::Text(serde_json::to_string(&initialized)?.into())) + send_message(&mut websocket, &initialized) .await .context("failed to send initialized notification")?; websocket.close(None).await.ok(); @@ -95,6 +60,91 @@ async fn probe_inner(socket_path: &Path) -> Result { }) } +pub(crate) async fn connect(socket_path: &Path) -> Result> { + let stream = UnixStream::connect(socket_path) + .await + .with_context(|| format!("failed to connect to {}", socket_path.display()))?; + let (websocket, _response) = client_async("ws://localhost/", stream) + .await + .with_context(|| format!("failed to upgrade {}", socket_path.display()))?; + Ok(websocket) +} + +pub(crate) async fn initialize( + websocket: &mut WebSocketStream, + experimental_api: bool, +) -> Result +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let initialize = JSONRPCMessage::Request(JSONRPCRequest { + id: INITIALIZE_REQUEST_ID, + method: "initialize".to_string(), + params: Some(serde_json::to_value(InitializeParams { + client_info: ClientInfo { + name: CLIENT_NAME.to_string(), + title: Some("Codex App Server Daemon".to_string()), + version: env!("CARGO_PKG_VERSION").to_string(), + }, + capabilities: if experimental_api { + Some(InitializeCapabilities { + experimental_api: true, + ..Default::default() + }) + } else { + None + }, + })?), + trace: None, + }); + send_message(websocket, &initialize) + .await + .context("failed to send initialize request")?; + + let response = loop { + let message = timeout(CONTROL_SOCKET_RESPONSE_TIMEOUT, read_message(websocket)) + .await + .context("timed out waiting for initialize response")??; + if let JSONRPCMessage::Response(response) = message + && response.id == INITIALIZE_REQUEST_ID + { + break response; + } + }; + serde_json::from_value::(response.result) + .context("failed to parse initialize response") +} + +pub(crate) async fn send_message( + websocket: &mut WebSocketStream, + message: &JSONRPCMessage, +) -> Result<()> +where + S: AsyncRead + AsyncWrite + Unpin, +{ + websocket + .send(Message::Text(serde_json::to_string(message)?.into())) + .await?; + Ok(()) +} + +pub(crate) async fn read_message(websocket: &mut WebSocketStream) -> Result +where + S: AsyncRead + AsyncWrite + Unpin, +{ + loop { + let frame = websocket + .next() + .await + .ok_or_else(|| anyhow!("app-server closed the control socket"))??; + let Message::Text(payload) = frame else { + continue; + }; + return serde_json::from_str::(&payload) + .context("failed to parse app-server JSON-RPC message"); + } +} + fn parse_version_from_user_agent(user_agent: &str) -> Result { let (_originator, rest) = user_agent .split_once('/') diff --git a/codex-rs/app-server-daemon/src/lib.rs b/codex-rs/app-server-daemon/src/lib.rs index 4ab223051c..354c08ec19 100644 --- a/codex-rs/app-server-daemon/src/lib.rs +++ b/codex-rs/app-server-daemon/src/lib.rs @@ -1,6 +1,7 @@ mod backend; mod client; mod managed_install; +mod remote_control_client; mod settings; mod update_loop; @@ -13,6 +14,7 @@ use anyhow::Result; use anyhow::anyhow; pub use backend::BackendKind; use backend::BackendPaths; +use codex_app_server_protocol::RemoteControlConnectionStatus; use codex_app_server_transport::app_server_control_socket_path; use codex_utils_home_dir::find_codex_home; use managed_install::managed_codex_bin; @@ -58,6 +60,8 @@ pub struct LifecycleOutput { pub backend: Option, #[serde(skip_serializing_if = "Option::is_none")] pub pid: Option, + pub managed_codex_path: PathBuf, + pub managed_codex_version: Option, pub socket_path: PathBuf, #[serde(skip_serializing_if = "Option::is_none")] pub cli_version: Option, @@ -70,6 +74,12 @@ pub struct BootstrapOptions { pub remote_control_enabled: bool, } +/// Passively probes an existing app-server socket and returns its reported +/// app-server version. +pub async fn probe_app_server_version(socket_path: &Path) -> Result { + Ok(client::probe(socket_path).await?.app_server_version) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub enum BootstrapStatus { @@ -84,6 +94,7 @@ pub struct BootstrapOutput { pub auto_update_enabled: bool, pub remote_control_enabled: bool, pub managed_codex_path: PathBuf, + pub managed_codex_version: Option, pub socket_path: PathBuf, pub cli_version: String, pub app_server_version: String, @@ -96,6 +107,20 @@ pub enum RemoteControlStartOutput { Start(LifecycleOutput), } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteControlReadyStatus { + pub status: RemoteControlConnectionStatus, + pub server_name: String, + pub environment_id: Option, + pub timed_out: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteControlReadyOutput { + pub daemon: RemoteControlStartOutput, + pub remote_control: RemoteControlReadyStatus, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RemoteControlMode { Enabled, @@ -179,6 +204,27 @@ pub async fn ensure_remote_control_started() -> Result .await } +pub async fn ensure_remote_control_ready() -> Result { + ensure_supported_platform()?; + Daemon::from_environment()? + .ensure_remote_control_ready() + .await +} + +pub async fn enable_remote_control_on_socket( + socket_path: &Path, + connect_timeout: Duration, + connect_retry_delay: Duration, +) -> Result { + ensure_supported_platform()?; + remote_control_client::enable_remote_control_with_connect_retry( + socket_path, + connect_timeout, + connect_retry_delay, + ) + .await +} + pub async fn set_remote_control(mode: RemoteControlMode) -> Result { ensure_supported_platform()?; Daemon::from_environment()?.set_remote_control(mode).await @@ -248,33 +294,39 @@ impl Daemon { async fn start(&self) -> Result { let settings = self.load_settings().await?; if let Ok(info) = client::probe(&self.socket_path).await { - return Ok(self.output( - LifecycleStatus::AlreadyRunning, - self.running_backend(&settings).await?, - /*pid*/ None, - Some(info.app_server_version), - )); + return Ok(self + .output( + LifecycleStatus::AlreadyRunning, + self.running_backend(&settings).await?, + /*pid*/ None, + Some(info.app_server_version), + ) + .await); } if self.running_backend_instance(&settings).await?.is_some() { let info = self.wait_until_ready().await?; - return Ok(self.output( - LifecycleStatus::AlreadyRunning, - Some(BackendKind::Pid), - /*pid*/ None, - Some(info.app_server_version), - )); + return Ok(self + .output( + LifecycleStatus::AlreadyRunning, + Some(BackendKind::Pid), + /*pid*/ None, + Some(info.app_server_version), + ) + .await); } self.ensure_managed_codex_bin()?; let pid = self.start_managed_backend(&settings).await?; let info = self.wait_until_ready().await?; - Ok(self.output( - LifecycleStatus::Started, - Some(BackendKind::Pid), - pid, - Some(info.app_server_version), - )) + Ok(self + .output( + LifecycleStatus::Started, + Some(BackendKind::Pid), + pid, + Some(info.app_server_version), + ) + .await) } async fn restart(&self) -> Result { @@ -294,12 +346,14 @@ impl Daemon { let pid = self.start_managed_backend(&settings).await?; let info = self.wait_until_ready().await?; - Ok(self.output( - LifecycleStatus::Restarted, - Some(BackendKind::Pid), - pid, - Some(info.app_server_version), - )) + Ok(self + .output( + LifecycleStatus::Restarted, + Some(BackendKind::Pid), + pid, + Some(info.app_server_version), + ) + .await) } #[cfg(unix)] @@ -352,12 +406,14 @@ impl Daemon { let settings = self.load_settings().await?; if let Some(backend) = self.running_backend_instance(&settings).await? { backend.stop().await?; - return Ok(self.output( - LifecycleStatus::Stopped, - Some(BackendKind::Pid), - /*pid*/ None, - /*app_server_version*/ None, - )); + return Ok(self + .output( + LifecycleStatus::Stopped, + Some(BackendKind::Pid), + /*pid*/ None, + /*app_server_version*/ None, + ) + .await); } if client::probe(&self.socket_path).await.is_ok() { @@ -366,23 +422,27 @@ impl Daemon { )); } - Ok(self.output( - LifecycleStatus::NotRunning, - /*backend*/ None, - /*pid*/ None, - /*app_server_version*/ None, - )) + Ok(self + .output( + LifecycleStatus::NotRunning, + /*backend*/ None, + /*pid*/ None, + /*app_server_version*/ None, + ) + .await) } async fn version(&self) -> Result { let settings = self.load_settings().await?; let info = client::probe(&self.socket_path).await?; - Ok(self.output( - LifecycleStatus::Running, - self.running_backend(&settings).await?, - /*pid*/ None, - Some(info.app_server_version), - )) + Ok(self + .output( + LifecycleStatus::Running, + self.running_backend(&settings).await?, + /*pid*/ None, + Some(info.app_server_version), + ) + .await) } async fn wait_until_ready(&self) -> Result { @@ -395,17 +455,34 @@ impl Daemon { sleep(START_POLL_INTERVAL).await; } Err(err) => { - return Err(err).with_context(|| { - format!( - "app server did not become ready on {}", - self.socket_path.display() - ) - }); + let context = self.app_server_not_ready_context().await; + return Err(err).context(context); } } } } + async fn app_server_not_ready_context(&self) -> String { + let mut context = format!( + "app server did not become ready on {}", + self.socket_path.display() + ); + self.append_daemon_app_server_context(&mut context).await; + backend::append_stderr_log_tail_context(&self.pid_file, &mut context).await; + context + } + + async fn append_daemon_app_server_context(&self, context: &mut String) { + let managed_codex_version = self + .managed_codex_version_best_effort() + .await + .unwrap_or_else(|| "unknown".to_string()); + context.push_str(&format!( + "\n\nDaemon used app-server:\n path: {}\n version: {managed_codex_version}", + self.managed_codex_bin.display() + )); + } + async fn bootstrap(&self, options: BootstrapOptions) -> Result { let _operation_lock = self.acquire_operation_lock().await?; self.bootstrap_locked(options).await @@ -430,6 +507,16 @@ impl Daemon { Ok(RemoteControlStartOutput::Bootstrap(output)) } + async fn ensure_remote_control_ready(&self) -> Result { + let daemon = self.ensure_remote_control_started().await?; + let remote_control = + remote_control_client::enable_remote_control(&self.socket_path).await?; + Ok(RemoteControlReadyOutput { + daemon, + remote_control, + }) + } + async fn set_remote_control(&self, mode: RemoteControlMode) -> Result { let _operation_lock = self.acquire_operation_lock().await?; self.set_remote_control_locked(mode).await @@ -512,12 +599,14 @@ impl Daemon { updater.start().await?; let info = self.wait_until_ready().await?; + let managed_codex_version = self.managed_codex_version_best_effort().await; Ok(BootstrapOutput { status: BootstrapStatus::Bootstrapped, backend: BackendKind::Pid, auto_update_enabled: true, remote_control_enabled: settings.remote_control_enabled, managed_codex_path: self.managed_codex_bin.clone(), + managed_codex_version, socket_path: self.socket_path.clone(), cli_version: env!("CARGO_PKG_VERSION").to_string(), app_server_version: info.app_server_version, @@ -577,6 +666,16 @@ impl Daemon { )) } + #[cfg(unix)] + async fn managed_codex_version_best_effort(&self) -> Option { + managed_codex_version(&self.managed_codex_bin).await.ok() + } + + #[cfg(not(unix))] + async fn managed_codex_version_best_effort(&self) -> Option { + None + } + fn backend_paths(&self, settings: &DaemonSettings) -> BackendPaths { self.backend_paths_with_bin(settings, &self.managed_codex_bin) } @@ -636,17 +735,20 @@ impl Daemon { }) } - fn output( + async fn output( &self, status: LifecycleStatus, backend: Option, pid: Option, app_server_version: Option, ) -> LifecycleOutput { + let managed_codex_version = self.managed_codex_version_best_effort().await; LifecycleOutput { status, backend, pid, + managed_codex_path: self.managed_codex_bin.clone(), + managed_codex_version, socket_path: self.socket_path.clone(), cli_version: Some(env!("CARGO_PKG_VERSION").to_string()), app_server_version, @@ -735,10 +837,12 @@ fn try_lock_file(_file: &tokio::fs::File) -> Result { #[cfg(all(test, unix))] mod tests { use pretty_assertions::assert_eq; + use tempfile::TempDir; use super::BackendKind; use super::BootstrapOutput; use super::BootstrapStatus; + use super::Daemon; use super::LifecycleOutput; use super::LifecycleStatus; use super::RemoteControlStartOutput; @@ -751,22 +855,6 @@ mod tests { use super::should_reexec_updater; use crate::client::ProbeInfo; - #[test] - fn lifecycle_status_uses_camel_case_json() { - assert_eq!( - serde_json::to_string(&LifecycleStatus::AlreadyRunning).expect("serialize"), - "\"alreadyRunning\"" - ); - } - - #[test] - fn bootstrap_status_uses_camel_case_json() { - assert_eq!( - serde_json::to_string(&BootstrapStatus::Bootstrapped).expect("serialize"), - "\"bootstrapped\"" - ); - } - #[test] fn remote_control_status_uses_camel_case_json() { assert_eq!( @@ -847,12 +935,26 @@ mod tests { status: LifecycleStatus::AlreadyRunning, backend: Some(BackendKind::Pid), pid: None, + managed_codex_path: "codex".into(), + managed_codex_version: Some("1.2.3".to_string()), socket_path: "codex.sock".into(), cli_version: Some("1.2.3".to_string()), app_server_version: Some("1.2.4".to_string()), }; let output = RemoteControlStartOutput::Start(lifecycle_output.clone()); + assert_eq!( + serde_json::to_value(&lifecycle_output).expect("serialize"), + serde_json::json!({ + "status": "alreadyRunning", + "backend": "pid", + "managedCodexPath": "codex", + "managedCodexVersion": "1.2.3", + "socketPath": "codex.sock", + "cliVersion": "1.2.3", + "appServerVersion": "1.2.4", + }) + ); assert_eq!( serde_json::to_value(output).expect("serialize"), serde_json::to_value(lifecycle_output).expect("serialize") @@ -864,15 +966,59 @@ mod tests { auto_update_enabled: true, remote_control_enabled: true, managed_codex_path: "codex".into(), + managed_codex_version: Some("1.2.3".to_string()), socket_path: "codex.sock".into(), cli_version: "1.2.3".to_string(), app_server_version: "1.2.4".to_string(), }; let output = RemoteControlStartOutput::Bootstrap(bootstrap_output.clone()); + assert_eq!( + serde_json::to_value(&bootstrap_output).expect("serialize"), + serde_json::json!({ + "status": "bootstrapped", + "backend": "pid", + "autoUpdateEnabled": true, + "remoteControlEnabled": true, + "managedCodexPath": "codex", + "managedCodexVersion": "1.2.3", + "socketPath": "codex.sock", + "cliVersion": "1.2.3", + "appServerVersion": "1.2.4", + }) + ); assert_eq!( serde_json::to_value(output).expect("serialize"), serde_json::to_value(bootstrap_output).expect("serialize") ); } + + #[tokio::test] + async fn not_ready_context_reports_daemon_app_server_before_stderr() { + let temp_dir = TempDir::new().expect("temp dir"); + let daemon = Daemon { + socket_path: temp_dir.path().join("app-server-control.sock"), + pid_file: temp_dir.path().join("app-server.pid"), + update_pid_file: temp_dir.path().join("app-server-updater.pid"), + operation_lock_file: temp_dir.path().join("daemon.lock"), + settings_file: temp_dir.path().join("settings.json"), + managed_codex_bin: temp_dir.path().join("missing-codex"), + }; + let stderr_log = daemon.pid_file.with_extension("stderr.log"); + tokio::fs::write(&stderr_log, "unexpected argument") + .await + .expect("write stderr log"); + + assert_eq!( + daemon.app_server_not_ready_context().await, + format!( + "app server did not become ready on {}\n\n\ + Daemon used app-server:\n path: {}\n version: unknown\n\n\ + Managed app-server stderr ({}):\n unexpected argument", + daemon.socket_path.display(), + daemon.managed_codex_bin.display(), + stderr_log.display() + ) + ); + } } diff --git a/codex-rs/app-server-daemon/src/remote_control_client.rs b/codex-rs/app-server-daemon/src/remote_control_client.rs new file mode 100644 index 0000000000..cc32e50dde --- /dev/null +++ b/codex-rs/app-server-daemon/src/remote_control_client.rs @@ -0,0 +1,459 @@ +use std::path::Path; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCRequest; +use codex_app_server_protocol::RemoteControlConnectionStatus; +use codex_app_server_protocol::RemoteControlEnableResponse; +use codex_app_server_protocol::RemoteControlStatusChangedNotification; +use codex_app_server_protocol::RequestId; +use tokio::io::AsyncRead; +use tokio::io::AsyncWrite; +use tokio::time::Instant; +use tokio::time::sleep; +use tokio::time::timeout; +use tokio_tungstenite::WebSocketStream; + +use crate::RemoteControlReadyStatus; +use crate::client; + +const REMOTE_CONTROL_READY_TIMEOUT: Duration = Duration::from_secs(10); +const REMOTE_CONTROL_ENABLE_REQUEST_ID: RequestId = RequestId::Integer(2); + +pub(crate) async fn enable_remote_control(socket_path: &Path) -> Result { + let mut websocket = client::connect(socket_path).await?; + enable_remote_control_with_timeout(&mut websocket, REMOTE_CONTROL_READY_TIMEOUT).await +} + +pub(crate) async fn enable_remote_control_with_connect_retry( + socket_path: &Path, + connect_timeout: Duration, + connect_retry_delay: Duration, +) -> Result { + let mut websocket = + connect_with_retry(socket_path, connect_timeout, connect_retry_delay).await?; + enable_remote_control_with_timeout(&mut websocket, REMOTE_CONTROL_READY_TIMEOUT).await +} + +async fn enable_remote_control_with_timeout( + websocket: &mut WebSocketStream, + ready_timeout: Duration, +) -> Result +where + S: AsyncRead + AsyncWrite + Unpin, +{ + client::initialize(websocket, /*experimental_api*/ true).await?; + let initialized = JSONRPCMessage::Notification(JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }); + client::send_message(websocket, &initialized) + .await + .context("failed to send initialized notification")?; + + let enable = JSONRPCMessage::Request(JSONRPCRequest { + id: REMOTE_CONTROL_ENABLE_REQUEST_ID, + method: "remoteControl/enable".to_string(), + params: None, + trace: None, + }); + client::send_message(websocket, &enable) + .await + .context("failed to send remoteControl/enable request")?; + + let mut latest = read_enable_response(websocket).await?; + if latest.status == RemoteControlConnectionStatus::Connecting { + latest = wait_for_remote_control_status(websocket, latest, ready_timeout).await?; + } + websocket.close(None).await.ok(); + Ok(latest) +} + +async fn connect_with_retry( + socket_path: &Path, + connect_timeout: Duration, + connect_retry_delay: Duration, +) -> Result> { + let deadline = Instant::now() + connect_timeout; + loop { + match client::connect(socket_path).await { + Ok(websocket) => return Ok(websocket), + Err(_) if Instant::now() < deadline => { + sleep(connect_retry_delay).await; + } + Err(error) => { + return Err(error).with_context(|| { + format!( + "app server did not become ready on {}", + socket_path.display() + ) + }); + } + } + } +} + +async fn read_enable_response( + websocket: &mut WebSocketStream, +) -> Result +where + S: AsyncRead + AsyncWrite + Unpin, +{ + loop { + let message = timeout( + client::CONTROL_SOCKET_RESPONSE_TIMEOUT, + client::read_message(websocket), + ) + .await + .context("timed out waiting for remoteControl/enable response")??; + match message { + JSONRPCMessage::Response(response) + if response.id == REMOTE_CONTROL_ENABLE_REQUEST_ID => + { + let response = + serde_json::from_value::(response.result) + .context("failed to parse remoteControl/enable response")?; + return Ok(RemoteControlReadyStatus::from(response)); + } + JSONRPCMessage::Error(err) if err.id == REMOTE_CONTROL_ENABLE_REQUEST_ID => { + return Err(anyhow!( + "remoteControl/enable failed: {}", + err.error.message + )); + } + JSONRPCMessage::Notification(notification) + if remote_control_status_notification(¬ification).is_some() => + { + continue; + } + _ => {} + } + } +} + +async fn wait_for_remote_control_status( + websocket: &mut WebSocketStream, + mut latest: RemoteControlReadyStatus, + ready_timeout: Duration, +) -> Result +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let deadline = tokio::time::Instant::now() + ready_timeout; + while tokio::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let message = match timeout(remaining, client::read_message(websocket)).await { + Ok(Ok(message)) => message, + Ok(Err(err)) => return Err(err), + Err(_) => { + latest.timed_out = true; + return Ok(latest); + } + }; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + let Some(status) = remote_control_status_notification(¬ification) else { + continue; + }; + latest = RemoteControlReadyStatus::from(status); + if latest.status != RemoteControlConnectionStatus::Connecting { + return Ok(latest); + } + } + latest.timed_out = true; + Ok(latest) +} + +fn remote_control_status_notification( + notification: &JSONRPCNotification, +) -> Option { + if notification.method != "remoteControl/status/changed" { + return None; + } + let params = notification.params.clone()?; + serde_json::from_value(params).ok() +} + +impl From for RemoteControlReadyStatus { + fn from(response: RemoteControlEnableResponse) -> Self { + let RemoteControlEnableResponse { + status, + server_name, + installation_id: _, + environment_id, + } = response; + Self { + status, + server_name, + environment_id, + timed_out: false, + } + } +} + +impl From for RemoteControlReadyStatus { + fn from(notification: RemoteControlStatusChangedNotification) -> Self { + let RemoteControlStatusChangedNotification { + status, + server_name, + installation_id: _, + environment_id, + } = notification; + Self { + status, + server_name, + environment_id, + timed_out: false, + } + } +} + +#[cfg(all(test, unix))] +mod tests { + use anyhow::Result; + use codex_app_server_protocol::JSONRPCResponse; + use codex_uds::UnixListener; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + use tokio_tungstenite::accept_async; + + use super::*; + + const INITIALIZE_REQUEST_ID: RequestId = RequestId::Integer(1); + const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111"; + const TEST_SERVER_NAME: &str = "owen-mbp"; + const TEST_CODEX_HOME: &str = "/tmp/codex-home"; + + #[tokio::test] + async fn enable_remote_control_uses_connected_enable_response_without_later_notification() + -> Result<()> { + let status = run_enable_remote_control_scenario(EnableScenario { + initial_notification: Some(remote_control_status( + RemoteControlConnectionStatus::Connected, + Some("env_test"), + )), + enable_response: remote_control_status( + RemoteControlConnectionStatus::Connected, + Some("env_test"), + ), + after_enable_notification: None, + ready_timeout: Duration::from_millis(20), + }) + .await?; + + assert_eq!( + status, + RemoteControlReadyStatus { + status: RemoteControlConnectionStatus::Connected, + server_name: TEST_SERVER_NAME.to_string(), + environment_id: Some("env_test".to_string()), + timed_out: false, + } + ); + Ok(()) + } + + #[tokio::test] + async fn enable_remote_control_waits_for_connected_notification() -> Result<()> { + let status = run_enable_remote_control_scenario(EnableScenario { + initial_notification: None, + enable_response: remote_control_status( + RemoteControlConnectionStatus::Connecting, + /*environment_id*/ None, + ), + after_enable_notification: Some(remote_control_status( + RemoteControlConnectionStatus::Connected, + Some("env_test"), + )), + ready_timeout: Duration::from_secs(1), + }) + .await?; + + assert_eq!( + status, + RemoteControlReadyStatus { + status: RemoteControlConnectionStatus::Connected, + server_name: TEST_SERVER_NAME.to_string(), + environment_id: Some("env_test".to_string()), + timed_out: false, + } + ); + Ok(()) + } + + #[tokio::test] + async fn enable_remote_control_reports_connecting_after_timeout() -> Result<()> { + let status = run_enable_remote_control_scenario(EnableScenario { + initial_notification: None, + enable_response: remote_control_status( + RemoteControlConnectionStatus::Connecting, + /*environment_id*/ None, + ), + after_enable_notification: None, + ready_timeout: Duration::from_millis(20), + }) + .await?; + + assert_eq!( + status, + RemoteControlReadyStatus { + status: RemoteControlConnectionStatus::Connecting, + server_name: TEST_SERVER_NAME.to_string(), + environment_id: None, + timed_out: true, + } + ); + Ok(()) + } + + #[tokio::test] + async fn enable_remote_control_returns_errored_enable_response() -> Result<()> { + let status = run_enable_remote_control_scenario(EnableScenario { + initial_notification: None, + enable_response: remote_control_status( + RemoteControlConnectionStatus::Errored, + /*environment_id*/ None, + ), + after_enable_notification: None, + ready_timeout: Duration::from_millis(20), + }) + .await?; + + assert_eq!( + status, + RemoteControlReadyStatus { + status: RemoteControlConnectionStatus::Errored, + server_name: TEST_SERVER_NAME.to_string(), + environment_id: None, + timed_out: false, + } + ); + Ok(()) + } + + struct EnableScenario { + initial_notification: Option, + enable_response: RemoteControlStatusChangedNotification, + after_enable_notification: Option, + ready_timeout: Duration, + } + + async fn run_enable_remote_control_scenario( + scenario: EnableScenario, + ) -> Result { + let dir = TempDir::new()?; + let socket_path = dir.path().join("app-server.sock"); + let listener = UnixListener::bind(&socket_path).await?; + let ready_timeout = scenario.ready_timeout; + let server_task = tokio::spawn(serve_enable_remote_control_scenario(listener, scenario)); + + let mut websocket = client::connect(&socket_path).await?; + let status = enable_remote_control_with_timeout(&mut websocket, ready_timeout).await?; + server_task.await??; + Ok(status) + } + + async fn serve_enable_remote_control_scenario( + mut listener: UnixListener, + scenario: EnableScenario, + ) -> Result<()> { + let stream = listener.accept().await?; + let mut websocket = accept_async(stream).await?; + + let initialize = client::read_message(&mut websocket).await?; + let JSONRPCMessage::Request(initialize) = initialize else { + panic!("expected initialize request"); + }; + assert_eq!(initialize.id, INITIALIZE_REQUEST_ID); + assert_eq!(initialize.method, "initialize"); + let Some(initialize_params) = initialize.params else { + panic!("expected initialize params"); + }; + assert_eq!( + initialize_params["capabilities"]["experimentalApi"], + serde_json::Value::Bool(true) + ); + client::send_message( + &mut websocket, + &JSONRPCMessage::Response(JSONRPCResponse { + id: INITIALIZE_REQUEST_ID, + result: serde_json::json!({ + "userAgent": "codex_app_server/1.2.3", + "codexHome": TEST_CODEX_HOME, + "platformFamily": "unix", + "platformOs": "macos", + }), + }), + ) + .await?; + + let initialized = client::read_message(&mut websocket).await?; + let JSONRPCMessage::Notification(initialized) = initialized else { + panic!("expected initialized notification"); + }; + assert_eq!(initialized.method, "initialized"); + + if let Some(status) = scenario.initial_notification { + send_remote_control_status(&mut websocket, status).await?; + } + + let enable = client::read_message(&mut websocket).await?; + let JSONRPCMessage::Request(enable) = enable else { + panic!("expected remoteControl/enable request"); + }; + assert_eq!(enable.id, REMOTE_CONTROL_ENABLE_REQUEST_ID); + assert_eq!(enable.method, "remoteControl/enable"); + client::send_message( + &mut websocket, + &JSONRPCMessage::Response(JSONRPCResponse { + id: REMOTE_CONTROL_ENABLE_REQUEST_ID, + result: serde_json::to_value(RemoteControlEnableResponse::from( + scenario.enable_response, + ))?, + }), + ) + .await?; + + if let Some(status) = scenario.after_enable_notification { + send_remote_control_status(&mut websocket, status).await?; + } else { + tokio::time::sleep(Duration::from_millis(50)).await; + } + + Ok(()) + } + + async fn send_remote_control_status( + websocket: &mut WebSocketStream, + status: RemoteControlStatusChangedNotification, + ) -> Result<()> + where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + { + client::send_message( + websocket, + &JSONRPCMessage::Notification(JSONRPCNotification { + method: "remoteControl/status/changed".to_string(), + params: Some(serde_json::to_value(status)?), + }), + ) + .await + } + + fn remote_control_status( + status: RemoteControlConnectionStatus, + environment_id: Option<&str>, + ) -> RemoteControlStatusChangedNotification { + RemoteControlStatusChangedNotification { + status, + server_name: TEST_SERVER_NAME.to_string(), + installation_id: TEST_INSTALLATION_ID.to_string(), + environment_id: environment_id.map(str::to_string), + } + } +} diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 410ffd4eda..7a82ea6e30 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -12,6 +12,28 @@ ], "type": "string" }, + "AdditionalContextEntry": { + "properties": { + "kind": { + "$ref": "#/definitions/AdditionalContextKind" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + "AdditionalContextKind": { + "enum": [ + "untrusted", + "application" + ], + "type": "string" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ @@ -423,7 +445,6 @@ ] }, "includeLayers": { - "default": false, "type": "boolean" } }, @@ -591,6 +612,13 @@ "integer", "null" ] + }, + "threadId": { + "description": "Optional loaded thread id. Pass this when showing feature state for an existing thread so enablement is computed from that thread's refreshed config, including project-local config for the thread's cwd.", + "type": [ + "string", + "null" + ] } }, "type": "object" @@ -714,207 +742,10 @@ } }, "required": [ - "classification", - "includeLogs" + "classification" ], "type": "object" }, - "FileSystemAccessMode": { - "enum": [ - "read", - "write", - "none" - ], - "type": "string" - }, - "FileSystemPath": { - "oneOf": [ - { - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "path" - ], - "title": "PathFileSystemPathType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "PathFileSystemPath", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": "string" - }, - "type": { - "enum": [ - "glob_pattern" - ], - "title": "GlobPatternFileSystemPathType", - "type": "string" - } - }, - "required": [ - "pattern", - "type" - ], - "title": "GlobPatternFileSystemPath", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "special" - ], - "title": "SpecialFileSystemPathType", - "type": "string" - }, - "value": { - "$ref": "#/definitions/FileSystemSpecialPath" - } - }, - "required": [ - "type", - "value" - ], - "title": "SpecialFileSystemPath", - "type": "object" - } - ] - }, - "FileSystemSandboxEntry": { - "properties": { - "access": { - "$ref": "#/definitions/FileSystemAccessMode" - }, - "path": { - "$ref": "#/definitions/FileSystemPath" - } - }, - "required": [ - "access", - "path" - ], - "type": "object" - }, - "FileSystemSpecialPath": { - "oneOf": [ - { - "properties": { - "kind": { - "enum": [ - "root" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "RootFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "minimal" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "MinimalFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "project_roots" - ], - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind" - ], - "title": "KindFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "tmpdir" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "TmpdirFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "slash_tmp" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "SlashTmpFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "unknown" - ], - "type": "string" - }, - "path": { - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind", - "path" - ], - "type": "object" - } - ] - }, "FsCopyParams": { "description": "Copy a file or directory tree on the host filesystem.", "properties": { @@ -1173,6 +1004,26 @@ ], "title": "InputImageFunctionCallOutputContentItem", "type": "object" + }, + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentFunctionCallOutputContentItem", + "type": "object" } ] }, @@ -1203,7 +1054,6 @@ "GetAccountParams": { "properties": { "refreshToken": { - "default": false, "description": "When `true`, requests a proactive token refresh before returning.\n\nIn managed auth mode this triggers the normal refresh-token flow. In external auth mode this flag is ignored. Clients should refresh tokens themselves and call `account/login/start` with `chatgptAuthTokens`.", "type": "boolean" } @@ -1317,6 +1167,12 @@ "integer", "null" ] + }, + "threadId": { + "type": [ + "string", + "null" + ] } }, "type": "object" @@ -1732,194 +1588,34 @@ ], "type": "string" }, - "PermissionProfile": { - "oneOf": [ - { - "description": "Codex owns sandbox construction for this profile.", - "properties": { - "fileSystem": { - "$ref": "#/definitions/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "managed" - ], - "title": "ManagedPermissionProfileType", - "type": "string" - } - }, - "required": [ - "fileSystem", - "network", - "type" - ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", - "type": "object" - } - ] - }, - "PermissionProfileFileSystemPermissions": { - "oneOf": [ - { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", - "type": "object" - } - ] - }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, - "PermissionProfileNetworkPermissions": { + "PermissionProfileListParams": { "properties": { - "enabled": { - "type": "boolean" + "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" + ] } }, - "required": [ - "enabled" - ], "type": "object" }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", @@ -1955,9 +1651,35 @@ ], "type": "object" }, + "PluginInstalledParams": { + "properties": { + "cwds": { + "description": "Optional working directories used to discover repo marketplaces.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "installSuggestionPluginNames": { + "description": "Additional uninstalled plugin names that should be returned when present locally. This is used by mention surfaces that intentionally expose install entrypoints.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "type": "object" + }, "PluginListMarketplaceKind": { "enum": [ "local", + "vertical", "workspace-directory", "shared-with-me" ], @@ -2788,6 +2510,22 @@ "title": "CompactionResponseItem", "type": "object" }, + { + "properties": { + "type": { + "enum": [ + "compaction_trigger" + ], + "title": "CompactionTriggerResponseItemType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "CompactionTriggerResponseItem", + "type": "object" + }, { "properties": { "encrypted_content": { @@ -3250,6 +2988,20 @@ ], "type": "object" }, + "SkillsExtraRootsSetParams": { + "properties": { + "extraRoots": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + } + }, + "required": [ + "extraRoots" + ], + "type": "object" + }, "SkillsListParams": { "properties": { "cwds": { @@ -3373,7 +3125,7 @@ "type": "object" }, "ThreadForkParams": { - "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", + "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using a non-empty path, the thread_id param will be ignored. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", "properties": { "approvalPolicy": { "anyOf": [ @@ -3473,10 +3225,68 @@ ], "type": "object" }, + "ThreadGoalClearParams": { + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "type": "object" + }, + "ThreadGoalGetParams": { + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "type": "object" + }, + "ThreadGoalSetParams": { + "properties": { + "objective": { + "type": [ + "string", + "null" + ] + }, + "status": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadGoalStatus" + }, + { + "type": "null" + } + ] + }, + "threadId": { + "type": "string" + }, + "tokenBudget": { + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "threadId" + ], + "type": "object" + }, "ThreadGoalStatus": { "enum": [ "active", "paused", + "blocked", + "usageLimited", "budgetLimited", "complete" ], @@ -3683,7 +3493,6 @@ "ThreadReadParams": { "properties": { "includeTurns": { - "default": false, "description": "When true, include turns and their items from rollout history.", "type": "boolean" }, @@ -3776,8 +3585,44 @@ } ] }, + "ThreadResumeInitialTurnsPageParams": { + "properties": { + "itemsView": { + "anyOf": [ + { + "$ref": "#/definitions/TurnItemsView" + }, + { + "type": "null" + } + ], + "description": "How much item detail to include for each returned turn; defaults to summary." + }, + "limit": { + "description": "Optional turn page size.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "sortDirection": { + "anyOf": [ + { + "$ref": "#/definitions/SortDirection" + }, + { + "type": "null" + } + ], + "description": "Optional turn pagination direction; defaults to descending." + } + }, + "type": "object" + }, "ThreadResumeParams": { - "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nThe precedence is: history > path > thread_id. If using history or path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", + "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nFor non-running threads, the precedence is: history > non-empty path > thread_id. If using history or a non-empty path for a non-running thread, the thread_id param will be ignored.\n\nIf thread_id identifies a running thread, app-server rejoins that thread and treats a non-empty path as a consistency check against the active rollout path. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", "properties": { "approvalPolicy": { "anyOf": [ @@ -4182,6 +4027,12 @@ ], "description": "Override where approval requests are routed for review on this turn and subsequent turns." }, + "clientUserMessageId": { + "type": [ + "string", + "null" + ] + }, "cwd": { "description": "Override the working directory for this turn and subsequent turns.", "type": [ @@ -4268,6 +4119,12 @@ }, "TurnSteerParams": { "properties": { + "clientUserMessageId": { + "type": [ + "string", + "null" + ] + }, "expectedTurnId": { "description": "Required active turn id precondition. The request fails when it does not match the currently active turn.", "type": "string" @@ -4321,6 +4178,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -4341,6 +4209,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, @@ -4609,6 +4488,78 @@ "title": "Thread/name/setRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/goal/set" + ], + "title": "Thread/goal/setRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadGoalSetParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/goal/setRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/goal/get" + ], + "title": "Thread/goal/getRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadGoalGetParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/goal/getRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/goal/clear" + ], + "title": "Thread/goal/clearRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadGoalClearParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/goal/clearRequest", + "type": "object" + }, { "properties": { "id": { @@ -4898,6 +4849,30 @@ "title": "Skills/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "skills/extraRoots/set" + ], + "title": "Skills/extraRoots/setRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/SkillsExtraRootsSetParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Skills/extraRoots/setRequest", + "type": "object" + }, { "properties": { "id": { @@ -5018,6 +4993,30 @@ "title": "Plugin/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/installed" + ], + "title": "Plugin/installedRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginInstalledParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/installedRequest", + "type": "object" + }, { "properties": { "id": { @@ -5666,6 +5665,30 @@ "title": "ExperimentalFeature/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "permissionProfile/list" + ], + "title": "PermissionProfile/listRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PermissionProfileListParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "PermissionProfile/listRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json index 5b6c4cd185..922db80f2f 100644 --- a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json @@ -277,7 +277,7 @@ "enum": [ "read", "write", - "none" + "deny" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json index 1383da6124..337a469890 100644 --- a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json @@ -62,7 +62,7 @@ "enum": [ "read", "write", - "none" + "deny" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json index 3e775a3da9..5cce2cdc5a 100644 --- a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalResponse.json @@ -62,7 +62,7 @@ "enum": [ "read", "write", - "none" + "deny" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index d4456b3a07..213bfa82b3 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -64,6 +64,26 @@ }, "type": "object" }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier from the selected permissions profile's `extends` setting, when present.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, "AdditionalFileSystemPermissions": { "properties": { "entries": { @@ -415,6 +435,65 @@ ], "type": "object" }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + "enum": [ + "user", + "auto_review", + "guardian_subagent" + ], + "type": "string" + }, + "AskForApproval": { + "oneOf": [ + { + "enum": [ + "untrusted", + "on-failure", + "on-request", + "never" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "granular": { + "properties": { + "mcp_elicitations": { + "type": "boolean" + }, + "request_permissions": { + "default": false, + "type": "boolean" + }, + "rules": { + "type": "boolean" + }, + "sandbox_approval": { + "type": "boolean" + }, + "skill_approval": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "mcp_elicitations", + "rules", + "sandbox_approval" + ], + "type": "object" + } + }, + "required": [ + "granular" + ], + "title": "GranularAskForApproval", + "type": "object" + } + ] + }, "AuthMode": { "description": "Authentication mode for OpenAI-backed providers.", "oneOf": [ @@ -658,6 +737,22 @@ ], "type": "string" }, + "CollaborationMode": { + "description": "Collaboration mode for a Codex session.", + "properties": { + "mode": { + "$ref": "#/definitions/ModeKind" + }, + "settings": { + "$ref": "#/definitions/Settings" + } + }, + "required": [ + "mode", + "settings" + ], + "type": "object" + }, "CommandAction": { "oneOf": [ { @@ -1085,7 +1180,7 @@ "enum": [ "read", "write", - "none" + "deny" ], "type": "string" }, @@ -1740,6 +1835,8 @@ "postCompact", "sessionStart", "userPromptSubmit", + "subagentStart", + "subagentStop", "stop" ], "type": "string" @@ -1905,6 +2002,7 @@ "sessionFlags", "plugin", "cloudRequirements", + "cloudManagedConfig", "legacyManagedConfigFile", "legacyManagedConfigMdm", "unknown" @@ -1932,6 +2030,15 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "auto", + "low", + "high", + "original" + ], + "type": "string" + }, "ItemCompletedNotification": { "properties": { "completedAtMs": { @@ -2250,6 +2357,14 @@ } ] }, + "ModeKind": { + "description": "Initial collaboration mode to use when the TUI starts.", + "enum": [ + "plan", + "default" + ], + "type": "string" + }, "ModelRerouteReason": { "enum": [ "highRiskCyberActivity" @@ -2311,6 +2426,13 @@ ], "type": "object" }, + "NetworkAccess": { + "enum": [ + "restricted", + "enabled" + ], + "type": "string" + }, "NetworkApprovalProtocol": { "enum": [ "http", @@ -2394,6 +2516,14 @@ } ] }, + "Personality": { + "enum": [ + "none", + "friendly", + "pragmatic" + ], + "type": "string" + }, "PlanDeltaNotification": { "description": "EXPERIMENTAL - proposed plan streaming deltas for plan items. Clients should not assume concatenated deltas match the completed plan item content.", "properties": { @@ -2647,6 +2777,26 @@ ], "type": "string" }, + "ReasoningSummary": { + "description": "A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries", + "oneOf": [ + { + "enum": [ + "auto", + "concise", + "detailed" + ], + "type": "string" + }, + { + "description": "Option to disable reasoning summaries.", + "enum": [ + "none" + ], + "type": "string" + } + ] + }, "ReasoningSummaryPartAddedNotification": { "properties": { "itemId": { @@ -2748,12 +2898,16 @@ "installationId": { "type": "string" }, + "serverName": { + "type": "string" + }, "status": { "$ref": "#/definitions/RemoteControlConnectionStatus" } }, "required": [ "installationId", + "serverName", "status" ], "type": "object" @@ -2795,6 +2949,105 @@ }, "type": "object" }, + "SandboxPolicy": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "dangerFullAccess" + ], + "title": "DangerFullAccessSandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DangerFullAccessSandboxPolicy", + "type": "object" + }, + { + "properties": { + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "enum": [ + "readOnly" + ], + "title": "ReadOnlySandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ReadOnlySandboxPolicy", + "type": "object" + }, + { + "properties": { + "networkAccess": { + "allOf": [ + { + "$ref": "#/definitions/NetworkAccess" + } + ], + "default": "restricted" + }, + "type": { + "enum": [ + "externalSandbox" + ], + "title": "ExternalSandboxSandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ExternalSandboxSandboxPolicy", + "type": "object" + }, + { + "properties": { + "excludeSlashTmp": { + "default": false, + "type": "boolean" + }, + "excludeTmpdirEnvVar": { + "default": false, + "type": "boolean" + }, + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "enum": [ + "workspaceWrite" + ], + "title": "WorkspaceWriteSandboxPolicyType", + "type": "string" + }, + "writableRoots": { + "default": [], + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + } + }, + "required": [ + "type" + ], + "title": "WorkspaceWriteSandboxPolicy", + "type": "object" + } + ] + }, "ServerRequestResolvedNotification": { "properties": { "requestId": { @@ -2850,6 +3103,34 @@ } ] }, + "Settings": { + "description": "Settings for a collaboration mode.", + "properties": { + "developer_instructions": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": "string" + }, + "reasoning_effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "model" + ], + "type": "object" + }, "SkillsChangedNotification": { "description": "Notification emitted when watched local skill files change.\n\nTreat this as an invalidation signal and re-run `skills/list` with the client's current parameters when refreshed skill metadata is needed.", "type": "object" @@ -3093,6 +3374,13 @@ "null" ] }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ @@ -3294,6 +3582,8 @@ "enum": [ "active", "paused", + "blocked", + "usageLimited", "budgetLimited", "complete" ], @@ -3327,6 +3617,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" @@ -3629,6 +3925,12 @@ "null" ] }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -4182,6 +4484,102 @@ ], "type": "object" }, + "ThreadSettings": { + "properties": { + "activePermissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/ActivePermissionProfile" + }, + { + "type": "null" + } + ] + }, + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "approvalsReviewer": { + "$ref": "#/definitions/ApprovalsReviewer" + }, + "collaborationMode": { + "$ref": "#/definitions/CollaborationMode" + }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "personality": { + "anyOf": [ + { + "$ref": "#/definitions/Personality" + }, + { + "type": "null" + } + ] + }, + "sandboxPolicy": { + "$ref": "#/definitions/SandboxPolicy" + }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, + "summary": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningSummary" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "approvalPolicy", + "approvalsReviewer", + "collaborationMode", + "cwd", + "model", + "modelProvider", + "sandboxPolicy" + ], + "type": "object" + }, + "ThreadSettingsUpdatedNotification": { + "properties": { + "threadId": { + "type": "string" + }, + "threadSettings": { + "$ref": "#/definitions/ThreadSettings" + } + }, + "required": [ + "threadId", + "threadSettings" + ], + "type": "object" + }, "ThreadSource": { "enum": [ "user", @@ -4637,6 +5035,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -4657,6 +5066,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, @@ -5121,6 +5541,26 @@ "title": "Thread/goal/clearedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "thread/settings/updated" + ], + "title": "Thread/settings/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadSettingsUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/settings/updatedNotification", + "type": "object" + }, { "properties": { "method": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 697cb22e92..411095f2c2 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -631,7 +631,7 @@ "enum": [ "read", "write", - "none" + "deny" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 0124a1e50e..0fa9fa0835 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -372,6 +372,78 @@ "title": "Thread/name/setRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "thread/goal/set" + ], + "title": "Thread/goal/setRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadGoalSetParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/goal/setRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "thread/goal/get" + ], + "title": "Thread/goal/getRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadGoalGetParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/goal/getRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "thread/goal/clear" + ], + "title": "Thread/goal/clearRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadGoalClearParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/goal/clearRequest", + "type": "object" + }, { "properties": { "id": { @@ -661,6 +733,30 @@ "title": "Skills/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "skills/extraRoots/set" + ], + "title": "Skills/extraRoots/setRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/SkillsExtraRootsSetParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Skills/extraRoots/setRequest", + "type": "object" + }, { "properties": { "id": { @@ -781,6 +877,30 @@ "title": "Plugin/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "plugin/installed" + ], + "title": "Plugin/installedRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/PluginInstalledParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/installedRequest", + "type": "object" + }, { "properties": { "id": { @@ -1429,6 +1549,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": { @@ -4051,6 +4195,26 @@ "title": "Thread/goal/clearedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "thread/settings/updated" + ], + "title": "Thread/settings/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadSettingsUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/settings/updatedNotification", + "type": "object" + }, { "properties": { "method": { @@ -5642,7 +5806,7 @@ "properties": { "extends": { "default": null, - "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "description": "Parent profile identifier from the selected permissions profile's `extends` setting, when present.", "type": [ "string", "null" @@ -5651,14 +5815,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/v2/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -5666,31 +5822,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/v2/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AddCreditsNudgeCreditType": { "enum": [ "credits", @@ -5705,6 +5836,28 @@ ], "type": "string" }, + "AdditionalContextEntry": { + "properties": { + "kind": { + "$ref": "#/definitions/v2/AdditionalContextKind" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + "AdditionalContextKind": { + "enum": [ + "untrusted", + "application" + ], + "type": "string" + }, "AdditionalFileSystemPermissions": { "properties": { "entries": { @@ -6367,6 +6520,25 @@ } ] }, + "AutoCompactTokenLimitScope": { + "description": "Selects which part of the active context is charged against `model_auto_compact_token_limit`.", + "oneOf": [ + { + "description": "Count the full active context against the limit.", + "enum": [ + "total" + ], + "type": "string" + }, + { + "description": "Count sampled output and later growth after the carried window prefix.", + "enum": [ + "body_after_prefix" + ], + "type": "string" + } + ] + }, "AutoReviewDecisionSource": { "description": "[UNSTABLE] Source that produced a terminal approval auto-review decision.", "enum": [ @@ -7123,6 +7295,17 @@ ], "type": "object" }, + "ComputerUseRequirements": { + "properties": { + "allowLockedComputerUse": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "Config": { "additionalProperties": true, "properties": { @@ -7163,6 +7346,13 @@ "null" ] }, + "desktop": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, "developer_instructions": { "type": [ "string", @@ -7208,6 +7398,16 @@ "null" ] }, + "model_auto_compact_token_limit_scope": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AutoCompactTokenLimitScope" + }, + { + "type": "null" + } + ] + }, "model_context_window": { "format": "int64", "type": [ @@ -7251,19 +7451,6 @@ } ] }, - "profile": { - "type": [ - "string", - "null" - ] - }, - "profiles": { - "additionalProperties": { - "$ref": "#/definitions/v2/ProfileV2" - }, - "default": {}, - "type": "object" - }, "review_model": { "type": [ "string", @@ -7460,6 +7647,33 @@ "title": "SystemConfigLayerSource", "type": "object" }, + { + "description": "Enterprise-managed config layer delivered by the cloud config bundle.", + "properties": { + "id": { + "description": "Stable identifier for the delivered layer.", + "type": "string" + }, + "name": { + "description": "Admin-facing name for the delivered layer. This is surfaced in diagnostics so users know which cloud layer needs administrator attention.", + "type": "string" + }, + "type": { + "enum": [ + "enterpriseManaged" + ], + "title": "EnterpriseManagedConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "id", + "name", + "type" + ], + "title": "EnterpriseManagedConfigLayerSource", + "type": "object" + }, { "description": "User config layer from $CODEX_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory", "properties": { @@ -7581,7 +7795,6 @@ ] }, "includeLayers": { - "default": false, "type": "boolean" } }, @@ -7619,6 +7832,12 @@ }, "ConfigRequirements": { "properties": { + "allowAppshots": { + "type": [ + "boolean", + "null" + ] + }, "allowManagedHooksOnly": { "type": [ "boolean", @@ -7634,6 +7853,15 @@ "null" ] }, + "allowedPermissions": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "allowedSandboxModes": { "items": { "$ref": "#/definitions/v2/SandboxMode" @@ -7652,6 +7880,25 @@ "null" ] }, + "allowedWindowsSandboxImplementations": { + "items": { + "$ref": "#/definitions/v2/WindowsSandboxSetupMode" + }, + "type": [ + "array", + "null" + ] + }, + "computerUse": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ComputerUseRequirements" + }, + { + "type": "null" + } + ] + }, "enforceResidency": { "anyOf": [ { @@ -8240,6 +8487,13 @@ "integer", "null" ] + }, + "threadId": { + "description": "Optional loaded thread id. Pass this when showing feature state for an existing thread so enablement is computed from that thread's refreshed config, including project-local config for the thread's cwd.", + "type": [ + "string", + "null" + ] } }, "title": "ExperimentalFeatureListParams", @@ -8457,8 +8711,7 @@ } }, "required": [ - "classification", - "includeLogs" + "classification" ], "title": "FeedbackUploadParams", "type": "object" @@ -8534,7 +8787,7 @@ "enum": [ "read", "write", - "none" + "deny" ], "type": "string" }, @@ -9207,6 +9460,26 @@ ], "title": "InputImageFunctionCallOutputContentItem", "type": "object" + }, + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentFunctionCallOutputContentItem", + "type": "object" } ] }, @@ -9214,7 +9487,6 @@ "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "refreshToken": { - "default": false, "description": "When `true`, requests a proactive token refresh before returning.\n\nIn managed auth mode this triggers the normal refresh-token flow. In external auth mode this flag is ignored. Clients should refresh tokens themselves and call `account/login/start` with `chatgptAuthTokens`.", "type": "boolean" } @@ -9634,6 +9906,8 @@ "postCompact", "sessionStart", "userPromptSubmit", + "subagentStart", + "subagentStop", "stop" ], "type": "string" @@ -9888,6 +10162,7 @@ "sessionFlags", "plugin", "cloudRequirements", + "cloudManagedConfig", "legacyManagedConfigFile", "legacyManagedConfigMdm", "unknown" @@ -10197,6 +10472,12 @@ "integer", "null" ] + }, + "threadId": { + "type": [ + "string", + "null" + ] } }, "title": "ListMcpServerStatusParams", @@ -10526,6 +10807,18 @@ }, "type": "array" }, + "SubagentStart": { + "items": { + "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "SubagentStop": { + "items": { + "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, "UserPromptSubmit": { "items": { "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" @@ -10553,6 +10846,8 @@ "PreToolUse", "SessionStart", "Stop", + "SubagentStart", + "SubagentStop", "UserPromptSubmit" ], "type": "object" @@ -10774,6 +11069,47 @@ "title": "McpResourceReadResponse", "type": "object" }, + "McpServerInfo": { + "description": "Presentation metadata advertised by an initialized MCP server.", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "icons": { + "items": true, + "type": [ + "array", + "null" + ] + }, + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string" + }, + "websiteUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, "McpServerMigration": { "properties": { "name": { @@ -10884,6 +11220,16 @@ }, "type": "array" }, + "serverInfo": { + "anyOf": [ + { + "$ref": "#/definitions/v2/McpServerInfo" + }, + { + "type": "null" + } + ] + }, "tools": { "additionalProperties": { "$ref": "#/definitions/v2/Tool" @@ -11185,6 +11531,14 @@ "defaultReasoningEffort": { "$ref": "#/definitions/v2/ReasoningEffort" }, + "defaultServiceTier": { + "default": null, + "description": "Catalog default service tier id for this model, when one is configured.", + "type": [ + "string", + "null" + ] + }, "description": { "type": "string" }, @@ -11596,7 +11950,7 @@ "NetworkUnixSocketPermission": { "enum": [ "allow", - "none" + "deny" ], "type": "string" }, @@ -11691,193 +12045,77 @@ } ] }, - "PermissionProfile": { - "oneOf": [ - { - "description": "Codex owns sandbox construction for this profile.", - "properties": { - "fileSystem": { - "$ref": "#/definitions/v2/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/v2/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "managed" - ], - "title": "ManagedPermissionProfileType", - "type": "string" - } - }, - "required": [ - "fileSystem", - "network", - "type" - ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/v2/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", - "type": "object" - } - ] - }, - "PermissionProfileFileSystemPermissions": { - "oneOf": [ - { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/v2/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", - "type": "object" - } - ] - }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/v2/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, - "PermissionProfileNetworkPermissions": { + "PermissionProfileListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "enabled": { - "type": "boolean" + "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": [ - "enabled" + "data" ], + "title": "PermissionProfileListResponse", "type": "object" }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/v2/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "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": [ @@ -12085,6 +12323,56 @@ "title": "PluginInstallResponse", "type": "object" }, + "PluginInstalledParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwds": { + "description": "Optional working directories used to discover repo marketplaces.", + "items": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "installSuggestionPluginNames": { + "description": "Additional uninstalled plugin names that should be returned when present locally. This is used by mention surfaces that intentionally expose install entrypoints.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "title": "PluginInstalledParams", + "type": "object" + }, + "PluginInstalledResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "marketplaceLoadErrors": { + "default": [], + "items": { + "$ref": "#/definitions/v2/MarketplaceLoadErrorInfo" + }, + "type": "array" + }, + "marketplaces": { + "items": { + "$ref": "#/definitions/v2/PluginMarketplaceEntry" + }, + "type": "array" + } + }, + "required": [ + "marketplaces" + ], + "title": "PluginInstalledResponse", + "type": "object" + }, "PluginInterface": { "properties": { "brandColor": { @@ -12218,6 +12506,7 @@ "PluginListMarketplaceKind": { "enum": [ "local", + "vertical", "workspace-directory", "shared-with-me" ], @@ -13060,107 +13349,6 @@ ], "type": "object" }, - "ProfileV2": { - "additionalProperties": true, - "properties": { - "approval_policy": { - "anyOf": [ - { - "$ref": "#/definitions/v2/AskForApproval" - }, - { - "type": "null" - } - ] - }, - "approvals_reviewer": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ApprovalsReviewer" - }, - { - "type": "null" - } - ], - "description": "[UNSTABLE] Optional profile-level override for where approval requests are routed for review. If omitted, the enclosing config default is used." - }, - "chatgpt_base_url": { - "type": [ - "string", - "null" - ] - }, - "model": { - "type": [ - "string", - "null" - ] - }, - "model_provider": { - "type": [ - "string", - "null" - ] - }, - "model_reasoning_effort": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "model_reasoning_summary": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ReasoningSummary" - }, - { - "type": "null" - } - ] - }, - "model_verbosity": { - "anyOf": [ - { - "$ref": "#/definitions/v2/Verbosity" - }, - { - "type": "null" - } - ] - }, - "service_tier": { - "type": [ - "string", - "null" - ] - }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ToolsV2" - }, - { - "type": "null" - } - ] - }, - "web_search": { - "anyOf": [ - { - "$ref": "#/definitions/v2/WebSearchMode" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, "RateLimitReachedType": { "enum": [ "rate_limit_reached", @@ -13575,12 +13763,16 @@ "installationId": { "type": "string" }, + "serverName": { + "type": "string" + }, "status": { "$ref": "#/definitions/v2/RemoteControlConnectionStatus" } }, "required": [ "installationId", + "serverName", "status" ], "title": "RemoteControlStatusChangedNotification", @@ -14194,6 +14386,22 @@ "title": "CompactionResponseItem", "type": "object" }, + { + "properties": { + "type": { + "enum": [ + "compaction_trigger" + ], + "title": "CompactionTriggerResponseItemType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "CompactionTriggerResponseItem", + "type": "object" + }, { "properties": { "encrypted_content": { @@ -15018,6 +15226,27 @@ "title": "SkillsConfigWriteResponse", "type": "object" }, + "SkillsExtraRootsSetParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "extraRoots": { + "items": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": "array" + } + }, + "required": [ + "extraRoots" + ], + "title": "SkillsExtraRootsSetParams", + "type": "object" + }, + "SkillsExtraRootsSetResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SkillsExtraRootsSetResponse", + "type": "object" + }, "SkillsListEntry": { "properties": { "cwd": { @@ -15336,6 +15565,13 @@ "null" ] }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ @@ -15585,7 +15821,7 @@ }, "ThreadForkParams": { "$schema": "http://json-schema.org/draft-07/schema#", - "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", + "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using a non-empty path, the thread_id param will be ignored. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", "properties": { "approvalPolicy": { "anyOf": [ @@ -15733,7 +15969,7 @@ "$ref": "#/definitions/v2/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance." }, "serviceTier": { "type": [ @@ -15803,6 +16039,32 @@ ], "type": "object" }, + "ThreadGoalClearParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadGoalClearParams", + "type": "object" + }, + "ThreadGoalClearResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cleared": { + "type": "boolean" + } + }, + "required": [ + "cleared" + ], + "title": "ThreadGoalClearResponse", + "type": "object" + }, "ThreadGoalClearedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -15816,10 +16078,91 @@ "title": "ThreadGoalClearedNotification", "type": "object" }, + "ThreadGoalGetParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadGoalGetParams", + "type": "object" + }, + "ThreadGoalGetResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "goal": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ThreadGoal" + }, + { + "type": "null" + } + ] + } + }, + "title": "ThreadGoalGetResponse", + "type": "object" + }, + "ThreadGoalSetParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "objective": { + "type": [ + "string", + "null" + ] + }, + "status": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ThreadGoalStatus" + }, + { + "type": "null" + } + ] + }, + "threadId": { + "type": "string" + }, + "tokenBudget": { + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "threadId" + ], + "title": "ThreadGoalSetParams", + "type": "object" + }, + "ThreadGoalSetResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "goal": { + "$ref": "#/definitions/v2/ThreadGoal" + } + }, + "required": [ + "goal" + ], + "title": "ThreadGoalSetResponse", + "type": "object" + }, "ThreadGoalStatus": { "enum": [ "active", "paused", + "blocked", + "usageLimited", "budgetLimited", "complete" ], @@ -15879,6 +16222,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/v2/UserInput" @@ -16181,6 +16530,12 @@ "null" ] }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -16812,7 +17167,6 @@ "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "includeTurns": { - "default": false, "description": "When true, include turns and their items from rollout history.", "type": "boolean" }, @@ -17079,9 +17433,45 @@ "title": "ThreadRealtimeTranscriptDoneNotification", "type": "object" }, + "ThreadResumeInitialTurnsPageParams": { + "properties": { + "itemsView": { + "anyOf": [ + { + "$ref": "#/definitions/v2/TurnItemsView" + }, + { + "type": "null" + } + ], + "description": "How much item detail to include for each returned turn; defaults to summary." + }, + "limit": { + "description": "Optional turn page size.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "sortDirection": { + "anyOf": [ + { + "$ref": "#/definitions/v2/SortDirection" + }, + { + "type": "null" + } + ], + "description": "Optional turn pagination direction; defaults to descending." + } + }, + "type": "object" + }, "ThreadResumeParams": { "$schema": "http://json-schema.org/draft-07/schema#", - "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nThe precedence is: history > path > thread_id. If using history or path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", + "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nFor non-running threads, the precedence is: history > non-empty path > thread_id. If using history or a non-empty path for a non-running thread, the thread_id param will be ignored.\n\nIf thread_id identifies a running thread, app-server rejoins that thread and treats a non-empty path as a consistency check against the active rollout path. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", "properties": { "approvalPolicy": { "anyOf": [ @@ -17225,7 +17615,7 @@ "$ref": "#/definitions/v2/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance." }, "serviceTier": { "type": [ @@ -17287,6 +17677,21 @@ "title": "ThreadRollbackResponse", "type": "object" }, + "ThreadSearchResult": { + "properties": { + "snippet": { + "type": "string" + }, + "thread": { + "$ref": "#/definitions/v2/Thread" + } + }, + "required": [ + "snippet", + "thread" + ], + "type": "object" + }, "ThreadSetNameParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -17309,6 +17714,104 @@ "title": "ThreadSetNameResponse", "type": "object" }, + "ThreadSettings": { + "properties": { + "activePermissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ActivePermissionProfile" + }, + { + "type": "null" + } + ] + }, + "approvalPolicy": { + "$ref": "#/definitions/v2/AskForApproval" + }, + "approvalsReviewer": { + "$ref": "#/definitions/v2/ApprovalsReviewer" + }, + "collaborationMode": { + "$ref": "#/definitions/v2/CollaborationMode" + }, + "cwd": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "effort": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "personality": { + "anyOf": [ + { + "$ref": "#/definitions/v2/Personality" + }, + { + "type": "null" + } + ] + }, + "sandboxPolicy": { + "$ref": "#/definitions/v2/SandboxPolicy" + }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, + "summary": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ReasoningSummary" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "approvalPolicy", + "approvalsReviewer", + "collaborationMode", + "cwd", + "model", + "modelProvider", + "sandboxPolicy" + ], + "type": "object" + }, + "ThreadSettingsUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "threadSettings": { + "$ref": "#/definitions/v2/ThreadSettings" + } + }, + "required": [ + "threadId", + "threadSettings" + ], + "title": "ThreadSettingsUpdatedNotification", + "type": "object" + }, "ThreadShellCommandParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -17533,7 +18036,7 @@ "$ref": "#/definitions/v2/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance." }, "serviceTier": { "type": [ @@ -18142,6 +18645,12 @@ ], "description": "Override where approval requests are routed for review on this turn and subsequent turns." }, + "clientUserMessageId": { + "type": [ + "string", + "null" + ] + }, "cwd": { "description": "Override the working directory for this turn and subsequent turns.", "type": [ @@ -18269,6 +18778,12 @@ "TurnSteerParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "clientUserMessageId": { + "type": [ + "string", + "null" + ] + }, "expectedTurnId": { "description": "Required active turn id precondition. The request fails when it does not match the currently active turn.", "type": "string" @@ -18304,6 +18819,32 @@ "title": "TurnSteerResponse", "type": "object" }, + "TurnsPage": { + "properties": { + "backwardsCursor": { + "type": [ + "string", + "null" + ] + }, + "data": { + "items": { + "$ref": "#/definitions/v2/Turn" + }, + "type": "array" + }, + "nextCursor": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "type": "object" + }, "UserInput": { "oneOf": [ { @@ -18336,6 +18877,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -18356,6 +18908,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 6444996099..ddf54b3451 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -134,7 +134,7 @@ "properties": { "extends": { "default": null, - "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "description": "Parent profile identifier from the selected permissions profile's `extends` setting, when present.", "type": [ "string", "null" @@ -143,14 +143,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -158,31 +150,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AddCreditsNudgeCreditType": { "enum": [ "credits", @@ -197,6 +164,28 @@ ], "type": "string" }, + "AdditionalContextEntry": { + "properties": { + "kind": { + "$ref": "#/definitions/AdditionalContextKind" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + "AdditionalContextKind": { + "enum": [ + "untrusted", + "application" + ], + "type": "string" + }, "AdditionalFileSystemPermissions": { "properties": { "entries": { @@ -859,6 +848,25 @@ } ] }, + "AutoCompactTokenLimitScope": { + "description": "Selects which part of the active context is charged against `model_auto_compact_token_limit`.", + "oneOf": [ + { + "description": "Count the full active context against the limit.", + "enum": [ + "total" + ], + "type": "string" + }, + { + "description": "Count sampled output and later growth after the carried window prefix.", + "enum": [ + "body_after_prefix" + ], + "type": "string" + } + ] + }, "AutoReviewDecisionSource": { "description": "[UNSTABLE] Source that produced a terminal approval auto-review decision.", "enum": [ @@ -1112,6 +1120,78 @@ "title": "Thread/name/setRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/goal/set" + ], + "title": "Thread/goal/setRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadGoalSetParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/goal/setRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/goal/get" + ], + "title": "Thread/goal/getRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadGoalGetParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/goal/getRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/goal/clear" + ], + "title": "Thread/goal/clearRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadGoalClearParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/goal/clearRequest", + "type": "object" + }, { "properties": { "id": { @@ -1401,6 +1481,30 @@ "title": "Skills/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "skills/extraRoots/set" + ], + "title": "Skills/extraRoots/setRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/SkillsExtraRootsSetParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Skills/extraRoots/setRequest", + "type": "object" + }, { "properties": { "id": { @@ -1521,6 +1625,30 @@ "title": "Plugin/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/installed" + ], + "title": "Plugin/installedRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginInstalledParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/installedRequest", + "type": "object" + }, { "properties": { "id": { @@ -2169,6 +2297,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": { @@ -3492,6 +3644,17 @@ ], "type": "object" }, + "ComputerUseRequirements": { + "properties": { + "allowLockedComputerUse": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "Config": { "additionalProperties": true, "properties": { @@ -3532,6 +3695,13 @@ "null" ] }, + "desktop": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, "developer_instructions": { "type": [ "string", @@ -3577,6 +3747,16 @@ "null" ] }, + "model_auto_compact_token_limit_scope": { + "anyOf": [ + { + "$ref": "#/definitions/AutoCompactTokenLimitScope" + }, + { + "type": "null" + } + ] + }, "model_context_window": { "format": "int64", "type": [ @@ -3620,19 +3800,6 @@ } ] }, - "profile": { - "type": [ - "string", - "null" - ] - }, - "profiles": { - "additionalProperties": { - "$ref": "#/definitions/ProfileV2" - }, - "default": {}, - "type": "object" - }, "review_model": { "type": [ "string", @@ -3829,6 +3996,33 @@ "title": "SystemConfigLayerSource", "type": "object" }, + { + "description": "Enterprise-managed config layer delivered by the cloud config bundle.", + "properties": { + "id": { + "description": "Stable identifier for the delivered layer.", + "type": "string" + }, + "name": { + "description": "Admin-facing name for the delivered layer. This is surfaced in diagnostics so users know which cloud layer needs administrator attention.", + "type": "string" + }, + "type": { + "enum": [ + "enterpriseManaged" + ], + "title": "EnterpriseManagedConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "id", + "name", + "type" + ], + "title": "EnterpriseManagedConfigLayerSource", + "type": "object" + }, { "description": "User config layer from $CODEX_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory", "properties": { @@ -3950,7 +4144,6 @@ ] }, "includeLayers": { - "default": false, "type": "boolean" } }, @@ -3988,6 +4181,12 @@ }, "ConfigRequirements": { "properties": { + "allowAppshots": { + "type": [ + "boolean", + "null" + ] + }, "allowManagedHooksOnly": { "type": [ "boolean", @@ -4003,6 +4202,15 @@ "null" ] }, + "allowedPermissions": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "allowedSandboxModes": { "items": { "$ref": "#/definitions/SandboxMode" @@ -4021,6 +4229,25 @@ "null" ] }, + "allowedWindowsSandboxImplementations": { + "items": { + "$ref": "#/definitions/WindowsSandboxSetupMode" + }, + "type": [ + "array", + "null" + ] + }, + "computerUse": { + "anyOf": [ + { + "$ref": "#/definitions/ComputerUseRequirements" + }, + { + "type": "null" + } + ] + }, "enforceResidency": { "anyOf": [ { @@ -4609,6 +4836,13 @@ "integer", "null" ] + }, + "threadId": { + "description": "Optional loaded thread id. Pass this when showing feature state for an existing thread so enablement is computed from that thread's refreshed config, including project-local config for the thread's cwd.", + "type": [ + "string", + "null" + ] } }, "title": "ExperimentalFeatureListParams", @@ -4826,8 +5060,7 @@ } }, "required": [ - "classification", - "includeLogs" + "classification" ], "title": "FeedbackUploadParams", "type": "object" @@ -4903,7 +5136,7 @@ "enum": [ "read", "write", - "none" + "deny" ], "type": "string" }, @@ -5576,6 +5809,26 @@ ], "title": "InputImageFunctionCallOutputContentItem", "type": "object" + }, + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentFunctionCallOutputContentItem", + "type": "object" } ] }, @@ -5694,7 +5947,6 @@ "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "refreshToken": { - "default": false, "description": "When `true`, requests a proactive token refresh before returning.\n\nIn managed auth mode this triggers the normal refresh-token flow. In external auth mode this flag is ignored. Clients should refresh tokens themselves and call `account/login/start` with `chatgptAuthTokens`.", "type": "boolean" } @@ -6114,6 +6366,8 @@ "postCompact", "sessionStart", "userPromptSubmit", + "subagentStart", + "subagentStop", "stop" ], "type": "string" @@ -6368,6 +6622,7 @@ "sessionFlags", "plugin", "cloudRequirements", + "cloudManagedConfig", "legacyManagedConfigFile", "legacyManagedConfigMdm", "unknown" @@ -6726,6 +6981,12 @@ "integer", "null" ] + }, + "threadId": { + "type": [ + "string", + "null" + ] } }, "title": "ListMcpServerStatusParams", @@ -7055,6 +7316,18 @@ }, "type": "array" }, + "SubagentStart": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "SubagentStop": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, "UserPromptSubmit": { "items": { "$ref": "#/definitions/ConfiguredHookMatcherGroup" @@ -7082,6 +7355,8 @@ "PreToolUse", "SessionStart", "Stop", + "SubagentStart", + "SubagentStop", "UserPromptSubmit" ], "type": "object" @@ -7303,6 +7578,47 @@ "title": "McpResourceReadResponse", "type": "object" }, + "McpServerInfo": { + "description": "Presentation metadata advertised by an initialized MCP server.", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "icons": { + "items": true, + "type": [ + "array", + "null" + ] + }, + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string" + }, + "websiteUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, "McpServerMigration": { "properties": { "name": { @@ -7413,6 +7729,16 @@ }, "type": "array" }, + "serverInfo": { + "anyOf": [ + { + "$ref": "#/definitions/McpServerInfo" + }, + { + "type": "null" + } + ] + }, "tools": { "additionalProperties": { "$ref": "#/definitions/Tool" @@ -7714,6 +8040,14 @@ "defaultReasoningEffort": { "$ref": "#/definitions/ReasoningEffort" }, + "defaultServiceTier": { + "default": null, + "description": "Catalog default service tier id for this model, when one is configured.", + "type": [ + "string", + "null" + ] + }, "description": { "type": "string" }, @@ -8125,7 +8459,7 @@ "NetworkUnixSocketPermission": { "enum": [ "allow", - "none" + "deny" ], "type": "string" }, @@ -8220,193 +8554,77 @@ } ] }, - "PermissionProfile": { - "oneOf": [ - { - "description": "Codex owns sandbox construction for this profile.", - "properties": { - "fileSystem": { - "$ref": "#/definitions/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "managed" - ], - "title": "ManagedPermissionProfileType", - "type": "string" - } - }, - "required": [ - "fileSystem", - "network", - "type" - ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", - "type": "object" - } - ] - }, - "PermissionProfileFileSystemPermissions": { - "oneOf": [ - { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", - "type": "object" - } - ] - }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, - "PermissionProfileNetworkPermissions": { + "PermissionProfileListParams": { + "$schema": "http://json-schema.org/draft-07/schema#", "properties": { - "enabled": { - "type": "boolean" + "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": [ - "enabled" + "data" ], + "title": "PermissionProfileListResponse", "type": "object" }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "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": [ @@ -8614,6 +8832,56 @@ "title": "PluginInstallResponse", "type": "object" }, + "PluginInstalledParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cwds": { + "description": "Optional working directories used to discover repo marketplaces.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "installSuggestionPluginNames": { + "description": "Additional uninstalled plugin names that should be returned when present locally. This is used by mention surfaces that intentionally expose install entrypoints.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "title": "PluginInstalledParams", + "type": "object" + }, + "PluginInstalledResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "marketplaceLoadErrors": { + "default": [], + "items": { + "$ref": "#/definitions/MarketplaceLoadErrorInfo" + }, + "type": "array" + }, + "marketplaces": { + "items": { + "$ref": "#/definitions/PluginMarketplaceEntry" + }, + "type": "array" + } + }, + "required": [ + "marketplaces" + ], + "title": "PluginInstalledResponse", + "type": "object" + }, "PluginInterface": { "properties": { "brandColor": { @@ -8747,6 +9015,7 @@ "PluginListMarketplaceKind": { "enum": [ "local", + "vertical", "workspace-directory", "shared-with-me" ], @@ -9589,107 +9858,6 @@ ], "type": "object" }, - "ProfileV2": { - "additionalProperties": true, - "properties": { - "approval_policy": { - "anyOf": [ - { - "$ref": "#/definitions/AskForApproval" - }, - { - "type": "null" - } - ] - }, - "approvals_reviewer": { - "anyOf": [ - { - "$ref": "#/definitions/ApprovalsReviewer" - }, - { - "type": "null" - } - ], - "description": "[UNSTABLE] Optional profile-level override for where approval requests are routed for review. If omitted, the enclosing config default is used." - }, - "chatgpt_base_url": { - "type": [ - "string", - "null" - ] - }, - "model": { - "type": [ - "string", - "null" - ] - }, - "model_provider": { - "type": [ - "string", - "null" - ] - }, - "model_reasoning_effort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "model_reasoning_summary": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningSummary" - }, - { - "type": "null" - } - ] - }, - "model_verbosity": { - "anyOf": [ - { - "$ref": "#/definitions/Verbosity" - }, - { - "type": "null" - } - ] - }, - "service_tier": { - "type": [ - "string", - "null" - ] - }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/ToolsV2" - }, - { - "type": "null" - } - ] - }, - "web_search": { - "anyOf": [ - { - "$ref": "#/definitions/WebSearchMode" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, "RateLimitReachedType": { "enum": [ "rate_limit_reached", @@ -10104,12 +10272,16 @@ "installationId": { "type": "string" }, + "serverName": { + "type": "string" + }, "status": { "$ref": "#/definitions/RemoteControlConnectionStatus" } }, "required": [ "installationId", + "serverName", "status" ], "title": "RemoteControlStatusChangedNotification", @@ -10723,6 +10895,22 @@ "title": "CompactionResponseItem", "type": "object" }, + { + "properties": { + "type": { + "enum": [ + "compaction_trigger" + ], + "title": "CompactionTriggerResponseItemType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "CompactionTriggerResponseItem", + "type": "object" + }, { "properties": { "encrypted_content": { @@ -11391,6 +11579,26 @@ "title": "Thread/goal/clearedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "thread/settings/updated" + ], + "title": "Thread/settings/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadSettingsUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/settings/updatedNotification", + "type": "object" + }, { "properties": { "method": { @@ -12842,6 +13050,27 @@ "title": "SkillsConfigWriteResponse", "type": "object" }, + "SkillsExtraRootsSetParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "extraRoots": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + } + }, + "required": [ + "extraRoots" + ], + "title": "SkillsExtraRootsSetParams", + "type": "object" + }, + "SkillsExtraRootsSetResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SkillsExtraRootsSetResponse", + "type": "object" + }, "SkillsListEntry": { "properties": { "cwd": { @@ -13160,6 +13389,13 @@ "null" ] }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ @@ -13409,7 +13645,7 @@ }, "ThreadForkParams": { "$schema": "http://json-schema.org/draft-07/schema#", - "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", + "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using a non-empty path, the thread_id param will be ignored. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", "properties": { "approvalPolicy": { "anyOf": [ @@ -13557,7 +13793,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance." }, "serviceTier": { "type": [ @@ -13627,6 +13863,32 @@ ], "type": "object" }, + "ThreadGoalClearParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadGoalClearParams", + "type": "object" + }, + "ThreadGoalClearResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cleared": { + "type": "boolean" + } + }, + "required": [ + "cleared" + ], + "title": "ThreadGoalClearResponse", + "type": "object" + }, "ThreadGoalClearedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -13640,10 +13902,91 @@ "title": "ThreadGoalClearedNotification", "type": "object" }, + "ThreadGoalGetParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadGoalGetParams", + "type": "object" + }, + "ThreadGoalGetResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "goal": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadGoal" + }, + { + "type": "null" + } + ] + } + }, + "title": "ThreadGoalGetResponse", + "type": "object" + }, + "ThreadGoalSetParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "objective": { + "type": [ + "string", + "null" + ] + }, + "status": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadGoalStatus" + }, + { + "type": "null" + } + ] + }, + "threadId": { + "type": "string" + }, + "tokenBudget": { + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "threadId" + ], + "title": "ThreadGoalSetParams", + "type": "object" + }, + "ThreadGoalSetResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "goal": { + "$ref": "#/definitions/ThreadGoal" + } + }, + "required": [ + "goal" + ], + "title": "ThreadGoalSetResponse", + "type": "object" + }, "ThreadGoalStatus": { "enum": [ "active", "paused", + "blocked", + "usageLimited", "budgetLimited", "complete" ], @@ -13703,6 +14046,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" @@ -14005,6 +14354,12 @@ "null" ] }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -14636,7 +14991,6 @@ "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "includeTurns": { - "default": false, "description": "When true, include turns and their items from rollout history.", "type": "boolean" }, @@ -14903,9 +15257,45 @@ "title": "ThreadRealtimeTranscriptDoneNotification", "type": "object" }, + "ThreadResumeInitialTurnsPageParams": { + "properties": { + "itemsView": { + "anyOf": [ + { + "$ref": "#/definitions/TurnItemsView" + }, + { + "type": "null" + } + ], + "description": "How much item detail to include for each returned turn; defaults to summary." + }, + "limit": { + "description": "Optional turn page size.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "sortDirection": { + "anyOf": [ + { + "$ref": "#/definitions/SortDirection" + }, + { + "type": "null" + } + ], + "description": "Optional turn pagination direction; defaults to descending." + } + }, + "type": "object" + }, "ThreadResumeParams": { "$schema": "http://json-schema.org/draft-07/schema#", - "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nThe precedence is: history > path > thread_id. If using history or path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", + "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nFor non-running threads, the precedence is: history > non-empty path > thread_id. If using history or a non-empty path for a non-running thread, the thread_id param will be ignored.\n\nIf thread_id identifies a running thread, app-server rejoins that thread and treats a non-empty path as a consistency check against the active rollout path. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", "properties": { "approvalPolicy": { "anyOf": [ @@ -15049,7 +15439,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance." }, "serviceTier": { "type": [ @@ -15111,6 +15501,21 @@ "title": "ThreadRollbackResponse", "type": "object" }, + "ThreadSearchResult": { + "properties": { + "snippet": { + "type": "string" + }, + "thread": { + "$ref": "#/definitions/Thread" + } + }, + "required": [ + "snippet", + "thread" + ], + "type": "object" + }, "ThreadSetNameParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -15133,6 +15538,104 @@ "title": "ThreadSetNameResponse", "type": "object" }, + "ThreadSettings": { + "properties": { + "activePermissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/ActivePermissionProfile" + }, + { + "type": "null" + } + ] + }, + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "approvalsReviewer": { + "$ref": "#/definitions/ApprovalsReviewer" + }, + "collaborationMode": { + "$ref": "#/definitions/CollaborationMode" + }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "personality": { + "anyOf": [ + { + "$ref": "#/definitions/Personality" + }, + { + "type": "null" + } + ] + }, + "sandboxPolicy": { + "$ref": "#/definitions/SandboxPolicy" + }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, + "summary": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningSummary" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "approvalPolicy", + "approvalsReviewer", + "collaborationMode", + "cwd", + "model", + "modelProvider", + "sandboxPolicy" + ], + "type": "object" + }, + "ThreadSettingsUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "threadSettings": { + "$ref": "#/definitions/ThreadSettings" + } + }, + "required": [ + "threadId", + "threadSettings" + ], + "title": "ThreadSettingsUpdatedNotification", + "type": "object" + }, "ThreadShellCommandParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -15357,7 +15860,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance." }, "serviceTier": { "type": [ @@ -15966,6 +16469,12 @@ ], "description": "Override where approval requests are routed for review on this turn and subsequent turns." }, + "clientUserMessageId": { + "type": [ + "string", + "null" + ] + }, "cwd": { "description": "Override the working directory for this turn and subsequent turns.", "type": [ @@ -16093,6 +16602,12 @@ "TurnSteerParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "clientUserMessageId": { + "type": [ + "string", + "null" + ] + }, "expectedTurnId": { "description": "Required active turn id precondition. The request fails when it does not match the currently active turn.", "type": "string" @@ -16128,6 +16643,32 @@ "title": "TurnSteerResponse", "type": "object" }, + "TurnsPage": { + "properties": { + "backwardsCursor": { + "type": [ + "string", + "null" + ] + }, + "data": { + "items": { + "$ref": "#/definitions/Turn" + }, + "type": "array" + }, + "nextCursor": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "type": "object" + }, "UserInput": { "oneOf": [ { @@ -16160,6 +16701,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -16180,6 +16732,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json index f29483862c..d00a0b60e5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json @@ -27,202 +27,6 @@ ], "type": "object" }, - "FileSystemAccessMode": { - "enum": [ - "read", - "write", - "none" - ], - "type": "string" - }, - "FileSystemPath": { - "oneOf": [ - { - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "path" - ], - "title": "PathFileSystemPathType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "PathFileSystemPath", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": "string" - }, - "type": { - "enum": [ - "glob_pattern" - ], - "title": "GlobPatternFileSystemPathType", - "type": "string" - } - }, - "required": [ - "pattern", - "type" - ], - "title": "GlobPatternFileSystemPath", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "special" - ], - "title": "SpecialFileSystemPathType", - "type": "string" - }, - "value": { - "$ref": "#/definitions/FileSystemSpecialPath" - } - }, - "required": [ - "type", - "value" - ], - "title": "SpecialFileSystemPath", - "type": "object" - } - ] - }, - "FileSystemSandboxEntry": { - "properties": { - "access": { - "$ref": "#/definitions/FileSystemAccessMode" - }, - "path": { - "$ref": "#/definitions/FileSystemPath" - } - }, - "required": [ - "access", - "path" - ], - "type": "object" - }, - "FileSystemSpecialPath": { - "oneOf": [ - { - "properties": { - "kind": { - "enum": [ - "root" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "RootFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "minimal" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "MinimalFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "project_roots" - ], - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind" - ], - "title": "KindFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "tmpdir" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "TmpdirFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "slash_tmp" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "SlashTmpFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "unknown" - ], - "type": "string" - }, - "path": { - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind", - "path" - ], - "type": "object" - } - ] - }, "NetworkAccess": { "enum": [ "restricted", @@ -230,135 +34,6 @@ ], "type": "string" }, - "PermissionProfile": { - "oneOf": [ - { - "description": "Codex owns sandbox construction for this profile.", - "properties": { - "fileSystem": { - "$ref": "#/definitions/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "managed" - ], - "title": "ManagedPermissionProfileType", - "type": "string" - } - }, - "required": [ - "fileSystem", - "network", - "type" - ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", - "type": "object" - } - ] - }, - "PermissionProfileFileSystemPermissions": { - "oneOf": [ - { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", - "type": "object" - } - ] - }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "SandboxPolicy": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadParams.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadParams.json index b173d2ba95..db38089a5d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadParams.json @@ -9,7 +9,6 @@ ] }, "includeLayers": { - "default": false, "type": "boolean" } }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index c21d146a4a..e714c0975d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -188,6 +188,25 @@ } ] }, + "AutoCompactTokenLimitScope": { + "description": "Selects which part of the active context is charged against `model_auto_compact_token_limit`.", + "oneOf": [ + { + "description": "Count the full active context against the limit.", + "enum": [ + "total" + ], + "type": "string" + }, + { + "description": "Count sampled output and later growth after the carried window prefix.", + "enum": [ + "body_after_prefix" + ], + "type": "string" + } + ] + }, "Config": { "additionalProperties": true, "properties": { @@ -228,6 +247,13 @@ "null" ] }, + "desktop": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, "developer_instructions": { "type": [ "string", @@ -273,6 +299,16 @@ "null" ] }, + "model_auto_compact_token_limit_scope": { + "anyOf": [ + { + "$ref": "#/definitions/AutoCompactTokenLimitScope" + }, + { + "type": "null" + } + ] + }, "model_context_window": { "format": "int64", "type": [ @@ -316,19 +352,6 @@ } ] }, - "profile": { - "type": [ - "string", - "null" - ] - }, - "profiles": { - "additionalProperties": { - "$ref": "#/definitions/ProfileV2" - }, - "default": {}, - "type": "object" - }, "review_model": { "type": [ "string", @@ -475,6 +498,33 @@ "title": "SystemConfigLayerSource", "type": "object" }, + { + "description": "Enterprise-managed config layer delivered by the cloud config bundle.", + "properties": { + "id": { + "description": "Stable identifier for the delivered layer.", + "type": "string" + }, + "name": { + "description": "Admin-facing name for the delivered layer. This is surfaced in diagnostics so users know which cloud layer needs administrator attention.", + "type": "string" + }, + "type": { + "enum": [ + "enterpriseManaged" + ], + "title": "EnterpriseManagedConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "id", + "name", + "type" + ], + "title": "EnterpriseManagedConfigLayerSource", + "type": "object" + }, { "description": "User config layer from $CODEX_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory", "properties": { @@ -606,107 +656,6 @@ ], "type": "string" }, - "ProfileV2": { - "additionalProperties": true, - "properties": { - "approval_policy": { - "anyOf": [ - { - "$ref": "#/definitions/AskForApproval" - }, - { - "type": "null" - } - ] - }, - "approvals_reviewer": { - "anyOf": [ - { - "$ref": "#/definitions/ApprovalsReviewer" - }, - { - "type": "null" - } - ], - "description": "[UNSTABLE] Optional profile-level override for where approval requests are routed for review. If omitted, the enclosing config default is used." - }, - "chatgpt_base_url": { - "type": [ - "string", - "null" - ] - }, - "model": { - "type": [ - "string", - "null" - ] - }, - "model_provider": { - "type": [ - "string", - "null" - ] - }, - "model_reasoning_effort": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningEffort" - }, - { - "type": "null" - } - ] - }, - "model_reasoning_summary": { - "anyOf": [ - { - "$ref": "#/definitions/ReasoningSummary" - }, - { - "type": "null" - } - ] - }, - "model_verbosity": { - "anyOf": [ - { - "$ref": "#/definitions/Verbosity" - }, - { - "type": "null" - } - ] - }, - "service_tier": { - "type": [ - "string", - "null" - ] - }, - "tools": { - "anyOf": [ - { - "$ref": "#/definitions/ToolsV2" - }, - { - "type": "null" - } - ] - }, - "web_search": { - "anyOf": [ - { - "$ref": "#/definitions/WebSearchMode" - }, - { - "type": "null" - } - ] - } - }, - "type": "object" - }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index 41bd337dec..0b8170d659 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -60,8 +60,25 @@ } ] }, + "ComputerUseRequirements": { + "properties": { + "allowLockedComputerUse": { + "type": [ + "boolean", + "null" + ] + } + }, + "type": "object" + }, "ConfigRequirements": { "properties": { + "allowAppshots": { + "type": [ + "boolean", + "null" + ] + }, "allowManagedHooksOnly": { "type": [ "boolean", @@ -77,6 +94,15 @@ "null" ] }, + "allowedPermissions": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "allowedSandboxModes": { "items": { "$ref": "#/definitions/SandboxMode" @@ -95,6 +121,25 @@ "null" ] }, + "allowedWindowsSandboxImplementations": { + "items": { + "$ref": "#/definitions/WindowsSandboxSetupMode" + }, + "type": [ + "array", + "null" + ] + }, + "computerUse": { + "anyOf": [ + { + "$ref": "#/definitions/ComputerUseRequirements" + }, + { + "type": "null" + } + ] + }, "enforceResidency": { "anyOf": [ { @@ -261,6 +306,18 @@ }, "type": "array" }, + "SubagentStart": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, + "SubagentStop": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, "UserPromptSubmit": { "items": { "$ref": "#/definitions/ConfiguredHookMatcherGroup" @@ -288,6 +345,8 @@ "PreToolUse", "SessionStart", "Stop", + "SubagentStart", + "SubagentStop", "UserPromptSubmit" ], "type": "object" @@ -410,7 +469,7 @@ "NetworkUnixSocketPermission": { "enum": [ "allow", - "none" + "deny" ], "type": "string" }, @@ -435,6 +494,13 @@ "live" ], "type": "string" + }, + "WindowsSandboxSetupMode": { + "enum": [ + "elevated", + "unelevated" + ], + "type": "string" } }, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigWriteResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigWriteResponse.json index 37816463b6..d1a2254a92 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigWriteResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigWriteResponse.json @@ -73,6 +73,33 @@ "title": "SystemConfigLayerSource", "type": "object" }, + { + "description": "Enterprise-managed config layer delivered by the cloud config bundle.", + "properties": { + "id": { + "description": "Stable identifier for the delivered layer.", + "type": "string" + }, + "name": { + "description": "Admin-facing name for the delivered layer. This is surfaced in diagnostics so users know which cloud layer needs administrator attention.", + "type": "string" + }, + "type": { + "enum": [ + "enterpriseManaged" + ], + "title": "EnterpriseManagedConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "id", + "name", + "type" + ], + "title": "EnterpriseManagedConfigLayerSource", + "type": "object" + }, { "description": "User config layer from $CODEX_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListParams.json b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListParams.json index ab562edbf2..13fa2efb98 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureListParams.json @@ -16,6 +16,13 @@ "integer", "null" ] + }, + "threadId": { + "description": "Optional loaded thread id. Pass this when showing feature state for an existing thread so enablement is computed from that thread's refreshed config, including project-local config for the thread's cwd.", + "type": [ + "string", + "null" + ] } }, "title": "ExperimentalFeatureListParams", diff --git a/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json b/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json index 07c2098606..3bf0c62194 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/FeedbackUploadParams.json @@ -39,8 +39,7 @@ } }, "required": [ - "classification", - "includeLogs" + "classification" ], "title": "FeedbackUploadParams", "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountParams.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountParams.json index ca18a451e9..445e90c101 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountParams.json @@ -2,7 +2,6 @@ "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "refreshToken": { - "default": false, "description": "When `true`, requests a proactive token refresh before returning.\n\nIn managed auth mode this triggers the normal refresh-token flow. In external auth mode this flag is ignored. Clients should refresh tokens themselves and call `account/login/start` with `chatgptAuthTokens`.", "type": "boolean" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json index f63e6a5cfe..8684bf9ae5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json @@ -14,6 +14,8 @@ "postCompact", "sessionStart", "userPromptSubmit", + "subagentStart", + "subagentStop", "stop" ], "type": "string" @@ -164,6 +166,7 @@ "sessionFlags", "plugin", "cloudRequirements", + "cloudManagedConfig", "legacyManagedConfigFile", "legacyManagedConfigMdm", "unknown" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json index f8eeecfe4d..5b2750d780 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json @@ -14,6 +14,8 @@ "postCompact", "sessionStart", "userPromptSubmit", + "subagentStart", + "subagentStop", "stop" ], "type": "string" @@ -164,6 +166,7 @@ "sessionFlags", "plugin", "cloudRequirements", + "cloudManagedConfig", "legacyManagedConfigFile", "legacyManagedConfigMdm", "unknown" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json index f2a7c80cf0..c3288ee0fd 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json @@ -29,6 +29,8 @@ "postCompact", "sessionStart", "userPromptSubmit", + "subagentStart", + "subagentStop", "stop" ], "type": "string" @@ -128,6 +130,7 @@ "sessionFlags", "plugin", "cloudRequirements", + "cloudManagedConfig", "legacyManagedConfigFile", "legacyManagedConfigMdm", "unknown" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index 6909415c2a..8ba25f9a07 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -285,6 +285,15 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "auto", + "low", + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -491,6 +500,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" @@ -793,6 +808,12 @@ "null" ] }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -1179,6 +1200,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1199,6 +1231,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json index 991d4de050..a366c99a41 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json @@ -69,7 +69,7 @@ "enum": [ "read", "write", - "none" + "deny" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json index 75ffeb753a..bc081c7be9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json @@ -62,7 +62,7 @@ "enum": [ "read", "write", - "none" + "deny" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index 758ceba32d..70fc43f7b8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -285,6 +285,15 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "auto", + "low", + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -491,6 +500,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" @@ -793,6 +808,12 @@ "null" ] }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -1179,6 +1200,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1199,6 +1231,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusParams.json b/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusParams.json index 52149d9fb8..19dad86adf 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusParams.json @@ -36,6 +36,12 @@ "integer", "null" ] + }, + "threadId": { + "type": [ + "string", + "null" + ] } }, "title": "ListMcpServerStatusParams", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusResponse.json index fc181c2702..0dc2f5e2e4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ListMcpServerStatusResponse.json @@ -10,6 +10,47 @@ ], "type": "string" }, + "McpServerInfo": { + "description": "Presentation metadata advertised by an initialized MCP server.", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "icons": { + "items": true, + "type": [ + "array", + "null" + ] + }, + "name": { + "type": "string" + }, + "title": { + "type": [ + "string", + "null" + ] + }, + "version": { + "type": "string" + }, + "websiteUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name", + "version" + ], + "type": "object" + }, "McpServerStatus": { "properties": { "authStatus": { @@ -30,6 +71,16 @@ }, "type": "array" }, + "serverInfo": { + "anyOf": [ + { + "$ref": "#/definitions/McpServerInfo" + }, + { + "type": "null" + } + ] + }, "tools": { "additionalProperties": { "$ref": "#/definitions/Tool" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json index c0221805eb..32ec2e7679 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json @@ -43,6 +43,14 @@ "defaultReasoningEffort": { "$ref": "#/definitions/ReasoningEffort" }, + "defaultServiceTier": { + "default": null, + "description": "Catalog default service tier id for this model, when one is configured.", + "type": [ + "string", + "null" + ] + }, "description": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListParams.json b/codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListParams.json new file mode 100644 index 0000000000..402dab6221 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListParams.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cursor": { + "description": "Opaque pagination cursor returned by a previous call.", + "type": [ + "string", + "null" + ] + }, + "cwd": { + "description": "Optional working directory to resolve project config layers.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "description": "Optional page size; defaults to the full result set.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + } + }, + "title": "PermissionProfileListParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListResponse.json new file mode 100644 index 0000000000..4d5a47f8d5 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListResponse.json @@ -0,0 +1,44 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "PermissionProfileSummary": { + "properties": { + "description": { + "description": "Optional user-facing description for display in clients.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Available permission profile identifier.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + } + }, + "properties": { + "data": { + "items": { + "$ref": "#/definitions/PermissionProfileSummary" + }, + "type": "array" + }, + "nextCursor": { + "description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "title": "PermissionProfileListResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledParams.json new file mode 100644 index 0000000000..f3ec95a5da --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledParams.json @@ -0,0 +1,33 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, + "properties": { + "cwds": { + "description": "Optional working directories used to discover repo marketplaces.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": [ + "array", + "null" + ] + }, + "installSuggestionPluginNames": { + "description": "Additional uninstalled plugin names that should be returned when present locally. This is used by mention surfaces that intentionally expose install entrypoints.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + } + }, + "title": "PluginInstalledParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json new file mode 100644 index 0000000000..ffe20e8c55 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json @@ -0,0 +1,525 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "MarketplaceInterface": { + "properties": { + "displayName": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "MarketplaceLoadErrorInfo": { + "properties": { + "marketplacePath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "message": { + "type": "string" + } + }, + "required": [ + "marketplacePath", + "message" + ], + "type": "object" + }, + "PluginAuthPolicy": { + "enum": [ + "ON_INSTALL", + "ON_USE" + ], + "type": "string" + }, + "PluginAvailability": { + "oneOf": [ + { + "enum": [ + "DISABLED_BY_ADMIN" + ], + "type": "string" + }, + { + "description": "Plugin-service currently sends `\"ENABLED\"` for available remote plugins. Codex app-server exposes `\"AVAILABLE\"` in its API; the alias keeps decoding compatible with that upstream response.", + "enum": [ + "AVAILABLE" + ], + "type": "string" + } + ] + }, + "PluginInstallPolicy": { + "enum": [ + "NOT_AVAILABLE", + "AVAILABLE", + "INSTALLED_BY_DEFAULT" + ], + "type": "string" + }, + "PluginInterface": { + "properties": { + "brandColor": { + "type": [ + "string", + "null" + ] + }, + "capabilities": { + "items": { + "type": "string" + }, + "type": "array" + }, + "category": { + "type": [ + "string", + "null" + ] + }, + "composerIcon": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local composer icon path, resolved from the installed plugin package." + }, + "composerIconUrl": { + "description": "Remote composer icon URL from the plugin catalog.", + "type": [ + "string", + "null" + ] + }, + "defaultPrompt": { + "description": "Starter prompts for the plugin. Capped at 3 entries with a maximum of 128 characters per entry.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "developerName": { + "type": [ + "string", + "null" + ] + }, + "displayName": { + "type": [ + "string", + "null" + ] + }, + "logo": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local logo path, resolved from the installed plugin package." + }, + "logoUrl": { + "description": "Remote logo URL from the plugin catalog.", + "type": [ + "string", + "null" + ] + }, + "longDescription": { + "type": [ + "string", + "null" + ] + }, + "privacyPolicyUrl": { + "type": [ + "string", + "null" + ] + }, + "screenshotUrls": { + "description": "Remote screenshot URLs from the plugin catalog.", + "items": { + "type": "string" + }, + "type": "array" + }, + "screenshots": { + "description": "Local screenshot paths, resolved from the installed plugin package.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "shortDescription": { + "type": [ + "string", + "null" + ] + }, + "termsOfServiceUrl": { + "type": [ + "string", + "null" + ] + }, + "websiteUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "capabilities", + "screenshotUrls", + "screenshots" + ], + "type": "object" + }, + "PluginMarketplaceEntry": { + "properties": { + "interface": { + "anyOf": [ + { + "$ref": "#/definitions/MarketplaceInterface" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "path": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local marketplace file path when the marketplace is backed by a local file. Remote-only catalog marketplaces do not have a local path." + }, + "plugins": { + "items": { + "$ref": "#/definitions/PluginSummary" + }, + "type": "array" + } + }, + "required": [ + "name", + "plugins" + ], + "type": "object" + }, + "PluginShareContext": { + "properties": { + "creatorAccountUserId": { + "type": [ + "string", + "null" + ] + }, + "creatorName": { + "type": [ + "string", + "null" + ] + }, + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, + "remotePluginId": { + "type": "string" + }, + "remoteVersion": { + "default": null, + "description": "Version of the remote shared plugin release when available.", + "type": [ + "string", + "null" + ] + }, + "sharePrincipals": { + "items": { + "$ref": "#/definitions/PluginSharePrincipal" + }, + "type": [ + "array", + "null" + ] + }, + "shareUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "remotePluginId" + ], + "type": "object" + }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, + "PluginSharePrincipal": { + "properties": { + "name": { + "type": "string" + }, + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginSharePrincipalRole" + } + }, + "required": [ + "name", + "principalId", + "principalType", + "role" + ], + "type": "object" + }, + "PluginSharePrincipalRole": { + "enum": [ + "reader", + "editor", + "owner" + ], + "type": "string" + }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, + "PluginSource": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": { + "enum": [ + "local" + ], + "title": "LocalPluginSourceType", + "type": "string" + } + }, + "required": [ + "path", + "type" + ], + "title": "LocalPluginSource", + "type": "object" + }, + { + "properties": { + "path": { + "type": [ + "string", + "null" + ] + }, + "refName": { + "type": [ + "string", + "null" + ] + }, + "sha": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "git" + ], + "title": "GitPluginSourceType", + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "type", + "url" + ], + "title": "GitPluginSource", + "type": "object" + }, + { + "description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.", + "properties": { + "type": { + "enum": [ + "remote" + ], + "title": "RemotePluginSourceType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "RemotePluginSource", + "type": "object" + } + ] + }, + "PluginSummary": { + "properties": { + "authPolicy": { + "$ref": "#/definitions/PluginAuthPolicy" + }, + "availability": { + "allOf": [ + { + "$ref": "#/definitions/PluginAvailability" + } + ], + "default": "AVAILABLE", + "description": "Availability state for installing and using the plugin." + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "installPolicy": { + "$ref": "#/definitions/PluginInstallPolicy" + }, + "installed": { + "type": "boolean" + }, + "interface": { + "anyOf": [ + { + "$ref": "#/definitions/PluginInterface" + }, + { + "type": "null" + } + ] + }, + "keywords": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "localVersion": { + "default": null, + "description": "Version of the locally materialized plugin package when available.", + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "remotePluginId": { + "description": "Backend remote plugin identifier when available.", + "type": [ + "string", + "null" + ] + }, + "shareContext": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareContext" + }, + { + "type": "null" + } + ], + "description": "Remote sharing context associated with this plugin when available." + }, + "source": { + "$ref": "#/definitions/PluginSource" + } + }, + "required": [ + "authPolicy", + "enabled", + "id", + "installPolicy", + "installed", + "name", + "source" + ], + "type": "object" + } + }, + "properties": { + "marketplaceLoadErrors": { + "default": [], + "items": { + "$ref": "#/definitions/MarketplaceLoadErrorInfo" + }, + "type": "array" + }, + "marketplaces": { + "items": { + "$ref": "#/definitions/PluginMarketplaceEntry" + }, + "type": "array" + } + }, + "required": [ + "marketplaces" + ], + "title": "PluginInstalledResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json index 65b1b4e88d..9c15ed5a7c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json @@ -8,6 +8,7 @@ "PluginListMarketplaceKind": { "enum": [ "local", + "vertical", "workspace-directory", "shared-with-me" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index 0c9f897dc9..c9fe43c34f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -46,6 +46,8 @@ "postCompact", "sessionStart", "userPromptSubmit", + "subagentStart", + "subagentStop", "stop" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index 6973d15baa..f82acbae64 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -140,6 +140,26 @@ ], "title": "InputImageFunctionCallOutputContentItem", "type": "object" + }, + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentFunctionCallOutputContentItem", + "type": "object" } ] }, @@ -732,6 +752,22 @@ "title": "CompactionResponseItem", "type": "object" }, + { + "properties": { + "type": { + "enum": [ + "compaction_trigger" + ], + "title": "CompactionTriggerResponseItemType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "CompactionTriggerResponseItem", + "type": "object" + }, { "properties": { "encrypted_content": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/RemoteControlStatusChangedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RemoteControlStatusChangedNotification.json index 85be3316d7..2f305c7d78 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RemoteControlStatusChangedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RemoteControlStatusChangedNotification.json @@ -22,12 +22,16 @@ "installationId": { "type": "string" }, + "serverName": { + "type": "string" + }, "status": { "$ref": "#/definitions/RemoteControlConnectionStatus" } }, "required": [ "installationId", + "serverName", "status" ], "title": "RemoteControlStatusChangedNotification", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 9afd1ae514..a644ce8c4e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -422,6 +422,15 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "auto", + "low", + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -635,6 +644,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" @@ -937,6 +952,12 @@ "null" ] }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -1452,6 +1473,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1472,6 +1504,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/SkillsExtraRootsSetParams.json b/codex-rs/app-server-protocol/schema/json/v2/SkillsExtraRootsSetParams.json new file mode 100644 index 0000000000..9992cdf8b4 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/SkillsExtraRootsSetParams.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, + "properties": { + "extraRoots": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + } + }, + "required": [ + "extraRoots" + ], + "title": "SkillsExtraRootsSetParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/SkillsExtraRootsSetResponse.json b/codex-rs/app-server-protocol/schema/json/v2/SkillsExtraRootsSetResponse.json new file mode 100644 index 0000000000..cf0792c748 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/SkillsExtraRootsSetResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "SkillsExtraRootsSetResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json index 29d67403cd..9d2f834dd8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -1,10 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { - "AbsolutePathBuf": { - "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - "type": "string" - }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ @@ -64,65 +60,6 @@ } ] }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "SandboxMode": { "enum": [ "read-only", @@ -140,7 +77,7 @@ "type": "string" } }, - "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", + "description": "There are two ways to fork a thread: 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. 2. By path: load the thread from disk by path and fork it into a new thread.\n\nIf using a non-empty path, the thread_id param will be ignored. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", "properties": { "approvalPolicy": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index ce2bbac78b..32a6e8b645 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -9,7 +9,7 @@ "properties": { "extends": { "default": null, - "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "description": "Parent profile identifier from the selected permissions profile's `extends` setting, when present.", "type": [ "string", "null" @@ -18,14 +18,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -33,31 +25,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AgentPath": { "type": "string" }, @@ -503,202 +470,6 @@ ], "type": "string" }, - "FileSystemAccessMode": { - "enum": [ - "read", - "write", - "none" - ], - "type": "string" - }, - "FileSystemPath": { - "oneOf": [ - { - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "path" - ], - "title": "PathFileSystemPathType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "PathFileSystemPath", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": "string" - }, - "type": { - "enum": [ - "glob_pattern" - ], - "title": "GlobPatternFileSystemPathType", - "type": "string" - } - }, - "required": [ - "pattern", - "type" - ], - "title": "GlobPatternFileSystemPath", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "special" - ], - "title": "SpecialFileSystemPathType", - "type": "string" - }, - "value": { - "$ref": "#/definitions/FileSystemSpecialPath" - } - }, - "required": [ - "type", - "value" - ], - "title": "SpecialFileSystemPath", - "type": "object" - } - ] - }, - "FileSystemSandboxEntry": { - "properties": { - "access": { - "$ref": "#/definitions/FileSystemAccessMode" - }, - "path": { - "$ref": "#/definitions/FileSystemPath" - } - }, - "required": [ - "access", - "path" - ], - "type": "object" - }, - "FileSystemSpecialPath": { - "oneOf": [ - { - "properties": { - "kind": { - "enum": [ - "root" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "RootFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "minimal" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "MinimalFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "project_roots" - ], - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind" - ], - "title": "KindFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "tmpdir" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "TmpdirFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "slash_tmp" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "SlashTmpFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "unknown" - ], - "type": "string" - }, - "path": { - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind", - "path" - ], - "type": "object" - } - ] - }, "FileUpdateChange": { "properties": { "diff": { @@ -756,6 +527,15 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "auto", + "low", + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -937,135 +717,6 @@ } ] }, - "PermissionProfile": { - "oneOf": [ - { - "description": "Codex owns sandbox construction for this profile.", - "properties": { - "fileSystem": { - "$ref": "#/definitions/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "managed" - ], - "title": "ManagedPermissionProfileType", - "type": "string" - } - }, - "required": [ - "fileSystem", - "network", - "type" - ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", - "type": "object" - } - ] - }, - "PermissionProfileFileSystemPermissions": { - "oneOf": [ - { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", - "type": "object" - } - ] - }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -1393,6 +1044,13 @@ "null" ] }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ @@ -1500,6 +1158,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" @@ -1802,6 +1466,12 @@ "null" ] }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -2400,6 +2070,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -2420,6 +2101,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, @@ -2635,7 +2327,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance." }, "serviceTier": { "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalClearParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalClearParams.json new file mode 100644 index 0000000000..99b32a7316 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalClearParams.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadGoalClearParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalClearResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalClearResponse.json new file mode 100644 index 0000000000..42c3a50ac1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalClearResponse.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "cleared": { + "type": "boolean" + } + }, + "required": [ + "cleared" + ], + "title": "ThreadGoalClearResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalGetParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalGetParams.json new file mode 100644 index 0000000000..631e06a990 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalGetParams.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadGoalGetParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalGetResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalGetResponse.json new file mode 100644 index 0000000000..7627276e11 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalGetResponse.json @@ -0,0 +1,76 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ThreadGoal": { + "properties": { + "createdAt": { + "format": "int64", + "type": "integer" + }, + "objective": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/ThreadGoalStatus" + }, + "threadId": { + "type": "string" + }, + "timeUsedSeconds": { + "format": "int64", + "type": "integer" + }, + "tokenBudget": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "tokensUsed": { + "format": "int64", + "type": "integer" + }, + "updatedAt": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "createdAt", + "objective", + "status", + "threadId", + "timeUsedSeconds", + "tokensUsed", + "updatedAt" + ], + "type": "object" + }, + "ThreadGoalStatus": { + "enum": [ + "active", + "paused", + "blocked", + "usageLimited", + "budgetLimited", + "complete" + ], + "type": "string" + } + }, + "properties": { + "goal": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadGoal" + }, + { + "type": "null" + } + ] + } + }, + "title": "ThreadGoalGetResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalSetParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalSetParams.json new file mode 100644 index 0000000000..0087827c21 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalSetParams.json @@ -0,0 +1,49 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ThreadGoalStatus": { + "enum": [ + "active", + "paused", + "blocked", + "usageLimited", + "budgetLimited", + "complete" + ], + "type": "string" + } + }, + "properties": { + "objective": { + "type": [ + "string", + "null" + ] + }, + "status": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadGoalStatus" + }, + { + "type": "null" + } + ] + }, + "threadId": { + "type": "string" + }, + "tokenBudget": { + "format": "int64", + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "threadId" + ], + "title": "ThreadGoalSetParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalSetResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalSetResponse.json new file mode 100644 index 0000000000..a0d17a84a2 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalSetResponse.json @@ -0,0 +1,72 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ThreadGoal": { + "properties": { + "createdAt": { + "format": "int64", + "type": "integer" + }, + "objective": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/ThreadGoalStatus" + }, + "threadId": { + "type": "string" + }, + "timeUsedSeconds": { + "format": "int64", + "type": "integer" + }, + "tokenBudget": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "tokensUsed": { + "format": "int64", + "type": "integer" + }, + "updatedAt": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "createdAt", + "objective", + "status", + "threadId", + "timeUsedSeconds", + "tokensUsed", + "updatedAt" + ], + "type": "object" + }, + "ThreadGoalStatus": { + "enum": [ + "active", + "paused", + "blocked", + "usageLimited", + "budgetLimited", + "complete" + ], + "type": "string" + } + }, + "properties": { + "goal": { + "$ref": "#/definitions/ThreadGoal" + } + }, + "required": [ + "goal" + ], + "title": "ThreadGoalSetResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalUpdatedNotification.json index 52a2e905a2..cbb04eaab2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalUpdatedNotification.json @@ -51,6 +51,8 @@ "enum": [ "active", "paused", + "blocked", + "usageLimited", "budgetLimited", "complete" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index ba9f31126b..b6409dd906 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -448,6 +448,15 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "auto", + "low", + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -850,6 +859,13 @@ "null" ] }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ @@ -957,6 +973,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" @@ -1259,6 +1281,12 @@ "null" ] }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -1857,6 +1885,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1877,6 +1916,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 4a1f3cb797..a3872fe45b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -448,6 +448,15 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "auto", + "low", + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -850,6 +859,13 @@ "null" ] }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ @@ -957,6 +973,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" @@ -1259,6 +1281,12 @@ "null" ] }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -1857,6 +1885,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1877,6 +1916,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadParams.json index f5e5503cc0..5fb1bcc17b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadParams.json @@ -2,7 +2,6 @@ "$schema": "http://json-schema.org/draft-07/schema#", "properties": { "includeTurns": { - "default": false, "description": "When true, include turns and their items from rollout history.", "type": "boolean" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index bbdbc88429..cba0d53247 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -448,6 +448,15 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "auto", + "low", + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -850,6 +859,13 @@ "null" ] }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ @@ -957,6 +973,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" @@ -1259,6 +1281,12 @@ "null" ] }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -1857,6 +1885,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1877,6 +1916,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 5f07fe0149..364cb36617 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -1,10 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { - "AbsolutePathBuf": { - "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", - "type": "string" - }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ @@ -203,6 +199,26 @@ ], "title": "InputImageFunctionCallOutputContentItem", "type": "object" + }, + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": [ + "encrypted_content" + ], + "title": "EncryptedContentFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": [ + "encrypted_content", + "type" + ], + "title": "EncryptedContentFunctionCallOutputContentItem", + "type": "object" } ] }, @@ -298,65 +314,6 @@ } ] }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", @@ -862,6 +819,22 @@ "title": "CompactionResponseItem", "type": "object" }, + { + "properties": { + "type": { + "enum": [ + "compaction_trigger" + ], + "title": "CompactionTriggerResponseItemType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "CompactionTriggerResponseItem", + "type": "object" + }, { "properties": { "encrypted_content": { @@ -1010,9 +983,77 @@ "danger-full-access" ], "type": "string" + }, + "SortDirection": { + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, + "ThreadResumeInitialTurnsPageParams": { + "properties": { + "itemsView": { + "anyOf": [ + { + "$ref": "#/definitions/TurnItemsView" + }, + { + "type": "null" + } + ], + "description": "How much item detail to include for each returned turn; defaults to summary." + }, + "limit": { + "description": "Optional turn page size.", + "format": "uint32", + "minimum": 0.0, + "type": [ + "integer", + "null" + ] + }, + "sortDirection": { + "anyOf": [ + { + "$ref": "#/definitions/SortDirection" + }, + { + "type": "null" + } + ], + "description": "Optional turn pagination direction; defaults to descending." + } + }, + "type": "object" + }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] } }, - "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nThe precedence is: history > path > thread_id. If using history or path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", + "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nFor non-running threads, the precedence is: history > non-empty path > thread_id. If using history or a non-empty path for a non-running thread, the thread_id param will be ignored.\n\nIf thread_id identifies a running thread, app-server rejoins that thread and treats a non-empty path as a consistency check against the active rollout path. Empty string path values are treated as absent.\n\nPrefer using thread_id whenever possible.", "properties": { "approvalPolicy": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index e977c2a9a6..557b8ec230 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -9,7 +9,7 @@ "properties": { "extends": { "default": null, - "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "description": "Parent profile identifier from the selected permissions profile's `extends` setting, when present.", "type": [ "string", "null" @@ -18,14 +18,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -33,31 +25,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AgentPath": { "type": "string" }, @@ -503,202 +470,6 @@ ], "type": "string" }, - "FileSystemAccessMode": { - "enum": [ - "read", - "write", - "none" - ], - "type": "string" - }, - "FileSystemPath": { - "oneOf": [ - { - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "path" - ], - "title": "PathFileSystemPathType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "PathFileSystemPath", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": "string" - }, - "type": { - "enum": [ - "glob_pattern" - ], - "title": "GlobPatternFileSystemPathType", - "type": "string" - } - }, - "required": [ - "pattern", - "type" - ], - "title": "GlobPatternFileSystemPath", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "special" - ], - "title": "SpecialFileSystemPathType", - "type": "string" - }, - "value": { - "$ref": "#/definitions/FileSystemSpecialPath" - } - }, - "required": [ - "type", - "value" - ], - "title": "SpecialFileSystemPath", - "type": "object" - } - ] - }, - "FileSystemSandboxEntry": { - "properties": { - "access": { - "$ref": "#/definitions/FileSystemAccessMode" - }, - "path": { - "$ref": "#/definitions/FileSystemPath" - } - }, - "required": [ - "access", - "path" - ], - "type": "object" - }, - "FileSystemSpecialPath": { - "oneOf": [ - { - "properties": { - "kind": { - "enum": [ - "root" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "RootFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "minimal" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "MinimalFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "project_roots" - ], - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind" - ], - "title": "KindFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "tmpdir" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "TmpdirFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "slash_tmp" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "SlashTmpFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "unknown" - ], - "type": "string" - }, - "path": { - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind", - "path" - ], - "type": "object" - } - ] - }, "FileUpdateChange": { "properties": { "diff": { @@ -756,6 +527,15 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "auto", + "low", + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -937,135 +717,6 @@ } ] }, - "PermissionProfile": { - "oneOf": [ - { - "description": "Codex owns sandbox construction for this profile.", - "properties": { - "fileSystem": { - "$ref": "#/definitions/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "managed" - ], - "title": "ManagedPermissionProfileType", - "type": "string" - } - }, - "required": [ - "fileSystem", - "network", - "type" - ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", - "type": "object" - } - ] - }, - "PermissionProfileFileSystemPermissions": { - "oneOf": [ - { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", - "type": "object" - } - ] - }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -1393,6 +1044,13 @@ "null" ] }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ @@ -1500,6 +1158,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" @@ -1802,6 +1466,12 @@ "null" ] }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -2368,6 +2038,32 @@ ], "type": "string" }, + "TurnsPage": { + "properties": { + "backwardsCursor": { + "type": [ + "string", + "null" + ] + }, + "data": { + "items": { + "$ref": "#/definitions/Turn" + }, + "type": "array" + }, + "nextCursor": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "data" + ], + "type": "object" + }, "UserInput": { "oneOf": [ { @@ -2400,6 +2096,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -2420,6 +2127,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, @@ -2635,7 +2353,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance." }, "serviceTier": { "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index f172287b46..0f693ed90d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -448,6 +448,15 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "auto", + "low", + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -850,6 +859,13 @@ "null" ] }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ @@ -957,6 +973,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" @@ -1259,6 +1281,12 @@ "null" ] }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -1857,6 +1885,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1877,6 +1916,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json new file mode 100644 index 0000000000..42a76ae364 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json @@ -0,0 +1,381 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier from the selected permissions profile's `extends` setting, when present.", + "type": [ + "string", + "null" + ] + }, + "id": { + "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + "enum": [ + "user", + "auto_review", + "guardian_subagent" + ], + "type": "string" + }, + "AskForApproval": { + "oneOf": [ + { + "enum": [ + "untrusted", + "on-failure", + "on-request", + "never" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "granular": { + "properties": { + "mcp_elicitations": { + "type": "boolean" + }, + "request_permissions": { + "default": false, + "type": "boolean" + }, + "rules": { + "type": "boolean" + }, + "sandbox_approval": { + "type": "boolean" + }, + "skill_approval": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "mcp_elicitations", + "rules", + "sandbox_approval" + ], + "type": "object" + } + }, + "required": [ + "granular" + ], + "title": "GranularAskForApproval", + "type": "object" + } + ] + }, + "CollaborationMode": { + "description": "Collaboration mode for a Codex session.", + "properties": { + "mode": { + "$ref": "#/definitions/ModeKind" + }, + "settings": { + "$ref": "#/definitions/Settings" + } + }, + "required": [ + "mode", + "settings" + ], + "type": "object" + }, + "ModeKind": { + "description": "Initial collaboration mode to use when the TUI starts.", + "enum": [ + "plan", + "default" + ], + "type": "string" + }, + "NetworkAccess": { + "enum": [ + "restricted", + "enabled" + ], + "type": "string" + }, + "Personality": { + "enum": [ + "none", + "friendly", + "pragmatic" + ], + "type": "string" + }, + "ReasoningEffort": { + "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", + "enum": [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh" + ], + "type": "string" + }, + "ReasoningSummary": { + "description": "A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries", + "oneOf": [ + { + "enum": [ + "auto", + "concise", + "detailed" + ], + "type": "string" + }, + { + "description": "Option to disable reasoning summaries.", + "enum": [ + "none" + ], + "type": "string" + } + ] + }, + "SandboxPolicy": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "dangerFullAccess" + ], + "title": "DangerFullAccessSandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "DangerFullAccessSandboxPolicy", + "type": "object" + }, + { + "properties": { + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "enum": [ + "readOnly" + ], + "title": "ReadOnlySandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ReadOnlySandboxPolicy", + "type": "object" + }, + { + "properties": { + "networkAccess": { + "allOf": [ + { + "$ref": "#/definitions/NetworkAccess" + } + ], + "default": "restricted" + }, + "type": { + "enum": [ + "externalSandbox" + ], + "title": "ExternalSandboxSandboxPolicyType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ExternalSandboxSandboxPolicy", + "type": "object" + }, + { + "properties": { + "excludeSlashTmp": { + "default": false, + "type": "boolean" + }, + "excludeTmpdirEnvVar": { + "default": false, + "type": "boolean" + }, + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "enum": [ + "workspaceWrite" + ], + "title": "WorkspaceWriteSandboxPolicyType", + "type": "string" + }, + "writableRoots": { + "default": [], + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + } + }, + "required": [ + "type" + ], + "title": "WorkspaceWriteSandboxPolicy", + "type": "object" + } + ] + }, + "Settings": { + "description": "Settings for a collaboration mode.", + "properties": { + "developer_instructions": { + "type": [ + "string", + "null" + ] + }, + "model": { + "type": "string" + }, + "reasoning_effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "model" + ], + "type": "object" + }, + "ThreadSettings": { + "properties": { + "activePermissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/ActivePermissionProfile" + }, + { + "type": "null" + } + ] + }, + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "approvalsReviewer": { + "$ref": "#/definitions/ApprovalsReviewer" + }, + "collaborationMode": { + "$ref": "#/definitions/CollaborationMode" + }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "personality": { + "anyOf": [ + { + "$ref": "#/definitions/Personality" + }, + { + "type": "null" + } + ] + }, + "sandboxPolicy": { + "$ref": "#/definitions/SandboxPolicy" + }, + "serviceTier": { + "type": [ + "string", + "null" + ] + }, + "summary": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningSummary" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "approvalPolicy", + "approvalsReviewer", + "collaborationMode", + "cwd", + "model", + "modelProvider", + "sandboxPolicy" + ], + "type": "object" + } + }, + "properties": { + "threadId": { + "type": "string" + }, + "threadSettings": { + "$ref": "#/definitions/ThreadSettings" + } + }, + "required": [ + "threadId", + "threadSettings" + ], + "title": "ThreadSettingsUpdatedNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index 9a60049a61..99b25490ab 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -90,65 +90,6 @@ ], "type": "object" }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index b79074cf98..360a4e8a6d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -9,7 +9,7 @@ "properties": { "extends": { "default": null, - "description": "Parent profile identifier once permissions profiles support inheritance. This is currently always `null`.", + "description": "Parent profile identifier from the selected permissions profile's `extends` setting, when present.", "type": [ "string", "null" @@ -18,14 +18,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -33,31 +25,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AgentPath": { "type": "string" }, @@ -503,202 +470,6 @@ ], "type": "string" }, - "FileSystemAccessMode": { - "enum": [ - "read", - "write", - "none" - ], - "type": "string" - }, - "FileSystemPath": { - "oneOf": [ - { - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "path" - ], - "title": "PathFileSystemPathType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "PathFileSystemPath", - "type": "object" - }, - { - "properties": { - "pattern": { - "type": "string" - }, - "type": { - "enum": [ - "glob_pattern" - ], - "title": "GlobPatternFileSystemPathType", - "type": "string" - } - }, - "required": [ - "pattern", - "type" - ], - "title": "GlobPatternFileSystemPath", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "special" - ], - "title": "SpecialFileSystemPathType", - "type": "string" - }, - "value": { - "$ref": "#/definitions/FileSystemSpecialPath" - } - }, - "required": [ - "type", - "value" - ], - "title": "SpecialFileSystemPath", - "type": "object" - } - ] - }, - "FileSystemSandboxEntry": { - "properties": { - "access": { - "$ref": "#/definitions/FileSystemAccessMode" - }, - "path": { - "$ref": "#/definitions/FileSystemPath" - } - }, - "required": [ - "access", - "path" - ], - "type": "object" - }, - "FileSystemSpecialPath": { - "oneOf": [ - { - "properties": { - "kind": { - "enum": [ - "root" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "RootFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "minimal" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "MinimalFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "project_roots" - ], - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind" - ], - "title": "KindFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "tmpdir" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "TmpdirFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "slash_tmp" - ], - "type": "string" - } - }, - "required": [ - "kind" - ], - "title": "SlashTmpFileSystemSpecialPath", - "type": "object" - }, - { - "properties": { - "kind": { - "enum": [ - "unknown" - ], - "type": "string" - }, - "path": { - "type": "string" - }, - "subpath": { - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "kind", - "path" - ], - "type": "object" - } - ] - }, "FileUpdateChange": { "properties": { "diff": { @@ -756,6 +527,15 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "auto", + "low", + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -937,135 +717,6 @@ } ] }, - "PermissionProfile": { - "oneOf": [ - { - "description": "Codex owns sandbox construction for this profile.", - "properties": { - "fileSystem": { - "$ref": "#/definitions/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "managed" - ], - "title": "ManagedPermissionProfileType", - "type": "string" - } - }, - "required": [ - "fileSystem", - "network", - "type" - ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", - "type": "object" - } - ] - }, - "PermissionProfileFileSystemPermissions": { - "oneOf": [ - { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", - "type": "object" - } - ] - }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -1393,6 +1044,13 @@ "null" ] }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ @@ -1500,6 +1158,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" @@ -1802,6 +1466,12 @@ "null" ] }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -2400,6 +2070,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -2420,6 +2101,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, @@ -2635,7 +2327,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance." }, "serviceTier": { "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 30ffa79da2..79f6564856 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -448,6 +448,15 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "auto", + "low", + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -850,6 +859,13 @@ "null" ] }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ @@ -957,6 +973,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" @@ -1259,6 +1281,12 @@ "null" ] }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -1857,6 +1885,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1877,6 +1916,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index f446118d93..b40b9584a0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -448,6 +448,15 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "auto", + "low", + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -850,6 +859,13 @@ "null" ] }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": [ + "string", + "null" + ] + }, "path": { "description": "[UNSTABLE] Path to the thread on disk.", "type": [ @@ -957,6 +973,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" @@ -1259,6 +1281,12 @@ "null" ] }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -1857,6 +1885,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1877,6 +1916,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index e5e2558e9c..6c64eb3275 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -422,6 +422,15 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "auto", + "low", + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -635,6 +644,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" @@ -937,6 +952,12 @@ "null" ] }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -1452,6 +1473,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1472,6 +1504,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index 1ef33d4301..f997976aca 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -5,6 +5,28 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "AdditionalContextEntry": { + "properties": { + "kind": { + "$ref": "#/definitions/AdditionalContextKind" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + "AdditionalContextKind": { + "enum": [ + "untrusted", + "application" + ], + "type": "string" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ @@ -99,6 +121,15 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "auto", + "low", + "high", + "original" + ], + "type": "string" + }, "ModeKind": { "description": "Initial collaboration mode to use when the TUI starts.", "enum": [ @@ -114,65 +145,6 @@ ], "type": "string" }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", @@ -410,6 +382,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -430,6 +413,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, @@ -522,6 +516,12 @@ ], "description": "Override where approval requests are routed for review on this turn and subsequent turns." }, + "clientUserMessageId": { + "type": [ + "string", + "null" + ] + }, "cwd": { "description": "Override the working directory for this turn and subsequent turns.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index a2eff7fdd8..5fe9754576 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -422,6 +422,15 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "auto", + "low", + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -635,6 +644,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" @@ -937,6 +952,12 @@ "null" ] }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -1452,6 +1473,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1472,6 +1504,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 0952db2aca..1db1497218 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -422,6 +422,15 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "auto", + "low", + "high", + "original" + ], + "type": "string" + }, "McpToolCallError": { "properties": { "message": { @@ -635,6 +644,12 @@ "oneOf": [ { "properties": { + "clientId": { + "type": [ + "string", + "null" + ] + }, "content": { "items": { "$ref": "#/definitions/UserInput" @@ -937,6 +952,12 @@ "null" ] }, + "pluginId": { + "type": [ + "string", + "null" + ] + }, "result": { "anyOf": [ { @@ -1452,6 +1473,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -1472,6 +1504,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json index a064d9e7e3..63317f13ba 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnSteerParams.json @@ -1,6 +1,28 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AdditionalContextEntry": { + "properties": { + "kind": { + "$ref": "#/definitions/AdditionalContextKind" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + "AdditionalContextKind": { + "enum": [ + "untrusted", + "application" + ], + "type": "string" + }, "ByteRange": { "properties": { "end": { @@ -20,6 +42,15 @@ ], "type": "object" }, + "ImageDetail": { + "enum": [ + "auto", + "low", + "high", + "original" + ], + "type": "string" + }, "TextElement": { "properties": { "byteRange": { @@ -75,6 +106,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "type": { "enum": [ "image" @@ -95,6 +137,17 @@ }, { "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, "path": { "type": "string" }, @@ -165,6 +218,12 @@ } }, "properties": { + "clientUserMessageId": { + "type": [ + "string", + "null" + ] + }, "expectedTurnId": { "description": "Required active turn id precondition. The request fails when it does not match the currently active turn.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/typescript/AutoCompactTokenLimitScope.ts b/codex-rs/app-server-protocol/schema/typescript/AutoCompactTokenLimitScope.ts new file mode 100644 index 0000000000..3d9a56c82a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/AutoCompactTokenLimitScope.ts @@ -0,0 +1,9 @@ +// 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. + +/** + * Selects which part of the active context is charged against + * `model_auto_compact_token_limit`. + */ +export type AutoCompactTokenLimitScope = "total" | "body_after_prefix"; diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 5babeeb05a..58b3ee4f90 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -42,7 +42,9 @@ import type { McpServerOauthLoginParams } from "./v2/McpServerOauthLoginParams"; import type { McpServerToolCallParams } from "./v2/McpServerToolCallParams"; import type { ModelListParams } from "./v2/ModelListParams"; import type { ModelProviderCapabilitiesReadParams } from "./v2/ModelProviderCapabilitiesReadParams"; +import type { PermissionProfileListParams } from "./v2/PermissionProfileListParams"; import type { PluginInstallParams } from "./v2/PluginInstallParams"; +import type { PluginInstalledParams } from "./v2/PluginInstalledParams"; import type { PluginListParams } from "./v2/PluginListParams"; import type { PluginReadParams } from "./v2/PluginReadParams"; import type { PluginShareCheckoutParams } from "./v2/PluginShareCheckoutParams"; @@ -55,12 +57,16 @@ import type { PluginUninstallParams } from "./v2/PluginUninstallParams"; import type { ReviewStartParams } from "./v2/ReviewStartParams"; import type { SendAddCreditsNudgeEmailParams } from "./v2/SendAddCreditsNudgeEmailParams"; import type { SkillsConfigWriteParams } from "./v2/SkillsConfigWriteParams"; +import type { SkillsExtraRootsSetParams } from "./v2/SkillsExtraRootsSetParams"; import type { SkillsListParams } from "./v2/SkillsListParams"; import type { ThreadApproveGuardianDeniedActionParams } from "./v2/ThreadApproveGuardianDeniedActionParams"; import type { ThreadArchiveParams } from "./v2/ThreadArchiveParams"; import type { ThreadArtifactCreateParams } from "./v2/ThreadArtifactCreateParams"; import type { ThreadCompactStartParams } from "./v2/ThreadCompactStartParams"; import type { ThreadForkParams } from "./v2/ThreadForkParams"; +import type { ThreadGoalClearParams } from "./v2/ThreadGoalClearParams"; +import type { ThreadGoalGetParams } from "./v2/ThreadGoalGetParams"; +import type { ThreadGoalSetParams } from "./v2/ThreadGoalSetParams"; import type { ThreadInjectItemsParams } from "./v2/ThreadInjectItemsParams"; import type { ThreadListParams } from "./v2/ThreadListParams"; import type { ThreadLoadedListParams } from "./v2/ThreadLoadedListParams"; @@ -81,4 +87,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/artifact/create", id: RequestId, params: ThreadArtifactCreateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/artifact/create", id: RequestId, params: ThreadArtifactCreateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/FunctionCallOutputContentItem.ts b/codex-rs/app-server-protocol/schema/typescript/FunctionCallOutputContentItem.ts index fb2996f1e5..cd18908145 100644 --- a/codex-rs/app-server-protocol/schema/typescript/FunctionCallOutputContentItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/FunctionCallOutputContentItem.ts @@ -7,4 +7,4 @@ import type { ImageDetail } from "./ImageDetail"; * Responses API compatible content items that can be returned by a tool call. * This is a subset of ContentItem with the types we support as function call outputs. */ -export type FunctionCallOutputContentItem = { "type": "input_text", text: string, } | { "type": "input_image", image_url: string, detail?: ImageDetail, }; +export type FunctionCallOutputContentItem = { "type": "input_text", text: string, } | { "type": "input_image", image_url: string, detail?: ImageDetail, } | { "type": "encrypted_content", encrypted_content: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/McpServerInfo.ts b/codex-rs/app-server-protocol/schema/typescript/McpServerInfo.ts new file mode 100644 index 0000000000..a3f6b0e142 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/McpServerInfo.ts @@ -0,0 +1,9 @@ +// 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 { JsonValue } from "./serde_json/JsonValue"; + +/** + * Presentation metadata advertised by an initialized MCP server. + */ +export type McpServerInfo = { name: string, title: string | null, version: string, description: string | null, icons: Array | null, websiteUrl: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts index 6fa9beee25..e5e960ff81 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts @@ -14,4 +14,4 @@ export type ResponseItem = { "type": "message", role: string, content: Array, }; +extends: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts deleted file mode 100644 index 1cbee6868a..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts +++ /dev/null @@ -1,6 +0,0 @@ -// 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 { AbsolutePathBuf } from "../AbsolutePathBuf"; - -export type ActivePermissionProfileModification = { "type": "additionalWritableRoot", path: AbsolutePathBuf, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalContextEntry.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalContextEntry.ts new file mode 100644 index 0000000000..8d959269d4 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalContextEntry.ts @@ -0,0 +1,6 @@ +// 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 { AdditionalContextKind } from "./AdditionalContextKind"; + +export type AdditionalContextEntry = { value: string, kind: AdditionalContextKind, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileNetworkPermissions.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalContextKind.ts similarity index 67% rename from codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileNetworkPermissions.ts rename to codex-rs/app-server-protocol/schema/typescript/v2/AdditionalContextKind.ts index 0b25a769a9..cd60bd7a8d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileNetworkPermissions.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AdditionalContextKind.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type PermissionProfileNetworkPermissions = { enabled: boolean, }; +export type AdditionalContextKind = "untrusted" | "application"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ComputerUseRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ComputerUseRequirements.ts new file mode 100644 index 0000000000..7a82e7aec8 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ComputerUseRequirements.ts @@ -0,0 +1,5 @@ +// 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 ComputerUseRequirements = { allowLockedComputerUse: boolean | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts index 50d652cea5..cc15fb4e72 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts @@ -1,6 +1,7 @@ // 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 { AutoCompactTokenLimitScope } from "../AutoCompactTokenLimitScope"; import type { ForcedLoginMethod } from "../ForcedLoginMethod"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; @@ -11,13 +12,12 @@ import type { AnalyticsConfig } from "./AnalyticsConfig"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { ForcedChatgptWorkspaceIds } from "./ForcedChatgptWorkspaceIds"; -import type { ProfileV2 } from "./ProfileV2"; import type { SandboxMode } from "./SandboxMode"; import type { SandboxWorkspaceWrite } from "./SandboxWorkspaceWrite"; import type { ToolsV2 } from "./ToolsV2"; -export type Config = {model: string | null, review_model: string | null, model_context_window: bigint | null, model_auto_compact_token_limit: bigint | null, model_provider: string | null, approval_policy: AskForApproval | null, /** +export type Config = {model: string | null, review_model: string | null, model_context_window: bigint | null, model_auto_compact_token_limit: bigint | null, model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope | null, model_provider: string | null, approval_policy: AskForApproval | null, /** * [UNSTABLE] Optional default for where approval requests are routed for * review. */ -approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: ForcedChatgptWorkspaceIds | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: string | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); +approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: ForcedChatgptWorkspaceIds | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: string | null, analytics: AnalyticsConfig | null, desktop: { [key in string]?: JsonValue } | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigLayerSource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigLayerSource.ts index 08cb8c6bfd..963c331b5d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigLayerSource.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigLayerSource.ts @@ -8,7 +8,17 @@ export type ConfigLayerSource = { "type": "mdm", domain: string, key: string, } * This is the path to the system config.toml file, though it is not * guaranteed to exist. */ -file: AbsolutePathBuf, } | { "type": "user", +file: AbsolutePathBuf, } | { "type": "enterpriseManaged", +/** + * Stable identifier for the delivered layer. + */ +id: string, +/** + * Admin-facing name for the delivered layer. This is surfaced in + * diagnostics so users know which cloud layer needs administrator + * attention. + */ +name: string, } | { "type": "user", /** * This is the path to the user's config.toml file, though it is not * guaranteed to exist. diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigReadParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigReadParams.ts index 1fd418d182..7acf72c850 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigReadParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigReadParams.ts @@ -2,7 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ConfigReadParams = { includeLayers: boolean, +export type ConfigReadParams = { includeLayers?: boolean, /** * Optional working directory to resolve project config layers. If specified, * return the effective config as seen from that directory (i.e., including any diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts index 1b0625e5e1..3e60e78da5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts @@ -3,7 +3,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { WebSearchMode } from "../WebSearchMode"; import type { AskForApproval } from "./AskForApproval"; +import type { ComputerUseRequirements } from "./ComputerUseRequirements"; import type { ResidencyRequirement } from "./ResidencyRequirement"; import type { SandboxMode } from "./SandboxMode"; +import type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode"; -export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; +export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWindowsSandboxImplementations: Array | null, allowedPermissions: Array | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureListParams.ts index 0a1555d998..c98425b849 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureListParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureListParams.ts @@ -10,4 +10,10 @@ cursor?: string | null, /** * Optional page size; defaults to a reasonable server-side value. */ -limit?: number | null, }; +limit?: number | null, +/** + * Optional loaded thread id. Pass this when showing feature state for an + * existing thread so enablement is computed from that thread's refreshed + * config, including project-local config for the thread's cwd. + */ +threadId?: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts index 86d9de2f0d..2afabd6e4a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FeedbackUploadParams.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type FeedbackUploadParams = { classification: string, reason?: string | null, threadId?: string | null, includeLogs: boolean, extraLogFiles?: Array | null, tags?: { [key in string]?: string } | null, }; +export type FeedbackUploadParams = { classification: string, reason?: string | null, threadId?: string | null, includeLogs?: boolean, extraLogFiles?: Array | null, tags?: { [key in string]?: string } | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FileSystemAccessMode.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FileSystemAccessMode.ts index b1d801fe41..2dac72777e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/FileSystemAccessMode.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FileSystemAccessMode.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type FileSystemAccessMode = "read" | "write" | "none"; +export type FileSystemAccessMode = "read" | "write" | "deny"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountParams.ts index a5c5c25f66..9e82ef5e13 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GetAccountParams.ts @@ -10,4 +10,4 @@ export type GetAccountParams = { * external auth mode this flag is ignored. Clients should refresh tokens * themselves and call `account/login/start` with `chatgptAuthTokens`. */ -refreshToken: boolean, }; +refreshToken?: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts index 91c2def709..477476289d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "preCompact" | "postCompact" | "sessionStart" | "userPromptSubmit" | "stop"; +export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "preCompact" | "postCompact" | "sessionStart" | "userPromptSubmit" | "subagentStart" | "subagentStop" | "stop"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookSource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookSource.ts index 98bbe1e412..5c3cb2eabf 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookSource.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookSource.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type HookSource = "system" | "user" | "project" | "mdm" | "sessionFlags" | "plugin" | "cloudRequirements" | "legacyManagedConfigFile" | "legacyManagedConfigMdm" | "unknown"; +export type HookSource = "system" | "user" | "project" | "mdm" | "sessionFlags" | "plugin" | "cloudRequirements" | "cloudManagedConfig" | "legacyManagedConfigFile" | "legacyManagedConfigMdm" | "unknown"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ListMcpServerStatusParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ListMcpServerStatusParams.ts index fa00b8ea01..2296c73621 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ListMcpServerStatusParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ListMcpServerStatusParams.ts @@ -16,4 +16,4 @@ limit?: number | null, * Controls how much MCP inventory data to fetch for each server. * Defaults to `Full` when omitted. */ -detail?: McpServerStatusDetail | null, }; +detail?: McpServerStatusDetail | null, threadId?: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts index cde0e4a503..1143bd017f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ConfiguredHookMatcherGroup } from "./ConfiguredHookMatcherGroup"; -export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array, PermissionRequest: Array, PostToolUse: Array, PreCompact: Array, PostCompact: Array, SessionStart: Array, UserPromptSubmit: Array, Stop: Array, }; +export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array, PermissionRequest: Array, PostToolUse: Array, PreCompact: Array, PostCompact: Array, SessionStart: Array, UserPromptSubmit: Array, SubagentStart: Array, SubagentStop: Array, Stop: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatus.ts b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatus.ts index 430494e268..d2e99ce96f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatus.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/McpServerStatus.ts @@ -1,9 +1,10 @@ // 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 { McpServerInfo } from "../McpServerInfo"; import type { Resource } from "../Resource"; import type { ResourceTemplate } from "../ResourceTemplate"; import type { Tool } from "../Tool"; import type { McpAuthStatus } from "./McpAuthStatus"; -export type McpServerStatus = { name: string, tools: { [key in string]?: Tool }, resources: Array, resourceTemplates: Array, authStatus: McpAuthStatus, }; +export type McpServerStatus = { name: string, serverInfo: McpServerInfo | null, tools: { [key in string]?: Tool }, resources: Array, resourceTemplates: Array, authStatus: McpAuthStatus, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts index 2354ffbf9e..e9dbf15b0b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts @@ -12,4 +12,8 @@ export type Model = { id: string, model: string, upgrade: string | null, upgrade /** * Deprecated: use `serviceTiers` instead. */ -additionalSpeedTiers: Array, serviceTiers: Array, isDefault: boolean, }; +additionalSpeedTiers: Array, serviceTiers: Array, +/** + * Catalog default service tier id for this model, when one is configured. + */ +defaultServiceTier: string | null, isDefault: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/NetworkUnixSocketPermission.ts b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkUnixSocketPermission.ts index 466c6e5f8f..c5474cbb60 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/NetworkUnixSocketPermission.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/NetworkUnixSocketPermission.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type NetworkUnixSocketPermission = "allow" | "none"; +export type NetworkUnixSocketPermission = "allow" | "deny"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfile.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfile.ts deleted file mode 100644 index 7642c27650..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfile.ts +++ /dev/null @@ -1,7 +0,0 @@ -// 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 { PermissionProfileFileSystemPermissions } from "./PermissionProfileFileSystemPermissions"; -import type { PermissionProfileNetworkPermissions } from "./PermissionProfileNetworkPermissions"; - -export type PermissionProfile = { "type": "managed", network: PermissionProfileNetworkPermissions, fileSystem: PermissionProfileFileSystemPermissions, } | { "type": "disabled" } | { "type": "external", network: PermissionProfileNetworkPermissions, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileFileSystemPermissions.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileFileSystemPermissions.ts deleted file mode 100644 index 29aeceb433..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileFileSystemPermissions.ts +++ /dev/null @@ -1,6 +0,0 @@ -// 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 { FileSystemSandboxEntry } from "./FileSystemSandboxEntry"; - -export type PermissionProfileFileSystemPermissions = { "type": "restricted", entries: Array, globScanMaxDepth?: number, } | { "type": "unrestricted" }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileListParams.ts new file mode 100644 index 0000000000..24582c9507 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileListParams.ts @@ -0,0 +1,17 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PermissionProfileListParams = { +/** + * Opaque pagination cursor returned by a previous call. + */ +cursor?: string | null, +/** + * Optional page size; defaults to the full result set. + */ +limit?: number | null, +/** + * Optional working directory to resolve project config layers. + */ +cwd?: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileListResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileListResponse.ts new file mode 100644 index 0000000000..ba0ccbc0d6 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileListResponse.ts @@ -0,0 +1,11 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PermissionProfileSummary } from "./PermissionProfileSummary"; + +export type PermissionProfileListResponse = { data: Array, +/** + * Opaque cursor to pass to the next call to continue after the last item. + * If None, there are no more items to return. + */ +nextCursor: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts deleted file mode 100644 index a415bd0028..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts +++ /dev/null @@ -1,6 +0,0 @@ -// 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 { PermissionProfileModificationParams } from "./PermissionProfileModificationParams"; - -export type PermissionProfileSelectionParams = { "type": "profile", id: string, modifications?: Array | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSummary.ts new file mode 100644 index 0000000000..9d02fd776b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSummary.ts @@ -0,0 +1,13 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PermissionProfileSummary = { +/** + * Available permission profile identifier. + */ +id: string, +/** + * Optional user-facing description for display in clients. + */ +description: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstalledParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstalledParams.ts new file mode 100644 index 0000000000..83a564921a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstalledParams.ts @@ -0,0 +1,15 @@ +// 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 { AbsolutePathBuf } from "../AbsolutePathBuf"; + +export type PluginInstalledParams = { +/** + * Optional working directories used to discover repo marketplaces. + */ +cwds?: Array | null, +/** + * Additional uninstalled plugin names that should be returned when present locally. + * This is used by mention surfaces that intentionally expose install entrypoints. + */ +installSuggestionPluginNames?: Array | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstalledResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstalledResponse.ts new file mode 100644 index 0000000000..d9713351ab --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstalledResponse.ts @@ -0,0 +1,7 @@ +// 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 { MarketplaceLoadErrorInfo } from "./MarketplaceLoadErrorInfo"; +import type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry"; + +export type PluginInstalledResponse = { marketplaces: Array, marketplaceLoadErrors: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListMarketplaceKind.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListMarketplaceKind.ts index 6ff6161f34..1be75e6f02 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListMarketplaceKind.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListMarketplaceKind.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type PluginListMarketplaceKind = "local" | "workspace-directory" | "shared-with-me"; +export type PluginListMarketplaceKind = "local" | "vertical" | "workspace-directory" | "shared-with-me"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts deleted file mode 100644 index d05038701c..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts +++ /dev/null @@ -1,18 +0,0 @@ -// 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 { ReasoningEffort } from "../ReasoningEffort"; -import type { ReasoningSummary } from "../ReasoningSummary"; -import type { Verbosity } from "../Verbosity"; -import type { WebSearchMode } from "../WebSearchMode"; -import type { JsonValue } from "../serde_json/JsonValue"; -import type { ApprovalsReviewer } from "./ApprovalsReviewer"; -import type { AskForApproval } from "./AskForApproval"; -import type { ToolsV2 } from "./ToolsV2"; - -export type ProfileV2 = {model: string | null, model_provider: string | null, approval_policy: AskForApproval | null, /** - * [UNSTABLE] Optional profile-level override for where approval requests - * are routed for review. If omitted, the enclosing config default is - * used. - */ -approvals_reviewer: ApprovalsReviewer | null, service_tier: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, chatgpt_base_url: string | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlStatusChangedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlStatusChangedNotification.ts index 8c63ab9029..403b0e6457 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlStatusChangedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlStatusChangedNotification.ts @@ -6,4 +6,4 @@ import type { RemoteControlConnectionStatus } from "./RemoteControlConnectionSta /** * Current remote-control connection status and remote identity exposed to clients. */ -export type RemoteControlStatusChangedNotification = { status: RemoteControlConnectionStatus, installationId: string, environmentId: string | null, }; +export type RemoteControlStatusChangedNotification = { status: RemoteControlConnectionStatus, serverName: string, installationId: string, environmentId: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsExtraRootsSetParams.ts similarity index 65% rename from codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts rename to codex-rs/app-server-protocol/schema/typescript/v2/SkillsExtraRootsSetParams.ts index c619edcea8..bcddb9f6f5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsExtraRootsSetParams.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; -export type PermissionProfileModificationParams = { "type": "additionalWritableRoot", path: AbsolutePathBuf, }; +export type SkillsExtraRootsSetParams = { extraRoots: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsExtraRootsSetResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsExtraRootsSetResponse.ts new file mode 100644 index 0000000000..63be08186d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsExtraRootsSetResponse.ts @@ -0,0 +1,5 @@ +// 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 SkillsExtraRootsSetResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts index 517bf1ddf9..e9fcca0744 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts @@ -18,6 +18,10 @@ sessionId: string, * Source thread id when this thread was created by forking another thread. */ forkedFromId: string | null, +/** + * The ID of the parent thread. This will only be set if this thread is a subagent. + */ +parentThreadId: string | null, /** * Usually the first user message in the thread, if available. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts index 6076a4bb14..c5109b2c7d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts @@ -12,7 +12,8 @@ import type { ThreadSource } from "./ThreadSource"; * 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. * 2. By path: load the thread from disk by path and fork it into a new thread. * - * If using path, the thread_id param will be ignored. + * If using a non-empty path, the thread_id param will be ignored. + * Empty string path values are treated as absent. * * Prefer using thread_id whenever possible. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts index c44533ec1a..c5b1201c26 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts @@ -16,7 +16,6 @@ instructionSources: Array, approvalPolicy: AskForApproval, /** */ approvalsReviewer: ApprovalsReviewer, /** * Legacy sandbox policy retained for compatibility. Experimental clients - * should prefer `permissionProfile` when they need exact runtime - * permissions. + * should prefer `activePermissionProfile` for profile provenance. */ sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalClearParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalClearParams.ts new file mode 100644 index 0000000000..efefc2538a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalClearParams.ts @@ -0,0 +1,5 @@ +// 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 ThreadGoalClearParams = { threadId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalClearResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalClearResponse.ts new file mode 100644 index 0000000000..882176df74 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalClearResponse.ts @@ -0,0 +1,5 @@ +// 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 ThreadGoalClearResponse = { cleared: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalGetParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalGetParams.ts new file mode 100644 index 0000000000..59f0006c87 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalGetParams.ts @@ -0,0 +1,5 @@ +// 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 ThreadGoalGetParams = { threadId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalGetResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalGetResponse.ts new file mode 100644 index 0000000000..fa2b6089d4 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalGetResponse.ts @@ -0,0 +1,6 @@ +// 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 { ThreadGoal } from "./ThreadGoal"; + +export type ThreadGoalGetResponse = { goal: ThreadGoal | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalSetParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalSetParams.ts new file mode 100644 index 0000000000..b92720c178 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalSetParams.ts @@ -0,0 +1,6 @@ +// 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 { ThreadGoalStatus } from "./ThreadGoalStatus"; + +export type ThreadGoalSetParams = { threadId: string, objective?: string | null, status?: ThreadGoalStatus | null, tokenBudget?: number | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalSetResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalSetResponse.ts new file mode 100644 index 0000000000..0f57130ce0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalSetResponse.ts @@ -0,0 +1,6 @@ +// 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 { ThreadGoal } from "./ThreadGoal"; + +export type ThreadGoalSetResponse = { goal: ThreadGoal, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalStatus.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalStatus.ts index 7a4bf332fb..46ec7ddd69 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalStatus.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalStatus.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ThreadGoalStatus = "active" | "paused" | "budgetLimited" | "complete"; +export type ThreadGoalStatus = "active" | "paused" | "blocked" | "usageLimited" | "budgetLimited" | "complete"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts index f7880c9d32..78dff0d765 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadItem.ts @@ -23,7 +23,7 @@ import type { PatchApplyStatus } from "./PatchApplyStatus"; import type { UserInput } from "./UserInput"; import type { WebSearchAction } from "./WebSearchAction"; -export type ThreadItem = { "type": "userMessage", id: string, content: Array, } | { "type": "hookPrompt", id: string, fragments: Array, } | { "type": "agentMessage", id: string, text: string, phase: MessagePhase | null, memoryCitation: MemoryCitation | null, } | { "type": "plan", id: string, text: string, } | { "type": "reasoning", id: string, summary: Array, content: Array, } | { "type": "commandExecution", id: string, +export type ThreadItem = { "type": "userMessage", id: string, clientId: string | null, content: Array, } | { "type": "hookPrompt", id: string, fragments: Array, } | { "type": "agentMessage", id: string, text: string, phase: MessagePhase | null, memoryCitation: MemoryCitation | null, } | { "type": "plan", id: string, text: string, } | { "type": "reasoning", id: string, summary: Array, content: Array, } | { "type": "commandExecution", id: string, /** * The command to be executed. */ @@ -53,7 +53,7 @@ exitCode: number | null, /** * The duration of the command execution in milliseconds. */ -durationMs: number | null, } | { "type": "fileChange", id: string, changes: Array, status: PatchApplyStatus, } | { "type": "mcpToolCall", id: string, server: string, tool: string, status: McpToolCallStatus, arguments: JsonValue, mcpAppResourceUri?: string, result: McpToolCallResult | null, error: McpToolCallError | null, +durationMs: number | null, } | { "type": "fileChange", id: string, changes: Array, status: PatchApplyStatus, } | { "type": "mcpToolCall", id: string, server: string, tool: string, status: McpToolCallStatus, arguments: JsonValue, mcpAppResourceUri?: string, pluginId: string | null, result: McpToolCallResult | null, error: McpToolCallError | null, /** * The duration of the MCP tool call in milliseconds. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadReadParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadReadParams.ts index 64169d2bf6..c26e896481 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadReadParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadReadParams.ts @@ -6,4 +6,4 @@ export type ThreadReadParams = { threadId: string, /** * When true, include turns and their items from rollout history. */ -includeTurns: boolean, }; +includeTurns?: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeInitialTurnsPageParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeInitialTurnsPageParams.ts new file mode 100644 index 0000000000..2dbcd97807 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeInitialTurnsPageParams.ts @@ -0,0 +1,19 @@ +// 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 { SortDirection } from "./SortDirection"; +import type { TurnItemsView } from "./TurnItemsView"; + +export type ThreadResumeInitialTurnsPageParams = { +/** + * Optional turn page size. + */ +limit?: number | null, +/** + * Optional turn pagination direction; defaults to descending. + */ +sortDirection?: SortDirection | null, +/** + * How much item detail to include for each returned turn; defaults to summary. + */ +itemsView?: TurnItemsView | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts index 6d1dbdca4f..0ec8953435 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts @@ -13,8 +13,13 @@ import type { SandboxMode } from "./SandboxMode"; * 2. By history: instantiate the thread from memory and resume it. * 3. By path: load the thread from disk by path and resume it. * - * The precedence is: history > path > thread_id. - * If using history or path, the thread_id param will be ignored. + * For non-running threads, the precedence is: history > non-empty path > thread_id. + * If using history or a non-empty path for a non-running thread, the thread_id + * param will be ignored. + * + * If thread_id identifies a running thread, app-server rejoins that thread and + * treats a non-empty path as a consistency check against the active rollout path. + * Empty string path values are treated as absent. * * Prefer using thread_id whenever possible. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts index f91756c7c6..7a4f90377c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts @@ -16,7 +16,6 @@ instructionSources: Array, approvalPolicy: AskForApproval, /** */ approvalsReviewer: ApprovalsReviewer, /** * Legacy sandbox policy retained for compatibility. Experimental clients - * should prefer `permissionProfile` when they need exact runtime - * permissions. + * should prefer `activePermissionProfile` for profile provenance. */ sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSearchResult.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSearchResult.ts new file mode 100644 index 0000000000..bdd83b8504 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSearchResult.ts @@ -0,0 +1,6 @@ +// 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 { Thread } from "./Thread"; + +export type ThreadSearchResult = { thread: Thread, snippet: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts new file mode 100644 index 0000000000..bcfd0ad86c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettings.ts @@ -0,0 +1,14 @@ +// 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 { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { CollaborationMode } from "../CollaborationMode"; +import type { Personality } from "../Personality"; +import type { ReasoningEffort } from "../ReasoningEffort"; +import type { ReasoningSummary } from "../ReasoningSummary"; +import type { ActivePermissionProfile } from "./ActivePermissionProfile"; +import type { ApprovalsReviewer } from "./ApprovalsReviewer"; +import type { AskForApproval } from "./AskForApproval"; +import type { SandboxPolicy } from "./SandboxPolicy"; + +export type ThreadSettings = { cwd: AbsolutePathBuf, approvalPolicy: AskForApproval, approvalsReviewer: ApprovalsReviewer, sandboxPolicy: SandboxPolicy, activePermissionProfile: ActivePermissionProfile | null, model: string, modelProvider: string, serviceTier: string | null, effort: ReasoningEffort | null, summary: ReasoningSummary | null, collaborationMode: CollaborationMode, personality: Personality | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettingsUpdatedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettingsUpdatedNotification.ts new file mode 100644 index 0000000000..964811ca69 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSettingsUpdatedNotification.ts @@ -0,0 +1,6 @@ +// 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 { ThreadSettings } from "./ThreadSettings"; + +export type ThreadSettingsUpdatedNotification = { threadId: string, threadSettings: ThreadSettings, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts index 9573bd7dee..38859a3805 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts @@ -16,7 +16,6 @@ instructionSources: Array, approvalPolicy: AskForApproval, /** */ approvalsReviewer: ApprovalsReviewer, /** * Legacy sandbox policy retained for compatibility. Experimental clients - * should prefer `permissionProfile` when they need exact runtime - * permissions. + * should prefer `activePermissionProfile` for profile provenance. */ sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index b04919d86b..afe1ac6d94 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -10,7 +10,7 @@ import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { UserInput } from "./UserInput"; -export type TurnStartParams = {threadId: string, input: Array, /** +export type TurnStartParams = {threadId: string, clientUserMessageId?: string | null, input: Array, /** * Override the working directory for this turn and subsequent turns. */ cwd?: string | null, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnSteerParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnSteerParams.ts index dae166b401..a984f2cba8 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnSteerParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnSteerParams.ts @@ -3,7 +3,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { UserInput } from "./UserInput"; -export type TurnSteerParams = {threadId: string, input: Array, /** +export type TurnSteerParams = {threadId: string, clientUserMessageId?: string | null, input: Array, /** * Required active turn id precondition. The request fails when it does not * match the currently active turn. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnsPage.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnsPage.ts new file mode 100644 index 0000000000..e91865aeeb --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnsPage.ts @@ -0,0 +1,6 @@ +// 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 { Turn } from "./Turn"; + +export type TurnsPage = { data: Array, nextCursor: string | null, backwardsCursor: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/UserInput.ts b/codex-rs/app-server-protocol/schema/typescript/v2/UserInput.ts index 38abc2338b..2ac37c5228 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/UserInput.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/UserInput.ts @@ -1,10 +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 { ImageDetail } from "../ImageDetail"; import type { TextElement } from "./TextElement"; export type UserInput = { "type": "text", text: string, /** * UI-defined spans within `text` used to render or persist special elements. */ -text_elements: Array, } | { "type": "image", url: string, } | { "type": "localImage", path: string, } | { "type": "skill", name: string, path: string, } | { "type": "mention", name: string, path: string, }; +text_elements: Array, } | { "type": "image", detail?: ImageDetail, url: string, } | { "type": "localImage", detail?: ImageDetail, path: string, } | { "type": "skill", name: string, path: string, } | { "type": "mention", name: string, path: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 6fc2390947..e5d1e958b9 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -5,9 +5,10 @@ export type { AccountLoginCompletedNotification } from "./AccountLoginCompletedN export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUpdatedNotification"; export type { AccountUpdatedNotification } from "./AccountUpdatedNotification"; export type { ActivePermissionProfile } from "./ActivePermissionProfile"; -export type { ActivePermissionProfileModification } from "./ActivePermissionProfileModification"; export type { AddCreditsNudgeCreditType } from "./AddCreditsNudgeCreditType"; export type { AddCreditsNudgeEmailStatus } from "./AddCreditsNudgeEmailStatus"; +export type { AdditionalContextEntry } from "./AdditionalContextEntry"; +export type { AdditionalContextKind } from "./AdditionalContextKind"; export type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions"; export type { AdditionalNetworkPermissions } from "./AdditionalNetworkPermissions"; export type { AdditionalPermissionProfile } from "./AdditionalPermissionProfile"; @@ -63,6 +64,7 @@ export type { CommandExecutionRequestApprovalResponse } from "./CommandExecution export type { CommandExecutionSource } from "./CommandExecutionSource"; export type { CommandExecutionStatus } from "./CommandExecutionStatus"; export type { CommandMigration } from "./CommandMigration"; +export type { ComputerUseRequirements } from "./ComputerUseRequirements"; export type { Config } from "./Config"; export type { ConfigBatchWriteParams } from "./ConfigBatchWriteParams"; export type { ConfigEdit } from "./ConfigEdit"; @@ -256,11 +258,9 @@ export type { OverriddenMetadata } from "./OverriddenMetadata"; export type { PatchApplyStatus } from "./PatchApplyStatus"; export type { PatchChangeKind } from "./PatchChangeKind"; export type { PermissionGrantScope } from "./PermissionGrantScope"; -export type { PermissionProfile } from "./PermissionProfile"; -export type { PermissionProfileFileSystemPermissions } from "./PermissionProfileFileSystemPermissions"; -export type { PermissionProfileModificationParams } from "./PermissionProfileModificationParams"; -export type { PermissionProfileNetworkPermissions } from "./PermissionProfileNetworkPermissions"; -export type { PermissionProfileSelectionParams } from "./PermissionProfileSelectionParams"; +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"; @@ -271,6 +271,8 @@ export type { PluginHookSummary } from "./PluginHookSummary"; export type { PluginInstallParams } from "./PluginInstallParams"; export type { PluginInstallPolicy } from "./PluginInstallPolicy"; export type { PluginInstallResponse } from "./PluginInstallResponse"; +export type { PluginInstalledParams } from "./PluginInstalledParams"; +export type { PluginInstalledResponse } from "./PluginInstalledResponse"; export type { PluginInterface } from "./PluginInterface"; export type { PluginListMarketplaceKind } from "./PluginListMarketplaceKind"; export type { PluginListParams } from "./PluginListParams"; @@ -308,7 +310,6 @@ export type { ProcessExitedNotification } from "./ProcessExitedNotification"; export type { ProcessOutputDeltaNotification } from "./ProcessOutputDeltaNotification"; export type { ProcessOutputStream } from "./ProcessOutputStream"; export type { ProcessTerminalSize } from "./ProcessTerminalSize"; -export type { ProfileV2 } from "./ProfileV2"; export type { RateLimitReachedType } from "./RateLimitReachedType"; export type { RateLimitSnapshot } from "./RateLimitSnapshot"; export type { RateLimitWindow } from "./RateLimitWindow"; @@ -343,6 +344,8 @@ export type { SkillToolDependency } from "./SkillToolDependency"; export type { SkillsChangedNotification } from "./SkillsChangedNotification"; export type { SkillsConfigWriteParams } from "./SkillsConfigWriteParams"; export type { SkillsConfigWriteResponse } from "./SkillsConfigWriteResponse"; +export type { SkillsExtraRootsSetParams } from "./SkillsExtraRootsSetParams"; +export type { SkillsExtraRootsSetResponse } from "./SkillsExtraRootsSetResponse"; export type { SkillsListEntry } from "./SkillsListEntry"; export type { SkillsListParams } from "./SkillsListParams"; export type { SkillsListResponse } from "./SkillsListResponse"; @@ -370,7 +373,13 @@ export type { ThreadCompactStartResponse } from "./ThreadCompactStartResponse"; export type { ThreadForkParams } from "./ThreadForkParams"; export type { ThreadForkResponse } from "./ThreadForkResponse"; export type { ThreadGoal } from "./ThreadGoal"; +export type { ThreadGoalClearParams } from "./ThreadGoalClearParams"; +export type { ThreadGoalClearResponse } from "./ThreadGoalClearResponse"; export type { ThreadGoalClearedNotification } from "./ThreadGoalClearedNotification"; +export type { ThreadGoalGetParams } from "./ThreadGoalGetParams"; +export type { ThreadGoalGetResponse } from "./ThreadGoalGetResponse"; +export type { ThreadGoalSetParams } from "./ThreadGoalSetParams"; +export type { ThreadGoalSetResponse } from "./ThreadGoalSetResponse"; export type { ThreadGoalStatus } from "./ThreadGoalStatus"; export type { ThreadGoalUpdatedNotification } from "./ThreadGoalUpdatedNotification"; export type { ThreadInjectItemsParams } from "./ThreadInjectItemsParams"; @@ -396,12 +405,16 @@ export type { ThreadRealtimeStartTransport } from "./ThreadRealtimeStartTranspor export type { ThreadRealtimeStartedNotification } from "./ThreadRealtimeStartedNotification"; export type { ThreadRealtimeTranscriptDeltaNotification } from "./ThreadRealtimeTranscriptDeltaNotification"; export type { ThreadRealtimeTranscriptDoneNotification } from "./ThreadRealtimeTranscriptDoneNotification"; +export type { ThreadResumeInitialTurnsPageParams } from "./ThreadResumeInitialTurnsPageParams"; export type { ThreadResumeParams } from "./ThreadResumeParams"; export type { ThreadResumeResponse } from "./ThreadResumeResponse"; export type { ThreadRollbackParams } from "./ThreadRollbackParams"; export type { ThreadRollbackResponse } from "./ThreadRollbackResponse"; +export type { ThreadSearchResult } from "./ThreadSearchResult"; export type { ThreadSetNameParams } from "./ThreadSetNameParams"; export type { ThreadSetNameResponse } from "./ThreadSetNameResponse"; +export type { ThreadSettings } from "./ThreadSettings"; +export type { ThreadSettingsUpdatedNotification } from "./ThreadSettingsUpdatedNotification"; export type { ThreadShellCommandParams } from "./ThreadShellCommandParams"; export type { ThreadShellCommandResponse } from "./ThreadShellCommandResponse"; export type { ThreadSortKey } from "./ThreadSortKey"; @@ -445,6 +458,7 @@ export type { TurnStartedNotification } from "./TurnStartedNotification"; export type { TurnStatus } from "./TurnStatus"; export type { TurnSteerParams } from "./TurnSteerParams"; export type { TurnSteerResponse } from "./TurnSteerResponse"; +export type { TurnsPage } from "./TurnsPage"; export type { UserInput } from "./UserInput"; export type { WarningNotification } from "./WarningNotification"; export type { WebSearchAction } from "./WebSearchAction"; diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index 0f9b33671b..56cf7038f2 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -948,10 +948,8 @@ impl ScanState { '(' => self.depth.paren += 1, ')' => self.depth.paren = (self.depth.paren - 1).max(0), '<' => self.depth.angle += 1, - '>' => { - if self.depth.angle > 0 { - self.depth.angle -= 1; - } + '>' if self.depth.angle > 0 => { + self.depth.angle -= 1; } _ => {} } @@ -2212,20 +2210,14 @@ mod tests { continue; } match ch { - '\\' => { - if in_single || in_double { - escape = true; - } + '\\' if (in_single || in_double) => { + escape = true; } - '\'' => { - if !in_double { - in_single = !in_single; - } + '\'' if !in_double => { + in_single = !in_single; } - '"' => { - if !in_single { - in_double = !in_double; - } + '"' if !in_single => { + in_double = !in_double; } '{' if !in_single && !in_double => level_brace += 1, '}' if !in_single && !in_double => level_brace -= 1, @@ -2747,7 +2739,6 @@ export type Config = { stableField: Keep, unstableField: string | null } & ({ [k let _guard = TempDirGuard(output_dir.clone()); let path = output_dir.join("CommandExecParams.ts"); let content = r#"import type { CommandExecTerminalSize } from "./CommandExecTerminalSize"; -import type { PermissionProfile } from "./PermissionProfile"; import type { SandboxPolicy } from "./SandboxPolicy"; export type CommandExecParams = {/** @@ -2770,12 +2761,12 @@ size?: CommandExecTerminalSize | null, /** */ sandboxPolicy?: SandboxPolicy | null, /** - * Optional full permissions profile for this command. + * Optional active permissions profile id for this command. * * Defaults to the user's configured permissions when omitted. Cannot be * combined with `sandboxPolicy`. */ -permissionProfile?: PermissionProfile | null}; +permissionProfile?: string | null}; "#; fs::write(&path, content)?; @@ -2788,14 +2779,7 @@ permissionProfile?: PermissionProfile | null}; filter_experimental_type_fields_ts(&output_dir, &[&CUSTOM_FIELD])?; let filtered = fs::read_to_string(&path)?; - assert_eq!( - filtered.contains("permissionProfile?: PermissionProfile"), - false - ); - assert_eq!( - filtered.contains(r#"import type { PermissionProfile } from "./PermissionProfile";"#), - false - ); + assert_eq!(filtered.contains("permissionProfile?: string"), false); assert_eq!(filtered.contains("sandboxPolicy?: SandboxPolicy"), true); assert_eq!( filtered.contains(r#"import type { SandboxPolicy } from "./SandboxPolicy";"#), diff --git a/codex-rs/app-server-protocol/src/lib.rs b/codex-rs/app-server-protocol/src/lib.rs index 2fcf54f4be..f6d7670e10 100644 --- a/codex-rs/app-server-protocol/src/lib.rs +++ b/codex-rs/app-server-protocol/src/lib.rs @@ -36,7 +36,6 @@ pub use protocol::v1::InitializeParams; pub use protocol::v1::InitializeResponse; pub use protocol::v1::InterruptConversationResponse; pub use protocol::v1::LoginApiKeyParams; -pub use protocol::v1::Profile; pub use protocol::v1::SandboxSettings; pub use protocol::v1::Tools; pub use protocol::v1::UserSavedConfig; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 46139f842d..1704bfb622 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -223,6 +223,7 @@ macro_rules! client_request_definitions { /// Typed response from the server to the client. #[derive(Serialize, Deserialize, Debug, Clone)] + #[allow(clippy::large_enum_variant)] #[serde(tag = "method", rename_all = "camelCase")] pub enum ClientResponse { $( @@ -493,19 +494,16 @@ client_request_definitions! { serialization: thread_id(params.thread_id), response: v2::ThreadSetNameResponse, }, - #[experimental("thread/goal/set")] ThreadGoalSet => "thread/goal/set" { params: v2::ThreadGoalSetParams, serialization: thread_id(params.thread_id), response: v2::ThreadGoalSetResponse, }, - #[experimental("thread/goal/get")] ThreadGoalGet => "thread/goal/get" { params: v2::ThreadGoalGetParams, serialization: thread_id(params.thread_id), response: v2::ThreadGoalGetResponse, }, - #[experimental("thread/goal/clear")] ThreadGoalClear => "thread/goal/clear" { params: v2::ThreadGoalClearParams, serialization: thread_id(params.thread_id), @@ -521,6 +519,13 @@ client_request_definitions! { serialization: thread_id(params.thread_id), response: v2::ThreadArtifactCreateResponse, }, + #[experimental("thread/settings/update")] + ThreadSettingsUpdate => "thread/settings/update" { + params: v2::ThreadSettingsUpdateParams, + inspect_params: true, + serialization: thread_id(params.thread_id), + response: v2::ThreadSettingsUpdateResponse, + }, #[experimental("thread/memoryMode/set")] ThreadMemoryModeSet => "thread/memoryMode/set" { params: v2::ThreadMemoryModeSetParams, @@ -569,6 +574,12 @@ client_request_definitions! { serialization: None, response: v2::ThreadListResponse, }, + #[experimental("thread/search")] + ThreadSearch => "thread/search" { + params: v2::ThreadSearchParams, + serialization: None, + response: v2::ThreadSearchResponse, + }, ThreadLoadedList => "thread/loaded/list" { params: v2::ThreadLoadedListParams, serialization: None, @@ -604,6 +615,11 @@ client_request_definitions! { serialization: global_shared_read("config"), response: v2::SkillsListResponse, }, + SkillsExtraRootsSet => "skills/extraRoots/set" { + params: v2::SkillsExtraRootsSetParams, + serialization: global("config"), + response: v2::SkillsExtraRootsSetResponse, + }, HooksList => "hooks/list" { params: v2::HooksListParams, serialization: global("config"), @@ -629,6 +645,11 @@ client_request_definitions! { serialization: None, response: v2::PluginListResponse, }, + PluginInstalled => "plugin/installed" { + params: v2::PluginInstalledParams, + serialization: None, + response: v2::PluginInstalledResponse, + }, PluginRead => "plugin/read" { params: v2::PluginReadParams, serialization: None, @@ -799,6 +820,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"), @@ -816,6 +842,12 @@ client_request_definitions! { serialization: global("remote-control"), response: v2::RemoteControlDisableResponse, }, + #[experimental("remoteControl/status/read")] + RemoteControlStatusRead => "remoteControl/status/read" { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + serialization: global_shared_read("remote-control"), + response: v2::RemoteControlStatusReadResponse, + }, #[experimental("collaborationMode/list")] /// Lists collaboration mode presets. CollaborationModeList => "collaborationMode/list" { @@ -1454,11 +1486,12 @@ server_notification_definitions! { ThreadClosed => "thread/closed" (v2::ThreadClosedNotification), SkillsChanged => "skills/changed" (v2::SkillsChangedNotification), ThreadNameUpdated => "thread/name/updated" (v2::ThreadNameUpdatedNotification), - #[experimental("thread/goal/updated")] ThreadGoalUpdated => "thread/goal/updated" (v2::ThreadGoalUpdatedNotification), ThreadArtifactsUpdated => "thread/artifacts/updated" (v2::ThreadArtifactsUpdatedNotification), #[experimental("thread/goal/cleared")] ThreadGoalCleared => "thread/goal/cleared" (v2::ThreadGoalClearedNotification), + #[experimental("thread/settings/updated")] + ThreadSettingsUpdated => "thread/settings/updated" (v2::ThreadSettingsUpdatedNotification), ThreadTokenUsageUpdated => "thread/tokenUsage/updated" (v2::ThreadTokenUsageUpdatedNotification), TurnStarted => "turn/started" (v2::TurnStartedNotification), HookStarted => "hook/started" (v2::HookStartedNotification), @@ -1549,6 +1582,7 @@ mod tests { use anyhow::Result; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; + use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::RealtimeConversationVersion; use codex_protocol::protocol::RealtimeOutputModality; @@ -1699,6 +1733,17 @@ mod tests { Some(ClientRequestSerializationScope::GlobalSharedRead("config")) ); + let skills_extra_roots_set = ClientRequest::SkillsExtraRootsSet { + request_id: request_id(), + params: v2::SkillsExtraRootsSetParams { + extra_roots: vec![absolute_path("/tmp/skills")], + }, + }; + assert_eq!( + skills_extra_roots_set.serialization_scope(), + Some(ClientRequestSerializationScope::Global("config")) + ); + let plugin_list = ClientRequest::PluginList { request_id: request_id(), params: v2::PluginListParams { @@ -1718,6 +1763,15 @@ mod tests { }; assert_eq!(plugin_read.serialization_scope(), None); + let plugin_installed = ClientRequest::PluginInstalled { + request_id: request_id(), + params: v2::PluginInstalledParams { + cwds: None, + install_suggestion_plugin_names: None, + }, + }; + assert_eq!(plugin_installed.serialization_scope(), None); + let plugin_uninstall = ClientRequest::PluginUninstall { request_id: request_id(), params: v2::PluginUninstallParams { @@ -2281,6 +2335,7 @@ mod tests { id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), session_id: "67e55044-10b1-426f-9247-bb680e5fe0c7".to_string(), forked_from_id: None, + parent_thread_id: None, preview: "first prompt".to_string(), ephemeral: true, model_provider: "openai".to_string(), @@ -2303,11 +2358,11 @@ mod tests { model_provider: "openai".to_string(), service_tier: None, cwd, + runtime_workspace_roots: Vec::new(), instruction_sources: vec![absolute_path("/tmp/AGENTS.md")], approval_policy: v2::AskForApproval::OnFailure, approvals_reviewer: v2::ApprovalsReviewer::User, sandbox: v2::SandboxPolicy::DangerFullAccess, - permission_profile: None, active_permission_profile: None, reasoning_effort: None, }, @@ -2324,6 +2379,7 @@ mod tests { "id": "67e55044-10b1-426f-9247-bb680e5fe0c8", "sessionId": "67e55044-10b1-426f-9247-bb680e5fe0c7", "forkedFromId": null, + "parentThreadId": null, "preview": "first prompt", "ephemeral": true, "modelProvider": "openai", @@ -2347,13 +2403,13 @@ mod tests { "modelProvider": "openai", "serviceTier": null, "cwd": absolute_path_string("tmp"), + "runtimeWorkspaceRoots": [], "instructionSources": [absolute_path_string("tmp/AGENTS.md")], "approvalPolicy": "on-failure", "approvalsReviewer": "user", "sandbox": { "type": "dangerFullAccess" }, - "permissionProfile": null, "activePermissionProfile": null, "reasoningEffort": null } @@ -2517,8 +2573,22 @@ mod tests { json!({ "method": "account/read", "id": 6, + "params": {} + }), + serde_json::to_value(&request)?, + ); + let request = ClientRequest::GetAccount { + request_id: RequestId::Integer(7), + params: v2::GetAccountParams { + refresh_token: true, + }, + }; + assert_eq!( + json!({ + "method": "account/read", + "id": 7, "params": { - "refreshToken": false + "refreshToken": true } }), serde_json::to_value(&request)?, @@ -2707,7 +2777,33 @@ mod tests { "id": 8, "params": { "cursor": null, - "limit": null + "limit": null, + "threadId": null + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + + #[test] + fn serialize_list_experimental_features_with_thread_id() -> Result<()> { + let request = ClientRequest::ExperimentalFeatureList { + request_id: RequestId::Integer(8), + params: v2::ExperimentalFeatureListParams { + cursor: Some("3".to_string()), + limit: Some(2), + thread_id: Some("00000000-0000-4000-8000-000000000001".to_string()), + }, + }; + assert_eq!( + json!({ + "method": "experimentalFeature/list", + "id": 8, + "params": { + "cursor": "3", + "limit": 2, + "threadId": "00000000-0000-4000-8000-000000000001" } }), serde_json::to_value(&request)?, @@ -2954,7 +3050,7 @@ mod tests { env: None, size: None, sandbox_policy: None, - permission_profile: Some(v2::PermissionProfile::Disabled), + permission_profile: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()), }, }; @@ -2980,7 +3076,7 @@ mod tests { } #[test] - fn thread_goal_methods_are_marked_experimental() { + fn thread_goal_methods_are_not_marked_experimental() { let set_request = ClientRequest::ThreadGoalSet { request_id: RequestId::Integer(1), params: v2::ThreadGoalSetParams { @@ -3005,20 +3101,20 @@ mod tests { assert_eq!( crate::experimental_api::ExperimentalApi::experimental_reason(&set_request), - Some("thread/goal/set") + None ); assert_eq!( crate::experimental_api::ExperimentalApi::experimental_reason(&get_request), - Some("thread/goal/get") + None ); assert_eq!( crate::experimental_api::ExperimentalApi::experimental_reason(&clear_request), - Some("thread/goal/clear") + None ); } #[test] - fn thread_goal_notifications_are_marked_experimental() { + fn thread_goal_notifications_are_not_marked_experimental() { let goal = v2::ThreadGoal { thread_id: "thr_123".to_string(), objective: "ship goal mode".to_string(), @@ -3040,11 +3136,45 @@ mod tests { assert_eq!( crate::experimental_api::ExperimentalApi::experimental_reason(&updated), - Some("thread/goal/updated") + None ); assert_eq!( crate::experimental_api::ExperimentalApi::experimental_reason(&cleared), - Some("thread/goal/cleared") + None + ); + } + + #[test] + fn thread_settings_updated_notification_is_marked_experimental() { + let notification = + ServerNotification::ThreadSettingsUpdated(v2::ThreadSettingsUpdatedNotification { + thread_id: "thr_123".to_string(), + thread_settings: v2::ThreadSettings { + cwd: absolute_path("/tmp/repo"), + approval_policy: v2::AskForApproval::Never, + approvals_reviewer: v2::ApprovalsReviewer::User, + sandbox_policy: v2::SandboxPolicy::DangerFullAccess, + active_permission_profile: None, + model: "gpt-5.4".to_string(), + model_provider: "openai".to_string(), + service_tier: None, + effort: None, + summary: None, + collaboration_mode: codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Default, + settings: codex_protocol::config_types::Settings { + model: "gpt-5.4".to_string(), + reasoning_effort: None, + developer_instructions: None, + }, + }, + personality: None, + }, + }); + + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(¬ification), + Some("thread/settings/updated") ); } diff --git a/codex-rs/app-server-protocol/src/protocol/serde_helpers.rs b/codex-rs/app-server-protocol/src/protocol/serde_helpers.rs index 0e35ebdba7..6d6747a5cc 100644 --- a/codex-rs/app-server-protocol/src/protocol/serde_helpers.rs +++ b/codex-rs/app-server-protocol/src/protocol/serde_helpers.rs @@ -1,8 +1,18 @@ +use std::path::PathBuf; + use serde::Deserialize; use serde::Deserializer; use serde::Serialize; use serde::Serializer; +pub fn deserialize_empty_path_as_none<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let path = Option::::deserialize(deserializer)?; + Ok(path.filter(|path| !path.as_os_str().is_empty())) +} + pub fn deserialize_double_option<'de, T, D>(deserializer: D) -> Result>, D::Error> where T: Deserialize<'de>, diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 1121d3a35b..aa65f9ab9d 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -278,7 +278,11 @@ impl ThreadHistoryBuilder { .unwrap_or_else(|| self.new_turn(/*id*/ None)); let id = self.next_item_id(); let content = self.build_user_inputs(payload); - turn.items.push(ThreadItem::UserMessage { id, content }); + turn.items.push(ThreadItem::UserMessage { + id, + client_id: payload.client_id.clone(), + content, + }); self.current_turn = Some(turn); } @@ -525,6 +529,7 @@ impl ThreadHistoryBuilder { .clone() .unwrap_or(serde_json::Value::Null), mcp_app_resource_uri: payload.mcp_app_resource_uri.clone(), + plugin_id: payload.plugin_id.clone(), result: None, error: None, duration_ms: None, @@ -566,6 +571,7 @@ impl ThreadHistoryBuilder { .clone() .unwrap_or(serde_json::Value::Null), mcp_app_resource_uri: payload.mcp_app_resource_uri.clone(), + plugin_id: payload.plugin_id.clone(), result, error, duration_ms, @@ -1077,12 +1083,18 @@ impl ThreadHistoryBuilder { }); } if let Some(images) = &payload.images { - for image in images { - content.push(UserInput::Image { url: image.clone() }); + for (idx, image) in images.iter().enumerate() { + content.push(UserInput::Image { + url: image.clone(), + detail: payload.image_details.get(idx).copied().flatten(), + }); } } - for path in &payload.local_images { - content.push(UserInput::LocalImage { path: path.clone() }); + for (idx, path) in payload.local_images.iter().enumerate() { + content.push(UserInput::LocalImage { + path: path.clone(), + detail: payload.local_image_details.get(idx).copied().flatten(), + }); } content } @@ -1203,6 +1215,7 @@ mod tests { use codex_protocol::items::UserMessageItem as CoreUserMessageItem; use codex_protocol::items::build_hook_prompt_message; use codex_protocol::mcp::CallToolResult; + use codex_protocol::models::ImageDetail; use codex_protocol::models::MessagePhase as CoreMessagePhase; use codex_protocol::models::WebSearchAction as CoreWebSearchAction; use codex_protocol::parse_command::ParsedCommand; @@ -1237,10 +1250,12 @@ mod tests { fn builds_multiple_turns_with_reasoning_items() { let events = vec![ EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "First turn".into(), images: Some(vec!["https://example.com/one.png".into()]), text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentMessage(AgentMessageEvent { message: "Hi there".into(), @@ -1254,10 +1269,12 @@ mod tests { text: "full reasoning".into(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "Second turn".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentMessage(AgentMessageEvent { message: "Reply two".into(), @@ -1281,6 +1298,7 @@ mod tests { first.items[0], ThreadItem::UserMessage { id: "item-1".into(), + client_id: None, content: vec![ UserInput::Text { text: "First turn".into(), @@ -1288,6 +1306,7 @@ mod tests { }, UserInput::Image { url: "https://example.com/one.png".into(), + detail: None, } ], } @@ -1318,6 +1337,7 @@ mod tests { second.items[0], ThreadItem::UserMessage { id: "item-4".into(), + client_id: None, content: vec![UserInput::Text { text: "Second turn".into(), text_elements: Vec::new(), @@ -1335,6 +1355,47 @@ mod tests { ); } + #[test] + fn rebuilds_user_message_image_details_from_legacy_events() { + let local_path = PathBuf::from("/tmp/local.png"); + let events = vec![RolloutItem::EventMsg(EventMsg::UserMessage( + UserMessageEvent { + client_id: None, + message: "inspect these".into(), + images: Some(vec!["https://example.com/image.png".into()]), + image_details: vec![Some(ImageDetail::Original)], + local_images: vec![local_path.clone()], + local_image_details: vec![Some(ImageDetail::Original)], + text_elements: Vec::new(), + }, + ))]; + + let turns = build_turns_from_rollout_items(&events); + + assert_eq!(turns.len(), 1); + assert_eq!( + turns[0].items[0], + ThreadItem::UserMessage { + id: "item-1".into(), + client_id: None, + content: vec![ + UserInput::Text { + text: "inspect these".into(), + text_elements: Vec::new(), + }, + UserInput::Image { + url: "https://example.com/image.png".into(), + detail: Some(ImageDetail::Original), + }, + UserInput::LocalImage { + path: local_path, + detail: Some(ImageDetail::Original), + }, + ], + } + ); + } + #[test] fn ignores_non_plan_item_lifecycle_events() { let turn_id = "turn-1"; @@ -1342,21 +1403,25 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "hello".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::ItemStarted(ItemStartedEvent { thread_id, turn_id: turn_id.to_string(), item: CoreTurnItem::UserMessage(CoreUserMessageItem { id: "user-item-id".to_string(), + client_id: None, content: Vec::new(), }), started_at_ms: 0, @@ -1381,6 +1446,7 @@ mod tests { turns[0].items[0], ThreadItem::UserMessage { id: "item-1".into(), + client_id: None, content: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -1389,6 +1455,67 @@ mod tests { ); } + #[test] + fn preserves_user_message_client_id_from_legacy_event() { + let turn_id = "turn-1"; + let thread_id = ThreadId::new(); + let events = vec![ + EventMsg::TurnStarted(TurnStartedEvent { + turn_id: turn_id.to_string(), + trace_id: None, + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + EventMsg::ItemStarted(ItemStartedEvent { + thread_id, + turn_id: turn_id.to_string(), + item: CoreTurnItem::UserMessage(CoreUserMessageItem { + id: "user-item-id".to_string(), + client_id: Some("client-message-1".to_string()), + content: vec![codex_protocol::user_input::UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + }), + started_at_ms: 0, + }), + EventMsg::UserMessage(UserMessageEvent { + client_id: Some("client-message-1".to_string()), + message: "hello".into(), + images: None, + text_elements: Vec::new(), + local_images: Vec::new(), + ..Default::default() + }), + EventMsg::TurnComplete(TurnCompleteEvent { + turn_id: turn_id.to_string(), + last_agent_message: None, + completed_at: None, + duration_ms: None, + time_to_first_token_ms: None, + }), + ]; + + let items = events + .into_iter() + .map(RolloutItem::EventMsg) + .collect::>(); + let turns = build_turns_from_rollout_items(&items); + assert_eq!(turns.len(), 1); + assert_eq!( + turns[0].items, + vec![ThreadItem::UserMessage { + id: "item-1".into(), + client_id: Some("client-message-1".to_string()), + content: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + }] + ); + } + #[test] fn preserves_agent_message_phase_in_history() { let events = vec![EventMsg::AgentMessage(AgentMessageEvent { @@ -1419,15 +1546,18 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-image".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "generate an image".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() })), RolloutItem::EventMsg(EventMsg::ImageGenerationEnd(ImageGenerationEndEvent { call_id: "ig_123".into(), @@ -1460,6 +1590,7 @@ mod tests { items: vec![ ThreadItem::UserMessage { id: "item-1".into(), + client_id: None, content: vec![UserInput::Text { text: "generate an image".into(), text_elements: Vec::new(), @@ -1481,10 +1612,12 @@ mod tests { fn splits_reasoning_when_interleaved() { let events = vec![ EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "Turn start".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentReasoning(AgentReasoningEvent { text: "first summary".into(), @@ -1533,10 +1666,12 @@ mod tests { fn marks_turn_as_interrupted_when_aborted() { let events = vec![ EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "Please do the thing".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentMessage(AgentMessageEvent { message: "Working...".into(), @@ -1550,10 +1685,12 @@ mod tests { duration_ms: None, }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "Let's try again".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentMessage(AgentMessageEvent { message: "Second attempt complete.".into(), @@ -1576,6 +1713,7 @@ mod tests { first_turn.items[0], ThreadItem::UserMessage { id: "item-1".into(), + client_id: None, content: vec![UserInput::Text { text: "Please do the thing".into(), text_elements: Vec::new(), @@ -1599,6 +1737,7 @@ mod tests { second_turn.items[0], ThreadItem::UserMessage { id: "item-3".into(), + client_id: None, content: vec![UserInput::Text { text: "Let's try again".into(), text_elements: Vec::new(), @@ -1620,10 +1759,12 @@ mod tests { fn drops_last_turns_on_thread_rollback() { let events = vec![ EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "First".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentMessage(AgentMessageEvent { message: "A1".into(), @@ -1631,10 +1772,12 @@ mod tests { memory_citation: None, }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "Second".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentMessage(AgentMessageEvent { message: "A2".into(), @@ -1643,10 +1786,12 @@ mod tests { }), EventMsg::ThreadRolledBack(ThreadRolledBackEvent { num_turns: 1 }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "Third".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentMessage(AgentMessageEvent { message: "A3".into(), @@ -1671,6 +1816,7 @@ mod tests { vec![ ThreadItem::UserMessage { id: "item-1".into(), + client_id: None, content: vec![UserInput::Text { text: "First".into(), text_elements: Vec::new(), @@ -1689,6 +1835,7 @@ mod tests { vec![ ThreadItem::UserMessage { id: "item-3".into(), + client_id: None, content: vec![UserInput::Text { text: "Third".into(), text_elements: Vec::new(), @@ -1708,10 +1855,12 @@ mod tests { fn thread_rollback_clears_all_turns_when_num_turns_exceeds_history() { let events = vec![ EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "One".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentMessage(AgentMessageEvent { message: "A1".into(), @@ -1719,10 +1868,12 @@ mod tests { memory_citation: None, }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "Two".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentMessage(AgentMessageEvent { message: "A2".into(), @@ -1745,21 +1896,26 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "Start".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "Steer".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), @@ -1782,6 +1938,7 @@ mod tests { vec![ ThreadItem::UserMessage { id: "item-1".into(), + client_id: None, content: vec![UserInput::Text { text: "Start".into(), text_elements: Vec::new(), @@ -1789,6 +1946,7 @@ mod tests { }, ThreadItem::UserMessage { id: "item-2".into(), + client_id: None, content: vec![UserInput::Text { text: "Steer".into(), text_elements: Vec::new(), @@ -1803,15 +1961,18 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "run tools".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::WebSearchEnd(WebSearchEndEvent { call_id: "search-1".into(), @@ -1849,6 +2010,7 @@ mod tests { arguments: Some(serde_json::json!({"id":"123"})), }, mcp_app_resource_uri: None, + plugin_id: None, duration: Duration::from_millis(8), result: Err("boom".into()), }), @@ -1898,6 +2060,7 @@ mod tests { status: McpToolCallStatus::Failed, arguments: serde_json::json!({"id":"123"}), mcp_app_resource_uri: None, + plugin_id: None, result: None, error: Some(McpToolCallError { message: "boom".into(), @@ -1912,6 +2075,7 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -1924,6 +2088,7 @@ mod tests { arguments: Some(serde_json::json!({"id":"123"})), }, mcp_app_resource_uri: Some("ui://widget/lookup.html".into()), + plugin_id: Some("sample@test".into()), duration: Duration::from_millis(8), result: Ok(CallToolResult { content: vec![serde_json::json!({ @@ -1954,6 +2119,7 @@ mod tests { status: McpToolCallStatus::Completed, arguments: serde_json::json!({"id":"123"}), mcp_app_resource_uri: Some("ui://widget/lookup.html".into()), + plugin_id: Some("sample@test".into()), result: Some(Box::new(McpToolCallResult { content: vec![serde_json::json!({ "type": "text", @@ -1975,15 +2141,18 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "run dynamic tool".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::DynamicToolCallRequest( codex_protocol::dynamic_tools::DynamicToolCallRequest { @@ -2040,15 +2209,18 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "run tools".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::ExecCommandEnd(ExecCommandEndEvent { call_id: "exec-declined".into(), @@ -2129,15 +2301,18 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "review this command".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::GuardianAssessment(GuardianAssessmentEvent { id: "review-guardian-exec".into(), @@ -2212,15 +2387,18 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "run a subcommand".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::GuardianAssessment(GuardianAssessmentEvent { id: "review-guardian-execve".into(), @@ -2275,15 +2453,18 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "first".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), @@ -2294,15 +2475,18 @@ mod tests { }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "second".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::ExecCommandEnd(ExecCommandEndEvent { call_id: "exec-late".into(), @@ -2367,15 +2551,18 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "first".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), @@ -2386,15 +2573,18 @@ mod tests { }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "second".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::ExecCommandEnd(ExecCommandEndEvent { call_id: "exec-unknown-turn".into(), @@ -2439,6 +2629,7 @@ mod tests { turns[1].items[0], ThreadItem::UserMessage { id: "item-2".into(), + client_id: None, content: vec![UserInput::Text { text: "second".into(), text_elements: Vec::new(), @@ -2454,15 +2645,18 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "apply patch".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::PatchApplyBegin(PatchApplyBeginEvent { call_id: "patch-call".into(), @@ -2493,6 +2687,7 @@ mod tests { vec![ ThreadItem::UserMessage { id: "item-1".into(), + client_id: None, content: vec![UserInput::Text { text: "apply patch".into(), text_elements: Vec::new(), @@ -2518,15 +2713,18 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "apply patch".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { call_id: "patch-call".into(), @@ -2559,6 +2757,7 @@ mod tests { vec![ ThreadItem::UserMessage { id: "item-1".into(), + client_id: None, content: vec![UserInput::Text { text: "apply patch".into(), text_elements: Vec::new(), @@ -2582,15 +2781,18 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "first".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), @@ -2601,15 +2803,18 @@ mod tests { }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "second".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), @@ -2648,15 +2853,18 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "first".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), @@ -2667,15 +2875,18 @@ mod tests { }), EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-b".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "second".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::TurnAborted(TurnAbortedEvent { turn_id: Some("turn-a".into()), @@ -2707,6 +2918,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-compact".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -2744,10 +2956,12 @@ mod tests { fn reconstructs_collab_resume_end_item() { let events = vec![ EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "resume agent".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::CollabResumeEnd(codex_protocol::protocol::CollabResumeEndEvent { call_id: "resume-1".into(), @@ -2801,10 +3015,12 @@ mod tests { .expect("valid receiver thread id"); let events = vec![ EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "spawn agent".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::CollabAgentSpawnEnd(codex_protocol::protocol::CollabAgentSpawnEndEvent { call_id: "spawn-1".into(), @@ -2862,10 +3078,12 @@ mod tests { .expect("valid receiver thread id"); let events = vec![ EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "redirect".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::CollabAgentInteractionBegin( codex_protocol::protocol::CollabAgentInteractionBeginEvent { @@ -2925,10 +3143,12 @@ mod tests { fn rollback_failed_error_does_not_mark_turn_failed() { let events = vec![ EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "hello".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::AgentMessage(AgentMessageEvent { message: "done".into(), @@ -2956,15 +3176,18 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "hello".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), @@ -2997,6 +3220,7 @@ mod tests { items_view: TurnItemsView::Full, items: vec![ThreadItem::UserMessage { id: "item-1".into(), + client_id: None, content: vec![UserInput::Text { text: "hello".into(), text_elements: Vec::new(), @@ -3011,15 +3235,18 @@ mod tests { let events = vec![ EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), }), EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "hello".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() }), EventMsg::Error(ErrorEvent { message: "stream failure".into(), @@ -3068,15 +3295,18 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), })), RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "hello".into(), images: None, text_elements: Vec::new(), local_images: Vec::new(), + ..Default::default() })), RolloutItem::ResponseItem(hook_prompt), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { @@ -3115,6 +3345,7 @@ mod tests { let items = vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-a".into(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 3c45c20b8f..f83674d4c3 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -209,20 +209,6 @@ pub struct UserSavedConfig { pub model_reasoning_summary: Option, pub model_verbosity: Option, pub tools: Option, - pub profile: Option, - pub profiles: HashMap, -} - -#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -pub struct Profile { - pub model: Option, - pub model_provider: Option, - pub approval_policy: Option, - pub model_reasoning_effort: Option, - pub model_reasoning_summary: Option, - pub model_verbosity: Option, - pub chatgpt_base_url: Option, } #[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/account.rs b/codex-rs/app-server-protocol/src/protocol/v2/account.rs index efb4a26f60..796c93d555 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -224,7 +224,7 @@ pub struct GetAccountParams { /// In managed auth mode this triggers the normal refresh-token flow. In /// external auth mode this flag is ignored. Clients should refresh tokens /// themselves and call `account/login/start` with `chatgptAuthTokens`. - #[serde(default)] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub refresh_token: bool, } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs b/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs index ff0cecf4f9..8f913b5bcf 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs @@ -1,4 +1,3 @@ -use super::PermissionProfile; use super::SandboxPolicy; use codex_experimental_api_macros::ExperimentalApi; use schemars::JsonSchema; @@ -100,13 +99,13 @@ pub struct CommandExecParams { /// combined with `permissionProfile`. #[ts(optional = nullable)] pub sandbox_policy: Option, - /// Optional full permissions profile for this command. + /// Optional active permissions profile id for this command. /// /// Defaults to the user's configured permissions when omitted. Cannot be /// combined with `sandboxPolicy`. #[experimental("command/exec.permissionProfile")] #[ts(optional = nullable)] - pub permission_profile: Option, + pub permission_profile: Option, } /// Final buffered result for `command/exec`. diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index ca929b02a9..473bb1e03c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -1,8 +1,10 @@ use super::ApprovalsReviewer; use super::AskForApproval; use super::SandboxMode; +use super::WindowsSandboxSetupMode; use super::shared::default_enabled; use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Verbosity; @@ -41,6 +43,19 @@ pub enum ConfigLayerSource { file: AbsolutePathBuf, }, + /// Enterprise-managed config layer delivered by the cloud config bundle. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + EnterpriseManaged { + /// Stable identifier for the delivered layer. + id: String, + + /// Admin-facing name for the delivered layer. This is surfaced in + /// diagnostics so users know which cloud layer needs administrator + /// attention. + name: String, + }, + /// User config layer from $CODEX_HOME/config.toml. This layer is special /// in that it is expected to be: /// - writable by the user @@ -88,6 +103,7 @@ impl ConfigLayerSource { match self { ConfigLayerSource::Mdm { .. } => 0, ConfigLayerSource::System { .. } => 10, + ConfigLayerSource::EnterpriseManaged { .. } => 15, ConfigLayerSource::User { profile, .. } => { if profile.is_some() { 21 @@ -132,30 +148,6 @@ pub struct ToolsV2 { pub web_search: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct ProfileV2 { - pub model: Option, - pub model_provider: Option, - #[experimental(nested)] - pub approval_policy: Option, - /// [UNSTABLE] Optional profile-level override for where approval requests - /// are routed for review. If omitted, the enclosing config default is - /// used. - #[experimental("config/read.approvalsReviewer")] - pub approvals_reviewer: Option, - pub service_tier: Option, - pub model_reasoning_effort: Option, - pub model_reasoning_summary: Option, - pub model_verbosity: Option, - pub web_search: Option, - pub tools: Option, - pub chatgpt_base_url: Option, - #[serde(default, flatten)] - pub additional: HashMap, -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(export_to = "v2/")] @@ -251,6 +243,7 @@ pub struct Config { pub review_model: Option, pub model_context_window: Option, pub model_auto_compact_token_limit: Option, + pub model_auto_compact_token_limit_scope: Option, pub model_provider: Option, #[experimental(nested)] pub approval_policy: Option, @@ -264,10 +257,6 @@ pub struct Config { pub forced_login_method: Option, pub web_search: Option, pub tools: Option, - pub profile: Option, - #[experimental(nested)] - #[serde(default)] - pub profiles: HashMap, pub instructions: Option, pub developer_instructions: Option, pub compact_prompt: Option, @@ -279,6 +268,7 @@ pub struct Config { #[experimental("config/read.apps")] #[serde(default)] pub apps: Option, + pub desktop: Option>, #[serde(default, flatten)] pub additional: HashMap, } @@ -354,7 +344,7 @@ pub enum ConfigWriteErrorCode { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct ConfigReadParams { - #[serde(default)] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub include_layers: bool, /// Optional working directory to resolve project config layers. If specified, /// return the effective config as seen from that directory (i.e., including any @@ -383,8 +373,12 @@ pub struct ConfigRequirements { #[experimental("configRequirements/read.allowedApprovalsReviewers")] pub allowed_approvals_reviewers: Option>, pub allowed_sandbox_modes: Option>, + pub allowed_windows_sandbox_implementations: Option>, + pub allowed_permissions: Option>, pub allowed_web_search_modes: Option>, pub allow_managed_hooks_only: Option, + pub allow_appshots: Option, + pub computer_use: Option, pub feature_requirements: Option>, #[experimental("configRequirements/read.hooks")] pub hooks: Option, @@ -393,6 +387,13 @@ pub struct ConfigRequirements { pub network: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ComputerUseRequirements { + pub allow_locked_computer_use: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -420,6 +421,12 @@ pub struct ManagedHooksRequirements { #[serde(rename = "UserPromptSubmit")] #[ts(rename = "UserPromptSubmit")] pub user_prompt_submit: Vec, + #[serde(rename = "SubagentStart")] + #[ts(rename = "SubagentStart")] + pub subagent_start: Vec, + #[serde(rename = "SubagentStop")] + #[ts(rename = "SubagentStop")] + pub subagent_stop: Vec, #[serde(rename = "Stop")] #[ts(rename = "Stop")] pub stop: Vec, @@ -499,7 +506,7 @@ pub enum NetworkDomainPermission { #[ts(export_to = "v2/")] pub enum NetworkUnixSocketPermission { Allow, - None, + Deny, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/experimental_feature.rs b/codex-rs/app-server-protocol/src/protocol/v2/experimental_feature.rs index 6adc21b6ef..a16f45216b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/experimental_feature.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/experimental_feature.rs @@ -14,6 +14,11 @@ pub struct ExperimentalFeatureListParams { /// Optional page size; defaults to a reasonable server-side value. #[ts(optional = nullable)] pub limit: Option, + /// Optional loaded thread id. Pass this when showing feature state for an + /// existing thread so enablement is computed from that thread's refreshed + /// config, including project-local config for the thread's cwd. + #[ts(optional = nullable)] + pub thread_id: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/feedback.rs b/codex-rs/app-server-protocol/src/protocol/v2/feedback.rs index aaf966a4bf..fdddb3aaa7 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/feedback.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/feedback.rs @@ -14,6 +14,7 @@ pub struct FeedbackUploadParams { pub reason: Option, #[ts(optional = nullable)] pub thread_id: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub include_logs: bool, #[ts(optional = nullable)] pub extra_log_files: Option>, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/hook.rs b/codex-rs/app-server-protocol/src/protocol/v2/hook.rs index 4a07bd495b..b8f47276a6 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/hook.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/hook.rs @@ -17,7 +17,7 @@ use ts_rs::TS; v2_enum_from_core!( pub enum HookEventName from CoreHookEventName { - PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, UserPromptSubmit, Stop + PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, UserPromptSubmit, SubagentStart, SubagentStop, Stop } ); @@ -48,6 +48,7 @@ v2_enum_from_core!( SessionFlags, Plugin, CloudRequirements, + CloudManagedConfig, LegacyManagedConfigFile, LegacyManagedConfigMdm, Unknown, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index 0e22c48590..d68485565e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -212,7 +212,11 @@ impl CommandAction { pub enum ThreadItem { #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] - UserMessage { id: String, content: Vec }, + UserMessage { + id: String, + client_id: Option, + content: Vec, + }, #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] HookPrompt { @@ -286,6 +290,7 @@ pub enum ThreadItem { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] mcp_app_resource_uri: Option, + plugin_id: Option, result: Option>, error: Option, /// The duration of the MCP tool call in milliseconds. @@ -775,6 +780,7 @@ impl From for ThreadItem { match value { CoreTurnItem::UserMessage(user) => ThreadItem::UserMessage { id: user.id, + client_id: user.client_id, content: user.content.into_iter().map(UserInput::from).collect(), }, CoreTurnItem::HookPrompt(hook_prompt) => ThreadItem::HookPrompt { @@ -846,6 +852,7 @@ impl From for ThreadItem { status: McpToolCallStatus::from(mcp.status), arguments: mcp.arguments, mcp_app_resource_uri: mcp.mcp_app_resource_uri, + plugin_id: mcp.plugin_id, result: mcp.result.map(McpToolCallResult::from).map(Box::new), error: mcp.error.map(McpToolCallError::from), duration_ms, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs index 9fd9384076..ae61f12b2d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs @@ -2,6 +2,7 @@ use super::shared::v2_enum_from_core; use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest; use codex_protocol::items::McpToolCallError as CoreMcpToolCallError; use codex_protocol::mcp::CallToolResult as CoreMcpCallToolResult; +use codex_protocol::mcp::McpServerInfo; use codex_protocol::mcp::Resource as McpResource; pub use codex_protocol::mcp::ResourceContent as McpResourceContent; use codex_protocol::mcp::ResourceTemplate as McpResourceTemplate; @@ -36,6 +37,8 @@ pub struct ListMcpServerStatusParams { /// Defaults to `Full` when omitted. #[ts(optional = nullable)] pub detail: Option, + #[ts(optional = nullable)] + pub thread_id: Option, } #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] @@ -51,6 +54,7 @@ pub enum McpServerStatusDetail { #[ts(export_to = "v2/")] pub struct McpServerStatus { pub name: String, + pub server_info: Option, pub tools: std::collections::HashMap, pub resources: Vec, pub resource_templates: Vec, @@ -688,6 +692,7 @@ impl From for rmcp::model::CreateElicitatio Self { action: value.action.into(), content: value.content, + meta: None, } } } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/model.rs b/codex-rs/app-server-protocol/src/protocol/v2/model.rs index cd139e9c4b..7f97d791a6 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/model.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/model.rs @@ -98,6 +98,9 @@ pub struct Model { pub additional_speed_tiers: Vec, #[serde(default)] pub service_tiers: Vec, + /// Catalog default service tier id for this model, when one is configured. + #[serde(default)] + pub default_service_tier: Option, // Only one model should be marked as default. pub is_default: bool, } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs index 86614a6aeb..dfc1942276 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -5,17 +5,13 @@ use codex_protocol::approvals::NetworkApprovalProtocol as CoreNetworkApprovalPro use codex_protocol::approvals::NetworkPolicyAmendment as CoreNetworkPolicyAmendment; use codex_protocol::approvals::NetworkPolicyRuleAction as CoreNetworkPolicyRuleAction; use codex_protocol::models::ActivePermissionProfile as CoreActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification as CoreActivePermissionProfileModification; use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; -use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; -use codex_protocol::models::PermissionProfile as CorePermissionProfile; use codex_protocol::permissions::FileSystemAccessMode as CoreFileSystemAccessMode; use codex_protocol::permissions::FileSystemPath as CoreFileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry as CoreFileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSpecialPath as CoreFileSystemSpecialPath; -use codex_protocol::permissions::NetworkSandboxPolicy as CoreNetworkSandboxPolicy; use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; @@ -135,13 +131,6 @@ pub struct AdditionalNetworkPermissions { pub enabled: Option, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PermissionProfileNetworkPermissions { - pub enabled: bool, -} - impl From for AdditionalNetworkPermissions { fn from(value: CoreNetworkPermissions) -> Self { Self { @@ -158,24 +147,6 @@ impl From for CoreNetworkPermissions { } } -impl From for PermissionProfileNetworkPermissions { - fn from(value: CoreNetworkSandboxPolicy) -> Self { - Self { - enabled: value.is_enabled(), - } - } -} - -impl From for CoreNetworkSandboxPolicy { - fn from(value: PermissionProfileNetworkPermissions) -> Self { - if value.enabled { - Self::Enabled - } else { - Self::Restricted - } - } -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[serde(deny_unknown_fields)] @@ -207,7 +178,7 @@ v2_enum_from_core!( pub enum FileSystemAccessMode from CoreFileSystemAccessMode { Read, Write, - None + Deny } ); @@ -316,114 +287,39 @@ impl From for CoreFileSystemSandboxEntry { } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] -pub enum PermissionProfileFileSystemPermissions { - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Restricted { - entries: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - glob_scan_max_depth: Option, - }, - Unrestricted, -} - -impl From for PermissionProfileFileSystemPermissions { - fn from(value: CoreManagedFileSystemPermissions) -> Self { - match value { - CoreManagedFileSystemPermissions::Restricted { - entries, - glob_scan_max_depth, - } => Self::Restricted { - entries: entries - .into_iter() - .map(FileSystemSandboxEntry::from) - .collect(), - glob_scan_max_depth, - }, - CoreManagedFileSystemPermissions::Unrestricted => Self::Unrestricted, - } - } -} - -impl From for CoreManagedFileSystemPermissions { - fn from(value: PermissionProfileFileSystemPermissions) -> Self { - match value { - PermissionProfileFileSystemPermissions::Restricted { - entries, - glob_scan_max_depth, - } => Self::Restricted { - entries: entries - .into_iter() - .map(CoreFileSystemSandboxEntry::from) - .collect(), - glob_scan_max_depth, - }, - PermissionProfileFileSystemPermissions::Unrestricted => Self::Unrestricted, - } - } +pub struct PermissionProfileListParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional page size; defaults to the full result set. + #[ts(optional = nullable)] + pub limit: Option, + /// Optional working directory to resolve project config layers. + #[ts(optional = nullable)] + pub cwd: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] +#[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] -pub enum PermissionProfile { - /// Codex owns sandbox construction for this profile. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Managed { - network: PermissionProfileNetworkPermissions, - file_system: PermissionProfileFileSystemPermissions, - }, - /// Do not apply an outer sandbox. - Disabled, - /// Filesystem isolation is enforced by an external caller. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - External { - network: PermissionProfileNetworkPermissions, - }, +pub struct PermissionProfileSummary { + /// Available permission profile identifier. + pub id: String, + /// Optional user-facing description for display in clients. + pub description: Option, } -impl From for PermissionProfile { - fn from(value: CorePermissionProfile) -> Self { - match value { - CorePermissionProfile::Managed { - file_system, - network, - } => Self::Managed { - network: network.into(), - file_system: file_system.into(), - }, - CorePermissionProfile::Disabled => Self::Disabled, - CorePermissionProfile::External { network } => Self::External { - network: network.into(), - }, - } - } -} - -impl From for CorePermissionProfile { - fn from(value: PermissionProfile) -> Self { - match value { - PermissionProfile::Managed { - file_system, - network, - } => Self::Managed { - file_system: file_system.into(), - network: network.into(), - }, - PermissionProfile::Disabled => Self::Disabled, - PermissionProfile::External { network } => Self::External { - network: network.into(), - }, - } - } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PermissionProfileListResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// If None, there are no more items to return. + pub next_cursor: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] @@ -433,44 +329,22 @@ pub struct ActivePermissionProfile { /// Identifier from `default_permissions` or the implicit built-in default, /// such as `:workspace` or a user-defined `[permissions.]` profile. pub id: String, - /// Parent profile identifier once permissions profiles support - /// inheritance. This is currently always `null`. + /// Parent profile identifier from the selected permissions profile's + /// `extends` setting, when present. #[serde(default)] pub extends: Option, - /// Bounded user-requested modifications applied on top of the named - /// profile, if any. - #[serde(default)] - pub modifications: Vec, } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum ActivePermissionProfileModification { - /// Additional concrete directory that should be writable. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - AdditionalWritableRoot { path: AbsolutePathBuf }, -} - -impl From for ActivePermissionProfileModification { - fn from(value: CoreActivePermissionProfileModification) -> Self { - match value { - CoreActivePermissionProfileModification::AdditionalWritableRoot { path } => { - Self::AdditionalWritableRoot { path } - } +impl ActivePermissionProfile { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + extends: None, } } -} -impl From for CoreActivePermissionProfileModification { - fn from(value: ActivePermissionProfileModification) -> Self { - match value { - ActivePermissionProfileModification::AdditionalWritableRoot { path } => { - Self::AdditionalWritableRoot { path } - } - } + pub fn read_only() -> Self { + CoreActivePermissionProfile::read_only().into() } } @@ -479,11 +353,6 @@ impl From for ActivePermissionProfile { Self { id: value.id, extends: value.extends, - modifications: value - .modifications - .into_iter() - .map(ActivePermissionProfileModification::from) - .collect(), } } } @@ -493,42 +362,10 @@ impl From for CoreActivePermissionProfile { Self { id: value.id, extends: value.extends, - modifications: value - .modifications - .into_iter() - .map(CoreActivePermissionProfileModification::from) - .collect(), } } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PermissionProfileSelectionParams { - /// Select a named built-in or user-defined profile and optionally apply - /// bounded modifications that Codex knows how to validate. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Profile { - id: String, - #[ts(optional = nullable)] - modifications: Option>, - }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PermissionProfileModificationParams { - /// Additional concrete directory that should be writable. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - AdditionalWritableRoot { path: AbsolutePathBuf }, -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs index 3ffab53218..1b7e8ba8ff 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -35,6 +35,18 @@ pub struct SkillsListResponse { pub data: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsExtraRootsSetParams { + pub extra_roots: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsExtraRootsSetResponse {} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -125,12 +137,28 @@ pub struct PluginListParams { pub marketplace_kinds: Option>, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginInstalledParams { + /// Optional working directories used to discover repo marketplaces. + #[ts(optional = nullable)] + pub cwds: Option>, + /// Additional uninstalled plugin names that should be returned when present locally. + /// This is used by mention surfaces that intentionally expose install entrypoints. + #[ts(optional = nullable)] + pub install_suggestion_plugin_names: Option>, +} + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] #[ts(export_to = "v2/")] 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, @@ -150,6 +178,15 @@ pub struct PluginListResponse { pub featured_plugin_ids: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginInstalledResponse { + pub marketplaces: Vec, + #[serde(default)] + pub marketplace_load_errors: Vec, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs b/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs index e89a9d19b8..c8ad617e7d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs @@ -9,6 +9,7 @@ use ts_rs::TS; #[ts(export_to = "v2/")] pub struct RemoteControlStatusChangedNotification { pub status: RemoteControlConnectionStatus, + pub server_name: String, pub installation_id: String, pub environment_id: Option, } @@ -18,6 +19,7 @@ pub struct RemoteControlStatusChangedNotification { #[ts(export_to = "v2/")] pub struct RemoteControlEnableResponse { pub status: RemoteControlConnectionStatus, + pub server_name: String, pub installation_id: String, pub environment_id: Option, } @@ -27,6 +29,17 @@ pub struct RemoteControlEnableResponse { #[ts(export_to = "v2/")] pub struct RemoteControlDisableResponse { pub status: RemoteControlConnectionStatus, + pub server_name: String, + pub installation_id: String, + pub environment_id: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlStatusReadResponse { + pub status: RemoteControlConnectionStatus, + pub server_name: String, pub installation_id: String, pub environment_id: Option, } @@ -45,11 +58,13 @@ impl From for RemoteControlEnableRespons fn from(notification: RemoteControlStatusChangedNotification) -> Self { let RemoteControlStatusChangedNotification { status, + server_name, installation_id, environment_id, } = notification; Self { status, + server_name, installation_id, environment_id, } @@ -60,11 +75,13 @@ impl From for RemoteControlDisableRespon fn from(notification: RemoteControlStatusChangedNotification) -> Self { let RemoteControlStatusChangedNotification { status, + server_name, installation_id, environment_id, } = notification; Self { status, + server_name, installation_id, environment_id, } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index f7041cc721..1e7558363d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -11,11 +11,13 @@ use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; use codex_protocol::items::WebSearchItem; use codex_protocol::mcp::CallToolResult; +use codex_protocol::mcp::McpServerInfo; use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation; use codex_protocol::memory_citation::MemoryCitationEntry as CoreMemoryCitationEntry; use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; -use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; +use codex_protocol::models::ImageDetail; use codex_protocol::models::MessagePhase; use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; use codex_protocol::models::WebSearchAction as CoreWebSearchAction; @@ -110,6 +112,86 @@ fn thread_turns_list_params_accepts_items_view() { assert_eq!(params.items_view, Some(TurnItemsView::NotLoaded)); } +#[test] +fn thread_resume_params_accept_turns_page_bootstrap() { + let params = serde_json::from_value::(json!({ + "threadId": "thr_123", + "initialTurnsPage": { + "limit": 25, + "sortDirection": "asc", + "itemsView": "full", + }, + })) + .expect("thread resume params should deserialize"); + + assert_eq!(params.thread_id, "thr_123"); + assert_eq!( + params.initial_turns_page, + Some(ThreadResumeInitialTurnsPageParams { + limit: Some(25), + sort_direction: Some(SortDirection::Asc), + items_view: Some(TurnItemsView::Full), + }) + ); +} + +#[test] +fn thread_resume_response_round_trips_initial_turns_page() { + let response = ThreadResumeResponse { + thread: Thread { + id: "thr_123".to_string(), + session_id: "thr_123".to_string(), + forked_from_id: None, + parent_thread_id: None, + preview: String::new(), + ephemeral: false, + model_provider: "openai".to_string(), + created_at: 1, + updated_at: 1, + status: ThreadStatus::Idle, + path: None, + cwd: absolute_path("tmp"), + cli_version: "0.0.0".to_string(), + source: SessionSource::Exec, + thread_source: None, + agent_nickname: None, + agent_role: None, + git_info: None, + name: None, + turns: Vec::new(), + }, + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + service_tier: None, + cwd: absolute_path("tmp"), + runtime_workspace_roots: Vec::new(), + instruction_sources: Vec::new(), + approval_policy: AskForApproval::OnFailure, + approvals_reviewer: ApprovalsReviewer::User, + sandbox: SandboxPolicy::DangerFullAccess, + active_permission_profile: None, + reasoning_effort: None, + initial_turns_page: Some(TurnsPage { + data: Vec::new(), + next_cursor: Some("cursor_next".to_string()), + backwards_cursor: Some("cursor_back".to_string()), + }), + }; + + let value = serde_json::to_value(&response).expect("serialize thread resume response"); + assert_eq!( + value.get("initialTurnsPage"), + Some(&json!({ + "data": [], + "nextCursor": "cursor_next", + "backwardsCursor": "cursor_back", + })) + ); + let decoded = serde_json::from_value::(value) + .expect("deserialize thread resume response"); + assert_eq!(decoded, response); +} + #[test] fn thread_turns_items_list_round_trips() { let params = ThreadTurnsItemsListParams { @@ -420,7 +502,7 @@ fn additional_file_system_permissions_preserves_canonical_entries() { path: CoreFileSystemPath::GlobPattern { pattern: "**/*.env".to_string(), }, - access: CoreFileSystemAccessMode::None, + access: CoreFileSystemAccessMode::Deny, }, ], glob_scan_max_depth: NonZeroUsize::new(2), @@ -444,7 +526,7 @@ fn additional_file_system_permissions_preserves_canonical_entries() { path: FileSystemPath::GlobPattern { pattern: "**/*.env".to_string(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]), } @@ -505,48 +587,6 @@ fn additional_file_system_permissions_rejects_zero_glob_scan_depth() { .expect_err("zero glob scan depth should fail deserialization"); } -#[test] -fn permission_profile_file_system_permissions_preserves_glob_scan_depth() { - let core_permissions = CoreManagedFileSystemPermissions::Restricted { - entries: vec![CoreFileSystemSandboxEntry { - path: CoreFileSystemPath::GlobPattern { - pattern: "**/*.env".to_string(), - }, - access: CoreFileSystemAccessMode::None, - }], - glob_scan_max_depth: NonZeroUsize::new(2), - }; - - let permissions = PermissionProfileFileSystemPermissions::from(core_permissions.clone()); - - assert_eq!( - permissions, - PermissionProfileFileSystemPermissions::Restricted { - entries: vec![FileSystemSandboxEntry { - path: FileSystemPath::GlobPattern { - pattern: "**/*.env".to_string(), - }, - access: FileSystemAccessMode::None, - }], - glob_scan_max_depth: NonZeroUsize::new(2), - } - ); - assert_eq!( - CoreManagedFileSystemPermissions::from(permissions), - core_permissions - ); -} - -#[test] -fn permission_profile_file_system_permissions_rejects_zero_glob_scan_depth() { - serde_json::from_value::(json!({ - "type": "restricted", - "entries": [], - "globScanMaxDepth": 0, - })) - .expect_err("zero glob scan depth should fail deserialization"); -} - #[test] fn legacy_current_working_directory_special_path_deserializes_as_project_roots() { let special_path = serde_json::from_value::(json!({ @@ -655,6 +695,80 @@ fn permissions_request_approval_response_accepts_strict_auto_review() { assert_eq!(response.strict_auto_review, Some(true)); } +#[test] +fn permission_profile_selection_uses_id_string() { + let start: ThreadStartParams = serde_json::from_value(json!({ + "permissions": BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + })) + .expect("thread/start params deserialize"); + assert_eq!( + start.permissions, + Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()) + ); + + let turn: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread-1", + "input": [], + "permissions": "dev", + })) + .expect("turn/start params deserialize"); + assert_eq!(turn.permissions, Some("dev".to_string())); + + let command: CommandExecParams = serde_json::from_value(json!({ + "command": ["echo", "hello"], + "permissionProfile": "dev", + })) + .expect("command/exec params deserialize"); + assert_eq!(command.permission_profile, Some("dev".to_string())); + + let resume: ThreadResumeParams = serde_json::from_value(json!({ + "threadId": "thread-1", + "permissions": BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + })) + .expect("thread/resume params deserialize"); + assert_eq!( + resume.permissions, + Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()) + ); + + let fork: ThreadForkParams = serde_json::from_value(json!({ + "threadId": "thread-1", + "permissions": BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + })) + .expect("thread/fork params deserialize"); + assert_eq!( + fork.permissions, + Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()) + ); +} + +#[test] +fn thread_path_params_deserialize_empty_path_as_none() { + let resume: ThreadResumeParams = serde_json::from_value(json!({ + "threadId": "thread-1", + "path": "", + })) + .expect("thread/resume params deserialize"); + assert_eq!(resume.path, None); + + let fork: ThreadForkParams = serde_json::from_value(json!({ + "threadId": "thread-1", + "path": "", + })) + .expect("thread/fork params deserialize"); + assert_eq!(fork.path, None); + + let resume_with_path: ThreadResumeParams = serde_json::from_value(json!({ + "threadId": "thread-1", + "path": "/tmp/resume-thread.jsonl", + })) + .expect("thread/resume params deserialize"); + assert_eq!( + resume_with_path.path, + Some(PathBuf::from("/tmp/resume-thread.jsonl")) + ); +} + #[test] fn fs_get_metadata_response_round_trips_minimal_fields() { let response = FsGetMetadataResponse { @@ -1472,32 +1586,6 @@ fn ask_for_approval_granular_is_marked_experimental() { ); } -#[test] -fn profile_v2_granular_approval_policy_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&ProfileV2 { - model: None, - model_provider: None, - approval_policy: Some(AskForApproval::Granular { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: true, - mcp_elicitations: false, - }), - approvals_reviewer: None, - service_tier: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - web_search: None, - tools: None, - chatgpt_base_url: None, - additional: HashMap::new(), - }); - - assert_eq!(reason, Some("askForApproval.granular")); -} - #[test] fn config_granular_approval_policy_is_marked_experimental() { let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { @@ -1505,6 +1593,7 @@ fn config_granular_approval_policy_is_marked_experimental() { review_model: None, model_context_window: None, model_auto_compact_token_limit: None, + model_auto_compact_token_limit_scope: None, model_provider: None, approval_policy: Some(AskForApproval::Granular { sandbox_approval: false, @@ -1520,8 +1609,6 @@ fn config_granular_approval_policy_is_marked_experimental() { forced_login_method: None, web_search: None, tools: None, - profile: None, - profiles: HashMap::new(), instructions: None, developer_instructions: None, compact_prompt: None, @@ -1531,6 +1618,7 @@ fn config_granular_approval_policy_is_marked_experimental() { service_tier: None, analytics: None, apps: None, + desktop: None, additional: HashMap::new(), }); @@ -1544,6 +1632,7 @@ fn config_approvals_reviewer_is_marked_experimental() { review_model: None, model_context_window: None, model_auto_compact_token_limit: None, + model_auto_compact_token_limit_scope: None, model_provider: None, approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::AutoReview), @@ -1553,112 +1642,6 @@ fn config_approvals_reviewer_is_marked_experimental() { forced_login_method: None, web_search: None, tools: None, - profile: None, - profiles: HashMap::new(), - instructions: None, - developer_instructions: None, - compact_prompt: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - service_tier: None, - analytics: None, - apps: None, - additional: HashMap::new(), - }); - - assert_eq!(reason, Some("config/read.approvalsReviewer")); -} - -#[test] -fn config_nested_profile_granular_approval_policy_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { - model: None, - review_model: None, - model_context_window: None, - model_auto_compact_token_limit: None, - model_provider: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_mode: None, - sandbox_workspace_write: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search: None, - tools: None, - profile: None, - profiles: HashMap::from([( - "default".to_string(), - ProfileV2 { - model: None, - model_provider: None, - approval_policy: Some(AskForApproval::Granular { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: false, - mcp_elicitations: true, - }), - approvals_reviewer: None, - service_tier: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - web_search: None, - tools: None, - chatgpt_base_url: None, - additional: HashMap::new(), - }, - )]), - instructions: None, - developer_instructions: None, - compact_prompt: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - service_tier: None, - analytics: None, - apps: None, - additional: HashMap::new(), - }); - - assert_eq!(reason, Some("askForApproval.granular")); -} - -#[test] -fn config_nested_profile_approvals_reviewer_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { - model: None, - review_model: None, - model_context_window: None, - model_auto_compact_token_limit: None, - model_provider: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_mode: None, - sandbox_workspace_write: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search: None, - tools: None, - profile: None, - profiles: HashMap::from([( - "default".to_string(), - ProfileV2 { - model: None, - model_provider: None, - approval_policy: None, - approvals_reviewer: Some(ApprovalsReviewer::AutoReview), - service_tier: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - web_search: None, - tools: None, - chatgpt_base_url: None, - additional: HashMap::new(), - }, - )]), instructions: None, developer_instructions: None, compact_prompt: None, @@ -1668,6 +1651,7 @@ fn config_nested_profile_approvals_reviewer_is_marked_experimental() { service_tier: None, analytics: None, apps: None, + desktop: None, additional: HashMap::new(), }); @@ -1687,8 +1671,12 @@ fn config_requirements_granular_allowed_approval_policy_is_marked_experimental() }]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_windows_sandbox_implementations: None, + allowed_permissions: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, + computer_use: None, feature_requirements: None, hooks: None, enforce_residency: None, @@ -1770,6 +1758,7 @@ fn client_request_turn_start_granular_approval_policy_is_marked_experimental() { request_id: crate::RequestId::Integer(4), params: TurnStartParams { thread_id: "thr_123".to_string(), + client_user_message_id: None, input: Vec::new(), approval_policy: Some(AskForApproval::Granular { sandbox_approval: false, @@ -1793,6 +1782,7 @@ fn mcp_server_elicitation_response_round_trips_rmcp_result() { content: Some(json!({ "confirmed": true, })), + meta: None, }; let v2_response = McpServerElicitationRequestResponse::from(rmcp_result.clone()); @@ -2017,6 +2007,80 @@ fn mcp_server_elicitation_response_serializes_nullable_content() { ); } +#[test] +fn mcp_server_status_serializes_absent_server_info_as_null() { + let response = ListMcpServerStatusResponse { + data: vec![McpServerStatus { + name: "not-ready".to_string(), + server_info: None, + tools: HashMap::new(), + resources: Vec::new(), + resource_templates: Vec::new(), + auth_status: McpAuthStatus::Unsupported, + }], + next_cursor: None, + }; + + assert_eq!( + serde_json::to_value(response).expect("response should serialize"), + json!({ + "data": [{ + "name": "not-ready", + "serverInfo": null, + "tools": {}, + "resources": [], + "resourceTemplates": [], + "authStatus": "unsupported", + }], + "nextCursor": null, + }) + ); +} + +#[test] +fn mcp_server_status_serializes_absent_server_info_metadata_as_null() { + let response = ListMcpServerStatusResponse { + data: vec![McpServerStatus { + name: "initialized".to_string(), + server_info: Some(McpServerInfo { + name: "lookup-server".to_string(), + title: None, + version: "1.0.0".to_string(), + description: None, + icons: None, + website_url: None, + }), + tools: HashMap::new(), + resources: Vec::new(), + resource_templates: Vec::new(), + auth_status: McpAuthStatus::Unsupported, + }], + next_cursor: None, + }; + + assert_eq!( + serde_json::to_value(response).expect("response should serialize"), + json!({ + "data": [{ + "name": "initialized", + "serverInfo": { + "name": "lookup-server", + "title": null, + "version": "1.0.0", + "description": null, + "icons": null, + "websiteUrl": null, + }, + "tools": {}, + "resources": [], + "resourceTemplates": [], + "authStatus": "unsupported", + }], + "nextCursor": null, + }) + ); +} + #[test] fn sandbox_policy_round_trips_workspace_write_access() { let v2_policy = SandboxPolicy::WorkspaceWrite { @@ -2216,7 +2280,7 @@ fn network_requirements_serializes_canonical_and_legacy_fields() { ), ( "/tmp/ignored.sock".to_string(), - NetworkUnixSocketPermission::None, + NetworkUnixSocketPermission::Deny, ), ])), allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), @@ -2240,7 +2304,7 @@ fn network_requirements_serializes_canonical_and_legacy_fields() { "allowedDomains": ["api.openai.com"], "deniedDomains": ["blocked.example.com"], "unixSockets": { - "/tmp/ignored.sock": "none", + "/tmp/ignored.sock": "deny", "/tmp/proxy.sock": "allow" }, "allowUnixSockets": ["/tmp/proxy.sock"], @@ -2253,6 +2317,7 @@ fn network_requirements_serializes_canonical_and_legacy_fields() { fn core_turn_item_into_thread_item_converts_supported_variants() { let user_item = TurnItem::UserMessage(UserMessageItem { id: "user-1".to_string(), + client_id: Some("client-message-1".to_string()), content: vec![ CoreUserInput::Text { text: "hello".to_string(), @@ -2260,9 +2325,11 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { }, CoreUserInput::Image { image_url: "https://example.com/image.png".to_string(), + detail: Some(ImageDetail::Original), }, CoreUserInput::LocalImage { path: PathBuf::from("local/image.png"), + detail: Some(ImageDetail::Original), }, CoreUserInput::Skill { name: "skill-creator".to_string(), @@ -2279,6 +2346,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { ThreadItem::from(user_item), ThreadItem::UserMessage { id: "user-1".to_string(), + client_id: Some("client-message-1".to_string()), content: vec![ UserInput::Text { text: "hello".to_string(), @@ -2286,9 +2354,11 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { }, UserInput::Image { url: "https://example.com/image.png".to_string(), + detail: Some(ImageDetail::Original), }, UserInput::LocalImage { path: PathBuf::from("local/image.png"), + detail: Some(ImageDetail::Original), }, UserInput::Skill { name: "skill-creator".to_string(), @@ -2445,6 +2515,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { tool: "tool".to_string(), arguments: json!({"arg": "value"}), mcp_app_resource_uri: Some("app://connector".to_string()), + plugin_id: Some("sample@test".to_string()), status: CoreMcpToolCallStatus::InProgress, result: None, error: None, @@ -2460,6 +2531,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { status: McpToolCallStatus::InProgress, arguments: json!({"arg": "value"}), mcp_app_resource_uri: Some("app://connector".to_string()), + plugin_id: Some("sample@test".to_string()), result: None, error: None, duration_ms: None, @@ -2472,6 +2544,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { tool: "tool".to_string(), arguments: JsonValue::Null, mcp_app_resource_uri: None, + plugin_id: None, status: CoreMcpToolCallStatus::Completed, result: Some(CallToolResult { content: vec![json!({"type": "text", "text": "ok"})], @@ -2492,6 +2565,7 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { status: McpToolCallStatus::Completed, arguments: JsonValue::Null, mcp_app_resource_uri: None, + plugin_id: None, result: Some(Box::new(McpToolCallResult { content: vec![json!({"type": "text", "text": "ok"})], structured_content: Some(json!({"ok": true})), @@ -2503,6 +2577,33 @@ fn core_turn_item_into_thread_item_converts_supported_variants() { ); } +#[test] +fn user_input_into_core_preserves_image_detail() { + assert_eq!( + UserInput::Image { + url: "https://example.com/image.png".to_string(), + detail: Some(ImageDetail::Original), + } + .into_core(), + CoreUserInput::Image { + image_url: "https://example.com/image.png".to_string(), + detail: Some(ImageDetail::Original), + } + ); + + assert_eq!( + UserInput::LocalImage { + path: PathBuf::from("local/image.png"), + detail: Some(ImageDetail::Original), + } + .into_core(), + CoreUserInput::LocalImage { + path: PathBuf::from("local/image.png"), + detail: Some(ImageDetail::Original), + } + ); +} + #[test] fn skills_list_params_serialization_uses_force_reload() { assert_eq!( @@ -2527,6 +2628,27 @@ fn skills_list_params_serialization_uses_force_reload() { ); } +#[test] +fn skills_extra_roots_set_params_serialization_uses_extra_roots() { + assert_eq!( + serde_json::to_value(SkillsExtraRootsSetParams { + extra_roots: vec![absolute_path("tmp/skills")], + }) + .unwrap(), + json!({ + "extraRoots": [absolute_path_string("tmp/skills")], + }), + ); +} + +#[test] +fn skills_extra_roots_set_params_rejects_relative_roots() { + let result = serde_json::from_value::(json!({ + "extraRoots": ["relative/path"], + })); + assert!(result.is_err()); +} + #[test] fn plugin_source_serializes_local_git_and_remote_variants() { let local_path = if cfg!(windows) { @@ -2635,14 +2757,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": [], @@ -2726,6 +2848,7 @@ fn plugin_list_params_serializes_marketplace_kind_filter() { cwds: None, marketplace_kinds: Some(vec![ PluginListMarketplaceKind::Local, + PluginListMarketplaceKind::Vertical, PluginListMarketplaceKind::WorkspaceDirectory, PluginListMarketplaceKind::SharedWithMe, ]), @@ -2735,6 +2858,7 @@ fn plugin_list_params_serializes_marketplace_kind_filter() { "cwds": null, "marketplaceKinds": [ "local", + "vertical", "workspace-directory", "shared-with-me", ], @@ -2742,6 +2866,27 @@ fn plugin_list_params_serializes_marketplace_kind_filter() { ); } +#[test] +fn plugin_installed_params_serializes_install_suggestion_names() { + assert_eq!( + serde_json::to_value(PluginInstalledParams { + cwds: None, + install_suggestion_plugin_names: Some(vec![ + "computer-use".to_string(), + "chrome".to_string(), + ]), + }) + .unwrap(), + json!({ + "cwds": null, + "installSuggestionPluginNames": [ + "computer-use", + "chrome", + ], + }), + ); +} + #[test] fn plugin_read_params_serialization_uses_install_source_fields() { let marketplace_path = if cfg!(windows) { @@ -2781,13 +2926,13 @@ fn plugin_read_params_serialization_uses_install_source_fields() { assert_eq!( serde_json::from_value::(json!({ - "remoteMarketplaceName": "openai-curated", + "remoteMarketplaceName": "openai-curated-remote", "pluginName": "gmail", })) .unwrap(), PluginReadParams { marketplace_path: None, - remote_marketplace_name: Some("openai-curated".to_string()), + remote_marketplace_name: Some("openai-curated-remote".to_string()), plugin_name: "gmail".to_string(), }, ); @@ -2832,14 +2977,14 @@ fn plugin_install_params_serialization_omits_force_remote_sync() { assert_eq!( serde_json::from_value::(json!({ - "remoteMarketplaceName": "openai-curated", + "remoteMarketplaceName": "openai-curated-remote", "pluginName": "gmail", "forceRemoteSync": true, })) .unwrap(), PluginInstallParams { marketplace_path: None, - remote_marketplace_name: Some("openai-curated".to_string()), + remote_marketplace_name: Some("openai-curated-remote".to_string()), plugin_name: "gmail".to_string(), }, ); @@ -2849,13 +2994,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", }), @@ -3050,7 +3195,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(), ), @@ -3073,7 +3218,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", @@ -3437,13 +3582,12 @@ fn thread_lifecycle_responses_default_missing_optional_fields() { let fork: ThreadForkResponse = serde_json::from_value(response).expect("thread/fork response"); assert_eq!(start.instruction_sources, Vec::::new()); + assert_eq!(start.thread.parent_thread_id, None); assert_eq!(resume.instruction_sources, Vec::::new()); assert_eq!(fork.instruction_sources, Vec::::new()); - assert_eq!(start.permission_profile, None); - assert_eq!(resume.permission_profile, None); - assert_eq!(fork.permission_profile, None); assert_eq!(start.active_permission_profile, None); assert_eq!(resume.active_permission_profile, None); + assert_eq!(resume.initial_turns_page, None); assert_eq!(fork.active_permission_profile, None); } @@ -3465,10 +3609,13 @@ fn turn_start_params_preserve_explicit_null_service_tier() { let without_override = TurnStartParams { thread_id: "thread_123".to_string(), + client_user_message_id: None, input: vec![], responsesapi_client_metadata: None, + additional_context: None, environments: None, cwd: None, + runtime_workspace_roots: None, approval_policy: None, approvals_reviewer: None, sandbox_policy: None, @@ -3486,6 +3633,77 @@ fn turn_start_params_preserve_explicit_null_service_tier() { assert_eq!(serialized_without_override.get("serviceTier"), None); } +#[test] +fn thread_settings_update_params_preserve_explicit_null_service_tier() { + let params: ThreadSettingsUpdateParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "serviceTier": null + })) + .expect("params should deserialize"); + assert_eq!(params.service_tier, Some(None)); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!( + serialized.get("serviceTier"), + Some(&serde_json::Value::Null) + ); + + let without_override = ThreadSettingsUpdateParams { + thread_id: "thread_123".to_string(), + service_tier: None, + ..Default::default() + }; + let serialized_without_override = + serde_json::to_value(&without_override).expect("params should serialize"); + assert_eq!(serialized_without_override.get("serviceTier"), None); +} + +#[test] +fn thread_settings_update_params_preserve_field_level_experimental_gates() { + let permissions = ThreadSettingsUpdateParams { + thread_id: "thread_123".to_string(), + permissions: Some(":workspace".to_string()), + ..Default::default() + }; + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&permissions), + Some("thread/settings/update.permissions") + ); + + let granular_approval = ThreadSettingsUpdateParams { + thread_id: "thread_123".to_string(), + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: true, + rules: true, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + }), + ..Default::default() + }; + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&granular_approval), + Some("askForApproval.granular") + ); + + let collaboration_mode = ThreadSettingsUpdateParams { + thread_id: "thread_123".to_string(), + collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { + mode: codex_protocol::config_types::ModeKind::Plan, + settings: codex_protocol::config_types::Settings { + model: "mock-model".to_string(), + reasoning_effort: None, + developer_instructions: None, + }, + }), + ..Default::default() + }; + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&collaboration_mode), + Some("thread/settings/update.collaborationMode") + ); +} + #[test] fn turn_start_params_round_trip_environments() { let cwd = test_absolute_path(); diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 6298722cd8..78eddc0278 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -1,8 +1,6 @@ use super::ActivePermissionProfile; use super::ApprovalsReviewer; use super::AskForApproval; -use super::PermissionProfile; -use super::PermissionProfileSelectionParams; use super::SandboxMode; use super::SandboxPolicy; use super::Thread; @@ -14,7 +12,9 @@ use super::TurnEnvironmentParams; use super::TurnItemsView; use super::shared::v2_enum_from_core; use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::ThreadGoalStatus as CoreThreadGoalStatus; @@ -108,6 +108,11 @@ pub struct ThreadStartParams { pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, + /// Replace the thread's runtime workspace roots. Relative paths are + /// resolved against the effective cwd for the thread. + #[experimental("thread/start.runtimeWorkspaceRoots")] + #[ts(optional = nullable)] + pub runtime_workspace_roots: Option>, #[experimental(nested)] #[ts(optional = nullable)] pub approval_policy: Option, @@ -117,12 +122,10 @@ pub struct ThreadStartParams { pub approvals_reviewer: Option, #[ts(optional = nullable)] pub sandbox: Option, - /// Named profile selection for this thread. Cannot be combined with - /// `sandbox`. Use bounded `modifications` for supported turn/thread - /// adjustments instead of replacing the full permissions profile. + /// Named profile id for this thread. Cannot be combined with `sandbox`. #[experimental("thread/start.permissions")] #[ts(optional = nullable)] - pub permissions: Option, + pub permissions: Option, #[ts(optional = nullable)] pub config: Option>, #[ts(optional = nullable)] @@ -160,13 +163,13 @@ pub struct ThreadStartParams { /// If true, opt into emitting raw Responses API items on the event stream. /// This is for internal use only (e.g. Codex Cloud). #[experimental("thread/start.experimentalRawEvents")] - #[serde(default)] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub experimental_raw_events: bool, /// Deprecated and ignored by app-server. Kept only so older clients can /// continue sending the field while rollout persistence always uses the /// limited history policy. #[experimental("thread/start.persistFullHistory")] - #[serde(default)] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub persist_extended_history: bool, } @@ -196,6 +199,11 @@ pub struct ThreadStartResponse { pub model_provider: String, pub service_tier: Option, pub cwd: AbsolutePathBuf, + /// Thread-scoped runtime workspace roots used to materialize + /// `:workspace_roots`. + #[experimental("thread/start.runtimeWorkspaceRoots")] + #[serde(default)] + pub runtime_workspace_roots: Vec, /// Instruction source files currently loaded for this thread. #[serde(default)] pub instruction_sources: Vec, @@ -204,14 +212,8 @@ pub struct ThreadStartResponse { /// Reviewer currently used for approval requests on this thread. pub approvals_reviewer: ApprovalsReviewer, /// Legacy sandbox policy retained for compatibility. Experimental clients - /// should prefer `permissionProfile` when they need exact runtime - /// permissions. + /// should prefer `activePermissionProfile` for profile provenance. pub sandbox: SandboxPolicy, - /// Full active permissions for this thread. `activePermissionProfile` - /// carries display/provenance metadata for this runtime profile. - #[experimental("thread/start.permissionProfile")] - #[serde(default)] - pub permission_profile: Option, /// Named or implicit built-in profile that produced the active /// permissions, when known. #[experimental("thread/start.activePermissionProfile")] @@ -220,6 +222,93 @@ pub struct ThreadStartResponse { pub reasoning_effort: Option, } +#[derive( + Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, +)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSettingsUpdateParams { + pub thread_id: String, + /// Override the working directory for subsequent turns. + #[ts(optional = nullable)] + pub cwd: Option, + /// Override the approval policy for subsequent turns. + #[experimental(nested)] + #[ts(optional = nullable)] + pub approval_policy: Option, + /// Override where approval requests are routed for subsequent turns. + #[ts(optional = nullable)] + pub approvals_reviewer: Option, + /// Override the sandbox policy for subsequent turns. + #[ts(optional = nullable)] + pub sandbox_policy: Option, + /// Select a named permissions profile id for subsequent turns. Cannot be + /// combined with `sandboxPolicy`. + #[experimental("thread/settings/update.permissions")] + #[ts(optional = nullable)] + pub permissions: Option, + /// Override the model for subsequent turns. + #[ts(optional = nullable)] + pub model: Option, + /// Override the service tier for subsequent turns. `null` clears the + /// current service tier; omission leaves it unchanged. + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub service_tier: Option>, + /// Override the reasoning effort for subsequent turns. + #[ts(optional = nullable)] + pub effort: Option, + /// Override the reasoning summary for subsequent turns. + #[ts(optional = nullable)] + pub summary: Option, + /// EXPERIMENTAL - Set a pre-set collaboration mode for subsequent turns. + /// + /// For `collaboration_mode.settings.developer_instructions`, `null` means + /// "use the built-in instructions for the selected mode". + #[experimental("thread/settings/update.collaborationMode")] + #[ts(optional = nullable)] + pub collaboration_mode: Option, + /// Override the personality for subsequent turns. + #[ts(optional = nullable)] + pub personality: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSettingsUpdateResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSettings { + pub cwd: AbsolutePathBuf, + pub approval_policy: AskForApproval, + pub approvals_reviewer: ApprovalsReviewer, + pub sandbox_policy: SandboxPolicy, + pub active_permission_profile: Option, + pub model: String, + pub model_provider: String, + pub service_tier: Option, + pub effort: Option, + pub summary: Option, + pub collaboration_mode: CollaborationMode, + pub personality: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSettingsUpdatedNotification { + pub thread_id: String, + pub thread_settings: ThreadSettings, +} + #[derive( Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, )] @@ -230,8 +319,13 @@ pub struct ThreadStartResponse { /// 2. By history: instantiate the thread from memory and resume it. /// 3. By path: load the thread from disk by path and resume it. /// -/// The precedence is: history > path > thread_id. -/// If using history or path, the thread_id param will be ignored. +/// For non-running threads, the precedence is: history > non-empty path > thread_id. +/// If using history or a non-empty path for a non-running thread, the thread_id +/// param will be ignored. +/// +/// If thread_id identifies a running thread, app-server rejoins that thread and +/// treats a non-empty path as a consistency check against the active rollout path. +/// Empty string path values are treated as absent. /// /// Prefer using thread_id whenever possible. pub struct ThreadResumeParams { @@ -245,8 +339,14 @@ pub struct ThreadResumeParams { pub history: Option>, /// [UNSTABLE] Specify the rollout path to resume from. - /// If specified, the thread_id param will be ignored. + /// If specified for a non-running thread, the thread_id param will be ignored. + /// If thread_id identifies a running thread, the path must match the active + /// rollout path. #[experimental("thread/resume.path")] + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_empty_path_as_none" + )] #[ts(optional = nullable)] pub path: Option, @@ -265,6 +365,11 @@ pub struct ThreadResumeParams { pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, + /// Replace the thread's runtime workspace roots. Relative paths are + /// resolved against the effective cwd for the thread. + #[experimental("thread/resume.runtimeWorkspaceRoots")] + #[ts(optional = nullable)] + pub runtime_workspace_roots: Option>, #[experimental(nested)] #[ts(optional = nullable)] pub approval_policy: Option, @@ -274,12 +379,11 @@ pub struct ThreadResumeParams { pub approvals_reviewer: Option, #[ts(optional = nullable)] pub sandbox: Option, - /// Named profile selection for the resumed thread. Cannot be combined - /// with `sandbox`. Use bounded `modifications` for supported thread - /// adjustments instead of replacing the full permissions profile. + /// Named profile id for the resumed thread. Cannot be combined with + /// `sandbox`. #[experimental("thread/resume.permissions")] #[ts(optional = nullable)] - pub permissions: Option, + pub permissions: Option, #[ts(optional = nullable)] pub config: Option>, #[ts(optional = nullable)] @@ -294,11 +398,16 @@ pub struct ThreadResumeParams { #[experimental("thread/resume.excludeTurns")] #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub exclude_turns: bool, + /// When present, include a `thread/turns/list` page in the resume response + /// so clients can bootstrap recent turns without a second request. + #[experimental("thread/resume.initialTurnsPage")] + #[ts(optional = nullable)] + pub initial_turns_page: Option, /// Deprecated and ignored by app-server. Kept only so older clients can /// continue sending the field while rollout persistence always uses the /// limited history policy. #[experimental("thread/resume.persistFullHistory")] - #[serde(default)] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub persist_extended_history: bool, } @@ -311,6 +420,11 @@ pub struct ThreadResumeResponse { pub model_provider: String, pub service_tier: Option, pub cwd: AbsolutePathBuf, + /// Thread-scoped runtime workspace roots used to materialize + /// `:workspace_roots`. + #[experimental("thread/resume.runtimeWorkspaceRoots")] + #[serde(default)] + pub runtime_workspace_roots: Vec, /// Instruction source files currently loaded for this thread. #[serde(default)] pub instruction_sources: Vec, @@ -319,20 +433,52 @@ pub struct ThreadResumeResponse { /// Reviewer currently used for approval requests on this thread. pub approvals_reviewer: ApprovalsReviewer, /// Legacy sandbox policy retained for compatibility. Experimental clients - /// should prefer `permissionProfile` when they need exact runtime - /// permissions. + /// should prefer `activePermissionProfile` for profile provenance. pub sandbox: SandboxPolicy, - /// Full active permissions for this thread. `activePermissionProfile` - /// carries display/provenance metadata for this runtime profile. - #[experimental("thread/resume.permissionProfile")] - #[serde(default)] - pub permission_profile: Option, /// Named or implicit built-in profile that produced the active /// permissions, when known. #[experimental("thread/resume.activePermissionProfile")] #[serde(default)] pub active_permission_profile: Option, pub reasoning_effort: Option, + /// `thread/turns/list` page returned when requested by `initialTurnsPage`. + #[experimental("thread/resume.initialTurnsPage")] + #[serde(default)] + pub initial_turns_page: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadResumeInitialTurnsPageParams { + /// Optional turn page size. + #[ts(optional = nullable)] + pub limit: Option, + /// Optional turn pagination direction; defaults to descending. + #[ts(optional = nullable)] + pub sort_direction: Option, + /// How much item detail to include for each returned turn; defaults to summary. + #[ts(optional = nullable)] + pub items_view: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnsPage { + pub data: Vec, + pub next_cursor: Option, + pub backwards_cursor: Option, +} + +impl From for TurnsPage { + fn from(response: ThreadTurnsListResponse) -> Self { + Self { + data: response.data, + next_cursor: response.next_cursor, + backwards_cursor: response.backwards_cursor, + } + } } #[derive( @@ -344,7 +490,8 @@ pub struct ThreadResumeResponse { /// 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. /// 2. By path: load the thread from disk by path and fork it into a new thread. /// -/// If using path, the thread_id param will be ignored. +/// If using a non-empty path, the thread_id param will be ignored. +/// Empty string path values are treated as absent. /// /// Prefer using thread_id whenever possible. pub struct ThreadForkParams { @@ -353,6 +500,10 @@ pub struct ThreadForkParams { /// [UNSTABLE] Specify the rollout path to fork from. /// If specified, the thread_id param will be ignored. #[experimental("thread/fork.path")] + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_empty_path_as_none" + )] #[ts(optional = nullable)] pub path: Option, @@ -371,6 +522,11 @@ pub struct ThreadForkParams { pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, + /// Replace the thread's runtime workspace roots. Relative paths are + /// resolved against the effective cwd for the thread. + #[experimental("thread/fork.runtimeWorkspaceRoots")] + #[ts(optional = nullable)] + pub runtime_workspace_roots: Option>, #[experimental(nested)] #[ts(optional = nullable)] pub approval_policy: Option, @@ -380,12 +536,11 @@ pub struct ThreadForkParams { pub approvals_reviewer: Option, #[ts(optional = nullable)] pub sandbox: Option, - /// Named profile selection for the forked thread. Cannot be combined with - /// `sandbox`. Use bounded `modifications` for supported thread - /// adjustments instead of replacing the full permissions profile. + /// Named profile id for the forked thread. Cannot be combined with + /// `sandbox`. #[experimental("thread/fork.permissions")] #[ts(optional = nullable)] - pub permissions: Option, + pub permissions: Option, #[ts(optional = nullable)] pub config: Option>, #[ts(optional = nullable)] @@ -407,7 +562,7 @@ pub struct ThreadForkParams { /// continue sending the field while rollout persistence always uses the /// limited history policy. #[experimental("thread/fork.persistFullHistory")] - #[serde(default)] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub persist_extended_history: bool, } @@ -420,6 +575,11 @@ pub struct ThreadForkResponse { pub model_provider: String, pub service_tier: Option, pub cwd: AbsolutePathBuf, + /// Thread-scoped runtime workspace roots used to materialize + /// `:workspace_roots`. + #[experimental("thread/fork.runtimeWorkspaceRoots")] + #[serde(default)] + pub runtime_workspace_roots: Vec, /// Instruction source files currently loaded for this thread. #[serde(default)] pub instruction_sources: Vec, @@ -428,14 +588,8 @@ pub struct ThreadForkResponse { /// Reviewer currently used for approval requests on this thread. pub approvals_reviewer: ApprovalsReviewer, /// Legacy sandbox policy retained for compatibility. Experimental clients - /// should prefer `permissionProfile` when they need exact runtime - /// permissions. + /// should prefer `activePermissionProfile` for profile provenance. pub sandbox: SandboxPolicy, - /// Full active permissions for this thread. `activePermissionProfile` - /// carries display/provenance metadata for this runtime profile. - #[experimental("thread/fork.permissionProfile")] - #[serde(default)] - pub permission_profile: Option, /// Named or implicit built-in profile that produced the active /// permissions, when known. #[experimental("thread/fork.activePermissionProfile")] @@ -543,6 +697,8 @@ v2_enum_from_core! { pub enum ThreadGoalStatus from CoreThreadGoalStatus { Active, Paused, + Blocked, + UsageLimited, BudgetLimited, Complete, } @@ -884,6 +1040,34 @@ pub struct ThreadListParams { pub search_term: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSearchParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional page size; defaults to a reasonable server-side value. + #[ts(optional = nullable)] + pub limit: Option, + /// Optional sort key; defaults to created_at. + #[ts(optional = nullable)] + pub sort_key: Option, + /// Optional sort direction; defaults to descending (newest first). + #[ts(optional = nullable)] + pub sort_direction: Option, + /// Optional source filter; when set, only sessions from these source kinds + /// are returned. When omitted or empty, defaults to interactive sources. + #[ts(optional = nullable)] + pub source_kinds: Option>, + /// Optional archived filter; when set to true, only archived threads are returned. + /// If false or null, only non-archived threads are returned. + #[ts(optional = nullable)] + pub archived: Option, + /// Required substring/full-text query for thread search. + pub search_term: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] #[serde(untagged)] pub enum ThreadListCwdFilter { @@ -940,6 +1124,29 @@ pub struct ThreadListResponse { pub backwards_cursor: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSearchResult { + pub thread: Thread, + pub snippet: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSearchResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// if None, there are no more items to return. + pub next_cursor: Option, + /// Opaque cursor to pass as `cursor` when reversing `sortDirection`. + /// This is only populated when the page contains at least one thread. + /// Use it with the opposite `sortDirection`; for timestamp sorts it anchors + /// at the start of the page timestamp so same-second updates are not skipped. + pub backwards_cursor: Option, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -992,7 +1199,7 @@ pub enum ThreadActiveFlag { pub struct ThreadReadParams { pub thread_id: String, /// When true, include turns and their items from rollout history. - #[serde(default)] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub include_turns: bool, } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs index 465f2248ce..112807ab5d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs @@ -119,6 +119,8 @@ pub struct Thread { pub session_id: String, /// Source thread id when this thread was created by forking another thread. pub forked_from_id: Option, + /// The ID of the parent thread. This will only be set if this thread is a subagent. + pub parent_thread_id: Option, /// Usually the first user message in the thread, if available. pub preview: String, /// Whether the thread is ephemeral and should not be materialized on disk. diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs index 61a09bfbf5..f9bfe21479 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -1,12 +1,12 @@ use super::ApprovalsReviewer; use super::AskForApproval; -use super::PermissionProfileSelectionParams; use super::SandboxPolicy; use super::Turn; use codex_experimental_api_macros::ExperimentalApi; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::models::ImageDetail; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::plan_tool::PlanItemArg as CorePlanItemArg; use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus; @@ -41,6 +41,23 @@ pub struct TurnEnvironmentParams { pub cwd: AbsolutePathBuf, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum AdditionalContextKind { + Untrusted, + Application, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AdditionalContextEntry { + pub value: String, + pub kind: AdditionalContextKind, +} + #[derive( Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, )] @@ -48,11 +65,17 @@ pub struct TurnEnvironmentParams { #[ts(export_to = "v2/")] pub struct TurnStartParams { pub thread_id: String, + #[ts(optional = nullable)] + pub client_user_message_id: Option, pub input: Vec, /// Optional turn-scoped Responses API client metadata. #[experimental("turn/start.responsesapiClientMetadata")] #[ts(optional = nullable)] pub responsesapi_client_metadata: Option>, + /// Optional client-provided context fragments keyed by an opaque source identifier. + #[experimental("turn/start.additionalContext")] + #[ts(optional = nullable)] + pub additional_context: Option>, /// Optional turn-scoped environments. /// /// Omitted uses the thread sticky environments. Empty disables @@ -64,6 +87,12 @@ pub struct TurnStartParams { /// Override the working directory for this turn and subsequent turns. #[ts(optional = nullable)] pub cwd: Option, + /// Replace the thread's runtime workspace roots for this turn and + /// subsequent turns. Relative paths are resolved against the effective + /// cwd for the turn. + #[experimental("turn/start.runtimeWorkspaceRoots")] + #[ts(optional = nullable)] + pub runtime_workspace_roots: Option>, /// Override the approval policy for this turn and subsequent turns. #[experimental(nested)] #[ts(optional = nullable)] @@ -75,13 +104,11 @@ pub struct TurnStartParams { /// Override the sandbox policy for this turn and subsequent turns. #[ts(optional = nullable)] pub sandbox_policy: Option, - /// Select a named permissions profile for this turn and subsequent turns. - /// Cannot be combined with `sandboxPolicy`. Use bounded `modifications` - /// for supported turn adjustments instead of replacing the full - /// permissions profile. + /// Select a named permissions profile id for this turn and subsequent + /// turns. Cannot be combined with `sandboxPolicy`. #[experimental("turn/start.permissions")] #[ts(optional = nullable)] - pub permissions: Option, + pub permissions: Option, /// Override the model for this turn and subsequent turns. #[ts(optional = nullable)] pub model: Option, @@ -132,11 +159,17 @@ pub struct TurnStartResponse { #[ts(export_to = "v2/")] pub struct TurnSteerParams { pub thread_id: String, + #[ts(optional = nullable)] + pub client_user_message_id: Option, pub input: Vec, /// Optional turn-scoped Responses API client metadata. #[experimental("turn/steer.responsesapiClientMetadata")] #[ts(optional = nullable)] pub responsesapi_client_metadata: Option>, + /// Optional client-provided context fragments keyed by an opaque source identifier. + #[experimental("turn/steer.additionalContext")] + #[ts(optional = nullable)] + pub additional_context: Option>, /// Required active turn id precondition. The request fails when it does not /// match the currently active turn. pub expected_turn_id: String, @@ -243,9 +276,15 @@ pub enum UserInput { text_elements: Vec, }, Image { + #[serde(default)] + #[ts(optional)] + detail: Option, url: String, }, LocalImage { + #[serde(default)] + #[ts(optional)] + detail: Option, path: PathBuf, }, Skill { @@ -268,8 +307,11 @@ impl UserInput { text, text_elements: text_elements.into_iter().map(Into::into).collect(), }, - UserInput::Image { url } => CoreUserInput::Image { image_url: url }, - UserInput::LocalImage { path } => CoreUserInput::LocalImage { path }, + UserInput::Image { url, detail } => CoreUserInput::Image { + image_url: url, + detail, + }, + UserInput::LocalImage { path, detail } => CoreUserInput::LocalImage { path, detail }, UserInput::Skill { name, path } => CoreUserInput::Skill { name, path }, UserInput::Mention { name, path } => CoreUserInput::Mention { name, path }, } @@ -286,8 +328,11 @@ impl From for UserInput { text, text_elements: text_elements.into_iter().map(Into::into).collect(), }, - CoreUserInput::Image { image_url } => UserInput::Image { url: image_url }, - CoreUserInput::LocalImage { path } => UserInput::LocalImage { path }, + CoreUserInput::Image { image_url, detail } => UserInput::Image { + url: image_url, + detail, + }, + CoreUserInput::LocalImage { path, detail } => UserInput::LocalImage { path, detail }, CoreUserInput::Skill { name, path } => UserInput::Skill { name, path }, CoreUserInput::Mention { name, path } => UserInput::Mention { name, path }, _ => unreachable!("unsupported user input variant"), diff --git a/codex-rs/app-server-protocol/src/schema_fixtures.rs b/codex-rs/app-server-protocol/src/schema_fixtures.rs index 447fcf8cfe..18ed557ddf 100644 --- a/codex-rs/app-server-protocol/src/schema_fixtures.rs +++ b/codex-rs/app-server-protocol/src/schema_fixtures.rs @@ -196,7 +196,7 @@ fn canonicalize_json(value: &Value) -> Value { } Value::Object(map) => { let mut entries: Vec<_> = map.iter().collect(); - entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + entries.sort_by_key(|(key, _)| *key); let mut sorted = Map::with_capacity(map.len()); for (key, child) in entries { sorted.insert(key.clone(), canonicalize_json(child)); diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index fd9ecc845d..e89a8dbe99 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -734,6 +734,7 @@ async fn trigger_zsh_fork_multi_cmd_approval( let mut turn_params = TurnStartParams { thread_id: thread_response.thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: message, text_elements: Vec::new(), @@ -818,6 +819,7 @@ async fn resume_message_v2( let turn_response = client.turn_start(TurnStartParams { thread_id: resume_response.thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: user_message, text_elements: Vec::new(), @@ -959,6 +961,7 @@ async fn send_message_v2_with_policies( println!("< thread/start response: {thread_response:?}"); let mut turn_params = TurnStartParams { thread_id: thread_response.thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: user_message, // Test client sends plain text without UI element ranges. @@ -999,6 +1002,7 @@ async fn send_follow_up_v2( let first_turn_params = TurnStartParams { thread_id: thread_response.thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: first_message, // Test client sends plain text without UI element ranges. @@ -1012,6 +1016,7 @@ async fn send_follow_up_v2( let follow_up_params = TurnStartParams { thread_id: thread_response.thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: follow_up_message, // Test client sends plain text without UI element ranges. @@ -1255,6 +1260,7 @@ fn live_elicitation_timeout_pause( let started_at = Instant::now(); let turn_response = client.turn_start(TurnStartParams { thread_id: thread_id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: prompt, text_elements: Vec::new(), diff --git a/codex-rs/app-server-transport/src/lib.rs b/codex-rs/app-server-transport/src/lib.rs index f4eb1fc6a5..8608102f36 100644 --- a/codex-rs/app-server-transport/src/lib.rs +++ b/codex-rs/app-server-transport/src/lib.rs @@ -6,6 +6,7 @@ pub use outgoing_message::OutgoingError; pub use outgoing_message::OutgoingMessage; pub use outgoing_message::OutgoingResponse; pub use outgoing_message::QueuedOutgoingMessage; +pub use transport::AppServerStartupLock; pub use transport::AppServerTransport; pub use transport::AppServerTransportParseError; pub use transport::CHANNEL_CAPACITY; @@ -14,8 +15,11 @@ pub use transport::RemoteControlHandle; pub use transport::RemoteControlStartConfig; pub use transport::RemoteControlUnavailable; pub use transport::TransportEvent; +pub use transport::acquire_app_server_startup_lock; pub use transport::app_server_control_socket_path; +pub use transport::app_server_startup_lock_path; pub use transport::auth; +pub use transport::prepare_control_socket_path; pub use transport::start_control_socket_acceptor; pub use transport::start_remote_control; pub use transport::start_stdio_connection; diff --git a/codex-rs/app-server-transport/src/transport/mod.rs b/codex-rs/app-server-transport/src/transport/mod.rs index a9e806c128..7452f06938 100644 --- a/codex-rs/app-server-transport/src/transport/mod.rs +++ b/codex-rs/app-server-transport/src/transport/mod.rs @@ -35,6 +35,9 @@ pub use remote_control::RemoteControlStartConfig; pub use remote_control::RemoteControlUnavailable; pub use remote_control::start_remote_control; pub use stdio::start_stdio_connection; +pub use unix_socket::AppServerStartupLock; +pub use unix_socket::acquire_app_server_startup_lock; +pub use unix_socket::prepare_control_socket_path; pub use unix_socket::start_control_socket_acceptor; pub use websocket::start_websocket_acceptor; @@ -42,6 +45,7 @@ const OVERLOADED_ERROR_CODE: i64 = -32001; const APP_SERVER_CONTROL_SOCKET_DIR_NAME: &str = "app-server-control"; const APP_SERVER_CONTROL_SOCKET_FILE_NAME: &str = "app-server-control.sock"; +const APP_SERVER_STARTUP_LOCK_FILE_NAME: &str = "app-server-startup.lock"; pub fn app_server_control_socket_path(codex_home: &Path) -> std::io::Result { AbsolutePathBuf::from_absolute_path( @@ -51,6 +55,14 @@ pub fn app_server_control_socket_path(codex_home: &Path) -> std::io::Result std::io::Result { + AbsolutePathBuf::from_absolute_path( + codex_home + .join(APP_SERVER_CONTROL_SOCKET_DIR_NAME) + .join(APP_SERVER_STARTUP_LOCK_FILE_NAME), + ) +} + #[derive(Clone, Debug, Eq, PartialEq)] pub enum AppServerTransport { Stdio, diff --git a/codex-rs/app-server-transport/src/transport/remote_control/client_tracker.rs b/codex-rs/app-server-transport/src/transport/remote_control/client_tracker.rs index 4639942b08..22e5e74eda 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/client_tracker.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/client_tracker.rs @@ -15,13 +15,21 @@ use codex_app_server_protocol::JSONRPCMessage; use std::collections::HashMap; use tokio::sync::mpsc; use tokio::sync::watch; +use tokio::task::JoinHandle; use tokio::task::JoinSet; use tokio::time::Duration; use tokio::time::Instant; +use tokio::time::timeout; use tokio_util::sync::CancellationToken; +use tracing::info; +use tracing::warn; const REMOTE_CONTROL_CLIENT_IDLE_TIMEOUT: Duration = Duration::from_secs(10 * 60); pub(crate) const REMOTE_CONTROL_IDLE_SWEEP_INTERVAL: Duration = Duration::from_secs(30); +#[cfg(not(test))] +const REMOTE_CONTROL_TRANSPORT_EVENT_SEND_TIMEOUT: Duration = Duration::from_secs(5); +#[cfg(test)] +const REMOTE_CONTROL_TRANSPORT_EVENT_SEND_TIMEOUT: Duration = Duration::from_millis(10); #[derive(Debug)] pub(crate) struct Stopped; @@ -138,9 +146,6 @@ impl ClientTracker { if let Some(connection_id) = self.clients.get_mut(&client_key).map(|client| { client.last_activity_at = Instant::now(); - if let Some(seq_id) = seq_id { - client.last_inbound_seq_id = Some(seq_id); - } client.connection_id }) { self.send_transport_event(TransportEvent::IncomingMessage { @@ -148,6 +153,7 @@ impl ClientTracker { message, }) .await?; + self.record_inbound_message_delivery(&client_key, seq_id); return Ok(()); } @@ -177,23 +183,37 @@ impl ClientTracker { disconnect_token.clone(), )); self.clients.insert( - client_key, + client_key.clone(), ClientState { connection_id, disconnect_token, last_activity_at: Instant::now(), - last_inbound_seq_id: if is_legacy_stream_id { None } else { seq_id }, + last_inbound_seq_id: None, status_tx, }, ); if is_legacy_stream_id { self.legacy_stream_ids.insert(client_id.clone(), stream_id); } - self.send_transport_event(TransportEvent::IncomingMessage { - connection_id, - message, - }) - .await + if let Err(err) = self + .send_transport_event(TransportEvent::IncomingMessage { + connection_id, + message, + }) + .await + { + if let Some(client) = self.remove_client(&client_key) { + client.disconnect_token.cancel(); + // The initialize send already timed out on this queue; preserve close + // delivery without blocking reconnect on the same backpressure. + drop(self.spawn_connection_closed(client.connection_id)); + } + return Err(err); + } + if !is_legacy_stream_id { + self.record_inbound_message_delivery(&client_key, seq_id); + } + Ok(()) } ClientEvent::ClientMessageChunk { .. } | ClientEvent::Ack { .. } => Ok(()), ClientEvent::Ping => { @@ -290,16 +310,9 @@ impl ClientTracker { &mut self, client_key: &(ClientId, StreamId), ) -> Result<(), Stopped> { - let Some(client) = self.clients.remove(client_key) else { + let Some(client) = self.remove_client(client_key) else { return Ok(()); }; - if self - .legacy_stream_ids - .get(&client_key.0) - .is_some_and(|stream_id| stream_id == &client_key.1) - { - self.legacy_stream_ids.remove(&client_key.0); - } client.disconnect_token.cancel(); self.send_transport_event(TransportEvent::ConnectionClosed { connection_id: client.connection_id, @@ -307,11 +320,109 @@ impl ClientTracker { .await } + fn remove_client(&mut self, client_key: &(ClientId, StreamId)) -> Option { + let client = self.clients.remove(client_key)?; + if self + .legacy_stream_ids + .get(&client_key.0) + .is_some_and(|stream_id| stream_id == &client_key.1) + { + self.legacy_stream_ids.remove(&client_key.0); + } + Some(client) + } + async fn send_transport_event(&self, event: TransportEvent) -> Result<(), Stopped> { - self.transport_event_tx - .send(event) - .await - .map_err(|_| Stopped) + let event = match event { + TransportEvent::ConnectionClosed { connection_id } => { + return self.send_connection_closed(connection_id).await; + } + event => event, + }; + + let event_name = transport_event_name(&event); + match timeout( + REMOTE_CONTROL_TRANSPORT_EVENT_SEND_TIMEOUT, + self.transport_event_tx.send(event), + ) + .await + { + Ok(Ok(())) => Ok(()), + Ok(Err(_)) => { + warn!( + transport_event = event_name, + "remote control transport event receiver dropped" + ); + Err(Stopped) + } + Err(_) => { + warn!( + transport_event = event_name, + timeout = ?REMOTE_CONTROL_TRANSPORT_EVENT_SEND_TIMEOUT, + "timed out forwarding remote control transport event" + ); + Err(Stopped) + } + } + } + + fn record_inbound_message_delivery( + &mut self, + client_key: &(ClientId, StreamId), + seq_id: Option, + ) { + // Timed forwarding can fail, so only dedupe retries after app-server receives it. + if let Some(seq_id) = seq_id + && let Some(client) = self.clients.get_mut(client_key) + { + client.last_inbound_seq_id = Some(seq_id); + } + } + + async fn send_connection_closed(&self, connection_id: ConnectionId) -> Result<(), Stopped> { + // Worker shutdown can abort the caller; detach the cleanup event before awaiting it. + match self.spawn_connection_closed(connection_id).await { + Ok(result) => result, + Err(err) => { + warn!( + transport_event = "connection_closed", + ?err, + "remote control transport event forwarding task failed" + ); + Err(Stopped) + } + } + } + + fn spawn_connection_closed( + &self, + connection_id: ConnectionId, + ) -> JoinHandle> { + info!( + connection_id = ?connection_id, + "forwarding remote control connection closed transport event" + ); + let transport_event_tx = self.transport_event_tx.clone(); + tokio::spawn(async move { + transport_event_tx + .send(TransportEvent::ConnectionClosed { connection_id }) + .await + .map_err(|_| { + warn!( + transport_event = "connection_closed", + "remote control transport event receiver dropped" + ); + Stopped + }) + }) + } +} + +fn transport_event_name(event: &TransportEvent) -> &'static str { + match event { + TransportEvent::ConnectionOpened { .. } => "connection_opened", + TransportEvent::ConnectionClosed { .. } => "connection_closed", + TransportEvent::IncomingMessage { .. } => "incoming_message", } } @@ -370,6 +481,13 @@ mod tests { } } + fn initialized_notification() -> JSONRPCMessage { + JSONRPCMessage::Notification(codex_app_server_protocol::JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }) + } + #[tokio::test] async fn cancelled_outbound_task_emits_connection_closed() { let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY); @@ -487,6 +605,256 @@ mod tests { .expect("shutdown should not hang on blocked server forwarding"); } + #[tokio::test] + async fn non_close_transport_event_send_times_out_when_queue_stays_full() { + let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (transport_event_tx, _transport_event_rx) = mpsc::channel(1); + let shutdown_token = CancellationToken::new(); + let client_tracker = + ClientTracker::new(server_event_tx, transport_event_tx.clone(), &shutdown_token); + + transport_event_tx + .send(TransportEvent::ConnectionClosed { + connection_id: next_connection_id(), + }) + .await + .expect("transport event queue should accept prefill"); + + let send_result = client_tracker + .send_transport_event(TransportEvent::IncomingMessage { + connection_id: next_connection_id(), + message: initialized_notification(), + }) + .await; + + assert!(send_result.is_err()); + } + + #[tokio::test] + async fn incoming_message_timeout_does_not_advance_seq_id() { + let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (transport_event_tx, mut transport_event_rx) = mpsc::channel(2); + let shutdown_token = CancellationToken::new(); + let mut client_tracker = + ClientTracker::new(server_event_tx, transport_event_tx.clone(), &shutdown_token); + + client_tracker + .handle_message(initialize_envelope_with_stream_id( + "client-1", + Some("stream-1"), + )) + .await + .expect("initialize should open client"); + let connection_id = match transport_event_rx.recv().await.expect("open event") { + TransportEvent::ConnectionOpened { connection_id, .. } => connection_id, + other => panic!("expected connection opened, got {other:?}"), + }; + let _ = transport_event_rx.recv().await.expect("initialize event"); + + for _ in 0..2 { + transport_event_tx + .send(TransportEvent::ConnectionClosed { + connection_id: next_connection_id(), + }) + .await + .expect("transport event queue should accept prefill"); + } + + let retry_envelope = ClientEnvelope { + event: ClientEvent::ClientMessage { + message: initialized_notification(), + }, + client_id: ClientId("client-1".to_string()), + stream_id: Some(StreamId("stream-1".to_string())), + seq_id: Some(1), + cursor: None, + }; + assert!( + client_tracker + .handle_message(retry_envelope.clone()) + .await + .is_err() + ); + for _ in 0..2 { + let _ = transport_event_rx.recv().await.expect("prefilled event"); + } + + client_tracker + .handle_message(retry_envelope) + .await + .expect("retry should forward after timeout"); + match transport_event_rx.recv().await.expect("retried event") { + TransportEvent::IncomingMessage { + connection_id: queued_connection_id, + .. + } => assert_eq!(queued_connection_id, connection_id), + other => panic!("expected incoming message, got {other:?}"), + } + } + + #[tokio::test] + async fn initialize_timeout_closes_open_connection() { + let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (transport_event_tx, mut transport_event_rx) = mpsc::channel(1); + let shutdown_token = CancellationToken::new(); + let client_tracker = + ClientTracker::new(server_event_tx, transport_event_tx, &shutdown_token); + let mut handle_message = tokio::spawn(async move { + let mut client_tracker = client_tracker; + client_tracker + .handle_message(initialize_envelope_with_stream_id( + "client-1", + Some("stream-1"), + )) + .await + }); + + assert!( + timeout(Duration::from_millis(50), &mut handle_message) + .await + .expect("initialize timeout rollback should not wait for close delivery") + .expect("handle message task should not panic") + .is_err() + ); + let connection_id = match transport_event_rx.recv().await.expect("open event") { + TransportEvent::ConnectionOpened { connection_id, .. } => connection_id, + other => panic!("expected connection opened, got {other:?}"), + }; + + match transport_event_rx.recv().await.expect("close event") { + TransportEvent::ConnectionClosed { + connection_id: closed_connection_id, + } => assert_eq!(closed_connection_id, connection_id), + other => panic!("expected connection closed, got {other:?}"), + } + } + + #[tokio::test] + async fn close_client_waits_for_transport_event_queue_capacity() { + let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (transport_event_tx, mut transport_event_rx) = mpsc::channel(2); + let shutdown_token = CancellationToken::new(); + let mut client_tracker = + ClientTracker::new(server_event_tx, transport_event_tx, &shutdown_token); + + client_tracker + .handle_message(initialize_envelope_with_stream_id( + "client-1", + Some("stream-1"), + )) + .await + .expect("initialize should open client"); + let connection_id = match transport_event_rx.recv().await.expect("open event") { + TransportEvent::ConnectionOpened { connection_id, .. } => connection_id, + other => panic!("expected connection opened, got {other:?}"), + }; + let _ = transport_event_rx.recv().await.expect("initialize event"); + + for _ in 0..2 { + client_tracker + .transport_event_tx + .send(TransportEvent::IncomingMessage { + connection_id, + message: initialized_notification(), + }) + .await + .expect("transport event queue should accept prefill"); + } + + let client_key = ( + ClientId("client-1".to_string()), + StreamId("stream-1".to_string()), + ); + let close_client = client_tracker.close_client(&client_key); + tokio::pin!(close_client); + assert!( + timeout(Duration::from_millis(20), &mut close_client) + .await + .is_err() + ); + + for _ in 0..2 { + match transport_event_rx.recv().await.expect("prefilled event") { + TransportEvent::IncomingMessage { + connection_id: queued_connection_id, + .. + } => assert_eq!(queued_connection_id, connection_id), + other => panic!("expected incoming message, got {other:?}"), + } + } + + close_client + .await + .expect("close should forward after queue drains"); + match transport_event_rx.recv().await.expect("close event") { + TransportEvent::ConnectionClosed { + connection_id: closed_connection_id, + } => assert_eq!(closed_connection_id, connection_id), + other => panic!("expected connection closed, got {other:?}"), + } + } + + #[tokio::test] + async fn close_client_keeps_forwarding_after_caller_is_aborted() { + let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY); + let (transport_event_tx, mut transport_event_rx) = mpsc::channel(2); + let shutdown_token = CancellationToken::new(); + let mut client_tracker = + ClientTracker::new(server_event_tx, transport_event_tx, &shutdown_token); + + client_tracker + .handle_message(initialize_envelope_with_stream_id( + "client-1", + Some("stream-1"), + )) + .await + .expect("initialize should open client"); + let connection_id = match transport_event_rx.recv().await.expect("open event") { + TransportEvent::ConnectionOpened { connection_id, .. } => connection_id, + other => panic!("expected connection opened, got {other:?}"), + }; + let _ = transport_event_rx.recv().await.expect("initialize event"); + + for _ in 0..2 { + client_tracker + .transport_event_tx + .send(TransportEvent::IncomingMessage { + connection_id, + message: initialized_notification(), + }) + .await + .expect("transport event queue should accept prefill"); + } + + let client_key = ( + ClientId("client-1".to_string()), + StreamId("stream-1".to_string()), + ); + let mut close_client = + tokio::spawn(async move { client_tracker.close_client(&client_key).await }); + assert!( + timeout(Duration::from_millis(20), &mut close_client) + .await + .is_err() + ); + close_client.abort(); + let _ = close_client.await; + + for _ in 0..2 { + let _ = transport_event_rx.recv().await.expect("prefilled event"); + } + match timeout(Duration::from_secs(1), transport_event_rx.recv()) + .await + .expect("close should be delivered") + .expect("close event") + { + TransportEvent::ConnectionClosed { + connection_id: closed_connection_id, + } => assert_eq!(closed_connection_id, connection_id), + other => panic!("expected connection closed, got {other:?}"), + } + } + #[tokio::test] async fn initialize_with_new_stream_id_opens_new_connection_for_same_client() { let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY); diff --git a/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs b/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs index 60dfc3d845..6037caa651 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs @@ -1,19 +1,24 @@ use super::protocol::EnrollRemoteServerRequest; use super::protocol::EnrollRemoteServerResponse; +use super::protocol::RefreshRemoteServerRequest; use super::protocol::RemoteControlTarget; use axum::http::HeaderMap; use codex_api::SharedAuthProvider; use codex_login::default_client::build_reqwest_client; use codex_state::RemoteControlEnrollmentRecord; use codex_state::StateRuntime; -use gethostname::gethostname; +use serde::Serialize; +use serde::de::DeserializeOwned; use std::io; use std::io::ErrorKind; +use time::OffsetDateTime; +use time::format_description::well_known::Rfc3339; use tracing::info; use tracing::warn; const REMOTE_CONTROL_ENROLL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); const REMOTE_CONTROL_RESPONSE_BODY_MAX_BYTES: usize = 4096; +const REMOTE_CONTROL_SERVER_TOKEN_REFRESH_SKEW_SECS: i64 = 30; const REQUEST_ID_HEADER: &str = "x-request-id"; const OAI_REQUEST_ID_HEADER: &str = "x-oai-request-id"; @@ -27,6 +32,24 @@ pub(super) struct RemoteControlEnrollment { pub(super) environment_id: String, pub(super) server_id: String, pub(super) server_name: String, + pub(super) remote_control_token: Option, + pub(super) expires_at: Option, +} + +impl RemoteControlEnrollment { + pub(super) fn should_refresh_server_token(&self) -> bool { + self.remote_control_token.is_none() + || self.expires_at.is_none_or(|expires_at| { + expires_at.unix_timestamp() + <= OffsetDateTime::now_utc().unix_timestamp() + + REMOTE_CONTROL_SERVER_TOKEN_REFRESH_SKEW_SECS + }) + } + + pub(super) fn clear_server_token(&mut self) { + self.remote_control_token = None; + self.expires_at = None; + } } pub(super) struct RemoteControlConnectionAuth { @@ -82,6 +105,8 @@ pub(super) async fn load_persisted_remote_control_enrollment( environment_id: enrollment.environment_id, server_id: enrollment.server_id, server_name: enrollment.server_name, + remote_control_token: None, + expires_at: None, })) } None => { @@ -165,19 +190,33 @@ pub(crate) fn preview_remote_control_response_body(body: &[u8]) -> String { if trimmed.is_empty() { return "".to_string(); } - if trimmed.len() <= REMOTE_CONTROL_RESPONSE_BODY_MAX_BYTES { - return trimmed.to_string(); + let redacted = redact_remote_control_response_body(trimmed); + if redacted.len() <= REMOTE_CONTROL_RESPONSE_BODY_MAX_BYTES { + return redacted; } let mut cut = REMOTE_CONTROL_RESPONSE_BODY_MAX_BYTES; - while !trimmed.is_char_boundary(cut) { + while !redacted.is_char_boundary(cut) { cut = cut.saturating_sub(1); } - let mut truncated = trimmed[..cut].to_string(); + let mut truncated = redacted[..cut].to_string(); truncated.push_str("..."); truncated } +fn redact_remote_control_response_body(body: &str) -> String { + let Ok(mut body_json) = serde_json::from_str::(body) else { + return body.to_string(); + }; + let Some(body_object) = body_json.as_object_mut() else { + return body.to_string(); + }; + if let Some(remote_control_token) = body_object.get_mut("remote_control_token") { + *remote_control_token = serde_json::Value::String("".to_string()); + } + body_json.to_string() +} + pub(crate) fn format_headers(headers: &HeaderMap) -> String { let request_id_str = headers .get(REQUEST_ID_HEADER) @@ -195,68 +234,156 @@ pub(super) async fn enroll_remote_control_server( remote_control_target: &RemoteControlTarget, auth: &RemoteControlConnectionAuth, installation_id: &str, + server_name: &str, ) -> io::Result { let enroll_url = &remote_control_target.enroll_url; - let server_name = gethostname().to_string_lossy().trim().to_string(); let request = EnrollRemoteServerRequest { - name: server_name.clone(), + name: server_name.to_string(), os: std::env::consts::OS, arch: std::env::consts::ARCH, app_server_version: env!("CARGO_PKG_VERSION"), installation_id: installation_id.to_string(), }; + let enrollment_response = send_remote_control_server_request::<_, EnrollRemoteServerResponse>( + enroll_url, + auth, + installation_id, + &request, + "enroll", + "server enrollment", + ) + .await?; + let mut enrollment = RemoteControlEnrollment { + account_id: auth.account_id.clone(), + environment_id: enrollment_response.environment_id, + server_id: enrollment_response.server_id, + server_name: server_name.to_string(), + remote_control_token: None, + expires_at: None, + }; + update_remote_control_server_token( + &mut enrollment, + enroll_url, + enrollment_response.remote_control_token, + enrollment_response.expires_at, + )?; + Ok(enrollment) +} + +pub(super) async fn refresh_remote_control_server( + remote_control_target: &RemoteControlTarget, + auth: &RemoteControlConnectionAuth, + installation_id: &str, + enrollment: &mut RemoteControlEnrollment, +) -> io::Result<()> { + let refresh_url = &remote_control_target.refresh_url; + let request = RefreshRemoteServerRequest { + server_id: enrollment.server_id.clone(), + installation_id: installation_id.to_string(), + }; + let refreshed = send_remote_control_server_request::<_, EnrollRemoteServerResponse>( + refresh_url, + auth, + installation_id, + &request, + "refresh", + "server refresh", + ) + .await?; + if refreshed.server_id != enrollment.server_id + || refreshed.environment_id != enrollment.environment_id + { + return Err(io::Error::other(format!( + "remote control server refresh returned mismatched enrollment: expected server_id={}, environment_id={}; got server_id={}, environment_id={}", + enrollment.server_id, + enrollment.environment_id, + refreshed.server_id, + refreshed.environment_id + ))); + } + + update_remote_control_server_token( + enrollment, + refresh_url, + refreshed.remote_control_token, + refreshed.expires_at, + ) +} + +async fn send_remote_control_server_request( + url: &str, + auth: &RemoteControlConnectionAuth, + installation_id: &str, + request: &Request, + action: &str, + response_kind: &str, +) -> io::Result +where + Request: Serialize, + Response: DeserializeOwned, +{ let client = build_reqwest_client(); let mut auth_headers = HeaderMap::new(); auth.auth_provider.add_auth_headers(&mut auth_headers); - let http_request = client - .post(enroll_url) + let response = client + .post(url) .timeout(REMOTE_CONTROL_ENROLL_TIMEOUT) .headers(auth_headers) .header(REMOTE_CONTROL_ACCOUNT_ID_HEADER, &auth.account_id) .header(REMOTE_CONTROL_INSTALLATION_ID_HEADER, installation_id) - .json(&request); - - let response = http_request.send().await.map_err(|err| { - io::Error::other(format!( - "failed to enroll remote control server at `{enroll_url}`: {err}" - )) - })?; + .json(request) + .send() + .await + .map_err(|err| { + io::Error::other(format!( + "failed to {action} remote control server at `{url}`: {err}" + )) + })?; let headers = response.headers().clone(); let status = response.status(); let body = response.bytes().await.map_err(|err| { io::Error::other(format!( - "failed to read remote control enrollment response from `{enroll_url}`: {err}" + "failed to read remote control {response_kind} response from `{url}`: {err}" )) })?; let body_preview = preview_remote_control_response_body(&body); if !status.is_success() { let headers_str = format_headers(&headers); - let error_kind = if matches!(status.as_u16(), 401 | 403) { - ErrorKind::PermissionDenied - } else { - ErrorKind::Other + let error_kind = match status.as_u16() { + 401 | 403 => ErrorKind::PermissionDenied, + 404 => ErrorKind::NotFound, + _ => ErrorKind::Other, }; return Err(io::Error::new( error_kind, format!( - "remote control server enrollment failed at `{enroll_url}`: HTTP {status}, {headers_str}, body: {body_preview}" + "remote control {response_kind} failed at `{url}`: HTTP {status}, {headers_str}, body: {body_preview}" ), )); } - let enrollment = serde_json::from_slice::(&body).map_err(|err| { + serde_json::from_slice::(&body).map_err(|err| { let headers_str = format_headers(&headers); io::Error::other(format!( - "failed to parse remote control enrollment response from `{enroll_url}`: HTTP {status}, {headers_str}, body: {body_preview}, decode error: {err}" + "failed to parse remote control {response_kind} response from `{url}`: HTTP {status}, {headers_str}, body: {body_preview}, decode error: {err}" + )) + }) +} + +fn update_remote_control_server_token( + enrollment: &mut RemoteControlEnrollment, + url: &str, + token: String, + expires_at: String, +) -> io::Result<()> { + let expires_at = OffsetDateTime::parse(&expires_at, &Rfc3339).map_err(|err| { + io::Error::other(format!( + "failed to parse remote control server token expiry from `{url}`: {err}" )) })?; - - Ok(RemoteControlEnrollment { - account_id: auth.account_id.clone(), - environment_id: enrollment.environment_id, - server_id: enrollment.server_id, - server_name, - }) + enrollment.remote_control_token = Some(token); + enrollment.expires_at = Some(expires_at); + Ok(()) } #[cfg(test)] @@ -282,6 +409,40 @@ mod tests { .expect("state runtime should initialize") } + #[test] + fn remote_control_enrollment_refreshes_server_token_before_expiry() { + let expires_soon = RemoteControlEnrollment { + account_id: "account-a".to_string(), + environment_id: "env_first".to_string(), + server_id: "srv_e_first".to_string(), + server_name: "first-server".to_string(), + remote_control_token: Some("expires-soon".to_string()), + expires_at: Some(OffsetDateTime::now_utc() + time::Duration::seconds(29)), + }; + let expires_later = RemoteControlEnrollment { + expires_at: Some(OffsetDateTime::now_utc() + time::Duration::seconds(31)), + remote_control_token: Some("expires-later".to_string()), + ..expires_soon.clone() + }; + + assert!(expires_soon.should_refresh_server_token()); + assert!(!expires_later.should_refresh_server_token()); + } + + #[test] + fn preview_remote_control_response_body_redacts_server_token() { + assert_eq!( + serde_json::from_str::(&preview_remote_control_response_body( + br#"{"server_id":"srv_e_test","remote_control_token":"secret"}"# + )) + .expect("redacted response preview should stay valid json"), + json!({ + "server_id": "srv_e_test", + "remote_control_token": "", + }) + ); + } + #[tokio::test] async fn persisted_remote_control_enrollment_round_trips_by_target_and_account() { let codex_home = TempDir::new().expect("temp dir should create"); @@ -296,12 +457,16 @@ mod tests { environment_id: "env_first".to_string(), server_id: "srv_e_first".to_string(), server_name: "first-server".to_string(), + remote_control_token: None, + expires_at: None, }; let second_enrollment = RemoteControlEnrollment { account_id: "account-a".to_string(), environment_id: "env_second".to_string(), server_id: "srv_e_second".to_string(), server_name: "second-server".to_string(), + remote_control_token: None, + expires_at: None, }; update_persisted_remote_control_enrollment( @@ -372,12 +537,16 @@ mod tests { environment_id: "env_first".to_string(), server_id: "srv_e_first".to_string(), server_name: "first-server".to_string(), + remote_control_token: None, + expires_at: None, }; let second_enrollment = RemoteControlEnrollment { account_id: "account-a".to_string(), environment_id: "env_second".to_string(), server_id: "srv_e_second".to_string(), server_name: "second-server".to_string(), + remote_control_token: None, + expires_at: None, }; update_persisted_remote_control_enrollment( @@ -449,7 +618,8 @@ mod tests { normalize_remote_control_url(&remote_control_url).expect("target should parse"); let enroll_url = remote_control_target.enroll_url.clone(); let response_body = json!({ - "error": "not enrolled", + "server_id": "srv_e_test", + "environment_id": "env_test", }); let expected_body = response_body.to_string(); let server_task = tokio::spawn(async move { @@ -464,6 +634,7 @@ mod tests { account_id: "account_id".to_string(), }, "11111111-1111-4111-8111-111111111111", + "test-server", ) .await .expect_err("invalid response should fail to parse"); @@ -472,7 +643,7 @@ mod tests { assert_eq!( err.to_string(), format!( - "failed to parse remote control enrollment response from `{enroll_url}`: HTTP 200 OK, request-id: , cf-ray: , body: {expected_body}, decode error: missing field `server_id` at line 1 column {}", + "failed to parse remote control server enrollment response from `{enroll_url}`: HTTP 200 OK, request-id: , cf-ray: , body: {expected_body}, decode error: missing field `remote_control_token` at line 1 column {}", expected_body.len() ) ); diff --git a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs index 8722f4e9ee..357a98a30d 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs @@ -19,15 +19,20 @@ use codex_app_server_protocol::RemoteControlConnectionStatus; use codex_app_server_protocol::RemoteControlStatusChangedNotification; use codex_login::AuthManager; use codex_state::StateRuntime; +use futures::FutureExt; +use gethostname::gethostname; use std::error::Error; use std::fmt; use std::io; +use std::panic::AssertUnwindSafe; use std::sync::Arc; use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::sync::watch; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; +use tracing::error; +use tracing::info; use tracing::warn; pub struct RemoteControlStartConfig { @@ -72,13 +77,21 @@ impl RemoteControlHandle { return Err(RemoteControlUnavailable); } - self.enabled_tx.send_if_modified(|state| { + let enabled_changed = self.enabled_tx.send_if_modified(|state| { let changed = !*state; *state = true; changed }); let status = self.status(); + info!( + enabled_changed, + current_status = ?status.status, + environment_id = ?status.environment_id, + installation_id = %status.installation_id, + server_name = %status.server_name, + "remote control enable requested" + ); if matches!( status.status, RemoteControlConnectionStatus::Connected | RemoteControlConnectionStatus::Connecting @@ -90,12 +103,21 @@ impl RemoteControlHandle { } pub fn disable(&self) -> RemoteControlStatusChangedNotification { - self.enabled_tx.send_if_modified(|state| { + let enabled_changed = self.enabled_tx.send_if_modified(|state| { let changed = *state; *state = false; changed }); + let status = self.status(); + info!( + enabled_changed, + current_status = ?status.status, + environment_id = ?status.environment_id, + installation_id = %status.installation_id, + server_name = %status.server_name, + "remote control disable requested" + ); self.publish_status(RemoteControlConnectionStatus::Disabled) } @@ -111,27 +133,49 @@ impl RemoteControlHandle { &self, connection_status: RemoteControlConnectionStatus, ) -> RemoteControlStatusChangedNotification { + let mut status_change = None; self.status_tx.send_if_modified(|status| { - let next_status = RemoteControlStatusChangedNotification { - status: connection_status, - installation_id: status.installation_id.clone(), - environment_id: if connection_status == RemoteControlConnectionStatus::Disabled { - None - } else { - status.environment_id.clone() - }, - }; + let next_status = + remote_control_status_with_connection_status(status, connection_status); if *status == next_status { return false; } + status_change = Some((status.clone(), next_status.clone())); *status = next_status; true }); + if let Some((previous_status, next_status)) = status_change { + info!( + previous_status = ?previous_status.status, + next_status = ?next_status.status, + previous_environment_id = ?previous_status.environment_id, + next_environment_id = ?next_status.environment_id, + installation_id = %next_status.installation_id, + server_name = %next_status.server_name, + "remote control handle status changed" + ); + } self.status() } } +fn remote_control_status_with_connection_status( + status: &RemoteControlStatusChangedNotification, + connection_status: RemoteControlConnectionStatus, +) -> RemoteControlStatusChangedNotification { + RemoteControlStatusChangedNotification { + status: connection_status, + server_name: status.server_name.clone(), + installation_id: status.installation_id.clone(), + environment_id: if connection_status == RemoteControlConnectionStatus::Disabled { + None + } else { + status.environment_id.clone() + }, + } +} + pub async fn start_remote_control( config: RemoteControlStartConfig, state_db: Option>, @@ -154,23 +198,47 @@ pub async fn start_remote_control( }; let (enabled_tx, enabled_rx) = watch::channel(initial_enabled); + let server_name = gethostname().to_string_lossy().trim().to_string(); + let remote_control_url = config.remote_control_url; + let installation_id = config.installation_id; let initial_status = RemoteControlStatusChangedNotification { status: if initial_enabled { RemoteControlConnectionStatus::Connecting } else { RemoteControlConnectionStatus::Disabled }, - installation_id: config.installation_id.clone(), + server_name: server_name.clone(), + installation_id: installation_id.clone(), environment_id: None, }; let (status_tx, _status_rx) = watch::channel(initial_status); let status_publisher = RemoteControlStatusPublisher::new(status_tx.clone()); + info!( + remote_control_url = %remote_control_url, + installation_id = %installation_id, + server_name = %server_name, + state_db_available, + initial_enabled, + "starting app-server remote control websocket task" + ); + let remote_control_url_for_log = remote_control_url.clone(); + let installation_id_for_log = installation_id.clone(); + let server_name_for_log = server_name.clone(); + let shutdown_token_for_log = shutdown_token.clone(); let join_handle = tokio::spawn(async move { - RemoteControlWebsocket::new( + info!( + remote_control_url = %remote_control_url_for_log, + installation_id = %installation_id_for_log, + server_name = %server_name_for_log, + initial_enabled, + "app-server remote control websocket task started" + ); + let websocket_task = RemoteControlWebsocket::new( websocket::RemoteControlWebsocketConfig { - remote_control_url: config.remote_control_url, - installation_id: config.installation_id, + remote_control_url, + installation_id, remote_control_target, + server_name, }, state_db, auth_manager, @@ -181,8 +249,38 @@ pub async fn start_remote_control( shutdown_token, enabled_rx, ) - .run(app_server_client_name_rx) - .await; + .run(app_server_client_name_rx); + match AssertUnwindSafe(websocket_task).catch_unwind().await { + Ok(()) => { + let shutdown_requested = shutdown_token_for_log.is_cancelled(); + if shutdown_requested { + info!( + remote_control_url = %remote_control_url_for_log, + installation_id = %installation_id_for_log, + server_name = %server_name_for_log, + shutdown_requested, + "app-server remote control websocket task exited" + ); + } else { + warn!( + remote_control_url = %remote_control_url_for_log, + installation_id = %installation_id_for_log, + server_name = %server_name_for_log, + shutdown_requested, + "app-server remote control websocket task exited without shutdown" + ); + } + } + Err(panic) => { + error!( + remote_control_url = %remote_control_url_for_log, + installation_id = %installation_id_for_log, + server_name = %server_name_for_log, + "app-server remote control websocket task panicked" + ); + std::panic::resume_unwind(panic); + } + } }); Ok(( diff --git a/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs b/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs index cee646b0d9..b10af42e86 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs @@ -11,6 +11,7 @@ use url::Url; pub(super) struct RemoteControlTarget { pub(super) websocket_url: String, pub(super) enroll_url: String, + pub(super) refresh_url: String, } #[derive(Debug, Serialize)] @@ -26,6 +27,14 @@ pub(super) struct EnrollRemoteServerRequest { pub(super) struct EnrollRemoteServerResponse { pub(super) server_id: String, pub(super) environment_id: String, + pub(super) remote_control_token: String, + pub(super) expires_at: String, +} + +#[derive(Debug, Serialize)] +pub(super) struct RefreshRemoteServerRequest { + pub(super) server_id: String, + pub(super) installation_id: String, } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -177,6 +186,9 @@ pub(super) fn normalize_remote_control_url( let enroll_url = remote_control_url .join("wham/remote/control/server/enroll") .map_err(map_url_parse_error)?; + let refresh_url = remote_control_url + .join("wham/remote/control/server/refresh") + .map_err(map_url_parse_error)?; let mut websocket_url = remote_control_url .join("wham/remote/control/server") .map_err(map_url_parse_error)?; @@ -194,6 +206,7 @@ pub(super) fn normalize_remote_control_url( Ok(RemoteControlTarget { websocket_url: websocket_url.to_string(), enroll_url: enroll_url.to_string(), + refresh_url: refresh_url.to_string(), }) } @@ -212,6 +225,8 @@ mod tests { .to_string(), enroll_url: "https://chatgpt.com/backend-api/wham/remote/control/server/enroll" .to_string(), + refresh_url: "https://chatgpt.com/backend-api/wham/remote/control/server/refresh" + .to_string(), } ); assert_eq!( @@ -224,6 +239,9 @@ mod tests { enroll_url: "https://api.chatgpt-staging.com/backend-api/wham/remote/control/server/enroll" .to_string(), + refresh_url: + "https://api.chatgpt-staging.com/backend-api/wham/remote/control/server/refresh" + .to_string(), } ); } @@ -238,6 +256,8 @@ mod tests { .to_string(), enroll_url: "http://localhost:8080/backend-api/wham/remote/control/server/enroll" .to_string(), + refresh_url: "http://localhost:8080/backend-api/wham/remote/control/server/refresh" + .to_string(), } ); assert_eq!( @@ -248,6 +268,9 @@ mod tests { .to_string(), enroll_url: "https://localhost:8443/backend-api/wham/remote/control/server/enroll" .to_string(), + refresh_url: + "https://localhost:8443/backend-api/wham/remote/control/server/refresh" + .to_string(), } ); } diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs index 3dcfb81ce5..85bcf09222 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -58,6 +58,9 @@ use tokio_tungstenite::tungstenite; use tokio_util::sync::CancellationToken; const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111"; +const TEST_REMOTE_CONTROL_SERVER_TOKEN: &str = "Remote Control Token"; +const TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN: &str = "Refreshed Remote Control Token"; +const TEST_REMOTE_CONTROL_SERVER_TOKEN_EXPIRES_AT: &str = "2999-01-01T00:00:00Z"; fn remote_control_auth_manager() -> Arc { auth_manager_from_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) @@ -121,6 +124,23 @@ fn remote_control_url_for_listener(listener: &TcpListener) -> String { format!("http://{addr}/backend-api/") } +fn test_server_name() -> String { + gethostname().to_string_lossy().trim().to_string() +} + +fn remote_control_server_token_response( + server_id: &str, + environment_id: &str, + remote_control_token: &str, +) -> serde_json::Value { + json!({ + "server_id": server_id, + "environment_id": environment_id, + "remote_control_token": remote_control_token, + "expires_at": TEST_REMOTE_CONTROL_SERVER_TOKEN_EXPIRES_AT, + }) +} + async fn expect_remote_control_status( status_rx: &mut watch::Receiver, expected_status: Option, @@ -134,6 +154,7 @@ async fn expect_remote_control_status( if let Some(expected_status) = expected_status { assert_eq!(status.status, expected_status); } + assert_eq!(status.server_name, test_server_name()); assert_eq!(status.installation_id, TEST_INSTALLATION_ID); assert_eq!(status.environment_id.as_deref(), expected_environment_id); } @@ -198,7 +219,11 @@ async fn remote_control_transport_manages_virtual_clients_and_routes_messages() ); respond_with_json( enroll_request.stream, - json!({ "server_id": "srv_e_test", "environment_id": "env_test" }), + remote_control_server_token_response( + "srv_e_test", + "env_test", + TEST_REMOTE_CONTROL_SERVER_TOKEN, + ), ) .await; let mut websocket = accept_remote_control_connection(&listener).await; @@ -478,17 +503,31 @@ async fn remote_control_transport_reconnects_after_disconnect() { ); respond_with_json( enroll_request.stream, - json!({ "server_id": "srv_e_test", "environment_id": "env_test" }), + remote_control_server_token_response( + "srv_e_test", + "env_test", + TEST_REMOTE_CONTROL_SERVER_TOKEN, + ), ) .await; - let mut first_websocket = accept_remote_control_connection(&listener).await; + let (first_handshake_request, mut first_websocket) = + accept_remote_control_backend_connection(&listener).await; + assert_eq!( + first_handshake_request.headers.get("authorization"), + Some(&format!("Bearer {TEST_REMOTE_CONTROL_SERVER_TOKEN}")) + ); first_websocket .close(None) .await .expect("first websocket should close"); drop(first_websocket); - let mut second_websocket = accept_remote_control_connection(&listener).await; + let (second_handshake_request, mut second_websocket) = + accept_remote_control_backend_connection(&listener).await; + assert_eq!( + second_handshake_request.headers.get("authorization"), + Some(&format!("Bearer {TEST_REMOTE_CONTROL_SERVER_TOKEN}")) + ); expect_remote_control_status( &mut status_rx, /*expected_status*/ None, @@ -532,6 +571,91 @@ async fn remote_control_transport_reconnects_after_disconnect() { let _ = remote_task.await; } +#[tokio::test] +async fn remote_control_transport_refreshes_server_token_after_websocket_unauthorized() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let (transport_event_tx, _transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let (remote_task, remote_handle) = start_remote_control( + RemoteControlStartConfig { + remote_control_url, + installation_id: TEST_INSTALLATION_ID.to_string(), + }, + Some(remote_control_state_runtime(&codex_home).await), + remote_control_auth_manager(), + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + /*initial_enabled*/ true, + ) + .await + .expect("remote control should start"); + let mut status_rx = remote_handle.status_receiver(); + + let enroll_request = accept_http_request(&listener).await; + assert_eq!( + enroll_request.request_line, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + respond_with_json( + enroll_request.stream, + remote_control_server_token_response( + "srv_e_test", + "env_test", + TEST_REMOTE_CONTROL_SERVER_TOKEN, + ), + ) + .await; + + let websocket_request = accept_http_request(&listener).await; + assert_eq!( + websocket_request.request_line, + "GET /backend-api/wham/remote/control/server HTTP/1.1" + ); + assert_eq!( + websocket_request.headers.get("authorization"), + Some(&format!("Bearer {TEST_REMOTE_CONTROL_SERVER_TOKEN}")) + ); + respond_with_status(websocket_request.stream, "401 Unauthorized", "").await; + + let refresh_request = accept_http_request(&listener).await; + assert_eq!( + refresh_request.request_line, + "POST /backend-api/wham/remote/control/server/refresh HTTP/1.1" + ); + respond_with_json( + refresh_request.stream, + remote_control_server_token_response( + "srv_e_test", + "env_test", + TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN, + ), + ) + .await; + + let (handshake_request, _websocket) = accept_remote_control_backend_connection(&listener).await; + expect_remote_control_status( + &mut status_rx, + /*expected_status*/ None, + Some("env_test"), + ) + .await; + assert_eq!( + handshake_request.headers.get("authorization"), + Some(&format!( + "Bearer {TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN}" + )) + ); + + shutdown_token.cancel(); + let _ = remote_task.await; +} + #[tokio::test] async fn remote_control_start_allows_remote_control_invalid_url_when_disabled() { let (transport_event_tx, _transport_event_rx) = @@ -630,6 +754,7 @@ async fn remote_control_start_reports_missing_state_db_as_disabled_when_enabled( status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Disabled, + server_name: test_server_name(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } @@ -690,7 +815,11 @@ async fn remote_control_handle_enable_disable_stops_and_restarts_connections() { ); respond_with_json( enroll_request.stream, - json!({ "server_id": "srv_e_test", "environment_id": "env_test" }), + remote_control_server_token_response( + "srv_e_test", + "env_test", + TEST_REMOTE_CONTROL_SERVER_TOKEN, + ), ) .await; let mut first_websocket = accept_remote_control_connection(&listener).await; @@ -698,6 +827,7 @@ async fn remote_control_handle_enable_disable_stops_and_restarts_connections() { &mut status_rx, RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connected, + server_name: test_server_name(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_test".to_string()), }, @@ -708,6 +838,7 @@ async fn remote_control_handle_enable_disable_stops_and_restarts_connections() { remote_handle.disable(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Disabled, + server_name: test_server_name(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } @@ -716,6 +847,7 @@ async fn remote_control_handle_enable_disable_stops_and_restarts_connections() { &mut status_rx, RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Disabled, + server_name: test_server_name(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, }, @@ -732,6 +864,7 @@ async fn remote_control_handle_enable_disable_stops_and_restarts_connections() { remote_handle.enable().expect("enable should succeed"), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + server_name: test_server_name(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } @@ -740,6 +873,7 @@ async fn remote_control_handle_enable_disable_stops_and_restarts_connections() { &mut status_rx, RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + server_name: test_server_name(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, }, @@ -790,7 +924,11 @@ async fn remote_control_transport_clears_outgoing_buffer_when_backend_acks() { let enroll_request = accept_http_request(&listener).await; respond_with_json( enroll_request.stream, - json!({ "server_id": "srv_e_test", "environment_id": "env_test" }), + remote_control_server_token_response( + "srv_e_test", + "env_test", + TEST_REMOTE_CONTROL_SERVER_TOKEN, + ), ) .await; let mut first_websocket = accept_remote_control_connection(&listener).await; @@ -997,7 +1135,11 @@ async fn remote_control_http_mode_enrolls_before_connecting() { ); respond_with_json( enroll_request.stream, - json!({ "server_id": "srv_e_test", "environment_id": "env_test" }), + remote_control_server_token_response( + "srv_e_test", + "env_test", + TEST_REMOTE_CONTROL_SERVER_TOKEN, + ), ) .await; @@ -1015,13 +1157,13 @@ async fn remote_control_http_mode_enrolls_before_connecting() { ); assert_eq!( handshake_request.headers.get("authorization"), - Some(&"Bearer Access Token".to_string()) + Some(&format!("Bearer {TEST_REMOTE_CONTROL_SERVER_TOKEN}")) ); assert_eq!( handshake_request .headers .get(REMOTE_CONTROL_ACCOUNT_ID_HEADER), - Some(&"account_id".to_string()) + None ); assert_eq!( handshake_request @@ -1161,7 +1303,7 @@ async fn remote_control_http_mode_enrolls_before_connecting() { } #[tokio::test] -async fn remote_control_http_mode_reuses_persisted_enrollment_before_reenrolling() { +async fn remote_control_http_mode_refreshes_persisted_enrollment_before_connecting() { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("listener should bind"); @@ -1175,6 +1317,8 @@ async fn remote_control_http_mode_reuses_persisted_enrollment_before_reenrolling environment_id: "env_persisted".to_string(), server_id: "srv_e_persisted".to_string(), server_name: "persisted-server".to_string(), + remote_control_token: None, + expires_at: None, }; update_persisted_remote_control_enrollment( Some(state_db.as_ref()), @@ -1204,6 +1348,33 @@ async fn remote_control_http_mode_reuses_persisted_enrollment_before_reenrolling .await .expect("remote control should start"); + let refresh_request = accept_http_request(&listener).await; + assert_eq!( + refresh_request.request_line, + "POST /backend-api/wham/remote/control/server/refresh HTTP/1.1" + ); + assert_eq!( + refresh_request.headers.get("authorization"), + Some(&"Bearer Access Token".to_string()) + ); + assert_eq!( + serde_json::from_str::(&refresh_request.body) + .expect("refresh body should deserialize"), + json!({ + "server_id": persisted_enrollment.server_id.clone(), + "installation_id": TEST_INSTALLATION_ID, + }) + ); + respond_with_json( + refresh_request.stream, + remote_control_server_token_response( + &persisted_enrollment.server_id, + &persisted_enrollment.environment_id, + TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN, + ), + ) + .await; + let (handshake_request, _websocket) = accept_remote_control_backend_connection(&listener).await; assert_eq!( handshake_request.path, @@ -1213,6 +1384,12 @@ async fn remote_control_http_mode_reuses_persisted_enrollment_before_reenrolling handshake_request.headers.get("x-codex-server-id"), Some(&persisted_enrollment.server_id) ); + assert_eq!( + handshake_request.headers.get("authorization"), + Some(&format!( + "Bearer {TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN}" + )) + ); assert_eq!( load_persisted_remote_control_enrollment( Some(state_db.as_ref()), @@ -1245,6 +1422,8 @@ async fn remote_control_stdio_mode_waits_for_client_name_before_connecting() { environment_id: "env_persisted".to_string(), server_id: "srv_e_persisted".to_string(), server_name: "persisted-server".to_string(), + remote_control_token: None, + expires_at: None, }; update_persisted_remote_control_enrollment( Some(state_db.as_ref()), @@ -1280,6 +1459,20 @@ async fn remote_control_stdio_mode_waits_for_client_name_before_connecting() { .expect_err("remote control should wait for the stdio client name"); let _ = app_server_client_name_tx.send(app_server_client_name.to_string()); + let refresh_request = accept_http_request(&listener).await; + assert_eq!( + refresh_request.request_line, + "POST /backend-api/wham/remote/control/server/refresh HTTP/1.1" + ); + respond_with_json( + refresh_request.stream, + remote_control_server_token_response( + &persisted_enrollment.server_id, + &persisted_enrollment.environment_id, + TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN, + ), + ) + .await; let (handshake_request, _websocket) = accept_remote_control_backend_connection(&listener).await; assert_eq!( handshake_request.headers.get("x-codex-server-id"), @@ -1317,6 +1510,8 @@ async fn remote_control_waits_for_account_id_before_enrolling() { environment_id: "env_ready".to_string(), server_id: "srv_e_ready".to_string(), server_name: expected_server_name, + remote_control_token: None, + expires_at: None, }; let (transport_event_tx, _transport_event_rx) = @@ -1328,7 +1523,7 @@ async fn remote_control_waits_for_account_id_before_enrolling() { installation_id: TEST_INSTALLATION_ID.to_string(), }, Some(state_db.clone()), - auth_manager, + auth_manager.clone(), transport_event_tx, shutdown_token.clone(), /*app_server_client_name_rx*/ None, @@ -1347,18 +1542,22 @@ async fn remote_control_waits_for_account_id_before_enrolling() { AuthCredentialsStoreMode::File, ) .expect("auth with account id should save"); + auth_manager.reload().await; - let enroll_request = accept_http_request(&listener).await; + let enroll_request = timeout(Duration::from_millis(100), accept_http_request(&listener)) + .await + .expect("auth change should wake remote control before the retry delay"); assert_eq!( enroll_request.request_line, "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" ); respond_with_json( enroll_request.stream, - json!({ - "server_id": expected_enrollment.server_id, - "environment_id": expected_enrollment.environment_id, - }), + remote_control_server_token_response( + &expected_enrollment.server_id, + &expected_enrollment.environment_id, + TEST_REMOTE_CONTROL_SERVER_TOKEN, + ), ) .await; @@ -1373,7 +1572,7 @@ async fn remote_control_waits_for_account_id_before_enrolling() { } #[tokio::test] -async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404() { +async fn remote_control_http_mode_reenrolls_when_refresh_reports_stale_enrollment() { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("listener should bind"); @@ -1388,12 +1587,16 @@ async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404() environment_id: "env_stale".to_string(), server_id: "srv_e_stale".to_string(), server_name: "stale-server".to_string(), + remote_control_token: None, + expires_at: None, }; let refreshed_enrollment = RemoteControlEnrollment { account_id: "account_id".to_string(), environment_id: "env_refreshed".to_string(), server_id: "srv_e_refreshed".to_string(), server_name: expected_server_name, + remote_control_token: None, + expires_at: None, }; update_persisted_remote_control_enrollment( Some(state_db.as_ref()), @@ -1424,6 +1627,138 @@ async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404() .expect("remote control should start"); let mut status_rx = remote_handle.status_receiver(); + let refresh_request = accept_http_request(&listener).await; + assert_eq!( + refresh_request.request_line, + "POST /backend-api/wham/remote/control/server/refresh HTTP/1.1" + ); + expect_remote_control_status( + &mut status_rx, + /*expected_status*/ None, + Some("env_stale"), + ) + .await; + respond_with_status(refresh_request.stream, "404 Not Found", "").await; + expect_remote_control_status( + &mut status_rx, + /*expected_status*/ None, + /*expected_environment_id*/ None, + ) + .await; + + let enroll_request = accept_http_request(&listener).await; + assert_eq!( + enroll_request.request_line, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + respond_with_json( + enroll_request.stream, + remote_control_server_token_response( + &refreshed_enrollment.server_id, + &refreshed_enrollment.environment_id, + TEST_REMOTE_CONTROL_SERVER_TOKEN, + ), + ) + .await; + + let (handshake_request, _websocket) = accept_remote_control_backend_connection(&listener).await; + expect_remote_control_status( + &mut status_rx, + /*expected_status*/ None, + Some("env_refreshed"), + ) + .await; + assert_eq!( + handshake_request.headers.get("x-codex-server-id"), + Some(&refreshed_enrollment.server_id) + ); + assert_eq!( + load_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &remote_control_target, + "account_id", + /*app_server_client_name*/ None, + ) + .await + .expect("refreshed enrollment should load"), + Some(refreshed_enrollment) + ); + + shutdown_token.cancel(); + let _ = remote_task.await; +} + +#[tokio::test] +async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let state_db = remote_control_state_runtime(&codex_home).await; + let remote_control_target = + normalize_remote_control_url(&remote_control_url).expect("target should parse"); + let expected_server_name = gethostname().to_string_lossy().trim().to_string(); + let stale_enrollment = RemoteControlEnrollment { + account_id: "account_id".to_string(), + environment_id: "env_stale".to_string(), + server_id: "srv_e_stale".to_string(), + server_name: "stale-server".to_string(), + remote_control_token: None, + expires_at: None, + }; + let refreshed_enrollment = RemoteControlEnrollment { + account_id: "account_id".to_string(), + environment_id: "env_refreshed".to_string(), + server_id: "srv_e_refreshed".to_string(), + server_name: expected_server_name, + remote_control_token: None, + expires_at: None, + }; + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &remote_control_target, + "account_id", + /*app_server_client_name*/ None, + Some(&stale_enrollment), + ) + .await + .expect("stale enrollment should save"); + + let (transport_event_tx, _transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let (remote_task, remote_handle) = start_remote_control( + RemoteControlStartConfig { + remote_control_url, + installation_id: TEST_INSTALLATION_ID.to_string(), + }, + Some(state_db.clone()), + remote_control_auth_manager_with_home(&codex_home), + transport_event_tx, + shutdown_token.clone(), + /*app_server_client_name_rx*/ None, + /*initial_enabled*/ true, + ) + .await + .expect("remote control should start"); + let mut status_rx = remote_handle.status_receiver(); + + let refresh_request = accept_http_request(&listener).await; + assert_eq!( + refresh_request.request_line, + "POST /backend-api/wham/remote/control/server/refresh HTTP/1.1" + ); + respond_with_json( + refresh_request.stream, + remote_control_server_token_response( + &stale_enrollment.server_id, + &stale_enrollment.environment_id, + TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN, + ), + ) + .await; + let websocket_request = accept_http_request(&listener).await; assert_eq!( websocket_request.request_line, @@ -1454,10 +1789,11 @@ async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404() ); respond_with_json( enroll_request.stream, - json!({ - "server_id": refreshed_enrollment.server_id, - "environment_id": refreshed_enrollment.environment_id, - }), + remote_control_server_token_response( + &refreshed_enrollment.server_id, + &refreshed_enrollment.environment_id, + TEST_REMOTE_CONTROL_SERVER_TOKEN, + ), ) .await; diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index 472639bc68..21710b5e26 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -7,6 +7,7 @@ use crate::transport::remote_control::enroll::enroll_remote_control_server; use crate::transport::remote_control::enroll::format_headers; use crate::transport::remote_control::enroll::load_persisted_remote_control_enrollment; use crate::transport::remote_control::enroll::preview_remote_control_response_body; +use crate::transport::remote_control::enroll::refresh_remote_control_server; use crate::transport::remote_control::enroll::update_persisted_remote_control_enrollment; use super::protocol::ClientEnvelope; @@ -15,6 +16,7 @@ use super::protocol::ClientId; use super::protocol::RemoteControlTarget; use super::protocol::ServerEnvelope; use super::protocol::StreamId; +use super::remote_control_status_with_connection_status; use super::segment::ClientSegmentObservation; use super::segment::ClientSegmentReassembler; use super::segment::REMOTE_CONTROL_SEGMENT_MAX_BYTES; @@ -54,7 +56,6 @@ use tracing::info; use tracing::warn; pub(super) const REMOTE_CONTROL_PROTOCOL_VERSION: &str = "3"; -pub(super) const REMOTE_CONTROL_ACCOUNT_ID_HEADER: &str = "chatgpt-account-id"; pub(super) const REMOTE_CONTROL_INSTALLATION_ID_HEADER: &str = "x-codex-installation-id"; const REMOTE_CONTROL_SUBSCRIBE_CURSOR_HEADER: &str = "x-codex-subscribe-cursor"; const REMOTE_CONTROL_WEBSOCKET_PING_INTERVAL: std::time::Duration = @@ -63,6 +64,12 @@ const REMOTE_CONTROL_WEBSOCKET_PONG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); const REMOTE_CONTROL_ACCOUNT_ID_RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1); +const REMOTE_CONTROL_RECONNECT_BACKOFF_CAP: std::time::Duration = + std::time::Duration::from_secs(30); +const REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT: std::time::Duration = + std::time::Duration::from_secs(30); +const REMOTE_CONTROL_CONNECTION_SHUTDOWN_TIMEOUT: std::time::Duration = + std::time::Duration::from_secs(5); struct BoundedOutboundBuffer { buffer_by_stream: HashMap<(ClientId, StreamId), VecDeque>, @@ -176,14 +183,32 @@ impl WebsocketState { return ClientSegmentObservation::Dropped; } - let observation = self.client_segment_reassembler.observe(client_envelope); - if matches!(observation, ClientSegmentObservation::Forward(_)) - && let Some((key, seq_id)) = client_message_key - { + self.client_segment_reassembler.observe(client_envelope) + } + + fn record_client_message_delivery( + &mut self, + client_envelope: &ClientEnvelope, + client_message_key: Option<((ClientId, Option), u64)>, + ) { + if let Some(cursor) = client_envelope.cursor.as_deref() { + self.subscribe_cursor = Some(cursor.to_string()); + } + if let Some((key, seq_id)) = client_message_key { self.last_completed_client_chunk_seq_id_by_stream .insert(key, seq_id); } - observation + if let ClientEvent::Ack { segment_id } = &client_envelope.event + && let Some(acked_seq_id) = client_envelope.seq_id + && let Some(stream_id) = client_envelope.stream_id.as_ref() + { + self.outbound_buffer.ack( + &client_envelope.client_id, + stream_id, + acked_seq_id, + *segment_id, + ); + } } fn invalidate_client_message_stream(&mut self, client_id: &ClientId, stream_id: &StreamId) { @@ -216,6 +241,7 @@ impl WebsocketState { pub(crate) struct RemoteControlWebsocket { remote_control_url: String, installation_id: String, + server_name: String, remote_control_target: Option, state_db: Option>, auth_manager: Arc, @@ -224,6 +250,7 @@ pub(crate) struct RemoteControlWebsocket { reconnect_attempt: u64, enrollment: Option, auth_recovery: UnauthorizedRecovery, + auth_change_rx: watch::Receiver, client_tracker: Arc>, state: Arc>, server_event_rx: Arc>>, @@ -235,6 +262,13 @@ pub(crate) struct RemoteControlWebsocketConfig { pub(crate) remote_control_url: String, pub(crate) installation_id: String, pub(crate) remote_control_target: Option, + pub(crate) server_name: String, +} + +pub(super) struct RemoteControlAuthContext<'a> { + auth_manager: &'a Arc, + auth_recovery: &'a mut UnauthorizedRecovery, + auth_change_rx: &'a mut watch::Receiver, } enum ConnectOutcome { @@ -243,6 +277,14 @@ enum ConnectOutcome { Shutdown, } +#[derive(Debug, Clone, Copy)] +enum ConnectionEndReason { + Shutdown, + Disabled, + EnabledWatchClosed, + ConnectionWorkerStopped, +} + pub(super) struct RemoteControlChannels { pub(super) transport_event_tx: mpsc::Sender, pub(super) status_publisher: RemoteControlStatusPublisher, @@ -258,33 +300,45 @@ impl RemoteControlStatusPublisher { Self { tx } } + fn status(&self) -> RemoteControlStatusChangedNotification { + self.tx.borrow().clone() + } + fn publish_status(&self, connection_status: RemoteControlConnectionStatus) { + let mut status_change = None; self.tx.send_if_modified(|status| { - let next_status = RemoteControlStatusChangedNotification { - status: connection_status, - installation_id: status.installation_id.clone(), - environment_id: if connection_status == RemoteControlConnectionStatus::Disabled { - None - } else { - status.environment_id.clone() - }, - }; + let next_status = + remote_control_status_with_connection_status(status, connection_status); if *status == next_status { return false; } + status_change = Some((status.clone(), next_status.clone())); *status = next_status; true }); + if let Some((previous_status, next_status)) = status_change { + info!( + previous_status = ?previous_status.status, + next_status = ?next_status.status, + previous_environment_id = ?previous_status.environment_id, + next_environment_id = ?next_status.environment_id, + installation_id = %next_status.installation_id, + server_name = %next_status.server_name, + "remote control websocket status changed" + ); + } } fn publish_environment_id(&self, environment_id: Option) { + let mut status_change = None; self.tx.send_if_modified(|status| { if status.status == RemoteControlConnectionStatus::Disabled { return false; } let next_status = RemoteControlStatusChangedNotification { status: status.status, + server_name: status.server_name.clone(), installation_id: status.installation_id.clone(), environment_id, }; @@ -292,15 +346,27 @@ impl RemoteControlStatusPublisher { return false; } + status_change = Some((status.clone(), next_status.clone())); *status = next_status; true }); + if let Some((previous_status, next_status)) = status_change { + info!( + status = ?next_status.status, + previous_environment_id = ?previous_status.environment_id, + next_environment_id = ?next_status.environment_id, + installation_id = %next_status.installation_id, + server_name = %next_status.server_name, + "remote control websocket environment changed" + ); + } } } #[derive(Clone, Copy)] pub(super) struct RemoteControlConnectOptions<'a> { installation_id: &'a str, + server_name: &'a str, subscribe_cursor: Option<&'a str>, app_server_client_name: Option<&'a str>, } @@ -323,10 +389,12 @@ impl RemoteControlWebsocket { ); let (outbound_buffer, used_rx) = BoundedOutboundBuffer::new(); let auth_recovery = auth_manager.unauthorized_recovery(); + let auth_change_rx = auth_manager.auth_change_receiver(); Self { remote_control_url: config.remote_control_url, installation_id: config.installation_id, + server_name: config.server_name, remote_control_target: config.remote_control_target, state_db, auth_manager, @@ -335,6 +403,7 @@ impl RemoteControlWebsocket { reconnect_attempt: 0, enrollment: None, auth_recovery, + auth_change_rx, client_tracker: Arc::new(Mutex::new(client_tracker)), state: Arc::new(Mutex::new(WebsocketState { outbound_buffer, @@ -357,12 +426,26 @@ impl RemoteControlWebsocket { mut self, app_server_client_name_rx: Option>, ) { + info!( + remote_control_url = %self.remote_control_url, + installation_id = %self.installation_id, + server_name = %self.server_name, + initial_enabled = *self.enabled_rx.borrow(), + "app-server remote control websocket loop started" + ); let app_server_client_name = match self .wait_for_app_server_client_name(app_server_client_name_rx) .await { Ok(app_server_client_name) => app_server_client_name, Err(_) => { + warn!( + remote_control_url = %self.remote_control_url, + installation_id = %self.installation_id, + server_name = %self.server_name, + shutdown_requested = self.shutdown_token.is_cancelled(), + "app-server remote control websocket loop stopped before client name was ready" + ); self.client_tracker.lock().await.shutdown().await; return; } @@ -370,9 +453,27 @@ impl RemoteControlWebsocket { loop { if !self.wait_until_enabled().await { + info!( + remote_control_url = %self.remote_control_url, + installation_id = %self.installation_id, + server_name = %self.server_name, + shutdown_requested = self.shutdown_token.is_cancelled(), + current_status = ?self.status_publisher.status().status, + "app-server remote control websocket loop exiting while waiting for enablement" + ); break; } + let status = self.status_publisher.status(); + info!( + remote_control_url = %self.remote_control_url, + installation_id = %self.installation_id, + server_name = %self.server_name, + reconnect_attempt = self.reconnect_attempt.saturating_add(1), + current_status = ?status.status, + environment_id = ?status.environment_id, + "starting app-server remote control websocket connection cycle" + ); let shutdown_token = self.shutdown_token.child_token(); let websocket_connection = match self .connect(&shutdown_token, app_server_client_name.as_deref()) @@ -387,11 +488,30 @@ impl RemoteControlWebsocket { ConnectOutcome::Shutdown => break, }; - self.run_connection(websocket_connection, shutdown_token) + let connection_end_reason = self + .run_connection(websocket_connection, shutdown_token) .await; + let status = self.status_publisher.status(); + info!( + remote_control_url = %self.remote_control_url, + installation_id = %self.installation_id, + server_name = %self.server_name, + connection_end_reason = ?connection_end_reason, + current_status = ?status.status, + environment_id = ?status.environment_id, + enabled = *self.enabled_rx.borrow(), + "app-server remote control websocket connection cycle ended" + ); } self.client_tracker.lock().await.shutdown().await; + info!( + remote_control_url = %self.remote_control_url, + installation_id = %self.installation_id, + server_name = %self.server_name, + shutdown_requested = self.shutdown_token.is_cancelled(), + "app-server remote control websocket loop exited" + ); } async fn wait_for_app_server_client_name( @@ -452,11 +572,30 @@ impl RemoteControlWebsocket { loop { let subscribe_cursor = self.state.lock().await.subscribe_cursor.clone(); + let enrollment = self.enrollment.as_ref(); + info!( + websocket_url = %remote_control_target.websocket_url, + installation_id = %self.installation_id, + server_name = %self.server_name, + reconnect_attempt = self.reconnect_attempt.saturating_add(1), + has_enrollment = enrollment.is_some(), + server_id = ?enrollment.map(|enrollment| enrollment.server_id.as_str()), + environment_id = ?enrollment.map(|enrollment| enrollment.environment_id.as_str()), + subscribe_cursor_present = subscribe_cursor.is_some(), + app_server_client_name = ?app_server_client_name, + "connecting to app-server remote control websocket" + ); let connect_options = RemoteControlConnectOptions { installation_id: &self.installation_id, + server_name: &self.server_name, subscribe_cursor: subscribe_cursor.as_deref(), app_server_client_name, }; + let auth_context = RemoteControlAuthContext { + auth_manager: &self.auth_manager, + auth_recovery: &mut self.auth_recovery, + auth_change_rx: &mut self.auth_change_rx, + }; let connect_result = tokio::select! { _ = shutdown_token.cancelled() => return ConnectOutcome::Shutdown, changed = self.enabled_rx.wait_for(|enabled| !*enabled) => { @@ -468,8 +607,7 @@ impl RemoteControlWebsocket { connect_result = connect_remote_control_websocket( &remote_control_target, self.state_db.as_deref(), - &self.auth_manager, - &mut self.auth_recovery, + auth_context, &mut self.enrollment, connect_options, &self.status_publisher, @@ -485,10 +623,16 @@ impl RemoteControlWebsocket { self.auth_recovery = self.auth_manager.unauthorized_recovery(); self.status_publisher .publish_status(RemoteControlConnectionStatus::Connected); + let enrollment = self.enrollment.as_ref(); info!( - "connected to app-server remote control websocket: {}, {}", - remote_control_target.websocket_url, - format_headers(response.headers()) + websocket_url = %remote_control_target.websocket_url, + installation_id = %self.installation_id, + server_name = %self.server_name, + server_id = ?enrollment.map(|enrollment| enrollment.server_id.as_str()), + environment_id = ?enrollment.map(|enrollment| enrollment.environment_id.as_str()), + subscribe_cursor_present = subscribe_cursor.is_some(), + response_headers = %format_headers(response.headers()), + "connected to app-server remote control websocket" ); return ConnectOutcome::Connected(Box::new(websocket_connection)); } @@ -501,12 +645,31 @@ impl RemoteControlWebsocket { } else { self.status_publisher .publish_status(RemoteControlConnectionStatus::Errored); + let reconnect_attempt = self.reconnect_attempt.saturating_add(1); + let (reconnect_delay, reconnect_backoff_reset) = + next_reconnect_delay(&mut self.reconnect_attempt); + let enrollment = self.enrollment.as_ref(); warn!( - "failed to connect to app-server remote control websocket: {}, err: {}", - remote_control_target.websocket_url, err + websocket_url = %remote_control_target.websocket_url, + installation_id = %self.installation_id, + server_name = %self.server_name, + error = %err, + error_kind = ?err.kind(), + reconnect_attempt, + reconnect_delay = ?reconnect_delay, + reconnect_backoff_reset, + has_enrollment = enrollment.is_some(), + server_id = ?enrollment.map(|enrollment| enrollment.server_id.as_str()), + environment_id = ?enrollment.map(|enrollment| enrollment.environment_id.as_str()), + subscribe_cursor_present = subscribe_cursor.is_some(), + "failed to connect to app-server remote control websocket" ); - let reconnect_delay = backoff(self.reconnect_attempt); - self.reconnect_attempt += 1; + if reconnect_backoff_reset { + info!( + reconnect_backoff_cap = ?REMOTE_CONTROL_RECONNECT_BACKOFF_CAP, + "reset app-server remote control websocket reconnect backoff after cap" + ); + } reconnect_delay }; tokio::select! { @@ -517,6 +680,14 @@ impl RemoteControlWebsocket { } return ConnectOutcome::Disabled; } + changed = self.auth_change_rx.changed() => { + if changed.is_err() { + return ConnectOutcome::Shutdown; + } + self.auth_recovery = self.auth_manager.unauthorized_recovery(); + self.reconnect_attempt = 0; + info!("retrying app-server remote control websocket after auth changed"); + } _ = tokio::time::sleep(reconnect_delay) => {} } } @@ -528,7 +699,7 @@ impl RemoteControlWebsocket { &self, websocket_connection: WebSocketStream>, shutdown_token: CancellationToken, - ) { + ) -> ConnectionEndReason { let (websocket_writer, websocket_reader) = websocket_connection.split(); let mut join_set = tokio::task::JoinSet::new(); @@ -549,19 +720,48 @@ impl RemoteControlWebsocket { )); let mut enabled_rx = self.enabled_rx.clone(); - tokio::select! { - _ = shutdown_token.cancelled() => {} + let connection_end_reason = tokio::select! { + _ = shutdown_token.cancelled() => ConnectionEndReason::Shutdown, changed = enabled_rx.wait_for(|enabled| !*enabled) => { if changed.is_ok() { self.status_publisher .publish_status(RemoteControlConnectionStatus::Disabled); + ConnectionEndReason::Disabled + } else { + ConnectionEndReason::EnabledWatchClosed } } - _ = join_set.join_next() => {} + _ = join_set.join_next() => ConnectionEndReason::ConnectionWorkerStopped, }; shutdown_token.cancel(); - join_set.join_all().await; + Self::join_connection_workers(&mut join_set, REMOTE_CONTROL_CONNECTION_SHUTDOWN_TIMEOUT) + .await; + connection_end_reason + } + + async fn join_connection_workers( + join_set: &mut tokio::task::JoinSet<()>, + shutdown_timeout: std::time::Duration, + ) { + if tokio::time::timeout(shutdown_timeout, Self::drain_join_set(join_set)) + .await + .is_ok() + { + return; + } + + warn!( + shutdown_timeout = ?shutdown_timeout, + remaining_workers = join_set.len(), + "timed out waiting for remote control connection workers to stop; aborting" + ); + join_set.abort_all(); + Self::drain_join_set(join_set).await; + } + + async fn drain_join_set(join_set: &mut tokio::task::JoinSet<()>) { + while join_set.join_next().await.is_some() {} } async fn run_server_writer( @@ -641,12 +841,14 @@ impl RemoteControlWebsocket { let queued_server_envelope = tokio::select! { _ = shutdown_token.cancelled() => return Ok(()), _ = ping_interval.tick() => { - if let Err(err) = websocket_writer - .send(tungstenite::Message::Ping(Vec::new().into())) - .await - { - return Err(io::Error::other(err)); - } + tokio::select! { + _ = shutdown_token.cancelled() => return Ok(()), + send_result = websocket_writer.send(tungstenite::Message::Ping(Vec::new().into())) => { + if let Err(err) = send_result { + return Err(io::Error::other(err)); + } + } + }; continue; } wait_result = used_rx.changed(), if !outbound_has_capacity => @@ -852,6 +1054,7 @@ impl RemoteControlWebsocket { } }; + let client_message_key = WebsocketState::client_message_key(&client_envelope); let observation = { let mut websocket_state = state.lock().await; websocket_state.observe_client_message(client_envelope, wire_size_bytes) @@ -861,24 +1064,6 @@ impl RemoteControlWebsocket { ClientSegmentObservation::Pending | ClientSegmentObservation::Dropped => continue, }; - { - let mut websocket_state = state.lock().await; - if let Some(cursor) = client_envelope.cursor.as_deref() { - websocket_state.subscribe_cursor = Some(cursor.to_string()); - } - if let ClientEvent::Ack { segment_id } = &client_envelope.event - && let Some(acked_seq_id) = client_envelope.seq_id - && let Some(stream_id) = client_envelope.stream_id.as_ref() - { - websocket_state.outbound_buffer.ack( - &client_envelope.client_id, - stream_id, - acked_seq_id, - *segment_id, - ); - } - } - let closed_client = matches!(&client_envelope.event, ClientEvent::ClientClosed).then(|| { ( @@ -886,6 +1071,7 @@ impl RemoteControlWebsocket { client_envelope.stream_id.clone(), ) }); + let delivered_client_envelope = client_envelope.clone(); if client_tracker .handle_message(client_envelope) .await @@ -893,6 +1079,10 @@ impl RemoteControlWebsocket { { return Ok(()); } + state + .lock() + .await + .record_client_message_delivery(&delivered_client_envelope, client_message_key); if let Some((client_id, stream_id)) = closed_client { let mut websocket_state = state.lock().await; if let Some(stream_id) = stream_id { @@ -929,7 +1119,6 @@ fn set_remote_control_header( fn build_remote_control_websocket_request( websocket_url: &str, enrollment: &RemoteControlEnrollment, - auth: &RemoteControlConnectionAuth, installation_id: &str, subscribe_cursor: Option<&str>, ) -> io::Result> { @@ -951,10 +1140,17 @@ fn build_remote_control_websocket_request( "x-codex-protocol-version", REMOTE_CONTROL_PROTOCOL_VERSION, )?; - let mut auth_headers = tungstenite::http::HeaderMap::new(); - auth.auth_provider.add_auth_headers(&mut auth_headers); - headers.extend(auth_headers); - set_remote_control_header(headers, REMOTE_CONTROL_ACCOUNT_ID_HEADER, &auth.account_id)?; + set_remote_control_header( + headers, + "authorization", + &format!( + "Bearer {}", + enrollment + .remote_control_token + .as_deref() + .ok_or_else(|| io::Error::other("missing remote control server token"))? + ), + )?; set_remote_control_header( headers, REMOTE_CONTROL_INSTALLATION_ID_HEADER, @@ -1015,11 +1211,21 @@ pub(crate) async fn load_remote_control_auth( }) } +fn next_reconnect_delay(reconnect_attempt: &mut u64) -> (std::time::Duration, bool) { + let reconnect_delay = backoff(*reconnect_attempt).min(REMOTE_CONTROL_RECONNECT_BACKOFF_CAP); + let reconnect_backoff_reset = reconnect_delay == REMOTE_CONTROL_RECONNECT_BACKOFF_CAP; + *reconnect_attempt = if reconnect_backoff_reset { + 0 + } else { + (*reconnect_attempt).saturating_add(1) + }; + (reconnect_delay, reconnect_backoff_reset) +} + pub(super) async fn connect_remote_control_websocket( remote_control_target: &RemoteControlTarget, state_db: Option<&StateRuntime>, - auth_manager: &Arc, - auth_recovery: &mut UnauthorizedRecovery, + mut auth_context: RemoteControlAuthContext<'_>, enrollment: &mut Option, connect_options: RemoteControlConnectOptions<'_>, status_publisher: &RemoteControlStatusPublisher, @@ -1037,7 +1243,7 @@ pub(super) async fn connect_remote_control_websocket( )); }; - let auth = match load_remote_control_auth(auth_manager).await { + let auth = match load_remote_control_auth(auth_context.auth_manager).await { Ok(auth) => auth, Err(err) => { if err.kind() == ErrorKind::PermissionDenied { @@ -1076,54 +1282,93 @@ pub(super) async fn connect_remote_control_websocket( if let Some(loaded_enrollment) = loaded_enrollment.as_ref() { status_publisher.publish_environment_id(Some(loaded_enrollment.environment_id.clone())); } - *enrollment = loaded_enrollment; + *enrollment = loaded_enrollment.map(|mut enrollment| { + enrollment.server_name = connect_options.server_name.to_string(); + enrollment + }); } - if enrollment.is_none() { + enroll_remote_control_server_if_missing( + remote_control_target, + state_db, + &auth, + &mut auth_context, + enrollment, + connect_options, + status_publisher, + ) + .await?; + + if enrollment + .as_ref() + .ok_or_else(|| io::Error::other("missing remote control enrollment after enrollment step"))? + .should_refresh_server_token() + { + let enrollment_ref = enrollment.as_ref().ok_or_else(|| { + io::Error::other("missing remote control enrollment after enrollment step") + })?; + let server_id = enrollment_ref.server_id.clone(); + let environment_id = enrollment_ref.environment_id.clone(); + info!( - "creating new remote control enrollment: websocket_url={}, enroll_url={}, account_id={}", - remote_control_target.websocket_url, remote_control_target.enroll_url, auth.account_id + "refreshing remote control server token: websocket_url={}, refresh_url={}, account_id={}, server_id={}, environment_id={}", + remote_control_target.websocket_url, + remote_control_target.refresh_url, + auth.account_id, + server_id, + environment_id ); - let new_enrollment = match enroll_remote_control_server( + let enrollment_ref = enrollment.as_mut().ok_or_else(|| { + io::Error::other("missing remote control enrollment before server refresh") + })?; + match refresh_remote_control_server( remote_control_target, &auth, connect_options.installation_id, + enrollment_ref, ) .await { - Ok(new_enrollment) => new_enrollment, + Ok(()) => {} + Err(err) if err.kind() == ErrorKind::NotFound => { + info!( + "remote control server refresh returned HTTP 404; clearing stale enrollment before re-enrolling: websocket_url={}, account_id={}, server_id={}, environment_id={}", + remote_control_target.websocket_url, auth.account_id, server_id, environment_id + ); + clear_remote_control_enrollment( + state_db, + remote_control_target, + &auth.account_id, + connect_options.app_server_client_name, + enrollment, + status_publisher, + ) + .await; + enroll_remote_control_server_if_missing( + remote_control_target, + state_db, + &auth, + &mut auth_context, + enrollment, + connect_options, + status_publisher, + ) + .await?; + } Err(err) if err.kind() == ErrorKind::PermissionDenied - && recover_remote_control_auth(auth_recovery).await => + && recover_remote_control_auth( + auth_context.auth_recovery, + auth_context.auth_change_rx, + ) + .await => { return Err(io::Error::other(format!( "{err}; retrying after auth recovery" ))); } Err(err) => return Err(err), - }; - if let Err(err) = update_persisted_remote_control_enrollment( - Some(state_db), - remote_control_target, - &auth.account_id, - connect_options.app_server_client_name, - Some(&new_enrollment), - ) - .await - { - return Err(io::Error::other(format!( - "failed to persist remote control enrollment in sqlite state db: {err}" - ))); } - info!( - "created new remote control enrollment: websocket_url={}, account_id={}, server_id={}, environment_id={}", - remote_control_target.websocket_url, - new_enrollment.account_id, - new_enrollment.server_id, - new_enrollment.environment_id - ); - status_publisher.publish_environment_id(Some(new_enrollment.environment_id.clone())); - *enrollment = Some(new_enrollment); } let enrollment_ref = enrollment.as_ref().ok_or_else(|| { @@ -1132,12 +1377,26 @@ pub(super) async fn connect_remote_control_websocket( let request = build_remote_control_websocket_request( &remote_control_target.websocket_url, enrollment_ref, - &auth, connect_options.installation_id, connect_options.subscribe_cursor, )?; - match connect_async(request).await { + let websocket_connect_result = tokio::time::timeout( + REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT, + connect_async(request), + ) + .await + .map_err(|_| { + io::Error::new( + ErrorKind::TimedOut, + format!( + "timed out connecting to remote control websocket at `{}` after {:?}", + remote_control_target.websocket_url, REMOTE_CONTROL_WEBSOCKET_CONNECT_TIMEOUT + ), + ) + })?; + + match websocket_connect_result { Ok((websocket_stream, response)) => Ok((websocket_stream, response.map(|_| ()))), Err(err) => { match &err { @@ -1149,31 +1408,31 @@ pub(super) async fn connect_remote_control_websocket( enrollment_ref.server_id, enrollment_ref.environment_id ); - if let Err(clear_err) = update_persisted_remote_control_enrollment( - Some(state_db), + clear_remote_control_enrollment( + state_db, remote_control_target, &auth.account_id, connect_options.app_server_client_name, - /*enrollment*/ None, + enrollment, + status_publisher, ) - .await - { - warn!( - "failed to clear stale remote control enrollment in sqlite state db: {clear_err}" - ); - } - *enrollment = None; - status_publisher.publish_environment_id(/*environment_id*/ None); + .await; } tungstenite::Error::Http(response) if matches!(response.status().as_u16(), 401 | 403) => { - if recover_remote_control_auth(auth_recovery).await { - return Err(io::Error::other(format!( - "remote control websocket auth failed with HTTP {}; retrying after auth recovery", - response.status() - ))); - } + enrollment + .as_mut() + .ok_or_else(|| { + io::Error::other( + "missing remote control enrollment after websocket auth failure", + ) + })? + .clear_server_token(); + return Err(io::Error::other(format!( + "remote control websocket auth failed with HTTP {}; refreshing server token before reconnect", + response.status() + ))); } _ => {} } @@ -1187,15 +1446,113 @@ pub(super) async fn connect_remote_control_websocket( } } -async fn recover_remote_control_auth(auth_recovery: &mut UnauthorizedRecovery) -> bool { +async fn clear_remote_control_enrollment( + state_db: &StateRuntime, + remote_control_target: &RemoteControlTarget, + account_id: &str, + app_server_client_name: Option<&str>, + enrollment: &mut Option, + status_publisher: &RemoteControlStatusPublisher, +) { + if let Err(clear_err) = update_persisted_remote_control_enrollment( + Some(state_db), + remote_control_target, + account_id, + app_server_client_name, + /*enrollment*/ None, + ) + .await + { + warn!("failed to clear stale remote control enrollment in sqlite state db: {clear_err}"); + } + *enrollment = None; + status_publisher.publish_environment_id(/*environment_id*/ None); +} + +async fn enroll_remote_control_server_if_missing( + remote_control_target: &RemoteControlTarget, + state_db: &StateRuntime, + auth: &RemoteControlConnectionAuth, + auth_context: &mut RemoteControlAuthContext<'_>, + enrollment: &mut Option, + connect_options: RemoteControlConnectOptions<'_>, + status_publisher: &RemoteControlStatusPublisher, +) -> io::Result<()> { + if enrollment.is_some() { + return Ok(()); + } + + info!( + "creating new remote control enrollment: websocket_url={}, enroll_url={}, account_id={}", + remote_control_target.websocket_url, remote_control_target.enroll_url, auth.account_id + ); + let new_enrollment = match enroll_remote_control_server( + remote_control_target, + auth, + connect_options.installation_id, + connect_options.server_name, + ) + .await + { + Ok(new_enrollment) => new_enrollment, + Err(err) + if err.kind() == ErrorKind::PermissionDenied + && recover_remote_control_auth( + auth_context.auth_recovery, + auth_context.auth_change_rx, + ) + .await => + { + return Err(io::Error::other(format!( + "{err}; retrying after auth recovery" + ))); + } + Err(err) => return Err(err), + }; + if let Err(err) = update_persisted_remote_control_enrollment( + Some(state_db), + remote_control_target, + &auth.account_id, + connect_options.app_server_client_name, + Some(&new_enrollment), + ) + .await + { + return Err(io::Error::other(format!( + "failed to persist remote control enrollment in sqlite state db: {err}" + ))); + } + info!( + "created new remote control enrollment: websocket_url={}, account_id={}, server_id={}, environment_id={}", + remote_control_target.websocket_url, + new_enrollment.account_id, + new_enrollment.server_id, + new_enrollment.environment_id + ); + status_publisher.publish_environment_id(Some(new_enrollment.environment_id.clone())); + *enrollment = Some(new_enrollment); + Ok(()) +} + +async fn recover_remote_control_auth( + auth_recovery: &mut UnauthorizedRecovery, + auth_change_rx: &mut watch::Receiver, +) -> bool { if !auth_recovery.has_next() { return false; } let mode = auth_recovery.mode_name(); let step = auth_recovery.step_name(); + let auth_change_revision_before_recovery = *auth_change_rx.borrow(); match auth_recovery.next().await { Ok(step_result) => { + if step_result.auth_state_changed() == Some(true) { + mark_recovery_auth_change_seen( + auth_change_rx, + auth_change_revision_before_recovery, + ); + } info!( "remote control websocket auth recovery succeeded: mode={mode}, step={step}, auth_state_changed={:?}", step_result.auth_state_changed() @@ -1209,6 +1566,20 @@ async fn recover_remote_control_auth(auth_recovery: &mut UnauthorizedRecovery) - } } +fn mark_recovery_auth_change_seen( + auth_change_rx: &mut watch::Receiver, + auth_change_revision_before_recovery: u64, +) { + let auth_change_revision_after_recovery = *auth_change_rx.borrow(); + if auth_change_revision_after_recovery == auth_change_revision_before_recovery.wrapping_add(1) { + // Recovery updated the same watch that wakes the outer reconnect + // loop. Mark only that single revision seen; if more revisions + // arrived while recovery was in flight, leave them pending so the + // reconnect loop still reacts to the later external auth change. + auth_change_rx.borrow_and_update(); + } +} + fn format_remote_control_websocket_connect_error( websocket_url: &str, err: &tungstenite::Error, @@ -1272,6 +1643,39 @@ mod tests { #[cfg(not(windows))] const TEST_HTTP_ACCEPT_TIMEOUT: Duration = Duration::from_secs(5); const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111"; + const TEST_REMOTE_CONTROL_SERVER_TOKEN: &str = "Remote Control Token"; + + fn remote_control_enrollment(remote_control_token: Option<&str>) -> RemoteControlEnrollment { + RemoteControlEnrollment { + account_id: "account_id".to_string(), + environment_id: "env_test".to_string(), + server_id: "srv_e_test".to_string(), + server_name: "test-server".to_string(), + remote_control_token: remote_control_token.map(str::to_string), + expires_at: remote_control_token + .map(|_| time::OffsetDateTime::now_utc() + time::Duration::hours(1)), + } + } + + #[test] + fn next_reconnect_delay_resets_after_cap() { + let mut reconnect_attempt = 9; + + let (reconnect_delay, reconnect_backoff_reset) = + next_reconnect_delay(&mut reconnect_attempt); + + assert_eq!(reconnect_delay, REMOTE_CONTROL_RECONNECT_BACKOFF_CAP); + assert!(reconnect_backoff_reset); + assert_eq!(reconnect_attempt, 0); + + let (reconnect_delay, reconnect_backoff_reset) = + next_reconnect_delay(&mut reconnect_attempt); + + assert!(reconnect_delay >= Duration::from_millis(180)); + assert!(reconnect_delay <= Duration::from_millis(220)); + assert!(!reconnect_backoff_reset); + assert_eq!(reconnect_attempt, 1); + } fn remote_control_status_channel() -> ( RemoteControlStatusPublisher, @@ -1279,12 +1683,44 @@ mod tests { ) { let (status_tx, status_rx) = watch::channel(RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, }); (RemoteControlStatusPublisher::new(status_tx), status_rx) } + #[test] + fn mark_recovery_auth_change_seen_marks_only_recovery_revision_seen() { + let (auth_change_tx, mut auth_change_rx) = watch::channel(0u64); + let auth_change_revision_before_recovery = *auth_change_rx.borrow(); + auth_change_tx.send_modify(|revision| *revision += 1); + + mark_recovery_auth_change_seen(&mut auth_change_rx, auth_change_revision_before_recovery); + + assert!( + !auth_change_rx + .has_changed() + .expect("auth change watch should remain open") + ); + } + + #[test] + fn mark_recovery_auth_change_seen_preserves_racing_auth_change() { + let (auth_change_tx, mut auth_change_rx) = watch::channel(0u64); + let auth_change_revision_before_recovery = *auth_change_rx.borrow(); + auth_change_tx.send_modify(|revision| *revision += 1); + auth_change_tx.send_modify(|revision| *revision += 1); + + mark_recovery_auth_change_seen(&mut auth_change_rx, auth_change_revision_before_recovery); + + assert!( + auth_change_rx + .has_changed() + .expect("auth change watch should remain open") + ); + } + async fn remote_control_state_runtime(codex_home: &TempDir) -> Arc { StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string()) .await @@ -1370,22 +1806,24 @@ mod tests { let state_db = remote_control_state_runtime(&codex_home).await; let auth_manager = remote_control_auth_manager(); let mut auth_recovery = auth_manager.unauthorized_recovery(); - let mut enrollment = Some(RemoteControlEnrollment { - account_id: "account_id".to_string(), - environment_id: "env_test".to_string(), - server_id: "srv_e_test".to_string(), - server_name: "test-server".to_string(), - }); + let mut auth_change_rx = auth_manager.auth_change_receiver(); + let mut enrollment = Some(remote_control_enrollment(Some( + TEST_REMOTE_CONTROL_SERVER_TOKEN, + ))); let (status_publisher, status_rx) = remote_control_status_channel(); let err = match connect_remote_control_websocket( &remote_control_target, Some(state_db.as_ref()), - &auth_manager, - &mut auth_recovery, + RemoteControlAuthContext { + auth_manager: &auth_manager, + auth_recovery: &mut auth_recovery, + auth_change_rx: &mut auth_change_rx, + }, &mut enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, + server_name: "test-server", subscribe_cursor: None, app_server_client_name: None, }, @@ -1403,6 +1841,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_test".to_string()), } @@ -1410,7 +1849,7 @@ mod tests { } #[tokio::test] - async fn connect_remote_control_websocket_recovers_after_unauthorized_reload() { + async fn connect_remote_control_websocket_invalidates_unauthorized_server_token() { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("listener should bind"); @@ -1418,34 +1857,14 @@ mod tests { let remote_control_target = normalize_remote_control_url(&remote_control_url).expect("target should parse"); let codex_home = TempDir::new().expect("temp dir should create"); - save_auth( - codex_home.path(), - &remote_control_auth_dot_json("stale-token"), - AuthCredentialsStoreMode::File, - ) - .expect("stale auth should save"); let state_db = remote_control_state_runtime(&codex_home).await; - let auth_manager = AuthManager::shared( - codex_home.path().to_path_buf(), - /*enable_codex_api_key_env*/ false, - AuthCredentialsStoreMode::File, - /*chatgpt_base_url*/ None, - ) - .await; + let auth_manager = remote_control_auth_manager(); let mut auth_recovery = auth_manager.unauthorized_recovery(); - let mut enrollment = Some(RemoteControlEnrollment { - account_id: "account_id".to_string(), - environment_id: "env_test".to_string(), - server_id: "srv_e_test".to_string(), - server_name: "test-server".to_string(), - }); + let mut auth_change_rx = auth_manager.auth_change_receiver(); + let mut enrollment = Some(remote_control_enrollment(Some( + TEST_REMOTE_CONTROL_SERVER_TOKEN, + ))); let (status_publisher, status_rx) = remote_control_status_channel(); - save_auth( - codex_home.path(), - &remote_control_auth_dot_json("fresh-token"), - AuthCredentialsStoreMode::File, - ) - .expect("fresh auth should save"); let server_task = tokio::spawn(async move { let (stream, request_line) = accept_http_request(&listener).await; @@ -1459,11 +1878,15 @@ mod tests { let err = connect_remote_control_websocket( &remote_control_target, Some(state_db.as_ref()), - &auth_manager, - &mut auth_recovery, + RemoteControlAuthContext { + auth_manager: &auth_manager, + auth_recovery: &mut auth_recovery, + auth_change_rx: &mut auth_change_rx, + }, &mut enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, + server_name: "test-server", subscribe_cursor: None, app_server_client_name: None, }, @@ -1477,22 +1900,20 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_test".to_string()), } ); assert_eq!( err.to_string(), - "remote control websocket auth failed with HTTP 401 Unauthorized; retrying after auth recovery" + "remote control websocket auth failed with HTTP 401 Unauthorized; refreshing server token before reconnect" ); assert_eq!( - auth_manager - .auth() - .await - .expect("auth should remain available") - .get_token() - .expect("token should be readable"), - "fresh-token" + enrollment, + Some(remote_control_enrollment( + /*remote_control_token*/ None + )) ); } @@ -1529,6 +1950,7 @@ mod tests { ) .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut auth_change_rx = auth_manager.auth_change_receiver(); let mut enrollment = None; let (status_publisher, status_rx) = remote_control_status_channel(); save_auth( @@ -1541,11 +1963,15 @@ mod tests { let err = connect_remote_control_websocket( &remote_control_target, Some(state_db.as_ref()), - &auth_manager, - &mut auth_recovery, + RemoteControlAuthContext { + auth_manager: &auth_manager, + auth_recovery: &mut auth_recovery, + auth_change_rx: &mut auth_change_rx, + }, &mut enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, + server_name: "test-server", subscribe_cursor: None, app_server_client_name: None, }, @@ -1575,6 +2001,110 @@ mod tests { .expect("token should be readable"), "fresh-token" ); + assert!( + !auth_change_rx + .has_changed() + .expect("auth change watch should remain open"), + "recovery's own auth reload should not wake the reconnect loop" + ); + } + + #[tokio::test] + async fn connect_remote_control_websocket_recovers_after_unauthorized_refresh() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let remote_control_target = + normalize_remote_control_url(&remote_control_url).expect("target should parse"); + let refresh_url = remote_control_target.refresh_url.clone(); + let server_task = tokio::spawn(async move { + let (stream, request_line) = accept_http_request(&listener).await; + assert_eq!( + request_line, + "POST /backend-api/wham/remote/control/server/refresh HTTP/1.1" + ); + respond_with_status_and_headers(stream, "401 Unauthorized", &[], "unauthorized").await; + }); + let codex_home = TempDir::new().expect("temp dir should create"); + save_auth( + codex_home.path(), + &remote_control_auth_dot_json("stale-token"), + AuthCredentialsStoreMode::File, + ) + .expect("stale auth should save"); + let state_db = remote_control_state_runtime(&codex_home).await; + let auth_manager = AuthManager::shared( + codex_home.path().to_path_buf(), + /*enable_codex_api_key_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await; + let mut auth_recovery = auth_manager.unauthorized_recovery(); + let mut auth_change_rx = auth_manager.auth_change_receiver(); + let mut enrollment = Some(remote_control_enrollment( + /*remote_control_token*/ None, + )); + let (status_publisher, status_rx) = remote_control_status_channel(); + save_auth( + codex_home.path(), + &remote_control_auth_dot_json("fresh-token"), + AuthCredentialsStoreMode::File, + ) + .expect("fresh auth should save"); + + let err = connect_remote_control_websocket( + &remote_control_target, + Some(state_db.as_ref()), + RemoteControlAuthContext { + auth_manager: &auth_manager, + auth_recovery: &mut auth_recovery, + auth_change_rx: &mut auth_change_rx, + }, + &mut enrollment, + RemoteControlConnectOptions { + installation_id: TEST_INSTALLATION_ID, + server_name: "test-server", + subscribe_cursor: None, + app_server_client_name: None, + }, + &status_publisher, + ) + .await + .expect_err("unauthorized refresh should fail the websocket connect"); + + server_task.await.expect("server task should succeed"); + assert_eq!( + status_rx.borrow().clone(), + RemoteControlStatusChangedNotification { + status: RemoteControlConnectionStatus::Connecting, + server_name: "test-server".to_string(), + installation_id: TEST_INSTALLATION_ID.to_string(), + environment_id: Some("env_test".to_string()), + } + ); + assert_eq!( + err.to_string(), + format!( + "remote control server refresh failed at `{refresh_url}`: HTTP 401 Unauthorized, request-id: , cf-ray: , body: unauthorized; retrying after auth recovery" + ) + ); + assert_eq!( + auth_manager + .auth() + .await + .expect("auth should remain available") + .get_token() + .expect("token should be readable"), + "fresh-token" + ); + assert!( + !auth_change_rx + .has_changed() + .expect("auth change watch should remain open"), + "recovery's own auth reload should not wake the reconnect loop" + ); } #[tokio::test] @@ -1583,22 +2113,24 @@ mod tests { .expect("target should parse"); let auth_manager = remote_control_auth_manager(); let mut auth_recovery = auth_manager.unauthorized_recovery(); - let mut enrollment = Some(RemoteControlEnrollment { - account_id: "account_id".to_string(), - environment_id: "env_test".to_string(), - server_id: "srv_e_test".to_string(), - server_name: "test-server".to_string(), - }); + let mut auth_change_rx = auth_manager.auth_change_receiver(); + let mut enrollment = Some(remote_control_enrollment(Some( + TEST_REMOTE_CONTROL_SERVER_TOKEN, + ))); let (status_publisher, _status_rx) = remote_control_status_channel(); let err = connect_remote_control_websocket( &remote_control_target, /*state_db*/ None, - &auth_manager, - &mut auth_recovery, + RemoteControlAuthContext { + auth_manager: &auth_manager, + auth_recovery: &mut auth_recovery, + auth_change_rx: &mut auth_change_rx, + }, &mut enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, + server_name: "test-server", subscribe_cursor: None, app_server_client_name: None, }, @@ -1626,12 +2158,10 @@ mod tests { ) .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); - let mut enrollment = Some(RemoteControlEnrollment { - account_id: "account_id".to_string(), - environment_id: "env_test".to_string(), - server_id: "srv_e_test".to_string(), - server_name: "test-server".to_string(), - }); + let mut auth_change_rx = auth_manager.auth_change_receiver(); + let mut enrollment = Some(remote_control_enrollment(Some( + TEST_REMOTE_CONTROL_SERVER_TOKEN, + ))); let (status_publisher, mut status_rx) = remote_control_status_channel(); status_publisher.publish_environment_id(Some("env_test".to_string())); status_rx @@ -1642,11 +2172,15 @@ mod tests { let err = connect_remote_control_websocket( &remote_control_target, Some(state_db.as_ref()), - &auth_manager, - &mut auth_recovery, + RemoteControlAuthContext { + auth_manager: &auth_manager, + auth_recovery: &mut auth_recovery, + auth_change_rx: &mut auth_change_rx, + }, &mut enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, + server_name: "test-server", subscribe_cursor: None, app_server_client_name: None, }, @@ -1665,6 +2199,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } @@ -1694,6 +2229,7 @@ mod tests { remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), remote_control_target: Some(remote_control_target), + server_name: "test-server".to_string(), }, /*state_db*/ None, remote_control_auth_manager(), @@ -1738,6 +2274,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_first".to_string()), } @@ -1759,6 +2296,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connected, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_first".to_string()), } @@ -1773,6 +2311,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connected, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } @@ -1788,6 +2327,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Disabled, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } @@ -1839,6 +2379,17 @@ mod tests { .expect("writer should stop cleanly"); } + #[tokio::test] + async fn join_connection_workers_aborts_stuck_worker_after_timeout() { + let mut join_set = tokio::task::JoinSet::new(); + join_set.spawn(futures::future::pending::<()>()); + + RemoteControlWebsocket::join_connection_workers(&mut join_set, Duration::from_millis(10)) + .await; + + assert!(join_set.is_empty()); + } + #[tokio::test] async fn run_server_writer_inner_assigns_contiguous_seq_ids_per_stream() { let (client_stream, mut server_stream) = connected_websocket_pair().await; @@ -2176,6 +2727,65 @@ mod tests { &raw[split..], ); + assert!(matches!( + observe_client_message(&mut state, first_chunk.clone()), + ClientSegmentObservation::Pending + )); + let completed_envelope = match observe_client_message(&mut state, second_chunk) { + ClientSegmentObservation::Forward(client_envelope) => *client_envelope, + _ => panic!("expected completed client message"), + }; + state.record_client_message_delivery( + &completed_envelope, + Some(( + ( + ClientId("client-1".to_string()), + Some(StreamId("stream-1".to_string())), + ), + 4, + )), + ); + assert!(matches!( + observe_client_message(&mut state, first_chunk), + ClientSegmentObservation::Dropped + )); + } + + #[test] + fn websocket_state_allows_replay_before_completed_chunk_delivery() { + let (outbound_buffer, _used_rx) = BoundedOutboundBuffer::new(); + let mut state = WebsocketState { + outbound_buffer, + subscribe_cursor: None, + next_seq_id_by_stream: HashMap::new(), + last_completed_client_chunk_seq_id_by_stream: HashMap::new(), + client_segment_reassembler: ClientSegmentReassembler::default(), + }; + let message = JSONRPCMessage::Notification(JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }); + let raw = serde_json::to_vec(&message).expect("message should serialize"); + let split = raw.len() / 2; + let first_chunk = client_chunk_envelope( + "client-1", + "stream-1", + /*seq_id*/ 4, + /*segment_id*/ 0, + /*segment_count*/ 2, + raw.len(), + &raw[..split], + ); + let second_chunk = client_chunk_envelope( + "client-1", + "stream-1", + /*seq_id*/ 4, + /*segment_id*/ 1, + /*segment_count*/ 2, + raw.len(), + &raw[split..], + ); + assert!(matches!( observe_client_message(&mut state, first_chunk.clone()), ClientSegmentObservation::Pending @@ -2186,7 +2796,7 @@ mod tests { )); assert!(matches!( observe_client_message(&mut state, first_chunk), - ClientSegmentObservation::Dropped + ClientSegmentObservation::Pending )); } diff --git a/codex-rs/app-server-transport/src/transport/unix_socket.rs b/codex-rs/app-server-transport/src/transport/unix_socket.rs index f75d3fe99a..dd46d88280 100644 --- a/codex-rs/app-server-transport/src/transport/unix_socket.rs +++ b/codex-rs/app-server-transport/src/transport/unix_socket.rs @@ -1,3 +1,4 @@ +use std::fs::OpenOptions; use std::io::ErrorKind; use std::io::Result as IoResult; use std::path::Path; @@ -89,7 +90,7 @@ async fn run_control_socket_acceptor( info!("control socket acceptor shutting down"); } -async fn prepare_control_socket_path(socket_path: &Path) -> IoResult<()> { +pub async fn prepare_control_socket_path(socket_path: &Path) -> IoResult<()> { if let Some(parent) = socket_path.parent() { codex_uds::prepare_private_socket_directory(parent).await?; } @@ -130,6 +131,30 @@ async fn prepare_control_socket_path(socket_path: &Path) -> IoResult<()> { tokio::fs::remove_file(socket_path).await } +pub struct AppServerStartupLock { + _file: std::fs::File, +} + +pub async fn acquire_app_server_startup_lock( + startup_lock_path: AbsolutePathBuf, +) -> IoResult { + if let Some(parent) = startup_lock_path.as_path().parent() { + codex_uds::prepare_private_socket_directory(parent).await?; + } + tokio::task::spawn_blocking(move || { + let file = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(startup_lock_path.as_path())?; + file.lock()?; + Ok(AppServerStartupLock { _file: file }) + }) + .await + .map_err(|err| std::io::Error::other(format!("startup lock task failed: {err}")))? +} + #[cfg(unix)] async fn set_control_socket_permissions(socket_path: &Path) -> IoResult<()> { use std::os::unix::fs::PermissionsExt; diff --git a/codex-rs/app-server-transport/src/transport/unix_socket_tests.rs b/codex-rs/app-server-transport/src/transport/unix_socket_tests.rs index 0b7dec0a23..ac0b2b00c4 100644 --- a/codex-rs/app-server-transport/src/transport/unix_socket_tests.rs +++ b/codex-rs/app-server-transport/src/transport/unix_socket_tests.rs @@ -1,6 +1,7 @@ use super::AppServerTransport; use super::CHANNEL_CAPACITY; use super::TransportEvent; +use super::acquire_app_server_startup_lock; use super::app_server_control_socket_path; use super::start_control_socket_acceptor; use codex_app_server_protocol::JSONRPCMessage; @@ -140,6 +141,28 @@ async fn control_socket_acceptor_upgrades_and_forwards_websocket_text_messages_a assert_socket_path_removed(socket_path.as_path()); } +#[tokio::test] +async fn app_server_startup_lock_serializes_waiters() { + let temp_dir = tempfile::TempDir::new().expect("temp dir"); + let lock_path = test_startup_lock_path(temp_dir.path()); + let first_lock = acquire_app_server_startup_lock(lock_path.clone()) + .await + .expect("first startup lock should succeed"); + let mut second_lock = tokio::spawn(acquire_app_server_startup_lock(lock_path)); + + assert!( + timeout(Duration::from_millis(100), &mut second_lock) + .await + .is_err() + ); + + drop(first_lock); + second_lock + .await + .expect("second startup lock task should join") + .expect("second startup lock should succeed"); +} + #[cfg(unix)] #[tokio::test] async fn control_socket_file_is_private_after_bind() { @@ -185,6 +208,15 @@ fn test_socket_path(temp_dir: &Path) -> AbsolutePathBuf { .expect("socket path should resolve") } +fn test_startup_lock_path(temp_dir: &Path) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path( + temp_dir + .join("app-server-control") + .join("app-server-startup.lock"), + ) + .expect("startup lock path should resolve") +} + async fn connect_to_socket(socket_path: &Path) -> IoResult { UnixStream::connect(socket_path).await } diff --git a/codex-rs/app-server-transport/src/transport/websocket.rs b/codex-rs/app-server-transport/src/transport/websocket.rs index 177e378906..5677faae67 100644 --- a/codex-rs/app-server-transport/src/transport/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/websocket.rs @@ -347,7 +347,7 @@ async fn run_websocket_inbound_loop( incoming_message = websocket_reader.next() => { match incoming_message { Some(Ok(message)) => match message.into_incoming() { - Some(IncomingWebSocketMessage::Text(text)) => { + Some(IncomingWebSocketMessage::Text(text)) if !forward_incoming_message( &transport_event_tx, &writer_tx_for_reader, @@ -355,10 +355,10 @@ async fn run_websocket_inbound_loop( &text, ) .await - { - break; - } + => { + break; } + Some(IncomingWebSocketMessage::Text(_)) => {} Some(IncomingWebSocketMessage::Ping(payload)) => { match writer_control_tx.try_send(M::pong(payload)) { Ok(()) => {} diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index ceb9a7169f..d1a69783ad 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -54,6 +54,9 @@ codex-backend-client = { workspace = true } codex-file-search = { workspace = true } codex-chatgpt = { workspace = true } codex-login = { workspace = true } +codex-image-generation-extension = { workspace = true } +codex-memories-extension = { workspace = true } +codex-web-search-extension = { workspace = true } codex-memories-write = { workspace = true } codex-mcp = { workspace = true } codex-model-provider = { workspace = true } @@ -91,6 +94,10 @@ tokio-util = { workspace = true } tracing = { workspace = true, features = ["log"] } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt", "json"] } uuid = { workspace = true, features = ["serde", "v7"] } + +[target.'cfg(windows)'.dependencies] +codex-windows-sandbox = { workspace = true } + [dev-dependencies] app_test_support = { workspace = true } axum = { workspace = true, default-features = false, features = [ diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 19d4fef01a..29acc51983 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -23,7 +23,7 @@ Similar to [MCP](https://modelcontextprotocol.io/), `codex app-server` supports Supported transports: -- stdio (`--listen stdio://`, default): newline-delimited JSON (JSONL) +- stdio (`--stdio` or `--listen stdio://`, default): newline-delimited JSON (JSONL) - websocket (`--listen ws://IP:PORT`): one JSON-RPC message per websocket text frame (**experimental / unsupported**) - unix socket (`--listen unix://` or `--listen unix://PATH`): websocket connections over `$CODEX_HOME/app-server-control/app-server-control.sock` or a custom socket path, using the standard HTTP Upgrade handshake - off (`--listen off`): do not expose a local transport @@ -130,16 +130,17 @@ Example with notification opt-out: ## API Overview -- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. For permissions, prefer experimental `permissions` profile selection; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. +- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; relative paths resolve against the effective thread cwd. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. - `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`. -- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read response `permissionProfile` for the exact active runtime permissions and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. -- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. +- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. +- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate control/spawn parent is known. - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. - `thread/turns/list` — experimental; page through a stored thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `itemsView`, `nextCursor`, and `backwardsCursor`. - `thread/turns/items/list` — experimental; reserved for paging full items for one turn. The API shape is present, but app-server currently returns an unsupported-method JSON-RPC error. - `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`. +- `thread/settings/update` — experimental; queue a partial update to a loaded thread’s next-turn settings without starting a turn or adding transcript items. Omitted fields leave settings unchanged; `serviceTier: null` clears the tier; `sandboxPolicy` and `permissions` cannot be combined. Returns `{}` when the update is accepted and emits `thread/settings/updated` with the full effective settings only if they actually change. `turn/start` settings overrides emit the same notification when they change the stored settings. - `thread/memoryMode/set` — experimental; set a thread’s persisted memory eligibility to `"enabled"` or `"disabled"` for either a loaded thread or a stored rollout; returns `{}` on success. - `memory/reset` — experimental; clear the current `CODEX_HOME/memories` directory and reset persisted memory stage data in sqlite while preserving existing thread memory modes; returns `{}` on success. - `thread/goal/set` — create or update the single persisted goal for a materialized thread; returns the current goal and emits `thread/goal/updated`. @@ -147,6 +148,7 @@ Example with notification opt-out: - `thread/goal/clear` — clear the current persisted goal for a materialized thread; returns whether a goal was removed and emits `thread/goal/cleared` when state changes. - `thread/goal/updated` — notification emitted whenever a thread goal changes; includes the full current goal. - `thread/goal/cleared` — notification emitted whenever a thread goal is removed. +- `thread/settings/updated` — experimental notification emitted to subscribed clients when a loaded thread’s effective next-turn settings change; includes `threadId` and the full `threadSettings`. - `thread/status/changed` — notification emitted when a loaded thread’s status changes (`threadId` + new `status`). - `thread/archive` — move a thread’s rollout file into the archived directory and attempt to move any spawned descendant thread rollout files; returns `{}` on success and emits `thread/archived` for each archived thread. - `thread/unsubscribe` — unsubscribe this connection from thread turn/item events. If this was the last subscriber, the server keeps the thread loaded and unloads it only after it has had no subscribers and no thread activity for 30 minutes, then emits `thread/closed`. @@ -156,9 +158,9 @@ Example with notification opt-out: - `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream. - `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted. - `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. -- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. Prefer experimental `permissions` profile selection for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". +- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; relative paths resolve against the effective turn cwd. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". - `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success. -- `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. Review and manual compaction turns reject `turn/steer`. +- `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Review and manual compaction turns reject `turn/steer`. - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. - `thread/realtime/start` — start a thread-scoped realtime session (experimental); pass `outputModality: "text"` or `outputModality: "audio"` to choose model output, returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create a WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`. - `thread/realtime/appendAudio` — append an input audio chunk to the active realtime session (experimental); returns `{}`. @@ -186,42 +188,46 @@ Example with notification opt-out: - `fs/watch` — subscribe this connection to filesystem change notifications for an absolute file or directory path and caller-provided `watchId`; returns the canonicalized `path`. - `fs/unwatch` — stop sending notifications for a prior `fs/watch`; returns `{}`. - `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`. -- `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. +- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, 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. For non-beta flags, `displayName`/`description`/`announcement` are `null`. -- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. +- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`. +- `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.]` entries to be included in the current catalog view. +- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for currently supported feature keys. For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. - `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; returns `{}` and does not change the default environment. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). +- `skills/extraRoots/set` — replace the app-server process runtime extra standalone skill roots. The roots are not persisted; missing directories are accepted and simply load no skills. - `hooks/list` — list discovered hooks for one or more `cwd` values. - `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present. - `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists. - `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors. - `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**). +- `plugin/installed` — list installed plugin rows plus any explicitly requested local install-suggestion plugin names, without fetching the broader remote catalog. Mention surfaces can use this narrower view when they need plugin mention payloads rather than plugin-page discovery data (**under development; do not call from production clients yet**). - `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/hooks/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering; bundled hooks are returned as lightweight declaration summaries keyed for correlation with `hooks/list`. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**). - `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle. - `skills/changed` — notification emitted when watched local skill files change. - `app/list` — list available apps. - `remoteControl/enable` — experimental; enable remote control for the current app-server process and return the current remote-control status snapshot. The caller is responsible for persisting the desired setting outside app-server. - `remoteControl/disable` — experimental; disable remote control for the current app-server process and return the current remote-control status snapshot. This does not revoke already enrolled controller devices. -- `remoteControl/status/changed` — notification emitted when the remote-control status or client-visible environment id changes. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. Newly initialized app-server clients always receive the current status snapshot. +- `remoteControl/status/read` — experimental; read the current remote-control status snapshot. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. +- `remoteControl/status/changed` — notification emitted when the remote-control status or client-visible environment id changes. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. Newly initialized app-server clients always receive the current status snapshot. - `skills/config/write` — write user-level skill config by name or absolute path. - `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**). - `plugin/uninstall` — uninstall a local plugin by `pluginId` in `@` form by removing its cached files and clearing its user-level config entry, or uninstall a remote ChatGPT plugin by backend `pluginId` by forwarding the uninstall to the ChatGPT plugin backend and removing any downloaded remote-plugin cache (**under development; do not call from production clients yet**). - `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes. - `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental). - `config/mcpServer/reload` — reload MCP server config from disk and queue a refresh for loaded threads (applied on each thread's next active turn); returns `{}`. Use this after editing `config.toml` without restarting the server. -- `mcpServerStatus/list` — enumerate configured MCP servers with their tools and auth status, plus resources/resource templates for `full` detail; supports cursor+limit pagination. If `detail` is omitted, the server defaults to `full`. +- `mcpServerStatus/list` — enumerate configured MCP servers with their tools, auth status, server info, plus resources/resource templates for `full` detail; supports optional `threadId` and cursor+limit pagination. If `threadId` is omitted, the server reads from the latest global config directly. If `detail` is omitted, the server defaults to `full`. - `mcpServer/resource/read` — read a resource from a configured MCP server by optional `threadId`, `server`, and `uri`, returning text/blob resource `contents`. If `threadId` is omitted, the server reads from the latest MCP config directly. - `mcpServer/tool/call` — call a tool on a thread's configured MCP server by `threadId`, `server`, `tool`, optional `arguments`, and optional `_meta`, returning the MCP tool result. - `windowsSandbox/setupStart` — start Windows sandbox setup for the selected mode (`elevated` or `unelevated`); accepts an optional absolute `cwd` to target setup for a specific workspace, returns `{ started: true }` immediately, and later emits `windowsSandbox/setupCompleted`. - `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id. -- `config/read` — fetch the effective config on disk after resolving config layering. +- `config/read` — fetch the effective config on disk after resolving config layering, including opaque `desktop` values stored in `config.toml`. - `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home), and plugin/session migration items may additionally include structured `details` grouping plugin ids or session metadata. - `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. When a request includes migration items, the server emits `externalAgentConfig/import/completed` once after the full import finishes (immediately after the response when everything completed synchronously, or after background imports finish). -- `config/value/write` — write a single config key/value to the user's config.toml on disk. -- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads. -- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), lifecycle hook lockdown (`allowManagedHooksOnly`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. +- `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface. +- `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits. +- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`, `allowedPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. ### Example: Start or resume a thread @@ -236,7 +242,9 @@ Start a fresh thread when you need a new Codex conversation. "approvalPolicy": "never", "sandbox": "workspaceWrite", // Prefer experimental profile selection: - // "permissions": { "type": "profile", "id": ":workspace" } + // "permissions": ":workspace" + // Experimental runtime roots for :workspace_roots materialization: + // "runtimeWorkspaceRoots": ["/Users/me/project", "/Users/me/openai"], // Do not send both "sandbox" and "permissions". "personality": "friendly", "serviceName": "my_app_server_client", // optional metrics tag (`service_name`) @@ -274,6 +282,8 @@ To continue a stored session, call `thread/resume` with the `thread.id` you prev By default, `thread/resume` includes the reconstructed turn history in `thread.turns`. Experimental clients can pass `excludeTurns: true` to return only thread metadata and live resume state, then call `thread/turns/list` separately if they want to page the turn history over the network. In that mode the server also skips replaying restored `thread/tokenUsage/updated`, which avoids rebuilding turns just to attribute historical usage. +Experimental clients that want the live resume subscription plus a turns page in one round trip can pass `initialTurnsPage`. It accepts the same `limit`, `sortDirection`, and `itemsView` controls as `thread/turns/list`; omitted controls use its defaults. The response includes `initialTurnsPage` with `nextCursor` and `backwardsCursor` for follow-up pagination. + By default, resume uses the latest persisted `model` and `reasoningEffort` values associated with the thread. Supplying any of `model`, `modelProvider`, `config.model`, or `config.model_reasoning_effort` disables that persisted fallback and uses the explicit overrides plus normal config resolution instead. Example: @@ -290,6 +300,24 @@ Example: "excludeTurns": true } } { "id": 12, "result": { "thread": { "id": "thr_123", "turns": [], … } } } + +{ "method": "thread/resume", "id": 13, "params": { + "threadId": "thr_123", + "excludeTurns": true, + "initialTurnsPage": { + "limit": 20, + "sortDirection": "desc", + "itemsView": "summary" + } +} } +{ "id": 13, "result": { + "thread": { "id": "thr_123", "turns": [], … }, + "initialTurnsPage": { + "data": [ ... ], + "nextCursor": "older-turns-cursor-or-null", + "backwardsCursor": "newer-turns-cursor-or-null" + } +} } ``` To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. The returned `thread.sessionId` identifies the current live session tree root. Root threads use their own `thread.id` as `thread.sessionId`; stored threads that are not loaded also report their own `thread.id`, because resuming one makes it the root of a new live session tree. When the source history includes persisted token usage, the server also emits `thread/tokenUsage/updated` for the new thread immediately after the response. If the source thread is actively running, the fork snapshots it as if the current turn had been interrupted first. Pass `ephemeral: true` when the fork should stay in-memory only: @@ -396,7 +424,7 @@ Later, after the idle unload timeout: ### Example: Read a thread -Use `thread/read` to fetch a stored thread by id without resuming it. Pass `includeTurns` when you want thread history loaded into `thread.turns`. The returned thread includes `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available. +Use `thread/read` to fetch a stored thread by id without resuming it. Pass `includeTurns` when you want thread history loaded into `thread.turns`. The returned thread includes `parentThreadId`, `agentNickname`, and `agentRole` for subagent threads when available. ```json { "method": "thread/read", "id": 22, "params": { "threadId": "thr_123" } } @@ -492,7 +520,7 @@ Experimental: use `memory/reset` to clear local memory artifacts and sqlite-back ### Example: Set and update a thread goal -Use `thread/goal/set` to create or update the current goal for a materialized thread. Clients can set `budgetLimited` when they stop because a token budget is exhausted or nearly exhausted; the system also sets it when accounting crosses a configured token budget. +Use `thread/goal/set` to create or update the current goal for a materialized thread. Clients can set `budgetLimited` when they stop because a token budget is exhausted or nearly exhausted, `blocked` when progress is waiting on outside intervention, and `usageLimited` when usage availability stops further work. The system also sets `budgetLimited` when accounting crosses a configured token budget and `usageLimited` when a turn ends on a hard usage-limit error. ```json { "method": "thread/goal/set", "id": 27, "params": { @@ -525,12 +553,12 @@ Use `thread/goal/set` to create or update the current goal for a materialized th ```json { "method": "thread/goal/set", "id": 28, "params": { "threadId": "thr_123", - "status": "paused" + "status": "blocked" } } { "id": 28, "result": { "goal": { "threadId": "thr_123", "objective": "Keep improving the benchmark until p95 latency is under 120ms", - "status": "paused", + "status": "blocked", "tokenBudget": 200000, "tokensUsed": 10000, "timeUsedSeconds": 60, @@ -635,6 +663,7 @@ You can optionally specify config overrides on the new turn. If specified, these ```json { "method": "turn/start", "id": 30, "params": { "threadId": "thr_123", + "clientUserMessageId": "client_msg_123", "input": [ { "type": "text", "text": "Run tests" } ], // Below are optional config overrides "cwd": "/Users/me/project", @@ -649,7 +678,9 @@ You can optionally specify config overrides on the new turn. If specified, these "networkAccess": true }, // Prefer experimental profile selection: - // "permissions": { "type": "profile", "id": ":workspace" } + // "permissions": ":workspace" + // Experimental runtime roots for :workspace_roots materialization: + // "runtimeWorkspaceRoots": ["/Users/me/project", "/Users/me/openai"], // Do not send both "sandboxPolicy" and "permissions". "model": "gpt-5.1-codex", "effort": "medium", @@ -713,7 +744,7 @@ Invoke an app by including `$` in the text input and adding a `mention ### Example: Start a turn (invoke a plugin) -Invoke a plugin by including a UI mention token such as `@sample` in the text input and adding a `mention` input item with the exact `plugin://@` path returned by `plugin/list`. +Invoke a plugin by including a UI mention token such as `@sample` in the text input and adding a `mention` input item with the exact `plugin://@` path returned by `plugin/installed` or `plugin/list`. ```json { "method": "turn/start", "id": 35, "params": { @@ -826,11 +857,12 @@ Use `thread/backgroundTerminals/clean` to terminate all running background termi ### Example: Steer an active turn Use `turn/steer` to append additional user input to the currently active regular turn. This does -not emit `turn/started` and does not accept turn context overrides. +not emit `turn/started` and does not accept thread settings overrides. ```json { "method": "turn/steer", "id": 32, "params": { "threadId": "thr_123", + "clientUserMessageId": "client_msg_124", "input": [ { "type": "text", "text": "Actually focus on failing tests first." } ], "expectedTurnId": "turn_456" } } @@ -921,14 +953,7 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin "cwd": "/Users/me/project", // optional; defaults to server cwd "env": { "FOO": "override" }, // optional; merges into the server env and overrides matching names "size": { "rows": 40, "cols": 120 }, // optional; PTY size in character cells, only valid with tty=true - "permissionProfile": { // optional; defaults to user config - "type": "managed", - "fileSystem": { "type": "restricted", "entries": [ - { "path": { "type": "special", "value": { "kind": "root" } }, "access": "read" }, - { "path": { "type": "special", "value": { "kind": "project_roots", "subpath": null } }, "access": "write" } - ] }, - "network": { "enabled": false } - }, + "permissionProfile": ":workspace", // optional profile id; defaults to user config "outputBytesCap": 1048576, // optional; per-stream capture cap "disableOutputCap": false, // optional; cannot be combined with outputBytesCap "timeoutMs": 10000, // optional; ms timeout; defaults to server timeout @@ -947,7 +972,7 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin Notes: - Empty `command` arrays are rejected. -- Prefer `permissionProfile` for command permission overrides. The legacy `sandboxPolicy` field accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags, `externalSandbox` with `networkAccess` `restricted|enabled`), but cannot be combined with `permissionProfile`. +- Prefer `permissionProfile` for command permission overrides. It selects an active profile by id (for example `:read-only`, `:workspace`, or a user-defined `[permissions.]` profile) rather than accepting low-level filesystem/network permissions. The legacy `sandboxPolicy` field accepts the same shape used by `turn/start` (e.g., `dangerFullAccess`, `readOnly`, `workspaceWrite` with flags, `externalSandbox` with `networkAccess` `restricted|enabled`), but cannot be combined with `permissionProfile`. - `env` merges into the environment produced by the server's shell environment policy. Matching names are overridden; unspecified variables are left intact. - When omitted, `timeoutMs` falls back to the server default. - When omitted, `outputBytesCap` falls back to the server default of 1 MiB per stream. @@ -1223,13 +1248,13 @@ Today both notifications carry an empty `items` array even when item events were `ThreadItem` is the tagged union carried in turn responses and `item/*` notifications. Currently we support events for the following items: -- `userMessage` — `{id, content}` where `content` is a list of user inputs (`text`, `image`, or `localImage`). +- `userMessage` — `{id, clientId, content}` where `clientId` is the optional `clientUserMessageId` supplied to `turn/start` or `turn/steer`, and `content` is a list of user inputs (`text`, `image`, or `localImage`). - `agentMessage` — `{id, text}` containing the accumulated agent reply. - `plan` — `{id, text}` emitted for plan-mode turns; plan text can stream via `item/plan/delta` (experimental). - `reasoning` — `{id, summary, content}` where `summary` holds streamed reasoning summaries (applicable for most OpenAI models) and `content` holds raw reasoning blocks (applicable for e.g. open source models). - `commandExecution` — `{id, command, cwd, status, commandActions, aggregatedOutput?, exitCode?, durationMs?}` for sandboxed commands; `status` is `inProgress`, `completed`, `failed`, or `declined`. - `fileChange` — `{id, changes, status}` describing proposed edits; `changes` list `{path, kind, diff}` and `status` is `inProgress`, `completed`, `failed`, or `declined`. -- `mcpToolCall` — `{id, server, tool, status, arguments, result?, error?}` describing MCP calls; `status` is `inProgress`, `completed`, or `failed`. +- `mcpToolCall` — `{id, server, tool, status, arguments, mcpAppResourceUri?, pluginId, result?, error?}` describing MCP calls; `status` is `inProgress`, `completed`, or `failed`. - `collabToolCall` — `{id, tool, status, senderThreadId, receiverThreadId?, newThreadId?, prompt?, agentStatus?}` describing collab tool calls (`spawn_agent`, `send_input`, `resume_agent`, `wait`, `close_agent`); `status` is `inProgress`, `completed`, or `failed`. - `webSearch` — `{id, query, action?}` for a web search request issued by the agent; `action` mirrors the Responses API web_search action payload (`search`, `open_page`, `find_in_page`) and may be omitted until completion. - `imageView` — `{id, path}` emitted when the agent invokes the image viewer tool. @@ -1482,6 +1507,7 @@ $skill-creator Add a new skill for triaging flaky CI and include step-by-step us Use `skills/list` to fetch the available skills (optionally scoped by `cwds`, with `forceReload`). `skills/list` might reuse a cached skills result per `cwd`; setting `forceReload` to `true` refreshes the result from disk. The server also emits `skills/changed` notifications when watched local skill files change. Treat this as an invalidation signal and re-run `skills/list` with your current params when needed. +Use `skills/extraRoots/set` to replace additional standalone skill roots for the current app-server process. These roots use the same layout as other standalone skill roots: each root contains skill directories, and each skill directory contains `SKILL.md`. Missing roots are accepted and load no skills until they exist. This setting is lost when app-server exits. ```json { "method": "skills/list", "id": 25, "params": { @@ -1518,12 +1544,23 @@ The server also emits `skills/changed` notifications when watched local skill fi } ``` +```json +{ + "method": "skills/extraRoots/set", + "id": 26, + "params": { + "extraRoots": ["/Users/me/generated-skills"] + } +} +{ "id": 26, "result": {} } +``` + To enable or disable a skill by absolute path: ```json { "method": "skills/config/write", - "id": 26, + "id": 27, "params": { "path": "/Users/alice/.codex/skills/skill-creator/SKILL.md", "name": null, @@ -1537,7 +1574,7 @@ To enable or disable a skill by name: ```json { "method": "skills/config/write", - "id": 27, + "id": 28, "params": { "path": null, "name": "github:yeet", @@ -1678,7 +1715,7 @@ The server also emits `app/list/updated` notifications whenever either source (a } ``` -Invoke an app by inserting `$` in the text input. The slug is derived from the app name and lowercased with non-alphanumeric characters replaced by `-` (for example, "Demo App" becomes `$demo-app`). Add a `mention` input item (recommended) so the server uses the exact `app://` path rather than guessing by name. Plugins use the same `mention` item shape, but with `plugin://@` paths from `plugin/list`. +Invoke an app by inserting `$` in the text input. The slug is derived from the app name and lowercased with non-alphanumeric characters replaced by `-` (for example, "Demo App" becomes `$demo-app`). Add a `mention` input item (recommended) so the server uses the exact `app://` path rather than guessing by name. Plugins use the same `mention` item shape, but with `plugin://@` paths from `plugin/installed` or `plugin/list`. Example: @@ -1929,7 +1966,7 @@ reason up through the containing type: ```rust #[derive(ExperimentalApi)] -struct ProfileV2 { +struct Config { #[experimental(nested)] approval_policy: Option, } @@ -1948,5 +1985,5 @@ For server-initiated request payloads, annotate the field the same way so schema 5. Verify the protocol crate: ```bash - cargo test -p codex-app-server-protocol + just test -p codex-app-server-protocol ``` diff --git a/codex-rs/app-server/src/attestation.rs b/codex-rs/app-server/src/attestation.rs index 17bb10c38c..206c38cee1 100644 --- a/codex-rs/app-server/src/attestation.rs +++ b/codex-rs/app-server/src/attestation.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use std::sync::Weak; use axum::http::HeaderValue; use codex_app_server_protocol::AttestationGenerateParams; @@ -22,13 +23,13 @@ pub(crate) fn app_server_attestation_provider( thread_state_manager: ThreadStateManager, ) -> Arc { Arc::new(AppServerAttestationProvider { - outgoing, + outgoing: Arc::downgrade(&outgoing), thread_state_manager, }) } struct AppServerAttestationProvider { - outgoing: Arc, + outgoing: Weak, thread_state_manager: ThreadStateManager, } @@ -42,7 +43,9 @@ impl std::fmt::Debug for AppServerAttestationProvider { impl AttestationProvider for AppServerAttestationProvider { fn header_for_request(&self, context: AttestationContext) -> GenerateAttestationFuture<'_> { - let outgoing = self.outgoing.clone(); + let Some(outgoing) = self.outgoing.upgrade() else { + return Box::pin(async { None }); + }; let thread_state_manager = self.thread_state_manager.clone(); Box::pin(async move { request_attestation_header_value_with_timeout( diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index f9e42f8363..e3501c0bce 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -4,6 +4,7 @@ use crate::outgoing_message::ClientRequestResult; use crate::outgoing_message::ThreadScopedOutgoingMessageSender; use crate::request_processors::populate_thread_turns_from_history; use crate::request_processors::thread_from_stored_thread; +use crate::request_processors::thread_settings_from_core_snapshot; use crate::server_request_error::is_turn_transition_server_request_error; use crate::thread_state::ThreadState; use crate::thread_state::TurnSummary; @@ -60,6 +61,7 @@ use codex_app_server_protocol::ThreadRealtimeStartedNotification; use codex_app_server_protocol::ThreadRealtimeTranscriptDeltaNotification; use codex_app_server_protocol::ThreadRealtimeTranscriptDoneNotification; use codex_app_server_protocol::ThreadRollbackResponse; +use codex_app_server_protocol::ThreadSettingsUpdatedNotification; use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; @@ -1200,6 +1202,24 @@ pub(crate) async fn apply_bespoke_event_handling( )) .await; } + EventMsg::ThreadSettingsApplied(thread_settings_event) => { + let thread_settings = + thread_settings_from_core_snapshot(thread_settings_event.thread_settings); + let changed = { + let mut state = thread_state.lock().await; + state.note_thread_settings(thread_settings.clone()) + }; + if changed { + outgoing + .send_server_notification(ServerNotification::ThreadSettingsUpdated( + ThreadSettingsUpdatedNotification { + thread_id: conversation_id.to_string(), + thread_settings, + }, + )) + .await; + } + } EventMsg::TurnDiff(turn_diff_event) => { handle_turn_diff(conversation_id, &event_turn_id, turn_diff_event, &outgoing).await; } @@ -2075,6 +2095,7 @@ mod tests { use codex_protocol::items::build_hook_prompt_message; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; + use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; @@ -2090,7 +2111,6 @@ mod tests { use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; use codex_protocol::protocol::RolloutItem; - use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; @@ -2133,10 +2153,12 @@ mod tests { let created_at = Utc::now(); let history_items = vec![ RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "before rollback".to_string(), images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() })), RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent { message: "after rollback".to_string(), @@ -2148,6 +2170,7 @@ mod tests { thread_id, rollout_path: None, forked_from_id: None, + parent_thread_id: None, preview: "fallback preview".to_string(), name: Some("Rollback thread".to_string()), model_provider: "openai".to_string(), @@ -2166,7 +2189,7 @@ mod tests { git_info: None, artifacts: Vec::new(), approval_mode: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), token_usage: None, first_user_message: Some("before rollback".to_string()), history: Some(StoredThreadHistory { @@ -3199,6 +3222,7 @@ mod tests { "turn-1", &EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + trace_id: None, started_at: Some(42), model_context_window: None, collaboration_mode_kind: Default::default(), @@ -3207,10 +3231,12 @@ mod tests { state.track_current_turn_event( "turn-1", &EventMsg::UserMessage(codex_protocol::protocol::UserMessageEvent { + client_id: None, message: "already tracked".to_string(), images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }), ); } @@ -3231,6 +3257,7 @@ mod tests { id: "turn-1".to_string(), msg: EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + trace_id: None, started_at: Some(42), model_context_window: None, collaboration_mode_kind: Default::default(), @@ -3280,6 +3307,7 @@ mod tests { &event_turn_id, &EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { turn_id: event_turn_id.clone(), + trace_id: None, started_at: Some(42), model_context_window: None, collaboration_mode_kind: Default::default(), diff --git a/codex-rs/app-server/src/command_exec.rs b/codex-rs/app-server/src/command_exec.rs index 443117e592..2f62f099e9 100644 --- a/codex-rs/app-server/src/command_exec.rs +++ b/codex-rs/app-server/src/command_exec.rs @@ -697,12 +697,13 @@ mod tests { let cwd = AbsolutePathBuf::current_dir().expect("current dir"); ExecRequest::new( vec!["cmd".to_string()], - cwd, + cwd.clone(), HashMap::new(), /*network*/ None, ExecExpiration::DefaultTimeout, codex_core::exec::ExecCapturePolicy::ShellTool, SandboxType::WindowsRestrictedToken, + vec![cwd], WindowsSandboxLevel::Disabled, /*windows_sandbox_private_desktop*/ false, PermissionProfile::read_only(), @@ -819,6 +820,7 @@ mod tests { ExecExpiration::Cancellation(CancellationToken::new()), codex_core::exec::ExecCapturePolicy::ShellTool, SandboxType::None, + vec![cwd.clone()], WindowsSandboxLevel::Disabled, /*windows_sandbox_private_desktop*/ false, PermissionProfile::read_only(), @@ -887,6 +889,7 @@ mod tests { }; let cancellation = CancellationToken::new(); let cancel = cancellation.clone(); + let cwd = AbsolutePathBuf::current_dir().expect("current dir"); manager .start(StartCommandExecParams { @@ -898,7 +901,7 @@ mod tests { process_id: Some("proc-101".to_string()), exec_request: ExecRequest::new( vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()], - AbsolutePathBuf::current_dir().expect("current dir"), + cwd.clone(), HashMap::new(), /*network*/ None, ExecExpiration::TimeoutOrCancellation { @@ -907,6 +910,7 @@ mod tests { }, codex_core::exec::ExecCapturePolicy::ShellTool, SandboxType::None, + vec![cwd], WindowsSandboxLevel::Disabled, /*windows_sandbox_private_desktop*/ false, PermissionProfile::read_only(), diff --git a/codex-rs/app-server/src/config/external_agent_config_tests.rs b/codex-rs/app-server/src/config/external_agent_config_tests.rs index 8435f9baf5..47f93a5f8b 100644 --- a/codex-rs/app-server/src/config/external_agent_config_tests.rs +++ b/codex-rs/app-server/src/config/external_agent_config_tests.rs @@ -422,7 +422,7 @@ async fn detect_repo_skips_hooks_when_only_unsupported_hooks_exist() { fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create external agent dir"); fs::write( repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"), - r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","if":"Bash(rm *)","command":"echo blocked"}]}],"SubagentStart":[{"matcher":"worker","hooks":[{"type":"command","command":"echo started"}]}]}}"#, + r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","if":"Bash(rm *)","command":"echo blocked"}]}],"UnsupportedEvent":[{"matcher":"worker","hooks":[{"type":"command","command":"echo started"}]}]}}"#, ) .expect("write hooks"); @@ -696,10 +696,25 @@ async fn import_home_migrates_supported_config_fields_skills_and_agents_md() { "Codex guidance" ); - assert_eq!( - fs::read_to_string(codex_home.join("config.toml")).expect("read config"), - "sandbox_mode = \"workspace-write\"\n\n[shell_environment_policy]\ninherit = \"core\"\n\n[shell_environment_policy.set]\nCI = \"false\"\nFOO = \"bar\"\nMAX_RETRIES = \"3\"\nMY_TEAM = \"codex\"\n" - ); + let config: TomlValue = + toml::from_str(&fs::read_to_string(codex_home.join("config.toml")).expect("read config")) + .expect("parse config"); + let expected: TomlValue = toml::from_str( + r#" +sandbox_mode = "workspace-write" + +[shell_environment_policy] +inherit = "core" + +[shell_environment_policy.set] +CI = "false" +FOO = "bar" +MAX_RETRIES = "3" +MY_TEAM = "codex" +"#, + ) + .expect("parse expected config"); + assert_eq!(config, expected); assert_eq!( fs::read_to_string(agents_skills.join("skill-a").join("SKILL.md")) .expect("read copied skill"), @@ -732,10 +747,24 @@ async fn import_home_config_uses_local_settings_over_project_settings() { .await .expect("import"); - assert_eq!( - fs::read_to_string(codex_home.join("config.toml")).expect("read config"), - "sandbox_mode = \"workspace-write\"\n\n[shell_environment_policy]\ninherit = \"core\"\n\n[shell_environment_policy.set]\nFOO = \"local\"\nLOCAL_ONLY = \"true\"\nPROJECT_ONLY = \"yes\"\n" - ); + let config: TomlValue = + toml::from_str(&fs::read_to_string(codex_home.join("config.toml")).expect("read config")) + .expect("parse config"); + let expected: TomlValue = toml::from_str( + r#" +sandbox_mode = "workspace-write" + +[shell_environment_policy] +inherit = "core" + +[shell_environment_policy.set] +FOO = "local" +LOCAL_ONLY = "true" +PROJECT_ONLY = "yes" +"#, + ) + .expect("parse expected config"); + assert_eq!(config, expected); } #[tokio::test] diff --git a/codex-rs/app-server/src/config_manager.rs b/codex-rs/app-server/src/config_manager.rs index 25fdc5c0cb..2cfd53b130 100644 --- a/codex-rs/app-server/src/config_manager.rs +++ b/codex-rs/app-server/src/config_manager.rs @@ -216,15 +216,23 @@ impl ConfigManager { &self, cli_overrides: &[(String, TomlValue)], request_overrides: Option>, - typesafe_overrides: ConfigOverrides, + mut typesafe_overrides: ConfigOverrides, fallback_cwd: Option, ) -> std::io::Result { + let mut request_overrides = request_overrides.unwrap_or_default(); + if let Some(value) = request_overrides.remove("bypass_hook_trust") { + typesafe_overrides.bypass_hook_trust = Some(value.as_bool().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "`bypass_hook_trust` override must be a boolean", + ) + })?); + } let merged_cli_overrides = cli_overrides .iter() .cloned() .chain( request_overrides - .unwrap_or_default() .into_iter() .map(|(key, value)| (key, json_to_toml(value))), ) diff --git a/codex-rs/app-server/src/config_manager_service.rs b/codex-rs/app-server/src/config_manager_service.rs index 64c5bee684..4b42c28aa9 100644 --- a/codex-rs/app-server/src/config_manager_service.rs +++ b/codex-rs/app-server/src/config_manager_service.rs @@ -125,7 +125,6 @@ impl ConfigManager { }; let effective = layers.effective_config(); - let effective_config_toml: ConfigToml = effective .try_into() .map_err(|err| ConfigManagerError::toml("invalid configuration", err))?; @@ -238,6 +237,23 @@ impl ConfigManager { let segments = parse_key_path(&key_path).map_err(|message| { ConfigManagerError::write(ConfigWriteErrorCode::ConfigValidationError, message) })?; + if !value.is_null() { + match segments.as_slice() { + [segment] if segment == "profile" => { + return Err(ConfigManagerError::write( + ConfigWriteErrorCode::ConfigValidationError, + "`profile` is a legacy config selector and can no longer be written; use `--profile ` with `.config.toml` instead", + )); + } + [segment, ..] if segment == "profiles" => { + return Err(ConfigManagerError::write( + ConfigWriteErrorCode::ConfigValidationError, + "`profiles` contains legacy config profile tables and can no longer be written; use `--profile ` with `.config.toml` instead", + )); + } + _ => {} + } + } let original_value = value_at_path(&user_config, &segments).cloned(); let parsed_value = parse_value(value).map_err(|message| { ConfigManagerError::write(ConfigWriteErrorCode::ConfigValidationError, message) @@ -403,10 +419,47 @@ fn parse_key_path(path: &str) -> Result, String> { if path.trim().is_empty() { return Err("keyPath must not be empty".to_string()); } - Ok(path - .split('.') - .map(std::string::ToString::to_string) - .collect()) + + let mut segments = Vec::new(); + let mut segment = String::new(); + let mut chars = path.chars(); + let mut quoted = false; + + // Split on dots unless they appear inside a quoted segment. Bare segments + // intentionally stay permissive so existing paths like `sample@catalog` + // remain valid. + while let Some(ch) = chars.next() { + match ch { + '"' if segment.is_empty() && !quoted => quoted = true, + '"' if quoted => quoted = false, + '\\' if quoted => { + // Quoted segments may escape punctuation that would otherwise + // participate in parsing, such as `.` or `"`. + let Some(escaped) = chars.next() else { + return Err("unterminated escape in keyPath".to_string()); + }; + segment.push(escaped); + } + '.' if !quoted => { + if segment.is_empty() { + return Err("keyPath segments must not be empty".to_string()); + } + segments.push(std::mem::take(&mut segment)); + } + '"' => return Err("invalid quoted keyPath segment".to_string()), + _ => segment.push(ch), + } + } + + if quoted { + return Err("unterminated quoted keyPath segment".to_string()); + } + if segment.is_empty() { + return Err("keyPath segments must not be empty".to_string()); + } + + segments.push(segment); + Ok(segments) } #[derive(Debug)] @@ -571,6 +624,9 @@ fn override_message(layer: &ConfigLayerSource) -> String { ConfigLayerSource::System { file } => { format!("Overridden by managed config (system): {}", file.display()) } + ConfigLayerSource::EnterpriseManaged { id: _, name } => { + format!("Overridden by enterprise-managed config: {name}") + } ConfigLayerSource::Project { dot_codex_folder } => format!( "Overridden by project config: {}/{CONFIG_TOML_FILE}", dot_codex_folder.display(), diff --git a/codex-rs/app-server/src/config_manager_service_tests.rs b/codex-rs/app-server/src/config_manager_service_tests.rs index c1a081e023..5c6e3de5a1 100644 --- a/codex-rs/app-server/src/config_manager_service_tests.rs +++ b/codex-rs/app-server/src/config_manager_service_tests.rs @@ -130,6 +130,112 @@ async fn clear_missing_nested_config_is_noop() -> Result<()> { Ok(()) } +#[tokio::test] +async fn write_value_rejects_legacy_profile_selector() -> Result<()> { + let tmp = tempdir().expect("tempdir"); + let path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&path, "model = \"gpt-main\"\n")?; + + let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf()); + let error = service + .write_value(ConfigValueWriteParams { + file_path: Some(path.display().to_string()), + key_path: "profile".to_string(), + value: serde_json::json!("work"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect_err("legacy profile selector write should fail"); + + assert_eq!( + error.write_error_code(), + Some(ConfigWriteErrorCode::ConfigValidationError) + ); + assert!( + error + .to_string() + .contains("`profile` is a legacy config selector"), + "{error}" + ); + assert_eq!(std::fs::read_to_string(&path)?, "model = \"gpt-main\"\n"); + Ok(()) +} + +#[tokio::test] +async fn write_value_rejects_legacy_profile_table() -> Result<()> { + let tmp = tempdir().expect("tempdir"); + let path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&path, "")?; + + let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf()); + let error = service + .write_value(ConfigValueWriteParams { + file_path: Some(path.display().to_string()), + key_path: "profiles.work.model".to_string(), + value: serde_json::json!("gpt-work"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect_err("legacy profile table write should fail"); + + assert_eq!( + error.write_error_code(), + Some(ConfigWriteErrorCode::ConfigValidationError) + ); + assert!( + error + .to_string() + .contains("`profiles` contains legacy config profile tables"), + "{error}" + ); + assert_eq!(std::fs::read_to_string(&path)?, ""); + Ok(()) +} + +#[tokio::test] +async fn batch_write_rejects_legacy_profile_selector() -> Result<()> { + let tmp = tempdir().expect("tempdir"); + let path = tmp.path().join(CONFIG_TOML_FILE); + std::fs::write(&path, "model = \"gpt-main\"\n")?; + + let service = ConfigManager::without_managed_config_for_tests(tmp.path().to_path_buf()); + let error = service + .batch_write(ConfigBatchWriteParams { + edits: vec![ + codex_app_server_protocol::ConfigEdit { + key_path: "model".to_string(), + value: serde_json::json!("gpt-work"), + merge_strategy: MergeStrategy::Replace, + }, + codex_app_server_protocol::ConfigEdit { + key_path: "profile".to_string(), + value: serde_json::json!("work"), + merge_strategy: MergeStrategy::Replace, + }, + ], + file_path: Some(path.display().to_string()), + expected_version: None, + reload_user_config: false, + }) + .await + .expect_err("legacy profile selector batch write should fail"); + + assert_eq!( + error.write_error_code(), + Some(ConfigWriteErrorCode::ConfigValidationError) + ); + assert!( + error + .to_string() + .contains("`profile` is a legacy config selector"), + "{error}" + ); + assert_eq!(std::fs::read_to_string(&path)?, "model = \"gpt-main\"\n"); + Ok(()) +} + #[tokio::test] async fn write_value_supports_nested_app_paths() -> Result<()> { let tmp = tempdir().expect("tempdir"); @@ -638,52 +744,6 @@ async fn write_value_rejects_feature_requirement_conflict() { ); } -#[tokio::test] -async fn write_value_rejects_profile_feature_requirement_conflict() { - let tmp = tempdir().expect("tempdir"); - std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "").unwrap(); - - let service = ConfigManager::new_for_tests( - tmp.path().to_path_buf(), - vec![], - LoaderOverrides::without_managed_config_for_tests(), - CloudRequirementsLoader::new(async { - Ok(Some(ConfigRequirementsToml { - feature_requirements: Some(FeatureRequirementsToml { - entries: BTreeMap::from([("personality".to_string(), true)]), - }), - ..Default::default() - })) - }), - ); - - let error = service - .write_value(ConfigValueWriteParams { - file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), - key_path: "profiles.enterprise.features.personality".to_string(), - value: serde_json::json!(false), - merge_strategy: MergeStrategy::Replace, - expected_version: None, - }) - .await - .expect_err("conflicting profile feature write should fail"); - - assert_eq!( - error.write_error_code(), - Some(ConfigWriteErrorCode::ConfigValidationError) - ); - assert!( - error.to_string().contains( - "invalid value for `features`: `profiles.enterprise.features.personality=false`" - ), - "{error}" - ); - assert_eq!( - std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(), - "" - ); -} - #[tokio::test] async fn read_reports_managed_overrides_user_and_session_flags() { let tmp = tempdir().expect("tempdir"); diff --git a/codex-rs/app-server/src/extensions.rs b/codex-rs/app-server/src/extensions.rs index a293daf4f2..7b2673ca06 100644 --- a/codex-rs/app-server/src/extensions.rs +++ b/codex-rs/app-server/src/extensions.rs @@ -1,26 +1,71 @@ use std::sync::Arc; use std::sync::Weak; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadGoalUpdatedNotification; use codex_core::NewThread; use codex_core::StartThreadOptions; use codex_core::ThreadManager; use codex_core::config::Config; use codex_extension_api::AgentSpawnFuture; use codex_extension_api::AgentSpawner; +use codex_extension_api::ExtensionEventSink; use codex_extension_api::ExtensionRegistry; use codex_extension_api::ExtensionRegistryBuilder; +use codex_login::AuthManager; use codex_protocol::ThreadId; use codex_protocol::error::CodexErr; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; -pub(crate) fn thread_extensions(guardian_agent_spawner: S) -> Arc> +use crate::outgoing_message::OutgoingMessageSender; + +pub(crate) fn thread_extensions( + guardian_agent_spawner: S, + event_sink: Arc, + auth_manager: Arc, +) -> Arc> where S: AgentSpawner + 'static, { - let mut builder = ExtensionRegistryBuilder::::new(); + let mut builder = ExtensionRegistryBuilder::::with_event_sink(event_sink); codex_guardian::install(&mut builder, guardian_agent_spawner); + codex_memories_extension::install(&mut builder, codex_otel::global()); + codex_web_search_extension::install(&mut builder, auth_manager.clone()); + codex_image_generation_extension::install(&mut builder, auth_manager); Arc::new(builder.build()) } +pub(crate) fn app_server_extension_event_sink( + outgoing: Arc, +) -> Arc { + Arc::new(AppServerExtensionEventSink { outgoing }) +} + +struct AppServerExtensionEventSink { + outgoing: Arc, +} + +impl ExtensionEventSink for AppServerExtensionEventSink { + fn emit(&self, event: Event) { + match event.msg { + EventMsg::ThreadGoalUpdated(thread_goal_event) => { + self.outgoing + .try_send_server_notification(ServerNotification::ThreadGoalUpdated( + ThreadGoalUpdatedNotification { + thread_id: thread_goal_event.thread_id.to_string(), + turn_id: thread_goal_event.turn_id, + goal: thread_goal_event.goal.into(), + }, + )); + } + msg => { + tracing::debug!(event_id = %event.id, ?msg, "dropping unsupported extension event"); + } + } + } +} + pub(crate) fn guardian_agent_spawner( thread_manager: Weak, ) -> impl AgentSpawner { @@ -38,3 +83,84 @@ pub(crate) fn guardian_agent_spawner( }) } } + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use codex_analytics::AnalyticsEventsClient; + use codex_app_server_protocol::ServerNotification; + use codex_app_server_protocol::ThreadGoal as AppServerThreadGoal; + use codex_app_server_protocol::ThreadGoalStatus as AppServerThreadGoalStatus; + use codex_protocol::protocol::ThreadGoal; + use codex_protocol::protocol::ThreadGoalStatus; + use codex_protocol::protocol::ThreadGoalUpdatedEvent; + use pretty_assertions::assert_eq; + use tokio::sync::mpsc; + use tokio::time::timeout; + + use super::*; + use crate::outgoing_message::OutgoingEnvelope; + use crate::outgoing_message::OutgoingMessage; + + #[tokio::test] + async fn app_server_event_sink_forwards_thread_goal_updates() { + let (outgoing_tx, mut outgoing_rx) = mpsc::channel(4); + let outgoing = Arc::new(OutgoingMessageSender::new( + outgoing_tx, + AnalyticsEventsClient::disabled(), + )); + let sink = app_server_extension_event_sink(outgoing); + let thread_id = ThreadId::default(); + + sink.emit(Event { + id: "call-1".to_string(), + msg: EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { + thread_id, + turn_id: Some("turn-1".to_string()), + goal: ThreadGoal { + thread_id, + objective: "wire extension events".to_string(), + status: ThreadGoalStatus::Active, + token_budget: Some(123), + tokens_used: 45, + time_used_seconds: 6, + created_at: 7, + updated_at: 8, + }, + }), + }); + + let envelope = timeout(Duration::from_secs(1), outgoing_rx.recv()) + .await + .expect("timed out waiting for forwarded extension event") + .expect("outgoing channel closed unexpectedly"); + let OutgoingEnvelope::Broadcast { message } = envelope else { + panic!("expected broadcast notification"); + }; + let OutgoingMessage::AppServerNotification(ServerNotification::ThreadGoalUpdated( + notification, + )) = message + else { + panic!("expected thread goal updated notification"); + }; + + assert_eq!( + ThreadGoalUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: Some("turn-1".to_string()), + goal: AppServerThreadGoal { + thread_id: thread_id.to_string(), + objective: "wire extension events".to_string(), + status: AppServerThreadGoalStatus::Active, + token_budget: Some(123), + tokens_used: 45, + time_used_seconds: 6, + created_at: 7, + updated_at: 8, + }, + }, + notification + ); + } +} diff --git a/codex-rs/app-server/src/fs_watch.rs b/codex-rs/app-server/src/fs_watch.rs index 09c6dc5533..405b0560a3 100644 --- a/codex-rs/app-server/src/fs_watch.rs +++ b/codex-rs/app-server/src/fs_watch.rs @@ -8,66 +8,24 @@ use codex_app_server_protocol::FsWatchParams; use codex_app_server_protocol::FsWatchResponse; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::ServerNotification; +use codex_file_watcher::DebouncedWatchReceiver; use codex_file_watcher::FileWatcher; -use codex_file_watcher::FileWatcherEvent; use codex_file_watcher::FileWatcherSubscriber; -use codex_file_watcher::Receiver; use codex_file_watcher::WatchPath; use codex_file_watcher::WatchRegistration; use std::collections::HashMap; -use std::collections::HashSet; use std::collections::hash_map::Entry; use std::hash::Hash; -use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use tokio::sync::Mutex as AsyncMutex; #[cfg(test)] use tokio::sync::mpsc; use tokio::sync::oneshot; -use tokio::time::Instant; use tracing::warn; const FS_CHANGED_NOTIFICATION_DEBOUNCE: Duration = Duration::from_millis(200); -struct DebouncedReceiver { - rx: Receiver, - interval: Duration, - changed_paths: HashSet, - next_allowance: Option, -} - -impl DebouncedReceiver { - fn new(rx: Receiver, interval: Duration) -> Self { - Self { - rx, - interval, - changed_paths: HashSet::new(), - next_allowance: None, - } - } - - async fn recv(&mut self) -> Option { - while self.changed_paths.is_empty() { - self.changed_paths.extend(self.rx.recv().await?.paths); - } - let next_allowance = *self - .next_allowance - .get_or_insert_with(|| Instant::now() + self.interval); - - loop { - tokio::select! { - event = self.rx.recv() => self.changed_paths.extend(event?.paths), - _ = tokio::time::sleep_until(next_allowance) => break, - } - } - - Some(FileWatcherEvent { - paths: self.changed_paths.drain().collect(), - }) - } -} - #[derive(Clone)] pub(crate) struct FsWatchManager { outgoing: Arc, @@ -151,7 +109,7 @@ impl FsWatchManager { let task_watch_id = watch_id.clone(); tokio::spawn(async move { - let mut rx = DebouncedReceiver::new(rx, FS_CHANGED_NOTIFICATION_DEBOUNCE); + let mut rx = DebouncedWatchReceiver::new(rx, FS_CHANGED_NOTIFICATION_DEBOUNCE); tokio::pin!(terminate_rx); loop { let event = tokio::select! { @@ -219,6 +177,8 @@ mod tests { use super::*; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; + use std::collections::HashSet; + use std::path::PathBuf; use tempfile::TempDir; fn absolute_path(path: PathBuf) -> AbsolutePathBuf { diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index c75c2d5ad1..f7072d0fa1 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -102,7 +102,10 @@ pub const DEFAULT_IN_PROCESS_CHANNEL_CAPACITY: usize = CHANNEL_CAPACITY; type PendingClientRequestResponse = std::result::Result; fn server_notification_requires_delivery(notification: &ServerNotification) -> bool { - matches!(notification, ServerNotification::TurnCompleted(_)) + matches!( + notification, + ServerNotification::TurnCompleted(_) | ServerNotification::ThreadSettingsUpdated(_) + ) } /// Input needed to start an in-process app-server runtime. diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 350acf002f..80305d2d9f 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -31,7 +31,10 @@ use crate::transport::ConnectionState; use crate::transport::OutboundConnectionState; use crate::transport::RemoteControlStartConfig; use crate::transport::TransportEvent; +use crate::transport::acquire_app_server_startup_lock; +use crate::transport::app_server_startup_lock_path; use crate::transport::auth::policy_from_settings; +use crate::transport::prepare_control_socket_path; use crate::transport::route_outgoing_envelope; use crate::transport::start_control_socket_acceptor; use crate::transport::start_remote_control; @@ -41,7 +44,6 @@ use codex_analytics::AppServerRpcTransport; use codex_app_server_protocol::ConfigLayerSource; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCMessage; -use codex_app_server_protocol::RemoteControlStatusChangedNotification; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::TextPosition as AppTextPosition; use codex_app_server_protocol::TextRange as AppTextRange; @@ -401,6 +403,7 @@ pub enum PluginStartupTasks { pub struct AppServerRuntimeOptions { pub plugin_startup_tasks: PluginStartupTasks, pub remote_control_enabled: bool, + pub install_shutdown_signal_handler: bool, } impl Default for AppServerRuntimeOptions { @@ -408,6 +411,7 @@ impl Default for AppServerRuntimeOptions { Self { plugin_startup_tasks: PluginStartupTasks::Start, remote_control_enabled: false, + install_shutdown_signal_handler: true, } } } @@ -444,9 +448,9 @@ pub async fn run_main_with_transport_options( arg0_paths.codex_linux_sandbox_exe.clone(), )?; let environment_manager = if loader_overrides.ignore_user_config { - EnvironmentManager::from_env(local_runtime_paths).await + EnvironmentManager::from_env(Some(local_runtime_paths)).await } else { - EnvironmentManager::from_codex_home(codex_home.clone(), local_runtime_paths).await + EnvironmentManager::from_codex_home(codex_home.clone(), Some(local_runtime_paths)).await } .map(Arc::new) .map_err(std::io::Error::other)?; @@ -515,13 +519,21 @@ pub async fn run_main_with_transport_options( })?; codex_core::otel_init::record_process_start(otel.as_ref(), OTEL_SERVICE_NAME); codex_core::otel_init::install_sqlite_telemetry(otel.as_ref(), OTEL_SERVICE_NAME); + let unix_socket_startup_lock = match &transport { + AppServerTransport::UnixSocket { socket_path } => { + let startup_lock_path = app_server_startup_lock_path(&codex_home)?; + let startup_lock = acquire_app_server_startup_lock(startup_lock_path).await?; + prepare_control_socket_path(socket_path.as_path()).await?; + Some(startup_lock) + } + _ => None, + }; let state_db = match rollout_state_db::try_init(&config).await { Ok(state_db) => Some(state_db), Err(err) => { - let state_db_path = codex_state::state_db_path(config.sqlite_home.as_path()); return Err(std::io::Error::other(format!( - "failed to initialize sqlite state db at {}: {err}", - state_db_path.display() + "failed to initialize sqlite state runtime under {}: {err}", + config.sqlite_home.display() ))); } }; @@ -589,7 +601,7 @@ pub async fn run_main_with_transport_options( }); } if let Some(warning) = - codex_core::config::system_bwrap_warning(config.permissions.permission_profile.get()) + codex_core::config::system_bwrap_warning(config.permissions.permission_profile()) { config_warnings.push(ConfigWarningNotification { summary: warning, @@ -646,7 +658,8 @@ pub async fn run_main_with_transport_options( let single_client_mode = matches!(&transport, AppServerTransport::Stdio); let shutdown_when_no_connections = single_client_mode; - let graceful_signal_restart_enabled = !single_client_mode; + let graceful_signal_restart_enabled = + runtime_options.install_shutdown_signal_handler && !single_client_mode; let mut app_server_client_name_rx = None; match &transport { @@ -681,6 +694,7 @@ pub async fn run_main_with_transport_options( } AppServerTransport::Off => {} } + drop(unix_socket_startup_lock); let auth_manager = AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await; @@ -772,8 +786,7 @@ pub async fn run_main_with_transport_options( }); let processor_handle = tokio::spawn({ - let auth_manager = - AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await; + let auth_manager = Arc::clone(&auth_manager); let analytics_events_client = analytics_events_client_from_config(Arc::clone(&auth_manager), &config); let outgoing_message_sender = Arc::new(OutgoingMessageSender::new( @@ -1003,14 +1016,9 @@ pub async fn run_main_with_transport_options( continue; } remote_control_status = status.clone(); + let notification = ServerNotification::RemoteControlStatusChanged(status); initialize_notification_sender - .send_server_notification(ServerNotification::RemoteControlStatusChanged( - RemoteControlStatusChangedNotification { - status: status.status, - installation_id: status.installation_id, - environment_id: status.environment_id, - }, - )) + .send_server_notification(notification) .await; } created = thread_created_rx.recv(), if listen_for_threads => { diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 6f5ecabf90..2435c532e8 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -17,6 +17,7 @@ const MANAGED_CONFIG_PATH_ENV_VAR: &str = "CODEX_APP_SERVER_MANAGED_CONFIG_PATH" const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; #[derive(Debug, Parser)] +#[command(version)] struct AppServerArgs { /// Transport endpoint URL. Supported values: `stdio://` (default), /// `unix://`, `unix://PATH`, `ws://IP:PORT`, `off`. diff --git a/codex-rs/app-server/src/mcp_refresh.rs b/codex-rs/app-server/src/mcp_refresh.rs index f7d32b2ea8..4ddfa34735 100644 --- a/codex-rs/app-server/src/mcp_refresh.rs +++ b/codex-rs/app-server/src/mcp_refresh.rs @@ -114,6 +114,7 @@ mod tests { use codex_core::init_state_db; use codex_core::thread_store_from_config; use codex_exec_server::EnvironmentManager; + use codex_extension_api::NoopExtensionEventSink; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_protocol::protocol::SessionSource; @@ -183,12 +184,16 @@ mod tests { let thread_manager = Arc::new_cyclic(|thread_manager| { ThreadManager::new( &good_config, - auth_manager, + auth_manager.clone(), SessionSource::Exec, Arc::new(EnvironmentManager::default_for_tests()), - thread_extensions(guardian_agent_spawner(thread_manager.clone())), + thread_extensions( + guardian_agent_spawner(thread_manager.clone()), + Arc::new(NoopExtensionEventSink), + auth_manager.clone(), + ), /*analytics_events_client*/ None, - thread_store, + Arc::clone(&thread_store), Some(state_db.clone()), "11111111-1111-4111-8111-111111111111".to_string(), /*attestation_provider*/ None, diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 893dd06e98..270596dc4d 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -8,6 +8,7 @@ use crate::attestation::app_server_attestation_provider; use crate::config_manager::ConfigManager; use crate::connection_rpc_gate::ConnectionRpcGate; use crate::error_code::invalid_request; +use crate::extensions::app_server_extension_event_sink; use crate::extensions::guardian_agent_spawner; use crate::extensions::thread_extensions; use crate::fs_watch::FsWatchManager; @@ -85,6 +86,7 @@ use tokio::sync::broadcast; use tokio::sync::watch; use tokio::time::Duration; use tokio::time::timeout; +use tokio_util::sync::CancellationToken; use tracing::Instrument; const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10); @@ -159,6 +161,7 @@ impl ExternalAuth for ExternalAuthRefreshBridge { pub(crate) struct MessageProcessor { outgoing: Arc, + skills_watcher: Arc, account_processor: AccountRequestProcessor, apps_processor: AppsRequestProcessor, catalog_processor: CatalogRequestProcessor, @@ -301,13 +304,18 @@ impl MessageProcessor { // affect per-thread behavior, but they must not move newly started, // resumed, or forked threads to a different persistence backend/root. let thread_store = codex_core::thread_store_from_config(config.as_ref(), state_db.clone()); + let environment_manager_for_requests = Arc::clone(&environment_manager); let thread_manager = Arc::new_cyclic(|thread_manager| { ThreadManager::new( config.as_ref(), auth_manager.clone(), session_source, environment_manager, - thread_extensions(guardian_agent_spawner(thread_manager.clone())), + thread_extensions( + guardian_agent_spawner(thread_manager.clone()), + app_server_extension_event_sink(outgoing.clone()), + auth_manager.clone(), + ), Some(analytics_events_client.clone()), Arc::clone(&thread_store), state_db.clone(), @@ -329,6 +337,7 @@ impl MessageProcessor { let thread_list_state_permit = Arc::new(Semaphore::new(/*permits*/ 1)); let workspace_settings_cache = Arc::new(workspace_settings::WorkspaceSettingsCache::default()); + let app_list_shutdown_token = CancellationToken::new(); let account_processor = AccountRequestProcessor::new( auth_manager.clone(), Arc::clone(&thread_manager), @@ -342,8 +351,11 @@ impl MessageProcessor { outgoing.clone(), config_manager.clone(), Arc::clone(&workspace_settings_cache), + app_list_shutdown_token, ); let catalog_processor = CatalogRequestProcessor::new( + outgoing.clone(), + Arc::clone(&skills_watcher), auth_manager.clone(), Arc::clone(&thread_manager), Arc::clone(&config), @@ -354,8 +366,13 @@ impl MessageProcessor { arg0_paths.clone(), Arc::clone(&config), outgoing.clone(), + config_manager.clone(), + Arc::clone(&environment_manager_for_requests), + ); + let process_exec_processor = ProcessExecRequestProcessor::new( + outgoing.clone(), + Arc::clone(&environment_manager_for_requests), ); - let process_exec_processor = ProcessExecRequestProcessor::new(outgoing.clone()); let feedback_processor = FeedbackRequestProcessor::new( auth_manager.clone(), Arc::clone(&thread_manager), @@ -442,7 +459,6 @@ impl MessageProcessor { Some(on_effective_plugins_changed), ); } - let fs_watch_manager = FsWatchManager::new(outgoing.clone()); let config_processor = ConfigRequestProcessor::new( outgoing.clone(), config_manager.clone(), @@ -461,11 +477,8 @@ impl MessageProcessor { let environment_processor = EnvironmentRequestProcessor::new(thread_manager.environment_manager()); let fs_processor = FsRequestProcessor::new( - thread_manager - .environment_manager() - .local_environment() - .get_filesystem(), - fs_watch_manager, + Arc::clone(&environment_manager_for_requests), + FsWatchManager::new(outgoing.clone()), ); let windows_sandbox_processor = WindowsSandboxRequestProcessor::new( outgoing.clone(), @@ -475,6 +488,7 @@ impl MessageProcessor { Self { outgoing, + skills_watcher, account_processor, apps_processor, catalog_processor, @@ -502,6 +516,8 @@ impl MessageProcessor { pub(crate) fn clear_runtime_references(&self) { self.account_processor.clear_external_auth(); + self.apps_processor.shutdown(); + self.skills_watcher.shutdown(); } pub(crate) async fn process_request( @@ -897,6 +913,10 @@ impl MessageProcessor { .remote_control_processor .disable() .map(|response| Some(response.into())), + ClientRequest::RemoteControlStatusRead { .. } => self + .remote_control_processor + .status_read() + .map(|response| Some(response.into())), ClientRequest::ConfigRequirementsRead { params: _, .. } => self .config_processor .config_requirements_read() @@ -1030,6 +1050,11 @@ impl MessageProcessor { ClientRequest::ThreadArtifactCreate { params, .. } => { self.thread_processor.thread_artifact_create(params).await } + ClientRequest::ThreadSettingsUpdate { params, .. } => { + self.turn_processor + .thread_settings_update(&request_id, params) + .await + } ClientRequest::ThreadMemoryModeSet { params, .. } => { self.thread_processor.thread_memory_mode_set(params).await } @@ -1057,6 +1082,9 @@ impl MessageProcessor { ClientRequest::ThreadList { params, .. } => { self.thread_processor.thread_list(params).await } + ClientRequest::ThreadSearch { params, .. } => { + self.thread_processor.thread_search(params).await + } ClientRequest::ThreadLoadedList { params, .. } => { self.thread_processor.thread_loaded_list(params).await } @@ -1085,6 +1113,9 @@ impl MessageProcessor { ClientRequest::SkillsList { params, .. } => { self.catalog_processor.skills_list(params).await } + ClientRequest::SkillsExtraRootsSet { params, .. } => { + self.catalog_processor.skills_extra_roots_set(params).await + } ClientRequest::HooksList { params, .. } => { self.catalog_processor.hooks_list(params).await } @@ -1100,6 +1131,9 @@ impl MessageProcessor { ClientRequest::PluginList { params, .. } => { self.plugin_processor.plugin_list(params).await } + ClientRequest::PluginInstalled { params, .. } => { + self.plugin_processor.plugin_installed(params).await + } ClientRequest::PluginRead { params, .. } => { self.plugin_processor.plugin_read(params).await } @@ -1143,6 +1177,9 @@ impl MessageProcessor { .experimental_feature_list(params) .await } + ClientRequest::PermissionProfileList { params, .. } => { + self.catalog_processor.permission_profile_list(params).await + } ClientRequest::CollaborationModeList { params, .. } => { self.catalog_processor.collaboration_mode_list(params).await } diff --git a/codex-rs/app-server/src/message_processor_tracing_tests.rs b/codex-rs/app-server/src/message_processor_tracing_tests.rs index c955d06ba2..94b5a1af27 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -653,12 +653,15 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { params: TurnStartParams { environments: None, thread_id, + client_user_message_id: None, input: vec![UserInput::Text { text: "hello".to_string(), text_elements: Vec::new(), }], responsesapi_client_metadata: None, + additional_context: None, cwd: None, + runtime_workspace_roots: None, approval_policy: None, sandbox_policy: None, permissions: None, diff --git a/codex-rs/app-server/src/models.rs b/codex-rs/app-server/src/models.rs index 4d75a20580..e53f13df84 100644 --- a/codex-rs/app-server/src/models.rs +++ b/codex-rs/app-server/src/models.rs @@ -53,6 +53,7 @@ fn model_from_preset(preset: ModelPreset) -> Model { description: service_tier.description, }) .collect(), + default_service_tier: preset.default_service_tier, is_default: preset.is_default, } } diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 97ef37f742..e8fffd5361 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -456,8 +456,8 @@ impl OutgoingMessageSender { ) -> Vec { let request_id_to_callback = self.request_id_to_callback.lock().await; let mut requests = request_id_to_callback - .iter() - .filter_map(|(_, entry)| { + .values() + .filter_map(|entry| { (entry.thread_id == Some(thread_id)).then_some(entry.request.clone()) }) .collect::>(); @@ -555,6 +555,16 @@ impl OutgoingMessageSender { .await; } + pub(crate) fn try_send_server_notification(&self, notification: ServerNotification) { + tracing::trace!("app-server event: {notification}"); + let outgoing_message = OutgoingMessage::AppServerNotification(notification); + if let Err(err) = self.sender.try_send(OutgoingEnvelope::Broadcast { + message: outgoing_message, + }) { + warn!("failed to send server notification to client without waiting: {err:?}"); + } + } + pub(crate) async fn send_server_notification_to_connections( &self, connection_ids: &[ConnectionId], diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 8b47612785..d71c58e0ce 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -25,6 +25,8 @@ use codex_app_server_protocol::AccountLoginCompletedNotification; use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::AddCreditsNudgeCreditType; use codex_app_server_protocol::AddCreditsNudgeEmailStatus; +use codex_app_server_protocol::AdditionalContextEntry; +use codex_app_server_protocol::AdditionalContextKind; use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::AppListUpdatedNotification; use codex_app_server_protocol::AppSummary; @@ -103,11 +105,14 @@ 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::PermissionProfileModificationParams; -use codex_app_server_protocol::PermissionProfileSelectionParams; +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; +use codex_app_server_protocol::PluginInstalledParams; +use codex_app_server_protocol::PluginInstalledResponse; use codex_app_server_protocol::PluginInterface; use codex_app_server_protocol::PluginListMarketplaceKind; use codex_app_server_protocol::PluginListParams; @@ -151,6 +156,8 @@ use codex_app_server_protocol::ServerRequestResolvedNotification; use codex_app_server_protocol::SkillSummary; use codex_app_server_protocol::SkillsConfigWriteParams; use codex_app_server_protocol::SkillsConfigWriteResponse; +use codex_app_server_protocol::SkillsExtraRootsSetParams; +use codex_app_server_protocol::SkillsExtraRootsSetResponse; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::SortDirection; @@ -213,11 +220,18 @@ use codex_app_server_protocol::ThreadRealtimeStartResponse; use codex_app_server_protocol::ThreadRealtimeStartTransport; use codex_app_server_protocol::ThreadRealtimeStopParams; use codex_app_server_protocol::ThreadRealtimeStopResponse; +use codex_app_server_protocol::ThreadResumeInitialTurnsPageParams; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadRollbackParams; +use codex_app_server_protocol::ThreadSearchParams; +use codex_app_server_protocol::ThreadSearchResponse; +use codex_app_server_protocol::ThreadSearchResult; use codex_app_server_protocol::ThreadSetNameParams; use codex_app_server_protocol::ThreadSetNameResponse; +use codex_app_server_protocol::ThreadSettings; +use codex_app_server_protocol::ThreadSettingsUpdateParams; +use codex_app_server_protocol::ThreadSettingsUpdateResponse; use codex_app_server_protocol::ThreadShellCommandParams; use codex_app_server_protocol::ThreadShellCommandResponse; use codex_app_server_protocol::ThreadSortKey; @@ -264,7 +278,7 @@ use codex_config::ConfigLayerStack; use codex_config::loader::project_trust_key; use codex_config::types::McpServerTransportConfig; use codex_core::CodexThread; -use codex_core::CodexThreadTurnContextOverrides; +use codex_core::CodexThreadSettingsOverrides; use codex_core::ExternalGoalPreviousStatus; use codex_core::ExternalGoalSet; use codex_core::ForkSnapshot; @@ -280,6 +294,7 @@ use codex_core::config::ConfigOverrides; use codex_core::config::NetworkProxyAuditMetadata; use codex_core::config::edit::ConfigEdit; use codex_core::config::edit::ConfigEditsBuilder; +use codex_core::connectors::AccessibleConnectorsStatus; use codex_core::exec::ExecCapturePolicy; use codex_core::exec::ExecExpiration; use codex_core::exec::ExecParams; @@ -292,7 +307,6 @@ use codex_core::windows_sandbox::WindowsSandboxLevelExt; use codex_core::windows_sandbox::WindowsSandboxSetupMode as CoreWindowsSandboxSetupMode; use codex_core::windows_sandbox::WindowsSandboxSetupRequest; use codex_core::windows_sandbox::sandbox_setup_is_complete; -use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_core_plugins::PluginInstallError as CorePluginInstallError; use codex_core_plugins::PluginInstallRequest; use codex_core_plugins::PluginLoadOutcome; @@ -338,12 +352,11 @@ use codex_login::complete_device_code_login; use codex_login::login_with_api_key; use codex_login::request_device_code; use codex_login::run_login_server; -use codex_mcp::McpRuntimeEnvironment; +use codex_mcp::McpRuntimeContext; use codex_mcp::McpServerStatusSnapshot; use codex_mcp::McpSnapshotDetail; use codex_mcp::collect_mcp_server_status_snapshot_with_detail; use codex_mcp::discover_supported_scopes; -use codex_mcp::effective_mcp_servers; use codex_mcp::read_mcp_resource as read_mcp_resource_without_thread; use codex_mcp::resolve_oauth_scopes; use codex_memories_write::clear_memory_roots_contents; @@ -354,6 +367,7 @@ use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::dynamic_tools::DynamicToolSpec as CoreDynamicToolSpec; @@ -361,7 +375,12 @@ 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; +use codex_protocol::openai_models::ReasoningEffort; +#[cfg(test)] use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::AgentStatus; use codex_protocol::protocol::ConversationAudioParams; @@ -405,6 +424,7 @@ use codex_thread_store::LocalThreadStore; use codex_thread_store::NewThreadArtifact as StoreNewThreadArtifact; use codex_thread_store::ReadThreadByRolloutPathParams as StoreReadThreadByRolloutPathParams; use codex_thread_store::ReadThreadParams as StoreReadThreadParams; +use codex_thread_store::SearchThreadsParams as StoreSearchThreadsParams; use codex_thread_store::SortDirection as StoreSortDirection; use codex_thread_store::StoredThread; use codex_thread_store::StoredThreadArtifact; @@ -414,6 +434,7 @@ use codex_thread_store::ThreadStore; use codex_thread_store::ThreadStoreError; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; +use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; use std::io::Error as IoError; @@ -430,6 +451,7 @@ use tokio::sync::broadcast; use tokio::sync::oneshot; use tokio::sync::watch; use tokio_util::sync::CancellationToken; +use tokio_util::sync::DropGuard; use tokio_util::task::TaskTracker; use toml::Value as TomlValue; use tracing::Instrument; @@ -517,6 +539,8 @@ pub(crate) use self::thread_processor::thread_from_stored_thread; pub(crate) use self::thread_summary::read_summary_from_rollout; #[cfg(test)] pub(crate) use self::thread_summary::summary_to_thread; +pub(crate) use self::thread_summary::thread_settings_from_config_snapshot; +pub(crate) use self::thread_summary::thread_settings_from_core_snapshot; pub(crate) fn build_api_turns_from_rollout_items(items: &[RolloutItem]) -> Vec { let mut builder = ThreadHistoryBuilder::new(); diff --git a/codex-rs/app-server/src/request_processors/apps_processor.rs b/codex-rs/app-server/src/request_processors/apps_processor.rs index da2956dbab..49a6615f1c 100644 --- a/codex-rs/app-server/src/request_processors/apps_processor.rs +++ b/codex-rs/app-server/src/request_processors/apps_processor.rs @@ -1,12 +1,13 @@ use super::*; -#[derive(Clone)] pub(crate) struct AppsRequestProcessor { auth_manager: Arc, thread_manager: Arc, outgoing: Arc, config_manager: ConfigManager, workspace_settings_cache: Arc, + shutdown_token: CancellationToken, + _shutdown_drop_guard: DropGuard, } impl AppsRequestProcessor { @@ -16,13 +17,17 @@ impl AppsRequestProcessor { outgoing: Arc, config_manager: ConfigManager, workspace_settings_cache: Arc, + shutdown_token: CancellationToken, ) -> Self { + let shutdown_drop_guard = shutdown_token.clone().drop_guard(); Self { auth_manager, thread_manager, outgoing, config_manager, workspace_settings_cache, + shutdown_token, + _shutdown_drop_guard: shutdown_drop_guard, } } @@ -41,11 +46,19 @@ impl AppsRequestProcessor { request_id: &ConnectionRequestId, params: AppsListParams, ) -> Result, JSONRPCErrorError> { - let mut config = self.load_latest_config(/*fallback_cwd*/ None).await?; - - if let Some(thread_id) = params.thread_id.as_deref() { - let (_, thread) = self.load_thread(thread_id).await?; + let thread = if let Some(thread_id) = params.thread_id.as_deref() { + let (_, loaded_thread) = self.load_thread(thread_id).await?; + Some(loaded_thread) + } else { + None + }; + let fallback_cwd = match thread.as_ref() { + Some(thread) => Some(thread.config_snapshot().await.cwd.to_path_buf()), + None => None, + }; + let mut config = self.load_latest_config(fallback_cwd).await?; + if let Some(thread) = thread { let _ = config .features .set_enabled(Feature::Apps, thread.enabled(Feature::Apps)); @@ -75,12 +88,20 @@ impl AppsRequestProcessor { let request = request_id.clone(); let outgoing = Arc::clone(&self.outgoing); let environment_manager = self.thread_manager.environment_manager(); + let shutdown_token = self.shutdown_token.child_token(); tokio::spawn(async move { - Self::apps_list_task(outgoing, request, params, config, environment_manager).await; + tokio::select! { + _ = shutdown_token.cancelled() => {} + _ = Self::apps_list_task(outgoing, request, params, config, environment_manager) => {} + } }); Ok(None) } + pub(crate) fn shutdown(&self) { + self.shutdown_token.cancel(); + } + async fn apps_list_task( outgoing: Arc, request_id: ConnectionRequestId, @@ -88,8 +109,31 @@ impl AppsRequestProcessor { config: Config, environment_manager: Arc, ) { + let retry_params = params.clone(); + let retry_config = config.clone(); + let retry_environment_manager = Arc::clone(&environment_manager); let result = Self::apps_list_response(&outgoing, params, config, environment_manager).await; - outgoing.send_result(request_id, result).await; + let should_retry = result + .as_ref() + .is_ok_and(|(_, codex_apps_ready)| !codex_apps_ready); + outgoing + .send_result(request_id, result.map(|(response, _)| response)) + .await; + + if should_retry && !retry_params.force_refetch { + let mut retry_params = retry_params; + retry_params.force_refetch = true; + if let Err(err) = Self::apps_list_response( + &outgoing, + retry_params, + retry_config, + retry_environment_manager, + ) + .await + { + warn!("failed to refresh app list after codex-apps readiness retry: {err:?}"); + } + } } async fn apps_list_response( @@ -97,7 +141,7 @@ impl AppsRequestProcessor { params: AppsListParams, config: Config, environment_manager: Arc, - ) -> Result { + ) -> Result<(AppsListResponse, bool), JSONRPCErrorError> { let AppsListParams { cursor, limit, @@ -127,10 +171,9 @@ impl AppsRequestProcessor { connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( &accessible_config, force_refetch, - &environment_manager, + Arc::clone(&environment_manager), ) .await - .map(|status| status.connectors) .map_err(|err| format!("failed to load accessible apps: {err}")); let _ = accessible_tx.send(AppListLoadResult::Accessible(result)); }); @@ -146,6 +189,7 @@ impl AppsRequestProcessor { let app_list_deadline = tokio::time::Instant::now() + APP_LIST_LOAD_TIMEOUT; let mut accessible_loaded = false; let mut all_loaded = false; + let mut codex_apps_ready = true; let mut last_notified_apps = None; if accessible_connectors.is_some() || all_connectors.is_some() { @@ -178,9 +222,10 @@ impl AppsRequestProcessor { }; match result { - AppListLoadResult::Accessible(Ok(connectors)) => { - accessible_connectors = Some(connectors); + AppListLoadResult::Accessible(Ok(status)) => { + accessible_connectors = Some(status.connectors); accessible_loaded = true; + codex_apps_ready = status.codex_apps_ready; } AppListLoadResult::Accessible(Err(err)) => { return Err(internal_error(err)); @@ -222,7 +267,8 @@ impl AppsRequestProcessor { } if accessible_loaded && all_loaded { - return paginate_apps(merged.as_slice(), start, limit); + let response = paginate_apps(merged.as_slice(), start, limit)?; + return Ok((response, codex_apps_ready)); } } } @@ -279,7 +325,7 @@ impl AppsRequestProcessor { const APP_LIST_LOAD_TIMEOUT: Duration = Duration::from_secs(90); enum AppListLoadResult { - Accessible(Result, String>), + Accessible(Result), Directory(Result, String>), } diff --git a/codex-rs/app-server/src/request_processors/catalog_processor.rs b/codex-rs/app-server/src/request_processors/catalog_processor.rs index 91fabf7616..6f17e4614f 100644 --- a/codex-rs/app-server/src/request_processors/catalog_processor.rs +++ b/codex-rs/app-server/src/request_processors/catalog_processor.rs @@ -1,8 +1,11 @@ use super::*; +use codex_config::config_toml::ConfigToml; use futures::StreamExt; #[derive(Clone)] pub(crate) struct CatalogRequestProcessor { + pub(super) outgoing: Arc, + pub(super) skills_watcher: Arc, pub(super) auth_manager: Arc, pub(super) thread_manager: Arc, pub(super) config: Arc, @@ -95,6 +98,8 @@ fn errors_to_info( impl CatalogRequestProcessor { pub(crate) fn new( + outgoing: Arc, + skills_watcher: Arc, auth_manager: Arc, thread_manager: Arc, config: Arc, @@ -102,6 +107,8 @@ impl CatalogRequestProcessor { workspace_settings_cache: Arc, ) -> Self { Self { + outgoing, + skills_watcher, auth_manager, thread_manager, config, @@ -137,6 +144,15 @@ impl CatalogRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) async fn skills_extra_roots_set( + &self, + params: SkillsExtraRootsSetParams, + ) -> Result, JSONRPCErrorError> { + self.skills_extra_roots_set_response(params) + .await + .map(|response| Some(response.into())) + } + pub(crate) async fn model_list( &self, params: ModelListParams, @@ -155,6 +171,15 @@ impl CatalogRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) async fn permission_profile_list( + &self, + params: PermissionProfileListParams, + ) -> Result, JSONRPCErrorError> { + self.permission_profile_list_response(params) + .await + .map(|response| Some(response.into())) + } + pub(crate) async fn collaboration_mode_list( &self, params: CollaborationModeListParams, @@ -285,8 +310,28 @@ impl CatalogRequestProcessor { &self, params: ExperimentalFeatureListParams, ) -> Result { - let ExperimentalFeatureListParams { cursor, limit } = params; - let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let ExperimentalFeatureListParams { + cursor, + limit, + thread_id, + } = params; + let config = match thread_id.as_deref() { + Some(thread_id) => { + let thread_id = ThreadId::from_string(thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + let thread = self + .thread_manager + .get_thread(thread_id) + .await + .map_err(|_| invalid_request(format!("thread not found: {thread_id}")))?; + let thread_config = thread.config().await; + self.config_manager + .load_latest_config_for_thread(thread_config.as_ref()) + .await + .map_err(|err| internal_error(format!("failed to reload config: {err}")))? + } + None => self.load_latest_config(/*fallback_cwd*/ None).await?, + }; let auth = self.auth_manager.auth().await; let workspace_codex_plugins_enabled = self .workspace_codex_plugins_enabled(&config, auth.as_ref()) @@ -369,6 +414,78 @@ impl CatalogRequestProcessor { Ok(ExperimentalFeatureListResponse { data, next_cursor }) } + async fn permission_profile_list_response( + &self, + params: PermissionProfileListParams, + ) -> Result { + let PermissionProfileListParams { cursor, limit, cwd } = params; + let config_layer_stack = match cwd { + Some(cwd) => { + let cwd = PathBuf::from(cwd); + let (_, config_layer_stack) = self + .resolve_cwd_config(&cwd) + .await + .map_err(|err| internal_error(format!("failed to reload config: {err}")))?; + config_layer_stack + } + None => self + .config_manager + .load_config_layers(/*cwd*/ None) + .await + .map_err(|err| internal_error(format!("failed to reload config: {err}")))?, + }; + let effective_config: ConfigToml = config_layer_stack + .effective_config() + .try_into() + .map_err(|err| internal_error(format!("failed to read effective config: {err}")))?; + let mut profiles = vec![ + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(), + description: None, + }, + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), + description: None, + }, + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(), + description: None, + }, + ]; + let mut configured_profiles = effective_config + .permissions + .into_iter() + .flat_map(|permissions| permissions.entries) + .map(|(id, profile)| PermissionProfileSummary { + id, + description: profile.description, + }) + .collect::>(); + configured_profiles.sort_by(|left, right| left.id.cmp(&right.id)); + profiles.extend(configured_profiles); + let total = profiles.len(); + let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; + let effective_limit = effective_limit.min(total); + let start = match cursor { + Some(cursor) => cursor + .parse::() + .map_err(|_| invalid_request(format!("invalid cursor: {cursor}")))?, + None => 0, + }; + + if start > total { + return Err(invalid_request(format!( + "cursor {start} exceeds total permission profiles {total}" + ))); + } + + let end = start.saturating_add(effective_limit).min(total); + let data = profiles[start..end].to_vec(); + let next_cursor = (end < total).then_some(end.to_string()); + + Ok(PermissionProfileListResponse { data, next_cursor }) + } + async fn mock_experimental_method_inner( &self, params: MockExperimentalMethodParams, @@ -465,6 +582,24 @@ impl CatalogRequestProcessor { Ok(SkillsListResponse { data }) } + async fn skills_extra_roots_set_response( + &self, + params: SkillsExtraRootsSetParams, + ) -> Result { + let SkillsExtraRootsSetParams { extra_roots } = params; + self.skills_watcher + .register_runtime_extra_roots(&extra_roots); + self.thread_manager + .skills_manager() + .set_extra_roots(extra_roots); + self.outgoing + .send_server_notification(ServerNotification::SkillsChanged( + codex_app_server_protocol::SkillsChangedNotification {}, + )) + .await; + Ok(SkillsExtraRootsSetResponse {}) + } + /// Handle `hooks/list` by resolving hooks for each requested cwd. async fn hooks_list_response( &self, @@ -510,15 +645,10 @@ impl CatalogRequestProcessor { .await; let plugins_enabled = config.features.enabled(Feature::Plugins) && workspace_codex_plugins_enabled; - let plugin_outcome = if plugins_enabled && config.features.enabled(Feature::PluginHooks) - { + let plugin_outcome = if plugins_enabled { let plugins_input = config.plugins_config_input(); plugins_manager - .plugins_for_layer_stack( - &config.config_layer_stack, - &plugins_input, - /*plugin_hooks_feature_enabled*/ true, - ) + .plugins_for_layer_stack(&config.config_layer_stack, &plugins_input) .await } else { PluginLoadOutcome::default() diff --git a/codex-rs/app-server/src/request_processors/command_exec_processor.rs b/codex-rs/app-server/src/request_processors/command_exec_processor.rs index 3236a67627..5b14f0b0c0 100644 --- a/codex-rs/app-server/src/request_processors/command_exec_processor.rs +++ b/codex-rs/app-server/src/request_processors/command_exec_processor.rs @@ -5,6 +5,8 @@ pub(crate) struct CommandExecRequestProcessor { arg0_paths: Arg0DispatchPaths, config: Arc, outgoing: Arc, + config_manager: ConfigManager, + environment_manager: Arc, command_exec_manager: CommandExecManager, } @@ -13,11 +15,15 @@ impl CommandExecRequestProcessor { arg0_paths: Arg0DispatchPaths, config: Arc, outgoing: Arc, + config_manager: ConfigManager, + environment_manager: Arc, ) -> Self { Self { arg0_paths, config, outgoing, + config_manager, + environment_manager, command_exec_manager: CommandExecManager::default(), } } @@ -27,6 +33,7 @@ impl CommandExecRequestProcessor { request_id: &ConnectionRequestId, params: CommandExecParams, ) -> Result, JSONRPCErrorError> { + self.require_local_environment()?; self.exec_one_off_command(request_id, params) .await .map(|()| None) @@ -71,6 +78,14 @@ impl CommandExecRequestProcessor { .await; } + fn require_local_environment(&self) -> Result<(), JSONRPCErrorError> { + self.environment_manager + .try_local_environment() + .is_some() + .then_some(()) + .ok_or_else(|| internal_error("local environment is not configured")) + } + async fn exec_one_off_command( &self, request_id: &ConnectionRequestId, @@ -159,28 +174,6 @@ impl CommandExecRequestProcessor { }, None => None, }; - let managed_network_requirements_enabled = - self.config.managed_network_requirements_enabled(); - let started_network_proxy = match self.config.permissions.network.as_ref() { - Some(spec) => match spec - .start_proxy( - self.config.permissions.permission_profile.get(), - /*policy_decider*/ None, - /*blocked_request_observer*/ None, - managed_network_requirements_enabled, - NetworkProxyAuditMetadata::default(), - ) - .await - { - Ok(started) => Some(started), - Err(err) => { - return Err(internal_error(format!( - "failed to start managed network proxy: {err}" - ))); - } - }, - None => None, - }; let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); let output_bytes_cap = if disable_output_cap { None @@ -205,6 +198,96 @@ impl CommandExecRequestProcessor { } else { self.config.cwd.clone() }; + let ( + effective_permission_profile, + network_proxy_spec, + network_proxy_permission_profile, + managed_network_requirements_enabled, + windows_sandbox_workspace_roots, + ) = if let Some(permission_profile) = permission_profile { + let overrides = ConfigOverrides { + cwd: Some(cwd.to_path_buf()), + default_permissions: Some(permission_profile), + ..Default::default() + }; + let config = self + .config_manager + .load_for_cwd( + /*request_overrides*/ None, + overrides, + Some(self.config.cwd.to_path_buf()), + ) + .await + .map_err(|err| invalid_request(format!("invalid permission profile: {err}")))?; + if let Some(warning) = config.startup_warnings.iter().find(|warning| { + warning.contains("Configured value for `permission_profile` is disallowed") + }) { + return Err(invalid_request(format!( + "invalid permission profile: {warning}" + ))); + } + ( + config.permissions.effective_permission_profile(), + config.permissions.network.clone(), + config.permissions.permission_profile().clone(), + config.managed_network_requirements_enabled(), + config.effective_workspace_roots(), + ) + } else if let Some(policy) = sandbox_policy.map(|policy| policy.to_core()) { + self.config + .permissions + .can_set_legacy_sandbox_policy(&policy, &sandbox_cwd) + .map_err(|err| invalid_request(format!("invalid sandbox policy: {err}")))?; + let file_system_sandbox_policy = + codex_protocol::permissions::FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&policy, &sandbox_cwd); + let network_sandbox_policy = + codex_protocol::permissions::NetworkSandboxPolicy::from(&policy); + let permission_profile = + codex_protocol::models::PermissionProfile::from_runtime_permissions_with_enforcement( + codex_protocol::models::SandboxEnforcement::from_legacy_sandbox_policy(&policy), + &file_system_sandbox_policy, + network_sandbox_policy, + ); + self.config + .permissions + .can_set_permission_profile(&permission_profile) + .map_err(|err| invalid_request(format!("invalid sandbox policy: {err}")))?; + ( + permission_profile, + self.config.permissions.network.clone(), + self.config.permissions.permission_profile().clone(), + self.config.managed_network_requirements_enabled(), + self.config.effective_workspace_roots(), + ) + } else { + ( + self.config.permissions.effective_permission_profile(), + self.config.permissions.network.clone(), + self.config.permissions.permission_profile().clone(), + self.config.managed_network_requirements_enabled(), + self.config.effective_workspace_roots(), + ) + }; + let started_network_proxy = match network_proxy_spec.as_ref() { + Some(spec) => match spec + .start_proxy( + &network_proxy_permission_profile, + /*policy_decider*/ None, + /*blocked_request_observer*/ None, + managed_network_requirements_enabled, + NetworkProxyAuditMetadata::default(), + ) + .await + { + Ok(started) => Some(started), + Err(err) => { + return Err(internal_error(format!( + "failed to start managed network proxy: {err}" + ))); + } + }, + None => None, + }; let exec_params = ExecParams { command, cwd: cwd.clone(), @@ -224,54 +307,6 @@ impl CommandExecRequestProcessor { arg0: None, }; - let effective_permission_profile = if let Some(permission_profile) = permission_profile { - let permission_profile = - codex_protocol::models::PermissionProfile::from(permission_profile); - let (mut file_system_sandbox_policy, network_sandbox_policy) = - permission_profile.to_runtime_permissions(); - let configured_file_system_sandbox_policy = - self.config.permissions.file_system_sandbox_policy(); - Self::preserve_configured_deny_read_restrictions( - &mut file_system_sandbox_policy, - &configured_file_system_sandbox_policy, - ); - let effective_permission_profile = - codex_protocol::models::PermissionProfile::from_runtime_permissions_with_enforcement( - permission_profile.enforcement(), - &file_system_sandbox_policy, - network_sandbox_policy, - ); - self.config - .permissions - .permission_profile - .can_set(&effective_permission_profile) - .map_err(|err| invalid_request(format!("invalid permission profile: {err}")))?; - effective_permission_profile - } else if let Some(policy) = sandbox_policy.map(|policy| policy.to_core()) { - self.config - .permissions - .can_set_legacy_sandbox_policy(&policy, &sandbox_cwd) - .map_err(|err| invalid_request(format!("invalid sandbox policy: {err}")))?; - let file_system_sandbox_policy = - codex_protocol::permissions::FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&policy, &sandbox_cwd); - let network_sandbox_policy = - codex_protocol::permissions::NetworkSandboxPolicy::from(&policy); - let permission_profile = - codex_protocol::models::PermissionProfile::from_runtime_permissions_with_enforcement( - codex_protocol::models::SandboxEnforcement::from_legacy_sandbox_policy(&policy), - &file_system_sandbox_policy, - network_sandbox_policy, - ); - self.config - .permissions - .permission_profile - .can_set(&permission_profile) - .map_err(|err| invalid_request(format!("invalid sandbox policy: {err}")))?; - permission_profile - } else { - self.config.permissions.permission_profile() - }; - let codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone(); let outgoing = self.outgoing.clone(); let request_for_task = request.clone(); @@ -287,6 +322,7 @@ impl CommandExecRequestProcessor { exec_params, &effective_permission_profile, &sandbox_cwd, + windows_sandbox_workspace_roots.as_slice(), &codex_linux_sandbox_exe, use_legacy_landlock, ) @@ -306,16 +342,4 @@ impl CommandExecRequestProcessor { }) .await } - - fn preserve_configured_deny_read_restrictions( - file_system_sandbox_policy: &mut FileSystemSandboxPolicy, - configured_file_system_sandbox_policy: &FileSystemSandboxPolicy, - ) { - file_system_sandbox_policy - .preserve_deny_read_restrictions_from(configured_file_system_sandbox_policy); - } } - -#[cfg(test)] -#[path = "command_exec_processor_tests.rs"] -mod command_exec_processor_tests; diff --git a/codex-rs/app-server/src/request_processors/command_exec_processor_tests.rs b/codex-rs/app-server/src/request_processors/command_exec_processor_tests.rs deleted file mode 100644 index 3e026a6a82..0000000000 --- a/codex-rs/app-server/src/request_processors/command_exec_processor_tests.rs +++ /dev/null @@ -1,38 +0,0 @@ -use super::*; -use codex_protocol::permissions::FileSystemAccessMode; -use codex_protocol::permissions::FileSystemPath; -use codex_protocol::permissions::FileSystemSandboxEntry; -use codex_protocol::permissions::FileSystemSandboxPolicy; -use codex_utils_absolute_path::test_support::PathBufExt; -use codex_utils_absolute_path::test_support::test_path_buf; -use pretty_assertions::assert_eq; - -#[test] -fn command_profile_preserves_configured_deny_read_restrictions() { - let readable_entry = FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: test_path_buf("/tmp/project").abs(), - }, - access: FileSystemAccessMode::Read, - }; - let deny_entry = FileSystemSandboxEntry { - path: FileSystemPath::GlobPattern { - pattern: "/tmp/project/**/*.env".to_string(), - }, - access: FileSystemAccessMode::None, - }; - let mut file_system_sandbox_policy = - FileSystemSandboxPolicy::restricted(vec![readable_entry.clone()]); - let mut configured_file_system_sandbox_policy = - FileSystemSandboxPolicy::restricted(vec![deny_entry.clone()]); - configured_file_system_sandbox_policy.glob_scan_max_depth = Some(2); - - CommandExecRequestProcessor::preserve_configured_deny_read_restrictions( - &mut file_system_sandbox_policy, - &configured_file_system_sandbox_policy, - ); - - let mut expected = FileSystemSandboxPolicy::restricted(vec![readable_entry, deny_entry]); - expected.glob_scan_max_depth = Some(2); - assert_eq!(file_system_sandbox_policy, expected); -} diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index b7f973b076..3571026193 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -9,6 +9,7 @@ use crate::outgoing_message::OutgoingMessageSender; use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::AppListUpdatedNotification; use codex_app_server_protocol::ClientResponsePayload; +use codex_app_server_protocol::ComputerUseRequirements; use codex_app_server_protocol::ConfigBatchWriteParams; use codex_app_server_protocol::ConfigReadParams; use codex_app_server_protocol::ConfigReadResponse; @@ -29,6 +30,7 @@ use codex_app_server_protocol::NetworkRequirements; use codex_app_server_protocol::NetworkUnixSocketPermission; use codex_app_server_protocol::SandboxMode; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::WindowsSandboxSetupMode; use codex_chatgpt::connectors; use codex_config::ConfigRequirementsToml; use codex_config::HookEventsToml; @@ -53,7 +55,7 @@ const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[ "mentions_v2", "plugins", "remote_control", - "tool_search", + "remote_plugin", "tool_suggest", "tool_call_mcp_elicitation", ]; @@ -220,7 +222,7 @@ impl ConfigRequestProcessor { connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( &config, /*force_refetch*/ true, - &environment_manager, + Arc::clone(&environment_manager), ), ); let all_connectors = match all_connectors_result { @@ -419,6 +421,24 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR .filter_map(map_sandbox_mode_requirement_to_api) .collect() }), + allowed_windows_sandbox_implementations: requirements.windows.and_then(|windows| { + windows + .allowed_sandbox_implementations + .map(|implementations| { + implementations + .into_iter() + .map(|implementation| match implementation { + codex_config::types::WindowsSandboxModeToml::Elevated => { + WindowsSandboxSetupMode::Elevated + } + codex_config::types::WindowsSandboxModeToml::Unelevated => { + WindowsSandboxSetupMode::Unelevated + } + }) + .collect() + }) + }), + allowed_permissions: requirements.allowed_permissions, allowed_web_search_modes: requirements.allowed_web_search_modes.map(|modes| { let mut normalized = modes .into_iter() @@ -430,6 +450,10 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR normalized }), allow_managed_hooks_only: requirements.allow_managed_hooks_only, + allow_appshots: requirements.allow_appshots, + computer_use: requirements + .computer_use + .map(map_computer_use_requirements_to_api), feature_requirements: requirements .feature_requirements .map(|requirements| requirements.entries), @@ -441,6 +465,14 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR } } +fn map_computer_use_requirements_to_api( + computer_use: codex_config::ComputerUseRequirementsToml, +) -> ComputerUseRequirements { + ComputerUseRequirements { + allow_locked_computer_use: computer_use.allow_locked_computer_use, + } +} + fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> ManagedHooksRequirements { let ManagedHooksRequirementsToml { managed_dir, @@ -455,6 +487,8 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed post_compact, session_start, user_prompt_submit, + subagent_start, + subagent_stop, stop, } = hooks; @@ -468,6 +502,8 @@ fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> Managed post_compact: map_hook_matcher_groups_to_api(post_compact), session_start: map_hook_matcher_groups_to_api(session_start), user_prompt_submit: map_hook_matcher_groups_to_api(user_prompt_submit), + subagent_start: map_hook_matcher_groups_to_api(subagent_start), + subagent_stop: map_hook_matcher_groups_to_api(subagent_stop), stop: map_hook_matcher_groups_to_api(stop), } } @@ -593,7 +629,7 @@ fn map_network_unix_socket_permission_to_api( ) -> NetworkUnixSocketPermission { match permission { codex_config::NetworkUnixSocketPermissionToml::Allow => NetworkUnixSocketPermission::Allow, - codex_config::NetworkUnixSocketPermissionToml::None => NetworkUnixSocketPermission::None, + codex_config::NetworkUnixSocketPermissionToml::Deny => NetworkUnixSocketPermission::Deny, } } @@ -616,17 +652,80 @@ fn config_write_error(code: ConfigWriteErrorCode, message: impl Into) -> #[cfg(test)] mod tests { use super::map_requirements_toml_to_api; + use codex_app_server_protocol::WindowsSandboxSetupMode; + use codex_config::ComputerUseRequirementsToml; use codex_config::ConfigRequirementsToml; + use codex_config::WindowsRequirementsToml; use pretty_assertions::assert_eq; #[test] fn requirements_api_includes_allow_managed_hooks_only() { let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { + allowed_permissions: Some(vec![ + "managed-standard".to_string(), + "managed-build".to_string(), + ]), allow_managed_hooks_only: Some(true), ..ConfigRequirementsToml::default() }); + assert_eq!( + mapped.allowed_permissions, + Some(vec![ + "managed-standard".to_string(), + "managed-build".to_string(), + ]) + ); assert_eq!(mapped.allow_managed_hooks_only, Some(true)); assert_eq!(mapped.hooks, None); } + + #[test] + fn requirements_api_includes_allow_appshots() { + let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { + allow_appshots: Some(false), + ..ConfigRequirementsToml::default() + }); + + assert_eq!(mapped.allow_appshots, Some(false)); + assert_eq!(mapped.hooks, None); + } + + #[test] + fn requirements_api_includes_computer_use_requirements() { + let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { + computer_use: Some(ComputerUseRequirementsToml { + allow_locked_computer_use: Some(false), + }), + ..ConfigRequirementsToml::default() + }); + + assert_eq!( + mapped + .computer_use + .and_then(|requirements| requirements.allow_locked_computer_use), + Some(false) + ); + } + + #[test] + fn requirements_api_includes_allowed_windows_sandbox_implementations() { + let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { + windows: Some(WindowsRequirementsToml { + allowed_sandbox_implementations: Some(vec![ + codex_config::types::WindowsSandboxModeToml::Elevated, + codex_config::types::WindowsSandboxModeToml::Unelevated, + ]), + }), + ..ConfigRequirementsToml::default() + }); + + assert_eq!( + mapped.allowed_windows_sandbox_implementations, + Some(vec![ + WindowsSandboxSetupMode::Elevated, + WindowsSandboxSetupMode::Unelevated, + ]) + ); + } } diff --git a/codex-rs/app-server/src/request_processors/feedback_processor.rs b/codex-rs/app-server/src/request_processors/feedback_processor.rs index 18f519d3c0..73da67f735 100644 --- a/codex-rs/app-server/src/request_processors/feedback_processor.rs +++ b/codex-rs/app-server/src/request_processors/feedback_processor.rs @@ -1,4 +1,6 @@ use super::*; +#[cfg(target_os = "windows")] +use codex_feedback::WINDOWS_SANDBOX_LOG_ATTACHMENT_FILENAME; #[derive(Clone)] pub(crate) struct FeedbackRequestProcessor { @@ -186,6 +188,12 @@ impl FeedbackRequestProcessor { )), }); } + if let Some(sandbox_log_attachment) = + windows_sandbox_log_attachment(&self.config.codex_home) + && seen_attachment_paths.insert(sandbox_log_attachment.path.clone()) + { + attachment_paths.push(sandbox_log_attachment); + } } if let Some(extra_log_files) = extra_log_files { for extra_log_file in extra_log_files { @@ -264,3 +272,46 @@ impl FeedbackRequestProcessor { fn auto_review_rollout_filename(thread_id: ThreadId) -> String { format!("auto-review-rollout-{thread_id}.jsonl") } + +#[cfg(target_os = "windows")] +fn windows_sandbox_log_attachment(codex_home: &Path) -> Option { + let sandbox_log_path = codex_windows_sandbox::current_log_file_path_for_codex_home(codex_home); + sandbox_log_path + .is_file() + .then_some(FeedbackAttachmentPath { + path: sandbox_log_path, + attachment_filename_override: Some(WINDOWS_SANDBOX_LOG_ATTACHMENT_FILENAME.to_string()), + }) +} + +#[cfg(not(target_os = "windows"))] +fn windows_sandbox_log_attachment(_codex_home: &Path) -> Option { + None +} + +#[cfg(all(test, target_os = "windows"))] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn windows_sandbox_log_attachment_uses_current_log() { + let codex_home = tempfile::tempdir().expect("create tempdir"); + let sandbox_dir = codex_windows_sandbox::sandbox_dir(codex_home.path()); + std::fs::create_dir_all(&sandbox_dir).expect("create sandbox dir"); + let sandbox_log_path = + codex_windows_sandbox::current_log_file_path_for_codex_home(codex_home.path()); + std::fs::write(&sandbox_log_path, "sandbox log").expect("write sandbox log"); + + let attachment = windows_sandbox_log_attachment(codex_home.path()) + .map(|attachment| (attachment.path, attachment.attachment_filename_override)); + + assert_eq!( + attachment, + Some(( + sandbox_log_path, + Some(WINDOWS_SANDBOX_LOG_ATTACHMENT_FILENAME.to_string()) + )) + ); + } +} diff --git a/codex-rs/app-server/src/request_processors/fs_processor.rs b/codex-rs/app-server/src/request_processors/fs_processor.rs index 01b9b20bfd..99a8620b4f 100644 --- a/codex-rs/app-server/src/request_processors/fs_processor.rs +++ b/codex-rs/app-server/src/request_processors/fs_processor.rs @@ -26,6 +26,7 @@ use codex_app_server_protocol::FsWriteFileResponse; use codex_app_server_protocol::JSONRPCErrorError; use codex_exec_server::CopyOptions; use codex_exec_server::CreateDirectoryOptions; +use codex_exec_server::EnvironmentManager; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::RemoveOptions; use std::io; @@ -33,21 +34,28 @@ use std::sync::Arc; #[derive(Clone)] pub(crate) struct FsRequestProcessor { - file_system: Arc, + environment_manager: Arc, fs_watch_manager: FsWatchManager, } impl FsRequestProcessor { pub(crate) fn new( - file_system: Arc, + environment_manager: Arc, fs_watch_manager: FsWatchManager, ) -> Self { Self { - file_system, + environment_manager, fs_watch_manager, } } + fn file_system(&self) -> Result, JSONRPCErrorError> { + self.environment_manager + .try_local_environment() + .map(|environment| environment.get_filesystem()) + .ok_or_else(|| internal_error("local filesystem is not configured")) + } + pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) { self.fs_watch_manager.connection_closed(connection_id).await; } @@ -57,7 +65,7 @@ impl FsRequestProcessor { params: FsReadFileParams, ) -> Result { let bytes = self - .file_system + .file_system()? .read_file(¶ms.path, /*sandbox*/ None) .await .map_err(map_fs_error)?; @@ -75,7 +83,7 @@ impl FsRequestProcessor { "fs/writeFile requires valid base64 dataBase64: {err}" )) })?; - self.file_system + self.file_system()? .write_file(¶ms.path, bytes, /*sandbox*/ None) .await .map_err(map_fs_error)?; @@ -86,7 +94,7 @@ impl FsRequestProcessor { &self, params: FsCreateDirectoryParams, ) -> Result { - self.file_system + self.file_system()? .create_directory( ¶ms.path, CreateDirectoryOptions { @@ -104,7 +112,7 @@ impl FsRequestProcessor { params: FsGetMetadataParams, ) -> Result { let metadata = self - .file_system + .file_system()? .get_metadata(¶ms.path, /*sandbox*/ None) .await .map_err(map_fs_error)?; @@ -122,7 +130,7 @@ impl FsRequestProcessor { params: FsReadDirectoryParams, ) -> Result { let entries = self - .file_system + .file_system()? .read_directory(¶ms.path, /*sandbox*/ None) .await .map_err(map_fs_error)?; @@ -142,7 +150,7 @@ impl FsRequestProcessor { &self, params: FsRemoveParams, ) -> Result { - self.file_system + self.file_system()? .remove( ¶ms.path, RemoveOptions { @@ -160,7 +168,7 @@ impl FsRequestProcessor { &self, params: FsCopyParams, ) -> Result { - self.file_system + self.file_system()? .copy( ¶ms.source_path, ¶ms.destination_path, @@ -179,6 +187,7 @@ impl FsRequestProcessor { connection_id: ConnectionId, params: FsWatchParams, ) -> Result { + self.file_system()?; self.fs_watch_manager.watch(connection_id, params).await } @@ -187,6 +196,7 @@ impl FsRequestProcessor { connection_id: ConnectionId, params: FsUnwatchParams, ) -> Result { + self.file_system()?; self.fs_watch_manager.unwatch(connection_id, params).await } } diff --git a/codex-rs/app-server/src/request_processors/mcp_processor.rs b/codex-rs/app-server/src/request_processors/mcp_processor.rs index 548bb5be87..ae62e2e785 100644 --- a/codex-rs/app-server/src/request_processors/mcp_processor.rs +++ b/codex-rs/app-server/src/request_processors/mcp_processor.rs @@ -199,33 +199,36 @@ impl McpRequestProcessor { let request = request_id.clone(); let outgoing = Arc::clone(&self.outgoing); - let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let config = match params.thread_id.as_deref() { + Some(thread_id) => { + let (_, thread) = self.load_thread(thread_id).await?; + let thread_config = thread.config().await; + self.config_manager + .load_latest_config_for_thread(thread_config.as_ref()) + .await + .map_err(|err| internal_error(format!("failed to reload config: {err}")))? + } + None => self.load_latest_config(/*fallback_cwd*/ None).await?, + }; let mcp_config = config .to_mcp_config(self.thread_manager.plugins_manager().as_ref()) .await; let auth = self.auth_manager.auth().await; let environment_manager = self.thread_manager.environment_manager(); - let runtime_environment = match environment_manager.default_environment() { - Some(environment) => { - // Status listing has no turn cwd. This fallback is used only - // by executor-backed stdio MCPs whose config omits `cwd`. - McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()) - } - None => McpRuntimeEnvironment::new( - environment_manager.local_environment(), - config.cwd.to_path_buf(), - ), - }; + // This status path has no turn-selected environment. Use config cwd + // as the local stdio fallback; named environment stdio MCPs must + // declare their own absolute cwd. + let runtime_context = + McpRuntimeContext::new(Arc::clone(&environment_manager), config.cwd.to_path_buf()); tokio::spawn(async move { Self::list_mcp_server_status_task( outgoing, request, params, - config, mcp_config, auth, - runtime_environment, + runtime_context, ) .await; }); @@ -236,18 +239,16 @@ impl McpRequestProcessor { outgoing: Arc, request_id: ConnectionRequestId, params: ListMcpServerStatusParams, - config: Config, mcp_config: codex_mcp::McpConfig, auth: Option, - runtime_environment: McpRuntimeEnvironment, + runtime_context: McpRuntimeContext, ) { let result = Self::list_mcp_server_status_response( request_id.request_id.to_string(), params, - config, mcp_config, auth, - runtime_environment, + runtime_context, ) .await; outgoing.send_result(request_id, result).await; @@ -256,10 +257,9 @@ impl McpRequestProcessor { async fn list_mcp_server_status_response( request_id: String, params: ListMcpServerStatusParams, - config: Config, mcp_config: codex_mcp::McpConfig, auth: Option, - runtime_environment: McpRuntimeEnvironment, + runtime_context: McpRuntimeContext, ) -> Result { let detail = match params.detail.unwrap_or(McpServerStatusDetail::Full) { McpServerStatusDetail::Full => McpSnapshotDetail::Full, @@ -270,31 +270,26 @@ impl McpRequestProcessor { &mcp_config, auth.as_ref(), request_id, - runtime_environment, + runtime_context, detail, ) .await; - let effective_servers = effective_mcp_servers(&mcp_config, auth.as_ref()); let McpServerStatusSnapshot { + server_infos, tools_by_server, resources, resource_templates, auth_statuses, + mut server_names, } = snapshot; - - let mut server_names: Vec = config - .mcp_servers - .keys() - .cloned() - // Include runtime-added/plugin MCP servers that are present in the - // effective runtime config even when they are not user-declared in - // `config.mcp_servers`. - .chain(effective_servers.keys().cloned()) - .chain(auth_statuses.keys().cloned()) - .chain(resources.keys().cloned()) - .chain(resource_templates.keys().cloned()) - .collect(); + server_names.extend( + auth_statuses + .keys() + .cloned() + .chain(resources.keys().cloned()) + .chain(resource_templates.keys().cloned()), + ); server_names.sort(); server_names.dedup(); @@ -321,6 +316,7 @@ impl McpRequestProcessor { .iter() .map(|name| McpServerStatus { name: name.clone(), + server_info: server_infos.get(name).cloned(), tools: tools_by_server.get(name).cloned().unwrap_or_default(), resources: resources.get(name).cloned().unwrap_or_default(), resource_templates: resource_templates.get(name).cloned().unwrap_or_default(), @@ -369,22 +365,19 @@ impl McpRequestProcessor { .to_mcp_config(self.thread_manager.plugins_manager().as_ref()) .await; let auth = self.auth_manager.auth().await; - let runtime_environment = { - let environment_manager = self.thread_manager.environment_manager(); - let environment = environment_manager - .default_environment() - .unwrap_or_else(|| environment_manager.local_environment()); - // Resource reads without a thread have no turn cwd. This fallback - // is used only by executor-backed stdio MCPs whose config omits `cwd`. - McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()) - }; + let environment_manager = self.thread_manager.environment_manager(); + // This threadless resource-read path has no turn cwd or turn-selected + // environment. Use config cwd only as the local stdio fallback; named + // environment stdio MCPs must declare their own absolute cwd. + let runtime_context = + McpRuntimeContext::new(Arc::clone(&environment_manager), config.cwd.to_path_buf()); let request_id = request_id.clone(); tokio::spawn(async move { let result = read_mcp_resource_without_thread( &mcp_config, auth.as_ref(), - runtime_environment, + runtime_context, &server, &uri, ) diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index c290d2091a..4171887cdd 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -6,6 +6,9 @@ use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginSharePrincipalRole; use codex_app_server_protocol::PluginShareTargetRole; use codex_config::types::McpServerConfig; +use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; +use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; +use codex_core_plugins::remote::RemotePluginScope; use codex_core_plugins::remote::is_valid_remote_plugin_id; use codex_core_plugins::remote::validate_remote_plugin_id; use codex_mcp::McpOAuthLoginSupport; @@ -122,6 +125,85 @@ fn share_context_for_source( } } +fn convert_configured_marketplace_plugin_to_plugin_summary( + plugin: codex_core_plugins::ConfiguredMarketplacePlugin, + shared_plugin_ids_by_local_path: &std::collections::BTreeMap, +) -> PluginSummary { + let share_context = share_context_for_source(&plugin.source, shared_plugin_ids_by_local_path); + PluginSummary { + id: plugin.id, + remote_plugin_id: None, + local_version: plugin.local_version, + installed: plugin.installed, + enabled: plugin.enabled, + name: plugin.name, + share_context, + source: marketplace_plugin_source_to_info(plugin.source), + install_policy: plugin.policy.installation.into(), + auth_policy: plugin.policy.authentication.into(), + availability: PluginAvailability::Available, + interface: plugin.interface.map(local_plugin_interface_to_info), + keywords: plugin.keywords, + } +} + +fn remote_installed_plugin_visible_scopes(config: &Config) -> Vec { + let mut scopes = Vec::new(); + if config.features.enabled(Feature::RemotePlugin) { + scopes.push(RemotePluginScope::Global); + } + if config.features.enabled(Feature::PluginSharing) { + scopes.push(RemotePluginScope::Workspace); + } + scopes +} + +fn filter_openai_curated_installed_conflicts( + marketplaces: &mut Vec, + prefer_remote_curated_conflicts: bool, +) { + let local_installed_plugin_names = marketplaces + .iter() + .find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) + .map(|marketplace| installed_plugin_names(&marketplace.plugins)) + .unwrap_or_default(); + let remote_installed_plugin_names = marketplaces + .iter() + .find(|marketplace| marketplace.name == REMOTE_GLOBAL_MARKETPLACE_NAME) + .map(|marketplace| installed_plugin_names(&marketplace.plugins)) + .unwrap_or_default(); + let conflicting_plugin_names = local_installed_plugin_names + .intersection(&remote_installed_plugin_names) + .cloned() + .collect::>(); + if conflicting_plugin_names.is_empty() { + return; + } + + let marketplace_to_filter = if prefer_remote_curated_conflicts { + OPENAI_CURATED_MARKETPLACE_NAME + } else { + REMOTE_GLOBAL_MARKETPLACE_NAME + }; + for marketplace in marketplaces.iter_mut() { + if marketplace.name != marketplace_to_filter { + continue; + } + marketplace + .plugins + .retain(|plugin| !plugin.installed || !conflicting_plugin_names.contains(&plugin.name)); + } + marketplaces.retain(|marketplace| !marketplace.plugins.is_empty()); +} + +fn installed_plugin_names(plugins: &[PluginSummary]) -> HashSet { + plugins + .iter() + .filter(|plugin| plugin.installed) + .map(|plugin| plugin.name.clone()) + .collect() +} + fn remote_plugin_share_discoverability( discoverability: PluginShareDiscoverability, ) -> codex_core_plugins::remote::RemotePluginShareDiscoverability { @@ -268,6 +350,15 @@ impl PluginRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) async fn plugin_installed( + &self, + params: PluginInstalledParams, + ) -> Result, JSONRPCErrorError> { + self.plugin_installed_response(params) + .await + .map(|response| Some(response.into())) + } + pub(crate) async fn plugin_read( &self, params: PluginReadParams, @@ -432,6 +523,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 { @@ -487,27 +579,10 @@ impl PluginRequestProcessor { .plugins .into_iter() .map(|plugin| { - let share_context = share_context_for_source( - &plugin.source, + convert_configured_marketplace_plugin_to_plugin_summary( + plugin, &shared_plugin_ids_by_local_path, - ); - PluginSummary { - id: plugin.id, - remote_plugin_id: None, - local_version: plugin.local_version, - installed: plugin.installed, - enabled: plugin.enabled, - name: plugin.name, - share_context, - source: marketplace_plugin_source_to_info(plugin.source), - install_policy: plugin.policy.installation.into(), - auth_policy: plugin.policy.authentication.into(), - availability: PluginAvailability::Available, - interface: plugin - .interface - .map(local_plugin_interface_to_info), - keywords: plugin.keywords, - } + ) }) .collect(), }) @@ -538,6 +613,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); @@ -566,14 +670,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( @@ -632,6 +729,197 @@ impl PluginRequestProcessor { }) } + async fn plugin_installed_response( + &self, + params: PluginInstalledParams, + ) -> Result { + let plugins_manager = self.thread_manager.plugins_manager(); + let PluginInstalledParams { + cwds, + install_suggestion_plugin_names, + } = params; + let roots = cwds.unwrap_or_default(); + let install_suggestion_plugin_names = install_suggestion_plugin_names + .unwrap_or_default() + .into_iter() + .collect::>(); + + let empty_response = || PluginInstalledResponse { + marketplaces: Vec::new(), + marketplace_load_errors: Vec::new(), + }; + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + if !config.features.enabled(Feature::Plugins) { + return Ok(empty_response()); + } + let auth = self.auth_manager.auth().await; + if !self + .workspace_codex_plugins_enabled(&config, auth.as_ref()) + .await + { + return Ok(empty_response()); + } + + let plugins_input = config.plugins_config_input(); + let remote_installed_plugin_visible_scopes = + remote_installed_plugin_visible_scopes(&config); + plugins_manager.maybe_start_remote_installed_plugin_bundle_sync( + &plugins_input, + auth.clone(), + Some(self.effective_plugins_changed_callback()), + ); + + let (mut data, marketplace_load_errors) = self + .load_local_installed_and_suggested_plugins( + plugins_manager.clone(), + &config, + &plugins_input, + roots, + install_suggestion_plugin_names, + ) + .await?; + + data.extend( + self.load_remote_installed_plugins( + plugins_manager, + &plugins_input, + &remote_installed_plugin_visible_scopes, + auth.as_ref(), + ) + .await, + ); + filter_openai_curated_installed_conflicts( + &mut data, + config.features.enabled(Feature::RemotePlugin), + ); + + Ok(PluginInstalledResponse { + marketplaces: data, + marketplace_load_errors, + }) + } + + async fn load_local_installed_and_suggested_plugins( + &self, + plugins_manager: Arc, + config: &Config, + plugins_input: &codex_core_plugins::PluginsConfigInput, + roots: Vec, + install_suggestion_plugin_names: HashSet, + ) -> Result< + ( + Vec, + Vec, + ), + JSONRPCErrorError, + > { + let config_for_marketplace_listing = plugins_input.clone(); + let shared_plugin_ids_by_local_path = load_shared_plugin_ids_by_local_path(config)?; + match tokio::task::spawn_blocking(move || { + let outcome = plugins_manager + .list_marketplaces_for_config(&config_for_marketplace_listing, &roots)?; + Ok::< + ( + Vec, + Vec, + ), + MarketplaceError, + >(( + outcome + .marketplaces + .into_iter() + .filter_map(|marketplace| { + let plugins = marketplace + .plugins + .into_iter() + .filter(|plugin| { + plugin.installed + || install_suggestion_plugin_names.contains(&plugin.name) + }) + .map(|plugin| { + convert_configured_marketplace_plugin_to_plugin_summary( + plugin, + &shared_plugin_ids_by_local_path, + ) + }) + .collect::>(); + + (!plugins.is_empty()).then_some(PluginMarketplaceEntry { + name: marketplace.name, + path: Some(marketplace.path), + interface: marketplace.interface.map(|interface| { + MarketplaceInterface { + display_name: interface.display_name, + } + }), + plugins, + }) + }) + .collect(), + outcome + .errors + .into_iter() + .map(|err| codex_app_server_protocol::MarketplaceLoadErrorInfo { + marketplace_path: err.path, + message: err.message, + }) + .collect(), + )) + }) + .await + { + Ok(Ok(outcome)) => Ok(outcome), + Ok(Err(err)) => Err(Self::marketplace_error( + err, + "list installed and suggested marketplace plugins", + )), + Err(err) => Err(internal_error(format!( + "failed to list installed and suggested plugins: {err}" + ))), + } + } + + async fn load_remote_installed_plugins( + &self, + plugins_manager: Arc, + plugins_input: &codex_core_plugins::PluginsConfigInput, + visible_scopes: &[RemotePluginScope], + auth: Option<&CodexAuth>, + ) -> Vec { + let remote_marketplaces = if let Some(remote_marketplaces) = + plugins_manager.build_remote_installed_plugin_marketplaces_from_cache(visible_scopes) + { + Ok(remote_marketplaces) + } else { + plugins_manager + .build_and_cache_remote_installed_plugin_marketplaces( + plugins_input, + auth, + visible_scopes, + Some(self.effective_plugins_changed_callback()), + ) + .await + }; + + match remote_marketplaces { + Ok(remote_marketplaces) => remote_marketplaces + .into_iter() + .map(remote_marketplace_to_info) + .collect(), + Err( + RemotePluginCatalogError::AuthRequired + | RemotePluginCatalogError::UnsupportedAuthMode, + ) => Vec::new(), + Err(err) => { + warn!( + error = %err, + "plugin/installed remote installed plugin fetch failed; returning local marketplaces only" + ); + Vec::new() + } + } + } + async fn plugin_read_response( &self, params: PluginReadParams, @@ -723,9 +1011,12 @@ impl PluginRequestProcessor { None => None, }; let environment_manager = self.thread_manager.environment_manager(); - let app_summaries = - load_plugin_app_summaries(&config, &outcome.plugin.apps, &environment_manager) - .await; + let app_summaries = load_plugin_app_summaries( + &config, + &outcome.plugin.apps, + Arc::clone(&environment_manager), + ) + .await; let visible_skills = outcome .plugin .skills @@ -801,8 +1092,12 @@ impl PluginRequestProcessor { .map(codex_plugin::AppConnectorId) .collect::>(); let environment_manager = self.thread_manager.environment_manager(); - let app_summaries = - load_plugin_app_summaries(&config, &plugin_apps, &environment_manager).await; + let app_summaries = load_plugin_app_summaries( + &config, + &plugin_apps, + Arc::clone(&environment_manager), + ) + .await; remote_plugin_detail_to_info(remote_detail, app_summaries) } }; @@ -1194,6 +1489,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)?; @@ -1265,11 +1561,11 @@ impl PluginRequestProcessor { let environment_manager = self.thread_manager.environment_manager(); let (all_connectors_result, accessible_connectors_result) = tokio::join!( - connectors::list_all_connectors_with_options(config, /*force_refetch*/ true), + connectors::list_all_connectors_with_options(config, /*force_refetch*/ false), connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( config, /*force_refetch*/ true, - &environment_manager + Arc::clone(&environment_manager) ), ); @@ -1535,7 +1831,7 @@ impl PluginRequestProcessor { async fn load_plugin_app_summaries( config: &Config, plugin_apps: &[codex_plugin::AppConnectorId], - environment_manager: &EnvironmentManager, + environment_manager: Arc, ) -> Vec { if plugin_apps.is_empty() { return Vec::new(); diff --git a/codex-rs/app-server/src/request_processors/process_exec_processor.rs b/codex-rs/app-server/src/request_processors/process_exec_processor.rs index 5742d0e4d5..0b84c7f7b9 100644 --- a/codex-rs/app-server/src/request_processors/process_exec_processor.rs +++ b/codex-rs/app-server/src/request_processors/process_exec_processor.rs @@ -23,6 +23,7 @@ use codex_app_server_protocol::ServerNotification; use codex_core::exec::ExecExpiration; use codex_core::exec::ExecExpirationOutcome; use codex_core::exec::IO_DRAIN_TIMEOUT_MS; +use codex_exec_server::EnvironmentManager; use codex_protocol::exec_output::bytes_to_string_smart; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; @@ -48,13 +49,18 @@ const OUTPUT_CHUNK_SIZE_HINT: usize = 64 * 1024; #[derive(Clone)] pub(crate) struct ProcessExecRequestProcessor { outgoing: Arc, + environment_manager: Arc, process_exec_manager: ProcessExecManager, } impl ProcessExecRequestProcessor { - pub(crate) fn new(outgoing: Arc) -> Self { + pub(crate) fn new( + outgoing: Arc, + environment_manager: Arc, + ) -> Self { Self { outgoing, + environment_manager, process_exec_manager: ProcessExecManager::default(), } } @@ -64,6 +70,7 @@ impl ProcessExecRequestProcessor { request_id: ConnectionRequestId, params: ProcessSpawnParams, ) -> Result<(), JSONRPCErrorError> { + self.require_local_environment()?; let ProcessSpawnParams { command, process_handle, @@ -173,6 +180,14 @@ impl ProcessExecRequestProcessor { .connection_closed(connection_id) .await; } + + fn require_local_environment(&self) -> Result<(), JSONRPCErrorError> { + self.environment_manager + .try_local_environment() + .is_some() + .then_some(()) + .ok_or_else(|| internal_error("local environment is not configured")) + } } #[derive(Clone, Default)] diff --git a/codex-rs/app-server/src/request_processors/remote_control_processor.rs b/codex-rs/app-server/src/request_processors/remote_control_processor.rs index 41a124e94b..2fceeb5030 100644 --- a/codex-rs/app-server/src/request_processors/remote_control_processor.rs +++ b/codex-rs/app-server/src/request_processors/remote_control_processor.rs @@ -5,6 +5,7 @@ use crate::transport::RemoteControlUnavailable; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RemoteControlDisableResponse; use codex_app_server_protocol::RemoteControlEnableResponse; +use codex_app_server_protocol::RemoteControlStatusReadResponse; #[derive(Clone)] pub(crate) struct RemoteControlRequestProcessor { @@ -31,6 +32,16 @@ impl RemoteControlRequestProcessor { Ok(RemoteControlDisableResponse::from(handle.disable())) } + pub(crate) fn status_read(&self) -> Result { + let status = self.handle()?.status(); + Ok(RemoteControlStatusReadResponse { + status: status.status, + server_name: status.server_name, + installation_id: status.installation_id, + environment_id: status.environment_id, + }) + } + fn handle(&self) -> Result<&RemoteControlHandle, JSONRPCErrorError> { self.remote_control_handle .as_ref() diff --git a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs index 57d2575590..c18806ea03 100644 --- a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs @@ -148,17 +148,20 @@ impl ThreadGoalRequestProcessor { thread.prepare_external_goal_mutation().await; } + let should_set_thread_preview = objective.is_some(); let (goal, previous_status) = (if let Some(objective) = objective { let existing_goal = state_db + .thread_goals() .get_thread_goal(thread_id) .await .map_err(|err| invalid_request(err.to_string()))?; if let Some(goal) = existing_goal.as_ref() { let previous_status = ExternalGoalPreviousStatus::from(goal); state_db + .thread_goals() .update_thread_goal( thread_id, - codex_state::ThreadGoalUpdate { + codex_state::GoalUpdate { objective: Some(objective.to_string()), status, token_budget: params.token_budget, @@ -177,6 +180,7 @@ impl ThreadGoalRequestProcessor { } else { let previous_status = ExternalGoalPreviousStatus::NewGoal; state_db + .thread_goals() .replace_thread_goal( thread_id, objective, @@ -188,6 +192,7 @@ impl ThreadGoalRequestProcessor { } } else { let existing_goal = state_db + .thread_goals() .get_thread_goal(thread_id) .await .map_err(|err| invalid_request(err.to_string()))?; @@ -198,9 +203,10 @@ impl ThreadGoalRequestProcessor { }; let previous_status = ExternalGoalPreviousStatus::from(&existing_goal); state_db + .thread_goals() .update_thread_goal( thread_id, - codex_state::ThreadGoalUpdate { + codex_state::GoalUpdate { objective: None, status, token_budget: params.token_budget, @@ -216,6 +222,13 @@ impl ThreadGoalRequestProcessor { .map(|goal| (goal, previous_status)) }) .map_err(|err| invalid_request(err.to_string()))?; + if should_set_thread_preview + && let Err(err) = state_db + .set_thread_preview_if_empty(thread_id, goal.objective.as_str()) + .await + { + warn!("failed to set empty thread preview from goal objective for {thread_id}: {err}"); + } let external_goal_set = ExternalGoalSet { goal: goal.clone(), previous_status, @@ -246,6 +259,7 @@ impl ThreadGoalRequestProcessor { let thread_id = parse_thread_id_for_request(params.thread_id.as_str())?; let state_db = self.state_db_for_materialized_thread(thread_id).await?; let goal = state_db + .thread_goals() .get_thread_goal(thread_id) .await .map_err(|err| internal_error(format!("failed to read thread goal: {err}")))? @@ -303,6 +317,7 @@ impl ThreadGoalRequestProcessor { thread_state.listener_command_tx() }; let cleared = state_db + .thread_goals() .delete_thread_goal(thread_id) .await .map_err(|err| internal_error(format!("failed to clear thread goal: {err}")))?; @@ -447,6 +462,8 @@ fn thread_goal_status_to_state(status: ThreadGoalStatus) -> codex_state::ThreadG match status { ThreadGoalStatus::Active => codex_state::ThreadGoalStatus::Active, ThreadGoalStatus::Paused => codex_state::ThreadGoalStatus::Paused, + ThreadGoalStatus::Blocked => codex_state::ThreadGoalStatus::Blocked, + ThreadGoalStatus::UsageLimited => codex_state::ThreadGoalStatus::UsageLimited, ThreadGoalStatus::BudgetLimited => codex_state::ThreadGoalStatus::BudgetLimited, ThreadGoalStatus::Complete => codex_state::ThreadGoalStatus::Complete, } @@ -456,6 +473,8 @@ fn thread_goal_status_from_state(status: codex_state::ThreadGoalStatus) -> Threa match status { codex_state::ThreadGoalStatus::Active => ThreadGoalStatus::Active, codex_state::ThreadGoalStatus::Paused => ThreadGoalStatus::Paused, + codex_state::ThreadGoalStatus::Blocked => ThreadGoalStatus::Blocked, + codex_state::ThreadGoalStatus::UsageLimited => ThreadGoalStatus::UsageLimited, codex_state::ThreadGoalStatus::BudgetLimited => ThreadGoalStatus::BudgetLimited, codex_state::ThreadGoalStatus::Complete => ThreadGoalStatus::Complete, } diff --git a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs index 7dab206d85..f72cfb5a62 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -237,12 +237,19 @@ pub(super) async fn ensure_listener_task_running( &environments, ) .await; + let thread_settings_baseline = + thread_settings_from_config_snapshot(&conversation.config_snapshot().await); let (mut listener_command_rx, listener_generation) = { let mut thread_state = thread_state.lock().await; if thread_state.listener_matches(&conversation) { return Ok(()); } - thread_state.set_listener(cancel_tx, &conversation, watch_registration) + thread_state.set_listener( + cancel_tx, + &conversation, + watch_registration, + thread_settings_baseline, + ) }; let ListenerTaskContext { outgoing, @@ -558,8 +565,28 @@ pub(super) async fn handle_pending_thread_resume_request( has_live_in_progress_turn, ); let token_usage_thread = pending.include_turns.then(|| thread.clone()); + let mut initial_turns_page = if let Some(params) = pending.initial_turns_page.as_ref() { + match super::thread_processor::build_thread_resume_initial_turns_page( + &pending.history_items, + thread.status.clone(), + has_live_in_progress_turn, + active_turn, + params, + ) { + Ok(page) => Some(page), + Err(error) => { + outgoing.send_error(request_id, error).await; + return; + } + } + } else { + None + }; if pending.redact_resume_payloads { - redact_thread_resume_payloads(&mut thread); + redact_thread_resume_payloads(&mut thread.turns); + if let Some(initial_turns_page) = initial_turns_page.as_mut() { + redact_thread_resume_payloads(&mut initial_turns_page.data); + } } { @@ -604,6 +631,7 @@ pub(super) async fn handle_pending_thread_resume_request( permission_profile, active_permission_profile, cwd, + workspace_roots, reasoning_effort, .. } = pending.config_snapshot; @@ -620,13 +648,14 @@ pub(super) async fn handle_pending_thread_resume_request( model_provider: model_provider_id, service_tier, cwd, + runtime_workspace_roots: workspace_roots, instruction_sources, approval_policy: approval_policy.into(), approvals_reviewer: approvals_reviewer.into(), sandbox, - permission_profile: Some(permission_profile.into()), active_permission_profile, reasoning_effort, + initial_turns_page, }; outgoing.send_response(request_id, response).await; // Match cold resume: metadata-only resume should attach the listener without @@ -675,7 +704,7 @@ pub(super) async fn send_thread_goal_snapshot_notification( thread_id: ThreadId, state_db: &StateDbHandle, ) { - match state_db.get_thread_goal(thread_id).await { + match state_db.thread_goals().get_thread_goal(thread_id).await { Ok(Some(goal)) => { outgoing .send_server_notification(ServerNotification::ThreadGoalUpdated( diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index ffc1025bce..7e8739c0bf 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -59,6 +59,25 @@ fn collect_resume_override_mismatches( )); } } + if let Some(requested_runtime_workspace_roots) = request.runtime_workspace_roots.as_ref() { + let base_cwd = request + .cwd + .as_deref() + .map(|cwd| { + AbsolutePathBuf::resolve_path_against_base(cwd, config_snapshot.cwd.as_path()) + }) + .unwrap_or_else(|| config_snapshot.cwd.clone()); + let requested_runtime_workspace_roots = requested_runtime_workspace_roots + .iter() + .map(|path| AbsolutePathBuf::resolve_path_against_base(path, base_cwd.as_path())) + .collect::>(); + if requested_runtime_workspace_roots != config_snapshot.workspace_roots { + mismatch_details.push(format!( + "runtime_workspace_roots requested={requested_runtime_workspace_roots:?} active={:?}", + config_snapshot.workspace_roots + )); + } + } if let Some(requested_approval) = request.approval_policy.as_ref() { let active_approval: AskForApproval = config_snapshot.approval_policy.into(); if requested_approval != &active_approval { @@ -579,6 +598,15 @@ impl ThreadRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) async fn thread_search( + &self, + params: ThreadSearchParams, + ) -> Result, JSONRPCErrorError> { + self.thread_search_response_inner(params) + .await + .map(|response| Some(response.into())) + } + pub(crate) async fn thread_loaded_list( &self, params: ThreadLoadedListParams, @@ -813,6 +841,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + runtime_workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -846,6 +875,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + runtime_workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -986,7 +1016,7 @@ impl ThreadRequestProcessor { let requested_permissions_trust_project = requested_permissions_trust_project(&typesafe_overrides, config.cwd.as_path()); let effective_permissions_trust_project = permission_profile_trusts_project( - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), config.cwd.as_path(), ); @@ -1182,11 +1212,11 @@ impl ThreadRequestProcessor { model_provider: config_snapshot.model_provider_id, service_tier: config_snapshot.service_tier, cwd: config_snapshot.cwd, + runtime_workspace_roots: config_snapshot.workspace_roots, instruction_sources, approval_policy: config_snapshot.approval_policy.into(), approvals_reviewer: config_snapshot.approvals_reviewer.into(), sandbox, - permission_profile: Some(config_snapshot.permission_profile.into()), active_permission_profile, reasoning_effort: config_snapshot.reasoning_effort, }; @@ -1223,19 +1253,22 @@ impl ThreadRequestProcessor { model_provider: Option, service_tier: Option>, cwd: Option, + runtime_workspace_roots: Option>, approval_policy: Option, approvals_reviewer: Option, sandbox: Option, - permissions: Option, + permissions: Option, base_instructions: Option, developer_instructions: Option, personality: Option, ) -> ConfigOverrides { - let mut overrides = ConfigOverrides { + ConfigOverrides { model, model_provider, service_tier, cwd: cwd.map(PathBuf::from), + workspace_roots: runtime_workspace_roots, + default_permissions: permissions, approval_policy: approval_policy .map(codex_app_server_protocol::AskForApproval::to_core), approvals_reviewer: approvals_reviewer @@ -1247,9 +1280,7 @@ impl ThreadRequestProcessor { developer_instructions, personality, ..Default::default() - }; - apply_permission_profile_selection_to_config_overrides(&mut overrides, permissions); - overrides + } } fn parse_environment_selections( @@ -1286,7 +1317,7 @@ impl ThreadRequestProcessor { params: ThreadArchiveParams, ) -> Result<(ThreadArchiveResponse, Vec), JSONRPCErrorError> { let thread_id = ThreadId::from_string(¶ms.thread_id) - .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + .map_err(|err| invalid_request(format!("invalid session id: {err}")))?; let mut thread_ids = vec![thread_id]; if let Some(state_db_ctx) = self.state_db.as_ref() { @@ -1295,7 +1326,7 @@ impl ThreadRequestProcessor { .await .map_err(|err| { internal_error(format!( - "failed to list spawned descendants for thread id {thread_id}: {err}" + "failed to list spawned descendants for session {thread_id}: {err}" )) })?; let mut seen = HashSet::from([thread_id]); @@ -1491,9 +1522,13 @@ impl ThreadRequestProcessor { .clone() .ok_or_else(|| internal_error("sqlite state db unavailable for memory reset"))?; - state_db.clear_memory_data().await.map_err(|err| { - internal_error(format!("failed to clear memory rows in state db: {err}")) - })?; + state_db + .memories() + .clear_memory_data() + .await + .map_err(|err| { + internal_error(format!("failed to clear memory rows in memories db: {err}")) + })?; clear_memory_roots_contents(&self.config.codex_home) .await @@ -1661,7 +1696,7 @@ impl ThreadRequestProcessor { params: ThreadUnarchiveParams, ) -> Result<(ThreadUnarchiveResponse, String), JSONRPCErrorError> { let thread_id = ThreadId::from_string(¶ms.thread_id) - .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + .map_err(|err| invalid_request(format!("invalid session id: {err}")))?; let fallback_provider = self.config.model_provider_id.clone(); let stored_thread = self @@ -1783,6 +1818,16 @@ impl ThreadRequestProcessor { if command.is_empty() { return Err(invalid_request("command must not be empty")); } + // `thread/shellCommand` is app-server's local-host shell escape hatch, + // not the normal turn-selected shell tool path. + if self + .thread_manager + .environment_manager() + .try_local_environment() + .is_none() + { + return Err(internal_error("local environment is not configured")); + } let (_, thread) = self.load_thread(&thread_id).await?; self.submit_core_op( @@ -1896,6 +1941,131 @@ impl ThreadRequestProcessor { }) } + async fn thread_search_response_inner( + &self, + params: ThreadSearchParams, + ) -> Result { + let ThreadSearchParams { + cursor, + limit, + sort_key, + sort_direction, + source_kinds, + archived, + search_term, + } = params; + let search_term = search_term.trim().to_string(); + let search_term = (!search_term.is_empty()) + .then_some(search_term) + .ok_or_else(|| invalid_request("thread/search requires a non-empty searchTerm"))?; + let requested_page_size = limit + .map(|value| value as usize) + .unwrap_or(THREAD_LIST_DEFAULT_LIMIT) + .clamp(1, THREAD_LIST_MAX_LIMIT); + let store_sort_key = match sort_key.unwrap_or(ThreadSortKey::CreatedAt) { + ThreadSortKey::CreatedAt => StoreThreadSortKey::CreatedAt, + ThreadSortKey::UpdatedAt => StoreThreadSortKey::UpdatedAt, + }; + let store_sort_direction = sort_direction.unwrap_or(SortDirection::Desc); + let (allowed_sources, source_kind_filter) = compute_source_filters(source_kinds); + let mut cursor_obj = cursor; + let mut last_cursor = cursor_obj.clone(); + let mut remaining = requested_page_size; + let mut search_results = Vec::with_capacity(requested_page_size); + let mut next_cursor = None; + + while remaining > 0 { + let page = self + .thread_store + .search_threads(StoreSearchThreadsParams { + page_size: remaining.min(THREAD_LIST_MAX_LIMIT), + cursor: cursor_obj.clone(), + sort_key: store_sort_key, + sort_direction: match store_sort_direction { + SortDirection::Asc => StoreSortDirection::Asc, + SortDirection::Desc => StoreSortDirection::Desc, + }, + allowed_sources: allowed_sources.clone(), + archived: archived.unwrap_or(false), + search_term: search_term.clone(), + }) + .await + .map_err(thread_store_list_error)?; + + for result in page.items { + let source = with_thread_spawn_agent_metadata( + result.thread.source.clone(), + result.thread.agent_nickname.clone(), + result.thread.agent_role.clone(), + ); + if source_kind_filter + .as_ref() + .is_none_or(|filter| source_kind_matches(&source, filter)) + { + search_results.push(result); + if search_results.len() >= requested_page_size { + break; + } + } + } + + remaining = requested_page_size.saturating_sub(search_results.len()); + next_cursor = page.next_cursor; + if remaining == 0 { + break; + } + + let Some(cursor_val) = next_cursor.clone() else { + break; + }; + if last_cursor.as_ref() == Some(&cursor_val) { + next_cursor = None; + break; + } + last_cursor = Some(cursor_val.clone()); + cursor_obj = Some(cursor_val); + } + + let backwards_cursor = search_results.first().and_then(|result| { + thread_backwards_cursor_for_sort_key( + &result.thread, + store_sort_key, + store_sort_direction, + ) + }); + let fallback_provider = self.config.model_provider_id.clone(); + let mut results = Vec::with_capacity(search_results.len()); + let mut status_ids = Vec::with_capacity(search_results.len()); + for result in search_results { + let (thread, _) = thread_from_stored_thread( + result.thread, + fallback_provider.as_str(), + &self.config.cwd, + ); + status_ids.push(thread.id.clone()); + results.push((thread, result.snippet)); + } + let statuses = self + .thread_watch_manager + .loaded_statuses_for_threads(status_ids) + .await; + let data = results + .into_iter() + .map(|(mut thread, snippet)| { + if let Some(status) = statuses.get(&thread.id) { + thread.status = status.clone(); + } + ThreadSearchResult { thread, snippet } + }) + .collect(); + + Ok(ThreadSearchResponse { + data, + next_cursor, + backwards_cursor, + }) + } + async fn thread_loaded_list_response_inner( &self, params: ThreadLoadedListParams, @@ -2138,8 +2308,6 @@ impl ThreadRequestProcessor { sort_direction, items_view, } = params; - let items_view = items_view.unwrap_or(TurnItemsView::Summary); - let thread_uuid = ThreadId::from_string(&thread_id) .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; @@ -2167,60 +2335,20 @@ impl ThreadRequestProcessor { } else { None }; - let mut turns = reconstruct_thread_turns_for_turns_list( + build_thread_turns_page_response( &items, self.thread_watch_manager .loaded_status_for_thread(&thread_uuid.to_string()) .await, has_live_running_thread, active_turn, - ); - for turn in &mut turns { - match items_view { - TurnItemsView::NotLoaded => { - turn.items.clear(); - turn.items_view = TurnItemsView::NotLoaded; - } - TurnItemsView::Summary => { - let first_user_message = turn - .items - .iter() - .find(|item| matches!(item, ThreadItem::UserMessage { .. })) - .cloned(); - let final_agent_message = turn - .items - .iter() - .rev() - .find(|item| matches!(item, ThreadItem::AgentMessage { .. })) - .cloned(); - turn.items = match (first_user_message, final_agent_message) { - (Some(user_message), Some(agent_message)) - if user_message.id() != agent_message.id() => - { - vec![user_message, agent_message] - } - (Some(user_message), _) => vec![user_message], - (None, Some(agent_message)) => vec![agent_message], - (None, None) => Vec::new(), - }; - turn.items_view = TurnItemsView::Summary; - } - TurnItemsView::Full => { - turn.items_view = TurnItemsView::Full; - } - } - } - let page = paginate_thread_turns( - turns, - cursor.as_deref(), - limit, - sort_direction.unwrap_or(SortDirection::Desc), - )?; - Ok(ThreadTurnsListResponse { - data: page.turns, - next_cursor: page.next_cursor, - backwards_cursor: page.backwards_cursor, - }) + ThreadTurnsPageOptions { + cursor: cursor.as_deref(), + limit, + sort_direction: sort_direction.unwrap_or(SortDirection::Desc), + items_view: items_view.unwrap_or(TurnItemsView::Summary), + }, + ) } async fn load_thread_turns_list_history( @@ -2319,6 +2447,7 @@ impl ThreadRequestProcessor { thread_id: ThreadId, connection_ids: Vec, ) { + let mut raw_events_enabled = false; if let Ok(thread) = self.thread_manager.get_thread(thread_id).await { let config_snapshot = thread.config_snapshot().await; let loaded_thread = build_thread_from_snapshot( @@ -2328,16 +2457,21 @@ impl ThreadRequestProcessor { thread.rollout_path(), ); self.thread_watch_manager.upsert_thread(loaded_thread).await; + if let Some(parent_thread_id) = config_snapshot.parent_thread_id { + raw_events_enabled = self + .thread_state_manager + .thread_state(parent_thread_id) + .await + .lock() + .await + .experimental_raw_events; + } } for connection_id in connection_ids { log_listener_attach_result( - self.ensure_conversation_listener( - thread_id, - connection_id, - /*raw_events_enabled*/ false, - ) - .await, + self.ensure_conversation_listener(thread_id, connection_id, raw_events_enabled) + .await, thread_id, connection_id, "thread", @@ -2418,6 +2552,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + runtime_workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -2427,6 +2562,7 @@ impl ThreadRequestProcessor { developer_instructions, personality, exclude_turns, + initial_turns_page, persist_extended_history: _persist_extended_history, } = params; let include_turns = !exclude_turns; @@ -2453,6 +2589,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + runtime_workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -2580,8 +2717,28 @@ impl ThreadRequestProcessor { config_snapshot.active_permission_profile, ); let token_usage_thread = include_turns.then(|| thread.clone()); + let mut initial_turns_page = if let Some(params) = initial_turns_page.as_ref() { + match build_thread_resume_initial_turns_page( + &response_history.get_rollout_items(), + thread.status.clone(), + /*has_live_running_thread*/ false, + /*active_turn*/ None, + params, + ) { + Ok(page) => Some(page), + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return Ok(()); + } + } + } else { + None + }; if redact_resume_payloads { - redact_thread_resume_payloads(&mut thread); + redact_thread_resume_payloads(&mut thread.turns); + if let Some(initial_turns_page) = initial_turns_page.as_mut() { + redact_thread_resume_payloads(&mut initial_turns_page.data); + } } let response = ThreadResumeResponse { @@ -2590,13 +2747,14 @@ impl ThreadRequestProcessor { model_provider: session_configured.model_provider_id, service_tier: session_configured.service_tier, cwd: session_configured.cwd, + runtime_workspace_roots: config_snapshot.workspace_roots, instruction_sources, approval_policy: session_configured.approval_policy.into(), approvals_reviewer: session_configured.approvals_reviewer.into(), sandbox, - permission_profile: Some(config_snapshot.permission_profile.into()), active_permission_profile, reasoning_effort: session_configured.reasoning_effort, + initial_turns_page, }; let connection_id = request_id.connection_id; @@ -2672,31 +2830,6 @@ impl ThreadRequestProcessor { ))); } None - } else if params.path.is_some() { - let source_thread = self - .read_stored_thread_for_resume( - ¶ms.thread_id, - params.path.as_ref(), - /*include_history*/ true, - ) - .await?; - let existing_thread_id = source_thread.thread_id; - if let Ok(existing_thread) = self.thread_manager.get_thread(existing_thread_id).await { - if let (Some(requested_path), Some(active_path)) = ( - params.path.as_ref(), - existing_thread.rollout_path().as_ref(), - ) && requested_path != active_path - { - return Err(invalid_request(format!( - "cannot resume running thread {existing_thread_id} with stale path: requested `{}`, active `{}`", - requested_path.display(), - active_path.display() - ))); - } - Some((existing_thread_id, existing_thread, source_thread)) - } else { - None - } } else if let Ok(existing_thread_id) = ThreadId::from_string(¶ms.thread_id) && let Ok(existing_thread) = self.thread_manager.get_thread(existing_thread_id).await { @@ -2707,18 +2840,78 @@ impl ThreadRequestProcessor { /*include_history*/ true, ) .await?; - if source_thread.thread_id != existing_thread_id { - return Err(invalid_request(format!( - "cannot resume running thread {existing_thread_id} from source thread {}", - source_thread.thread_id - ))); - } Some((existing_thread_id, existing_thread, source_thread)) } else { - None + let source_thread = self + .read_stored_thread_for_resume( + ¶ms.thread_id, + params.path.as_ref(), + /*include_history*/ true, + ) + .await?; + let existing_thread_id = source_thread.thread_id; + match self.thread_manager.get_thread(existing_thread_id).await { + Ok(existing_thread) => Some((existing_thread_id, existing_thread, source_thread)), + Err(_) => None, + } }; if let Some((existing_thread_id, existing_thread, source_thread)) = running_thread { + let existing_thread_rollout_path = existing_thread.rollout_path(); + let active_path = existing_thread_rollout_path + .as_ref() + .or(source_thread.rollout_path.as_ref()); + if let (Some(requested_path), Some(active_path)) = (params.path.as_ref(), active_path) + && requested_path != active_path + { + return Err(invalid_request(format!( + "cannot resume running thread {existing_thread_id} with stale path: requested `{}`, active `{}`", + requested_path.display(), + active_path.display() + ))); + } + let config_snapshot = existing_thread.config_snapshot().await; + let mismatch_details = collect_resume_override_mismatches(params, &config_snapshot); + if !mismatch_details.is_empty() { + let has_subscribers = !self + .thread_state_manager + .subscribed_connection_ids(existing_thread_id) + .await + .is_empty(); + let loaded_status = self + .thread_watch_manager + .loaded_status_for_thread(&existing_thread_id.to_string()) + .await; + let is_running = + matches!(existing_thread.agent_status().await, AgentStatus::Running); + + if !has_subscribers && matches!(loaded_status, ThreadStatus::Idle) && !is_running { + // A loaded idle thread is only a cache entry. Shut it down + // before removing it so cold resume cannot duplicate a + // thread that timed out during shutdown. + match wait_for_thread_shutdown(&existing_thread).await { + ThreadShutdownResult::Complete => { + self.thread_manager.remove_thread(&existing_thread_id).await; + self.finalize_thread_teardown(existing_thread_id).await; + return Ok(false); + } + ThreadShutdownResult::SubmitFailed => { + warn!("failed to submit Shutdown to thread {existing_thread_id}"); + } + ThreadShutdownResult::TimedOut => { + warn!("thread {existing_thread_id} shutdown timed out"); + } + } + } + + // Preserve rejoin semantics when another client can still observe + // the loaded thread or shutdown did not complete. + tracing::warn!( + "thread/resume overrides ignored for loaded thread {}: {}", + existing_thread_id, + mismatch_details.join("; ") + ); + } let redact_resume_payloads = should_redact_thread_resume_payloads(app_server_client_name.as_deref()); let history_items = source_thread @@ -2748,15 +2941,6 @@ impl ThreadRequestProcessor { ) .await?; - let config_snapshot = existing_thread.config_snapshot().await; - let mismatch_details = collect_resume_override_mismatches(params, &config_snapshot); - if !mismatch_details.is_empty() { - tracing::warn!( - "thread/resume overrides ignored for running thread {}: {}", - existing_thread_id, - mismatch_details.join("; ") - ); - } let mut summary_source_thread = source_thread; summary_source_thread.history = None; let mut thread_summary = self.stored_thread_to_api_thread( @@ -2795,6 +2979,7 @@ impl ThreadRequestProcessor { emit_thread_goal_update, thread_goal_state_db, include_turns: !params.exclude_turns, + initial_turns_page: params.initial_turns_page.clone(), redact_resume_payloads, }), ); @@ -2856,7 +3041,7 @@ impl ThreadRequestProcessor { let existing_thread_id = match ThreadId::from_string(thread_id) { Ok(id) => id, Err(err) => { - return Err(invalid_request(format!("invalid thread id: {err}"))); + return Err(invalid_request(format!("invalid session id: {err}"))); } }; let params = StoreReadThreadParams { @@ -2867,7 +3052,15 @@ impl ThreadRequestProcessor { self.thread_store.read_thread(params).await }; - result.map_err(thread_store_resume_read_error) + let stored_thread = result.map_err(thread_store_resume_read_error)?; + if stored_thread.archived_at.is_some() { + let thread_id = stored_thread.thread_id; + return Err(invalid_request(format!( + "session {thread_id} is archived. Run `codex unarchive {thread_id}` to unarchive it first." + ))); + } + + Ok(stored_thread) } async fn stored_thread_to_initial_history( @@ -3054,6 +3247,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + runtime_workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -3119,6 +3313,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + runtime_workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -3248,11 +3443,11 @@ impl ThreadRequestProcessor { model_provider: session_configured.model_provider_id, service_tier: session_configured.service_tier, cwd: session_configured.cwd, + runtime_workspace_roots: config_snapshot.workspace_roots, instruction_sources, approval_policy: session_configured.approval_policy.into(), approvals_reviewer: session_configured.approvals_reviewer.into(), sandbox, - permission_profile: Some(config_snapshot.permission_profile.into()), active_permission_profile, reasoning_effort: session_configured.reasoning_effort, }; @@ -3573,6 +3768,95 @@ fn parse_thread_turns_cursor(cursor: &str) -> Result { + cursor: Option<&'a str>, + limit: Option, + sort_direction: SortDirection, + items_view: TurnItemsView, +} + +fn build_thread_turns_page_response( + items: &[RolloutItem], + loaded_status: ThreadStatus, + has_live_running_thread: bool, + active_turn: Option, + options: ThreadTurnsPageOptions<'_>, +) -> Result { + let mut turns = reconstruct_thread_turns_for_turns_list( + items, + loaded_status, + has_live_running_thread, + active_turn, + ); + apply_thread_turns_items_view(&mut turns, options.items_view); + let page = paginate_thread_turns(turns, options.cursor, options.limit, options.sort_direction)?; + Ok(ThreadTurnsListResponse { + data: page.turns, + next_cursor: page.next_cursor, + backwards_cursor: page.backwards_cursor, + }) +} + +pub(super) fn build_thread_resume_initial_turns_page( + items: &[RolloutItem], + loaded_status: ThreadStatus, + has_live_running_thread: bool, + active_turn: Option, + params: &ThreadResumeInitialTurnsPageParams, +) -> Result { + build_thread_turns_page_response( + items, + loaded_status, + has_live_running_thread, + active_turn, + ThreadTurnsPageOptions { + cursor: None, + limit: params.limit, + sort_direction: params.sort_direction.unwrap_or(SortDirection::Desc), + items_view: params.items_view.unwrap_or(TurnItemsView::Summary), + }, + ) + .map(Into::into) +} + +fn apply_thread_turns_items_view(turns: &mut [Turn], items_view: TurnItemsView) { + for turn in turns { + match items_view { + TurnItemsView::NotLoaded => { + turn.items.clear(); + turn.items_view = TurnItemsView::NotLoaded; + } + TurnItemsView::Summary => { + let first_user_message = turn + .items + .iter() + .find(|item| matches!(item, ThreadItem::UserMessage { .. })) + .cloned(); + let final_agent_message = turn + .items + .iter() + .rev() + .find(|item| matches!(item, ThreadItem::AgentMessage { .. })) + .cloned(); + turn.items = match (first_user_message, final_agent_message) { + (Some(user_message), Some(agent_message)) + if user_message.id() != agent_message.id() => + { + vec![user_message, agent_message] + } + (Some(user_message), _) => vec![user_message], + (None, Some(agent_message)) => vec![agent_message], + (None, None) => Vec::new(), + }; + turn.items_view = TurnItemsView::Summary; + } + TurnItemsView::Full => { + turn.items_view = TurnItemsView::Full; + } + } + } +} + fn reconstruct_thread_turns_for_turns_list( items: &[RolloutItem], loaded_status: ThreadStatus, @@ -3761,7 +4045,7 @@ fn thread_store_archive_error(operation: &str, err: ThreadStoreError) -> JSONRPC ThreadStoreError::Unsupported { operation: unsupported_operation, } => unsupported_thread_store_operation(unsupported_operation), - err => internal_error(format!("failed to {operation} thread: {err}")), + err => internal_error(format!("failed to {operation} session: {err}")), } } @@ -3819,6 +4103,7 @@ pub(crate) fn thread_from_stored_thread( id: thread_id.clone(), session_id: thread_id, forked_from_id: thread.forked_from_id.map(|id| id.to_string()), + parent_thread_id: thread.parent_thread_id.map(|id| id.to_string()), preview: thread.preview, ephemeral: false, model_provider: if thread.model_provider.is_empty() { @@ -4055,6 +4340,7 @@ fn build_thread_from_snapshot( id: thread_id.to_string(), session_id, forked_from_id: None, + parent_thread_id: config_snapshot.parent_thread_id.map(|id| id.to_string()), preview: String::new(), ephemeral: config_snapshot.ephemeral, model_provider: config_snapshot.model_provider_id.clone(), diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index f98b5506eb..385ced3040 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -62,16 +62,19 @@ mod thread_processor_behavior_tests { use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::WireApi; use codex_protocol::ThreadId; + use codex_protocol::config_types::CollaborationMode; + use codex_protocol::config_types::ModeKind; + use codex_protocol::config_types::Settings; 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::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; - use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_state::ThreadMetadataBuilder; @@ -210,16 +213,19 @@ mod thread_processor_behavior_tests { fn thread_turns_list_merges_in_progress_active_turn_before_agent_status_running() { let persisted_items = vec![RolloutItem::EventMsg(EventMsg::UserMessage( codex_protocol::protocol::UserMessageEvent { + client_id: None, message: "persisted".to_string(), images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, ))]; let active_turn = Turn { id: "live-turn".to_string(), items: vec![ThreadItem::UserMessage { id: "live-user-message".to_string(), + client_id: None, content: vec![V2UserInput::Text { text: "live".to_string(), text_elements: Vec::new(), @@ -420,6 +426,7 @@ mod thread_processor_behavior_tests { thread_id, rollout_path: Some(PathBuf::from("/tmp/thread.jsonl")), forked_from_id: None, + parent_thread_id: None, preview: "preview".to_string(), name: None, model_provider: "openai".to_string(), @@ -438,7 +445,7 @@ mod thread_processor_behavior_tests { git_info: None, artifacts: Vec::new(), approval_mode: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: PermissionProfile::read_only(), token_usage: None, first_user_message: Some("first user message".to_string()), history: None, @@ -473,7 +480,7 @@ mod thread_processor_behavior_tests { path: FileSystemPath::GlobPattern { pattern: "/tmp/project/**/*.env".to_string(), }, - access: FileSystemAccessMode::None, + access: FileSystemAccessMode::Deny, }, ]), NetworkSandboxPolicy::Restricted, @@ -638,6 +645,7 @@ mod thread_processor_behavior_tests { Some(HashMap::from([ ("model_provider".to_string(), json!("request")), ("features.plugins".to_string(), json!(true)), + ("bypass_hook_trust".to_string(), json!(true)), ( "model_providers.session".to_string(), json!({ @@ -654,6 +662,7 @@ mod thread_processor_behavior_tests { assert_eq!(config.model_provider_id, "session"); assert_eq!(config.model_provider, session_provider); assert!(!config.features.enabled(Feature::Plugins)); + assert!(config.bypass_hook_trust); Ok(()) } @@ -668,6 +677,7 @@ mod thread_processor_behavior_tests { model_provider: None, service_tier: Some(Some("priority".to_string())), cwd: None, + runtime_workspace_roots: None, approval_policy: None, approvals_reviewer: None, sandbox: None, @@ -677,6 +687,7 @@ mod thread_processor_behavior_tests { developer_instructions: None, personality: None, exclude_turns: false, + initial_turns_page: None, persist_extended_history: false, }; let config_snapshot = ThreadConfigSnapshot { @@ -688,10 +699,22 @@ mod thread_processor_behavior_tests { permission_profile: codex_protocol::models::PermissionProfile::Disabled, active_permission_profile: None, cwd, + workspace_roots: Vec::new(), + profile_workspace_roots: Vec::new(), ephemeral: false, reasoning_effort: None, + reasoning_summary: None, personality: None, + collaboration_mode: CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: "gpt-5".to_string(), + reasoning_effort: None, + developer_instructions: None, + }, + }, session_source: SessionSource::Cli, + parent_thread_id: None, thread_source: None, }; @@ -1174,6 +1197,7 @@ mod thread_processor_behavior_tests { "turn-1", &EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { turn_id: "turn-1".to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), diff --git a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs index 5ac1e9aef3..843334a3b4 100644 --- a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs +++ b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs @@ -1,6 +1,6 @@ use codex_app_server_protocol::McpToolCallResult; -use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::Turn; use serde_json::Value as JsonValue; // Temporary bandaid for remote clients: thread/resume can include large MCP and @@ -14,8 +14,8 @@ pub(super) fn should_redact_thread_resume_payloads(client_name: Option<&str>) -> client_name.is_some_and(|client_name| CHATGPT_REMOTE_CLIENT_NAMES.contains(&client_name)) } -pub(super) fn redact_thread_resume_payloads(thread: &mut Thread) { - for turn in &mut thread.turns { +pub(super) fn redact_thread_resume_payloads(turns: &mut [Turn]) { + for turn in turns { turn.items.retain_mut(|item| match item { ThreadItem::McpToolCall { arguments, @@ -55,8 +55,8 @@ mod tests { use codex_app_server_protocol::McpToolCallError; use codex_app_server_protocol::McpToolCallStatus; use codex_app_server_protocol::SessionSource; + use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadStatus; - use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnItemsView; use codex_app_server_protocol::TurnStatus; use codex_utils_absolute_path::test_support::PathBufExt; @@ -79,6 +79,7 @@ mod tests { status: McpToolCallStatus::Completed, arguments: serde_json::json!({"secret":"argument"}), mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()), + plugin_id: Some("sample@test".to_string()), result: Some(Box::new(McpToolCallResult { content: vec![serde_json::json!({ "type": "text", @@ -99,7 +100,7 @@ mod tests { }, ]); - redact_thread_resume_payloads(&mut thread); + redact_thread_resume_payloads(&mut thread.turns); assert_eq!(thread.turns[0].items.len(), 2); assert_eq!( @@ -120,6 +121,7 @@ mod tests { status: McpToolCallStatus::Completed, arguments: JsonValue::String(REDACTED_PAYLOAD.to_string()), mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()), + plugin_id: Some("sample@test".to_string()), result: Some(Box::new(redacted_mcp_tool_call_result())), error: None, duration_ms: Some(8), @@ -136,6 +138,7 @@ mod tests { status: McpToolCallStatus::Failed, arguments: serde_json::json!({"secret":"argument"}), mcp_app_resource_uri: None, + plugin_id: None, result: None, error: Some(McpToolCallError { message: "secret error".to_string(), @@ -143,7 +146,7 @@ mod tests { duration_ms: Some(8), }]); - redact_thread_resume_payloads(&mut thread); + redact_thread_resume_payloads(&mut thread.turns); assert_eq!( thread.turns[0].items[0], @@ -154,6 +157,7 @@ mod tests { status: McpToolCallStatus::Failed, arguments: JsonValue::String(REDACTED_PAYLOAD.to_string()), mcp_app_resource_uri: None, + plugin_id: None, result: None, error: Some(McpToolCallError { message: REDACTED_PAYLOAD.to_string(), @@ -168,6 +172,7 @@ mod tests { id: "thread-1".to_string(), session_id: "session-1".to_string(), forked_from_id: None, + parent_thread_id: None, preview: "preview".to_string(), ephemeral: false, model_provider: "mock_provider".to_string(), diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index 293343445d..ec744b2504 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -170,32 +170,13 @@ pub(super) fn with_thread_spawn_agent_metadata( } } -pub(super) fn thread_response_active_permission_profile( +pub(crate) fn thread_response_active_permission_profile( active_permission_profile: Option, ) -> Option { active_permission_profile.map(Into::into) } -pub(super) fn apply_permission_profile_selection_to_config_overrides( - overrides: &mut ConfigOverrides, - permissions: Option, -) { - let Some(PermissionProfileSelectionParams::Profile { id, modifications }) = permissions else { - return; - }; - overrides.default_permissions = Some(id); - overrides - .additional_writable_roots - .extend(modifications.unwrap_or_default().into_iter().map( - |modification| match modification { - PermissionProfileModificationParams::AdditionalWritableRoot { path } => { - path.to_path_buf() - } - }, - )); -} - -pub(super) fn thread_response_sandbox_policy( +pub(crate) fn thread_response_sandbox_policy( permission_profile: &codex_protocol::models::PermissionProfile, cwd: &Path, ) -> codex_app_server_protocol::SandboxPolicy { @@ -209,6 +190,54 @@ pub(super) fn thread_response_sandbox_policy( sandbox_policy.into() } +pub(crate) fn thread_settings_from_config_snapshot( + config_snapshot: &ThreadConfigSnapshot, +) -> ThreadSettings { + ThreadSettings { + cwd: config_snapshot.cwd.clone(), + approval_policy: config_snapshot.approval_policy.into(), + approvals_reviewer: config_snapshot.approvals_reviewer.into(), + sandbox_policy: thread_response_sandbox_policy( + &config_snapshot.permission_profile, + config_snapshot.cwd.as_path(), + ), + active_permission_profile: thread_response_active_permission_profile( + config_snapshot.active_permission_profile.clone(), + ), + model: config_snapshot.model.clone(), + model_provider: config_snapshot.model_provider_id.clone(), + service_tier: config_snapshot.service_tier.clone(), + effort: config_snapshot.reasoning_effort, + summary: config_snapshot.reasoning_summary, + collaboration_mode: config_snapshot.collaboration_mode.clone(), + personality: config_snapshot.personality, + } +} + +pub(crate) fn thread_settings_from_core_snapshot( + snapshot: codex_protocol::protocol::ThreadSettingsSnapshot, +) -> ThreadSettings { + ThreadSettings { + sandbox_policy: thread_response_sandbox_policy( + &snapshot.permission_profile, + snapshot.cwd.as_path(), + ), + cwd: snapshot.cwd, + approval_policy: snapshot.approval_policy.into(), + approvals_reviewer: snapshot.approvals_reviewer.into(), + active_permission_profile: thread_response_active_permission_profile( + snapshot.active_permission_profile, + ), + model: snapshot.model, + model_provider: snapshot.model_provider_id, + service_tier: snapshot.service_tier, + effort: snapshot.reasoning_effort, + summary: snapshot.reasoning_summary, + collaboration_mode: snapshot.collaboration_mode, + personality: snapshot.personality, + } +} + #[cfg(test)] fn parse_datetime(timestamp: Option<&str>) -> Option> { timestamp.and_then(|ts| { @@ -277,6 +306,7 @@ pub(crate) fn summary_to_thread( id: thread_id.clone(), session_id: thread_id, forked_from_id: None, + parent_thread_id: None, preview, ephemeral: false, model_provider, diff --git a/codex-rs/app-server/src/request_processors/token_usage_replay.rs b/codex-rs/app-server/src/request_processors/token_usage_replay.rs index b19c4a61a0..d4d2228d4b 100644 --- a/codex-rs/app-server/src/request_processors/token_usage_replay.rs +++ b/codex-rs/app-server/src/request_processors/token_usage_replay.rs @@ -148,10 +148,12 @@ mod tests { fn token_usage_history() -> Vec { vec![ RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "first turn".to_string(), images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() })), RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent { message: "first answer".to_string(), @@ -163,10 +165,12 @@ mod tests { rate_limits: None, })), RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + client_id: None, message: "second turn".to_string(), images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() })), ] } diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index d1dae4ef46..2fbc64c398 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -1,4 +1,6 @@ use super::*; +use codex_protocol::protocol::AdditionalContextEntry as CoreAdditionalContextEntry; +use codex_protocol::protocol::AdditionalContextKind as CoreAdditionalContextKind; #[derive(Clone)] pub(crate) struct TurnRequestProcessor { @@ -16,6 +18,59 @@ pub(crate) struct TurnRequestProcessor { skills_watcher: Arc, } +fn resolve_runtime_workspace_roots( + workspace_roots: Vec, + base_cwd: &AbsolutePathBuf, +) -> Vec { + let mut resolved_roots = Vec::new(); + for path in workspace_roots { + let root = AbsolutePathBuf::resolve_path_against_base(path, base_cwd.as_path()); + if !resolved_roots.iter().any(|existing| existing == &root) { + resolved_roots.push(root); + } + } + resolved_roots +} + +fn map_additional_context( + additional_context: Option>, +) -> BTreeMap { + additional_context + .unwrap_or_default() + .into_iter() + .map(|(key, entry)| { + ( + key, + CoreAdditionalContextEntry { + value: entry.value, + kind: match entry.kind { + AdditionalContextKind::Untrusted => CoreAdditionalContextKind::Untrusted, + AdditionalContextKind::Application => { + CoreAdditionalContextKind::Application + } + }, + }, + ) + }) + .collect() +} + +struct ThreadSettingsBuildParams { + method: &'static str, + cwd: Option, + runtime_workspace_roots: Option>, + approval_policy: Option, + approvals_reviewer: Option, + sandbox_policy: Option, + permissions: Option, + model: Option, + service_tier: Option>, + effort: Option, + summary: Option, + collaboration_mode: Option, + personality: Option, +} + impl TurnRequestProcessor { #[allow(clippy::too_many_arguments)] pub(crate) fn new( @@ -74,6 +129,16 @@ impl TurnRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) async fn thread_settings_update( + &self, + request_id: &ConnectionRequestId, + params: ThreadSettingsUpdateParams, + ) -> Result, JSONRPCErrorError> { + self.thread_settings_update_inner(request_id, params) + .await + .map(|response| Some(response.into())) + } + pub(crate) async fn turn_steer( &self, request_id: &ConnectionRequestId, @@ -185,7 +250,7 @@ impl TurnRequestProcessor { Ok((thread_id, thread)) } - fn normalize_turn_start_collaboration_mode( + fn normalize_collaboration_mode( &self, mut collaboration_mode: CollaborationMode, ) -> CollaborationMode { @@ -343,9 +408,6 @@ impl TurnRequestProcessor { self.track_error_response(&request_id, error, /*error_type*/ None); })?; - let collaboration_mode = params - .collaboration_mode - .map(|mode| self.normalize_turn_start_collaboration_mode(mode)); let environment_selections = self.parse_environment_selections(params.environments)?; // Map v2 input items to core input items. @@ -354,132 +416,45 @@ impl TurnRequestProcessor { .into_iter() .map(V2UserInput::into_core) .collect(); + let client_user_message_id = params.client_user_message_id; + let additional_context = map_additional_context(params.additional_context); let turn_has_input = !mapped_items.is_empty(); - - let has_any_overrides = params.cwd.is_some() - || params.approval_policy.is_some() - || params.approvals_reviewer.is_some() - || params.sandbox_policy.is_some() - || params.permissions.is_some() - || params.model.is_some() - || params.service_tier.is_some() - || params.effort.is_some() - || params.summary.is_some() - || collaboration_mode.is_some() - || params.personality.is_some(); - - if params.sandbox_policy.is_some() && params.permissions.is_some() { - return Err(invalid_request( - "`permissions` cannot be combined with `sandboxPolicy`", - )); - } - - let cwd = params.cwd; - let approval_policy = params.approval_policy.map(AskForApproval::to_core); - let approvals_reviewer = params - .approvals_reviewer - .map(codex_app_server_protocol::ApprovalsReviewer::to_core); - let sandbox_policy = params.sandbox_policy.map(|p| p.to_core()); - let (permission_profile, active_permission_profile) = - if let Some(permissions) = params.permissions { - let snapshot = thread.config_snapshot().await; - let mut overrides = ConfigOverrides { - cwd: cwd.clone(), - codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), - main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), - ..Default::default() - }; - apply_permission_profile_selection_to_config_overrides( - &mut overrides, - Some(permissions), - ); - let config = self - .config_manager - .load_for_cwd( - /*request_overrides*/ None, - overrides, - Some(snapshot.cwd.to_path_buf()), - ) - .await - .map_err(|err| config_load_error(&err))?; - // Startup config is allowed to fall back when requirements - // disallow a configured profile. An explicit turn request - // is different: reject it before accepting user input. - if let Some(warning) = config.startup_warnings.iter().find(|warning| { - warning.contains("Configured value for `permission_profile` is disallowed") - }) { - return Err(invalid_request(format!( - "invalid turn context override: {warning}" - ))); - } - ( - Some(config.permissions.permission_profile()), - config.permissions.active_permission_profile(), - ) - } else { - (None, None) - }; - let model = params.model; - let effort = params.effort.map(Some); - let summary = params.summary; - let service_tier = params.service_tier; - let personality = params.personality; - - // If any overrides are provided, validate them synchronously so the - // request can fail before accepting user input. The actual update is - // still queued together with the input below to preserve submission order. - if has_any_overrides { - thread - .validate_turn_context_overrides(CodexThreadTurnContextOverrides { - cwd: cwd.clone(), - approval_policy, - approvals_reviewer, - sandbox_policy: sandbox_policy.clone(), - permission_profile: permission_profile.clone(), - active_permission_profile: active_permission_profile.clone(), - windows_sandbox_level: None, - model: model.clone(), - effort, - summary, - service_tier: service_tier.clone(), - collaboration_mode: collaboration_mode.clone(), - personality, - }) - .await - .map_err(|err| invalid_request(format!("invalid turn context override: {err}")))?; - } + let thread_settings = self + .build_thread_settings_overrides( + thread.as_ref(), + ThreadSettingsBuildParams { + method: "turn/start", + cwd: params.cwd, + runtime_workspace_roots: params.runtime_workspace_roots, + approval_policy: params.approval_policy, + approvals_reviewer: params.approvals_reviewer, + sandbox_policy: params.sandbox_policy, + permissions: params.permissions, + model: params.model, + service_tier: params.service_tier, + effort: params.effort, + summary: params.summary, + collaboration_mode: params.collaboration_mode, + personality: params.personality, + }, + ) + .await?; // Start the turn by submitting the user input. Return its submission id as turn_id. - let turn_op = if has_any_overrides { - Op::UserInputWithTurnContext { - items: mapped_items, - environments: environment_selections, - final_output_json_schema: params.output_schema, - responsesapi_client_metadata: params.responsesapi_client_metadata, - cwd, - approval_policy, - approvals_reviewer, - sandbox_policy, - permission_profile, - active_permission_profile, - windows_sandbox_level: None, - model, - effort, - summary, - service_tier, - collaboration_mode, - personality, - } - } else { - Op::UserInput { - items: mapped_items, - environments: environment_selections, - final_output_json_schema: params.output_schema, - responsesapi_client_metadata: params.responsesapi_client_metadata, - } + let turn_op = Op::UserInput { + items: mapped_items, + environments: environment_selections, + final_output_json_schema: params.output_schema, + responsesapi_client_metadata: params.responsesapi_client_metadata, + additional_context, + thread_settings, }; - let turn_id = self - .submit_core_op(&request_id, thread.as_ref(), turn_op) + let turn_id = thread + .submit_user_input_with_client_user_message_id( + turn_op, + self.request_trace_context(&request_id).await, + client_user_message_id, + ) .await .map_err(|err| { let error = internal_error(format!("failed to start turn: {err}")); @@ -516,6 +491,215 @@ impl TurnRequestProcessor { Ok(TurnStartResponse { turn }) } + async fn build_thread_settings_overrides( + &self, + thread: &CodexThread, + params: ThreadSettingsBuildParams, + ) -> Result { + let ThreadSettingsBuildParams { + method, + cwd, + runtime_workspace_roots, + approval_policy, + approvals_reviewer, + sandbox_policy, + permissions, + model, + service_tier, + effort, + summary, + collaboration_mode, + personality, + } = params; + + if sandbox_policy.is_some() && permissions.is_some() { + return Err(invalid_request( + "`permissions` cannot be combined with `sandboxPolicy`", + )); + } + + let collaboration_mode = + collaboration_mode.map(|mode| self.normalize_collaboration_mode(mode)); + let runtime_workspace_roots_request = runtime_workspace_roots; + // `thread/settings/update` only acknowledges that the update was queued. + // Clients that send dependent partial updates should wait for + // `thread/settings/updated` or combine the fields in one request. + let snapshot = if permissions.is_some() || runtime_workspace_roots_request.is_some() { + Some(thread.config_snapshot().await) + } else { + None + }; + + let has_any_overrides = cwd.is_some() + || runtime_workspace_roots_request.is_some() + || approval_policy.is_some() + || approvals_reviewer.is_some() + || sandbox_policy.is_some() + || permissions.is_some() + || model.is_some() + || service_tier.is_some() + || effort.is_some() + || summary.is_some() + || collaboration_mode.is_some() + || personality.is_some(); + + let runtime_workspace_roots = if let Some(workspace_roots) = + runtime_workspace_roots_request.clone() + { + let Some(snapshot) = snapshot.as_ref() else { + return Err(internal_error(format!( + "{method} runtime workspace roots missing thread snapshot" + ))); + }; + let base_cwd = cwd + .as_ref() + .map(|cwd| AbsolutePathBuf::resolve_path_against_base(cwd, snapshot.cwd.as_path())) + .unwrap_or_else(|| snapshot.cwd.clone()); + Some(resolve_runtime_workspace_roots(workspace_roots, &base_cwd)) + } else { + None + }; + let approval_policy = + approval_policy.map(codex_app_server_protocol::AskForApproval::to_core); + let approvals_reviewer = + approvals_reviewer.map(codex_app_server_protocol::ApprovalsReviewer::to_core); + let sandbox_policy = sandbox_policy.map(|policy| policy.to_core()); + let (permission_profile, active_permission_profile, profile_workspace_roots) = + if let Some(permissions) = permissions { + let Some(snapshot) = snapshot.as_ref() else { + return Err(internal_error(format!( + "{method} permission selection missing thread snapshot" + ))); + }; + let overrides = ConfigOverrides { + cwd: cwd.clone(), + workspace_roots: Some(runtime_workspace_roots_request.clone().unwrap_or_else( + || { + snapshot + .workspace_roots + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect() + }, + )), + default_permissions: Some(permissions), + codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), + ..Default::default() + }; + let config = self + .config_manager + .load_for_cwd( + /*request_overrides*/ None, + overrides, + Some(snapshot.cwd.to_path_buf()), + ) + .await + .map_err(|err| config_load_error(&err))?; + // Startup config is allowed to fall back when requirements + // disallow a configured profile. An explicit settings update + // is different: reject it before accepting the request. + if let Some(warning) = config.startup_warnings.iter().find(|warning| { + warning.contains("Configured value for `permission_profile` is disallowed") + }) { + return Err(invalid_request(format!( + "invalid thread settings override: {warning}" + ))); + } + ( + Some(config.permissions.permission_profile().clone()), + config.permissions.active_permission_profile(), + Some(config.permissions.profile_workspace_roots().to_vec()), + ) + } else { + (None, None, None) + }; + let effort = effort.map(Some); + + if has_any_overrides { + thread + .preview_thread_settings_overrides(CodexThreadSettingsOverrides { + cwd: cwd.clone(), + workspace_roots: runtime_workspace_roots.clone(), + approval_policy, + approvals_reviewer, + sandbox_policy: sandbox_policy.clone(), + permission_profile: permission_profile.clone(), + active_permission_profile: active_permission_profile.clone(), + profile_workspace_roots: profile_workspace_roots.clone(), + windows_sandbox_level: None, + model: model.clone(), + effort, + summary, + service_tier: service_tier.clone(), + collaboration_mode: collaboration_mode.clone(), + personality, + }) + .await + .map_err(|err| { + invalid_request(format!("invalid thread settings override: {err}")) + })?; + } + + Ok(codex_protocol::protocol::ThreadSettingsOverrides { + cwd, + workspace_roots: runtime_workspace_roots, + profile_workspace_roots, + approval_policy, + approvals_reviewer, + sandbox_policy, + permission_profile, + active_permission_profile, + windows_sandbox_level: None, + model, + effort, + summary, + service_tier, + collaboration_mode, + personality, + }) + } + + async fn thread_settings_update_inner( + &self, + request_id: &ConnectionRequestId, + params: ThreadSettingsUpdateParams, + ) -> Result { + let (_, thread) = self.load_thread(¶ms.thread_id).await?; + let thread_settings = self + .build_thread_settings_overrides( + thread.as_ref(), + ThreadSettingsBuildParams { + method: "thread/settings/update", + cwd: params.cwd, + runtime_workspace_roots: None, + approval_policy: params.approval_policy, + approvals_reviewer: params.approvals_reviewer, + sandbox_policy: params.sandbox_policy, + permissions: params.permissions, + model: params.model, + service_tier: params.service_tier, + effort: params.effort, + summary: params.summary, + collaboration_mode: params.collaboration_mode, + personality: params.personality, + }, + ) + .await?; + + if thread_settings != codex_protocol::protocol::ThreadSettingsOverrides::default() { + self.submit_core_op( + request_id, + thread.as_ref(), + Op::ThreadSettings { thread_settings }, + ) + .await + .map_err(|err| internal_error(format!("failed to update thread settings: {err}")))?; + } + + Ok(ThreadSettingsUpdateResponse {}) + } + async fn thread_inject_items_response_inner( &self, params: ThreadInjectItemsParams, @@ -594,11 +778,14 @@ impl TurnRequestProcessor { .into_iter() .map(V2UserInput::into_core) .collect(); + let additional_context = map_additional_context(params.additional_context); let turn_id = thread .steer_input( mapped_items, + additional_context, Some(¶ms.expected_turn_id), + params.client_user_message_id, params.responsesapi_client_metadata, ) .await @@ -808,6 +995,7 @@ impl TurnRequestProcessor { } else { vec![ThreadItem::UserMessage { id: turn_id.clone(), + client_id: None, content: vec![V2UserInput::Text { text: display_text.to_string(), // Review prompt display text is synthesized; no UI element ranges to preserve. diff --git a/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs b/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs index 2392cc8078..c0917d933b 100644 --- a/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs +++ b/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs @@ -41,6 +41,29 @@ impl WindowsSandboxRequestProcessor { request_id: &ConnectionRequestId, params: WindowsSandboxSetupStartParams, ) -> Result<(), JSONRPCErrorError> { + // Validate requirements before acknowledging setup so callers do not get a + // `started` response for a Windows sandbox mode that cannot be persisted. + let command_cwd = params + .cwd + .map(PathBuf::from) + .unwrap_or_else(|| self.config.cwd.to_path_buf()); + let config = self + .config_manager + .load_for_cwd( + /*request_overrides*/ None, + ConfigOverrides { + cwd: Some(command_cwd.clone()), + ..Default::default() + }, + Some(command_cwd.clone()), + ) + .await + .map_err(|err| config_load_error(&err))?; + let setup_mode = resolve_allowed_windows_sandbox_setup_mode( + config.config_layer_stack.requirements(), + params.mode, + )?; + self.outgoing .send_response( request_id.clone(), @@ -48,49 +71,22 @@ impl WindowsSandboxRequestProcessor { ) .await; - let mode = match params.mode { - WindowsSandboxSetupMode::Elevated => CoreWindowsSandboxSetupMode::Elevated, - WindowsSandboxSetupMode::Unelevated => CoreWindowsSandboxSetupMode::Unelevated, - }; - let config = Arc::clone(&self.config); - let config_manager = self.config_manager.clone(); - let command_cwd = params - .cwd - .map(PathBuf::from) - .unwrap_or_else(|| config.cwd.to_path_buf()); let outgoing = Arc::clone(&self.outgoing); let connection_id = request_id.connection_id; tokio::spawn(async move { - let derived_config = config_manager - .load_for_cwd( - /*request_overrides*/ None, - ConfigOverrides { - cwd: Some(command_cwd.clone()), - ..Default::default() - }, - Some(command_cwd.clone()), - ) - .await; - let setup_result = match derived_config { - Ok(config) => { - let setup_request = WindowsSandboxSetupRequest { - mode, - policy: config - .permissions - .legacy_sandbox_policy(config.cwd.as_path()), - policy_cwd: config.cwd.to_path_buf(), - command_cwd, - env_map: std::env::vars().collect(), - codex_home: config.codex_home.to_path_buf(), - active_profile: config.active_profile.clone(), - }; - codex_core::windows_sandbox::run_windows_sandbox_setup(setup_request).await - } - Err(err) => Err(err.into()), + let setup_request = WindowsSandboxSetupRequest { + mode: setup_mode, + permission_profile: config.permissions.effective_permission_profile(), + workspace_roots: config.effective_workspace_roots(), + command_cwd, + env_map: std::env::vars().collect(), + codex_home: config.codex_home.to_path_buf(), }; + let setup_result = + codex_core::windows_sandbox::run_windows_sandbox_setup(setup_request).await; let notification = WindowsSandboxSetupCompletedNotification { - mode: match mode { + mode: match setup_mode { CoreWindowsSandboxSetupMode::Elevated => WindowsSandboxSetupMode::Elevated, CoreWindowsSandboxSetupMode::Unelevated => WindowsSandboxSetupMode::Unelevated, }, @@ -108,6 +104,28 @@ impl WindowsSandboxRequestProcessor { } } +/// Resolves the requested API mode after checking that managed requirements allow it. +fn resolve_allowed_windows_sandbox_setup_mode( + requirements: &codex_config::ConfigRequirements, + requested_mode: WindowsSandboxSetupMode, +) -> Result { + let (setup_mode, config_mode) = match requested_mode { + WindowsSandboxSetupMode::Elevated => ( + CoreWindowsSandboxSetupMode::Elevated, + codex_config::types::WindowsSandboxModeToml::Elevated, + ), + WindowsSandboxSetupMode::Unelevated => ( + CoreWindowsSandboxSetupMode::Unelevated, + codex_config::types::WindowsSandboxModeToml::Unelevated, + ), + }; + requirements + .windows_sandbox_mode + .can_set(&Some(config_mode)) + .map_err(|err| invalid_request(format!("invalid Windows sandbox setup mode: {err}")))?; + Ok(setup_mode) +} + fn determine_windows_sandbox_readiness(config: &Config) -> WindowsSandboxReadinessResponse { if !cfg!(windows) { return WindowsSandboxReadinessResponse { @@ -143,6 +161,34 @@ fn determine_windows_sandbox_readiness_from_state( #[cfg(test)] mod tests { use super::*; + use crate::error_code::INVALID_REQUEST_ERROR_CODE; + use codex_config::ConfigRequirements; + use codex_config::Constrained; + use codex_config::ConstrainedWithSource; + use codex_config::types::WindowsSandboxModeToml; + + #[test] + fn resolve_allowed_windows_sandbox_setup_mode_rejects_disallowed_mode() { + let requirements = ConfigRequirements { + windows_sandbox_mode: ConstrainedWithSource::new( + Constrained::allow_only(Some(WindowsSandboxModeToml::Elevated)), + /*source*/ None, + ), + ..Default::default() + }; + + let err = resolve_allowed_windows_sandbox_setup_mode( + &requirements, + WindowsSandboxSetupMode::Unelevated, + ) + .expect_err("unelevated setup should be rejected"); + + assert_eq!(err.code, INVALID_REQUEST_ERROR_CODE); + assert!( + err.message.contains("invalid Windows sandbox setup mode"), + "{err:?}" + ); + } #[test] fn determine_windows_sandbox_readiness_reports_not_configured_when_disabled() { diff --git a/codex-rs/app-server/src/skills_watcher.rs b/codex-rs/app-server/src/skills_watcher.rs index d20628d386..57edb89c7f 100644 --- a/codex-rs/app-server/src/skills_watcher.rs +++ b/codex-rs/app-server/src/skills_watcher.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use std::sync::Mutex; use std::time::Duration; use crate::outgoing_message::OutgoingMessageSender; @@ -15,6 +16,9 @@ use codex_file_watcher::ThrottledWatchReceiver; use codex_file_watcher::WatchPath; use codex_file_watcher::WatchRegistration; use codex_protocol::protocol::TurnEnvironmentSelection; +use codex_utils_absolute_path::AbsolutePathBuf; +use tokio_util::sync::CancellationToken; +use tokio_util::sync::DropGuard; use tracing::warn; #[cfg(not(test))] @@ -24,6 +28,9 @@ const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_millis(50); pub(crate) struct SkillsWatcher { subscriber: FileWatcherSubscriber, + runtime_extra_roots_registration: Mutex, + shutdown_token: CancellationToken, + _shutdown_drop_guard: DropGuard, } impl SkillsWatcher { @@ -39,8 +46,35 @@ impl SkillsWatcher { } }; let (subscriber, rx) = file_watcher.add_subscriber(); - Self::spawn_event_loop(rx, skills_manager, outgoing); - Arc::new(Self { subscriber }) + let shutdown_token = CancellationToken::new(); + let shutdown_drop_guard = shutdown_token.clone().drop_guard(); + Self::spawn_event_loop(rx, skills_manager, outgoing, shutdown_token.child_token()); + Arc::new(Self { + subscriber, + runtime_extra_roots_registration: Mutex::new(WatchRegistration::default()), + shutdown_token, + _shutdown_drop_guard: shutdown_drop_guard, + }) + } + + pub(crate) fn shutdown(&self) { + self.shutdown_token.cancel(); + } + + pub(crate) fn register_runtime_extra_roots(&self, extra_roots: &[AbsolutePathBuf]) { + let roots = extra_roots + .iter() + .map(|root| WatchPath { + path: root.clone().into_path_buf(), + recursive: true, + }) + .collect(); + let registration = self.subscriber.register_paths(roots); + let mut guard = self + .runtime_extra_roots_registration + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *guard = registration; } pub(crate) async fn register_thread_config( @@ -92,6 +126,7 @@ impl SkillsWatcher { rx: Receiver, skills_manager: Arc, outgoing: Arc, + shutdown_token: CancellationToken, ) { let mut rx = ThrottledWatchReceiver::new(rx, WATCHER_THROTTLE_INTERVAL); let Ok(handle) = tokio::runtime::Handle::try_current() else { @@ -99,7 +134,14 @@ impl SkillsWatcher { return; }; handle.spawn(async move { - while rx.recv().await.is_some() { + loop { + let event = tokio::select! { + _ = shutdown_token.cancelled() => break, + event = rx.recv() => event, + }; + if event.is_none() { + break; + } skills_manager.clear_cache(); outgoing .send_server_notification(ServerNotification::SkillsChanged( diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index 32dfcc325d..2d932f0326 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -3,6 +3,7 @@ use crate::outgoing_message::ConnectionRequestId; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadGoal; use codex_app_server_protocol::ThreadHistoryBuilder; +use codex_app_server_protocol::ThreadSettings; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnError; use codex_core::CodexThread; @@ -34,6 +35,8 @@ pub(crate) struct PendingThreadResumeRequest { pub(crate) emit_thread_goal_update: bool, pub(crate) thread_goal_state_db: Option, pub(crate) include_turns: bool, + pub(crate) initial_turns_page: + Option, pub(crate) redact_resume_payloads: bool, } @@ -76,6 +79,7 @@ pub(crate) struct ThreadState { pub(crate) cancel_tx: Option>, pub(crate) experimental_raw_events: bool, pub(crate) listener_generation: u64, + last_thread_settings: Option, listener_command_tx: Option>, current_turn_history: ThreadHistoryBuilder, listener_thread: Option>, @@ -95,11 +99,13 @@ impl ThreadState { cancel_tx: oneshot::Sender<()>, conversation: &Arc, watch_registration: WatchRegistration, + thread_settings_baseline: ThreadSettings, ) -> (mpsc::UnboundedReceiver, u64) { if let Some(previous) = self.cancel_tx.replace(cancel_tx) { let _ = previous.send(()); } self.listener_generation = self.listener_generation.wrapping_add(1); + self.last_thread_settings = Some(thread_settings_baseline); let (listener_command_tx, listener_command_rx) = mpsc::unbounded_channel(); self.listener_command_tx = Some(listener_command_tx); self.listener_thread = Some(Arc::downgrade(conversation)); @@ -143,6 +149,12 @@ impl ThreadState { self.current_turn_history.reset(); } } + + pub(crate) fn note_thread_settings(&mut self, thread_settings: ThreadSettings) -> bool { + let changed = self.last_thread_settings.as_ref() != Some(&thread_settings); + self.last_thread_settings = Some(thread_settings); + changed + } } pub(crate) async fn resolve_server_request_on_thread_listener( @@ -177,6 +189,60 @@ pub(crate) async fn resolve_server_request_on_thread_listener( } } +#[cfg(test)] +mod tests { + use super::*; + use codex_app_server_protocol::ApprovalsReviewer; + use codex_app_server_protocol::AskForApproval; + use codex_app_server_protocol::SandboxPolicy; + use codex_protocol::config_types::CollaborationMode; + use codex_protocol::config_types::ModeKind; + use codex_protocol::config_types::Settings; + use pretty_assertions::assert_eq; + + #[test] + fn note_thread_settings_reports_only_effective_changes() { + let mut state = ThreadState::default(); + let initial = thread_settings("mock-model"); + let updated = thread_settings("mock-model-2"); + + let results = vec![ + state.note_thread_settings(initial.clone()), + state.note_thread_settings(initial), + state.note_thread_settings(updated.clone()), + state.note_thread_settings(updated), + ]; + + assert_eq!(results, vec![true, false, true, false]); + } + + fn thread_settings(model: &str) -> ThreadSettings { + ThreadSettings { + cwd: AbsolutePathBuf::from_absolute_path("/tmp").expect("absolute path"), + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::ReadOnly { + network_access: false, + }, + active_permission_profile: None, + model: model.to_string(), + model_provider: "mock_provider".to_string(), + service_tier: None, + effort: None, + summary: None, + collaboration_mode: CollaborationMode { + mode: ModeKind::Default, + settings: Settings { + model: model.to_string(), + reasoning_effort: None, + developer_instructions: None, + }, + }, + personality: None, + } + } +} + struct ThreadEntry { state: Arc>, connection_ids: HashSet, diff --git a/codex-rs/app-server/src/thread_status.rs b/codex-rs/app-server/src/thread_status.rs index a670411db5..bd7e83e8c8 100644 --- a/codex-rs/app-server/src/thread_status.rs +++ b/codex-rs/app-server/src/thread_status.rs @@ -891,6 +891,7 @@ mod tests { id: thread_id.to_string(), session_id: thread_id.to_string(), forked_from_id: None, + parent_thread_id: None, preview: String::new(), ephemeral: false, model_provider: "mock-provider".to_string(), diff --git a/codex-rs/app-server/src/transport.rs b/codex-rs/app-server/src/transport.rs index f7222bd28f..2be5b55414 100644 --- a/codex-rs/app-server/src/transport.rs +++ b/codex-rs/app-server/src/transport.rs @@ -22,8 +22,11 @@ pub(crate) use codex_app_server_transport::RemoteControlHandle; pub(crate) use codex_app_server_transport::RemoteControlStartConfig; pub(crate) use codex_app_server_transport::RemoteControlUnavailable; pub(crate) use codex_app_server_transport::TransportEvent; +pub(crate) use codex_app_server_transport::acquire_app_server_startup_lock; pub use codex_app_server_transport::app_server_control_socket_path; +pub(crate) use codex_app_server_transport::app_server_startup_lock_path; pub use codex_app_server_transport::auth; +pub(crate) use codex_app_server_transport::prepare_control_socket_path; pub(crate) use codex_app_server_transport::start_control_socket_acceptor; pub(crate) use codex_app_server_transport::start_remote_control; pub(crate) use codex_app_server_transport::start_stdio_connection; diff --git a/codex-rs/app-server/src/transport_tests.rs b/codex-rs/app-server/src/transport_tests.rs index 5790e46a17..57a6c4c454 100644 --- a/codex-rs/app-server/src/transport_tests.rs +++ b/codex-rs/app-server/src/transport_tests.rs @@ -2,9 +2,8 @@ use super::*; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; -use codex_app_server_protocol::ThreadGoal; -use codex_app_server_protocol::ThreadGoalStatus; -use codex_app_server_protocol::ThreadGoalUpdatedNotification; +use codex_app_server_protocol::ThreadRealtimeStartedNotification; +use codex_protocol::protocol::RealtimeConversationVersion; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::json; @@ -15,20 +14,11 @@ fn absolute_path(path: &str) -> AbsolutePathBuf { AbsolutePathBuf::from_absolute_path(path).expect("absolute path") } -fn thread_goal_updated_notification() -> ServerNotification { - ServerNotification::ThreadGoalUpdated(ThreadGoalUpdatedNotification { +fn thread_realtime_started_notification() -> ServerNotification { + ServerNotification::ThreadRealtimeStarted(ThreadRealtimeStartedNotification { thread_id: "thread-1".to_string(), - turn_id: None, - goal: ThreadGoal { - thread_id: "thread-1".to_string(), - objective: "ship goal mode".to_string(), - status: ThreadGoalStatus::Active, - token_budget: None, - tokens_used: 0, - time_used_seconds: 0, - created_at: 1, - updated_at: 1, - }, + realtime_session_id: None, + version: RealtimeConversationVersion::V1, }) } @@ -182,7 +172,7 @@ async fn experimental_notifications_are_dropped_without_capability() { &mut connections, OutgoingEnvelope::ToConnection { connection_id, - message: OutgoingMessage::AppServerNotification(thread_goal_updated_notification()), + message: OutgoingMessage::AppServerNotification(thread_realtime_started_notification()), write_complete_tx: None, }, ) @@ -215,7 +205,7 @@ async fn experimental_notifications_are_preserved_with_capability() { &mut connections, OutgoingEnvelope::ToConnection { connection_id, - message: OutgoingMessage::AppServerNotification(thread_goal_updated_notification()), + message: OutgoingMessage::AppServerNotification(thread_realtime_started_notification()), write_complete_tx: None, }, ) @@ -227,7 +217,7 @@ async fn experimental_notifications_are_preserved_with_capability() { .expect("experimental notification should reach opted-in client"); assert!(matches!( message.message, - OutgoingMessage::AppServerNotification(ServerNotification::ThreadGoalUpdated(_)) + OutgoingMessage::AppServerNotification(ServerNotification::ThreadRealtimeStarted(_)) )); } diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 6bb600bd82..2285907ad9 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -1,11 +1,11 @@ mod analytics_server; mod auth_fixtures; mod config; -mod mcp_process; mod mock_model_server; mod models_cache; mod responses; mod rollout; +mod test_app_server; pub use analytics_server::start_analytics_events_server; pub use auth_fixtures::ChatGptAuthFixture; @@ -24,9 +24,6 @@ pub use core_test_support::test_absolute_path; pub use core_test_support::test_path_buf_with_windows; pub use core_test_support::test_tmp_path; pub use core_test_support::test_tmp_path_buf; -pub use mcp_process::DEFAULT_CLIENT_NAME; -pub use mcp_process::DISABLE_PLUGIN_STARTUP_TASKS_ARG; -pub use mcp_process::McpProcess; pub use mock_model_server::create_mock_responses_server_repeating_assistant; pub use mock_model_server::create_mock_responses_server_sequence; pub use mock_model_server::create_mock_responses_server_sequence_unchecked; @@ -38,12 +35,16 @@ pub use responses::create_final_assistant_message_sse_response; pub use responses::create_request_permissions_sse_response; pub use responses::create_request_user_input_sse_response; pub use responses::create_shell_command_sse_response; +pub use rollout::create_fake_parented_rollout_with_source; pub use rollout::create_fake_rollout; pub use rollout::create_fake_rollout_with_source; pub use rollout::create_fake_rollout_with_text_elements; pub use rollout::create_fake_rollout_with_token_usage; pub use rollout::rollout_path; use serde::de::DeserializeOwned; +pub use test_app_server::DEFAULT_CLIENT_NAME; +pub use test_app_server::DISABLE_PLUGIN_STARTUP_TASKS_ARG; +pub use test_app_server::TestAppServer; pub fn to_response(response: JSONRPCResponse) -> anyhow::Result { let value = serde_json::to_value(response.result)?; diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index be7d5d047f..56a9378662 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -30,6 +30,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { priority, additional_speed_tiers: preset.additional_speed_tiers.clone(), service_tiers: preset.service_tiers.clone(), + default_service_tier: preset.default_service_tier.clone(), upgrade: preset.upgrade.as_ref().map(Into::into), base_instructions: "base instructions".to_string(), model_messages: None, @@ -51,6 +52,8 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + auto_review_model_override: None, + tool_mode: None, } } diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index d570ed245c..b9d428fa3b 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -118,6 +118,53 @@ pub fn create_fake_rollout_with_source( model_provider: Option<&str>, git_info: Option, source: SessionSource, +) -> Result { + create_fake_rollout_with_source_and_parent_thread_id( + codex_home, + filename_ts, + meta_rfc3339, + preview, + model_provider, + git_info, + source, + /*parent_thread_id*/ None, + ) +} + +/// Create a minimal rollout file with an explicit session source and control parent. +#[allow(clippy::too_many_arguments)] +pub fn create_fake_parented_rollout_with_source( + codex_home: &Path, + filename_ts: &str, + meta_rfc3339: &str, + preview: &str, + model_provider: Option<&str>, + git_info: Option, + source: SessionSource, + parent_thread_id: ThreadId, +) -> Result { + create_fake_rollout_with_source_and_parent_thread_id( + codex_home, + filename_ts, + meta_rfc3339, + preview, + model_provider, + git_info, + source, + Some(parent_thread_id), + ) +} + +#[allow(clippy::too_many_arguments)] +fn create_fake_rollout_with_source_and_parent_thread_id( + codex_home: &Path, + filename_ts: &str, + meta_rfc3339: &str, + preview: &str, + model_provider: Option<&str>, + git_info: Option, + source: SessionSource, + parent_thread_id: Option, ) -> Result { let uuid = Uuid::new_v4(); let uuid_str = uuid.to_string(); @@ -133,6 +180,7 @@ pub fn create_fake_rollout_with_source( let meta = SessionMeta { id: conversation_id, forked_from_id: None, + parent_thread_id, timestamp: meta_rfc3339.to_string(), cwd: PathBuf::from("/"), originator: "codex".to_string(), @@ -218,6 +266,7 @@ pub fn create_fake_rollout_with_text_elements( let meta = SessionMeta { id: conversation_id, forked_from_id: None, + parent_thread_id: None, timestamp: meta_rfc3339.to_string(), cwd: PathBuf::from("/"), originator: "codex".to_string(), diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/test_app_server.rs similarity index 94% rename from codex-rs/app-server/tests/common/mcp_process.rs rename to codex-rs/app-server/tests/common/test_app_server.rs index 19f3b5b85e..3a3787d234 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -56,7 +56,9 @@ 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; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginSkillReadParams; @@ -69,6 +71,7 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewStartParams; use codex_app_server_protocol::SendAddCreditsNudgeEmailParams; use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::SkillsExtraRootsSetParams; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::ThreadArchiveParams; use codex_app_server_protocol::ThreadCompactStartParams; @@ -86,7 +89,9 @@ use codex_app_server_protocol::ThreadRealtimeStartParams; use codex_app_server_protocol::ThreadRealtimeStopParams; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadRollbackParams; +use codex_app_server_protocol::ThreadSearchParams; use codex_app_server_protocol::ThreadSetNameParams; +use codex_app_server_protocol::ThreadSettingsUpdateParams; use codex_app_server_protocol::ThreadShellCommandParams; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadTurnsItemsListParams; @@ -101,7 +106,7 @@ use codex_app_server_protocol::WindowsSandboxSetupStartParams; use codex_login::default_client::CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR; use tokio::process::Command; -pub struct McpProcess { +pub struct TestAppServer { next_request_id: AtomicI64, /// Retain this child process until the client is dropped. The Tokio runtime /// will make a "best effort" to reap the process after it exits, but it is @@ -117,7 +122,7 @@ pub const DEFAULT_CLIENT_NAME: &str = "codex-app-server-tests"; pub const DISABLE_PLUGIN_STARTUP_TASKS_ARG: &str = "--disable-plugin-startup-tasks-for-tests"; const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_CONFIG"; -impl McpProcess { +impl TestAppServer { pub async fn new(codex_home: &Path) -> anyhow::Result { Self::new_with_env_and_args(codex_home, &[], &[DISABLE_PLUGIN_STARTUP_TASKS_ARG]).await } @@ -169,6 +174,20 @@ impl McpProcess { .await } + pub async fn new_with_program_and_env( + codex_home: &Path, + program: &Path, + env_overrides: &[(&str, Option<&str>)], + ) -> anyhow::Result { + Self::new_with_program_env_and_args( + codex_home, + program, + env_overrides, + &[DISABLE_PLUGIN_STARTUP_TASKS_ARG], + ) + .await + } + async fn new_with_env_and_args( codex_home: &Path, env_overrides: &[(&str, Option<&str>)], @@ -176,6 +195,15 @@ impl McpProcess { ) -> anyhow::Result { let program = codex_utils_cargo_bin::cargo_bin("codex-app-server") .context("should find binary for codex-app-server")?; + Self::new_with_program_env_and_args(codex_home, &program, env_overrides, args).await + } + + async fn new_with_program_env_and_args( + codex_home: &Path, + program: &Path, + env_overrides: &[(&str, Option<&str>)], + args: &[&str], + ) -> anyhow::Result { let mut cmd = Command::new(program); cmd.stdin(Stdio::piped()); @@ -442,6 +470,15 @@ impl McpProcess { self.send_request("thread/metadata/update", params).await } + /// Send a `thread/settings/update` JSON-RPC request. + pub async fn send_thread_settings_update_request( + &mut self, + params: ThreadSettingsUpdateParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/settings/update", params).await + } + /// Send a `thread/unsubscribe` JSON-RPC request. pub async fn send_thread_unsubscribe_request( &mut self, @@ -496,6 +533,15 @@ impl McpProcess { self.send_request("thread/list", params).await } + /// Send a `thread/search` JSON-RPC request. + pub async fn send_thread_search_request( + &mut self, + params: ThreadSearchParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/search", params).await + } + /// Send a `thread/loaded/list` JSON-RPC request. pub async fn send_thread_loaded_list_request( &mut self, @@ -560,6 +606,15 @@ impl McpProcess { self.send_request("experimentalFeature/list", params).await } + /// Send a `permissionProfile/list` JSON-RPC request. + pub async fn send_permission_profile_list_request( + &mut self, + params: PermissionProfileListParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("permissionProfile/list", params).await + } + /// Send an `experimentalFeature/enablement/set` JSON-RPC request. pub async fn send_experimental_feature_enablement_set_request( &mut self, @@ -582,6 +637,12 @@ impl McpProcess { .await } + /// Send a `remoteControl/status/read` JSON-RPC request. + pub async fn send_remote_control_status_read_request(&mut self) -> anyhow::Result { + self.send_request("remoteControl/status/read", /*params*/ None) + .await + } + /// Send an `app/list` JSON-RPC request. pub async fn send_apps_list_request(&mut self, params: AppsListParams) -> anyhow::Result { let params = Some(serde_json::to_value(params)?); @@ -615,6 +676,15 @@ impl McpProcess { self.send_request("skills/list", params).await } + /// Send a `skills/extraRoots/set` JSON-RPC request. + pub async fn send_skills_extra_roots_set_request( + &mut self, + params: SkillsExtraRootsSetParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("skills/extraRoots/set", params).await + } + /// Send a `hooks/list` JSON-RPC request. pub async fn send_hooks_list_request( &mut self, @@ -678,6 +748,15 @@ impl McpProcess { self.send_request("plugin/list", params).await } + /// Send a `plugin/installed` JSON-RPC request. + pub async fn send_plugin_installed_request( + &mut self, + params: PluginInstalledParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("plugin/installed", params).await + } + /// Send a `plugin/read` JSON-RPC request. pub async fn send_plugin_read_request( &mut self, @@ -1434,7 +1513,7 @@ impl McpProcess { } } -impl Drop for McpProcess { +impl Drop for TestAppServer { fn drop(&mut self) { // These tests spawn a `codex-app-server` child process. // diff --git a/codex-rs/app-server/tests/suite/auth.rs b/codex-rs/app-server/tests/suite/auth.rs index 1e60871012..b1ef99cb71 100644 --- a/codex-rs/app-server/tests/suite/auth.rs +++ b/codex-rs/app-server/tests/suite/auth.rs @@ -1,6 +1,6 @@ use anyhow::Result; use app_test_support::ChatGptAuthFixture; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use chrono::Duration; @@ -92,7 +92,7 @@ shell_snapshot = false std::fs::write(config_toml, contents) } -async fn login_with_api_key_via_request(mcp: &mut McpProcess, api_key: &str) -> Result<()> { +async fn login_with_api_key_via_request(mcp: &mut TestAppServer, api_key: &str) -> Result<()> { let request_id = mcp.send_login_account_api_key_request(api_key).await?; let resp: JSONRPCResponse = timeout( @@ -110,7 +110,8 @@ async fn get_auth_status_no_auth() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path())?; - let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -136,7 +137,7 @@ async fn get_auth_status_with_api_key() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; login_with_api_key_via_request(&mut mcp, "sk-test-key").await?; @@ -164,7 +165,7 @@ async fn get_auth_status_with_api_key_when_auth_not_required() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml_custom_provider(codex_home.path(), /*requires_openai_auth*/ false)?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; login_with_api_key_via_request(&mut mcp, "sk-test-key").await?; @@ -197,7 +198,7 @@ async fn get_auth_status_with_api_key_no_include_token() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; login_with_api_key_via_request(&mut mcp, "sk-test-key").await?; @@ -225,7 +226,7 @@ async fn get_auth_status_with_api_key_refresh_requested() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; login_with_api_key_via_request(&mut mcp, "sk-test-key").await?; @@ -281,7 +282,7 @@ async fn get_auth_status_omits_token_after_permanent_refresh_failure() -> Result .await; let refresh_url = format!("{}/oauth/token", server.uri()); - let mut mcp = McpProcess::new_with_env( + let mut mcp = TestAppServer::new_with_env( codex_home.path(), &[ ("OPENAI_API_KEY", None), @@ -363,7 +364,7 @@ async fn get_auth_status_omits_token_after_proactive_refresh_failure() -> Result .await; let refresh_url = format!("{}/oauth/token", server.uri()); - let mut mcp = McpProcess::new_with_env( + let mut mcp = TestAppServer::new_with_env( codex_home.path(), &[ ("OPENAI_API_KEY", None), @@ -430,7 +431,7 @@ async fn get_auth_status_returns_token_after_proactive_refresh_recovery() -> Res .await; let refresh_url = format!("{}/oauth/token", server.uri()); - let mut mcp = McpProcess::new_with_env( + let mut mcp = TestAppServer::new_with_env( codex_home.path(), &[ ("OPENAI_API_KEY", None), @@ -507,7 +508,7 @@ async fn login_api_key_rejected_when_forced_chatgpt() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml_forced_login(codex_home.path(), "chatgpt")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index 5253850107..4ca483c883 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_fake_rollout; use app_test_support::rollout_path; use app_test_support::to_response; @@ -93,7 +93,7 @@ async fn get_conversation_summary_by_thread_id_reads_rollout() -> Result<()> { ))?, ); - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -124,6 +124,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() -> .create_thread(CreateThreadParams { thread_id, forked_from_id: None, + parent_thread_id: None, source: SessionSource::Cli, thread_source: None, base_instructions: BaseInstructions::default(), @@ -211,7 +212,7 @@ async fn get_conversation_summary_by_relative_rollout_path_resolves_from_codex_h let relative_path = rollout_path.strip_prefix(codex_home.path())?.to_path_buf(); let expected = expected_summary(thread_id, normalized_canonical_path(rollout_path)?); - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp diff --git a/codex-rs/app-server/tests/suite/fuzzy_file_search.rs b/codex-rs/app-server/tests/suite/fuzzy_file_search.rs index f508e0c987..2c30d05fde 100644 --- a/codex-rs/app-server/tests/suite/fuzzy_file_search.rs +++ b/codex-rs/app-server/tests/suite/fuzzy_file_search.rs @@ -1,6 +1,6 @@ use anyhow::Result; use anyhow::anyhow; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use codex_app_server_protocol::FuzzyFileSearchSessionCompletedNotification; use codex_app_server_protocol::FuzzyFileSearchSessionUpdatedNotification; use codex_app_server_protocol::JSONRPCResponse; @@ -44,15 +44,15 @@ shell_snapshot = false ) } -async fn initialized_mcp(codex_home: &TempDir) -> Result { +async fn initialized_mcp(codex_home: &TempDir) -> Result { create_config_toml(codex_home.path())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; Ok(mcp) } async fn wait_for_session_updated( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, session_id: &str, query: &str, file_expectation: FileExpectation, @@ -99,7 +99,7 @@ async fn wait_for_session_updated( } async fn wait_for_session_completed( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, session_id: &str, ) -> Result { let description = format!("session completion for sessionId={session_id}"); @@ -140,7 +140,7 @@ async fn wait_for_session_completed( } async fn assert_update_request_fails_for_missing_session( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, session_id: &str, query: &str, ) -> Result<()> { @@ -161,7 +161,7 @@ async fn assert_update_request_fails_for_missing_session( } async fn assert_no_session_updates_for( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, session_id: &str, grace_period: std::time::Duration, duration: std::time::Duration, @@ -236,7 +236,7 @@ async fn test_fuzzy_file_search_sorts_and_includes_indices() -> Result<()> { .to_string(); // Start MCP server and initialize. - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let root_path = root.path().to_string_lossy().to_string(); @@ -302,7 +302,7 @@ async fn test_fuzzy_file_search_accepts_cancellation_token() -> Result<()> { std::fs::write(root.path().join("alpha.txt"), "contents")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let root_path = root.path().to_string_lossy().to_string(); diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index a15b46abd9..1b95bc7d5b 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -1,6 +1,6 @@ use anyhow::Result; use anyhow::bail; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::to_response; use app_test_support::ChatGptAuthFixture; @@ -201,7 +201,8 @@ async fn logout_account_removes_auth_and_notifies() -> Result<()> { )?; assert!(codex_home.path().join("auth.json").exists()); - let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let id = mcp.send_logout_account_request().await?; @@ -268,7 +269,8 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> { .chatgpt_account_id(WORKSPACE_ID_EMBEDDED), )?; - let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let set_id = mcp @@ -342,7 +344,8 @@ async fn account_read_refresh_token_is_noop_in_external_mode() -> Result<()> { .chatgpt_account_id(WORKSPACE_ID_EMBEDDED), )?; - let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let set_id = mcp @@ -401,7 +404,7 @@ async fn account_read_refresh_token_is_noop_in_external_mode() -> Result<()> { } async fn respond_to_refresh_request( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, access_token: &str, chatgpt_account_id: &str, chatgpt_plan_type: Option<&str>, @@ -467,7 +470,8 @@ async fn external_auth_refreshes_on_unauthorized() -> Result<()> { .chatgpt_account_id(WORKSPACE_ID_REFRESHED), )?; - let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let set_id = mcp @@ -506,6 +510,7 @@ async fn external_auth_refreshes_on_unauthorized() -> Result<()> { let turn_req = mcp .send_turn_start_request(codex_app_server_protocol::TurnStartParams { thread_id: thread.thread.id, + client_user_message_id: None, input: vec![codex_app_server_protocol::UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -573,7 +578,8 @@ async fn external_auth_refresh_error_fails_turn() -> Result<()> { .chatgpt_account_id(WORKSPACE_ID_INITIAL), )?; - let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let set_id = mcp @@ -612,6 +618,7 @@ async fn external_auth_refresh_error_fails_turn() -> Result<()> { let turn_req = mcp .send_turn_start_request(codex_app_server_protocol::TurnStartParams { thread_id: thread.thread.id.clone(), + client_user_message_id: None, input: vec![codex_app_server_protocol::UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -695,7 +702,8 @@ async fn external_auth_refresh_mismatched_workspace_fails_turn() -> Result<()> { .chatgpt_account_id(WORKSPACE_ID_DISALLOWED), )?; - let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let set_id = mcp @@ -734,6 +742,7 @@ async fn external_auth_refresh_mismatched_workspace_fails_turn() -> Result<()> { let turn_req = mcp .send_turn_start_request(codex_app_server_protocol::TurnStartParams { thread_id: thread.thread.id.clone(), + client_user_message_id: None, input: vec![codex_app_server_protocol::UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -810,7 +819,8 @@ async fn external_auth_refresh_invalid_access_token_fails_turn() -> Result<()> { .chatgpt_account_id(WORKSPACE_ID_INITIAL), )?; - let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let set_id = mcp @@ -849,6 +859,7 @@ async fn external_auth_refresh_invalid_access_token_fails_turn() -> Result<()> { let turn_req = mcp .send_turn_start_request(codex_app_server_protocol::TurnStartParams { thread_id: thread.thread.id.clone(), + client_user_message_id: None, input: vec![codex_app_server_protocol::UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -902,7 +913,7 @@ async fn login_account_api_key_succeeds_and_notifies() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -956,7 +967,7 @@ async fn login_account_api_key_rejected_when_forced_chatgpt() -> Result<()> { }, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -986,7 +997,7 @@ async fn login_account_chatgpt_rejected_when_forced_api() -> Result<()> { }, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_login_account_chatgpt_request().await?; @@ -1019,7 +1030,7 @@ async fn login_account_chatgpt_device_code_returns_error_when_disabled() -> Resu mock_device_code_usercode_failure(&mock_server, /*status*/ 404).await; let issuer = mock_server.uri(); - let mut mcp = McpProcess::new_with_env( + let mut mcp = TestAppServer::new_with_env( codex_home.path(), &[ ("OPENAI_API_KEY", None), @@ -1084,7 +1095,7 @@ async fn login_account_chatgpt_device_code_succeeds_and_notifies() -> Result<()> mock_device_code_oauth_token(&mock_server, &id_token).await; let issuer = mock_server.uri(); - let mut mcp = McpProcess::new_with_env( + let mut mcp = TestAppServer::new_with_env( codex_home.path(), &[ ("OPENAI_API_KEY", None), @@ -1161,7 +1172,7 @@ async fn login_account_chatgpt_device_code_failure_notifies_without_account_upda mock_device_code_token_failure(&mock_server, /*status*/ 500).await; let issuer = mock_server.uri(); - let mut mcp = McpProcess::new_with_env( + let mut mcp = TestAppServer::new_with_env( codex_home.path(), &[ ("OPENAI_API_KEY", None), @@ -1236,7 +1247,7 @@ async fn login_account_chatgpt_device_code_can_be_cancelled() -> Result<()> { mock_device_code_token_failure(&mock_server, /*status*/ 404).await; let issuer = mock_server.uri(); - let mut mcp = McpProcess::new_with_env( + let mut mcp = TestAppServer::new_with_env( codex_home.path(), &[ ("OPENAI_API_KEY", None), @@ -1309,7 +1320,7 @@ async fn login_account_chatgpt_start_can_be_cancelled() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_login_account_chatgpt_request().await?; @@ -1375,7 +1386,7 @@ async fn set_auth_token_cancels_active_chatgpt_login() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // Initiate the ChatGPT login flow @@ -1450,7 +1461,7 @@ async fn login_account_chatgpt_includes_forced_workspace_query_param() -> Result }, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_login_account_chatgpt_request().await?; @@ -1487,7 +1498,7 @@ async fn login_account_chatgpt_includes_forced_workspace_allowlist_query_param() }, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_login_account_chatgpt_request().await?; @@ -1526,7 +1537,8 @@ async fn get_account_no_auth() -> Result<()> { }, )?; - let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let params = GetAccountParams { @@ -1557,7 +1569,7 @@ async fn get_account_with_api_key() -> Result<()> { }, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -1601,7 +1613,7 @@ async fn get_account_when_auth_not_required() -> Result<()> { }, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let params = GetAccountParams { @@ -1642,7 +1654,7 @@ region = "us-west-2" }, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let params = GetAccountParams { @@ -1683,7 +1695,8 @@ async fn get_account_with_chatgpt() -> Result<()> { AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let params = GetAccountParams { @@ -1743,7 +1756,7 @@ async fn get_account_omits_chatgpt_after_permanent_refresh_failure() -> Result<( .await; let refresh_url = format!("{}/oauth/token", server.uri()); - let mut mcp = McpProcess::new_with_env( + let mut mcp = TestAppServer::new_with_env( codex_home.path(), &[ ("OPENAI_API_KEY", None), @@ -1809,7 +1822,8 @@ async fn get_account_with_chatgpt_missing_plan_claim_returns_unknown() -> Result AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let params = GetAccountParams { diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index c6f95af95d..8e8f2a7557 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -168,11 +168,13 @@ pub(crate) fn thread_initialized_event(payload: &Value) -> Result<&Value> { pub(crate) fn assert_basic_thread_initialized_event( event: &Value, thread_id: &str, + session_id: &str, expected_model: &str, initialization_mode: &str, expected_thread_source: &str, ) { assert_eq!(event["event_params"]["thread_id"], thread_id); + assert_eq!(event["event_params"]["session_id"], session_id); assert_eq!( event["event_params"]["app_server_client"]["product_client_id"], DEFAULT_CLIENT_NAME diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index 395dff5668..ea7ff8fa4b 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -8,7 +8,7 @@ use std::time::Duration; use anyhow::Result; use anyhow::bail; use app_test_support::ChatGptAuthFixture; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use axum::Json; @@ -63,7 +63,7 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); #[tokio::test] async fn list_apps_returns_empty_when_connectors_disabled() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -124,7 +124,7 @@ async fn list_apps_returns_empty_with_api_key_auth() -> Result<()> { AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -186,7 +186,7 @@ async fn list_apps_returns_empty_when_workspace_codex_plugins_disabled() -> Resu AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -245,7 +245,7 @@ async fn list_apps_uses_thread_feature_flag_when_thread_id_is_provided() -> Resu AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let start_request = mcp @@ -316,6 +316,73 @@ connectors = false Ok(()) } +#[tokio::test] +async fn list_apps_keeps_apps_with_app_only_tools_accessible() -> Result<()> { + let connectors = vec![AppInfo { + id: "beta".to_string(), + name: "Beta".to_string(), + description: Some("Beta connector".to_string()), + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: false, + is_enabled: true, + plugin_display_names: Vec::new(), + }]; + let mut app_only_tool = connector_tool("beta", "Beta App")?; + app_only_tool + .meta + .as_mut() + .expect("connector tool should include metadata") + .0 + .insert("ui".to_string(), json!({ "visibility": ["app"] })); + let tools = vec![app_only_tool]; + let (server_url, server_handle) = + start_apps_server_with_delays(connectors, tools, Duration::ZERO, Duration::ZERO).await?; + + let codex_home = TempDir::new()?; + write_connectors_config(codex_home.path(), &server_url)?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-app-only") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_apps_list_request(AppsListParams { + limit: None, + cursor: None, + thread_id: None, + force_refetch: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let AppsListResponse { data, next_cursor } = to_response(response)?; + + assert_eq!(data.len(), 1); + assert_eq!(data[0].id, "beta"); + assert!(data[0].is_accessible); + assert!(next_cursor.is_none()); + + server_handle.abort(); + let _ = server_handle.await; + Ok(()) +} + #[tokio::test] async fn list_apps_reports_is_enabled_from_config() -> Result<()> { let connectors = vec![AppInfo { @@ -361,7 +428,7 @@ enabled = false AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -480,7 +547,7 @@ async fn list_apps_emits_updates_and_returns_after_both_lists_load() -> Result<( AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -620,7 +687,7 @@ async fn list_apps_waits_for_accessible_data_before_emitting_directory_updates() AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -726,7 +793,7 @@ async fn list_apps_does_not_emit_empty_interim_updates() -> Result<()> { AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -835,7 +902,7 @@ async fn list_apps_paginates_results() -> Result<()> { AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let first_request = mcp @@ -955,7 +1022,7 @@ async fn list_apps_force_refetch_preserves_previous_cache_on_failure() -> Result AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let initial_request = mcp @@ -1081,7 +1148,7 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let warm_request = mcp @@ -1304,7 +1371,7 @@ async fn experimental_feature_enablement_set_refreshes_apps_list_when_apps_turn_ AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let disable_request = mcp @@ -1371,7 +1438,7 @@ async fn experimental_feature_enablement_set_refreshes_apps_list_when_apps_turn_ } async fn read_app_list_updated_notification( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, ) -> Result { let notification = timeout( DEFAULT_TIMEOUT, @@ -1432,10 +1499,7 @@ impl AppsServerControl { impl ServerHandler for AppListMcpServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder().enable_tools().build(), - ..ServerInfo::default() - } + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) } fn list_tools( diff --git a/codex-rs/app-server/tests/suite/v2/attestation.rs b/codex-rs/app-server/tests/suite/v2/attestation.rs index d0565e2571..567f37397a 100644 --- a/codex-rs/app-server/tests/suite/v2/attestation.rs +++ b/codex-rs/app-server/tests/suite/v2/attestation.rs @@ -1,7 +1,7 @@ use anyhow::Result; use anyhow::bail; use app_test_support::ChatGptAuthFixture; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::AttestationGenerateResponse; @@ -76,7 +76,8 @@ async fn attestation_generate_round_trip_adds_header_to_responses_websocket_hand AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; let initialized = timeout( DEFAULT_READ_TIMEOUT, mcp.initialize_with_capabilities( @@ -110,6 +111,7 @@ async fn attestation_generate_round_trip_adds_header_to_responses_websocket_hand let turn_request_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/client_metadata.rs b/codex-rs/app-server/tests/suite/v2/client_metadata.rs index 8d68888e7e..873a1cc044 100644 --- a/codex-rs/app-server/tests/suite/v2/client_metadata.rs +++ b/codex-rs/app-server/tests/suite/v2/client_metadata.rs @@ -1,8 +1,20 @@ use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; +use app_test_support::create_fake_parented_rollout_with_source; +use app_test_support::create_fake_rollout; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ReviewDelivery; +use codex_app_server_protocol::ReviewStartParams; +use codex_app_server_protocol::ReviewStartResponse; +use codex_app_server_protocol::ReviewTarget; +use codex_app_server_protocol::SessionSource as ApiSessionSource; +use codex_app_server_protocol::ThreadForkParams; +use codex_app_server_protocol::ThreadForkResponse; +use codex_app_server_protocol::ThreadResumeParams; +use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadSource; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStartParams; @@ -10,6 +22,9 @@ use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput as V2UserInput; +use codex_protocol::ThreadId as CoreThreadId; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; @@ -44,7 +59,7 @@ async fn turn_start_forwards_client_metadata_to_responses_request_v2() -> Result /*supports_websockets*/ false, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -60,10 +75,12 @@ async fn turn_start_forwards_client_metadata_to_responses_request_v2() -> Result let client_metadata = HashMap::from([ ("fiber_run_id".to_string(), "fiber-start-123".to_string()), ("origin".to_string(), "gaas".to_string()), + ("thread_source".to_string(), "client-supplied".to_string()), ]); let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -93,8 +110,288 @@ async fn turn_start_forwards_client_metadata_to_responses_request_v2() -> Result .unwrap_or_else(|| panic!("missing x-codex-turn-metadata header")); assert_eq!(metadata["fiber_run_id"].as_str(), Some("fiber-start-123")); assert_eq!(metadata["origin"].as_str(), Some("gaas")); + assert_eq!(metadata["thread_source"].as_str(), Some("client-supplied")); assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str())); assert!(metadata.get("session_id").is_some()); + assert_eq!( + metadata["window_id"].as_str(), + request.header("x-codex-window-id").as_deref() + ); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_sends_fork_lineage_in_turn_metadata_for_thread_fork_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + /*supports_websockets*/ false, + )?; + + let source_thread_id = create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some("mock_provider"), + /*git_info*/ None, + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let ThreadForkResponse { thread, .. } = + fork_fake_rollout_thread(&mut mcp, source_thread_id.clone()).await?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: vec![V2UserInput::Text { + text: "Continue".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let request = response_mock.single_request(); + let metadata = request + .header("x-codex-turn-metadata") + .as_deref() + .map(parse_json_header) + .unwrap_or_else(|| panic!("missing x-codex-turn-metadata header")); + assert_eq!( + metadata["forked_from_thread_id"].as_str(), + Some(source_thread_id.as_str()) + ); + assert_eq!(metadata["thread_id"].as_str(), Some(thread.id.as_str())); + assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str())); + + Ok(()) +} + +#[tokio::test] +async fn review_start_sends_parent_lineage_in_turn_metadata_for_thread_fork_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let review_payload = serde_json::json!({ + "findings": [], + "overall_correctness": "good", + "overall_explanation": "Done", + "overall_confidence_score": 0.5 + }) + .to_string(); + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", &review_payload), + responses::ev_completed("resp-1"), + ]), + ) + .await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + /*supports_websockets*/ false, + )?; + + let source_thread_id = create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some("mock_provider"), + /*git_info*/ None, + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let ThreadForkResponse { thread, .. } = + fork_fake_rollout_thread(&mut mcp, source_thread_id.clone()).await?; + + let review_req = mcp + .send_review_start_request(ReviewStartParams { + thread_id: thread.id.clone(), + delivery: Some(ReviewDelivery::Inline), + target: ReviewTarget::Custom { + instructions: "Review the fork".to_string(), + }, + }) + .await?; + let review_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(review_req)), + ) + .await??; + let ReviewStartResponse { + review_thread_id, .. + } = to_response::(review_resp)?; + assert_eq!(review_thread_id, thread.id); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let request = response_mock.single_request(); + let metadata = request + .header("x-codex-turn-metadata") + .as_deref() + .map(parse_json_header) + .unwrap_or_else(|| panic!("missing x-codex-turn-metadata header")); + assert_eq!( + request.header("x-openai-subagent").as_deref(), + Some("review") + ); + assert!(metadata.get("forked_from_thread_id").is_none()); + assert_eq!( + metadata["parent_thread_id"].as_str(), + Some(review_thread_id.as_str()) + ); + let review_request_thread_id = metadata["thread_id"] + .as_str() + .unwrap_or_else(|| panic!("missing review request thread_id")); + assert!(review_request_thread_id != review_thread_id.as_str()); + assert_eq!( + request + .header("x-codex-window-id") + .as_deref() + .and_then(|window_id| window_id.split_once(':').map(|(thread_id, _)| thread_id)), + Some(review_request_thread_id) + ); + assert!(metadata["turn_id"].as_str().is_some()); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_sends_other_subagent_lineage_after_cold_thread_resume_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + /*supports_websockets*/ false, + )?; + + let parent_thread_id = CoreThreadId::new(); + let parent_thread_id_str = parent_thread_id.to_string(); + let subagent_thread_id = create_fake_parented_rollout_with_source( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved subagent message", + Some("mock_provider"), + /*git_info*/ None, + SessionSource::SubAgent(SubAgentSource::Other("guardian".to_string())), + parent_thread_id, + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let resume_req = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: subagent_thread_id.clone(), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_req)), + ) + .await??; + let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; + assert_eq!(thread.id, subagent_thread_id); + assert_eq!(thread.parent_thread_id, Some(parent_thread_id_str.clone())); + assert_eq!( + thread.source, + ApiSessionSource::SubAgent(SubAgentSource::Other("guardian".to_string())) + ); + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Continue".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let request = response_mock.single_request(); + let metadata = request + .header("x-codex-turn-metadata") + .as_deref() + .map(parse_json_header) + .unwrap_or_else(|| panic!("missing x-codex-turn-metadata header")); + assert_eq!( + metadata["parent_thread_id"].as_str(), + Some(parent_thread_id_str.as_str()) + ); + assert_eq!(metadata["subagent_kind"].as_str(), Some("guardian")); + assert_eq!(metadata["thread_id"].as_str(), Some(thread.id.as_str())); + assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str())); + assert!(metadata.get("forked_from_thread_id").is_none()); Ok(()) } @@ -126,7 +423,7 @@ async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2() /*supports_websockets*/ false, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -144,6 +441,7 @@ async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2() let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Run sleep".to_string(), text_elements: Vec::new(), @@ -174,11 +472,13 @@ async fn turn_steer_updates_client_metadata_on_follow_up_responses_request_v2() let steer_req = mcp .send_turn_steer_request(TurnSteerParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Focus on the failure".to_string(), text_elements: Vec::new(), }], responsesapi_client_metadata: Some(steer_metadata.clone()), + additional_context: None, expected_turn_id: turn_id.clone(), }) .await?; @@ -248,7 +548,7 @@ async fn turn_start_forwards_client_metadata_to_responses_websocket_request_body /*supports_websockets*/ true, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -268,6 +568,7 @@ async fn turn_start_forwards_client_metadata_to_responses_websocket_request_body let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -311,6 +612,10 @@ async fn turn_start_forwards_client_metadata_to_responses_websocket_request_body assert_eq!(metadata["origin"].as_str(), Some("gaas")); assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str())); assert!(metadata.get("session_id").is_some()); + assert_eq!( + metadata["window_id"].as_str(), + request["client_metadata"]["x-codex-window-id"].as_str() + ); websocket_server.shutdown().await; Ok(()) @@ -344,6 +649,25 @@ supports_websockets = {supports_websockets} ) } +async fn fork_fake_rollout_thread( + mcp: &mut TestAppServer, + source_thread_id: String, +) -> Result { + let fork_req = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: source_thread_id, + thread_source: Some(ThreadSource::User), + ..Default::default() + }) + .await?; + let fork_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(fork_req)), + ) + .await??; + to_response::(fork_resp) +} + fn parse_json_header(value: &str) -> serde_json::Value { match serde_json::from_str(value) { Ok(value) => value, diff --git a/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs b/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs index f5914f0449..31dd810fbd 100644 --- a/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs +++ b/codex-rs/app-server/tests/suite/v2/collaboration_mode_list.rs @@ -8,7 +8,7 @@ use std::time::Duration; use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::to_response; use codex_app_server_protocol::CollaborationModeListParams; use codex_app_server_protocol::CollaborationModeListResponse; @@ -28,7 +28,7 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); #[tokio::test] async fn list_collaboration_modes_returns_presets() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; diff --git a/codex-rs/app-server/tests/suite/v2/command_exec.rs b/codex-rs/app-server/tests/suite/v2/command_exec.rs index 2a4b8435ef..7cad6c0388 100644 --- a/codex-rs/app-server/tests/suite/v2/command_exec.rs +++ b/codex-rs/app-server/tests/suite/v2/command_exec.rs @@ -1,6 +1,6 @@ use anyhow::Context; use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::to_response; use base64::Engine; @@ -13,19 +13,15 @@ use codex_app_server_protocol::CommandExecResponse; use codex_app_server_protocol::CommandExecTerminalSize; use codex_app_server_protocol::CommandExecTerminateParams; use codex_app_server_protocol::CommandExecWriteParams; -use codex_app_server_protocol::FileSystemAccessMode; -use codex_app_server_protocol::FileSystemPath; -use codex_app_server_protocol::FileSystemSandboxEntry; -use codex_app_server_protocol::FileSystemSpecialPath; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCNotification; -use codex_app_server_protocol::PermissionProfile; -use codex_app_server_protocol::PermissionProfileFileSystemPermissions; -use codex_app_server_protocol::PermissionProfileNetworkPermissions; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy; +use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; use pretty_assertions::assert_eq; use std::collections::HashMap; +use std::path::Path; use tempfile::TempDir; use tokio::time::Duration; use tokio::time::Instant; @@ -46,7 +42,7 @@ async fn command_exec_without_streams_can_be_terminated() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let process_id = "sleep-1".to_string(); @@ -96,7 +92,7 @@ async fn command_exec_without_process_id_keeps_buffered_compatibility() -> Resul let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let command_request_id = mcp @@ -144,7 +140,7 @@ async fn command_exec_env_overrides_merge_with_server_environment_and_support_un let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = McpProcess::new_with_env( + let mut mcp = TestAppServer::new_with_env( codex_home.path(), &[("COMMAND_EXEC_BASELINE", Some("server"))], ) @@ -202,7 +198,7 @@ async fn command_exec_accepts_permission_profile() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let command_request_id = mcp @@ -224,7 +220,7 @@ async fn command_exec_accepts_permission_profile() -> Result<()> { env: None, size: None, sandbox_policy: None, - permission_profile: Some(root_read_only_permission_profile()), + permission_profile: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()), }) .await?; @@ -244,6 +240,105 @@ async fn command_exec_accepts_permission_profile() -> Result<()> { Ok(()) } +#[tokio::test] +async fn command_exec_permission_profile_starts_selected_network_proxy() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + insert_networked_permission_profile_config( + codex_home.path(), + /*default_permissions*/ None, + )?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "printf '%s' \"${CODEX_NETWORK_PROXY_ACTIVE-unset}\"".to_string(), + ], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: Some("networked".to_string()), + }) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!( + response, + CommandExecResponse { + exit_code: 0, + stdout: "1".to_string(), + stderr: String::new(), + } + ); + + Ok(()) +} + +#[tokio::test] +async fn command_exec_permission_profile_does_not_reuse_default_network_proxy() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + insert_networked_permission_profile_config(codex_home.path(), Some("networked"))?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec![ + "sh".to_string(), + "-lc".to_string(), + "printf '%s' \"${CODEX_NETWORK_PROXY_ACTIVE-unset}\"".to_string(), + ], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()), + }) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(command_request_id)) + .await?; + let response: CommandExecResponse = to_response(response)?; + assert_eq!( + response, + CommandExecResponse { + exit_code: 0, + stdout: "unset".to_string(), + stderr: String::new(), + } + ); + + Ok(()) +} + #[cfg(unix)] #[tokio::test] async fn command_exec_permission_profile_project_roots_use_command_cwd() -> Result<()> { @@ -252,23 +347,17 @@ async fn command_exec_permission_profile_project_roots_use_command_cwd() -> Resu let command_dir = codex_home.path().join("command-cwd"); std::fs::create_dir(&command_dir)?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + insert_command_exec_config( + codex_home.path(), + r#" +[permissions.command-cwd.filesystem] +":root" = "read" +":workspace_roots" = "write" +"#, + )?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - let mut permission_profile = root_read_only_permission_profile(); - let PermissionProfile::Managed { file_system, .. } = &mut permission_profile else { - panic!("root read-only helper should use managed permissions"); - }; - let PermissionProfileFileSystemPermissions::Restricted { entries, .. } = file_system else { - panic!("root read-only helper should use restricted filesystem permissions"); - }; - entries.push(FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::ProjectRoots { subpath: None }, - }, - access: FileSystemAccessMode::Write, - }); - let command_request_id = mcp .send_command_exec_request(CommandExecParams { command: vec![ @@ -288,7 +377,7 @@ async fn command_exec_permission_profile_project_roots_use_command_cwd() -> Resu env: None, size: None, sandbox_policy: None, - permission_profile: Some(permission_profile), + permission_profile: Some("command-cwd".to_string()), }) .await?; @@ -312,12 +401,51 @@ async fn command_exec_permission_profile_project_roots_use_command_cwd() -> Resu Ok(()) } +#[tokio::test] +async fn command_exec_returns_error_when_local_environment_is_disabled() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = TestAppServer::new_with_env( + codex_home.path(), + &[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let command_request_id = mcp + .send_command_exec_request(CommandExecParams { + command: vec!["sh".to_string(), "-lc".to_string(), "true".to_string()], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: None, + }) + .await?; + + let error = mcp + .read_stream_until_error_message(RequestId::Integer(command_request_id)) + .await?; + assert_eq!(error.error.message, "local environment is not configured"); + + Ok(()) +} + #[tokio::test] async fn command_exec_rejects_sandbox_policy_with_permission_profile() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let command_request_id = mcp @@ -335,7 +463,7 @@ async fn command_exec_rejects_sandbox_policy_with_permission_profile() -> Result env: None, size: None, sandbox_policy: Some(SandboxPolicy::DangerFullAccess), - permission_profile: Some(root_read_only_permission_profile()), + permission_profile: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()), }) .await?; @@ -355,7 +483,7 @@ async fn command_exec_rejects_disable_timeout_with_timeout_ms() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let command_request_id = mcp @@ -393,7 +521,7 @@ async fn command_exec_rejects_disable_output_cap_with_output_bytes_cap() -> Resu let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let command_request_id = mcp @@ -431,7 +559,7 @@ async fn command_exec_rejects_negative_timeout_ms() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let command_request_id = mcp @@ -469,7 +597,7 @@ async fn command_exec_without_process_id_rejects_streaming() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let command_request_id = mcp @@ -507,7 +635,7 @@ async fn command_exec_non_streaming_respects_output_cap() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let command_request_id = mcp @@ -554,7 +682,7 @@ async fn command_exec_streaming_does_not_buffer_output() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let process_id = "stream-cap-1".to_string(); @@ -618,7 +746,7 @@ async fn command_exec_pipe_streams_output_and_accepts_write() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let process_id = "pipe-1".to_string(); @@ -694,7 +822,7 @@ async fn command_exec_tty_implies_streaming_and_reports_pty_output() -> Result<( let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let process_id = "tty-1".to_string(); @@ -765,7 +893,7 @@ async fn command_exec_tty_supports_initial_size_and_resize() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let process_id = "tty-size-1".to_string(); @@ -934,7 +1062,7 @@ async fn command_exec_process_ids_are_connection_scoped_and_disconnect_terminate } async fn read_command_exec_delta( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, ) -> Result { let notification = mcp .read_stream_until_notification_message("command/exec/outputDelta") @@ -943,7 +1071,7 @@ async fn read_command_exec_delta( } async fn wait_for_command_exec_output_contains( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, process_id: &str, stream: CommandExecOutputStream, expected: &str, @@ -966,7 +1094,7 @@ async fn wait_for_command_exec_output_contains( } async fn wait_for_command_exec_outputs_contains( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, process_id: &str, stdout_expected: &str, stderr_expected: &str, @@ -984,7 +1112,7 @@ async fn wait_for_command_exec_outputs_contains( } enum CommandExecDeltaReader<'a> { - Mcp(&'a mut McpProcess), + Mcp(&'a mut TestAppServer), Websocket(&'a mut super::connection_handling_websocket::WsClient), } @@ -1061,19 +1189,41 @@ fn decode_delta_notification( serde_json::from_value(params).context("deserialize command/exec/outputDelta notification") } -fn root_read_only_permission_profile() -> PermissionProfile { - PermissionProfile::Managed { - network: PermissionProfileNetworkPermissions { enabled: false }, - file_system: PermissionProfileFileSystemPermissions::Restricted { - entries: vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - }], - glob_scan_max_depth: None, - }, - } +fn insert_networked_permission_profile_config( + codex_home: &Path, + default_permissions: Option<&str>, +) -> Result<()> { + let default_permissions = default_permissions + .map(|default_permissions| format!("default_permissions = \"{default_permissions}\"\n\n")) + .unwrap_or_default(); + let inserted_config = format!( + r#"{default_permissions}[features] +network_proxy = true + +[permissions.networked.filesystem] +":root" = "read" + +[permissions.networked.network] +enabled = true +proxy_url = "http://127.0.0.1:0" +enable_socks5 = false + +"# + ); + insert_command_exec_config(codex_home, &inserted_config)?; + Ok(()) +} + +fn insert_command_exec_config(codex_home: &Path, inserted_config: &str) -> Result<()> { + let config_path = codex_home.join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + let marker = "\n[model_providers.mock_provider]\n"; + let (prefix, suffix) = config + .split_once(marker) + .context("test config should include mock provider table")?; + let config = format!("{prefix}\n{inserted_config}{marker}{suffix}"); + std::fs::write(config_path, config)?; + Ok(()) } async fn read_initialize_response( diff --git a/codex-rs/app-server/tests/suite/v2/compaction.rs b/codex-rs/app-server/tests/suite/v2/compaction.rs index 6db031b278..690c6d8bff 100644 --- a/codex-rs/app-server/tests/suite/v2/compaction.rs +++ b/codex-rs/app-server/tests/suite/v2/compaction.rs @@ -9,7 +9,7 @@ use anyhow::Result; use app_test_support::ChatGptAuthFixture; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use app_test_support::write_mock_responses_config_toml; @@ -82,7 +82,7 @@ async fn auto_compaction_local_emits_started_and_completed_items() -> Result<()> COMPACT_PROMPT, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_id = start_thread(&mut mcp).await?; @@ -162,7 +162,8 @@ async fn auto_compaction_remote_emits_started_and_completed_items() -> Result<() AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_id = start_thread(&mut mcp).await?; @@ -190,6 +191,58 @@ async fn auto_compaction_remote_emits_started_and_completed_items() -> Result<() let response_requests = responses_log.requests(); assert_eq!(response_requests.len(), 3); + let turn_metadata = response_requests + .iter() + .map(|request| { + request + .header("x-codex-turn-metadata") + .as_deref() + .map(parse_json_header) + .unwrap_or_else(|| panic!("turn request should include turn metadata")) + }) + .collect::>(); + for (request, metadata) in response_requests.iter().zip(&turn_metadata) { + assert_eq!(metadata["request_kind"].as_str(), Some("turn")); + assert!( + metadata["turn_id"] + .as_str() + .is_some_and(|turn_id| !turn_id.is_empty()), + "turn request should carry a non-empty turn id" + ); + assert_eq!( + metadata["window_id"].as_str(), + request.header("x-codex-window-id").as_deref() + ); + assert!(metadata.get("compaction").is_none()); + } + + let compact_metadata = compact_requests[0] + .header("x-codex-turn-metadata") + .as_deref() + .map(parse_json_header) + .unwrap_or_else(|| panic!("compact request should include turn metadata")); + assert_eq!( + compact_metadata["request_kind"].as_str(), + Some("compaction") + ); + assert_eq!( + compact_metadata["compaction"], + serde_json::json!({ + "trigger": "auto", + "reason": "context_limit", + "implementation": "responses_compact", + "phase": "pre_turn", + "strategy": "memento", + }) + ); + assert_eq!( + compact_metadata["turn_id"], turn_metadata[2]["turn_id"], + "pre-turn compaction should carry the current turn id" + ); + assert_eq!( + compact_metadata["window_id"].as_str(), + compact_requests[0].header("x-codex-window-id").as_deref() + ); Ok(()) } @@ -216,7 +269,7 @@ async fn thread_compact_start_triggers_compaction_and_returns_empty_response() - COMPACT_PROMPT, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_id = start_thread(&mut mcp).await?; @@ -266,7 +319,7 @@ async fn thread_compact_start_rejects_invalid_thread_id() -> Result<()> { COMPACT_PROMPT, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -302,7 +355,7 @@ async fn thread_compact_start_rejects_unknown_thread_id() -> Result<()> { COMPACT_PROMPT, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -322,7 +375,7 @@ async fn thread_compact_start_rejects_unknown_thread_id() -> Result<()> { Ok(()) } -async fn start_thread(mcp: &mut McpProcess) -> Result { +async fn start_thread(mcp: &mut TestAppServer) -> Result { let thread_id = mcp .send_thread_start_request(ThreadStartParams { model: Some("mock-model".to_string()), @@ -338,10 +391,15 @@ async fn start_thread(mcp: &mut McpProcess) -> Result { Ok(thread.id) } -async fn send_turn_and_wait(mcp: &mut McpProcess, thread_id: &str, text: &str) -> Result { +async fn send_turn_and_wait( + mcp: &mut TestAppServer, + thread_id: &str, + text: &str, +) -> Result { let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread_id.to_string(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: text.to_string(), text_elements: Vec::new(), @@ -359,7 +417,7 @@ async fn send_turn_and_wait(mcp: &mut McpProcess, thread_id: &str, text: &str) - Ok(turn.id) } -async fn wait_for_turn_completed(mcp: &mut McpProcess, turn_id: &str) -> Result<()> { +async fn wait_for_turn_completed(mcp: &mut TestAppServer, turn_id: &str) -> Result<()> { loop { let notification: JSONRPCNotification = timeout( DEFAULT_READ_TIMEOUT, @@ -375,7 +433,7 @@ async fn wait_for_turn_completed(mcp: &mut McpProcess, turn_id: &str) -> Result< } async fn wait_for_context_compaction_started( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, ) -> Result { loop { let notification: JSONRPCNotification = timeout( @@ -392,7 +450,7 @@ async fn wait_for_context_compaction_started( } async fn wait_for_context_compaction_completed( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, ) -> Result { loop { let notification: JSONRPCNotification = timeout( @@ -407,3 +465,7 @@ async fn wait_for_context_compaction_completed( } } } + +fn parse_json_header(value: &str) -> serde_json::Value { + serde_json::from_str(value).unwrap_or_else(|err| panic!("turn metadata should be json: {err}")) +} diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index f95b9fc6dc..dbcc12084a 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::test_path_buf_with_windows; use app_test_support::test_tmp_path_buf; use app_test_support::to_response; @@ -58,7 +58,7 @@ sandbox_mode = "workspace-write" let codex_home_path = codex_home.path().canonicalize()?; let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -108,7 +108,7 @@ allowed_domains = ["example.com"] let codex_home_path = codex_home.path().canonicalize()?; let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -179,7 +179,7 @@ forced_chatgpt_workspace_id = "{WORKSPACE_ID}" ), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -218,7 +218,7 @@ forced_chatgpt_workspace_id = ["{WORKSPACE_ID_A}", "{WORKSPACE_ID_B}"] ), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -260,7 +260,7 @@ location = { country = "US", city = "New York", timezone = "America/New_York" } "#, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -304,7 +304,7 @@ web_search = true "#, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -340,7 +340,7 @@ default_tools_approval_mode = "prompt" let codex_home_path = codex_home.path().canonicalize()?; let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -411,6 +411,52 @@ default_tools_approval_mode = "prompt" Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_read_includes_desktop_settings() -> Result<()> { + let codex_home = TempDir::new()?; + write_config( + &codex_home, + r#" +[desktop] +appearanceTheme = "dark" +selected-avatar-id = "codex" + +[desktop.workspace] +collapsed = true +width = 320 +"#, + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ConfigReadResponse { config, .. } = to_response(resp)?; + + let desktop = config.desktop.expect("desktop settings present"); + assert_eq!(desktop.get("appearanceTheme"), Some(&json!("dark"))); + assert_eq!(desktop.get("selected-avatar-id"), Some(&json!("codex"))); + assert_eq!( + desktop.get("workspace"), + Some(&json!({ + "collapsed": true, + "width": 320, + })) + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_includes_project_layers_for_cwd() -> Result<()> { let codex_home = TempDir::new()?; @@ -428,7 +474,7 @@ model_reasoning_effort = "high" set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; let project_config = AbsolutePathBuf::try_from(project_config_dir)?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -498,7 +544,7 @@ writable_roots = [{}] let managed_path_str = managed_path.display().to_string(); - let mut mcp = McpProcess::new_with_env( + let mut mcp = TestAppServer::new_with_env( codex_home.path(), &[( "CODEX_APP_SERVER_MANAGED_CONFIG_PATH", @@ -594,7 +640,7 @@ model = "gpt-old" "#, )?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = TestAppServer::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let read_id = mcp @@ -649,6 +695,50 @@ model = "gpt-old" Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_value_write_updates_desktop_settings() -> Result<()> { + let temp_dir = TempDir::new()?; + let codex_home = temp_dir.path().canonicalize()?; + write_config(&temp_dir, "")?; + + let mut mcp = TestAppServer::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let write_id = mcp + .send_config_value_write_request(ConfigValueWriteParams { + file_path: None, + key_path: "desktop.appearanceTheme".to_string(), + value: json!("dark"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await?; + let write_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(write_id)), + ) + .await??; + let write: ConfigWriteResponse = to_response(write_resp)?; + assert_eq!(write.status, WriteStatus::Ok); + + let read_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let read: ConfigReadResponse = to_response(read_resp)?; + let desktop = read.config.desktop.expect("desktop settings present"); + assert_eq!(desktop.get("appearanceTheme"), Some(&json!("dark"))); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_after_pipelined_write_sees_written_value() -> Result<()> { let temp_dir = TempDir::new()?; @@ -660,7 +750,7 @@ model = "gpt-old" "#, )?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = TestAppServer::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let write_id = mcp @@ -708,7 +798,7 @@ model = "gpt-old" "#, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let write_id = mcp @@ -743,7 +833,7 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> { let codex_home = tmp_dir.path().canonicalize()?; write_config(&tmp_dir, "")?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = TestAppServer::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let writable_root = test_tmp_path_buf(); @@ -803,6 +893,132 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_batch_write_rejects_legacy_profile_tables() -> Result<()> { + let tmp_dir = TempDir::new()?; + let codex_home = tmp_dir.path().canonicalize()?; + write_config( + &tmp_dir, + r#" +[profiles."team.prod"] +model = "gpt-5.3-spark" +"#, + )?; + + let mut mcp = TestAppServer::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let batch_id = mcp + .send_config_batch_write_request(ConfigBatchWriteParams { + file_path: Some(codex_home.join("config.toml").display().to_string()), + edits: vec![ + ConfigEdit { + key_path: "profiles.\"team.prod\".model".to_string(), + value: json!("gpt-5.5"), + merge_strategy: MergeStrategy::Replace, + }, + ConfigEdit { + key_path: "items.sample@catalog.enabled".to_string(), + value: json!(true), + merge_strategy: MergeStrategy::Replace, + }, + ], + expected_version: None, + reload_user_config: false, + }) + .await?; + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(batch_id)), + ) + .await??; + let code = err + .error + .data + .as_ref() + .and_then(|data| data.get("config_write_error_code")) + .and_then(|value| value.as_str()); + assert_eq!(code, Some("configValidationError")); + assert!( + err.error.message.contains("`profiles`"), + "unexpected error: {err:?}" + ); + + let config: toml::Value = + toml::from_str(&std::fs::read_to_string(codex_home.join("config.toml"))?)?; + assert_eq!( + config["profiles"]["team.prod"]["model"].as_str(), + Some("gpt-5.3-spark") + ); + assert_eq!(config.get("items"), None); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_batch_write_updates_multiple_desktop_settings() -> Result<()> { + let tmp_dir = TempDir::new()?; + let codex_home = tmp_dir.path().canonicalize()?; + write_config(&tmp_dir, "")?; + + let mut mcp = TestAppServer::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let batch_id = mcp + .send_config_batch_write_request(ConfigBatchWriteParams { + file_path: Some(codex_home.join("config.toml").display().to_string()), + edits: vec![ + ConfigEdit { + key_path: "desktop.selected-avatar-id".to_string(), + value: json!("codex"), + merge_strategy: MergeStrategy::Replace, + }, + ConfigEdit { + key_path: "desktop.workspace".to_string(), + value: json!({ + "collapsed": true, + "width": 320, + }), + merge_strategy: MergeStrategy::Replace, + }, + ], + expected_version: None, + reload_user_config: false, + }) + .await?; + let batch_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(batch_id)), + ) + .await??; + let batch_write: ConfigWriteResponse = to_response(batch_resp)?; + assert_eq!(batch_write.status, WriteStatus::Ok); + + let read_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + cwd: None, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let read: ConfigReadResponse = to_response(read_resp)?; + let desktop = read.config.desktop.expect("desktop settings present"); + assert_eq!(desktop.get("selected-avatar-id"), Some(&json!("codex"))); + assert_eq!( + desktop.get("workspace"), + Some(&json!({ + "collapsed": true, + "width": 320, + })) + ); + + Ok(()) +} + fn assert_layers_user_then_optional_system( layers: &[codex_app_server_protocol::ConfigLayer], user_file: AbsolutePathBuf, diff --git a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs index cf76b9d573..0e00bc6309 100644 --- a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs +++ b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs @@ -758,12 +758,15 @@ pub(super) async fn read_response_and_notification_for_method( JSONRPCMessage::Response(candidate) if candidate.id == target_id => { response = Some(candidate); } + JSONRPCMessage::Notification(candidate) + if candidate.method == method && notification.is_some() => + { + bail!( + "received duplicate notification for method `{method}` before completing paired read" + ); + } JSONRPCMessage::Notification(candidate) if candidate.method == method => { - if notification.replace(candidate).is_some() { - bail!( - "received duplicate notification for method `{method}` before completing paired read" - ); - } + notification = Some(candidate); } _ => {} } diff --git a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket_unix.rs b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket_unix.rs index 4b708d4ab3..0c8a59a9c8 100644 --- a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket_unix.rs +++ b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket_unix.rs @@ -226,6 +226,7 @@ async fn send_turn_start_request(stream: &mut WsClient, id: i64, thread_id: &str id, Some(serde_json::to_value(TurnStartParams { thread_id: thread_id.to_string(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs index b357e139db..79c5b801bd 100644 --- a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs +++ b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs @@ -1,6 +1,6 @@ use anyhow::Context; use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::to_response; @@ -51,7 +51,7 @@ async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()> let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // Use a minimal JSON schema so we can assert the tool payload round-trips. @@ -89,6 +89,7 @@ async fn thread_start_injects_dynamic_tools_into_model_requests() -> Result<()> let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -134,7 +135,7 @@ async fn thread_start_keeps_hidden_dynamic_tools_out_of_model_requests() -> Resu let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let dynamic_tool = DynamicToolSpec { @@ -168,6 +169,7 @@ async fn thread_start_keeps_hidden_dynamic_tools_out_of_model_requests() -> Resu let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -206,7 +208,7 @@ async fn thread_start_rejects_hidden_dynamic_tools_without_namespace() -> Result let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let dynamic_tool = DynamicToolSpec { @@ -246,7 +248,7 @@ async fn thread_start_rejects_dynamic_tools_not_supported_by_responses() -> Resu let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let dynamic_tool = DynamicToolSpec { @@ -311,7 +313,7 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let dynamic_tool = DynamicToolSpec { @@ -347,6 +349,7 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread_id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Run the tool".to_string(), text_elements: Vec::new(), @@ -486,7 +489,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<( let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let dynamic_tool = DynamicToolSpec { @@ -521,6 +524,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<( let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread_id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Run the tool".to_string(), text_elements: Vec::new(), @@ -704,7 +708,7 @@ fn function_call_output_raw_output(body: &Value, call_id: &str) -> Option } async fn wait_for_dynamic_tool_started( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, call_id: &str, ) -> Result { loop { @@ -724,7 +728,7 @@ async fn wait_for_dynamic_tool_started( } async fn wait_for_dynamic_tool_completed( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, call_id: &str, ) -> Result { loop { diff --git a/codex-rs/app-server/tests/suite/v2/experimental_api.rs b/codex-rs/app-server/tests/suite/v2/experimental_api.rs index 4096e3d96f..fc1100d17b 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_api.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_api.rs @@ -1,6 +1,6 @@ use anyhow::Result; use app_test_support::DEFAULT_CLIENT_NAME; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::to_response; use codex_app_server_protocol::AskForApproval; @@ -15,6 +15,7 @@ use codex_app_server_protocol::ThreadMemoryMode; use codex_app_server_protocol::ThreadMemoryModeSetParams; use codex_app_server_protocol::ThreadRealtimeStartParams; use codex_app_server_protocol::ThreadRealtimeStartTransport; +use codex_app_server_protocol::ThreadSettingsUpdateParams; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_protocol::protocol::RealtimeOutputModality; @@ -29,7 +30,7 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); #[tokio::test] async fn mock_experimental_method_requires_experimental_api_capability() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; let init = mcp .initialize_with_capabilities( @@ -60,7 +61,7 @@ async fn mock_experimental_method_requires_experimental_api_capability() -> Resu #[tokio::test] async fn realtime_conversation_start_requires_experimental_api_capability() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; let init = mcp .initialize_with_capabilities( @@ -98,7 +99,7 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R #[tokio::test] async fn thread_memory_mode_set_requires_experimental_api_capability() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; let init = mcp .initialize_with_capabilities( @@ -129,10 +130,44 @@ async fn thread_memory_mode_set_requires_experimental_api_capability() -> Result Ok(()) } +#[tokio::test] +async fn thread_settings_update_requires_experimental_api_capability() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; + + let init = mcp + .initialize_with_capabilities( + default_client_info(), + Some(InitializeCapabilities { + experimental_api: false, + request_attestation: false, + opt_out_notification_methods: None, + }), + ) + .await?; + let JSONRPCMessage::Response(_) = init else { + anyhow::bail!("expected initialize response, got {init:?}"); + }; + + let request_id = mcp + .send_thread_settings_update_request(ThreadSettingsUpdateParams { + thread_id: "thr_123".to_string(), + ..Default::default() + }) + .await?; + let error = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert_experimental_capability_error(error, "thread/settings/update"); + Ok(()) +} + #[tokio::test] async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; let init = mcp .initialize_with_capabilities( @@ -175,7 +210,7 @@ async fn thread_start_mock_field_requires_experimental_api_capability() -> Resul let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; let init = mcp .initialize_with_capabilities( default_client_info(), @@ -213,7 +248,7 @@ async fn thread_start_without_dynamic_tools_allows_without_experimental_api_capa let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; let init = mcp .initialize_with_capabilities( default_client_info(), @@ -250,7 +285,7 @@ async fn thread_start_granular_approval_policy_requires_experimental_api_capabil let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; let init = mcp .initialize_with_capabilities( default_client_info(), diff --git a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs index d50683cf80..60a0f94839 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -2,7 +2,8 @@ use std::time::Duration; use anyhow::Result; use app_test_support::ChatGptAuthFixture; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; +use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::ConfigReadParams; @@ -16,6 +17,8 @@ use codex_app_server_protocol::ExperimentalFeatureStage; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; use codex_config::LoaderOverrides; use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::ConfigBuilder; @@ -47,7 +50,7 @@ async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Resu )) .build() .await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -131,7 +134,7 @@ async fn experimental_feature_list_marks_apps_and_plugins_disabled_by_workspace_ .mount(&server) .await; - let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -156,6 +159,104 @@ async fn experimental_feature_list_marks_apps_and_plugins_disabled_by_workspace_ Ok(()) } +#[tokio::test] +async fn experimental_feature_list_resolves_thread_project_config() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + let workspace = TempDir::new()?; + let server_uri = server.uri(); + let workspace_key = workspace.path().to_string_lossy().replace('\\', "\\\\"); + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#"model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +model_provider = "mock_provider" + +[projects."{workspace_key}"] +trust_level = "trusted" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + )?; + 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#"[features] +memories = true +"#, + )?; + + let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + ..Default::default() + }) + .await?; + let ThreadStartResponse { thread, .. } = + read_response::(&mut mcp, thread_start_id).await?; + + let request_id = mcp + .send_experimental_feature_list_request(ExperimentalFeatureListParams { + cursor: None, + limit: None, + thread_id: Some(thread.id), + }) + .await?; + + let actual = read_response::(&mut mcp, request_id).await?; + let memories = actual + .data + .iter() + .find(|feature| feature.name == "memories") + .expect("memories feature should be present"); + assert!(memories.enabled); + + Ok(()) +} + +#[tokio::test] +async fn experimental_feature_list_rejects_unknown_thread_id() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_experimental_feature_list_request(ExperimentalFeatureListParams { + cursor: None, + limit: None, + thread_id: Some("00000000-0000-4000-8000-000000000001".to_string()), + }) + .await?; + let JSONRPCError { error, .. } = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.code, -32600); + assert!( + error + .message + .contains("thread not found: 00000000-0000-4000-8000-000000000001"), + "{}", + error.message + ); + + Ok(()) +} + #[tokio::test] async fn experimental_feature_enablement_set_applies_to_global_and_thread_config_reads() -> Result<()> { @@ -163,7 +264,7 @@ async fn experimental_feature_enablement_set_applies_to_global_and_thread_config let project_cwd = codex_home.path().join("project"); std::fs::create_dir_all(&project_cwd)?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let actual = @@ -198,7 +299,7 @@ async fn experimental_feature_enablement_set_does_not_override_user_config() -> codex_home.path().join("config.toml"), "[features]\nmemories = false\n", )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let actual = set_experimental_feature_enablement( @@ -229,7 +330,7 @@ async fn experimental_feature_enablement_set_does_not_override_user_config() -> #[tokio::test] async fn experimental_feature_enablement_set_only_updates_named_features() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) @@ -239,7 +340,7 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re BTreeMap::from([ ("memories".to_string(), true), ("plugins".to_string(), true), - ("tool_search".to_string(), true), + ("remote_plugin".to_string(), true), ("tool_suggest".to_string(), true), ("tool_call_mcp_elicitation".to_string(), false), ]), @@ -252,7 +353,7 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re enablement: BTreeMap::from([ ("memories".to_string(), true), ("plugins".to_string(), true), - ("tool_search".to_string(), true), + ("remote_plugin".to_string(), true), ("tool_suggest".to_string(), true), ("tool_call_mcp_elicitation".to_string(), false), ]), @@ -286,7 +387,7 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re config .additional .get("features") - .and_then(|features| features.get("tool_search")), + .and_then(|features| features.get("remote_plugin")), Some(&json!(true)) ); assert_eq!( @@ -310,7 +411,7 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re #[tokio::test] async fn experimental_feature_enablement_set_allows_remote_control() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let remote_control_enabled = false; let enablement = BTreeMap::from([("remote_control".to_string(), remote_control_enabled)]); @@ -328,7 +429,7 @@ async fn experimental_feature_enablement_set_allows_remote_control() -> Result<( #[tokio::test] async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) @@ -358,7 +459,7 @@ async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()> #[tokio::test] async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -382,7 +483,7 @@ async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() - ); assert!( error.message.contains( - "apps, memories, mentions_v2, plugins, remote_control, tool_search, tool_suggest, tool_call_mcp_elicitation" + "apps, memories, mentions_v2, plugins, remote_control, remote_plugin, tool_suggest, tool_call_mcp_elicitation" ), "{}", error.message @@ -392,7 +493,7 @@ async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() - } async fn set_experimental_feature_enablement( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, enablement: BTreeMap, ) -> Result { let request_id = mcp @@ -403,7 +504,7 @@ async fn set_experimental_feature_enablement( read_response(mcp, request_id).await } -async fn read_config(mcp: &mut McpProcess, cwd: Option) -> Result { +async fn read_config(mcp: &mut TestAppServer, cwd: Option) -> Result { let request_id = mcp .send_config_read_request(ConfigReadParams { include_layers: false, @@ -413,7 +514,7 @@ async fn read_config(mcp: &mut McpProcess, cwd: Option) -> Result(mcp: &mut McpProcess, request_id: i64) -> Result { +async fn read_response(mcp: &mut TestAppServer, request_id: i64) -> Result { let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index f5f74c0231..fdb996c2f4 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -1,7 +1,7 @@ use std::time::Duration; use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::to_response; use app_test_support::write_mock_responses_config_toml; @@ -38,7 +38,8 @@ async fn external_agent_config_import_sends_completion_notification_for_sync_onl let codex_home = TempDir::new()?; let home_dir = codex_home.path().display().to_string(); let mut mcp = - McpProcess::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]).await?; + TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -117,7 +118,8 @@ async fn external_agent_config_import_sends_completion_notification_for_local_pl let home_dir = codex_home.path().display().to_string(); let mut mcp = - McpProcess::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]).await?; + TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -205,7 +207,8 @@ async fn external_agent_config_import_sends_completion_notification_after_pendin let home_dir = codex_home.path().display().to_string(); let mut mcp = - McpProcess::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]).await?; + TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -283,7 +286,8 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { let home_dir = codex_home.path().display().to_string(); let mut mcp = - McpProcess::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]).await?; + TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -391,6 +395,7 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { let request_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "follow up".to_string(), text_elements: Vec::new(), @@ -455,7 +460,8 @@ async fn external_agent_config_import_accepts_detected_session_payload_after_res let home_dir = codex_home.path().display().to_string(); let mut mcp = - McpProcess::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]).await?; + TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -540,7 +546,8 @@ async fn external_agent_config_import_skips_already_imported_session_versions() let home_dir = codex_home.path().display().to_string(); let mut mcp = - McpProcess::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]).await?; + TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -636,7 +643,8 @@ async fn external_agent_config_import_returns_before_background_session_import_f let home_dir = codex_home.path().display().to_string(); let mut mcp = - McpProcess::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]).await?; + TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -765,7 +773,8 @@ async fn external_agent_config_import_rejects_undetected_session_paths() -> Resu let home_dir = codex_home.path().display().to_string(); let mut mcp = - McpProcess::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]).await?; + TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -884,7 +893,8 @@ async fn external_agent_config_import_compacts_huge_session_before_first_follow_ let home_dir = codex_home.path().display().to_string(); let mut mcp = - McpProcess::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]).await?; + TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -964,6 +974,7 @@ async fn external_agent_config_import_compacts_huge_session_before_first_follow_ let request_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "follow up".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/fs.rs b/codex-rs/app-server/tests/suite/v2/fs.rs index a780a51e0b..1f04c847d2 100644 --- a/codex-rs/app-server/tests/suite/v2/fs.rs +++ b/codex-rs/app-server/tests/suite/v2/fs.rs @@ -1,6 +1,6 @@ use anyhow::Context; use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::to_response; use base64::Engine; use base64::engine::general_purpose::STANDARD; @@ -14,6 +14,7 @@ use codex_app_server_protocol::FsWatchResponse; use codex_app_server_protocol::FsWriteFileParams; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::RequestId; +use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::json; @@ -35,14 +36,14 @@ const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60); const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); const OPTIONAL_FS_CHANGE_TIMEOUT: Duration = Duration::from_secs(2); -async fn initialized_mcp(codex_home: &TempDir) -> Result { - let mut mcp = McpProcess::new(codex_home.path()).await?; +async fn initialized_mcp(codex_home: &TempDir) -> Result { + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; Ok(mcp) } async fn expect_error_message( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, request_id: i64, expected_message: &str, ) -> Result<()> { @@ -119,6 +120,28 @@ async fn fs_get_metadata_returns_only_used_fields() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_methods_return_error_when_local_environment_is_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let absolute_file = codex_home.path().join("absolute.txt"); + + let mut mcp = TestAppServer::new_with_env( + codex_home.path(), + &[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let read_id = mcp + .send_fs_read_file_request(codex_app_server_protocol::FsReadFileParams { + path: absolute_path(absolute_file), + }) + .await?; + expect_error_message(&mut mcp, read_id, "local filesystem is not configured").await?; + + Ok(()) +} + #[cfg(unix)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn fs_get_metadata_reports_symlink() -> Result<()> { @@ -830,7 +853,7 @@ fn fs_changed_notification(notification: JSONRPCNotification) -> Result Result> { match timeout( OPTIONAL_FS_CHANGE_TIMEOUT, diff --git a/codex-rs/app-server/tests/suite/v2/hooks_list.rs b/codex-rs/app-server/tests/suite/v2/hooks_list.rs index 15675257fd..b7270f317e 100644 --- a/codex-rs/app-server/tests/suite/v2/hooks_list.rs +++ b/codex-rs/app-server/tests/suite/v2/hooks_list.rs @@ -1,7 +1,7 @@ use std::time::Duration; use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::to_response; @@ -97,7 +97,6 @@ fn write_plugin_hook_config(codex_home: &std::path::Path, hooks_json: &str) -> R codex_home.join("config.toml"), r#"[features] plugins = true -plugin_hooks = true hooks = true [plugins."demo@test"] @@ -136,7 +135,7 @@ async fn hooks_list_shows_discovered_hook() -> Result<()> { let cwd = TempDir::new()?; write_user_hook_config(codex_home.path())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -212,7 +211,7 @@ async fn hooks_list_shows_discovered_plugin_hook() -> Result<()> { }"#, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -271,7 +270,7 @@ async fn hooks_list_shows_plugin_hook_load_warnings() -> Result<()> { let cwd = TempDir::new()?; write_plugin_hook_config(codex_home.path(), "{ not-json")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -327,7 +326,7 @@ timeout = 5 )?; set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -409,7 +408,7 @@ async fn hooks_list_uses_root_repo_hooks_for_linked_worktrees() -> Result<()> { write_project_hook_config(&worktree_root.join(".codex"), "echo worktree hook")?; set_project_trust_level(codex_home.path(), &repo_root, TrustLevel::Trusted)?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let list_id = mcp @@ -479,7 +478,7 @@ async fn config_batch_write_toggles_user_hook() -> Result<()> { let cwd = TempDir::new()?; write_user_hook_config(codex_home.path())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -630,7 +629,7 @@ command = "python3 {hook_script_path}" ), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let hook_list_id = mcp @@ -663,6 +662,7 @@ command = "python3 {hook_script_path}" let first_turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "first turn".to_string(), text_elements: Vec::new(), @@ -724,6 +724,7 @@ command = "python3 {hook_script_path}" let second_turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "second turn".to_string(), text_elements: Vec::new(), @@ -793,6 +794,7 @@ command = "python3 {hook_script_path}" let third_turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![V2UserInput::Text { text: "third turn".to_string(), text_elements: Vec::new(), @@ -877,7 +879,7 @@ command = "python3 {hook_script_path}" ), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let hook_list_id = mcp @@ -933,6 +935,7 @@ command = "python3 {hook_script_path}" let first_turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "first turn".to_string(), text_elements: Vec::new(), @@ -984,6 +987,7 @@ command = "python3 {hook_script_path}" let second_turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![V2UserInput::Text { text: "second turn".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/initialize.rs b/codex-rs/app-server/tests/suite/v2/initialize.rs index 47cacdb20e..0cf8504d3b 100644 --- a/codex-rs/app-server/tests/suite/v2/initialize.rs +++ b/codex-rs/app-server/tests/suite/v2/initialize.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::to_response; @@ -33,7 +33,7 @@ async fn initialize_uses_client_info_name_as_originator() -> Result<()> { let codex_home = TempDir::new()?; let expected_codex_home = AbsolutePathBuf::try_from(codex_home.path().canonicalize()?)?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; let message = timeout( DEFAULT_READ_TIMEOUT, @@ -68,7 +68,7 @@ async fn initialize_probe_does_not_override_originator() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(responses).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; let message = timeout( DEFAULT_READ_TIMEOUT, @@ -95,7 +95,7 @@ async fn initialize_codex_backend_does_not_override_originator() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(responses).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; let message = timeout( DEFAULT_READ_TIMEOUT, @@ -123,7 +123,7 @@ async fn initialize_respects_originator_override_env_var() -> Result<()> { let codex_home = TempDir::new()?; let expected_codex_home = AbsolutePathBuf::try_from(codex_home.path().canonicalize()?)?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = McpProcess::new_with_env( + let mut mcp = TestAppServer::new_with_env( codex_home.path(), &[( "CODEX_INTERNAL_ORIGINATOR_OVERRIDE", @@ -165,7 +165,7 @@ async fn initialize_rejects_invalid_client_name() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(responses).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = McpProcess::new_with_env( + let mut mcp = TestAppServer::new_with_env( codex_home.path(), &[("CODEX_INTERNAL_ORIGINATOR_OVERRIDE", None)], ) @@ -200,7 +200,7 @@ async fn initialize_opt_out_notification_methods_filters_notifications() -> Resu let server = create_mock_responses_server_sequence_unchecked(responses).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; let message = timeout( DEFAULT_READ_TIMEOUT, @@ -282,7 +282,7 @@ async fn turn_start_notify_payload_includes_initialize_client_name() -> Result<( ), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout( DEFAULT_READ_TIMEOUT, mcp.initialize_with_client_info(ClientInfo { @@ -306,6 +306,7 @@ async fn turn_start_notify_payload_includes_initialize_client_name() -> Result<( let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/marketplace_add.rs b/codex-rs/app-server/tests/suite/v2/marketplace_add.rs index 5f470f617d..7dc9f745af 100644 --- a/codex-rs/app-server/tests/suite/v2/marketplace_add.rs +++ b/codex-rs/app-server/tests/suite/v2/marketplace_add.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::MarketplaceAddParams; @@ -28,7 +28,7 @@ async fn marketplace_add_local_directory_source() -> Result<()> { r#"{"name":"sample"}"#, )?; std::fs::write(source.join("plugins/sample/marker.txt"), "local ref")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/marketplace_remove.rs b/codex-rs/app-server/tests/suite/v2/marketplace_remove.rs index dc438499f3..c6808b8271 100644 --- a/codex-rs/app-server/tests/suite/v2/marketplace_remove.rs +++ b/codex-rs/app-server/tests/suite/v2/marketplace_remove.rs @@ -2,7 +2,7 @@ use std::time::Duration; use anyhow::Context; use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::MarketplaceRemoveParams; @@ -53,7 +53,7 @@ async fn marketplace_remove_deletes_config_and_installed_root() -> Result<()> { write_installed_marketplace(codex_home.path(), "debug")?; let installed_root = marketplace_install_root(codex_home.path()).join("debug"); - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -91,7 +91,7 @@ async fn marketplace_remove_deletes_config_and_installed_root() -> Result<()> { async fn marketplace_remove_rejects_unknown_marketplace() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/marketplace_upgrade.rs b/codex-rs/app-server/tests/suite/v2/marketplace_upgrade.rs index 8660497da5..5ed1f2c6f5 100644 --- a/codex-rs/app-server/tests/suite/v2/marketplace_upgrade.rs +++ b/codex-rs/app-server/tests/suite/v2/marketplace_upgrade.rs @@ -4,7 +4,7 @@ use std::time::Duration; use anyhow::Context; use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::MarketplaceUpgradeParams; @@ -127,7 +127,7 @@ fn expected_installed_root(codex_home: &Path, marketplace_name: &str) -> Result< } async fn send_marketplace_upgrade( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, marketplace_name: Option<&str>, ) -> Result { let request_id = mcp @@ -169,7 +169,7 @@ async fn marketplace_upgrade_all_configured_git_marketplaces() -> Result<()> { )?; disable_plugin_startup_tasks(codex_home.path())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let debug_root = expected_installed_root(codex_home.path(), "debug")?; @@ -223,7 +223,7 @@ async fn marketplace_upgrade_named_marketplace_only() -> Result<()> { )?; disable_plugin_startup_tasks(codex_home.path())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let tools_root = expected_installed_root(codex_home.path(), "tools")?; @@ -264,7 +264,7 @@ async fn marketplace_upgrade_returns_empty_roots_when_already_up_to_date() -> Re )?; disable_plugin_startup_tasks(codex_home.path())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let first_response = send_marketplace_upgrade(&mut mcp, Some("debug")).await?; assert!(first_response.errors.is_empty()); @@ -292,7 +292,7 @@ async fn marketplace_upgrade_rejects_unknown_or_non_git_marketplace() -> Result< &configured_local_marketplace_update(&local_source.path().display().to_string()), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; for marketplace_name in ["missing", "local-only"] { diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs index bddf57f666..37b7a10d9b 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -3,7 +3,7 @@ use std::time::Duration; use anyhow::Result; use app_test_support::ChatGptAuthFixture; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use axum::Router; @@ -92,7 +92,7 @@ stream_max_retries = 0 AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_start_id = mcp @@ -157,7 +157,7 @@ apps = true AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let read_request_id = mcp @@ -288,11 +288,8 @@ struct ResourceAppsMcpServer; impl ServerHandler for ResourceAppsMcpServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - protocol_version: ProtocolVersion::V_2025_06_18, - capabilities: ServerCapabilities::builder().enable_resources().build(), - ..ServerInfo::default() - } + ServerInfo::new(ServerCapabilities::builder().enable_resources().build()) + .with_protocol_version(ProtocolVersion::V_2025_06_18) } async fn read_resource( @@ -308,21 +305,19 @@ impl ServerHandler for ResourceAppsMcpServer { )); } - Ok(ReadResourceResult { - contents: vec![ - ResourceContents::TextResourceContents { - uri: TEST_RESOURCE_URI.to_string(), - mime_type: Some("text/markdown".to_string()), - text: TEST_RESOURCE_TEXT.to_string(), - meta: None, - }, - ResourceContents::BlobResourceContents { - uri: TEST_BLOB_RESOURCE_URI.to_string(), - mime_type: Some("application/octet-stream".to_string()), - blob: TEST_RESOURCE_BLOB.to_string(), - meta: None, - }, - ], - }) + Ok(ReadResourceResult::new(vec![ + ResourceContents::TextResourceContents { + uri: TEST_RESOURCE_URI.to_string(), + mime_type: Some("text/markdown".to_string()), + text: TEST_RESOURCE_TEXT.to_string(), + meta: None, + }, + ResourceContents::BlobResourceContents { + uri: TEST_BLOB_RESOURCE_URI.to_string(), + mime_type: Some("application/octet-stream".to_string()), + blob: TEST_RESOURCE_BLOB.to_string(), + meta: None, + }, + ])) } } diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs index 13ebe0b99c..8d0387ee97 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_elicitation.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use anyhow::Result; use app_test_support::ChatGptAuthFixture; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use axum::Json; @@ -116,7 +116,7 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_start_id = mcp @@ -135,6 +135,7 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { let warmup_turn_start_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Warm up connectors.".to_string(), text_elements: Vec::new(), @@ -167,6 +168,7 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> { let turn_start_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Use [$calendar](app://calendar) to run the calendar tool.".to_string(), text_elements: Vec::new(), @@ -308,11 +310,8 @@ struct ElicitationAppsMcpServer; impl ServerHandler for ElicitationAppsMcpServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - protocol_version: rmcp::model::ProtocolVersion::V_2025_06_18, - capabilities: ServerCapabilities::builder().enable_tools().build(), - ..ServerInfo::default() - } + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_protocol_version(rmcp::model::ProtocolVersion::V_2025_06_18) } async fn list_tools( diff --git a/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs b/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs index 44efec4ed3..2c34684ada 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_server_status.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use std::time::Duration; use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::to_response; use app_test_support::write_mock_responses_config_toml; @@ -14,8 +14,13 @@ use codex_app_server_protocol::ListMcpServerStatusParams; use codex_app_server_protocol::ListMcpServerStatusResponse; use codex_app_server_protocol::McpServerStatusDetail; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_core::config::set_project_trust_level; +use codex_protocol::config_types::TrustLevel; use pretty_assertions::assert_eq; use rmcp::handler::server::ServerHandler; +use rmcp::model::Implementation; use rmcp::model::JsonObject; use rmcp::model::ListResourceTemplatesResult; use rmcp::model::ListResourcesResult; @@ -62,7 +67,7 @@ url = "{mcp_server_url}/mcp" )); std::fs::write(config_path, config_toml)?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -70,6 +75,7 @@ url = "{mcp_server_url}/mcp" cursor: None, limit: None, detail: None, + thread_id: None, }) .await?; let response = timeout( @@ -94,6 +100,105 @@ url = "{mcp_server_url}/mcp" .map(|tool| tool.name.as_str()), Some("look-up.raw") ); + assert_eq!( + status + .server_info + .as_ref() + .and_then(|info| info.title.as_deref()), + Some("Lookup Server") + ); + + mcp_server_handle.abort(); + let _ = mcp_server_handle.await; + + Ok(()) +} + +#[tokio::test] +async fn mcp_server_status_list_uses_thread_project_local_config() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let (mcp_server_url, mcp_server_handle) = start_mcp_server("project_lookup").await?; + let codex_home = TempDir::new()?; + let workspace = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::new(), + /*auto_compact_limit*/ 1024, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + )?; + std::fs::create_dir_all(workspace.path().join(".git"))?; + set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().to_string_lossy().into_owned()), + ..Default::default() + }) + .await?; + let thread_start_response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_response)?; + + 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"), + format!( + r#" +[mcp_servers.project-server] +url = "{mcp_server_url}/mcp" +"# + ), + )?; + + let threadless_request_id = mcp + .send_list_mcp_server_status_request(ListMcpServerStatusParams { + cursor: None, + limit: None, + detail: Some(McpServerStatusDetail::ToolsAndAuthOnly), + thread_id: None, + }) + .await?; + let threadless_response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(threadless_request_id)), + ) + .await??; + let threadless_response: ListMcpServerStatusResponse = to_response(threadless_response)?; + assert_eq!(threadless_response.data, Vec::new()); + + let thread_request_id = mcp + .send_list_mcp_server_status_request(ListMcpServerStatusParams { + cursor: None, + limit: None, + detail: Some(McpServerStatusDetail::ToolsAndAuthOnly), + thread_id: Some(thread.id), + }) + .await?; + let thread_response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_request_id)), + ) + .await??; + let thread_response: ListMcpServerStatusResponse = to_response(thread_response)?; + + assert_eq!(thread_response.next_cursor, None); + assert_eq!(thread_response.data.len(), 1); + let status = &thread_response.data[0]; + assert_eq!(status.name, "project-server"); + assert_eq!( + status.tools.keys().cloned().collect::>(), + BTreeSet::from(["project_lookup".to_string()]) + ); mcp_server_handle.abort(); let _ = mcp_server_handle.await; @@ -108,10 +213,9 @@ struct McpStatusServer { impl ServerHandler for McpStatusServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder().enable_tools().build(), - ..ServerInfo::default() - } + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()).with_server_info( + Implementation::new("lookup-server", "1.0.0").with_title("Lookup Server"), + ) } async fn list_tools( @@ -147,13 +251,12 @@ struct SlowInventoryServer { impl ServerHandler for SlowInventoryServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder() + ServerInfo::new( + ServerCapabilities::builder() .enable_tools() .enable_resources() .build(), - ..ServerInfo::default() - } + ) } async fn list_tools( @@ -233,7 +336,7 @@ url = "{mcp_server_url}/mcp" )); std::fs::write(config_path, config_toml)?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -241,6 +344,7 @@ url = "{mcp_server_url}/mcp" cursor: None, limit: None, detail: Some(McpServerStatusDetail::ToolsAndAuthOnly), + thread_id: None, }) .await?; let response = timeout( @@ -297,7 +401,7 @@ url = "{underscore_server_url}/mcp" )); std::fs::write(config_path, config_toml)?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -305,6 +409,7 @@ url = "{underscore_server_url}/mcp" cursor: None, limit: None, detail: None, + thread_id: None, }) .await?; let response = timeout( diff --git a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs index b805f75ba7..10c8a6ef04 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use std::time::Duration; use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_sequence; use app_test_support::to_response; @@ -94,7 +94,7 @@ url = "{mcp_server_url}/mcp" )); std::fs::write(config_path, config_toml)?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_start_id = mcp @@ -161,7 +161,7 @@ url = "{mcp_server_url}/mcp" #[tokio::test] async fn mcp_server_tool_call_returns_error_for_unknown_thread() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -212,7 +212,7 @@ url = "{mcp_server_url}/mcp" )); std::fs::write(config_path, config_toml)?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_start_id = mcp @@ -322,7 +322,7 @@ url = "{mcp_server_url}/mcp" )); std::fs::write(config_path, config_toml)?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_start_id = mcp @@ -403,7 +403,7 @@ url = "{mcp_server_url}/mcp" #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn mcp_tool_call_completion_notification_contains_truncated_large_result() -> Result<()> { let call_id = "call-large-mcp"; - let namespace = format!("mcp__{TEST_SERVER_NAME}__"); + let namespace = format!("mcp__{TEST_SERVER_NAME}"); let responses = vec![ responses::sse(vec![ responses::ev_response_created("resp-1"), @@ -442,7 +442,7 @@ url = "{mcp_server_url}/mcp" )); std::fs::write(config_path, config_toml)?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_start_id = mcp @@ -461,6 +461,7 @@ url = "{mcp_server_url}/mcp" let turn_start_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Call the large MCP tool".to_string(), text_elements: Vec::new(), @@ -513,6 +514,7 @@ url = "{mcp_server_url}/mcp" status, arguments: json!({ "message": LARGE_RESPONSE_MESSAGE }), mcp_app_resource_uri: None, + plugin_id: None, result: Some(result), error: None, duration_ms: None, @@ -536,10 +538,7 @@ struct ToolAppsMcpServer; impl ServerHandler for ToolAppsMcpServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder().enable_tools().build(), - ..ServerInfo::default() - } + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) } async fn list_tools( @@ -684,7 +683,7 @@ async fn start_mcp_server() -> Result<(String, JoinHandle<()>)> { } async fn wait_for_mcp_tool_call_completed( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, call_id: &str, ) -> Result { loop { diff --git a/codex-rs/app-server/tests/suite/v2/memory_reset.rs b/codex-rs/app-server/tests/suite/v2/memory_reset.rs index 3c7ae38671..07e16a3ba6 100644 --- a/codex-rs/app-server/tests/suite/v2/memory_reset.rs +++ b/codex-rs/app-server/tests/suite/v2/memory_reset.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::to_response; use chrono::Utc; use codex_app_server_protocol::JSONRPCResponse; @@ -36,7 +36,7 @@ async fn memory_reset_clears_memory_files_and_rows_preserves_threads() -> Result let thread_id = seed_stage1_output(&state_db, codex_home.path()).await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -49,7 +49,10 @@ async fn memory_reset_clears_memory_files_and_rows_preserves_threads() -> Result .await??; let _: MemoryResetResponse = to_response::(response)?; - let stage1_outputs = state_db.list_stage1_outputs_for_global(/*n*/ 10).await?; + let stage1_outputs = state_db + .memories() + .list_stage1_outputs_for_global(/*n*/ 10) + .await?; assert_eq!(stage1_outputs, Vec::new()); assert_eq!( state_db.get_thread_memory_mode(thread_id).await?.as_deref(), @@ -81,6 +84,7 @@ async fn seed_stage1_output(state_db: &Arc, codex_home: &Path) -> state_db.upsert_thread(&metadata).await?; let claim = state_db + .memories() .try_claim_stage1_job( thread_id, worker_id, @@ -94,6 +98,7 @@ async fn seed_stage1_output(state_db: &Arc, codex_home: &Path) -> }; assert!( state_db + .memories() .mark_stage1_job_succeeded( thread_id, ownership_token.as_str(), @@ -106,6 +111,7 @@ async fn seed_stage1_output(state_db: &Arc, codex_home: &Path) -> "stage1 success should be recorded" ); state_db + .memories() .enqueue_global_consolidation(now.timestamp()) .await?; diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index bdbe7b7ddd..1605983705 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -29,6 +29,7 @@ mod memory_reset; mod model_list; mod model_provider_capabilities_read; mod output_schema; +mod permission_profile_list; mod plan_item; mod plugin_install; mod plugin_list; @@ -57,6 +58,7 @@ mod thread_name_websocket; mod thread_read; mod thread_resume; mod thread_rollback; +mod thread_settings_update; mod thread_shell_command; mod thread_start; mod thread_status; @@ -66,4 +68,5 @@ mod turn_interrupt; mod turn_start; mod turn_start_zsh_fork; mod turn_steer; +mod web_search; mod windows_sandbox_setup; diff --git a/codex-rs/app-server/tests/suite/v2/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs index d22bdd2a18..778577f857 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -2,7 +2,7 @@ use std::time::Duration; use anyhow::Result; use app_test_support::ChatGptAuthFixture; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use app_test_support::write_models_cache; @@ -69,6 +69,7 @@ fn model_from_preset(preset: &ModelPreset) -> Model { description: service_tier.description.clone(), }) .collect(), + default_service_tier: preset.default_service_tier.clone(), is_default: preset.is_default, } } @@ -94,7 +95,7 @@ fn expected_visible_models() -> Vec { async fn list_models_returns_all_models_with_large_limit() -> Result<()> { let codex_home = TempDir::new()?; write_models_cache(codex_home.path())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -128,7 +129,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> { async fn list_models_includes_hidden_models() -> Result<()> { let codex_home = TempDir::new()?; write_models_cache(codex_home.path())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -213,7 +214,8 @@ openai_base_url = "{server_uri}/v1" AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -255,7 +257,7 @@ openai_base_url = "{server_uri}/v1" async fn list_models_pagination_works() -> Result<()> { let codex_home = TempDir::new()?; write_models_cache(codex_home.path())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -304,7 +306,7 @@ async fn list_models_pagination_works() -> Result<()> { async fn list_models_rejects_invalid_cursor() -> Result<()> { let codex_home = TempDir::new()?; write_models_cache(codex_home.path())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; diff --git a/codex-rs/app-server/tests/suite/v2/model_provider_capabilities_read.rs b/codex-rs/app-server/tests/suite/v2/model_provider_capabilities_read.rs index 6dcb9ac4ec..4143e825ea 100644 --- a/codex-rs/app-server/tests/suite/v2/model_provider_capabilities_read.rs +++ b/codex-rs/app-server/tests/suite/v2/model_provider_capabilities_read.rs @@ -1,7 +1,7 @@ use std::time::Duration; use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::ModelProviderCapabilitiesReadParams; @@ -16,7 +16,7 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); #[tokio::test] async fn read_default_provider_capabilities() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -46,7 +46,7 @@ async fn read_amazon_bedrock_provider_capabilities() -> Result<()> { r#"model_provider = "amazon-bedrock" "#, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -60,7 +60,7 @@ async fn read_amazon_bedrock_provider_capabilities() -> Result<()> { let received: ModelProviderCapabilitiesReadResponse = to_response(response)?; let expected = ModelProviderCapabilitiesReadResponse { - namespace_tools: false, + namespace_tools: true, image_generation: false, web_search: false, }; diff --git a/codex-rs/app-server/tests/suite/v2/output_schema.rs b/codex-rs/app-server/tests/suite/v2/output_schema.rs index 149e098b68..bcd95068b3 100644 --- a/codex-rs/app-server/tests/suite/v2/output_schema.rs +++ b/codex-rs/app-server/tests/suite/v2/output_schema.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -32,7 +32,7 @@ async fn turn_start_accepts_output_schema_v2() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -59,6 +59,7 @@ async fn turn_start_accepts_output_schema_v2() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -114,7 +115,7 @@ async fn turn_start_output_schema_is_per_turn_v2() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -141,6 +142,7 @@ async fn turn_start_output_schema_is_per_turn_v2() -> Result<()> { let turn_req_1 = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -183,6 +185,7 @@ async fn turn_start_output_schema_is_per_turn_v2() -> Result<()> { let turn_req_2 = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello again".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/permission_profile_list.rs b/codex-rs/app-server/tests/suite/v2/permission_profile_list.rs new file mode 100644 index 0000000000..63e92af578 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/permission_profile_list.rs @@ -0,0 +1,233 @@ +use std::time::Duration; + +use anyhow::Result; +use app_test_support::TestAppServer; +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 = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_permission_profile_list_request(PermissionProfileListParams { + cursor: None, + limit: None, + cwd: None, + }) + .await?; + let actual = read_response::(&mut mcp, request_id).await?; + + assert_eq!( + actual, + PermissionProfileListResponse { + data: vec![ + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(), + description: None, + }, + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), + description: None, + }, + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(), + description: None, + }, + PermissionProfileSummary { + id: "audit".to_string(), + description: Some("Inspect without writes.".to_string()), + }, + PermissionProfileSummary { + id: "dev".to_string(), + description: Some("Day-to-day coding work.".to_string()), + }, + ], + next_cursor: None, + } + ); + Ok(()) +} + +#[tokio::test] +async fn permission_profile_list_resolves_project_profiles_and_paginates() -> Result<()> { + let codex_home = TempDir::new()?; + let workspace = TempDir::new()?; + let project_config_dir = workspace.path().join(".codex"); + std::fs::create_dir_all(&project_config_dir)?; + std::fs::write( + codex_home.path().join("config.toml"), + r#" +default_permissions = ":workspace" +"#, + )?; + std::fs::write( + project_config_dir.join("config.toml"), + r#" +[permissions.project] +description = "Project-scoped profile." + +[permissions.project.filesystem] +":workspace_roots" = "write" +"#, + )?; + set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let first_request_id = mcp + .send_permission_profile_list_request(PermissionProfileListParams { + cursor: None, + limit: Some(3), + cwd: Some(workspace.path().to_string_lossy().into_owned()), + }) + .await?; + let first = read_response::(&mut mcp, first_request_id).await?; + assert_eq!( + first, + PermissionProfileListResponse { + data: vec![ + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(), + description: None, + }, + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), + description: None, + }, + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(), + description: None, + }, + ], + next_cursor: Some("3".to_string()), + } + ); + + let second_request_id = mcp + .send_permission_profile_list_request(PermissionProfileListParams { + cursor: first.next_cursor, + limit: Some(3), + cwd: Some(workspace.path().to_string_lossy().into_owned()), + }) + .await?; + let second = + read_response::(&mut mcp, second_request_id).await?; + assert_eq!( + second, + PermissionProfileListResponse { + data: vec![PermissionProfileSummary { + id: "project".to_string(), + description: Some("Project-scoped profile.".to_string()), + }], + next_cursor: None, + } + ); + Ok(()) +} + +#[tokio::test] +async fn permission_profile_list_discovers_project_profiles_without_default_selection() -> Result<()> +{ + let codex_home = TempDir::new()?; + let workspace = TempDir::new()?; + let project_config_dir = workspace.path().join(".codex"); + std::fs::create_dir_all(&project_config_dir)?; + std::fs::write( + project_config_dir.join("config.toml"), + r#" +[permissions.project] +description = "Project-scoped profile." + +[permissions.project.filesystem] +":workspace_roots" = "write" +"#, + )?; + set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_permission_profile_list_request(PermissionProfileListParams { + cursor: None, + limit: None, + cwd: Some(workspace.path().to_string_lossy().into_owned()), + }) + .await?; + let actual = read_response::(&mut mcp, request_id).await?; + + assert_eq!( + actual, + PermissionProfileListResponse { + data: vec![ + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(), + description: None, + }, + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), + description: None, + }, + PermissionProfileSummary { + id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(), + description: None, + }, + PermissionProfileSummary { + id: "project".to_string(), + description: Some("Project-scoped profile.".to_string()), + }, + ], + next_cursor: None, + } + ); + Ok(()) +} + +async fn read_response( + mcp: &mut TestAppServer, + request_id: i64, +) -> Result { + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response(response) +} diff --git a/codex-rs/app-server/tests/suite/v2/plan_item.rs b/codex-rs/app-server/tests/suite/v2/plan_item.rs index 97e67fa090..b5464231da 100644 --- a/codex-rs/app-server/tests/suite/v2/plan_item.rs +++ b/codex-rs/app-server/tests/suite/v2/plan_item.rs @@ -1,7 +1,7 @@ use anyhow::Result; use anyhow::anyhow; use anyhow::bail; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::to_response; use codex_app_server_protocol::ItemCompletedNotification; @@ -53,7 +53,7 @@ async fn plan_mode_uses_proposed_plan_block_for_plan_item() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let turn = start_plan_mode_turn(&mut mcp).await?; @@ -111,7 +111,7 @@ async fn plan_mode_without_proposed_plan_does_not_emit_plan_item() -> Result<()> let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let _turn = start_plan_mode_turn(&mut mcp).await?; @@ -127,7 +127,7 @@ async fn plan_mode_without_proposed_plan_does_not_emit_plan_item() -> Result<()> Ok(()) } -async fn start_plan_mode_turn(mcp: &mut McpProcess) -> Result { +async fn start_plan_mode_turn(mcp: &mut TestAppServer) -> Result { let thread_req = mcp .send_thread_start_request(ThreadStartParams { model: Some("mock-model".to_string()), @@ -152,6 +152,7 @@ async fn start_plan_mode_turn(mcp: &mut McpProcess) -> Result Result Result<( Vec, Vec, diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 6adcd92195..2de63f1451 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -1,13 +1,15 @@ use std::borrow::Cow; use std::sync::Arc; use std::sync::Mutex as StdMutex; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; use std::time::Duration; use anyhow::Result; use anyhow::bail; use app_test_support::ChatGptAuthFixture; use app_test_support::DEFAULT_CLIENT_NAME; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::start_analytics_events_server; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; @@ -21,6 +23,8 @@ use axum::http::header::AUTHORIZATION; use axum::routing::get; use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::AppSummary; +use codex_app_server_protocol::AppsListParams; +use codex_app_server_protocol::AppsListResponse; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginAvailability; @@ -68,7 +72,7 @@ const TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS: &str = #[tokio::test] async fn plugin_install_rejects_relative_marketplace_paths() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -95,7 +99,7 @@ async fn plugin_install_rejects_relative_marketplace_paths() -> Result<()> { #[tokio::test] async fn plugin_install_rejects_missing_install_source() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -124,7 +128,7 @@ async fn plugin_install_rejects_missing_install_source() -> Result<()> { #[tokio::test] async fn plugin_install_rejects_multiple_install_sources() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -132,7 +136,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?; @@ -161,13 +165,13 @@ async fn plugin_install_rejects_remote_marketplace_when_plugins_are_disabled() - plugins = false "#, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; 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 +197,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, @@ -210,7 +231,7 @@ async fn plugin_install_writes_remote_plugin_to_cloud_and_cache() -> Result<()> ) .await; - let mut mcp = McpProcess::new_with_env( + let mut mcp = TestAppServer::new_with_env( codex_home.path(), &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], ) @@ -247,12 +268,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() ); @@ -273,7 +302,7 @@ async fn plugin_install_rejects_missing_remote_bundle_url() -> Result<()> { .await; mount_empty_remote_installed_plugins(&server).await; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; @@ -299,7 +328,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(()) @@ -314,7 +343,7 @@ async fn plugin_install_rejects_plain_http_remote_bundle_url() -> Result<()> { mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await; mount_empty_remote_installed_plugins(&server).await; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; @@ -340,7 +369,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(()) @@ -360,7 +389,7 @@ async fn plugin_install_rejects_invalid_remote_release_version() -> Result<()> { .await; mount_empty_remote_installed_plugins(&server).await; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; @@ -382,7 +411,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(()) @@ -392,13 +421,13 @@ async fn plugin_install_rejects_invalid_remote_release_version() -> Result<()> { async fn plugin_install_rejects_invalid_remote_plugin_name() -> Result<()> { let codex_home = TempDir::new()?; write_remote_plugin_catalog_config(codex_home.path(), "https://example.invalid/backend-api/")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; 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?; @@ -435,7 +464,7 @@ async fn plugin_install_rejects_remote_plugin_disabled_by_admin_before_download( .await; mount_empty_remote_installed_plugins(&server).await; - let mut mcp = McpProcess::new_with_env( + let mut mcp = TestAppServer::new_with_env( codex_home.path(), &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], ) @@ -468,7 +497,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(()) @@ -515,7 +544,7 @@ async fn plugin_install_rejects_when_workspace_codex_plugins_disabled() -> Resul .mount(&server) .await; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -544,7 +573,7 @@ async fn plugin_install_rejects_when_workspace_codex_plugins_disabled() -> Resul #[tokio::test] async fn plugin_install_returns_invalid_request_for_missing_marketplace_file() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -585,7 +614,7 @@ async fn plugin_install_returns_invalid_request_for_not_available_plugin() -> Re let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -635,7 +664,7 @@ async fn plugin_install_returns_invalid_request_for_disallowed_product_plugin() AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; let mut mcp = - McpProcess::new_with_args(codex_home.path(), &["--session-source", "atlas"]).await?; + TestAppServer::new_with_args(codex_home.path(), &["--session-source", "atlas"]).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -684,7 +713,7 @@ async fn plugin_install_tracks_analytics_event() -> Result<()> { let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -739,7 +768,7 @@ async fn plugin_install_tracks_remote_plugin_analytics_event() -> Result<()> { mount_remote_plugin_install(&server, REMOTE_PLUGIN_ID).await; mount_backend_analytics_events(&server).await; - let mut mcp = McpProcess::new_with_env( + let mut mcp = TestAppServer::new_with_env( codex_home.path(), &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], ) @@ -764,7 +793,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": [], @@ -791,7 +820,7 @@ async fn plugin_install_errors_when_remote_bundle_download_fails() -> Result<()> mount_empty_remote_installed_plugins(&server).await; mount_remote_plugin_install(&server, REMOTE_PLUGIN_ID).await; - let mut mcp = McpProcess::new_with_env( + let mut mcp = TestAppServer::new_with_env( codex_home.path(), &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], ) @@ -824,7 +853,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(()) @@ -865,7 +894,7 @@ async fn plugin_install_returns_apps_needing_auth() -> Result<()> { }, ]; let tools = vec![connector_tool("beta", "Beta App")?]; - let (server_url, server_handle) = start_apps_server(connectors, tools).await?; + let (server_url, server_handle, server_control) = start_apps_server(connectors, tools).await?; let codex_home = TempDir::new()?; write_connectors_config(codex_home.path(), &server_url)?; @@ -891,8 +920,9 @@ async fn plugin_install_returns_apps_needing_auth() -> Result<()> { let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let directory_requests_before_install = server_control.directory_request_count(); let request_id = mcp .send_plugin_install_request(PluginInstallParams { @@ -922,6 +952,7 @@ async fn plugin_install_returns_apps_needing_auth() -> Result<()> { }], } ); + assert!(server_control.directory_request_count() > directory_requests_before_install); server_handle.abort(); let _ = server_handle.await; @@ -945,7 +976,8 @@ async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> { is_enabled: true, plugin_display_names: Vec::new(), }]; - let (server_url, server_handle) = start_apps_server(connectors, Vec::new()).await?; + let (server_url, server_handle, server_control) = + start_apps_server(connectors, Vec::new()).await?; let codex_home = TempDir::new()?; write_connectors_config(codex_home.path(), &server_url)?; @@ -975,8 +1007,10 @@ async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> { let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let directory_requests_before_install = + warm_app_directory_cache(&mut mcp, &server_control, "Alpha").await?; let request_id = mcp .send_plugin_install_request(PluginInstallParams { @@ -1006,6 +1040,10 @@ async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> { }], } ); + assert_eq!( + server_control.directory_request_count(), + directory_requests_before_install + ); server_handle.abort(); let _ = server_handle.await; @@ -1042,7 +1080,7 @@ async fn plugin_install_makes_bundled_mcp_servers_available_to_followup_requests let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1088,6 +1126,46 @@ async fn plugin_install_makes_bundled_mcp_servers_available_to_followup_requests #[derive(Clone)] struct AppsServerState { response: Arc>, + directory_request_count: Arc, +} + +#[derive(Clone)] +struct AppsServerControl { + directory_request_count: Arc, +} + +impl AppsServerControl { + fn directory_request_count(&self) -> usize { + self.directory_request_count.load(Ordering::SeqCst) + } +} + +async fn warm_app_directory_cache( + mcp: &mut TestAppServer, + server_control: &AppsServerControl, + expected_app_name: &str, +) -> Result { + let app_list_request_id = mcp + .send_apps_list_request(AppsListParams { + force_refetch: true, + ..Default::default() + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(app_list_request_id)), + ) + .await??; + let response: AppsListResponse = to_response(response)?; + assert!( + response + .data + .iter() + .any(|app| app.name == expected_app_name) + ); + let directory_request_count = server_control.directory_request_count(); + assert!(directory_request_count > 0); + Ok(directory_request_count) } #[derive(Clone)] @@ -1097,10 +1175,7 @@ struct PluginInstallMcpServer { impl ServerHandler for PluginInstallMcpServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder().enable_tools().build(), - ..ServerInfo::default() - } + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) } fn list_tools( @@ -1127,12 +1202,17 @@ impl ServerHandler for PluginInstallMcpServer { async fn start_apps_server( connectors: Vec, tools: Vec, -) -> Result<(String, JoinHandle<()>)> { +) -> Result<(String, JoinHandle<()>, AppsServerControl)> { + let directory_request_count = Arc::new(AtomicUsize::new(0)); let state = Arc::new(AppsServerState { response: Arc::new(StdMutex::new( json!({ "apps": connectors, "next_token": null }), )), + directory_request_count: directory_request_count.clone(), }); + let server_control = AppsServerControl { + directory_request_count, + }; let tools = Arc::new(StdMutex::new(tools)); let listener = TcpListener::bind("127.0.0.1:0").await?; @@ -1162,7 +1242,7 @@ async fn start_apps_server( let _ = axum::serve(listener, router).await; }); - Ok((format!("http://{addr}"), handle)) + Ok((format!("http://{addr}"), handle, server_control)) } async fn list_directory_connectors( @@ -1170,6 +1250,8 @@ async fn list_directory_connectors( headers: HeaderMap, uri: Uri, ) -> Result { + state.directory_request_count.fetch_add(1, Ordering::SeqCst); + let bearer_ok = headers .get(AUTHORIZATION) .and_then(|value| value.to_str().ok()) @@ -1349,12 +1431,49 @@ async fn mount_remote_plugin_detail( .await; } +async fn mount_remote_plugin_detail_with_app_manifest( + server: &MockServer, + remote_plugin_id: &str, + release_version: &str, + bundle_download_url: Option<&str>, + app_manifest: serde_json::Value, +) { + mount_remote_plugin_detail_with_status_and_app_manifest( + server, + remote_plugin_id, + release_version, + bundle_download_url, + PluginAvailability::Available, + Some(app_manifest), + ) + .await; +} + async fn mount_remote_plugin_detail_with_status( server: &MockServer, remote_plugin_id: &str, release_version: &str, bundle_download_url: Option<&str>, status: PluginAvailability, +) { + mount_remote_plugin_detail_with_status_and_app_manifest( + server, + remote_plugin_id, + release_version, + bundle_download_url, + status, + /*app_manifest*/ None, + ) + .await; +} + +async fn mount_remote_plugin_detail_with_status_and_app_manifest( + server: &MockServer, + remote_plugin_id: &str, + release_version: &str, + bundle_download_url: Option<&str>, + status: PluginAvailability, + app_manifest: Option, ) { let status = match status { PluginAvailability::Available => "ENABLED", @@ -1363,6 +1482,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 +1499,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" }}, @@ -1461,7 +1584,7 @@ async fn mount_remote_plugin_install_after_cache_write( } async fn send_remote_plugin_install_request( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, remote_plugin_id: &str, ) -> Result { mcp.send_plugin_install_request(PluginInstallParams { @@ -1576,13 +1699,20 @@ fn write_plugin_source( fn remote_plugin_bundle_tar_gz_bytes(plugin_name: &str) -> Result> { let manifest = format!(r#"{{"name":"{plugin_name}"}}"#); + remote_plugin_bundle_tar_gz_bytes_with_contents(&manifest, /*app_manifest*/ None) +} + +fn remote_plugin_bundle_tar_gz_bytes_with_contents( + plugin_manifest: &str, + app_manifest: Option<&str>, +) -> Result> { let skill = "# Plan Work\n\nTrack work in Linear.\n"; let encoder = GzEncoder::new(Vec::new(), Compression::default()); let mut tar = tar::Builder::new(encoder); - for (path, contents, mode) in [ + let mut entries = vec![ ( ".codex-plugin/plugin.json", - manifest.as_bytes(), + plugin_manifest.as_bytes(), /*mode*/ 0o644, ), ( @@ -1590,7 +1720,11 @@ fn remote_plugin_bundle_tar_gz_bytes(plugin_name: &str) -> Result> { skill.as_bytes(), /*mode*/ 0o644, ), - ] { + ]; + if let Some(app_manifest) = app_manifest { + entries.push((".app.json", app_manifest.as_bytes(), /*mode*/ 0o644)); + } + for (path, contents, mode) in entries { let mut header = tar::Header::new_gnu(); header.set_size(contents.len() as u64); header.set_mode(mode); diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index 4ffa3893d0..ea9e5f4f35 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -3,12 +3,14 @@ use std::time::Duration; use anyhow::Result; use anyhow::bail; use app_test_support::ChatGptAuthFixture; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginInstalledParams; +use codex_app_server_protocol::PluginInstalledResponse; use codex_app_server_protocol::PluginListMarketplaceKind; use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginListResponse; @@ -79,7 +81,7 @@ async fn plugin_list_skips_invalid_marketplace_file_and_reports_error() -> Resul std::fs::write(marketplace_path.as_path(), "{not json")?; let home = codex_home.path().to_string_lossy().into_owned(); - let mut mcp = McpProcess::new_with_env( + let mut mcp = TestAppServer::new_with_env( codex_home.path(), &[ ("HOME", Some(home.as_str())), @@ -125,10 +127,200 @@ async fn plugin_list_skips_invalid_marketplace_file_and_reports_error() -> Resul Ok(()) } +#[tokio::test] +async fn plugin_installed_includes_installed_plugins_and_explicit_install_suggestions() -> Result<()> +{ + let codex_home = TempDir::new()?; + write_openai_curated_marketplace( + codex_home.path(), + &["linear", "computer-use", "not-mentioned"], + )?; + write_installed_plugin(&codex_home, "openai-curated", "linear")?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +plugins = true + +[plugins."linear@openai-curated"] +enabled = true +"#, + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_installed_request(PluginInstalledParams { + cwds: None, + install_suggestion_plugin_names: Some(vec!["computer-use".to_string()]), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginInstalledResponse = to_response(response)?; + + assert_eq!(response.marketplaces.len(), 1); + assert_eq!(response.marketplaces[0].name, "openai-curated"); + assert_eq!( + response.marketplaces[0] + .plugins + .iter() + .map(|plugin| (plugin.id.clone(), plugin.installed, plugin.enabled)) + .collect::>(), + vec![ + ("linear@openai-curated".to_string(), true, true), + ("computer-use@openai-curated".to_string(), false, false), + ] + ); + assert_eq!(response.marketplace_load_errors, Vec::new()); + Ok(()) +} + +#[tokio::test] +async fn plugin_installed_prefers_remote_curated_conflicts_when_remote_plugin_enabled() -> Result<()> +{ + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_openai_curated_marketplace(codex_home.path(), &["linear", "calendar"])?; + write_installed_plugin(&codex_home, "openai-curated", "linear")?; + write_installed_plugin(&codex_home, "openai-curated", "calendar")?; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#"chatgpt_base_url = "{}/backend-api/" + +[features] +plugins = true +remote_plugin = true +plugin_sharing = false + +[plugins."linear@openai-curated"] +enabled = true + +[plugins."calendar@openai-curated"] +enabled = true +"#, + 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 global_installed_body: serde_json::Value = serde_json::from_str( + &remote_installed_plugin_body("", "1.2.3", /*enabled*/ true), + )?; + let mut remote_only = global_installed_body["plugins"][0].clone(); + remote_only["id"] = serde_json::json!("plugins~Plugin_11111111111111111111111111111111"); + remote_only["name"] = serde_json::json!("remote-only"); + remote_only["release"]["display_name"] = serde_json::json!("Remote Only"); + global_installed_body["plugins"] + .as_array_mut() + .expect("installed plugins should be an array") + .push(remote_only); + let global_installed_body = serde_json::to_string(&global_installed_body)?; + mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await; + mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) + .await; + + let mut app_server = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??; + + let request_id = app_server + .send_plugin_installed_request(PluginInstalledParams { + cwds: None, + install_suggestion_plugin_names: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginInstalledResponse = to_response(response)?; + + let local_marketplace = response + .marketplaces + .iter() + .find(|marketplace| marketplace.name == "openai-curated") + .expect("expected openai-curated marketplace entry"); + assert_eq!( + local_marketplace + .plugins + .iter() + .map(|plugin| plugin.id.clone()) + .collect::>(), + vec!["calendar@openai-curated".to_string()] + ); + let remote_marketplace = response + .marketplaces + .iter() + .find(|marketplace| marketplace.name == "openai-curated-remote") + .expect("expected openai-curated-remote marketplace entry"); + assert_eq!( + remote_marketplace + .plugins + .iter() + .map(|plugin| plugin.id.clone()) + .collect::>(), + vec![ + "linear@openai-curated-remote".to_string(), + "remote-only@openai-curated-remote".to_string(), + ] + ); + assert_eq!(response.marketplace_load_errors, Vec::new()); + Ok(()) +} + +#[tokio::test] +async fn plugin_installed_ignores_local_cache_without_catalog() -> Result<()> { + let codex_home = TempDir::new()?; + write_installed_plugin(&codex_home, "openai-curated", "linear")?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +plugins = true + +[plugins."linear@openai-curated"] +enabled = true +"#, + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_installed_request(PluginInstalledParams { + cwds: None, + install_suggestion_plugin_names: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginInstalledResponse = to_response(response)?; + + assert_eq!(response.marketplaces, Vec::new()); + assert_eq!(response.marketplace_load_errors, Vec::new()); + Ok(()) +} + #[tokio::test] async fn plugin_list_rejects_relative_cwds() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -205,7 +397,7 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_ std::fs::write(invalid_marketplace_path.as_path(), "{not json")?; let home = codex_home.path().to_string_lossy().into_owned(); - let mut mcp = McpProcess::new_with_env( + let mut mcp = TestAppServer::new_with_env( codex_home.path(), &[ ("HOME", Some(home.as_str())), @@ -322,7 +514,7 @@ async fn plugin_list_returns_empty_when_workspace_codex_plugins_disabled() -> Re .await; let home = codex_home.path().to_string_lossy().into_owned(); - let mut mcp = McpProcess::new_without_managed_config_with_env( + let mut mcp = TestAppServer::new_without_managed_config_with_env( codex_home.path(), &[ ("HOME", Some(home.as_str())), @@ -413,7 +605,7 @@ async fn plugin_list_reuses_cached_workspace_codex_plugins_setting() -> Result<( .await; let home = codex_home.path().to_string_lossy().into_owned(); - let mut mcp = McpProcess::new_without_managed_config_with_env( + let mut mcp = TestAppServer::new_without_managed_config_with_env( codex_home.path(), &[ ("HOME", Some(home.as_str())), @@ -498,7 +690,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab )?; let home = codex_home.path().to_string_lossy().into_owned(); - let mut mcp = McpProcess::new_with_env( + let mut mcp = TestAppServer::new_with_env( codex_home.path(), &[ ("HOME", Some(home.as_str())), @@ -611,7 +803,7 @@ async fn plugin_list_accepts_omitted_cwds() -> Result<()> { }"#, )?; let home = codex_home.path().to_string_lossy().into_owned(); - let mut mcp = McpProcess::new_with_env( + let mut mcp = TestAppServer::new_with_env( codex_home.path(), &[ ("HOME", Some(home.as_str())), @@ -671,7 +863,7 @@ async fn plugin_list_returns_share_context_for_shared_local_plugin() -> Result<( &AbsolutePathBuf::try_from(plugin_root)?, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -763,7 +955,7 @@ enabled = false "#, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -910,7 +1102,7 @@ enabled = false let workspace_default = TempDir::new()?; let home = codex_home.path().to_string_lossy().into_owned(); - let mut mcp = McpProcess::new_with_env( + let mut mcp = TestAppServer::new_with_env( codex_home.path(), &[ ("HOME", Some(home.as_str())), @@ -1004,7 +1196,7 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res }"##, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1117,7 +1309,7 @@ async fn plugin_list_accepts_legacy_string_default_prompt() -> Result<()> { }"##, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1205,7 +1397,7 @@ enabled = true "#, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1308,7 +1500,7 @@ async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> { .join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE); { - let mut mcp = McpProcess::new_with_plugin_startup_tasks(codex_home.path()).await?; + let mut mcp = TestAppServer::new_with_plugin_startup_tasks(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; wait_for_path_exists(&marker_path).await?; @@ -1347,7 +1539,7 @@ async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> { assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); { - let mut mcp = McpProcess::new_with_plugin_startup_tasks(codex_home.path()).await?; + let mut mcp = TestAppServer::new_with_plugin_startup_tasks(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; } @@ -1360,7 +1552,7 @@ async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> { async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() -> Result<()> { let codex_home = TempDir::new()?; let server = MockServer::start().await; - write_plugins_enabled_config_with_base_url( + write_remote_plugin_catalog_config( codex_home.path(), &format!("{}/backend-api/", server.uri()), )?; @@ -1379,16 +1571,27 @@ 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"); - let mut mcp = McpProcess::new_with_env_and_plugin_startup_tasks( + .join("plugins/cache/openai-curated-remote/linear/1.2.3"); + let mut mcp = TestAppServer::new_with_env_and_plugin_startup_tasks( codex_home.path(), &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], ) @@ -1396,9 +1599,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(()) } @@ -1418,8 +1631,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, @@ -1427,8 +1640,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; @@ -1437,13 +1661,15 @@ 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( + let mut mcp = TestAppServer::new_with_env( codex_home.path(), &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], ) @@ -1465,22 +1691,32 @@ async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles() let remote_marketplace = response .marketplaces .into_iter() - .find(|marketplace| marketplace.name == "chatgpt-global") - .expect("expected chatgpt-global marketplace entry"); + .find(|marketplace| marketplace.name == "openai-curated-remote") + .expect("expected openai-curated-remote marketplace entry"); assert_eq!( remote_marketplace .plugins .into_iter() .map(|plugin| (plugin.id, plugin.installed, plugin.enabled)) .collect::>(), - vec![("linear@chatgpt-global".to_string(), true, true)] + vec![("linear@openai-curated-remote".to_string(), true, true)] ); wait_for_path_exists(&new_path.join(".codex-plugin/plugin.json")).await?; + let installed_plugin_manifest: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(new_path.join(".codex-plugin/plugin.json"))?, + )?; + assert_eq!( + installed_plugin_manifest["version"], + serde_json::json!("1.2.3") + ); + let installed_app_manifest: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(new_path.join(".app.json"))?)?; + assert_eq!(installed_app_manifest, remote_app_manifest); wait_for_path_missing(&old_path).await?; wait_for_path_missing(&stale_path).await?; let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?; - assert!(!config.contains("linear@chatgpt-global")); + assert!(!config.contains("linear@openai-curated-remote")); Ok(()) } @@ -1603,7 +1839,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - .mount(&server) .await; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1623,18 +1859,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") @@ -1662,6 +1901,283 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - ] ); assert_eq!(response.featured_plugin_ids, Vec::::new()); + assert!( + !server + .received_requests() + .await + .expect("wiremock should record requests") + .iter() + .any(|request| request + .url + .query_pairs() + .any(|(name, value)| name == "collection" && value == "vertical")) + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_includes_openai_curated_remote_collection_when_requested() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_plugins_enabled_config_with_base_url( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let collection_body = r#"{ + "plugins": [ + { + "id": "plugins~Plugin_00000000000000000000000000000000", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "status": "ENABLED", + "release": { + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "interface": { + "short_description": "Plan and track work", + "capabilities": ["Read", "Write"] + }, + "skills": [] + } + } + ], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + mount_openai_curated_remote_collection_plugin_list(&server, collection_body).await; + mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await; + mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) + .await; + + let mut mcp = TestAppServer::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 = TestAppServer::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 = TestAppServer::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 = TestAppServer::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(()) } @@ -1683,7 +2199,7 @@ async fn plugin_list_does_not_append_global_remote_when_marketplace_kinds_are_ex AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1704,12 +2220,191 @@ 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(()) } +#[tokio::test] +async fn plugin_installed_includes_remote_shared_with_me_plugins() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#"chatgpt_base_url = "{}/backend-api/" + +[features] +plugins = true +remote_plugin = false +plugin_sharing = true +"#, + 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 workspace_installed_body: serde_json::Value = + serde_json::from_str(&workspace_remote_plugin_page_body( + "plugins~Plugin_22222222222222222222222222222222", + "shared-linear", + "Shared Linear", + "PRIVATE", + /*enabled*/ Some(true), + ))?; + let unlisted_installed_body: serde_json::Value = + serde_json::from_str(&workspace_remote_plugin_page_body( + "plugins~Plugin_33333333333333333333333333333333", + "unlisted-linear", + "Unlisted Linear", + "UNLISTED", + /*enabled*/ Some(false), + ))?; + workspace_installed_body["plugins"] + .as_array_mut() + .expect("installed plugins should be an array") + .push(unlisted_installed_body["plugins"][0].clone()); + let workspace_installed_body = serde_json::to_string(&workspace_installed_body)?; + let global_installed_body = remote_installed_plugin_body("", "1.2.3", /*enabled*/ true); + mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await; + mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_installed_request(PluginInstalledParams { + cwds: None, + install_suggestion_plugin_names: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginInstalledResponse = to_response(response)?; + + assert_eq!(response.marketplaces.len(), 1); + let marketplace = &response.marketplaces[0]; + assert_eq!(marketplace.name, "workspace-shared-with-me"); + assert_eq!( + marketplace + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()), + Some("Shared with me") + ); + assert_eq!( + marketplace + .plugins + .iter() + .map(|plugin| (plugin.id.clone(), plugin.installed, plugin.enabled)) + .collect::>(), + vec![ + ( + "shared-linear@workspace-shared-with-me".to_string(), + true, + true + ), + ( + "unlisted-linear@workspace-shared-with-me".to_string(), + true, + false + ) + ] + ); + wait_for_remote_installed_scope_request(&server, "WORKSPACE").await?; + wait_for_remote_installed_scope_request(&server, "GLOBAL").await?; + Ok(()) +} + +#[tokio::test] +async fn plugin_installed_starts_remote_installed_bundle_sync() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#"chatgpt_base_url = "{}/backend-api/" + +[features] +plugins = true +remote_plugin = true +plugin_sharing = false +"#, + 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 bundle_url = mount_remote_plugin_bundle( + &server, + "linear", + remote_plugin_bundle_tar_gz_bytes("linear")?, + ) + .await; + let global_installed_body = + remote_installed_plugin_body(&bundle_url, "1.2.3", /*enabled*/ true); + mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await; + mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) + .await; + + let mut mcp = TestAppServer::new_with_env( + codex_home.path(), + &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let plugin_installed_request_id = mcp + .send_plugin_installed_request(PluginInstalledParams { + cwds: None, + install_suggestion_plugin_names: None, + }) + .await?; + let response: PluginInstalledResponse = to_response( + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(plugin_installed_request_id)), + ) + .await??, + )?; + + assert_eq!(response.marketplaces.len(), 1); + assert_eq!(response.marketplaces[0].name, "openai-curated-remote"); + assert_eq!( + response.marketplaces[0] + .plugins + .iter() + .map(|plugin| (plugin.id.clone(), plugin.installed, plugin.enabled)) + .collect::>(), + vec![("linear@openai-curated-remote".to_string(), true, true)] + ); + let installed_path = codex_home + .path() + .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?; + Ok(()) +} + #[tokio::test] async fn plugin_list_fetches_workspace_directory_kind_without_remote_plugin_flag() -> Result<()> { let codex_home = TempDir::new()?; @@ -1744,7 +2439,7 @@ async fn plugin_list_fetches_workspace_directory_kind_without_remote_plugin_flag mount_remote_plugin_list(&server, "WORKSPACE", &workspace_plugin_body).await; mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1861,7 +2556,7 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await; mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1987,12 +2682,8 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { share_context.discoverability, Some(PluginShareDiscoverability::Unlisted) ); - wait_for_remote_plugin_request_count( - &server, - "/ps/plugins/installed", - /*expected_count*/ 5, - ) - .await?; + wait_for_remote_installed_scope_request(&server, "WORKSPACE").await?; + wait_for_remote_installed_scope_request(&server, "GLOBAL").await?; wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?; Ok(()) } @@ -2022,7 +2713,7 @@ plugin_sharing = false AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -2157,7 +2848,7 @@ async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> { .await; } - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -2176,7 +2867,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 @@ -2191,133 +2882,6 @@ async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> { Ok(()) } -#[tokio::test] -async fn plugin_list_remote_marketplace_replaces_local_marketplace_with_same_name() -> Result<()> { - let codex_home = TempDir::new()?; - let server = MockServer::start().await; - write_remote_plugin_catalog_config( - codex_home.path(), - &format!("{}/backend-api/", server.uri()), - )?; - write_chatgpt_auth( - codex_home.path(), - ChatGptAuthFixture::new("chatgpt-token") - .account_id("account-123") - .chatgpt_user_id("user-123") - .chatgpt_account_id("account-123"), - AuthCredentialsStoreMode::File, - )?; - let local_plugin_root = codex_home - .path() - .join(".agents/plugins/plugins/local-linear/.codex-plugin"); - std::fs::create_dir_all(&local_plugin_root)?; - std::fs::write( - codex_home.path().join(".agents/plugins/marketplace.json"), - r#"{ - "name": "chatgpt-global", - "plugins": [ - { - "name": "local-linear", - "source": { - "source": "local", - "path": "./plugins/local-linear" - } - } - ] -}"#, - )?; - std::fs::write( - local_plugin_root.join("plugin.json"), - r#"{"name":"local-linear"}"#, - )?; - - let global_directory_body = r#"{ - "plugins": [ - { - "id": "plugins~Plugin_00000000000000000000000000000000", - "name": "linear", - "scope": "GLOBAL", - "installation_policy": "AVAILABLE", - "authentication_policy": "ON_USE", - "release": { - "display_name": "Linear", - "description": "Track work in Linear", - "app_ids": [], - "interface": {}, - "skills": [] - } - } - ], - "pagination": { - "limit": 50, - "next_page_token": null - } -}"#; - let empty_page_body = r#"{ - "plugins": [], - "pagination": { - "limit": 50, - "next_page_token": null - } -}"#; - for (scope, body) in [ - ("GLOBAL", global_directory_body), - ("WORKSPACE", empty_page_body), - ] { - Mock::given(method("GET")) - .and(path("/backend-api/ps/plugins/list")) - .and(query_param("scope", scope)) - .and(query_param("limit", "200")) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with(ResponseTemplate::new(200).set_body_string(body)) - .mount(&server) - .await; - } - for scope in ["GLOBAL", "WORKSPACE"] { - Mock::given(method("GET")) - .and(path("/backend-api/ps/plugins/installed")) - .and(query_param("scope", scope)) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with(ResponseTemplate::new(200).set_body_string(empty_page_body)) - .mount(&server) - .await; - } - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - - let request_id = mcp - .send_plugin_list_request(PluginListParams { - cwds: None, - marketplace_kinds: None, - }) - .await?; - - let response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - let response: PluginListResponse = to_response(response)?; - let matching_marketplaces = response - .marketplaces - .iter() - .filter(|marketplace| marketplace.name == "chatgpt-global") - .collect::>(); - - assert_eq!(matching_marketplaces.len(), 1); - assert_eq!(matching_marketplaces[0].path, None); - assert_eq!(matching_marketplaces[0].plugins.len(), 1); - assert_eq!( - matching_marketplaces[0].plugins[0].source, - PluginSource::Remote - ); - assert_eq!(matching_marketplaces[0].plugins[0].name, "linear"); - Ok(()) -} - #[tokio::test] async fn plugin_list_does_not_fetch_remote_marketplaces_when_plugins_disabled() -> Result<()> { let codex_home = TempDir::new()?; @@ -2344,7 +2908,7 @@ remote_plugin = true AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -2380,7 +2944,7 @@ async fn plugin_list_fetches_featured_plugin_ids_without_chatgpt_auth() -> Resul .mount(&server) .await; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -2419,7 +2983,7 @@ async fn plugin_list_uses_warmed_featured_plugin_ids_cache_on_first_request() -> .mount(&server) .await; - let mut mcp = McpProcess::new_with_plugin_startup_tasks(codex_home.path()).await?; + let mut mcp = TestAppServer::new_with_plugin_startup_tasks(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; wait_for_featured_plugin_request_count(&server, /*expected_count*/ 1).await?; @@ -2490,6 +3054,29 @@ async fn wait_for_remote_plugin_request_count( Ok(()) } +async fn wait_for_remote_installed_scope_request(server: &MockServer, scope: &str) -> Result<()> { + timeout(DEFAULT_TIMEOUT, async { + loop { + let Some(requests) = server.received_requests().await else { + bail!("wiremock did not record requests"); + }; + if requests.iter().any(|request| { + request.method == "GET" + && request.url.path().ends_with("/ps/plugins/installed") + && request + .url + .query_pairs() + .any(|(name, value)| name == "scope" && value == scope) + }) { + return Ok::<(), anyhow::Error>(()); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await??; + Ok(()) +} + async fn wait_for_path_exists(path: &std::path::Path) -> Result<()> { timeout(DEFAULT_TIMEOUT, async { loop { @@ -2528,6 +3115,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")) @@ -2621,6 +3221,37 @@ fn remote_installed_plugin_body( release_version: &str, enabled: bool, ) -> String { + remote_installed_plugin_body_with_optional_app_manifest( + bundle_download_url, + release_version, + enabled, + /*app_manifest*/ None, + ) +} + +fn remote_installed_plugin_body_with_app_manifest( + bundle_download_url: &str, + release_version: &str, + enabled: bool, + app_manifest: serde_json::Value, +) -> String { + remote_installed_plugin_body_with_optional_app_manifest( + bundle_download_url, + release_version, + enabled, + Some(app_manifest), + ) +} + +fn remote_installed_plugin_body_with_optional_app_manifest( + bundle_download_url: &str, + release_version: &str, + enabled: bool, + app_manifest: Option, +) -> String { + let app_manifest_field = app_manifest + .map(|manifest| format!(r#" "app_manifest": {manifest},"#)) + .unwrap_or_default(); format!( r#"{{ "plugins": [ @@ -2636,6 +3267,7 @@ fn remote_installed_plugin_body( "description": "Track work in Linear", "bundle_download_url": "{bundle_download_url}", "app_ids": [], +{app_manifest_field} "interface": {{}}, "skills": [] }}, diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 812f884a55..1f1c217ee5 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -5,7 +5,7 @@ use std::time::Duration; use anyhow::Result; use app_test_support::ChatGptAuthFixture; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use axum::Json; @@ -64,7 +64,7 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); #[tokio::test] async fn plugin_read_rejects_missing_read_source() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -93,7 +93,7 @@ async fn plugin_read_rejects_missing_read_source() -> Result<()> { #[tokio::test] async fn plugin_read_rejects_multiple_read_sources() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -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?; @@ -190,13 +190,13 @@ plugins = true .mount(&server) .await; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; 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") @@ -297,7 +297,7 @@ async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result< .mount(&server) .await; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; for remote_marketplace_name in [ @@ -478,13 +478,13 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> .mount(&server) .await; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; 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") @@ -563,12 +563,12 @@ async fn plugin_skill_read_reads_remote_skill_contents_when_remote_plugin_enable .mount(&server) .await; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; 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(), }) @@ -615,13 +615,13 @@ async fn plugin_read_maps_missing_remote_plugin_to_invalid_request() -> Result<( .mount(&server) .await; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; 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?; @@ -667,13 +667,13 @@ remote_plugin = true AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; 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?; @@ -697,13 +697,13 @@ remote_plugin = true async fn plugin_read_rejects_invalid_remote_plugin_name() -> Result<()> { let codex_home = TempDir::new()?; write_remote_plugin_catalog_config(codex_home.path(), "https://example.invalid/backend-api/")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; 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?; @@ -755,7 +755,7 @@ enabled = true )?; write_installed_plugin(&codex_home, "openai-curated", "demo-plugin")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let marketplace_path = @@ -855,7 +855,7 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<( .expect(1) .mount(&server) .await; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -981,7 +981,7 @@ async fn plugin_read_keeps_remote_version_when_share_principals_are_missing() -> .expect(1) .mount(&server) .await; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1037,7 +1037,7 @@ async fn plugin_read_falls_back_to_local_share_context_without_remote_auth() -> let plugin_path = AbsolutePathBuf::try_from(repo_root.path().join("demo-plugin"))?; write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &plugin_path)?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1095,7 +1095,7 @@ async fn plugin_read_fails_on_malformed_share_mapping() -> Result<()> { "not valid json\n", )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1273,7 +1273,6 @@ description: Visible only for ChatGPT codex_home.path().join("config.toml"), r#"[features] plugins = true -plugin_hooks = true [[skills.config]] name = "demo-plugin:thread-summarizer" @@ -1288,7 +1287,7 @@ enabled = false )?; write_installed_plugin(&codex_home, "codex-curated", "demo-plugin")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let marketplace_path = @@ -1459,7 +1458,7 @@ async fn plugin_read_returns_app_needs_auth() -> Result<()> { let marketplace_path = AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1526,7 +1525,7 @@ async fn plugin_read_accepts_legacy_string_default_prompt() -> Result<()> { )?; write_plugins_enabled_config(&codex_home)?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1588,7 +1587,7 @@ async fn plugin_read_describes_uninstalled_git_source_without_cloning() -> Resul )?; write_plugins_enabled_config(&codex_home)?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1651,7 +1650,7 @@ async fn plugin_read_returns_invalid_request_when_plugin_is_missing() -> Result< )?; write_plugins_enabled_config(&codex_home)?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1704,7 +1703,7 @@ async fn plugin_read_returns_invalid_request_when_plugin_manifest_is_missing() - )?; write_plugins_enabled_config(&codex_home)?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -1769,10 +1768,7 @@ struct PluginReadMcpServer { impl ServerHandler for PluginReadMcpServer { fn get_info(&self) -> ServerInfo { - ServerInfo { - capabilities: ServerCapabilities::builder().enable_tools().build(), - ..ServerInfo::default() - } + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) } fn list_tools( diff --git a/codex-rs/app-server/tests/suite/v2/plugin_share.rs b/codex-rs/app-server/tests/suite/v2/plugin_share.rs index 69f93f0b7e..99fc1289d4 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_share.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_share.rs @@ -4,7 +4,7 @@ use std::time::Duration; use anyhow::Result; use app_test_support::ChatGptAuthFixture; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCError; @@ -102,7 +102,7 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> { .mount(&server) .await; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let expected_plugin_path = AbsolutePathBuf::try_from(plugin_path.clone())?; let request_id = mcp @@ -251,7 +251,7 @@ async fn plugin_share_save_forwards_access_policy() -> Result<()> { .mount(&server) .await; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let expected_plugin_path = AbsolutePathBuf::try_from(plugin_path)?; let request_id = mcp @@ -304,7 +304,7 @@ async fn plugin_share_save_rejects_listed_discoverability() -> Result<()> { AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_raw_request( @@ -359,7 +359,7 @@ plugin_sharing = false AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_raw_request( @@ -404,7 +404,7 @@ async fn plugin_share_rejects_workspace_targets_from_client() -> Result<()> { AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_raw_request( @@ -482,7 +482,7 @@ async fn plugin_share_save_rejects_access_policy_for_existing_plugin() -> Result AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_raw_request( @@ -555,7 +555,7 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { .mount(&server) .await; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_raw_request("plugin/share/list", Some(json!({}))) @@ -626,7 +626,7 @@ async fn plugin_share_checkout_adds_personal_marketplace_entry() -> Result<()> { mount_empty_remote_installed_plugins(&server, "WORKSPACE").await; let home_env = home.path().to_string_lossy().into_owned(); - let mut mcp = McpProcess::new_with_env( + let mut mcp = TestAppServer::new_with_env( codex_home.path(), &[ ("HOME", Some(home_env.as_str())), @@ -789,7 +789,7 @@ async fn plugin_share_checkout_rejects_non_share_remote_plugin() -> Result<()> { mount_empty_remote_installed_plugins(&server, "GLOBAL").await; let home_env = home.path().to_string_lossy().into_owned(); - let mut mcp = McpProcess::new_with_env( + let mut mcp = TestAppServer::new_with_env( codex_home.path(), &[ ("HOME", Some(home_env.as_str())), @@ -880,7 +880,7 @@ async fn plugin_share_checkout_cleans_up_path_when_marketplace_update_fails() -> mount_empty_remote_installed_plugins(&server, "WORKSPACE").await; let home_env = home.path().to_string_lossy().into_owned(); - let mut mcp = McpProcess::new_with_env( + let mut mcp = TestAppServer::new_with_env( codex_home.path(), &[ ("HOME", Some(home_env.as_str())), @@ -983,7 +983,7 @@ async fn plugin_share_update_targets_updates_share_targets() -> Result<()> { .mount(&server) .await; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_raw_request( @@ -1065,7 +1065,7 @@ plugin_sharing = false AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_raw_request( @@ -1114,7 +1114,7 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> { .mount(&server) .await; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_raw_request( diff --git a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs index 8fdf149a01..46299d1186 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs @@ -4,7 +4,7 @@ use anyhow::Result; use anyhow::bail; use app_test_support::ChatGptAuthFixture; use app_test_support::DEFAULT_CLIENT_NAME; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::start_analytics_events_server; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; @@ -42,7 +42,7 @@ enabled = true "#, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let params = PluginUninstallParams { @@ -100,7 +100,7 @@ async fn plugin_uninstall_tracks_analytics_event() -> Result<()> { AuthCredentialsStoreMode::File, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -161,7 +161,7 @@ async fn plugin_uninstall_rejects_remote_plugin_when_plugins_are_disabled() -> R plugins = false "#, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -219,18 +219,18 @@ 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?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -299,10 +299,10 @@ 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?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -376,7 +376,7 @@ async fn plugin_uninstall_accepts_workspace_remote_plugin_id_shape() -> Result<( r#"{"name":"skill-improver","version":"1.0.0"}"#, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -420,12 +420,12 @@ 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?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -467,7 +467,7 @@ async fn plugin_uninstall_rejects_remote_plugin_id_with_spaces_before_network_ca codex_home.path(), &format!("{}/backend-api/", server.uri()), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -502,7 +502,7 @@ async fn plugin_uninstall_rejects_invalid_remote_plugin_id_before_network_call() codex_home.path(), &format!("{}/backend-api/", server.uri()), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -537,7 +537,7 @@ async fn plugin_uninstall_rejects_empty_remote_plugin_id() -> Result<()> { codex_home.path(), &format!("{}/backend-api/", server.uri()), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/process_exec.rs b/codex-rs/app-server/tests/suite/v2/process_exec.rs index 5dd3e84b4c..ac4f6eeda3 100644 --- a/codex-rs/app-server/tests/suite/v2/process_exec.rs +++ b/codex-rs/app-server/tests/suite/v2/process_exec.rs @@ -1,11 +1,12 @@ use anyhow::Context; use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_mock_responses_server_sequence_unchecked; use codex_app_server_protocol::ProcessExitedNotification; use codex_app_server_protocol::ProcessKillParams; use codex_app_server_protocol::ProcessSpawnParams; use codex_app_server_protocol::RequestId; +use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::collections::HashMap; @@ -102,6 +103,33 @@ async fn process_spawn_returns_before_exit_and_emits_exit_notification() -> Resu Ok(()) } +#[tokio::test] +async fn process_spawn_returns_error_when_local_environment_is_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = TestAppServer::new_with_env( + codex_home.path(), + &[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let process_request_id = mcp + .send_process_spawn_request(process_spawn_params( + "disabled-process".to_string(), + codex_home.path(), + vec!["sh".to_string(), "-lc".to_string(), "true".to_string()], + )?) + .await?; + let error = mcp + .read_stream_until_error_message(RequestId::Integer(process_request_id)) + .await?; + assert_eq!(error.error.message, "local environment is not configured"); + + Ok(()) +} + #[tokio::test] async fn process_spawn_reports_buffered_output_cap_reached() -> Result<()> { let codex_home = TempDir::new()?; @@ -202,10 +230,10 @@ async fn process_kill_terminates_running_process() -> Result<()> { Ok(()) } -async fn initialized_mcp(codex_home: &Path) -> Result<(MockServer, McpProcess)> { +async fn initialized_mcp(codex_home: &Path) -> Result<(MockServer, TestAppServer)> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; create_config_toml(codex_home, &server.uri(), "never")?; - let mut mcp = McpProcess::new(codex_home).await?; + let mut mcp = TestAppServer::new(codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; Ok((server, mcp)) } @@ -229,7 +257,7 @@ fn process_spawn_params( }) } -async fn read_process_exited(mcp: &mut McpProcess) -> Result { +async fn read_process_exited(mcp: &mut TestAppServer) -> Result { let notification = mcp .read_stream_until_notification_message("process/exited") .await?; diff --git a/codex-rs/app-server/tests/suite/v2/rate_limits.rs b/codex-rs/app-server/tests/suite/v2/rate_limits.rs index b15960c1e9..0eca044991 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limits.rs @@ -1,6 +1,6 @@ use anyhow::Result; use app_test_support::ChatGptAuthFixture; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::AddCreditsNudgeCreditType; @@ -37,7 +37,8 @@ const INTERNAL_ERROR_CODE: i64 = -32603; async fn get_account_rate_limits_requires_auth() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_get_account_rate_limits_request().await?; @@ -62,7 +63,7 @@ async fn get_account_rate_limits_requires_auth() -> Result<()> { async fn get_account_rate_limits_requires_chatgpt_auth() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; login_with_api_key(&mut mcp, "sk-test-key").await?; @@ -153,7 +154,8 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { .mount(&server) .await; - let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_get_account_rate_limits_request().await?; @@ -238,7 +240,8 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { async fn send_add_credits_nudge_email_requires_auth() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -267,7 +270,7 @@ async fn send_add_credits_nudge_email_requires_auth() -> Result<()> { async fn send_add_credits_nudge_email_requires_chatgpt_auth() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; login_with_api_key(&mut mcp, "sk-test-key").await?; @@ -321,7 +324,8 @@ async fn send_add_credits_nudge_email_posts_expected_body() -> Result<()> { .mount(&server) .await; - let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -364,7 +368,8 @@ async fn send_add_credits_nudge_email_maps_cooldown() -> Result<()> { .mount(&server) .await; - let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -407,7 +412,8 @@ async fn send_add_credits_nudge_email_surfaces_backend_failure() -> Result<()> { .mount(&server) .await; - let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -437,7 +443,7 @@ async fn send_add_credits_nudge_email_surfaces_backend_failure() -> Result<()> { Ok(()) } -async fn login_with_api_key(mcp: &mut McpProcess, api_key: &str) -> Result<()> { +async fn login_with_api_key(mcp: &mut TestAppServer, api_key: &str) -> Result<()> { let request_id = mcp.send_login_account_api_key_request(api_key).await?; let response: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index 975819dc7f..f88fb54929 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -1,6 +1,6 @@ use anyhow::Context; use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; @@ -191,7 +191,7 @@ struct RealtimeSidebandScript { } struct RealtimeE2eHarness { - mcp: McpProcess, + mcp: TestAppServer, _codex_home: TempDir, main_loop_responses_server: MockServer, realtime_server: WebSocketTestServer, @@ -280,7 +280,7 @@ impl RealtimeE2eHarness { sandbox, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; login_with_api_key(&mut mcp, "sk-test-key").await?; @@ -539,7 +539,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { StartupContextConfig::Generated, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; login_with_api_key(&mut mcp, "sk-test-key").await?; @@ -788,7 +788,7 @@ async fn realtime_text_output_modality_requests_text_output_and_final_transcript StartupContextConfig::Generated, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; login_with_api_key(&mut mcp, "sk-test-key").await?; @@ -890,7 +890,7 @@ async fn realtime_list_voices_returns_supported_names() -> Result<()> { StartupContextConfig::Generated, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -962,7 +962,7 @@ async fn realtime_conversation_stop_emits_closed_notification() -> Result<()> { StartupContextConfig::Generated, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; login_with_api_key(&mut mcp, "sk-test-key").await?; @@ -1058,7 +1058,7 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> { StartupContextConfig::Override("startup context"), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; login_with_api_key(&mut mcp, "sk-test-key").await?; @@ -1973,7 +1973,7 @@ async fn realtime_webrtc_start_surfaces_backend_error() -> Result<()> { StartupContextConfig::Override("startup context"), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; login_with_api_key(&mut mcp, "sk-test-key").await?; @@ -2034,7 +2034,7 @@ async fn realtime_conversation_requires_feature_flag() -> Result<()> { StartupContextConfig::Generated, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let thread_start_request_id = mcp @@ -2074,7 +2074,10 @@ async fn realtime_conversation_requires_feature_flag() -> Result<()> { Ok(()) } -async fn read_notification(mcp: &mut McpProcess, method: &str) -> Result { +async fn read_notification( + mcp: &mut TestAppServer, + method: &str, +) -> Result { let notification = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_notification_message(method), @@ -2086,7 +2089,7 @@ async fn read_notification(mcp: &mut McpProcess, method: &s Ok(serde_json::from_value(params)?) } -async fn login_with_api_key(mcp: &mut McpProcess, api_key: &str) -> Result<()> { +async fn login_with_api_key(mcp: &mut TestAppServer, api_key: &str) -> Result<()> { let request_id = mcp.send_login_account_api_key_request(api_key).await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, @@ -2100,7 +2103,7 @@ async fn login_with_api_key(mcp: &mut McpProcess, api_key: &str) -> Result<()> { } async fn wait_for_started_command_execution( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, ) -> Result { loop { let started = read_notification::(mcp, "item/started").await?; @@ -2111,7 +2114,7 @@ async fn wait_for_started_command_execution( } async fn wait_for_completed_command_execution( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, ) -> Result { loop { let completed = diff --git a/codex-rs/app-server/tests/suite/v2/remote_control.rs b/codex-rs/app-server/tests/suite/v2/remote_control.rs index ae6bc9013f..d15f2d4187 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_control.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_control.rs @@ -1,14 +1,27 @@ use std::time::Duration; +use anyhow::Context; use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::ChatGptAuthFixture; +use app_test_support::TestAppServer; use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RemoteControlConnectionStatus; use codex_app_server_protocol::RemoteControlDisableResponse; use codex_app_server_protocol::RemoteControlEnableResponse; +use codex_app_server_protocol::RemoteControlStatusReadResponse; use codex_app_server_protocol::RequestId; +use codex_config::types::AuthCredentialsStoreMode; +use pretty_assertions::assert_eq; use tempfile::TempDir; +use tokio::io::AsyncBufReadExt; +use tokio::io::BufReader; +use tokio::net::TcpListener; +use tokio::net::TcpStream; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; use tokio::time::timeout; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); @@ -16,7 +29,7 @@ const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); #[tokio::test] async fn remote_control_disable_returns_disabled_status() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_remote_control_disable_request().await?; @@ -28,6 +41,28 @@ async fn remote_control_disable_returns_disabled_status() -> Result<()> { let received: RemoteControlDisableResponse = to_response(response)?; assert_eq!(received.status, RemoteControlConnectionStatus::Disabled); + assert!(!received.server_name.is_empty()); + assert_eq!(received.environment_id, None); + assert!(!received.installation_id.is_empty()); + Ok(()) +} + +#[tokio::test] +async fn remote_control_status_read_returns_disabled_status() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_remote_control_status_read_request().await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: RemoteControlStatusReadResponse = to_response(response)?; + + assert_eq!(received.status, RemoteControlConnectionStatus::Disabled); + assert!(!received.server_name.is_empty()); assert_eq!(received.environment_id, None); assert!(!received.installation_id.is_empty()); Ok(()) @@ -36,7 +71,8 @@ async fn remote_control_disable_returns_disabled_status() -> Result<()> { #[tokio::test] async fn remote_control_enable_returns_connecting_status() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let _backend = BlockingRemoteControlBackend::start(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp.send_remote_control_enable_request().await?; @@ -48,7 +84,117 @@ async fn remote_control_enable_returns_connecting_status() -> Result<()> { let received: RemoteControlEnableResponse = to_response(response)?; assert_eq!(received.status, RemoteControlConnectionStatus::Connecting); + assert!(!received.server_name.is_empty()); assert_eq!(received.environment_id, None); assert!(!received.installation_id.is_empty()); Ok(()) } + +#[tokio::test] +async fn remote_control_status_read_returns_connecting_status_after_enable() -> Result<()> { + let codex_home = TempDir::new()?; + let mut backend = BlockingRemoteControlBackend::start(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_remote_control_enable_request().await?; + let _: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let enroll_request = timeout(DEFAULT_TIMEOUT, backend.wait_for_enroll_request()).await??; + assert_eq!( + enroll_request, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + + let request_id = mcp.send_remote_control_status_read_request().await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: RemoteControlStatusReadResponse = to_response(response)?; + + assert_eq!(received.status, RemoteControlConnectionStatus::Connecting); + assert!(!received.server_name.is_empty()); + assert_eq!(received.environment_id, None); + assert!(!received.installation_id.is_empty()); + Ok(()) +} + +struct BlockingRemoteControlBackend { + enroll_request_rx: Option>>, + server_task: JoinHandle<()>, +} + +impl BlockingRemoteControlBackend { + async fn start(codex_home: &std::path::Path) -> Result { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let remote_control_url = format!("http://{}/backend-api/", listener.local_addr()?); + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home, + &remote_control_url, + &remote_control_url, + )?; + write_chatgpt_auth( + codex_home, + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account_id") + .chatgpt_account_id("account_id"), + AuthCredentialsStoreMode::File, + )?; + + let (enroll_request_tx, enroll_request_rx) = oneshot::channel(); + let server_task = tokio::spawn(async move { + match read_enroll_request(listener).await { + Ok((request_line, _reader)) => { + let _ = enroll_request_tx.send(Ok(request_line)); + std::future::pending::<()>().await; + } + Err(err) => { + let _ = enroll_request_tx.send(Err(err)); + } + } + }); + + Ok(Self { + enroll_request_rx: Some(enroll_request_rx), + server_task, + }) + } + + async fn wait_for_enroll_request(&mut self) -> Result { + let rx = self + .enroll_request_rx + .take() + .context("enroll request should only be awaited once")?; + rx.await? + } +} + +impl Drop for BlockingRemoteControlBackend { + fn drop(&mut self) { + self.server_task.abort(); + } +} + +async fn read_enroll_request(listener: TcpListener) -> Result<(String, BufReader)> { + let (stream, _) = listener.accept().await?; + let mut reader = BufReader::new(stream); + + let mut request_line = String::new(); + reader.read_line(&mut request_line).await?; + + loop { + let mut line = String::new(); + reader.read_line(&mut line).await?; + if line == "\r\n" { + break; + } + } + + Ok((request_line.trim_end().to_string(), reader)) +} diff --git a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs index 9787868f5c..158ebb25d1 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs @@ -113,6 +113,7 @@ async fn thread_start_with_non_local_thread_store_does_not_create_local_persiste request_id: RequestId::Integer(2), params: TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -228,7 +229,6 @@ fn assert_no_local_persistence_artifacts(codex_home: &Path) -> Result<()> { BTreeSet::from([ "config.toml".to_string(), "installation_id".to_string(), - "memories".to_string(), "skills".to_string(), ]), "non-local thread persistence should not create unexpected files in codex_home" diff --git a/codex-rs/app-server/tests/suite/v2/request_permissions.rs b/codex-rs/app-server/tests/suite/v2/request_permissions.rs index 7acdc1b12f..bea78901f5 100644 --- a/codex-rs/app-server/tests/suite/v2/request_permissions.rs +++ b/codex-rs/app-server/tests/suite/v2/request_permissions.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_request_permissions_sse_response; @@ -30,7 +30,7 @@ async fn request_permissions_round_trip() -> Result<()> { let server = create_mock_responses_server_sequence(responses).await; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_start_id = mcp @@ -49,6 +49,7 @@ async fn request_permissions_round_trip() -> Result<()> { let turn_start_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "pick a directory".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/request_user_input.rs b/codex-rs/app-server/tests/suite/v2/request_user_input.rs index f77ddfb4f7..da61d34112 100644 --- a/codex-rs/app-server/tests/suite/v2/request_user_input.rs +++ b/codex-rs/app-server/tests/suite/v2/request_user_input.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_request_user_input_sse_response; @@ -32,7 +32,7 @@ async fn request_user_input_round_trip() -> Result<()> { let server = create_mock_responses_server_sequence(responses).await; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_start_id = mcp @@ -51,6 +51,7 @@ async fn request_user_input_round_trip() -> Result<()> { let turn_start_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "ask something".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/review.rs b/codex-rs/app-server/tests/suite/v2/review.rs index bf0271f821..427a9f288b 100644 --- a/codex-rs/app-server/tests/suite/v2/review.rs +++ b/codex-rs/app-server/tests/suite/v2/review.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::create_mock_responses_server_sequence; @@ -59,7 +59,7 @@ async fn review_start_runs_review_turn_and_emits_code_review_item() -> Result<() let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_id = start_default_thread(&mut mcp).await?; @@ -91,6 +91,7 @@ async fn review_start_runs_review_turn_and_emits_code_review_item() -> Result<() turn.items, vec![ThreadItem::UserMessage { id: turn_id.clone(), + client_id: None, content: vec![V2UserInput::Text { text: "commit 1234567: Tidy UI colors".to_string(), text_elements: Vec::new(), @@ -172,7 +173,7 @@ async fn review_start_exec_approval_item_id_matches_command_execution_item() -> let codex_home = TempDir::new()?; create_config_toml_with_approval_policy(codex_home.path(), &server.uri(), "untrusted")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_id = start_default_thread(&mut mcp).await?; @@ -199,6 +200,7 @@ async fn review_start_exec_approval_item_id_matches_command_execution_item() -> turn.items, vec![ThreadItem::UserMessage { id: turn_id.clone(), + client_id: None, content: vec![V2UserInput::Text { text: "commit 1234567: Check review approvals".to_string(), text_elements: Vec::new(), @@ -254,7 +256,7 @@ async fn review_start_rejects_empty_base_branch() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_id = start_default_thread(&mut mcp).await?; @@ -297,7 +299,7 @@ async fn review_start_with_detached_delivery_returns_new_thread_id() -> Result<( let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_id = start_default_thread(&mut mcp).await?; @@ -328,6 +330,7 @@ async fn review_start_with_detached_delivery_returns_new_thread_id() -> Result<( turn.items, vec![ThreadItem::UserMessage { id: turn.id.clone(), + client_id: None, content: vec![V2UserInput::Text { text: "detached review".to_string(), text_elements: Vec::new(), @@ -374,7 +377,7 @@ async fn review_start_rejects_empty_commit_sha() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_id = start_default_thread(&mut mcp).await?; @@ -409,7 +412,7 @@ async fn review_start_rejects_empty_custom_instructions() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_id = start_default_thread(&mut mcp).await?; @@ -440,7 +443,7 @@ async fn review_start_rejects_empty_custom_instructions() -> Result<()> { Ok(()) } -async fn start_default_thread(mcp: &mut McpProcess) -> Result { +async fn start_default_thread(mcp: &mut TestAppServer) -> Result { let thread_req = mcp .send_thread_start_request(ThreadStartParams { model: Some("mock-model".to_string()), @@ -461,10 +464,11 @@ async fn start_default_thread(mcp: &mut McpProcess) -> Result { Ok(thread.id) } -async fn materialize_thread_rollout(mcp: &mut McpProcess, thread_id: &str) -> Result<()> { +async fn materialize_thread_rollout(mcp: &mut TestAppServer, thread_id: &str) -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread_id.to_string(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "materialize rollout".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs b/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs index 7c1619e38a..c5b70b2334 100644 --- a/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs +++ b/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::to_response; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::ErrorNotification; @@ -48,7 +48,7 @@ async fn openai_model_header_mismatch_emits_model_rerouted_notification_v2() -> let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -67,6 +67,7 @@ async fn openai_model_header_mismatch_emits_model_rerouted_notification_v2() -> let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "trigger safeguard".to_string(), text_elements: Vec::new(), @@ -114,7 +115,7 @@ async fn cyber_policy_response_emits_typed_error_notification_v2() -> Result<()> let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -133,6 +134,7 @@ async fn cyber_policy_response_emits_typed_error_notification_v2() -> Result<()> let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "trigger cyber policy error".to_string(), text_elements: Vec::new(), @@ -190,7 +192,7 @@ async fn response_model_field_mismatch_emits_model_rerouted_notification_v2_when let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -209,6 +211,7 @@ async fn response_model_field_mismatch_emits_model_rerouted_notification_v2_when let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "trigger response model check".to_string(), text_elements: Vec::new(), @@ -258,7 +261,7 @@ async fn model_verification_emits_typed_notification_and_warning_v2() -> Result< let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -277,6 +280,7 @@ async fn model_verification_emits_typed_notification_and_warning_v2() -> Result< let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "trigger model verification".to_string(), text_elements: Vec::new(), @@ -306,7 +310,7 @@ async fn model_verification_emits_typed_notification_and_warning_v2() -> Result< } async fn collect_turn_notifications_and_validate_no_warning_item( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, ) -> Result { let mut rerouted = None; @@ -348,7 +352,7 @@ async fn collect_turn_notifications_and_validate_no_warning_item( } async fn collect_model_verification_notifications_and_validate_no_warning_item( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, ) -> Result { let mut verification = None; @@ -399,7 +403,7 @@ async fn collect_model_verification_notifications_and_validate_no_warning_item( } async fn collect_cyber_policy_error_and_validate_no_reroute( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, ) -> Result { let mut error = None; diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index 416b2515ad..8405aaf308 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -3,7 +3,7 @@ use std::time::Duration; use anyhow::Context; use anyhow::Result; use app_test_support::ChatGptAuthFixture; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; @@ -13,11 +13,14 @@ use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SkillsChangedNotification; +use codex_app_server_protocol::SkillsExtraRootsSetParams; +use codex_app_server_protocol::SkillsExtraRootsSetResponse; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadStartParams; use codex_config::types::AuthCredentialsStoreMode; use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; +use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; @@ -40,6 +43,23 @@ fn write_skill(root: &TempDir, name: &str) -> Result<()> { Ok(()) } +async fn expect_skills_changed_notification( + mcp: &mut TestAppServer, + timeout_duration: Duration, +) -> Result<()> { + let notification = timeout( + timeout_duration, + mcp.read_stream_until_notification_message("skills/changed"), + ) + .await??; + let params = notification + .params + .context("skills/changed params must be present")?; + let notification: SkillsChangedNotification = serde_json::from_value(params)?; + assert_eq!(notification, SkillsChangedNotification {}); + Ok(()) +} + fn write_plugins_enabled_config_with_base_url( codex_home: &std::path::Path, base_url: &str, @@ -117,7 +137,7 @@ fn write_plugin_with_skill( fn write_cached_remote_plugin_with_skill( codex_home: &std::path::Path, ) -> Result { - let plugin_root = codex_home.join("plugins/cache/chatgpt-global/linear/local"); + let plugin_root = codex_home.join("plugins/cache/openai-curated-remote/linear/local"); std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; std::fs::write( plugin_root.join(".codex-plugin/plugin.json"), @@ -222,7 +242,7 @@ async fn skills_list_loads_remote_installed_plugin_skills_from_cache() -> Result .mount(&server) .await; } - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let stale_skills_list_request_id = mcp @@ -346,7 +366,7 @@ async fn skills_list_excludes_plugin_skills_when_workspace_codex_plugins_disable .mount(&server) .await; - let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -392,7 +412,7 @@ async fn skills_list_skips_cwd_roots_when_environment_disabled() -> Result<()> { "---\nname: repo-skill\ndescription: from repo root\n---\n\n# Body\n", )?; - let mut mcp = McpProcess::new_with_env( + let mut mcp = TestAppServer::new_with_env( codex_home.path(), &[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))], ) @@ -436,7 +456,7 @@ async fn skills_list_accepts_relative_cwds() -> Result<()> { let relative_cwd = std::path::PathBuf::from("relative-cwd"); std::fs::create_dir_all(codex_home.path().join(&relative_cwd))?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -464,7 +484,7 @@ async fn skills_list_preserves_requested_cwd_order() -> Result<()> { let first_cwd = TempDir::new()?; let second_cwd = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -500,7 +520,7 @@ async fn skills_list_uses_cached_result_until_force_reload() -> Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; // Seed the cwd cache before the cwd-local skill exists. @@ -573,6 +593,150 @@ async fn skills_list_uses_cached_result_until_force_reload() -> Result<()> { Ok(()) } +#[tokio::test] +async fn skills_extra_roots_set_updates_process_runtime_roots() -> Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let extra_root = TempDir::new()?; + let extra_skills_root = extra_root.path().join("skills"); + let skill_dir = extra_skills_root.join("runtime-skill"); + std::fs::create_dir_all(&skill_dir)?; + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: runtime-skill\ndescription: runtime skill\n---\n\n# Body\n", + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let set_request_id = mcp + .send_skills_extra_roots_set_request(SkillsExtraRootsSetParams { + extra_roots: vec![AbsolutePathBuf::from_absolute_path(&extra_skills_root)?], + }) + .await?; + let set_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(set_request_id)), + ) + .await??; + let _: SkillsExtraRootsSetResponse = to_response(set_response)?; + expect_skills_changed_notification(&mut mcp, DEFAULT_TIMEOUT).await?; + + let skills_request_id = mcp + .send_skills_list_request(SkillsListParams { + cwds: vec![cwd.path().to_path_buf()], + force_reload: false, + }) + .await?; + let skills_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(skills_request_id)), + ) + .await??; + let SkillsListResponse { data } = to_response(skills_response)?; + assert_eq!(data.len(), 1); + assert_eq!(data[0].errors, Vec::new()); + assert!( + data[0] + .skills + .iter() + .any(|skill| skill.name == "runtime-skill") + ); + + let missing_root = extra_root.path().join("missing-skills"); + let reset_request_id = mcp + .send_skills_extra_roots_set_request(SkillsExtraRootsSetParams { + extra_roots: vec![AbsolutePathBuf::from_absolute_path(&missing_root)?], + }) + .await?; + let reset_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(reset_request_id)), + ) + .await??; + let _: SkillsExtraRootsSetResponse = to_response(reset_response)?; + expect_skills_changed_notification(&mut mcp, DEFAULT_TIMEOUT).await?; + + let skills_request_id = mcp + .send_skills_list_request(SkillsListParams { + cwds: vec![cwd.path().to_path_buf()], + force_reload: false, + }) + .await?; + let skills_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(skills_request_id)), + ) + .await??; + let SkillsListResponse { data } = to_response(skills_response)?; + assert_eq!(data.len(), 1); + assert_eq!(data[0].errors, Vec::new()); + assert!( + data[0] + .skills + .iter() + .all(|skill| skill.name != "runtime-skill") + ); + + let clear_request_id = mcp + .send_skills_extra_roots_set_request(SkillsExtraRootsSetParams { + extra_roots: Vec::new(), + }) + .await?; + let clear_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(clear_request_id)), + ) + .await??; + let _: SkillsExtraRootsSetResponse = to_response(clear_response)?; + expect_skills_changed_notification(&mut mcp, DEFAULT_TIMEOUT).await?; + let skills_request_id = mcp + .send_skills_list_request(SkillsListParams { + cwds: vec![cwd.path().to_path_buf()], + force_reload: false, + }) + .await?; + let skills_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(skills_request_id)), + ) + .await??; + let SkillsListResponse { data } = to_response(skills_response)?; + assert_eq!(data.len(), 1); + assert_eq!(data[0].errors, Vec::new()); + assert!( + data[0] + .skills + .iter() + .all(|skill| skill.name != "runtime-skill") + ); + + drop(mcp); + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let skills_request_id = mcp + .send_skills_list_request(SkillsListParams { + cwds: vec![cwd.path().to_path_buf()], + force_reload: false, + }) + .await?; + let skills_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(skills_request_id)), + ) + .await??; + let SkillsListResponse { data } = to_response(skills_response)?; + assert_eq!(data.len(), 1); + assert_eq!(data[0].errors, Vec::new()); + assert!( + data[0] + .skills + .iter() + .all(|skill| skill.name != "runtime-skill") + ); + Ok(()) +} + #[tokio::test] async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -585,7 +749,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<( write_skill(&codex_home, "demo")?; let mut mcp = - McpProcess::new_with_env(codex_home.path(), &[(CODEX_EXEC_SERVER_URL_ENV_VAR, None)]) + TestAppServer::new_with_env(codex_home.path(), &[(CODEX_EXEC_SERVER_URL_ENV_VAR, None)]) .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let initial_skills_request_id = mcp @@ -614,6 +778,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<( model_provider: None, service_tier: None, cwd: None, + runtime_workspace_roots: None, approval_policy: None, approvals_reviewer: None, sandbox: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_archive.rs b/codex-rs/app-server/tests/suite/v2/thread_archive.rs index b441a23cb6..7bf6fc9bc5 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_archive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_archive.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_fake_rollout; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::to_response; @@ -38,7 +38,7 @@ async fn thread_archive_requires_materialized_rollout() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // Start a thread. @@ -93,6 +93,7 @@ async fn thread_archive_requires_materialized_rollout() -> Result<()> { let turn_start_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "materialize".to_string(), text_elements: Vec::new(), @@ -220,7 +221,7 @@ async fn thread_archive_archives_spawned_descendants() -> Result<()> { ) .await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let archive_id = mcp @@ -341,7 +342,7 @@ async fn thread_archive_succeeds_when_descendant_archive_fails() -> Result<()> { .join(child_rollout_path.file_name().expect("rollout file name")); std::fs::create_dir_all(&archived_child_path)?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let archive_id = mcp @@ -445,7 +446,7 @@ async fn thread_archive_succeeds_when_spawned_descendant_is_missing() -> Result< ) .await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let archive_id = mcp @@ -498,7 +499,7 @@ async fn thread_archive_clears_stale_subscriptions_before_resume() -> Result<()> let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut primary = McpProcess::new(codex_home.path()).await?; + let mut primary = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary @@ -517,6 +518,7 @@ async fn thread_archive_clears_stale_subscriptions_before_resume() -> Result<()> let turn_start_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "materialize".to_string(), text_elements: Vec::new(), @@ -537,7 +539,7 @@ async fn thread_archive_clears_stale_subscriptions_before_resume() -> Result<()> .await??; primary.clear_message_buffer(); - let mut secondary = McpProcess::new(codex_home.path()).await?; + let mut secondary = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, secondary.initialize()).await??; let archive_id = primary @@ -594,6 +596,7 @@ async fn thread_archive_clears_stale_subscriptions_before_resume() -> Result<()> let resumed_turn_id = secondary .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![UserInput::Text { text: "secondary turn".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 3eb262bd2b..57a24fc9e7 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -1,6 +1,6 @@ use anyhow::Result; use app_test_support::ChatGptAuthFixture; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_fake_rollout; use app_test_support::create_fake_rollout_with_token_usage; use app_test_support::create_mock_responses_server_repeating_assistant; @@ -83,7 +83,7 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { ); let original_contents = std::fs::read_to_string(&original_path)?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -241,7 +241,7 @@ async fn thread_fork_can_load_source_by_path() -> Result<()> { "rollout-2025-01-05T12-00-00-{conversation_id}.jsonl" )); - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -281,7 +281,7 @@ async fn thread_fork_emits_restored_token_usage_before_next_turn() -> Result<()> Some("mock_provider"), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -335,7 +335,7 @@ async fn thread_fork_can_exclude_turns_and_skip_restored_token_usage() -> Result Some("mock_provider"), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -386,7 +386,7 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -405,7 +405,14 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?; let event = thread_initialized_event(&payload)?; - assert_basic_thread_initialized_event(event, &thread.id, "mock-model", "forked", "user"); + assert_basic_thread_initialized_event( + event, + &thread.id, + &thread.session_id, + "mock-model", + "forked", + "user", + ); Ok(()) } @@ -415,7 +422,7 @@ async fn thread_fork_rejects_unmaterialized_thread() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -454,6 +461,46 @@ async fn thread_fork_rejects_unmaterialized_thread() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_fork_with_empty_path_uses_thread_id() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let conversation_id = create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some("mock_provider"), + /*git_info*/ None, + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: conversation_id.clone(), + path: Some(std::path::PathBuf::new()), + thread_source: Some(ThreadSource::User), + ..Default::default() + }) + .await?; + let fork_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(fork_id)), + ) + .await??; + let ThreadForkResponse { thread, .. } = to_response::(fork_resp)?; + + assert_eq!( + thread.forked_from_id.as_deref(), + Some(conversation_id.as_str()) + ); + Ok(()) +} + #[tokio::test] async fn thread_fork_surfaces_cloud_requirements_load_errors() -> Result<()> { let server = MockServer::start().await; @@ -503,7 +550,7 @@ async fn thread_fork_surfaces_cloud_requirements_load_errors() -> Result<()> { )?; let refresh_token_url = format!("{}/oauth/token", server.uri()); - let mut mcp = McpProcess::new_with_env( + let mut mcp = TestAppServer::new_with_env( codex_home.path(), &[ ("OPENAI_API_KEY", None), @@ -566,7 +613,7 @@ async fn thread_fork_ephemeral_remains_pathless_and_omits_listing() -> Result<() /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -700,6 +747,7 @@ async fn thread_fork_ephemeral_remains_pathless_and_omits_listing() -> Result<() let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: fork_thread_id, + client_user_message_id: None, input: vec![UserInput::Text { text: "continue".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs b/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs index 5a45e81e1d..809358cc84 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs @@ -1,6 +1,6 @@ use anyhow::Context; use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -36,7 +36,7 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history() -> Resu let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -92,6 +92,7 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history() -> Resu let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -151,7 +152,7 @@ async fn thread_inject_items_adds_raw_response_items_after_a_turn() -> Result<() let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -170,6 +171,7 @@ async fn thread_inject_items_adds_raw_response_items_after_a_turn() -> Result<() let first_turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "First turn".to_string(), text_elements: Vec::new(), @@ -215,6 +217,7 @@ async fn thread_inject_items_adds_raw_response_items_after_a_turn() -> Result<() let second_turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Second turn".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 80254d8f47..0b49647128 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -1,5 +1,6 @@ use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; +use app_test_support::create_fake_parented_rollout_with_source; use app_test_support::create_fake_rollout; use app_test_support::create_fake_rollout_with_source; use app_test_support::create_final_assistant_message_sse_response; @@ -17,6 +18,7 @@ use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::SortDirection; use codex_app_server_protocol::ThreadListCwdFilter; use codex_app_server_protocol::ThreadListResponse; +use codex_app_server_protocol::ThreadSearchResponse; use codex_app_server_protocol::ThreadSortKey; use codex_app_server_protocol::ThreadSourceKind; use codex_app_server_protocol::ThreadStartParams; @@ -46,14 +48,14 @@ use uuid::Uuid; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); -async fn init_mcp(codex_home: &Path) -> Result { - let mut mcp = McpProcess::new(codex_home).await?; +async fn init_mcp(codex_home: &Path) -> Result { + let mut mcp = TestAppServer::new(codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; Ok(mcp) } async fn list_threads( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, cursor: Option, limit: Option, providers: Option>, @@ -73,7 +75,7 @@ async fn list_threads( } async fn list_threads_with_sort( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, cursor: Option, limit: Option, providers: Option>, @@ -226,6 +228,7 @@ async fn thread_list_reports_system_error_idle_flag_after_failed_turn() -> Resul let seed_turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "seed history".to_string(), text_elements: Vec::new(), @@ -248,6 +251,7 @@ async fn thread_list_reports_system_error_idle_flag_after_failed_turn() -> Resul let failed_turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "fail turn".to_string(), text_elements: Vec::new(), @@ -660,6 +664,161 @@ sqlite = true Ok(()) } +#[tokio::test] +async fn thread_search_returns_content_matches() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let older_match = create_fake_rollout( + codex_home.path(), + "2025-01-02T10-00-00", + "2025-01-02T10:00:00Z", + "match: needle", + Some("mock_provider"), + /*git_info*/ None, + )?; + let _non_match = create_fake_rollout( + codex_home.path(), + "2025-01-02T11-00-00", + "2025-01-02T11:00:00Z", + "no hit here", + Some("mock_provider"), + /*git_info*/ None, + )?; + let newer_match = create_fake_rollout( + codex_home.path(), + "2025-01-02T12-00-00", + "2025-01-02T12:00:00Z", + "mixed NEEDLE suffix", + Some("mock_provider"), + /*git_info*/ None, + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + let request_id = mcp + .send_thread_search_request(codex_app_server_protocol::ThreadSearchParams { + cursor: None, + limit: Some(10), + sort_key: None, + sort_direction: None, + source_kinds: None, + archived: None, + search_term: "needle".to_string(), + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ThreadSearchResponse { + data, next_cursor, .. + } = to_response::(resp)?; + + assert_eq!(next_cursor, None); + let ids: Vec<_> = data + .iter() + .map(|result| result.thread.id.as_str()) + .collect(); + assert_eq!(ids, vec![newer_match, older_match]); + assert_eq!(data[0].snippet, "mixed NEEDLE suffix"); + + Ok(()) +} + +#[tokio::test] +async fn thread_search_matches_json_escaped_content() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let search_term = r#"quoted "needle" \ path"#; + let thread_id = create_fake_rollout( + codex_home.path(), + "2025-01-02T10-00-00", + "2025-01-02T10:00:00Z", + search_term, + Some("mock_provider"), + /*git_info*/ None, + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + let request_id = mcp + .send_thread_search_request(codex_app_server_protocol::ThreadSearchParams { + cursor: None, + limit: Some(10), + sort_key: None, + sort_direction: None, + source_kinds: None, + archived: None, + search_term: search_term.to_string(), + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ThreadSearchResponse { data, .. } = to_response::(resp)?; + + assert_eq!(data.len(), 1); + assert_eq!(data[0].thread.id, thread_id); + assert_eq!(data[0].snippet, search_term); + + Ok(()) +} + +#[tokio::test] +async fn thread_search_filters_by_source_kind() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let cli_id = create_fake_rollout( + codex_home.path(), + "2025-02-01T10-00-00", + "2025-02-01T10:00:00Z", + "shared needle", + Some("mock_provider"), + /*git_info*/ None, + )?; + let exec_id = create_fake_rollout_with_source( + codex_home.path(), + "2025-02-01T11-00-00", + "2025-02-01T11:00:00Z", + "shared needle", + Some("mock_provider"), + /*git_info*/ None, + CoreSessionSource::Exec, + )?; + + let mut mcp = init_mcp(codex_home.path()).await?; + let request_id = mcp + .send_thread_search_request(codex_app_server_protocol::ThreadSearchParams { + cursor: None, + limit: Some(10), + sort_key: None, + sort_direction: None, + source_kinds: Some(vec![ThreadSourceKind::Exec]), + archived: None, + search_term: "needle".to_string(), + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ThreadSearchResponse { data, .. } = to_response::(resp)?; + + let ids: Vec<_> = data + .iter() + .map(|result| result.thread.id.as_str()) + .collect(); + assert_eq!(ids, vec![exec_id.as_str()]); + assert_ne!(cli_id, exec_id); + + Ok(()) +} + #[tokio::test] async fn thread_list_state_db_only_returns_sqlite_without_jsonl_repair() -> Result<()> { let codex_home = TempDir::new()?; @@ -891,7 +1050,7 @@ async fn thread_list_filters_by_subagent_variant() -> Result<()> { let parent_thread_id = ThreadId::from_string(&Uuid::new_v4().to_string())?; - let review_id = create_fake_rollout_with_source( + let review_id = create_fake_parented_rollout_with_source( codex_home.path(), "2025-02-02T09-00-00", "2025-02-02T09:00:00Z", @@ -899,6 +1058,7 @@ async fn thread_list_filters_by_subagent_variant() -> Result<()> { Some("mock_provider"), /*git_info*/ None, CoreSessionSource::SubAgent(SubAgentSource::Review), + parent_thread_id, )?; let compact_id = create_fake_rollout_with_source( codex_home.path(), @@ -951,6 +1111,10 @@ async fn thread_list_filters_by_subagent_variant() -> Result<()> { .map(|thread| thread.id.as_str()) .collect(); assert_eq!(review_ids, vec![review_id.as_str()]); + assert_eq!( + review.data[0].parent_thread_id, + Some(parent_thread_id.to_string()) + ); let compact = list_threads( &mut mcp, diff --git a/codex-rs/app-server/tests/suite/v2/thread_loaded_list.rs b/codex-rs/app-server/tests/suite/v2/thread_loaded_list.rs index 245cfafb49..ef0c5d986b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_loaded_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_loaded_list.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCResponse; @@ -21,7 +21,7 @@ async fn thread_loaded_list_returns_loaded_thread_ids() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_id = start_thread(&mut mcp).await?; @@ -51,7 +51,7 @@ async fn thread_loaded_list_paginates() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let first = start_thread(&mut mcp).await?; @@ -122,7 +122,7 @@ stream_max_retries = 0 ) } -async fn start_thread(mcp: &mut McpProcess) -> Result { +async fn start_thread(mcp: &mut TestAppServer) -> Result { let req_id = mcp .send_thread_start_request(ThreadStartParams { model: Some("gpt-5.2".to_string()), diff --git a/codex-rs/app-server/tests/suite/v2/thread_memory_mode_set.rs b/codex-rs/app-server/tests/suite/v2/thread_memory_mode_set.rs index bf9bba7b2f..c93e966cc3 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_memory_mode_set.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_memory_mode_set.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_fake_rollout; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::to_response; @@ -27,7 +27,7 @@ async fn thread_memory_mode_set_updates_loaded_thread_state() -> Result<()> { create_config_toml(codex_home.path(), &server.uri())?; let state_db = init_state_db(codex_home.path()).await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -79,7 +79,7 @@ async fn thread_memory_mode_set_updates_stored_thread_state() -> Result<()> { )?; let thread_uuid = ThreadId::from_string(&thread_id)?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; for mode in [ThreadMemoryMode::Disabled, ThreadMemoryMode::Enabled] { diff --git a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs index c78e9b8152..fb61973466 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_fake_rollout; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::rollout_path; @@ -41,7 +41,7 @@ async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread() let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -136,7 +136,7 @@ async fn thread_metadata_update_rejects_empty_git_info_patch() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -182,7 +182,7 @@ async fn thread_metadata_update_rejects_ephemeral_thread() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -244,7 +244,7 @@ async fn thread_metadata_update_repairs_missing_sqlite_row_for_stored_thread() - /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let update_id = mcp @@ -309,7 +309,7 @@ async fn thread_metadata_update_repairs_loaded_thread_without_resetting_summary( ) .await; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -387,7 +387,7 @@ async fn thread_metadata_update_repairs_missing_sqlite_row_for_archived_thread() ); fs::rename(&archived_source, &archived_dest)?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let update_id = mcp @@ -443,7 +443,7 @@ async fn thread_metadata_update_can_clear_stored_git_fields() -> Result<()> { )?; let _state_db = init_state_db(codex_home.path()).await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let update_id = mcp diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index fa143254a5..773b85f4a5 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_fake_rollout_with_text_elements; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::rollout_path; @@ -24,6 +24,7 @@ use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadNameUpdatedNotification; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; +use codex_app_server_protocol::ThreadResumeInitialTurnsPageParams; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadSetNameParams; @@ -103,7 +104,7 @@ async fn thread_read_returns_summary_without_turns() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let read_id = mcp @@ -158,7 +159,7 @@ async fn thread_read_can_include_turns() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let read_id = mcp @@ -216,7 +217,7 @@ async fn thread_turns_list_can_page_backward_and_forward() -> Result<()> { append_user_message(rollout_path.as_path(), "2025-01-05T12:01:00Z", "second")?; append_user_message(rollout_path.as_path(), "2025-01-05T12:02:00Z", "third")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let read_id = mcp @@ -305,7 +306,7 @@ async fn thread_turns_list_supports_requested_items_view() -> Result<()> { append_agent_message(rollout_path.as_path(), "2025-01-05T12:01:00Z", "draft")?; append_agent_message(rollout_path.as_path(), "2025-01-05T12:02:00Z", "final")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let full = read_single_turn_items_view( @@ -607,7 +608,7 @@ async fn thread_read_can_return_archived_threads_by_id() -> Result<()> { archived_dir.join(active_rollout_path.file_name().expect("rollout file name")); std::fs::rename(&active_rollout_path, &archived_rollout_path)?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let read_id = mcp @@ -631,6 +632,77 @@ async fn thread_read_can_return_archived_threads_by_id() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_resume_initial_turns_page_matches_requested_turns_list_page() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let filename_ts = "2025-01-05T12-00-00"; + let conversation_id = create_fake_rollout_with_text_elements( + codex_home.path(), + filename_ts, + "2025-01-05T12:00:00Z", + "first", + vec![], + Some("mock_provider"), + /*git_info*/ None, + )?; + let rollout_path = rollout_path(codex_home.path(), filename_ts, &conversation_id); + append_user_message(rollout_path.as_path(), "2025-01-05T12:01:00Z", "second")?; + append_user_message(rollout_path.as_path(), "2025-01-05T12:02:00Z", "third")?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let turns_list_id = mcp + .send_thread_turns_list_request(ThreadTurnsListParams { + thread_id: conversation_id.clone(), + cursor: None, + limit: Some(2), + sort_direction: Some(SortDirection::Asc), + items_view: Some(TurnItemsView::NotLoaded), + }) + .await?; + let turns_list_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turns_list_id)), + ) + .await??; + let expected_page = to_response::(turns_list_resp)?; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: conversation_id, + exclude_turns: true, + initial_turns_page: Some(ThreadResumeInitialTurnsPageParams { + limit: Some(2), + sort_direction: Some(SortDirection::Asc), + items_view: Some(TurnItemsView::NotLoaded), + }), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + thread, + initial_turns_page, + .. + } = to_response::(resume_resp)?; + + assert!(thread.turns.is_empty()); + assert_eq!( + initial_turns_page, + Some(codex_app_server_protocol::TurnsPage::from(expected_page)) + ); + + Ok(()) +} + #[tokio::test] async fn thread_turns_list_rejects_cursor_when_anchor_turn_is_rolled_back() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -651,7 +723,7 @@ async fn thread_turns_list_rejects_cursor_when_anchor_turn_is_rolled_back() -> R append_user_message(rollout_path.as_path(), "2025-01-05T12:01:00Z", "second")?; append_user_message(rollout_path.as_path(), "2025-01-05T12:02:00Z", "third")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let read_id = mcp @@ -718,7 +790,7 @@ async fn thread_read_returns_forked_from_id_for_forked_threads() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let fork_id = mcp @@ -758,7 +830,7 @@ async fn thread_read_loaded_thread_returns_precomputed_path_before_materializati let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -818,7 +890,7 @@ async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // Set a user-facing thread title. @@ -964,7 +1036,7 @@ async fn thread_read_include_turns_rejects_unmaterialized_loaded_thread() -> Res let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -1015,7 +1087,7 @@ async fn thread_turns_list_rejects_unmaterialized_loaded_thread() -> Result<()> let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -1069,7 +1141,7 @@ async fn thread_turns_items_list_returns_unsupported() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let read_id = mcp @@ -1107,7 +1179,7 @@ async fn thread_read_reports_system_error_idle_flag_after_failed_turn() -> Resul let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -1126,6 +1198,7 @@ async fn thread_read_reports_system_error_idle_flag_after_failed_turn() -> Resul let turn_start_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "fail this turn".to_string(), text_elements: Vec::new(), @@ -1216,7 +1289,7 @@ fn append_thread_rollback(path: &Path, timestamp: &str, num_turns: u32) -> std:: } async fn read_single_turn_items_view( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, thread_id: &str, items_view: Option, ) -> anyhow::Result { @@ -1285,6 +1358,7 @@ async fn seed_pathless_store_thread( .create_thread(CreateThreadParams { thread_id, forked_from_id: None, + parent_thread_id: None, source: ProtocolSessionSource::Cli, thread_source: None, base_instructions: BaseInstructions::default(), @@ -1319,10 +1393,12 @@ async fn seed_pathless_store_thread( fn store_history_items() -> Vec { vec![RolloutItem::EventMsg(EventMsg::UserMessage( UserMessageEvent { + client_id: None, message: "history from store".to_string(), images: None, local_images: Vec::new(), text_elements: Vec::new(), + ..Default::default() }, ))] } diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 01e0cfd58d..38dad9b143 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -1,6 +1,6 @@ use anyhow::Result; use app_test_support::ChatGptAuthFixture; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_fake_rollout; use app_test_support::create_fake_rollout_with_text_elements; @@ -37,18 +37,21 @@ use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams; use codex_app_server_protocol::ThreadMetadataUpdateParams; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; +use codex_app_server_protocol::ThreadResumeInitialTurnsPageParams; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadSource; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus; +use codex_app_server_protocol::ThreadUnsubscribeParams; use codex_app_server_protocol::TurnItemsView; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; use codex_config::types::AuthCredentialsStoreMode; +use codex_core::ARCHIVED_SESSIONS_SUBDIR; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use codex_protocol::ThreadId; use codex_protocol::config_types::Personality; @@ -143,7 +146,7 @@ async fn thread_resume_rejects_unmaterialized_thread() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // Start a thread. @@ -184,6 +187,145 @@ async fn thread_resume_rejects_unmaterialized_thread() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_resume_with_empty_path_uses_running_thread_id() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.4".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: vec![UserInput::Text { + text: "materialize rollout".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id.clone(), + path: Some(PathBuf::new()), + exclude_turns: true, + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + thread: resumed, .. + } = to_response::(resume_resp)?; + + assert_eq!(resumed.id, thread.id); + Ok(()) +} + +#[tokio::test] +async fn turn_start_updates_runtime_workspace_roots_for_loaded_thread() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let extra_root_tmp = TempDir::new()?; + let extra_root = extra_root_tmp.path().join("extra-root"); + std::fs::create_dir_all(&extra_root)?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.4".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: vec![UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + runtime_workspace_roots: Some(vec![extra_root.clone(), extra_root.join(".")]), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id, + exclude_turns: true, + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + runtime_workspace_roots, + .. + } = to_response::(resume_resp)?; + + assert_eq!( + runtime_workspace_roots, + vec![AbsolutePathBuf::from_absolute_path(extra_root)?] + ); + + Ok(()) +} + #[tokio::test] async fn thread_goal_get_rejects_unmaterialized_thread() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -196,7 +338,7 @@ async fn thread_goal_get_rejects_unmaterialized_thread() -> Result<()> { config.replace("personality = true\n", "personality = true\ngoals = true\n"), )?; - let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -261,7 +403,7 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { "user", )?; - let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -284,7 +426,14 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?; let event = thread_initialized_event(&payload)?; - assert_basic_thread_initialized_event(event, &thread.id, "gpt-5.3-codex", "resumed", "user"); + assert_basic_thread_initialized_event( + event, + &thread.id, + &thread.session_id, + "gpt-5.3-codex", + "resumed", + "user", + ); assert_eq!(event["event_params"]["thread_source"], "user"); Ok(()) } @@ -332,7 +481,7 @@ async fn thread_resume_returns_rollout_history() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -385,48 +534,59 @@ async fn thread_resume_returns_rollout_history() -> Result<()> { #[tokio::test] async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<()> { for client_name in ["codex_chatgpt_android_remote", "codex_chatgpt_ios_remote"] { - let remote_thread = resume_redaction_fixture(Some(client_name)).await?; - let remote_turn = remote_thread + let remote_resume = resume_redaction_fixture(Some(client_name)).await?; + let remote_turn = remote_resume + .thread .turns .first() .expect("remote resume should include a turn"); - let remote_mcp_item = remote_turn - .items - .iter() - .find(|item| matches!(item, ThreadItem::McpToolCall { .. })) - .expect("remote resume should include redacted MCP item"); - let ThreadItem::McpToolCall { - arguments, - result, - error, - .. - } = remote_mcp_item - else { - unreachable!("matched MCP item"); - }; - assert_eq!(arguments, &json!("[redacted]")); - let result = result.as_ref().expect("redacted MCP result"); - assert_eq!( - result.content, - vec![json!({ - "type": "text", - "text": "[redacted]", - })] - ); - assert_eq!(result.structured_content, None); - assert_eq!(result.meta, None); - assert_eq!(error, &None); - assert!( - !remote_turn + let remote_page_turn = remote_resume + .initial_turns_page + .as_ref() + .expect("remote resume should include the requested initial turns page") + .data + .first() + .expect("remote initial turns page should include a turn"); + for remote_turn in [remote_turn, remote_page_turn] { + let remote_mcp_item = remote_turn .items .iter() - .any(|item| matches!(item, ThreadItem::ImageGeneration { .. })), - "remote resume should drop image generation items for {client_name}" - ); + .find(|item| matches!(item, ThreadItem::McpToolCall { .. })) + .expect("remote resume should include redacted MCP item"); + let ThreadItem::McpToolCall { + arguments, + result, + error, + .. + } = remote_mcp_item + else { + unreachable!("matched MCP item"); + }; + assert_eq!(arguments, &json!("[redacted]")); + let result = result.as_ref().expect("redacted MCP result"); + assert_eq!( + result.content, + vec![json!({ + "type": "text", + "text": "[redacted]", + })] + ); + assert_eq!(result.structured_content, None); + assert_eq!(result.meta, None); + assert_eq!(error, &None); + assert!( + !remote_turn + .items + .iter() + .any(|item| matches!(item, ThreadItem::ImageGeneration { .. })), + "remote resume should drop image generation items for {client_name}" + ); + } } - let normal_thread = resume_redaction_fixture(Some("some_other_client")).await?; - let normal_turn = normal_thread + let normal_resume = resume_redaction_fixture(Some("some_other_client")).await?; + let normal_turn = normal_resume + .thread .turns .first() .expect("normal resume should include a turn"); @@ -471,9 +631,7 @@ async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<( Ok(()) } -async fn resume_redaction_fixture( - client_name: Option<&str>, -) -> Result { +async fn resume_redaction_fixture(client_name: Option<&str>) -> Result { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; @@ -495,7 +653,7 @@ async fn resume_redaction_fixture( &conversation_id, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; if let Some(client_name) = client_name { let _ = timeout( DEFAULT_READ_TIMEOUT, @@ -513,6 +671,11 @@ async fn resume_redaction_fixture( let resume_id = mcp .send_thread_resume_request(ThreadResumeParams { thread_id: conversation_id, + initial_turns_page: Some(ThreadResumeInitialTurnsPageParams { + limit: None, + sort_direction: None, + items_view: Some(TurnItemsView::Full), + }), ..Default::default() }) .await?; @@ -521,8 +684,7 @@ async fn resume_redaction_fixture( mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), ) .await??; - let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; - Ok(thread) + to_response::(resume_resp) } fn append_resume_redaction_history( @@ -542,6 +704,7 @@ fn append_resume_redaction_history( arguments: Some(json!({"secret":"argument"})), }, mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()), + plugin_id: None, duration: Duration::from_millis(8), result: Ok(CallToolResult { content: vec![json!({ @@ -595,7 +758,7 @@ async fn thread_resume_can_skip_turns_for_metadata_only_resume() -> Result<()> { /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -618,6 +781,57 @@ async fn thread_resume_can_skip_turns_for_metadata_only_resume() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_resume_rejects_archived_session_by_id() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let filename_ts = "2025-01-05T12-00-00"; + let conversation_id = create_fake_rollout_with_text_elements( + codex_home.path(), + filename_ts, + "2025-01-05T12:00:00Z", + "Archived saved user message", + Vec::new(), + Some("mock_provider"), + /*git_info*/ None, + )?; + let active_rollout_path = rollout_path(codex_home.path(), filename_ts, &conversation_id); + let archived_dir = codex_home.path().join(ARCHIVED_SESSIONS_SUBDIR); + std::fs::create_dir_all(&archived_dir)?; + std::fs::rename( + &active_rollout_path, + archived_dir.join(active_rollout_path.file_name().expect("rollout file name")), + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: conversation_id.clone(), + ..Default::default() + }) + .await?; + let resume_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(resume_id)), + ) + .await??; + + let message = resume_err.error.message; + assert!( + message.contains(&format!("session {conversation_id} is archived")) + && message.contains(&format!( + "codex unarchive {conversation_id}` to unarchive it first" + )), + "unexpected resume error: {message}" + ); + + Ok(()) +} + #[tokio::test] async fn thread_resume_keeps_paused_goal_paused() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -630,7 +844,7 @@ async fn thread_resume_keeps_paused_goal_paused() -> Result<()> { config.replace("personality = true\n", "personality = true\ngoals = true\n"), )?; - let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -649,6 +863,7 @@ async fn thread_resume_keeps_paused_goal_paused() -> Result<()> { let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "materialize this thread".to_string(), text_elements: Vec::new(), @@ -734,7 +949,7 @@ async fn thread_goal_set_preserves_budget_limited_same_objective() -> Result<()> config.replace("personality = true\n", "personality = true\ngoals = true\n"), )?; - let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -753,6 +968,7 @@ async fn thread_goal_set_preserves_budget_limited_same_objective() -> Result<()> let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "materialize this thread".to_string(), text_elements: Vec::new(), @@ -820,6 +1036,93 @@ async fn thread_goal_set_preserves_budget_limited_same_objective() -> Result<()> Ok(()) } +#[tokio::test] +async fn thread_goal_set_persists_resumable_stopped_statuses() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config.replace("personality = true\n", "personality = true\ngoals = true\n"), + )?; + + let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.2-codex".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: vec![UserInput::Text { + text: "materialize this thread".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let _turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + for (wire_status, expected_status) in [ + ("blocked", ThreadGoalStatus::Blocked), + ("usageLimited", ThreadGoalStatus::UsageLimited), + ] { + let goal_id = mcp + .send_raw_request( + "thread/goal/set", + Some(json!({ + "threadId": thread.id.clone(), + "objective": "keep polishing", + "status": wire_status, + })), + ) + .await?; + let goal_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(goal_id)), + ) + .await??; + let goal: ThreadGoalSetResponse = to_response(goal_resp)?; + assert_eq!(goal.goal.status, expected_status); + + let notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/goal/updated"), + ) + .await??; + let notification: ServerNotification = notification.try_into()?; + let ServerNotification::ThreadGoalUpdated(notification) = notification else { + anyhow::bail!("expected thread goal update notification"); + }; + assert_eq!(notification.goal.status, expected_status); + } + + Ok(()) +} + #[tokio::test] async fn thread_goal_set_edits_objective_without_resetting_usage() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -835,12 +1138,12 @@ async fn thread_goal_set_edits_objective_without_resetting_usage() -> Result<()> codex_home.path(), "2025-01-05T12-00-00", "2025-01-05T12:00:00Z", - "materialized thread", + "", Some("mock_provider"), /*git_info*/ None, )?; - let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let goal_id = mcp @@ -869,16 +1172,23 @@ async fn thread_goal_set_edits_objective_without_resetting_usage() -> Result<()> let state_db = StateRuntime::init(codex_home.path().to_path_buf(), "mock_provider".into()).await?; let thread_id = ThreadId::from_string(&thread_id)?; + let thread_metadata = state_db + .get_thread(thread_id) + .await? + .expect("thread metadata should exist"); + assert_eq!(thread_metadata.preview.as_deref(), Some("keep polishing")); let persisted_goal = state_db + .thread_goals() .get_thread_goal(thread_id) .await? .expect("goal should exist"); state_db + .thread_goals() .account_thread_goal_usage( thread_id, /*time_delta_seconds*/ 12, /*token_delta*/ 50, - codex_state::ThreadGoalAccountingMode::ActiveOnly, + codex_state::GoalAccountingMode::ActiveOnly, Some(persisted_goal.goal_id.as_str()), ) .await?; @@ -901,11 +1211,17 @@ async fn thread_goal_set_edits_objective_without_resetting_usage() -> Result<()> .await??; let edit: ThreadGoalSetResponse = to_response(edit_resp)?; let updated_goal = state_db + .thread_goals() .get_thread_goal(thread_id) .await? .expect("goal should still exist"); + let thread_metadata = state_db + .get_thread(thread_id) + .await? + .expect("thread metadata should still exist"); assert_eq!(persisted_goal.goal_id, updated_goal.goal_id); + assert_eq!(thread_metadata.preview.as_deref(), Some("keep polishing")); assert_eq!(edit.goal.objective, "keep polishing with clearer wording"); assert_eq!(edit.goal.status, ThreadGoalStatus::BudgetLimited); assert_eq!(edit.goal.token_budget, Some(40)); @@ -928,7 +1244,7 @@ async fn thread_goal_clear_deletes_goal_and_notifies() -> Result<()> { config.replace("personality = true\n", "personality = true\ngoals = true\n"), )?; - let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -947,6 +1263,7 @@ async fn thread_goal_clear_deletes_goal_and_notifies() -> Result<()> { let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "materialize this thread".to_string(), text_elements: Vec::new(), @@ -1057,7 +1374,7 @@ async fn thread_resume_emits_restored_token_usage_before_next_turn() -> Result<( Some("mock_provider"), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -1110,7 +1427,7 @@ async fn thread_resume_skips_restored_token_usage_when_turns_are_excluded() -> R Some("mock_provider"), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let first_resume_id = mcp @@ -1194,6 +1511,7 @@ async fn thread_resume_token_usage_replay_ignores_stale_interrupted_tail_turn() "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: stale_turn_id.to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -1217,7 +1535,7 @@ async fn thread_resume_token_usage_replay_ignores_stale_interrupted_tail_turn() format!("{persisted_rollout}{appended_rollout}\n"), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -1281,6 +1599,7 @@ async fn thread_resume_token_usage_replay_can_belong_to_interrupted_turn() -> Re "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: interrupted_turn_id.to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -1340,7 +1659,7 @@ async fn thread_resume_token_usage_replay_can_belong_to_interrupted_turn() -> Re format!("{persisted_rollout}{appended_rollout}\n"), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -1472,6 +1791,7 @@ stream_max_retries = 0 let session_meta = SessionMeta { id: conversation_id, forked_from_id: None, + parent_thread_id: None, timestamp: "2025-01-05T12:00:00Z".to_string(), cwd: repo_path.clone(), originator: "codex".to_string(), @@ -1529,7 +1849,7 @@ stream_max_retries = 0 .mark_backfill_complete(/*last_watermark*/ None) .await?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let update_id = mcp @@ -1599,6 +1919,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is "type": "event_msg", "payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: Default::default(), @@ -1622,7 +1943,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is format!("{persisted_rollout}{appended_rollout}\n"), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -1690,13 +2011,13 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is } #[tokio::test] -async fn thread_resume_without_overrides_does_not_change_updated_at_or_mtime() -> Result<()> { +async fn thread_resume_defers_updated_at_until_turn_start() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; let rollout = setup_rollout_fixture(codex_home.path(), &server.uri())?; let thread_id = rollout.conversation_id.clone(); - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let read_id = mcp @@ -1734,6 +2055,33 @@ async fn thread_resume_without_overrides_does_not_change_updated_at_or_mtime() - let after_modified = std::fs::metadata(&rollout.rollout_file_path)?.modified()?; assert_eq!(after_modified, rollout.before_modified); + let unsubscribe_id = mcp + .send_thread_unsubscribe_request(ThreadUnsubscribeParams { + thread_id: thread_id.clone(), + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(unsubscribe_id)), + ) + .await??; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: "not-a-valid-thread-id".to_string(), + path: Some(normalized_existing_path(&rollout.rollout_file_path)?), + cwd: Some(codex_home.path().to_string_lossy().to_string()), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { cwd, .. } = to_response::(resume_resp)?; + assert_eq!(cwd, AbsolutePathBuf::from_absolute_path(codex_home.path())?); + let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id, @@ -1767,7 +2115,7 @@ async fn thread_resume_keeps_in_flight_turn_streaming() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut primary = McpProcess::new(codex_home.path()).await?; + let mut primary = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary @@ -1786,6 +2134,7 @@ async fn thread_resume_keeps_in_flight_turn_streaming() -> Result<()> { let seed_turn_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "seed history".to_string(), text_elements: Vec::new(), @@ -1805,12 +2154,13 @@ async fn thread_resume_keeps_in_flight_turn_streaming() -> Result<()> { .await??; primary.clear_message_buffer(); - let mut secondary = McpProcess::new(codex_home.path()).await?; + let mut secondary = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, secondary.initialize()).await??; let turn_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "respond with docs".to_string(), text_elements: Vec::new(), @@ -1874,7 +2224,7 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut primary = McpProcess::new(codex_home.path()).await?; + let mut primary = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary @@ -1893,6 +2243,7 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> { let seed_turn_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "seed history".to_string(), text_elements: Vec::new(), @@ -1916,6 +2267,7 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> { let running_turn_request_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread_id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "keep running".to_string(), text_elements: Vec::new(), @@ -1972,7 +2324,7 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> { } #[tokio::test] -async fn thread_resume_uses_path_over_thread_id_when_thread_is_running() -> Result<()> { +async fn thread_resume_rejects_mismatched_path_for_running_thread_id() -> Result<()> { let server = responses::start_mock_server().await; let first_body = responses::sse(vec![ responses::ev_response_created("resp-1"), @@ -1990,7 +2342,7 @@ async fn thread_resume_uses_path_over_thread_id_when_thread_is_running() -> Resu let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut primary = McpProcess::new(codex_home.path()).await?; + let mut primary = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary @@ -2009,6 +2361,7 @@ async fn thread_resume_uses_path_over_thread_id_when_thread_is_running() -> Resu let seed_turn_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "seed history".to_string(), text_elements: Vec::new(), @@ -2032,6 +2385,7 @@ async fn thread_resume_uses_path_over_thread_id_when_thread_is_running() -> Resu let running_turn_request_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread_id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "keep running".to_string(), text_elements: Vec::new(), @@ -2052,10 +2406,10 @@ async fn thread_resume_uses_path_over_thread_id_when_thread_is_running() -> Resu ) .await??; - let other_thread_id = ThreadId::new().to_string(); - let stale_path = rollout_path(codex_home.path(), "2025-01-01T00-00-00", &thread_id); + let stale_thread_id = Uuid::new_v4().to_string(); + let stale_path = rollout_path(codex_home.path(), "2025-01-01T00-00-00", &stale_thread_id); std::fs::create_dir_all(stale_path.parent().expect("stale path parent"))?; - let thread_uuid = Uuid::parse_str(&thread_id)?; + let thread_uuid = Uuid::parse_str(&stale_thread_id)?; let mut stale_file = std::fs::File::create(&stale_path)?; let stale_meta = json!({ "timestamp": "2025-01-01T00:00:00Z", @@ -2084,7 +2438,7 @@ async fn thread_resume_uses_path_over_thread_id_when_thread_is_running() -> Resu let stale_resume_id = primary .send_thread_resume_request(ThreadResumeParams { - thread_id: other_thread_id.clone(), + thread_id: thread_id.clone(), path: Some(stale_path), ..Default::default() }) @@ -2100,23 +2454,6 @@ async fn thread_resume_uses_path_over_thread_id_when_thread_is_running() -> Resu stale_resume_err.error.message ); - let resume_by_path_id = primary - .send_thread_resume_request(ThreadResumeParams { - thread_id: other_thread_id.clone(), - path: thread.path, - ..Default::default() - }) - .await?; - let resume_by_path_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - primary.read_stream_until_response_message(RequestId::Integer(resume_by_path_id)), - ) - .await??; - let ThreadResumeResponse { - thread: resumed, .. - } = to_response::(resume_by_path_resp)?; - assert_eq!(resumed.id, thread_id); - primary .interrupt_turn_and_wait_for_aborted(thread_id, running_turn.id, DEFAULT_READ_TIMEOUT) .await?; @@ -2143,7 +2480,7 @@ async fn thread_resume_rejoins_running_thread_even_with_override_mismatch() -> R let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut primary = McpProcess::new(codex_home.path()).await?; + let mut primary = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary @@ -2162,6 +2499,7 @@ async fn thread_resume_rejoins_running_thread_even_with_override_mismatch() -> R let seed_turn_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "seed history".to_string(), text_elements: Vec::new(), @@ -2184,6 +2522,7 @@ async fn thread_resume_rejoins_running_thread_even_with_override_mismatch() -> R let running_turn_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "keep running".to_string(), text_elements: Vec::new(), @@ -2191,11 +2530,13 @@ async fn thread_resume_rejoins_running_thread_even_with_override_mismatch() -> R ..Default::default() }) .await?; - timeout( + let running_turn_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, primary.read_stream_until_response_message(RequestId::Integer(running_turn_id)), ) .await??; + let TurnStartResponse { turn: running_turn } = + to_response::(running_turn_resp)?; timeout( DEFAULT_READ_TIMEOUT, primary.read_stream_until_notification_message("turn/started"), @@ -2207,6 +2548,11 @@ async fn thread_resume_rejoins_running_thread_even_with_override_mismatch() -> R thread_id: thread.id.clone(), model: Some("not-the-running-model".to_string()), cwd: Some("/tmp".to_string()), + initial_turns_page: Some(ThreadResumeInitialTurnsPageParams { + limit: None, + sort_direction: None, + items_view: None, + }), ..Default::default() }) .await?; @@ -2215,9 +2561,23 @@ async fn thread_resume_rejoins_running_thread_even_with_override_mismatch() -> R primary.read_stream_until_response_message(RequestId::Integer(resume_id)), ) .await??; - let ThreadResumeResponse { thread, model, .. } = - to_response::(resume_resp)?; + let ThreadResumeResponse { + thread, + model, + initial_turns_page, + .. + } = to_response::(resume_resp)?; assert_eq!(model, "gpt-5.4"); + let initial_turns_page = initial_turns_page.expect("resume should include initial turns page"); + let resumed_running_turn = initial_turns_page + .data + .first() + .expect("resume page should include the running turn"); + assert_eq!(resumed_running_turn.id, running_turn.id); + assert_eq!(resumed_running_turn.items_view, TurnItemsView::Summary); + assert_eq!(resumed_running_turn.status, TurnStatus::InProgress); + assert!(initial_turns_page.backwards_cursor.is_some()); + assert_eq!(initial_turns_page.next_cursor, None); // The running-thread resume response is queued onto the thread listener task. // If the in-flight turn completes before that queued command runs, the response // can legitimately observe the thread as idle. @@ -2251,7 +2611,7 @@ async fn thread_resume_can_skip_turns_when_thread_is_running() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut primary = McpProcess::new(codex_home.path()).await?; + let mut primary = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary @@ -2270,6 +2630,7 @@ async fn thread_resume_can_skip_turns_when_thread_is_running() -> Result<()> { let turn_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "seed history".to_string(), text_elements: Vec::new(), @@ -2288,7 +2649,7 @@ async fn thread_resume_can_skip_turns_when_thread_is_running() -> Result<()> { ) .await??; - let mut secondary = McpProcess::new(codex_home.path()).await?; + let mut secondary = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, secondary.initialize()).await??; let resume_id = secondary @@ -2334,7 +2695,7 @@ async fn thread_resume_replays_pending_command_execution_request_approval() -> R let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut primary = McpProcess::new(codex_home.path()).await?; + let mut primary = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary @@ -2353,6 +2714,7 @@ async fn thread_resume_replays_pending_command_execution_request_approval() -> R let seed_turn_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "seed history".to_string(), text_elements: Vec::new(), @@ -2375,6 +2737,7 @@ async fn thread_resume_replays_pending_command_execution_request_approval() -> R let running_turn_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "run command".to_string(), text_elements: Vec::new(), @@ -2471,7 +2834,7 @@ async fn thread_resume_replays_pending_file_change_request_approval() -> Result< let server = create_mock_responses_server_sequence_unchecked(responses).await; create_config_toml(&codex_home, &server.uri())?; - let mut primary = McpProcess::new(&codex_home).await?; + let mut primary = TestAppServer::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary @@ -2491,6 +2854,7 @@ async fn thread_resume_replays_pending_file_change_request_approval() -> Result< let seed_turn_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "seed history".to_string(), text_elements: Vec::new(), @@ -2514,6 +2878,7 @@ async fn thread_resume_replays_pending_file_change_request_approval() -> Result< let running_turn_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "apply patch".to_string(), text_elements: Vec::new(), @@ -2658,6 +3023,7 @@ async fn thread_resume_with_overrides_defers_updated_at_until_turn_start() -> Re let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: resumed_thread.id, + client_user_message_id: None, input: vec![UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -2689,7 +3055,7 @@ async fn thread_resume_fails_when_required_mcp_server_fails_to_initialize() -> R let rollout = setup_rollout_fixture(codex_home.path(), &server.uri())?; create_config_toml_with_required_broken_mcp(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp @@ -2768,7 +3134,7 @@ async fn thread_resume_surfaces_cloud_requirements_load_errors() -> Result<()> { /*git_info*/ None, )?; let refresh_token_url = format!("{}/oauth/token", server.uri()); - let mut mcp = McpProcess::new_with_env( + let mut mcp = TestAppServer::new_with_env( codex_home.path(), &[ ("OPENAI_API_KEY", None), @@ -2813,53 +3179,22 @@ async fn thread_resume_surfaces_cloud_requirements_load_errors() -> Result<()> { } #[tokio::test] -async fn thread_resume_uses_path_over_invalid_thread_id() -> Result<()> { +async fn thread_resume_uses_path_over_non_running_thread_id() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let RestartedThreadFixture { + mut mcp, + thread_id, + rollout_file_path, + .. + } = start_materialized_thread_and_restart(codex_home.path(), "materialize").await?; - let start_id = mcp - .send_thread_start_request(ThreadStartParams { - model: Some("gpt-5.4".to_string()), - ..Default::default() - }) - .await?; - let start_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(start_id)), - ) - .await??; - let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; - - let turn_id = mcp - .send_turn_start_request(TurnStartParams { - thread_id: thread.id.clone(), - input: vec![UserInput::Text { - text: "materialize".to_string(), - text_elements: Vec::new(), - }], - ..Default::default() - }) - .await?; - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), - ) - .await??; - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/completed"), - ) - .await??; - - let thread_path = thread.path.clone().expect("thread path"); let resume_id = mcp .send_thread_resume_request(ThreadResumeParams { - thread_id: "not-a-valid-thread-id".to_string(), - path: Some(thread_path.to_path_buf()), + thread_id: ThreadId::new().to_string(), + path: Some(rollout_file_path), ..Default::default() }) .await?; @@ -2872,7 +3207,7 @@ async fn thread_resume_uses_path_over_invalid_thread_id() -> Result<()> { let ThreadResumeResponse { thread: resumed, .. } = to_response::(resume_resp)?; - assert_eq!(resumed.id, thread.id); + assert_eq!(resumed.id, thread_id); Ok(()) } @@ -2893,7 +3228,7 @@ async fn thread_resume_can_load_source_by_external_path() -> Result<()> { )?; let thread_path = rollout_path(external_home.path(), "2025-01-05T12-00-00", &thread_id); - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let resume_id = mcp .send_thread_resume_request(ThreadResumeParams { @@ -2972,7 +3307,7 @@ async fn thread_resume_supports_history_and_overrides() -> Result<()> { } struct RestartedThreadFixture { - mcp: McpProcess, + mcp: TestAppServer, thread_id: String, rollout_file_path: PathBuf, updated_at: i64, @@ -2982,7 +3317,7 @@ async fn start_materialized_thread_and_restart( codex_home: &Path, seed_text: &str, ) -> Result { - let mut first_mcp = McpProcess::new(codex_home).await?; + let mut first_mcp = TestAppServer::new(codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, first_mcp.initialize()).await??; let start_id = first_mcp @@ -3001,6 +3336,7 @@ async fn start_materialized_thread_and_restart( let materialize_turn_id = first_mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: seed_text.to_string(), text_elements: Vec::new(), @@ -3040,7 +3376,7 @@ async fn start_materialized_thread_and_restart( drop(first_mcp); - let mut second_mcp = McpProcess::new(codex_home).await?; + let mut second_mcp = TestAppServer::new(codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, second_mcp.initialize()).await??; Ok(RestartedThreadFixture { @@ -3071,7 +3407,7 @@ async fn thread_resume_accepts_personality_override() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut primary = McpProcess::new(codex_home.path()).await?; + let mut primary = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; let start_id = primary @@ -3090,6 +3426,7 @@ async fn thread_resume_accepts_personality_override() -> Result<()> { let materialize_id = primary .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "seed history".to_string(), text_elements: Vec::new(), @@ -3108,7 +3445,7 @@ async fn thread_resume_accepts_personality_override() -> Result<()> { ) .await??; - let mut secondary = McpProcess::new(codex_home.path()).await?; + let mut secondary = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, secondary.initialize()).await??; let resume_id = secondary @@ -3130,6 +3467,7 @@ async fn thread_resume_accepts_personality_override() -> Result<()> { let turn_id = secondary .send_turn_start_request(TurnStartParams { thread_id: resume.thread.id, + client_user_message_id: None, input: vec![UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_rollback.rs b/codex-rs/app-server/tests/suite/v2/thread_rollback.rs index 5f79db0e26..4f45aa496b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_rollback.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_rollback.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::to_response; @@ -35,7 +35,7 @@ async fn thread_rollback_drops_last_turns_and_persists_to_rollout() -> Result<() let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // Start a thread. @@ -57,6 +57,7 @@ async fn thread_rollback_drops_last_turns_and_persists_to_rollout() -> Result<() let turn1_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: first_text.to_string(), text_elements: Vec::new(), @@ -78,6 +79,7 @@ async fn thread_rollback_drops_last_turns_and_persists_to_rollout() -> Result<() let turn2_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Second".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs new file mode 100644 index 0000000000..98ad587326 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs @@ -0,0 +1,405 @@ +use anyhow::Context; +use anyhow::Result; +use app_test_support::TestAppServer; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_responses_server_sequence_unchecked; +use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml; +use app_test_support::write_models_cache; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxPolicy; +use codex_app_server_protocol::ThreadReadParams; +use codex_app_server_protocol::ThreadReadResponse; +use codex_app_server_protocol::ThreadSettingsUpdateParams; +use codex_app_server_protocol::ThreadSettingsUpdateResponse; +use codex_app_server_protocol::ThreadSettingsUpdatedNotification; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_core::test_support::all_model_presets; +use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use serde_json::Value; +use std::collections::BTreeMap; +use std::time::Duration; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::test] +async fn thread_settings_update_emits_notification_and_updates_future_turns() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(vec![ + create_final_assistant_message_sse_response("done")?, + ]) + .await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + write_models_cache(codex_home.path())?; + let (model_id, service_tier_id) = service_tier_model_and_tier_id()?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let thread = start_thread(&mut mcp).await?.thread; + + send_thread_settings_update( + &mut mcp, + ThreadSettingsUpdateParams { + thread_id: thread.id.clone(), + model: Some(model_id.clone()), + service_tier: Some(Some(service_tier_id.clone())), + ..Default::default() + }, + ) + .await?; + assert!( + received_response_bodies(&server).await?.is_empty(), + "settings-only update should not start a model request" + ); + + start_text_turn(&mut mcp, thread.id.clone()).await?; + + let updated = read_thread_settings_updated(&mut mcp).await?; + assert_eq!(updated.thread_id, thread.id); + assert_eq!(updated.thread_settings.model, model_id); + assert_eq!( + updated.thread_settings.service_tier.as_deref(), + Some(service_tier_id.as_str()) + ); + + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let read = read_thread_with_turns(&mut mcp, &thread.id).await?; + assert_eq!(read.thread.turns.len(), 1); + + let request_bodies = received_response_bodies(&server).await?; + assert!( + request_bodies.iter().any(|body| { + body.get("model").and_then(Value::as_str) == Some(model_id.as_str()) + && body.get("service_tier").and_then(Value::as_str) + == Some(service_tier_id.as_str()) + }), + "future turn did not use updated model/service tier: {request_bodies:#?}" + ); + Ok(()) +} + +#[tokio::test] +async fn thread_settings_update_while_turn_is_active_emits_notification() -> Result<()> { + let server = responses::start_mock_server().await; + let first_response = + responses::sse_response(create_final_assistant_message_sse_response("first done")?) + .set_delay(Duration::from_secs(2)); + let _requests = responses::mount_response_sequence(&server, vec![first_response]).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let thread = start_thread(&mut mcp).await?.thread; + start_text_turn(&mut mcp, thread.id.clone()).await?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/started"), + ) + .await??; + + send_thread_settings_update( + &mut mcp, + ThreadSettingsUpdateParams { + thread_id: thread.id.clone(), + model: Some("mock-model-4".to_string()), + ..Default::default() + }, + ) + .await?; + + let updated = read_thread_settings_updated(&mut mcp).await?; + assert_eq!(updated.thread_id, thread.id); + assert_eq!(updated.thread_settings.model, "mock-model-4"); + + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + Ok(()) +} + +#[tokio::test] +async fn thread_settings_update_null_service_tier_uses_default() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(vec![ + create_final_assistant_message_sse_response("done")?, + ]) + .await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + write_models_cache(codex_home.path())?; + let (model_id, service_tier_id) = service_tier_model_and_tier_id()?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let thread = start_thread(&mut mcp).await?.thread; + + send_thread_settings_update( + &mut mcp, + ThreadSettingsUpdateParams { + thread_id: thread.id.clone(), + model: Some(model_id.clone()), + service_tier: Some(Some(service_tier_id.clone())), + ..Default::default() + }, + ) + .await?; + + let set_updated = read_thread_settings_updated(&mut mcp).await?; + assert_eq!(set_updated.thread_id, thread.id); + assert_eq!( + set_updated.thread_settings.service_tier.as_deref(), + Some(service_tier_id.as_str()) + ); + + send_thread_settings_update( + &mut mcp, + ThreadSettingsUpdateParams { + thread_id: thread.id.clone(), + service_tier: Some(None), + ..Default::default() + }, + ) + .await?; + + let clear_updated = read_thread_settings_updated(&mut mcp).await?; + assert_eq!(clear_updated.thread_id, thread.id); + assert_eq!(clear_updated.thread_settings.model, model_id); + assert_eq!( + clear_updated.thread_settings.service_tier.as_deref(), + Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE) + ); + + start_text_turn(&mut mcp, thread.id).await?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let request_bodies = received_response_bodies(&server).await?; + assert!( + request_bodies.iter().any(|body| { + body.get("model").and_then(Value::as_str) == Some(model_id.as_str()) + && body + .as_object() + .is_some_and(|object| !object.contains_key("service_tier")) + }), + "future turn did not clear service tier: {request_bodies:#?}" + ); + Ok(()) +} + +#[tokio::test] +async fn thread_settings_update_rejects_sandbox_policy_with_permissions() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let thread = start_thread(&mut mcp).await?.thread; + + let request_id = mcp + .send_thread_settings_update_request(ThreadSettingsUpdateParams { + thread_id: thread.id, + sandbox_policy: Some(SandboxPolicy::DangerFullAccess), + permissions: Some(":workspace".to_string()), + ..Default::default() + }) + .await?; + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!( + error.error.message, + "`permissions` cannot be combined with `sandboxPolicy`" + ); + Ok(()) +} + +#[tokio::test] +async fn turn_start_settings_override_emits_thread_settings_updated() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(vec![ + create_final_assistant_message_sse_response("done")?, + ]) + .await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let thread = start_thread(&mut mcp).await?.thread; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("thread/started"), + ) + .await??; + + let turn_request_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + model: Some("mock-model-3".to_string()), + ..Default::default() + }) + .await?; + let turn_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_request_id)), + ) + .await??; + let TurnStartResponse { turn } = to_response(turn_response)?; + assert!(!turn.id.is_empty()); + + let updated = read_thread_settings_updated(&mut mcp).await?; + assert_eq!(updated.thread_id, thread.id); + assert_eq!(updated.thread_settings.model, "mock-model-3"); + + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + Ok(()) +} + +async fn send_thread_settings_update( + mcp: &mut TestAppServer, + params: ThreadSettingsUpdateParams, +) -> Result<()> { + let request_id = mcp.send_thread_settings_update_request(params).await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let _: ThreadSettingsUpdateResponse = to_response(response)?; + Ok(()) +} + +async fn start_text_turn(mcp: &mut TestAppServer, thread_id: String) -> Result<()> { + let turn_request_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id, + input: vec![V2UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_request_id)), + ) + .await??; + let TurnStartResponse { turn } = to_response(turn_response)?; + assert!(!turn.id.is_empty()); + Ok(()) +} + +async fn start_thread(mcp: &mut TestAppServer) -> Result { + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response(response) +} + +async fn read_thread_with_turns( + mcp: &mut TestAppServer, + thread_id: &str, +) -> Result { + let request_id = mcp + .send_thread_read_request(ThreadReadParams { + thread_id: thread_id.to_string(), + include_turns: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response(response) +} + +async fn read_thread_settings_updated( + mcp: &mut TestAppServer, +) -> Result { + let notification: JSONRPCNotification = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("thread/settings/updated"), + ) + .await??; + let params = notification + .params + .context("thread/settings/updated should include params")?; + Ok(serde_json::from_value(params)?) +} + +async fn received_response_bodies(server: &wiremock::MockServer) -> Result> { + let requests = server + .received_requests() + .await + .context("failed to fetch received requests")?; + let mut bodies = Vec::new(); + for request in requests { + if request.url.path().ends_with("/responses") { + bodies.push(request.body_json::()?); + } + } + Ok(bodies) +} + +fn service_tier_model_and_tier_id() -> Result<(String, String)> { + let model = all_model_presets() + .iter() + .find(|preset| preset.show_in_picker && !preset.service_tiers.is_empty()) + .context("bundled model catalog should include a picker model with service tiers")?; + Ok((model.id.clone(), model.service_tiers[0].id.clone())) +} + +fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { + write_mock_responses_config_toml( + codex_home, + server_uri, + &BTreeMap::default(), + /*auto_compact_limit*/ 200_000, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs index b7cfba2f95..975ef3b3a7 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_shell_command_sse_response; @@ -32,6 +32,7 @@ use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; use codex_core::shell::default_user_shell; +use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_features::FEATURES; use codex_features::Feature; use pretty_assertions::assert_eq; @@ -59,7 +60,7 @@ async fn thread_shell_command_history_responses_exclude_persisted_command_execut &BTreeMap::default(), )?; - let mut mcp = McpProcess::new(codex_home.as_path()).await?; + let mut mcp = TestAppServer::new(codex_home.as_path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -182,6 +183,49 @@ async fn thread_shell_command_history_responses_exclude_persisted_command_execut Ok(()) } +#[tokio::test] +async fn thread_shell_command_returns_error_when_local_environment_is_disabled() -> Result<()> { + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let server = create_mock_responses_server_sequence(vec![]).await; + create_config_toml( + codex_home.as_path(), + &server.uri(), + "never", + &BTreeMap::default(), + )?; + + let mut mcp = TestAppServer::new_with_env( + codex_home.as_path(), + &[(CODEX_EXEC_SERVER_URL_ENV_VAR, Some("none"))], + ) + .await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + let shell_id = mcp + .send_thread_shell_command_request(ThreadShellCommandParams { + thread_id: thread.id, + command: "pwd".to_string(), + }) + .await?; + let error = mcp + .read_stream_until_error_message(RequestId::Integer(shell_id)) + .await?; + assert_eq!(error.error.message, "local environment is not configured"); + + Ok(()) +} + #[tokio::test] async fn thread_shell_command_uses_existing_active_turn() -> Result<()> { let tmp = TempDir::new()?; @@ -211,7 +255,7 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> { &BTreeMap::default(), )?; - let mut mcp = McpProcess::new(codex_home.as_path()).await?; + let mut mcp = TestAppServer::new(codex_home.as_path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -231,6 +275,7 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> { let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "run python".to_string(), text_elements: Vec::new(), @@ -365,7 +410,7 @@ fn current_shell_output_command(text: &str) -> Result<(String, String)> { } async fn wait_for_command_execution_started( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, expected_id: Option<&str>, ) -> Result { loop { @@ -387,7 +432,7 @@ async fn wait_for_command_execution_started( } async fn wait_for_command_execution_started_by_source( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, expected_source: CommandExecutionSource, ) -> Result { loop { @@ -402,7 +447,7 @@ async fn wait_for_command_execution_started_by_source( } async fn wait_for_command_execution_completed( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, expected_id: Option<&str>, ) -> Result { loop { @@ -424,7 +469,7 @@ async fn wait_for_command_execution_completed( } async fn wait_for_command_execution_output_delta( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, item_id: &str, ) -> Result { loop { diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index 78155d8c9a..b55cf997df 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -1,7 +1,7 @@ use anyhow::Result; use app_test_support::ChatGptAuthFixture; -use app_test_support::McpProcess; use app_test_support::PathBufExt; +use app_test_support::TestAppServer; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; @@ -28,6 +28,7 @@ use codex_core::config::set_project_trust_level; use codex_exec_server::LOCAL_FS; use codex_git_utils::resolve_root_git_project_for_trust; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::TrustLevel; use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; @@ -57,7 +58,7 @@ async fn thread_start_deprecates_persist_extended_history_true() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -100,7 +101,7 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; // Start server and initialize. - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // Start a v2 thread with an explicit model override. @@ -236,6 +237,89 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_start_resolves_runtime_workspace_roots_against_cwd() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + let cwd_tmp = TempDir::new()?; + let cwd = cwd_tmp.path().to_path_buf(); + let relative_root = PathBuf::from("extra-root"); + std::fs::create_dir_all(cwd.join(&relative_root))?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(cwd.to_string_lossy().to_string()), + runtime_workspace_roots: Some(vec![relative_root.clone()]), + ..Default::default() + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let ThreadStartResponse { + cwd: response_cwd, + runtime_workspace_roots, + .. + } = to_response::(resp)?; + + assert_eq!(response_cwd, cwd.abs()); + assert_eq!( + runtime_workspace_roots, + vec![cwd_tmp.path().join(relative_root).abs()] + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_excludes_profile_workspace_roots_from_runtime_workspace_roots() -> Result<()> +{ + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let profile_root = TempDir::new()?; + create_config_toml_with_profile_workspace_root( + codex_home.path(), + &server.uri(), + profile_root.path(), + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(cwd.path().to_string_lossy().to_string()), + ..Default::default() + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let ThreadStartResponse { + runtime_workspace_roots, + .. + } = to_response::(resp)?; + + assert_eq!( + runtime_workspace_roots, + vec![cwd.path().to_path_buf().abs()] + ); + + Ok(()) +} + #[tokio::test] async fn thread_start_rejects_unknown_environment_as_invalid_request() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -243,7 +327,7 @@ async fn thread_start_rejects_unknown_environment_as_invalid_request() -> Result let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -280,7 +364,7 @@ async fn thread_start_response_includes_loaded_instruction_sources() -> Result<( let project_agents_path = workspace.path().join("AGENTS.md"); std::fs::write(&project_agents_path, "project instructions")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -336,7 +420,7 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { create_config_toml_with_chatgpt_base_url(codex_home.path(), &server.uri(), &server.uri())?; mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -355,7 +439,14 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { let payload = wait_for_analytics_payload(&server, DEFAULT_READ_TIMEOUT).await?; assert_eq!(payload["events"].as_array().expect("events array").len(), 1); let event = thread_initialized_event(&payload)?; - assert_basic_thread_initialized_event(event, &thread.id, "mock-model", "new", "user"); + assert_basic_thread_initialized_event( + event, + &thread.id, + &thread.session_id, + "mock-model", + "new", + "user", + ); Ok(()) } @@ -377,7 +468,7 @@ model_reasoning_effort = "high" )?; set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -401,13 +492,13 @@ model_reasoning_effort = "high" } #[tokio::test] -async fn thread_start_accepts_arbitrary_service_tier_id() -> Result<()> { +async fn thread_start_drops_unsupported_service_tier_id() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let service_tier_id = "experimental-tier-id".to_string(); @@ -425,7 +516,39 @@ async fn thread_start_accepts_arbitrary_service_tier_id() -> Result<()> { .await??; let ThreadStartResponse { service_tier, .. } = to_response::(resp)?; - assert_eq!(service_tier, Some(service_tier_id)); + // Unsupported catalog ids are dropped at session config time instead of echoed back. + assert_eq!(service_tier, None); + Ok(()) +} + +#[tokio::test] +async fn thread_start_accepts_default_service_tier() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_thread_start_request(ThreadStartParams { + service_tier: Some(Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())), + ..Default::default() + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let ThreadStartResponse { service_tier, .. } = to_response::(resp)?; + + assert_eq!( + service_tier, + Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()) + ); Ok(()) } @@ -436,7 +559,7 @@ async fn thread_start_accepts_metrics_service_name() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -463,7 +586,7 @@ async fn thread_start_ephemeral_remains_pathless() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -509,7 +632,7 @@ async fn thread_start_fails_when_required_mcp_server_fails_to_initialize() -> Re let codex_home = TempDir::new()?; create_config_toml_with_required_broken_mcp(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -545,7 +668,7 @@ async fn thread_start_emits_mcp_server_status_updated_notifications() -> Result< let codex_home = TempDir::new()?; create_config_toml_with_optional_broken_mcp(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp @@ -675,7 +798,7 @@ async fn thread_start_surfaces_cloud_requirements_load_errors() -> Result<()> { )?; let refresh_token_url = format!("{}/oauth/token", server.uri()); - let mut mcp = McpProcess::new_with_env( + let mut mcp = TestAppServer::new_with_env( codex_home.path(), &[ ("OPENAI_API_KEY", None), @@ -735,7 +858,7 @@ model_reasoning_effort = "high" "#, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let first_request = mcp @@ -795,7 +918,7 @@ async fn thread_start_with_nested_git_cwd_trusts_repo_root() -> Result<()> { let nested = repo_root.path().join("nested/project"); std::fs::create_dir_all(&nested)?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -833,7 +956,7 @@ async fn thread_start_with_read_only_sandbox_does_not_persist_project_trust() -> let workspace = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -871,7 +994,7 @@ async fn thread_start_preserves_untrusted_project_trust() -> Result<()> { std::fs::write(&config_path, config_toml.to_string())?; let config_before = std::fs::read_to_string(&config_path)?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -912,7 +1035,7 @@ model_reasoning_effort = "high" set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; let config_before = std::fs::read_to_string(codex_home.path().join("config.toml"))?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -980,6 +1103,42 @@ stream_max_retries = 0 ) } +fn create_config_toml_with_profile_workspace_root( + codex_home: &Path, + server_uri: &str, + profile_root: &Path, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + let profile_root_key = profile_root + .display() + .to_string() + .replace('\\', "\\\\") + .replace('"', "\\\""); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +default_permissions = "dev" +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 + +[permissions.dev.workspace_roots] +"{profile_root_key}" = true + +[permissions.dev.filesystem.":workspace_roots"] +"." = "write" +"#, + ), + ) +} + fn create_config_toml_with_chatgpt_base_url( codex_home: &Path, server_uri: &str, diff --git a/codex-rs/app-server/tests/suite/v2/thread_status.rs b/codex-rs/app-server/tests/suite/v2/thread_status.rs index 957969c3ea..b8349a77f1 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_status.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_status.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_sequence; use app_test_support::to_response; @@ -29,7 +29,7 @@ async fn thread_status_changed_emits_runtime_updates() -> Result<()> { create_config_toml(codex_home.path(), &server.uri())?; let mut mcp = - McpProcess::new_with_env(codex_home.path(), &[("RUST_LOG", Some("info"))]).await?; + TestAppServer::new_with_env(codex_home.path(), &[("RUST_LOG", Some("info"))]).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_start_id = mcp @@ -48,6 +48,7 @@ async fn thread_status_changed_emits_runtime_updates() -> Result<()> { let turn_start_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "collect status updates".to_string(), text_elements: Vec::new(), @@ -134,7 +135,7 @@ async fn thread_status_changed_can_be_opted_out() -> Result<()> { let server = create_mock_responses_server_sequence(responses).await; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; let message = timeout( DEFAULT_READ_TIMEOUT, mcp.initialize_with_capabilities( @@ -171,6 +172,7 @@ async fn thread_status_changed_can_be_opted_out() -> Result<()> { let turn_start_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![V2UserInput::Text { text: "run once".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index d2f8f268a2..350d18a5b5 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::to_response; use codex_app_server::in_process; @@ -60,7 +60,7 @@ async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -81,6 +81,7 @@ async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result let turn_start_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![UserInput::Text { text: "materialize".to_string(), text_elements: Vec::new(), @@ -210,6 +211,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> { .create_thread(CreateThreadParams { thread_id, forked_from_id: Some(parent_thread_id), + parent_thread_id: None, source: SessionSource::Cli, thread_source: None, base_instructions: BaseInstructions::default(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs b/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs index c0188add8c..55aac670f8 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unsubscribe.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::to_response; use codex_app_server_protocol::DynamicToolCallOutputContentItem; @@ -41,7 +41,7 @@ async fn thread_unsubscribe_keeps_thread_loaded_until_idle_timeout() -> Result<( let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_id = start_thread(&mut mcp).await?; @@ -120,7 +120,7 @@ async fn thread_unsubscribe_during_turn_keeps_turn_running() -> Result<()> { let final_response_completed = completions.remove(0); create_config_toml(&codex_home, server.uri())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = TestAppServer::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -151,6 +151,7 @@ async fn thread_unsubscribe_during_turn_keeps_turn_running() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread_id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "run deterministic tool".to_string(), text_elements: Vec::new(), @@ -252,7 +253,7 @@ async fn thread_unsubscribe_preserves_cached_status_before_idle_unload() -> Resu let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_id = start_thread(&mut mcp).await?; @@ -260,6 +261,7 @@ async fn thread_unsubscribe_preserves_cached_status_before_idle_unload() -> Resu let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread_id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "fail this turn".to_string(), text_elements: Vec::new(), @@ -317,6 +319,7 @@ async fn thread_unsubscribe_preserves_cached_status_before_idle_unload() -> Resu let resume_id = mcp .send_thread_resume_request(ThreadResumeParams { thread_id, + cwd: Some(codex_home.path().to_string_lossy().to_string()), ..Default::default() }) .await?; @@ -337,7 +340,7 @@ async fn thread_unsubscribe_reports_not_subscribed_before_idle_unload() -> Resul let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_id = start_thread(&mut mcp).await?; @@ -376,7 +379,7 @@ async fn thread_unsubscribe_reports_not_subscribed_before_idle_unload() -> Resul } async fn wait_for_dynamic_tool_started( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, call_id: &str, ) -> Result { loop { @@ -416,7 +419,7 @@ stream_max_retries = 0 ) } -async fn start_thread(mcp: &mut McpProcess) -> Result { +async fn start_thread(mcp: &mut TestAppServer) -> Result { let req_id = mcp .send_thread_start_request(ThreadStartParams { model: Some("mock-model".to_string()), diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index aedc54e016..335d32776d 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -1,7 +1,7 @@ #![cfg(unix)] use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; @@ -57,7 +57,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = TestAppServer::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // Start a v2 thread and capture its id. @@ -78,6 +78,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "run sleep".to_string(), text_elements: Vec::new(), @@ -140,7 +141,7 @@ async fn turn_interrupt_rejects_completed_turn() -> Result<()> { .await; create_config_toml(&codex_home, &server.uri(), "never", "workspace-write")?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = TestAppServer::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -159,6 +160,7 @@ async fn turn_interrupt_rejects_completed_turn() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "say done".to_string(), text_elements: Vec::new(), @@ -234,7 +236,7 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() .await; create_config_toml(&codex_home, &server.uri(), "untrusted", "read-only")?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = TestAppServer::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -253,6 +255,7 @@ async fn turn_interrupt_resolves_pending_command_approval_request() -> Result<() let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "run python".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 314be8a6ff..0c2c7b71cd 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1,6 +1,7 @@ +use anyhow::Context; use anyhow::Result; use app_test_support::DEFAULT_CLIENT_NAME; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_apply_patch_sse_response; use app_test_support::create_exec_command_sse_response; use app_test_support::create_fake_rollout; @@ -15,6 +16,8 @@ use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use app_test_support::write_models_cache; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; +use codex_app_server_protocol::AdditionalContextEntry; +use codex_app_server_protocol::AdditionalContextKind; use codex_app_server_protocol::ByteRange; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::CollabAgentStatus; @@ -34,7 +37,6 @@ use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PatchApplyStatus; use codex_app_server_protocol::PatchChangeKind; -use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerRequestResolvedNotification; @@ -63,11 +65,13 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Settings; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; +use codex_protocol::models::ImageDetail; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; +use serde_json::Value; use serde_json::json; use std::collections::BTreeMap; use std::collections::HashMap; @@ -85,6 +89,11 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs const TEST_ORIGINATOR: &str = "codex_vscode"; const LOCAL_PRAGMATIC_TEMPLATE: &str = "You are a deeply pragmatic, effective software engineer."; const INVALID_REQUEST_ERROR_CODE: i64 = -32600; +const TINY_PNG_BYTES: &[u8] = &[ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, + 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2, 0, 0, 5, 0, 1, + 122, 94, 171, 63, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, +]; fn body_contains(req: &wiremock::Request, text: &str) -> bool { String::from_utf8(req.body.clone()) @@ -92,6 +101,287 @@ fn body_contains(req: &wiremock::Request, text: &str) -> bool { .is_some_and(|body| body.contains(text)) } +async fn run_local_image_turn(detail: Option) -> Result> { + // Two Codex turns hit the mock model (session start + turn/start). + let responses = vec![ + create_final_assistant_message_sse_response("Done")?, + create_final_assistant_message_sse_response("Done")?, + ]; + // Use the unchecked variant because the strict matcher does not currently + // cover image-bearing request payloads. + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::default(), + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let image_path = codex_home.path().join("image.png"); + std::fs::write(&image_path, TINY_PNG_BYTES)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: vec![V2UserInput::LocalImage { + path: image_path, + detail, + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + assert!(!turn.id.is_empty()); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + received_response_input_images(&server).await +} + +async fn received_response_input_images(server: &wiremock::MockServer) -> Result> { + let requests = server + .received_requests() + .await + .context("failed to fetch received requests")?; + let mut input_images = Vec::new(); + + for request in requests { + if !request.url.path().ends_with("/responses") { + continue; + } + let body = request + .body_json::() + .context("request body should be JSON")?; + let Some(input) = body.get("input").and_then(Value::as_array) else { + continue; + }; + + for item in input { + if item.get("type").and_then(Value::as_str) != Some("message") { + continue; + } + let Some(content) = item.get("content").and_then(Value::as_array) else { + continue; + }; + input_images.extend( + content + .iter() + .filter(|span| span.get("type").and_then(Value::as_str) == Some("input_image")) + .cloned(), + ); + } + } + + Ok(input_images) +} + +#[tokio::test] +async fn turn_start_with_empty_input_runs_model_request() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::default(), + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + thread_source: Some(ThreadSource::User), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: Vec::new(), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + assert!(!turn.id.is_empty()); + + let started_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/started"), + ) + .await??; + let started: TurnStartedNotification = + serde_json::from_value(started_notif.params.expect("params must be present"))?; + assert_eq!(started.thread_id, thread.id); + assert_eq!(started.turn.id, turn.id); + assert_eq!(started.turn.status, TurnStatus::InProgress); + + let completed_notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.thread_id, thread.id); + assert_eq!(completed.turn.id, turn.id); + assert_eq!(completed.turn.status, TurnStatus::Completed); + + let requests = server + .received_requests() + .await + .context("failed to fetch received requests")?; + let response_requests = requests + .iter() + .filter(|request| request.url.path().ends_with("/responses")) + .collect::>(); + assert_eq!(response_requests.len(), 1); + let body = response_requests[0] + .body_json::() + .context("request body should be JSON")?; + let input = body + .get("input") + .and_then(Value::as_array) + .context("request body should include input array")?; + assert!( + !input.iter().any(|item| { + item.get("type").and_then(Value::as_str) == Some("message") + && item.get("role").and_then(Value::as_str) == Some("user") + && item + .get("content") + .and_then(Value::as_array) + .is_some_and(Vec::is_empty) + }), + "empty turn/start should not synthesize an empty user message: {input:?}" + ); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_additional_context_flows_to_model_input() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::default(), + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + client_user_message_id: None, + input: vec![V2UserInput::Text { + text: "inspect tab".to_string(), + text_elements: Vec::new(), + }], + additional_context: Some(HashMap::from([( + "custom_source".to_string(), + AdditionalContextEntry { + value: "source value".to_string(), + kind: AdditionalContextKind::Untrusted, + }, + )])), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = server + .received_requests() + .await + .context("failed to fetch received requests")?; + let request = requests + .iter() + .find(|request| request.url.path().ends_with("/responses")) + .context("expected model request")?; + let body = request + .body_json::() + .context("request body should be JSON")?; + assert!( + body.to_string() + .contains("source value") + ); + + Ok(()) +} + #[tokio::test] async fn turn_start_sends_originator_header() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; @@ -105,7 +395,7 @@ async fn turn_start_sends_originator_header() -> Result<()> { &BTreeMap::from([(Feature::Personality, true)]), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout( DEFAULT_READ_TIMEOUT, mcp.initialize_with_client_info(ClientInfo { @@ -133,6 +423,7 @@ async fn turn_start_sends_originator_header() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -181,7 +472,7 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { &BTreeMap::from([(Feature::Personality, true)]), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -205,6 +496,7 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: Some("client-message-1".to_string()), input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: text_elements.clone(), @@ -234,7 +526,10 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { .await??; match user_message_item { - ThreadItem::UserMessage { content, .. } => { + ThreadItem::UserMessage { + client_id, content, .. + } => { + assert_eq!(client_id, Some("client-message-1".to_string())); assert_eq!( content, vec![V2UserInput::Text { @@ -292,7 +587,7 @@ async fn turn_start_emits_thread_scoped_warning_notification_for_trimmed_skills( write_test_skill(codex_home.path(), "alpha-skill")?; write_test_skill(codex_home.path(), "beta-skill")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -308,6 +603,7 @@ async fn turn_start_emits_thread_scoped_warning_notification_for_trimmed_skills( let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -384,7 +680,7 @@ async fn turn_start_sends_service_tier_id_to_model_request() -> Result<()> { .expect("bundled model catalog should include a picker model with service tiers"); let service_tier_id = service_tier_model.service_tiers[0].id.clone(); - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -448,7 +744,7 @@ async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> &BTreeMap::default(), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -474,6 +770,7 @@ async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -533,7 +830,7 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { )?; mount_analytics_capture(&server, codex_home.path()).await?; - let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + let mut mcp = TestAppServer::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -553,8 +850,10 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Image { url: "https://example.com/a.png".to_string(), + detail: None, }], ..Default::default() }) @@ -574,6 +873,7 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_event").await?; assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["session_id"], thread.session_id); assert_eq!(event["event_params"]["turn_id"], turn.id); assert_eq!( event["event_params"]["app_server_client"]["product_client_id"], @@ -620,7 +920,7 @@ async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { &BTreeMap::from([(Feature::Personality, true)]), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -639,6 +939,7 @@ async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![ V2UserInput::Text { text: "x".repeat(MAX_USER_INPUT_TEXT_CHARS), @@ -679,7 +980,7 @@ async fn turn_start_rejects_combined_oversized_text_input() -> Result<()> { &BTreeMap::from([(Feature::Personality, true)]), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -702,6 +1003,7 @@ async fn turn_start_rejects_combined_oversized_text_input() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![ V2UserInput::Text { text: first, @@ -758,7 +1060,7 @@ async fn turn_start_rejects_invalid_permission_selection_before_starting_turn() "sandbox_mode = \"read-only\"\n", )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -776,14 +1078,12 @@ async fn turn_start_rejects_invalid_permission_selection_before_starting_turn() let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), }], - permissions: Some(PermissionProfileSelectionParams::Profile { - id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(), - modifications: None, - }), + permissions: Some(BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string()), ..Default::default() }) .await?; @@ -794,9 +1094,17 @@ async fn turn_start_rejects_invalid_permission_selection_before_starting_turn() .await??; assert_eq!(err.error.code, INVALID_REQUEST_ERROR_CODE); - assert!(err.error.message.contains("invalid turn context override")); assert!( - err.error.message.contains("allowed set [ReadOnly]"), + err.error + .message + .contains("`approval_policy = \"never\"` cannot be used"), + "unexpected error message: {}", + err.error.message + ); + assert!( + err.error + .message + .contains("requirements do not allow `sandbox_mode = \"danger-full-access\"`"), "unexpected error message: {}", err.error.message ); @@ -824,7 +1132,7 @@ async fn turn_start_rejects_unknown_environment_before_starting_turn() -> Result &BTreeMap::default(), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -843,6 +1151,7 @@ async fn turn_start_rejects_unknown_environment_before_starting_turn() -> Result let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -895,7 +1204,7 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<( &BTreeMap::from([(Feature::Personality, true)]), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // Start a thread (v2) and capture its id. @@ -916,6 +1225,7 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<( let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -968,6 +1278,7 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<( let turn_req2 = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Second".to_string(), text_elements: Vec::new(), @@ -1038,7 +1349,7 @@ async fn turn_start_accepts_collaboration_mode_override_v2() -> Result<()> { &BTreeMap::default(), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -1066,6 +1377,7 @@ async fn turn_start_accepts_collaboration_mode_override_v2() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -1123,7 +1435,7 @@ async fn turn_start_uses_thread_feature_overrides_for_request_user_input_tool_de &BTreeMap::default(), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -1155,6 +1467,7 @@ async fn turn_start_uses_thread_feature_overrides_for_request_user_input_tool_de let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -1207,7 +1520,7 @@ async fn turn_start_accepts_personality_override_v2() -> Result<()> { &BTreeMap::from([(Feature::Personality, true)]), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -1226,6 +1539,7 @@ async fn turn_start_accepts_personality_override_v2() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -1288,7 +1602,7 @@ async fn turn_start_change_personality_mid_thread_v2() -> Result<()> { &BTreeMap::from([(Feature::Personality, true)]), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -1307,6 +1621,7 @@ async fn turn_start_change_personality_mid_thread_v2() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -1331,6 +1646,7 @@ async fn turn_start_change_personality_mid_thread_v2() -> Result<()> { let turn_req2 = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello again".to_string(), text_elements: Vec::new(), @@ -1402,7 +1718,7 @@ async fn turn_start_uses_migrated_pragmatic_personality_without_override_v2() -> /*git_info*/ None, )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let persisted_toml: ConfigToml = toml::from_str(&std::fs::read_to_string( @@ -1433,6 +1749,7 @@ async fn turn_start_uses_migrated_pragmatic_personality_without_override_v2() -> let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), @@ -1465,63 +1782,27 @@ async fn turn_start_uses_migrated_pragmatic_personality_without_override_v2() -> } #[tokio::test] -async fn turn_start_accepts_local_image_input() -> Result<()> { - // Two Codex turns hit the mock model (session start + turn/start). - let responses = vec![ - create_final_assistant_message_sse_response("Done")?, - create_final_assistant_message_sse_response("Done")?, - ]; - // Use the unchecked variant because the request payload includes a LocalImage - // which the strict matcher does not currently cover. - let server = create_mock_responses_server_sequence_unchecked(responses).await; +async fn turn_start_defaults_local_image_detail_to_high() -> Result<()> { + let input_images = run_local_image_turn(/*detail*/ None).await?; - let codex_home = TempDir::new()?; - create_config_toml( - codex_home.path(), - &server.uri(), - "never", - &BTreeMap::default(), - )?; + assert_eq!(input_images.len(), 1); + assert_eq!( + input_images[0].get("detail").and_then(Value::as_str), + Some("high") + ); - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + Ok(()) +} - let thread_req = mcp - .send_thread_start_request(ThreadStartParams { - model: Some("mock-model".to_string()), - ..Default::default() - }) - .await?; - let thread_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), - ) - .await??; - let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; +#[tokio::test] +async fn turn_start_forwards_custom_local_image_detail() -> Result<()> { + let input_images = run_local_image_turn(Some(ImageDetail::Original)).await?; - let image_path = codex_home.path().join("image.png"); - // No need to actually write the file; we just exercise the input path. - - let turn_req = mcp - .send_turn_start_request(TurnStartParams { - thread_id: thread.id.clone(), - input: vec![V2UserInput::LocalImage { path: image_path }], - ..Default::default() - }) - .await?; - let turn_resp: JSONRPCResponse = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), - ) - .await??; - let TurnStartResponse { turn } = to_response::(turn_resp)?; - assert!(!turn.id.is_empty()); - - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("turn/completed"), - ) - .await??; + assert_eq!(input_images.len(), 1); + assert_eq!( + input_images[0].get("detail").and_then(Value::as_str), + Some("original") + ); Ok(()) } @@ -1568,7 +1849,7 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { &BTreeMap::default(), )?; - let mut mcp = McpProcess::new(codex_home.as_path()).await?; + let mut mcp = TestAppServer::new(codex_home.as_path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // thread/start @@ -1589,6 +1870,7 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { let first_turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "run python".to_string(), text_elements: Vec::new(), @@ -1653,6 +1935,7 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { let second_turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "run python again".to_string(), text_elements: Vec::new(), @@ -1711,7 +1994,7 @@ async fn turn_start_exec_approval_decline_v2() -> Result<()> { &BTreeMap::default(), )?; - let mut mcp = McpProcess::new(codex_home.as_path()).await?; + let mut mcp = TestAppServer::new(codex_home.as_path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -1730,6 +2013,7 @@ async fn turn_start_exec_approval_decline_v2() -> Result<()> { let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "run python".to_string(), text_elements: Vec::new(), @@ -1863,7 +2147,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { &BTreeMap::default(), )?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = TestAppServer::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // thread/start @@ -1885,12 +2169,15 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { .send_turn_start_request(TurnStartParams { environments: None, thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "first turn".to_string(), text_elements: Vec::new(), }], responsesapi_client_metadata: None, + additional_context: None, cwd: Some(first_cwd.clone()), + runtime_workspace_roots: None, approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), approvals_reviewer: None, sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { @@ -1926,12 +2213,15 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { .send_turn_start_request(TurnStartParams { environments: None, thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "second turn".to_string(), text_elements: Vec::new(), }], responsesapi_client_metadata: None, + additional_context: None, cwd: Some(second_cwd.clone()), + runtime_workspace_roots: None, approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), approvals_reviewer: None, sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), @@ -1991,6 +2281,154 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { Ok(()) } +#[cfg(unix)] +#[tokio::test] +async fn turn_start_permission_profile_rebinds_runtime_workspace_roots_between_turns() -> Result<()> +{ + skip_if_no_network!(Ok(())); + + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let old_root = tmp.path().join("old-root"); + let new_root = tmp.path().join("new-root"); + std::fs::create_dir(&old_root)?; + std::fs::create_dir(&new_root)?; + let old_root_text = old_root.to_string_lossy().into_owned(); + let new_root_text = new_root.to_string_lossy().into_owned(); + + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "done first"), + responses::ev_completed("resp-1"), + ]), + responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_assistant_message("msg-2", "done second"), + responses::ev_completed("resp-2"), + ]), + ], + ) + .await; + let server_uri = server.uri(); + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "never" +default_permissions = "dev" +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 + +[permissions.dev.filesystem.":workspace_roots"] +"." = "write" +"# + ), + )?; + + let mut mcp = TestAppServer::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let first_turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: vec![V2UserInput::Text { + text: "select dev profile".to_string(), + text_elements: Vec::new(), + }], + runtime_workspace_roots: Some(vec![old_root]), + permissions: Some("dev".to_string()), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: vec![V2UserInput::Text { + text: "write in new root".to_string(), + text_elements: Vec::new(), + }], + runtime_workspace_roots: Some(vec![new_root]), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_id)), + ) + .await??; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2, "expected two Responses API requests"); + let latest_permissions_instructions = + |request: &core_test_support::responses::ResponsesRequest| { + request + .message_input_texts("developer") + .into_iter() + .rev() + .find(|text| text.contains("")) + .expect("permissions instructions") + }; + let first_permissions = latest_permissions_instructions(&requests[0]); + assert!(first_permissions.contains(&old_root_text)); + assert!( + !first_permissions.contains(&new_root_text), + "first turn should materialize the initial runtime workspace root" + ); + + let second_permissions = latest_permissions_instructions(&requests[1]); + assert!(second_permissions.contains(&new_root_text)); + assert!( + !second_permissions.contains(&old_root_text), + "second turn should rebind :workspace_roots to the updated runtime workspace root" + ); + + Ok(()) +} + #[tokio::test] async fn turn_start_resolves_sticky_thread_local_environment_and_turn_overrides() -> Result<()> { let tmp = TempDir::new()?; @@ -2010,7 +2448,7 @@ url = "ws://127.0.0.1:1" "#, )?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = TestAppServer::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; for case in [ @@ -2053,7 +2491,7 @@ struct EnvironmentSelectionCase { } async fn run_environment_selection_case( - mcp: &mut McpProcess, + mcp: &mut TestAppServer, workspace: &Path, case: EnvironmentSelectionCase, ) -> Result<()> { @@ -2075,6 +2513,7 @@ async fn run_environment_selection_case( let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: format!("run {}", case.name), text_elements: Vec::new(), @@ -2170,7 +2609,7 @@ async fn turn_start_file_change_approval_v2() -> Result<()> { &BTreeMap::default(), )?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = TestAppServer::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_req = mcp @@ -2190,6 +2629,7 @@ async fn turn_start_file_change_approval_v2() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "apply patch".into(), text_elements: Vec::new(), @@ -2360,7 +2800,7 @@ async fn turn_start_does_not_stream_apply_patch_change_updates_without_feature_v let server = create_mock_responses_server_sequence(responses).await; create_config_toml(&codex_home, &server.uri(), "never", &BTreeMap::default())?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = TestAppServer::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_req = mcp @@ -2380,6 +2820,7 @@ async fn turn_start_does_not_stream_apply_patch_change_updates_without_feature_v let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, + client_user_message_id: None, input: vec![V2UserInput::Text { text: "apply patch".into(), text_elements: Vec::new(), @@ -2497,7 +2938,7 @@ async fn turn_start_streams_apply_patch_change_updates_v2() -> Result<()> { model["apply_patch_tool_type"] = serde_json::Value::from("freeform"); std::fs::write(&cache_path, serde_json::to_string_pretty(&cache)?)?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = TestAppServer::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_req = mcp @@ -2517,6 +2958,7 @@ async fn turn_start_streams_apply_patch_change_updates_v2() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "apply patch".into(), text_elements: Vec::new(), @@ -2587,7 +3029,12 @@ async fn turn_start_emits_spawn_agent_item_with_model_metadata_v2() -> Result<() |req: &wiremock::Request| body_contains(req, PARENT_PROMPT), responses::sse(vec![ responses::ev_response_created("resp-turn1-1"), - responses::ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + responses::ev_function_call_with_namespace( + SPAWN_CALL_ID, + "multi_agent_v1", + "spawn_agent", + &spawn_args, + ), responses::ev_completed("resp-turn1-1"), ]), ) @@ -2623,7 +3070,7 @@ async fn turn_start_emits_spawn_agent_item_with_model_metadata_v2() -> Result<() &BTreeMap::from([(Feature::Collab, true)]), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -2642,6 +3089,7 @@ async fn turn_start_emits_spawn_agent_item_with_model_metadata_v2() -> Result<() let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: PARENT_PROMPT.to_string(), text_elements: Vec::new(), @@ -2784,7 +3232,12 @@ async fn turn_start_emits_spawn_agent_item_with_effective_role_model_metadata_v2 |req: &wiremock::Request| body_contains(req, PARENT_PROMPT), responses::sse(vec![ responses::ev_response_created("resp-turn1-1"), - responses::ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + responses::ev_function_call_with_namespace( + SPAWN_CALL_ID, + "multi_agent_v1", + "spawn_agent", + &spawn_args, + ), responses::ev_completed("resp-turn1-1"), ]), ) @@ -2837,7 +3290,7 @@ config_file = "./custom-role.toml" ), )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -2856,6 +3309,7 @@ config_file = "./custom-role.toml" let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: PARENT_PROMPT.to_string(), text_elements: Vec::new(), @@ -2980,7 +3434,7 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res &BTreeMap::default(), )?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = TestAppServer::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_req = mcp @@ -3001,6 +3455,7 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res let turn_1_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "apply patch 1".into(), text_elements: Vec::new(), @@ -3073,6 +3528,7 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res let turn_2_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "apply patch 2".into(), text_elements: Vec::new(), @@ -3151,7 +3607,7 @@ async fn turn_start_file_change_approval_decline_v2() -> Result<()> { &BTreeMap::default(), )?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = TestAppServer::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_req = mcp @@ -3171,6 +3627,7 @@ async fn turn_start_file_change_approval_decline_v2() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "apply patch".into(), text_elements: Vec::new(), @@ -3297,7 +3754,7 @@ async fn command_execution_notifications_include_process_id() -> Result<()> { "danger-full-access", )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -3316,6 +3773,7 @@ async fn command_execution_notifications_include_process_id() -> Result<()> { let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "run a command".to_string(), text_elements: Vec::new(), @@ -3430,7 +3888,7 @@ async fn turn_start_with_elevated_override_does_not_persist_project_trust() -> R let workspace = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_request = mcp diff --git a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs index 6fe93e36af..dfb76ecbc2 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs @@ -7,7 +7,7 @@ // network access are required the first time the artifact is fetched. use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; @@ -37,6 +37,7 @@ use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; use std::collections::BTreeMap; use std::path::Path; +use std::path::PathBuf; use tempfile::TempDir; use tokio::time::timeout; @@ -94,10 +95,9 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { (Feature::UnifiedExec, false), (Feature::ShellSnapshot, false), ]), - &zsh_path, )?; - let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?; + let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace, &zsh_path).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -117,6 +117,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "run echo hi".to_string(), text_elements: Vec::new(), @@ -162,7 +163,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { }; assert_eq!(id, "call-zsh-fork"); assert_eq!(status, CommandExecutionStatus::InProgress); - assert!(command.starts_with(&zsh_path.display().to_string())); + assert!(command.starts_with(&command_packaged_zsh_path(&codex_home).display().to_string())); assert!(command.contains("/bin/sh -c")); assert!(command.contains("sleep 0.01")); assert!(command.contains(&release_marker.display().to_string())); @@ -213,10 +214,9 @@ async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> { (Feature::UnifiedExec, false), (Feature::ShellSnapshot, false), ]), - &zsh_path, )?; - let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?; + let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace, &zsh_path).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -236,6 +236,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> { let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "run python".to_string(), text_elements: Vec::new(), @@ -346,10 +347,9 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> { (Feature::UnifiedExec, false), (Feature::ShellSnapshot, false), ]), - &zsh_path, )?; - let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?; + let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace, &zsh_path).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -369,6 +369,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> { let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "run python".to_string(), text_elements: Vec::new(), @@ -505,10 +506,9 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() (Feature::UnifiedExec, false), (Feature::ShellSnapshot, false), ]), - &zsh_path, )?; - let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?; + let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace, &zsh_path).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -528,18 +528,18 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "remove both files".to_string(), text_elements: Vec::new(), }], cwd: Some(workspace.clone()), approval_policy: Some(codex_app_server_protocol::AskForApproval::UnlessTrusted), - sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { - writable_roots: vec![workspace.clone().try_into()?], - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }), + // This test is about execve-intercept approval propagation, not + // workspace sandboxing. Using full access avoids macOS sandbox + // setup failures that can terminate the parent shell before the + // second subcommand approval is observed. + sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), model: Some("mock-model".to_string()), effort: Some(codex_protocol::openai_models::ReasoningEffort::Medium), summary: Some(codex_protocol::config_types::ReasoningSummary::Auto), @@ -603,7 +603,8 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() ); approved_subcommand_strings.push(approval_command.to_string()); } - let is_parent_approval = approval_command.contains(&zsh_path.display().to_string()) + let is_parent_approval = approval_command + .contains(&command_packaged_zsh_path(&codex_home).display().to_string()) && (approval_command.contains(&shell_command) || (has_first_file && has_second_file) || approval_command.contains(&parent_shell_hint)); @@ -738,9 +739,58 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() Ok(()) } -async fn create_zsh_test_mcp_process(codex_home: &Path, zdotdir: &Path) -> Result { +async fn create_zsh_test_mcp_process( + codex_home: &Path, + zdotdir: &Path, + zsh_path: &Path, +) -> Result { + let app_server = create_test_package_app_server(codex_home, zsh_path)?; let zdotdir = zdotdir.to_string_lossy().into_owned(); - McpProcess::new_with_env(codex_home, &[("ZDOTDIR", Some(zdotdir.as_str()))]).await + TestAppServer::new_with_program_and_env( + codex_home, + &app_server, + &[("ZDOTDIR", Some(zdotdir.as_str()))], + ) + .await +} + +fn create_test_package_app_server(codex_home: &Path, zsh_path: &Path) -> Result { + let package_dir = codex_home.join("test-package"); + let bin_dir = package_dir.join("bin"); + let package_zsh_path = packaged_zsh_path(codex_home); + let Some(zsh_bin_dir) = package_zsh_path.parent() else { + anyhow::bail!("packaged zsh path should have parent"); + }; + std::fs::create_dir_all(&bin_dir)?; + std::fs::create_dir_all(zsh_bin_dir)?; + std::fs::write(package_dir.join("codex-package.json"), "{}")?; + + let app_server = bin_dir.join("codex-app-server"); + copy_with_permissions( + &codex_utils_cargo_bin::cargo_bin("codex-app-server")?, + &app_server, + )?; + copy_with_permissions(zsh_path, &package_zsh_path)?; + Ok(app_server) +} + +fn packaged_zsh_path(codex_home: &Path) -> PathBuf { + codex_home + .join("test-package") + .join("codex-resources") + .join("zsh") + .join("bin") + .join("zsh") +} + +fn command_packaged_zsh_path(codex_home: &Path) -> PathBuf { + let path = packaged_zsh_path(codex_home); + std::fs::canonicalize(&path).unwrap_or(path) +} + +fn copy_with_permissions(source: &Path, destination: &Path) -> std::io::Result<()> { + std::fs::copy(source, destination)?; + std::fs::set_permissions(destination, std::fs::metadata(source)?.permissions()) } fn create_config_toml( @@ -748,7 +798,6 @@ fn create_config_toml( server_uri: &str, approval_policy: &str, feature_flags: &BTreeMap, - zsh_path: &Path, ) -> std::io::Result<()> { let mut features = BTreeMap::from([(Feature::RemoteModels, false)]); for (feature, enabled) in feature_flags { @@ -774,7 +823,6 @@ fn create_config_toml( model = "mock-model" approval_policy = "{approval_policy}" sandbox_mode = "read-only" -zsh_path = "{zsh_path}" model_provider = "mock_provider" @@ -787,9 +835,7 @@ base_url = "{server_uri}/v1" wire_api = "responses" request_max_retries = 0 stream_max_retries = 0 -"#, - approval_policy = approval_policy, - zsh_path = zsh_path.display() +"# ), ) } diff --git a/codex-rs/app-server/tests/suite/v2/turn_steer.rs b/codex-rs/app-server/tests/suite/v2/turn_steer.rs index a92b2db528..ed0ea2def5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_steer.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_steer.rs @@ -1,7 +1,8 @@ #![cfg(unix)] +use anyhow::Context; use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; @@ -9,10 +10,14 @@ use app_test_support::to_response; use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE; use codex_app_server::INVALID_PARAMS_ERROR_CODE; +use codex_app_server_protocol::AdditionalContextEntry; +use codex_app_server_protocol::AdditionalContextKind; +use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStartParams; @@ -21,6 +26,8 @@ use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput as V2UserInput; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; +use serde_json::Value; +use std::collections::HashMap; use tempfile::TempDir; use tokio::time::timeout; @@ -43,7 +50,7 @@ async fn turn_steer_requires_active_turn() -> Result<()> { )?; mount_analytics_capture(&server, &codex_home).await?; - let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; + let mut mcp = TestAppServer::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -62,11 +69,13 @@ async fn turn_steer_requires_active_turn() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { thread_id: thread.id.clone(), + client_user_message_id: Some("client-steer-message-1".to_string()), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), }], responsesapi_client_metadata: None, + additional_context: None, expected_turn_id: "turn-does-not-exist".to_string(), }) .await?; @@ -127,7 +136,7 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { )?; mount_analytics_capture(&server, &codex_home).await?; - let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; + let mut mcp = TestAppServer::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -146,6 +155,7 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "run sleep".to_string(), text_elements: Vec::new(), @@ -171,11 +181,13 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: oversized_input.clone(), text_elements: Vec::new(), }], responsesapi_client_metadata: None, + additional_context: None, expected_turn_id: turn.id.clone(), }) .await?; @@ -210,10 +222,10 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let shell_command = vec![ "powershell".to_string(), "-Command".to_string(), - "Start-Sleep -Seconds 10".to_string(), + "Start-Sleep -Seconds 2".to_string(), ]; #[cfg(not(target_os = "windows"))] - let shell_command = vec!["sleep".to_string(), "10".to_string()]; + let shell_command = vec!["sleep".to_string(), "2".to_string()]; let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); @@ -221,14 +233,16 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let working_directory = tmp.path().join("workdir"); std::fs::create_dir(&working_directory)?; - let server = - create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response( + let server = create_mock_responses_server_sequence_unchecked(vec![ + create_shell_command_sse_response( shell_command.clone(), Some(&working_directory), Some(10_000), "call_sleep", - )?]) - .await; + )?, + app_test_support::create_final_assistant_message_sse_response("Done")?, + ]) + .await; write_mock_responses_config_toml_with_chatgpt_base_url( &codex_home, &server.uri(), @@ -236,7 +250,7 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { )?; mount_analytics_capture(&server, &codex_home).await?; - let mut mcp = McpProcess::new_without_managed_config(&codex_home).await?; + let mut mcp = TestAppServer::new_without_managed_config(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -255,6 +269,7 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), + client_user_message_id: None, input: vec![V2UserInput::Text { text: "run sleep".to_string(), text_elements: Vec::new(), @@ -279,11 +294,13 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer_req = mcp .send_turn_steer_request(TurnSteerParams { thread_id: thread.id.clone(), + client_user_message_id: Some("client-steer-message-1".to_string()), input: vec![V2UserInput::Text { text: "steer".to_string(), text_elements: Vec::new(), }], responsesapi_client_metadata: None, + additional_context: None, expected_turn_id: turn.id.clone(), }) .await?; @@ -295,9 +312,38 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { let steer: TurnSteerResponse = to_response::(steer_resp)?; assert_eq!(steer.turn_id, turn.id); + timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let notification = mcp + .read_stream_until_notification_message("item/started") + .await?; + let params = notification.params.expect("item/started params"); + let item_started: ItemStartedNotification = + serde_json::from_value(params).expect("deserialize item/started notification"); + let ThreadItem::UserMessage { + client_id, content, .. + } = item_started.item + else { + continue; + }; + if client_id == Some("client-steer-message-1".to_string()) { + assert_eq!( + content, + vec![V2UserInput::Text { + text: "steer".to_string(), + text_elements: Vec::new(), + }] + ); + return Ok::<(), anyhow::Error>(()); + } + } + }) + .await??; + let event = wait_for_analytics_event(&server, DEFAULT_READ_TIMEOUT, "codex_turn_steer_event").await?; assert_eq!(event["event_params"]["thread_id"], thread.id); + assert_eq!(event["event_params"]["session_id"], thread.session_id); assert_eq!(event["event_params"]["result"], "accepted"); assert_eq!(event["event_params"]["num_input_images"], 0); assert_eq!(event["event_params"]["expected_turn_id"], turn.id); @@ -307,8 +353,128 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> { serde_json::Value::Null ); - mcp.interrupt_turn_and_wait_for_aborted(thread.id, steer.turn_id, DEFAULT_READ_TIMEOUT) - .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + +#[tokio::test] +async fn turn_steer_rejects_context_only_input_without_merging_context() -> Result<()> { + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let working_directory = tmp.path().join("workdir"); + std::fs::create_dir(&working_directory)?; + + let server = create_mock_responses_server_sequence_unchecked(vec![ + create_shell_command_sse_response( + vec!["sleep".to_string(), "1".to_string()], + Some(&working_directory), + Some(10_000), + "call_sleep", + )?, + app_test_support::create_final_assistant_message_sse_response("Done")?, + ]) + .await; + write_mock_responses_config_toml_with_chatgpt_base_url( + &codex_home, + &server.uri(), + &server.uri(), + )?; + mount_analytics_capture(&server, &codex_home).await?; + + let mut mcp = TestAppServer::new_without_managed_config(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: vec![V2UserInput::Text { + text: "run sleep".to_string(), + text_elements: Vec::new(), + }], + cwd: Some(working_directory), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/started"), + ) + .await??; + + let additional_context = Some(HashMap::from([( + "browser_info".to_string(), + AdditionalContextEntry { + value: "tab one".to_string(), + kind: AdditionalContextKind::Untrusted, + }, + )])); + let steer_req = mcp + .send_turn_steer_request(TurnSteerParams { + thread_id: thread.id.clone(), + client_user_message_id: None, + input: Vec::new(), + responsesapi_client_metadata: None, + additional_context, + expected_turn_id: turn.id, + }) + .await?; + let steer_error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(steer_req)), + ) + .await??; + assert_eq!(steer_error.error.code, -32600); + assert_eq!(steer_error.error.message, "input must not be empty"); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = server + .received_requests() + .await + .context("failed to fetch received requests")?; + let response_requests = requests + .iter() + .filter(|request| request.url.path().ends_with("/responses")) + .collect::>(); + assert_eq!(response_requests.len(), 2); + let body = response_requests[1] + .body_json::() + .context("request body should be JSON")?; + assert!( + !body + .to_string() + .contains("tab one") + ); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/web_search.rs b/codex-rs/app-server/tests/suite/v2/web_search.rs new file mode 100644 index 0000000000..13c595d148 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/web_search.rs @@ -0,0 +1,315 @@ +use std::path::Path; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::TestAppServer; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadReadParams; +use codex_app_server_protocol::ThreadReadResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_app_server_protocol::WebSearchAction; +use codex_config::types::AuthCredentialsStoreMode; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use serde_json::Value; +use serde_json::json; +use tempfile::TempDir; +use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +// macOS and Windows Bazel CI can spend tens of seconds starting app-server +// subprocesses or processing test RPCs under load. +#[cfg(any(target_os = "macos", windows))] +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60); +#[cfg(not(any(target_os = "macos", windows)))] +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); + +#[tokio::test] +async fn standalone_web_search_round_trips_encrypted_output() -> Result<()> { + let call_id = "web-run-1"; + let server = responses::start_mock_server().await; + mount_search_response(&server).await; + + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_function_call_with_namespace( + call_id, + "web", + "run", + &json!({ + "search_query": [{"q": "standalone web search"}], + }) + .to_string(), + ), + responses::ev_completed("resp-1"), + ]), + responses::sse(vec![ + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-2"), + ]), + ], + ) + .await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("access-chatgpt"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + let thread_id = thread.id.clone(); + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread_id.clone(), + client_user_message_id: None, + input: vec![V2UserInput::Text { + text: "Search the web".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let _turn: TurnStartResponse = to_response::(turn_resp)?; + + let started = timeout(DEFAULT_READ_TIMEOUT, wait_for_web_search_started(&mut mcp)).await??; + let completed = timeout( + DEFAULT_READ_TIMEOUT, + wait_for_web_search_completed(&mut mcp), + ) + .await??; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + + let first_response = requests[0].body_json(); + let web_run = requests[0] + .tool_by_name("web", "run") + .context("web.run should be sent to the model")?; + assert_eq!( + web_run.pointer("/parameters/properties/time/description"), + Some(&json!("Get time for the given UTC offsets.")) + ); + assert!( + !has_hosted_web_search(&first_response), + "standalone web search should replace hosted web search" + ); + + let search_body = search_request_body(&server).await?; + assert_eq!(search_body["model"], json!("mock-model")); + assert_eq!( + search_body["commands"], + json!({ + "search_query": [{"q": "standalone web search"}], + }) + ); + assert_eq!( + search_body["settings"]["allowed_callers"], + json!(["direct"]) + ); + assert_eq!( + search_body["input"] + .as_array() + .context("search input should be an array")? + .last(), + Some(&json!({ + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "Search the web"}], + })) + ); + + assert_eq!( + requests[1].function_call_output(call_id), + json!({ + "type": "function_call_output", + "call_id": call_id, + "output": [{ + "type": "encrypted_content", + "encrypted_content": "ciphertext", + }], + }) + ); + assert_eq!( + started.item, + ThreadItem::WebSearch { + id: call_id.to_string(), + query: String::new(), + action: Some(WebSearchAction::Other), + } + ); + let expected_completed_item = ThreadItem::WebSearch { + id: call_id.to_string(), + query: "standalone web search".to_string(), + action: Some(WebSearchAction::Search { + query: Some("standalone web search".to_string()), + queries: None, + }), + }; + assert_eq!(completed.item, expected_completed_item); + + drop(mcp); + let mut reloaded_mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, reloaded_mcp.initialize()).await??; + let read_req = reloaded_mcp + .send_thread_read_request(ThreadReadParams { + thread_id, + include_turns: true, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + reloaded_mcp.read_stream_until_response_message(RequestId::Integer(read_req)), + ) + .await??; + let ThreadReadResponse { thread, .. } = to_response::(read_resp)?; + let persisted_web_searches: Vec<&ThreadItem> = thread + .turns + .iter() + .flat_map(|turn| &turn.items) + .filter(|item| matches!(item, ThreadItem::WebSearch { .. })) + .collect(); + assert_eq!(persisted_web_searches, vec![&expected_completed_item]); + + Ok(()) +} + +async fn wait_for_web_search_started(mcp: &mut TestAppServer) -> Result { + loop { + let notification = mcp + .read_stream_until_notification_message("item/started") + .await?; + let started: ItemStartedNotification = serde_json::from_value( + notification + .params + .context("item/started notification should include params")?, + )?; + if matches!(&started.item, ThreadItem::WebSearch { .. }) { + return Ok(started); + } + } +} + +async fn wait_for_web_search_completed( + mcp: &mut TestAppServer, +) -> Result { + loop { + let notification = mcp + .read_stream_until_notification_message("item/completed") + .await?; + let completed: ItemCompletedNotification = serde_json::from_value( + notification + .params + .context("item/completed notification should include params")?, + )?; + if matches!(&completed.item, ThreadItem::WebSearch { .. }) { + return Ok(completed); + } + } +} + +async fn mount_search_response(server: &MockServer) { + Mock::given(method("POST")) + .and(path("/api/codex/alpha/search")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "encrypted_output": "ciphertext", + }))) + .expect(1) + .mount(server) + .await; +} + +fn has_hosted_web_search(body: &Value) -> bool { + body.get("tools") + .and_then(Value::as_array) + .is_some_and(|tools| { + tools + .iter() + .any(|tool| tool.get("type").and_then(Value::as_str) == Some("web_search")) + }) +} + +async fn search_request_body(server: &MockServer) -> Result { + server + .received_requests() + .await + .context("failed to fetch received requests")? + .into_iter() + .find(|request| request.url.path() == "/api/codex/alpha/search") + .context("expected standalone search request")? + .body_json() + .context("search request body should be JSON") +} + +fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +model_provider = "openai-custom" +chatgpt_base_url = "{server_uri}" + +[features] +standalone_web_search = true + +[model_providers.openai-custom] +name = "OpenAI" +base_url = "{server_uri}/api/codex" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +supports_websockets = false +requires_openai_auth = true +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/windows_sandbox_setup.rs b/codex-rs/app-server/tests/suite/v2/windows_sandbox_setup.rs index a0466a459b..afb2b2ab33 100644 --- a/codex-rs/app-server/tests/suite/v2/windows_sandbox_setup.rs +++ b/codex-rs/app-server/tests/suite/v2/windows_sandbox_setup.rs @@ -1,6 +1,6 @@ use anyhow::Context; use anyhow::Result; -use app_test_support::McpProcess; +use app_test_support::TestAppServer; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::to_response; use app_test_support::write_mock_responses_config_toml; @@ -31,7 +31,7 @@ async fn windows_sandbox_setup_start_emits_completion_notification() -> Result<( "mock_provider", "compact prompt", )?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp @@ -66,7 +66,7 @@ async fn windows_sandbox_setup_start_emits_completion_notification() -> Result<( #[tokio::test] async fn windows_sandbox_setup_start_rejects_relative_cwd() -> Result<()> { let codex_home = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp diff --git a/codex-rs/apply-patch/BUILD.bazel b/codex-rs/apply-patch/BUILD.bazel index e68984bc37..b43e8ed6aa 100644 --- a/codex-rs/apply-patch/BUILD.bazel +++ b/codex-rs/apply-patch/BUILD.bazel @@ -1,11 +1,6 @@ load("//:defs.bzl", "codex_rust_crate") -exports_files(["apply_patch_tool_instructions.md"]) - codex_rust_crate( name = "apply-patch", crate_name = "codex_apply_patch", - compile_data = [ - "apply_patch_tool_instructions.md", - ], ) diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index aa2326ab28..29d42e1c07 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -31,9 +31,6 @@ pub use standalone_executable::main; use crate::invocation::ExtractHeredocError; -/// Detailed instructions for gpt-4.1 on how to use the `apply_patch` tool. -pub const APPLY_PATCH_TOOL_INSTRUCTIONS: &str = include_str!("../apply_patch_tool_instructions.md"); - /// Special argv[1] flag used when the Codex executable self-invokes to run the /// internal `apply_patch` path. /// @@ -776,7 +773,7 @@ fn compute_replacements( } } - replacements.sort_by(|(lhs_idx, _, _), (rhs_idx, _, _)| lhs_idx.cmp(rhs_idx)); + replacements.sort_by_key(|(index, _, _)| *index); Ok(replacements) } diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 6365d527ed..46c83fdde4 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -1,4 +1,5 @@ use crate::types::CodeTaskDetailsResponse; +use crate::types::ConfigBundleResponse; use crate::types::ConfigFileResponse; use crate::types::PaginatedListTaskListItem; use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind; @@ -408,6 +409,23 @@ impl Client { .map_err(RequestError::from) } + /// Fetch the selected cloud-managed config bundle from codex-backend. + /// + /// `GET /api/codex/config/bundle` (Codex API style) or + /// `GET /wham/config/bundle` (ChatGPT backend-api style). + pub async fn get_config_bundle( + &self, + ) -> std::result::Result { + let url = match self.path_style { + PathStyle::CodexApi => format!("{}/api/codex/config/bundle", self.base_url), + PathStyle::ChatGptApi => format!("{}/wham/config/bundle", self.base_url), + }; + let req = self.http.get(&url).headers(self.headers()); + let (body, ct) = self.exec_request_detailed(req, "GET", &url).await?; + self.decode_json::(&url, &ct, &body) + .map_err(RequestError::from) + } + /// Create a new task (user turn) by POSTing to the appropriate backend path /// based on `path_style`. Returns the created task id. pub async fn create_task(&self, request_body: serde_json::Value) -> Result { diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index 300da81568..00b5bd3697 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -6,7 +6,11 @@ pub use client::Client; pub use client::RequestError; pub use types::CodeTaskDetailsResponse; pub use types::CodeTaskDetailsResponseExt; +pub use types::ConfigBundleResponse; pub use types::ConfigFileResponse; +pub use types::DeliveredConfigToml; +pub use types::DeliveredRequirementsToml; +pub use types::DeliveredTomlFragment; pub use types::PaginatedListTaskListItem; pub use types::TaskListItem; pub use types::TurnAttemptsSiblingTurnsResponse; diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index d8d24ab9fc..67304f100f 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -1,5 +1,9 @@ +pub use codex_backend_openapi_models::models::ConfigBundleResponse; pub use codex_backend_openapi_models::models::ConfigFileResponse; pub use codex_backend_openapi_models::models::CreditStatusDetails; +pub use codex_backend_openapi_models::models::DeliveredConfigToml; +pub use codex_backend_openapi_models::models::DeliveredRequirementsToml; +pub use codex_backend_openapi_models::models::DeliveredTomlFragment; pub use codex_backend_openapi_models::models::PaginatedListTaskListItem; pub use codex_backend_openapi_models::models::PlanType; pub use codex_backend_openapi_models::models::RateLimitReachedKind; diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index 2e54192e85..63b34e7355 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::collections::HashSet; use std::time::Duration; @@ -154,20 +155,21 @@ pub fn connectors_for_plugin_apps( connectors: Vec, plugin_apps: &[AppConnectorId], ) -> Vec { - let plugin_app_ids = plugin_apps - .iter() - .map(|connector_id| connector_id.0.as_str()) - .collect::>(); - let connectors = merge_plugin_connectors( connectors, plugin_apps .iter() .map(|connector_id| connector_id.0.clone()), ); - filter_disallowed_connectors(connectors, originator().value.as_str()) - .into_iter() - .filter(|connector| plugin_app_ids.contains(connector.id.as_str())) + let mut connectors_by_id = + filter_disallowed_connectors(connectors, originator().value.as_str()) + .into_iter() + .map(|connector| (connector.id.clone(), connector)) + .collect::>(); + + plugin_apps + .iter() + .filter_map(|connector_id| connectors_by_id.remove(connector_id.0.as_str())) .collect() } @@ -266,13 +268,14 @@ mod tests { let connectors = connectors_for_plugin_apps( vec![app("alpha"), app("beta")], &[ + AppConnectorId("gmail".to_string()), AppConnectorId("alpha".to_string()), AppConnectorId("gmail".to_string()), ], ); assert_eq!( connectors, - vec![app("alpha"), merged_app("gmail", /*is_accessible*/ false)] + vec![merged_app("gmail", /*is_accessible*/ false), app("alpha")] ); } diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 627d5d16d1..6a4d78fbc7 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -37,6 +37,7 @@ codex-exec = { workspace = true } codex-exec-server = { workspace = true } codex-execpolicy = { workspace = true } codex-features = { workspace = true } +codex-git-utils = { workspace = true } codex-install-context = { workspace = true } codex-login = { workspace = true } codex-memories-write = { workspace = true } @@ -48,6 +49,7 @@ codex-plugin = { workspace = true } codex-protocol = { workspace = true } codex-responses-api-proxy = { workspace = true } codex-rmcp-client = { workspace = true } +codex-rollout = { workspace = true } codex-rollout-trace = { workspace = true } codex-sandboxing = { workspace = true } codex-state = { workspace = true } @@ -59,11 +61,13 @@ codex-utils-path = { workspace = true } crossterm = { workspace = true } http = { workspace = true } libc = { workspace = true } +os_info = { workspace = true } owo-colors = { workspace = true } regex-lite = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } supports-color = { workspace = true } +sys-locale = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = [ "io-std", @@ -78,14 +82,22 @@ toml = { workspace = true } tracing = { workspace = true } tracing-appender = { workspace = true } tracing-subscriber = { workspace = true } +unicode-segmentation = { workspace = true } +url = { workspace = true } +which = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] codex_windows_sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" } +windows-sys = { version = "0.52", features = [ + "Win32_Foundation", + "Win32_System_Console", +] } [dev-dependencies] assert_cmd = { workspace = true } assert_matches = { workspace = true } codex-utils-cargo-bin = { workspace = true } +insta = { workspace = true } predicates = { workspace = true } pretty_assertions = { workspace = true } sqlx = { workspace = true } diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 30c60d7b95..5a4944f50d 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -41,9 +41,11 @@ use seatbelt::DenialLogger; pub async fn run_command_under_seatbelt( command: SeatbeltCommand, codex_linux_sandbox_exe: Option, + loader_overrides: LoaderOverrides, ) -> anyhow::Result<()> { let SeatbeltCommand { permissions_profile, + config_profile: _, cwd, include_managed_config, allow_unix_sockets, @@ -60,6 +62,7 @@ pub async fn run_command_under_seatbelt( permissions_profile, cwd, managed_requirements_mode, + loader_overrides, }, command, config_overrides, @@ -75,6 +78,7 @@ pub async fn run_command_under_seatbelt( pub async fn run_command_under_seatbelt( _command: SeatbeltCommand, _codex_linux_sandbox_exe: Option, + _loader_overrides: LoaderOverrides, ) -> anyhow::Result<()> { anyhow::bail!("Seatbelt sandbox is only available on macOS"); } @@ -82,9 +86,11 @@ pub async fn run_command_under_seatbelt( pub async fn run_command_under_landlock( command: LandlockCommand, codex_linux_sandbox_exe: Option, + loader_overrides: LoaderOverrides, ) -> anyhow::Result<()> { let LandlockCommand { permissions_profile, + config_profile: _, cwd, include_managed_config, config_overrides, @@ -99,6 +105,7 @@ pub async fn run_command_under_landlock( permissions_profile, cwd, managed_requirements_mode, + loader_overrides, }, command, config_overrides, @@ -110,12 +117,14 @@ pub async fn run_command_under_landlock( .await } -pub async fn run_command_under_windows( +pub async fn run_command_under_windows_sandbox( command: WindowsCommand, codex_linux_sandbox_exe: Option, + loader_overrides: LoaderOverrides, ) -> anyhow::Result<()> { let WindowsCommand { permissions_profile, + config_profile: _, cwd, include_managed_config, config_overrides, @@ -130,6 +139,7 @@ pub async fn run_command_under_windows( permissions_profile, cwd, managed_requirements_mode, + loader_overrides, }, command, config_overrides, @@ -153,6 +163,7 @@ struct DebugSandboxConfigOptions { permissions_profile: Option, cwd: Option, managed_requirements_mode: ManagedRequirementsMode, + loader_overrides: LoaderOverrides, } #[derive(Debug, Clone, Copy)] @@ -197,10 +208,12 @@ async fn run_command_under_sandbox( // In practice, this should be `std::env::current_dir()` because this CLI // does not support `--cwd`, but let's use the config value for consistency. let cwd = config.cwd.clone(); - // For now, we always use the same cwd for both the command and the - // sandbox policy. In the future, we could add a CLI option to set them - // separately. + // Non-Windows sandbox launchers still use `sandbox_policy_cwd` for any + // remaining cwd-dependent policy resolution. `:workspace_roots` entries in + // the effective profile have already been materialized from config roots. let sandbox_policy_cwd = cwd.clone(); + #[cfg(target_os = "windows")] + let workspace_roots = config.effective_workspace_roots(); let env = create_env( &config.permissions.shell_environment_policy, @@ -211,7 +224,7 @@ async fn run_command_under_sandbox( if let SandboxType::Windows = sandbox_type { #[cfg(target_os = "windows")] { - run_command_under_windows_session(&config, command, cwd, sandbox_policy_cwd, env).await; + run_command_under_windows_session(&config, command, cwd, workspace_roots, env).await; } #[cfg(not(target_os = "windows"))] { @@ -230,7 +243,7 @@ async fn run_command_under_sandbox( let network_proxy = match config.permissions.network.as_ref() { Some(spec) => Some( spec.start_proxy( - config.permissions.permission_profile.get(), + config.permissions.permission_profile(), /*policy_decider*/ None, /*blocked_request_observer*/ None, managed_network_requirements_enabled, @@ -285,7 +298,7 @@ async fn run_command_under_sandbox( let args = create_linux_sandbox_command_args_for_permission_profile( command, cwd.as_path(), - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), sandbox_policy_cwd.as_path(), use_legacy_landlock, allow_network_for_proxy(managed_network_requirements_enabled), @@ -338,24 +351,15 @@ async fn run_command_under_windows_session( config: &Config, command: Vec, cwd: AbsolutePathBuf, - sandbox_policy_cwd: AbsolutePathBuf, + workspace_roots: Vec, env: std::collections::HashMap, ) -> ! { use codex_core::windows_sandbox::WindowsSandboxLevelExt; use codex_protocol::config_types::WindowsSandboxLevel; - use codex_windows_sandbox::spawn_windows_sandbox_session_elevated; + use codex_windows_sandbox::spawn_windows_sandbox_session_elevated_for_permission_profile; use codex_windows_sandbox::spawn_windows_sandbox_session_legacy; - let sandbox_policy = config - .permissions - .legacy_sandbox_policy(sandbox_policy_cwd.as_path()); - let policy_str = match serde_json::to_string(&sandbox_policy) { - Ok(policy_str) => policy_str, - Err(err) => { - eprintln!("windows sandbox failed to serialize policy: {err}"); - std::process::exit(1); - } - }; + let permission_profile = config.permissions.effective_permission_profile(); let use_elevated = matches!( WindowsSandboxLevel::from_config(config), @@ -363,9 +367,9 @@ async fn run_command_under_windows_session( ); let spawned = if use_elevated { - spawn_windows_sandbox_session_elevated( - policy_str.as_str(), - sandbox_policy_cwd.as_path(), + spawn_windows_sandbox_session_elevated_for_permission_profile( + &permission_profile, + workspace_roots.as_slice(), config.codex_home.as_path(), command, cwd.as_path(), @@ -383,8 +387,8 @@ async fn run_command_under_windows_session( .await } else { spawn_windows_sandbox_session_legacy( - policy_str.as_str(), - sandbox_policy_cwd.as_path(), + &permission_profile, + workspace_roots.as_slice(), config.codex_home.as_path(), command, cwd.as_path(), @@ -650,7 +654,7 @@ async fn load_debug_sandbox_config( } async fn load_debug_sandbox_config_with_codex_home( - mut cli_overrides: Vec<(String, TomlValue)>, + cli_overrides: Vec<(String, TomlValue)>, codex_linux_sandbox_exe: Option, options: DebugSandboxConfigOptions, codex_home: Option, @@ -660,7 +664,9 @@ async fn load_debug_sandbox_config_with_codex_home( permissions_profile, cwd, managed_requirements_mode, + loader_overrides, } = options; + let mut cli_overrides = cli_overrides; if let Some(permissions_profile) = permissions_profile { cli_overrides.push(( @@ -672,10 +678,9 @@ async fn load_debug_sandbox_config_with_codex_home( // For legacy configs, `codex sandbox` historically defaulted to read-only // instead of inheriting ambient `sandbox_mode` settings from user/system // config. Keep that behavior unless this invocation explicitly passes a - // legacy `sandbox_mode` CLI override, which is now the documented writable - // replacement for the removed `--full-auto` flag. + // legacy `sandbox_mode` CLI override for compatibility with older callers. let uses_legacy_sandbox_mode_override = cli_overrides_use_legacy_sandbox_mode(&cli_overrides); - let config = build_debug_sandbox_config( + let config = build_debug_sandbox_config_with_loader_overrides( cli_overrides.clone(), ConfigOverrides { cwd: cwd.clone(), @@ -684,6 +689,7 @@ async fn load_debug_sandbox_config_with_codex_home( }, codex_home.clone(), managed_requirements_mode, + loader_overrides.clone(), strict_config, ) .await?; @@ -692,7 +698,7 @@ async fn load_debug_sandbox_config_with_codex_home( return Ok(config); } - build_debug_sandbox_config( + build_debug_sandbox_config_with_loader_overrides( cli_overrides, ConfigOverrides { sandbox_mode: Some(SandboxMode::ReadOnly), @@ -702,17 +708,19 @@ async fn load_debug_sandbox_config_with_codex_home( }, codex_home, managed_requirements_mode, + loader_overrides, strict_config, ) .await .map_err(Into::into) } -async fn build_debug_sandbox_config( +async fn build_debug_sandbox_config_with_loader_overrides( cli_overrides: Vec<(String, TomlValue)>, harness_overrides: ConfigOverrides, codex_home: Option, managed_requirements_mode: ManagedRequirementsMode, + mut loader_overrides: LoaderOverrides, strict_config: bool, ) -> std::io::Result { let mut builder = ConfigBuilder::default() @@ -720,11 +728,9 @@ async fn build_debug_sandbox_config( .harness_overrides(harness_overrides) .strict_config(strict_config); if matches!(managed_requirements_mode, ManagedRequirementsMode::Ignore) { - builder = builder.loader_overrides(LoaderOverrides { - ignore_managed_requirements: true, - ..LoaderOverrides::default() - }); + loader_overrides.ignore_managed_requirements = true; } + builder = builder.loader_overrides(loader_overrides); if let Some(codex_home) = codex_home { builder = builder .codex_home(codex_home.clone()) @@ -751,6 +757,24 @@ mod tests { use pretty_assertions::assert_eq; use tempfile::TempDir; + async fn build_debug_sandbox_config( + cli_overrides: Vec<(String, TomlValue)>, + harness_overrides: ConfigOverrides, + codex_home: Option, + managed_requirements_mode: ManagedRequirementsMode, + strict_config: bool, + ) -> std::io::Result { + build_debug_sandbox_config_with_loader_overrides( + cli_overrides, + harness_overrides, + codex_home, + managed_requirements_mode, + LoaderOverrides::default(), + strict_config, + ) + .await + } + fn escape_toml_path(path: &std::path::Path) -> String { path.display().to_string().replace('\\', "\\\\") } @@ -759,6 +783,18 @@ mod tests { codex_home: &TempDir, docs: &std::path::Path, private: &std::path::Path, + ) -> std::io::Result<()> { + write_permissions_profile_config_to_path( + &codex_home.path().join("config.toml"), + docs, + private, + ) + } + + fn write_permissions_profile_config_to_path( + config_path: &std::path::Path, + docs: &std::path::Path, + private: &std::path::Path, ) -> std::io::Result<()> { std::fs::create_dir_all(private)?; let config = format!( @@ -773,7 +809,7 @@ mod tests { escape_toml_path(docs), escape_toml_path(private), ); - std::fs::write(codex_home.path().join("config.toml"), config)?; + std::fs::write(config_path, config)?; Ok(()) } @@ -813,6 +849,7 @@ mod tests { permissions_profile: None, cwd: None, managed_requirements_mode: ManagedRequirementsMode::Include, + loader_overrides: LoaderOverrides::default(), }, Some(codex_home_path), /*strict_config*/ false, @@ -837,6 +874,70 @@ mod tests { Ok(()) } + #[tokio::test] + async fn debug_sandbox_honors_config_profile_loader_overrides() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let sandbox_paths = TempDir::new()?; + let docs = sandbox_paths.path().join("docs"); + let private = docs.join("private"); + let profile_path = codex_home.path().join("work.config.toml"); + write_permissions_profile_config_to_path(&profile_path, &docs, &private)?; + let codex_home_path = codex_home.path().to_path_buf(); + let loader_overrides = LoaderOverrides { + user_config_path: Some(AbsolutePathBuf::from_absolute_path(&profile_path)?), + user_config_profile: Some("work".parse().expect("profile name should parse")), + ..LoaderOverrides::default() + }; + + let profile_config = build_debug_sandbox_config_with_loader_overrides( + Vec::new(), + ConfigOverrides::default(), + Some(codex_home_path.clone()), + ManagedRequirementsMode::Include, + loader_overrides.clone(), + /*strict_config*/ false, + ) + .await?; + let read_only_config = build_debug_sandbox_config( + Vec::new(), + ConfigOverrides { + sandbox_mode: Some(SandboxMode::ReadOnly), + ..Default::default() + }, + Some(codex_home_path.clone()), + ManagedRequirementsMode::Include, + /*strict_config*/ false, + ) + .await?; + + let config = load_debug_sandbox_config_with_codex_home( + Vec::new(), + /*codex_linux_sandbox_exe*/ None, + DebugSandboxConfigOptions { + permissions_profile: None, + cwd: None, + managed_requirements_mode: ManagedRequirementsMode::Include, + loader_overrides, + }, + Some(codex_home_path), + /*strict_config*/ false, + ) + .await?; + + assert!(config_uses_permission_profiles(&config)); + assert_ne!( + profile_config.permissions.file_system_sandbox_policy(), + read_only_config.permissions.file_system_sandbox_policy(), + "test fixture should distinguish the profile config from read-only" + ); + assert_eq!( + config.permissions.file_system_sandbox_policy(), + profile_config.permissions.file_system_sandbox_policy(), + ); + + Ok(()) + } + #[tokio::test] async fn debug_sandbox_honors_explicit_legacy_sandbox_mode() -> anyhow::Result<()> { let codex_home = TempDir::new()?; @@ -873,6 +974,7 @@ mod tests { permissions_profile: None, cwd: None, managed_requirements_mode: ManagedRequirementsMode::Include, + loader_overrides: LoaderOverrides::default(), }, Some(codex_home_path), /*strict_config*/ false, @@ -930,6 +1032,7 @@ mod tests { permissions_profile: None, cwd: None, managed_requirements_mode: ManagedRequirementsMode::Include, + loader_overrides: LoaderOverrides::default(), }, Some(codex_home_path), /*strict_config*/ false, @@ -956,50 +1059,25 @@ mod tests { permissions_profile: Some(":workspace".to_string()), cwd: None, managed_requirements_mode: ManagedRequirementsMode::Ignore, + loader_overrides: LoaderOverrides::default(), }, Some(codex_home.path().to_path_buf()), /*strict_config*/ false, ) .await?; - assert_eq!( - config.permissions.file_system_sandbox_policy(), - codex_protocol::models::PermissionProfile::workspace_write() - .file_system_sandbox_policy() - ); - - Ok(()) - } - - #[tokio::test] - async fn explicit_permission_profile_overrides_active_profile_sandbox_mode() - -> anyhow::Result<()> { - let codex_home = TempDir::new()?; - std::fs::write( - codex_home.path().join("config.toml"), - "profile = \"legacy\"\n\ - \n\ - [profiles.legacy]\n\ - sandbox_mode = \"danger-full-access\"\n", - )?; - - let config = load_debug_sandbox_config_with_codex_home( - Vec::new(), - /*codex_linux_sandbox_exe*/ None, - DebugSandboxConfigOptions { - permissions_profile: Some(":workspace".to_string()), - cwd: None, - managed_requirements_mode: ManagedRequirementsMode::Ignore, - }, - Some(codex_home.path().to_path_buf()), - /*strict_config*/ false, - ) - .await?; - - assert_eq!( - config.permissions.file_system_sandbox_policy(), - codex_protocol::models::PermissionProfile::workspace_write() - .file_system_sandbox_policy() + let actual = config + .permissions + .permission_profile() + .file_system_sandbox_policy(); + let expected = codex_protocol::models::PermissionProfile::workspace_write() + .file_system_sandbox_policy(); + assert!( + expected + .entries + .iter() + .all(|entry| actual.entries.contains(entry)), + "explicit workspace profile should preserve the built-in workspace rules" ); Ok(()) @@ -1020,6 +1098,7 @@ mod tests { permissions_profile: Some("limited-read-test".to_string()), cwd: None, managed_requirements_mode: ManagedRequirementsMode::Ignore, + loader_overrides: LoaderOverrides::default(), }, Some(codex_home.path().to_path_buf()), /*strict_config*/ false, @@ -1047,7 +1126,7 @@ mod tests { } #[tokio::test] - async fn debug_sandbox_uses_explicit_profile_cwd() -> anyhow::Result<()> { + async fn debug_sandbox_uses_explicit_cwd() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; @@ -1058,6 +1137,7 @@ mod tests { permissions_profile: Some(":workspace".to_string()), cwd: Some(cwd.path().to_path_buf()), managed_requirements_mode: ManagedRequirementsMode::Ignore, + loader_overrides: LoaderOverrides::default(), }, Some(codex_home.path().to_path_buf()), /*strict_config*/ false, diff --git a/codex-rs/cli/src/desktop_app/mac.rs b/codex-rs/cli/src/desktop_app/mac.rs index 10a47806b1..85928b551f 100644 --- a/codex-rs/cli/src/desktop_app/mac.rs +++ b/codex-rs/cli/src/desktop_app/mac.rs @@ -82,10 +82,11 @@ async fn open_codex_app(app_path: &Path, workspace: &Path) -> anyhow::Result<()> "Opening workspace {workspace}...", workspace = workspace.display() ); + let url = codex_new_thread_url(workspace); let status = Command::new("open") .arg("-a") .arg(app_path) - .arg(workspace) + .arg(&url) .status() .await .context("failed to invoke `open`")?; @@ -95,12 +96,20 @@ async fn open_codex_app(app_path: &Path, workspace: &Path) -> anyhow::Result<()> } anyhow::bail!( - "`open -a {app_path} {workspace}` exited with {status}", + "`open -a {app_path} {url}` exited with {status}", app_path = app_path.display(), - workspace = workspace.display() + url = url ); } +fn codex_new_thread_url(workspace: &Path) -> String { + let workspace = workspace.as_os_str().to_string_lossy(); + let mut serializer = url::form_urlencoded::Serializer::new(String::new()); + serializer.append_pair("path", workspace.as_ref()); + let query = serializer.finish(); + format!("codex://threads/new?{query}") +} + async fn download_and_install_codex_to_user_applications(dmg_url: &str) -> anyhow::Result { let temp_dir = Builder::new() .prefix("codex-app-installer-") @@ -293,8 +302,10 @@ fn parse_hdiutil_attach_mount_point(output: &str) -> Option { #[cfg(test)] mod tests { + use super::codex_new_thread_url; use super::parse_hdiutil_attach_mount_point; use pretty_assertions::assert_eq; + use std::path::Path; #[test] fn parses_mount_point_from_tab_separated_hdiutil_output() { @@ -313,4 +324,25 @@ mod tests { Some("/Volumes/Codex Installer") ); } + + #[test] + fn codex_new_thread_url_encodes_workspace_path() { + let url = url::Url::parse(&codex_new_thread_url(Path::new("/tmp/codex workspace/#1"))) + .expect("deep link should parse"); + + assert_eq!( + ( + url.scheme().to_string(), + url.host_str().map(str::to_string), + url.path().to_string(), + url.query_pairs().into_owned().collect::>(), + ), + ( + "codex".to_string(), + Some("threads".to_string()), + "/new".to_string(), + vec![("path".to_string(), "/tmp/codex workspace/#1".to_string())], + ) + ); + } } diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index a3742baa72..2e53d5d51f 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -39,7 +39,9 @@ use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_core::config::find_codex_home; use codex_features::FEATURES; +use codex_install_context::CodexPackageLayout; use codex_install_context::InstallContext; +use codex_install_context::InstallMethod; use codex_install_context::StandalonePlatform; use codex_login::AuthDotJson; use codex_login::AuthManager; @@ -64,12 +66,17 @@ use serde::Serialize; use supports_color::Stream; mod background; +mod git; mod output; mod progress; mod runtime; +mod system; +mod thread_inventory; +mod title; mod updates; use background::background_server_check; +use git::git_check; use output::HumanOutputOptions; use output::redact_detail; use output::render_human_report; @@ -77,6 +84,9 @@ use progress::DoctorProgress; use progress::doctor_progress; use runtime::runtime_check; use runtime::search_check; +use system::system_check; +use thread_inventory::thread_inventory_check; +use title::terminal_title_check; use updates::updates_check; const OPENAI_BETA_HEADER: &str = "OpenAI-Beta"; @@ -105,6 +115,10 @@ const COLOR_ENV_VARS: &[&str] = &[ const TERMINAL_DIMENSION_ENV_VARS: &[&str] = &["COLUMNS", "LINES"]; const TERMINFO_ENV_VARS: &[&str] = &["TERMINFO", "TERMINFO_DIRS"]; const LOCALE_ENV_VARS: &[&str] = &["LC_ALL", "LC_CTYPE", "LANG"]; +#[cfg(windows)] +const NPM_COMMAND: &str = "npm.cmd"; +#[cfg(not(windows))] +const NPM_COMMAND: &str = "npm"; const REMOTE_TERMINAL_ENV_VARS: &[&str] = &[ "SSH_TTY", "SSH_CONNECTION", @@ -324,6 +338,7 @@ async fn build_report( ) -> DoctorReport { let progress = doctor_progress(command.json); let mut checks = Vec::new(); + checks.push(run_sync_check("system", progress.clone(), system_check)); checks.push(run_sync_check("installation", progress.clone(), || { installation_check(!command.summary) })); @@ -346,7 +361,10 @@ async fn build_report( mcp_check, sandbox_check, terminal_check, + git_check, + terminal_title_check, state_check, + thread_inventory_check, background_server_check, reachability_check, ) = tokio::join!( @@ -370,12 +388,23 @@ async fn build_report( terminal_check(command.no_color) }) }, - run_async_check("state", progress.clone(), state_check(config)), + run_async_check("git", progress.clone(), git_check(config.cwd.as_path())), async { - run_sync_check("app-server", progress.clone(), || { - background_server_check(config) + run_sync_check("terminal title", progress.clone(), || { + terminal_title_check(config) }) }, + run_async_check("state", progress.clone(), state_check(config)), + run_async_check( + "thread inventory", + progress.clone(), + thread_inventory_check(config), + ), + run_async_check( + "app-server", + progress.clone(), + background_server_check(config) + ), run_async_check( "provider reachability", progress.clone(), @@ -391,14 +420,28 @@ async fn build_report( mcp_check, sandbox_check, terminal_check, + git_check, + terminal_title_check, state_check, + thread_inventory_check, background_server_check, reachability_check, ]); } Err(err) => { let reachability_plan = default_reachability_plan(); - let (config_check, network_check, terminal_check, state_check, reachability_check) = tokio::join!( + let fallback_cwd = interactive + .cwd + .clone() + .unwrap_or_else(|| env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); + let ( + config_check, + network_check, + terminal_check, + git_check, + state_check, + reachability_check, + ) = tokio::join!( async { run_sync_check("config", progress.clone(), || { DoctorCheck::new( @@ -417,6 +460,7 @@ async fn build_report( terminal_check(command.no_color) }) }, + run_async_check("git", progress.clone(), git_check(fallback_cwd.as_path())), async { run_sync_check("state", progress.clone(), fallback_state_check) }, run_async_check( "provider reachability", @@ -428,6 +472,7 @@ async fn build_report( config_check, network_check, terminal_check, + git_check, state_check, reachability_check, ]); @@ -490,7 +535,6 @@ fn config_overrides_from_interactive( }; ConfigOverrides { model: interactive.model.clone(), - config_profile: interactive.config_profile.clone(), approval_policy, sandbox_mode, cwd: interactive.cwd.clone(), @@ -808,7 +852,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() } @@ -839,8 +886,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, @@ -849,22 +896,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 { @@ -884,7 +972,7 @@ fn npm_global_root_check() -> NpmRootCheck { return NpmRootCheck::MissingPackageRoot; }; - let output = match run_command("npm", ["root", "-g"]) { + let output = match run_command(NPM_COMMAND, ["root", "-g"]) { Ok(output) => output, Err(err) => return NpmRootCheck::NpmUnavailable(err), }; @@ -985,6 +1073,7 @@ fn config_check(config: &Config) -> DoctorCheck { let status = if config.startup_warnings.is_empty() { CheckStatus::Ok } else { + push_startup_warning_counts(&mut details, &config.startup_warnings); details.extend( config .startup_warnings @@ -997,6 +1086,23 @@ fn config_check(config: &Config) -> DoctorCheck { DoctorCheck::new("config.load", "config", status, "config loaded").details(details) } +fn push_startup_warning_counts(details: &mut Vec, warnings: &[String]) { + details.push(format!("startup warnings: {}", warnings.len())); + for (label, needle) in [ + ("startup warning skills", "skill"), + ("startup warning hooks", "hook"), + ("startup warning plugins", "plugin"), + ("startup warning MCP", "mcp"), + ("startup warning deprecated", "deprecated"), + ] { + let count = warnings + .iter() + .filter(|warning| warning.to_ascii_lowercase().contains(needle)) + .count(); + details.push(format!("{label}: {count}")); + } +} + fn feature_flag_details(config: &Config, details: &mut Vec) { let features = config.features.get(); let enabled_features = FEATURES @@ -1530,6 +1636,7 @@ struct TerminalCheckInputs { stream_supports_color: bool, terminal_size: Result<(u16, u16), String>, tmux_details: Vec, + windows_console_details: Vec, } impl TerminalCheckInputs { @@ -1543,6 +1650,7 @@ impl TerminalCheckInputs { } else { Vec::new() }; + let windows_console_details = windows_console_details(); Self { info, env, @@ -1554,6 +1662,7 @@ impl TerminalCheckInputs { stream_supports_color: supports_color::on(Stream::Stdout).is_some(), terminal_size, tmux_details, + windows_console_details, } } @@ -1570,6 +1679,51 @@ fn terminal_check(no_color_flag: bool) -> DoctorCheck { terminal_check_from_inputs(TerminalCheckInputs::detect(no_color_flag)) } +#[cfg(windows)] +fn windows_console_details() -> Vec { + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + use windows_sys::Win32::System::Console::ENABLE_VIRTUAL_TERMINAL_PROCESSING; + use windows_sys::Win32::System::Console::GetConsoleCP; + use windows_sys::Win32::System::Console::GetConsoleMode; + use windows_sys::Win32::System::Console::GetConsoleOutputCP; + use windows_sys::Win32::System::Console::GetStdHandle; + use windows_sys::Win32::System::Console::STD_ERROR_HANDLE; + use windows_sys::Win32::System::Console::STD_OUTPUT_HANDLE; + + let mut details = Vec::new(); + details.push(format!("console input code page: {}", unsafe { + GetConsoleCP() + })); + details.push(format!("console output code page: {}", unsafe { + GetConsoleOutputCP() + })); + details.push(console_mode_detail("stdout console mode", unsafe { + GetStdHandle(STD_OUTPUT_HANDLE) + })); + details.push(console_mode_detail("stderr console mode", unsafe { + GetStdHandle(STD_ERROR_HANDLE) + })); + + fn console_mode_detail(label: &str, handle: isize) -> String { + if handle == 0 || handle == INVALID_HANDLE_VALUE { + return format!("{label}: unavailable"); + } + let mut mode = 0_u32; + if unsafe { GetConsoleMode(handle, &mut mode) } == 0 { + return format!("{label}: unavailable"); + } + let vt_enabled = mode & ENABLE_VIRTUAL_TERMINAL_PROCESSING != 0; + format!("{label}: 0x{mode:08x} (VT processing: {vt_enabled})") + } + + details +} + +#[cfg(not(windows))] +fn windows_console_details() -> Vec { + Vec::new() +} + fn terminal_check_from_inputs(inputs: TerminalCheckInputs) -> DoctorCheck { let info = &inputs.info; let name = info.name; @@ -1603,6 +1757,7 @@ fn terminal_check_from_inputs(inputs: TerminalCheckInputs) -> DoctorCheck { } push_presence_env_values(&mut details, &inputs, REMOTE_TERMINAL_ENV_VARS); details.extend(inputs.tmux_details.iter().cloned()); + details.extend(inputs.windows_console_details.iter().cloned()); let locale_warning = locale.as_deref().is_some_and(is_non_utf8_locale); let mut issues = Vec::new(); @@ -1939,13 +2094,11 @@ async fn state_check(config: &Config) -> DoctorCheck { path_readiness(&mut details, "CODEX_HOME", &config.codex_home); path_readiness(&mut details, "log dir", &config.log_dir); path_readiness(&mut details, "sqlite home", &config.sqlite_home); - let state_db = codex_state::state_db_path(&config.sqlite_home); - let log_db = codex_state::logs_db_path(&config.sqlite_home); - path_readiness(&mut details, "state DB", &state_db); - path_readiness(&mut details, "log DB", &log_db); let mut integrity_failures = Vec::new(); - sqlite_integrity_detail(&mut details, &mut integrity_failures, "state DB", &state_db).await; - sqlite_integrity_detail(&mut details, &mut integrity_failures, "log DB", &log_db).await; + for db in codex_state::runtime_db_paths(&config.sqlite_home) { + path_readiness(&mut details, db.label, &db.path); + sqlite_integrity_detail(&mut details, &mut integrity_failures, db.label, &db.path).await; + } rollout_stats_details(&mut details, &config.codex_home); standalone_release_cache_details(&mut details); @@ -2023,11 +2176,7 @@ struct RolloutStats { impl RolloutStats { fn average_bytes(&self) -> u64 { - if self.files == 0 { - 0 - } else { - self.total_bytes / self.files - } + self.total_bytes.checked_div(self.files).unwrap_or(0) } } @@ -2789,13 +2938,14 @@ fn path_readiness(details: &mut Vec, label: &str, path: &Path) { } fn standalone_release_cache_details(details: &mut Vec) { - let InstallContext::Standalone { release_dir, .. } = InstallContext::current() else { + let context = InstallContext::current(); + let InstallMethod::Standalone { release_dir, .. } = &context.method else { return; }; let Some(releases_dir) = release_dir.parent() else { return; }; - let Ok(entries) = std::fs::read_dir(releases_dir) else { + let Ok(entries) = std::fs::read_dir(&releases_dir) else { return; }; let release_count = entries.filter_map(Result::ok).count(); @@ -2982,6 +3132,31 @@ mod tests { ); } + #[test] + fn startup_warning_counts_group_known_sources() { + let warnings = vec![ + "Skipped loading 2 skill(s) due to invalid SKILL.md files.".to_string(), + "[features].codex_hooks is deprecated. Use [features].hooks instead.".to_string(), + "plugin example failed to load".to_string(), + "MCP server example failed to start".to_string(), + ]; + let mut details = Vec::new(); + + push_startup_warning_counts(&mut details, &warnings); + + assert_eq!( + details, + vec![ + "startup warnings: 4", + "startup warning skills: 1", + "startup warning hooks: 1", + "startup warning plugins: 1", + "startup warning MCP: 1", + "startup warning deprecated: 1", + ] + ); + } + #[test] fn config_overrides_from_interactive_preserves_global_options() { let interactive = TuiCli::parse_from([ @@ -3675,6 +3850,7 @@ mod tests { stream_supports_color: true, terminal_size: Ok((120, 40)), tmux_details: Vec::new(), + windows_console_details: Vec::new(), } } @@ -3808,6 +3984,22 @@ mod tests { ); } + #[test] + fn terminal_check_includes_windows_console_details() { + let mut inputs = terminal_inputs(); + inputs + .windows_console_details + .push("stdout console mode: 0x00000004 (VT processing: true)".to_string()); + + let check = terminal_check_from_inputs(inputs); + + assert!( + check + .details + .contains(&"stdout console mode: 0x00000004 (VT processing: true)".to_string()) + ); + } + #[test] fn terminal_check_keeps_tmux_probe_failures_non_fatal() { let mut inputs = terminal_inputs(); diff --git a/codex-rs/cli/src/doctor/background.rs b/codex-rs/cli/src/doctor/background.rs index 0ec5132cd3..3611e28f6c 100644 --- a/codex-rs/cli/src/doctor/background.rs +++ b/codex-rs/cli/src/doctor/background.rs @@ -2,7 +2,7 @@ //! //! The background-server check is deliberately passive. It reads the daemon //! state directory, PID files, settings file, and control socket path, then -//! attempts only a local socket connection when a socket already exists. That +//! attempts only a bounded initialize probe when a socket already exists. That //! keeps doctor safe to run while the user is debugging startup or update-loop //! issues. @@ -13,6 +13,7 @@ use codex_core::config::Config; use super::CheckStatus; use super::DoctorCheck; +const MAX_PROBE_ERROR_CHARS: usize = 120; const STATE_DIR_NAME: &str = "app-server-daemon"; const SETTINGS_FILE_NAME: &str = "settings.json"; const PID_FILE_NAME: &str = "app-server.pid"; @@ -23,7 +24,7 @@ const UPDATE_PID_FILE_NAME: &str = "app-server-updater.pid"; /// Missing files are expected for the ephemeral/not-running case and should not /// be treated as failures. A stale socket is a warning because it can explain /// client connection problems without proving the daemon itself is broken. -pub(super) fn background_server_check(config: &Config) -> DoctorCheck { +pub(super) async fn background_server_check(config: &Config) -> DoctorCheck { let mut details = Vec::new(); let state_dir = config.codex_home.join(STATE_DIR_NAME); details.push(format!("daemon state dir: {}", state_dir.display())); @@ -54,8 +55,11 @@ pub(super) fn background_server_check(config: &Config) -> DoctorCheck { }; details.push(format!("control socket: {}", socket_path.display())); - let status = socket_status(socket_path.as_path()); + let status = socket_status(socket_path.as_path()).await; details.push(format!("status: {}", status.detail_label())); + if let Some(version_detail) = status.app_server_version_detail() { + details.push(version_detail); + } details.push(format!("mode: {}", server_mode(&state_dir))); let mut check = DoctorCheck::new( @@ -94,57 +98,162 @@ fn server_mode(state_dir: &Path) -> &'static str { } } -#[derive(Clone, Copy)] enum SocketStatus { NotRunning, - Running, - #[cfg(unix)] - StaleOrUnreachable, + Running(String), + StaleOrUnreachable(String), } impl SocketStatus { - fn check_status(self) -> CheckStatus { + fn check_status(&self) -> CheckStatus { match self { - Self::NotRunning | Self::Running => CheckStatus::Ok, - #[cfg(unix)] - Self::StaleOrUnreachable => CheckStatus::Warning, + Self::NotRunning | Self::Running(_) => CheckStatus::Ok, + Self::StaleOrUnreachable(_) => CheckStatus::Warning, } } - fn summary(self) -> &'static str { + fn summary(&self) -> &'static str { match self { Self::NotRunning => "background server is not running", - Self::Running => "background server is running", - #[cfg(unix)] - Self::StaleOrUnreachable => "background server socket is stale or unreachable", + Self::Running(_) => "background server is running", + Self::StaleOrUnreachable(_) => "background server socket is stale or unreachable", } } - fn detail_label(self) -> &'static str { + fn detail_label(&self) -> &'static str { match self { Self::NotRunning => "not running", - Self::Running => "running", - #[cfg(unix)] - Self::StaleOrUnreachable => "stale or unreachable", + Self::Running(_) => "running", + Self::StaleOrUnreachable(_) => "stale or unreachable", + } + } + + fn app_server_version_detail(&self) -> Option { + match self { + Self::NotRunning => None, + Self::Running(app_server_version) => { + Some(format!("app-server version: {app_server_version}")) + } + Self::StaleOrUnreachable(error) => { + Some(format!("app-server version: unavailable ({error})")) + } } } } -fn socket_status(socket_path: &Path) -> SocketStatus { +async fn socket_status(socket_path: &Path) -> SocketStatus { if !socket_path.exists() { return SocketStatus::NotRunning; } - #[cfg(unix)] - { - match std::os::unix::net::UnixStream::connect(socket_path) { - Ok(_) => SocketStatus::Running, - Err(_) => SocketStatus::StaleOrUnreachable, - } - } - - #[cfg(not(unix))] - { - SocketStatus::Running + match codex_app_server_daemon::probe_app_server_version(socket_path).await { + Ok(app_server_version) => SocketStatus::Running(app_server_version), + Err(err) => SocketStatus::StaleOrUnreachable(concise_probe_error(&err, socket_path)), + } +} + +fn concise_probe_error(err: &anyhow::Error, socket_path: &Path) -> String { + let socket_path = socket_path.display().to_string(); + let message = err + .to_string() + .replace(&socket_path, "control socket") + .split_whitespace() + .collect::>() + .join(" "); + if message.is_empty() { + return "unknown error".to_string(); + } + let mut chars = message.chars(); + let truncated = chars + .by_ref() + .take(MAX_PROBE_ERROR_CHARS) + .collect::(); + if chars.next().is_some() { + format!("{truncated}...") + } else { + message + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use codex_core::config::ConfigBuilder; + use pretty_assertions::assert_eq; + + use super::*; + + async fn test_config(codex_home: PathBuf) -> Config { + ConfigBuilder::default() + .codex_home(codex_home) + .build() + .await + .expect("config") + } + + fn create_socket_placeholder(config: &Config) { + let socket_path = codex_app_server::app_server_control_socket_path(&config.codex_home) + .expect("socket path"); + std::fs::create_dir_all(socket_path.parent().expect("socket parent")) + .expect("create socket dir"); + std::fs::write(socket_path, "").expect("create socket placeholder"); + } + + #[tokio::test] + async fn not_running_background_server_stays_ok_without_version() { + let temp = tempfile::tempdir().expect("tempdir"); + let config = test_config(temp.path().to_path_buf()).await; + + let check = background_server_check(&config).await; + + assert_eq!(check.status, CheckStatus::Ok); + assert_eq!(check.summary, "background server is not running"); + assert!(check.details.contains(&"status: not running".to_string())); + assert!( + !check + .details + .iter() + .any(|detail| detail.starts_with("app-server version:")) + ); + } + + #[test] + fn running_background_server_reports_app_server_version() { + let status = SocketStatus::Running("1.2.3".to_string()); + + assert_eq!(status.check_status(), CheckStatus::Ok); + assert_eq!(status.summary(), "background server is running"); + assert_eq!(status.detail_label(), "running"); + assert_eq!( + status.app_server_version_detail(), + Some("app-server version: 1.2.3".to_string()) + ); + } + + #[tokio::test] + async fn failed_version_probe_reports_unavailable() { + let temp = tempfile::tempdir().expect("tempdir"); + let config = test_config(temp.path().to_path_buf()).await; + create_socket_placeholder(&config); + + let check = background_server_check(&config).await; + + assert_eq!(check.status, CheckStatus::Warning); + assert_eq!( + check.summary, + "background server socket is stale or unreachable" + ); + assert!( + check + .details + .contains(&"status: stale or unreachable".to_string()) + ); + assert!( + check + .details + .iter() + .any(|detail| detail.starts_with("app-server version: unavailable (")) + ); } } diff --git a/codex-rs/cli/src/doctor/git.rs b/codex-rs/cli/src/doctor/git.rs new file mode 100644 index 0000000000..390a624f6f --- /dev/null +++ b/codex-rs/cli/src/doctor/git.rs @@ -0,0 +1,378 @@ +use std::collections::BTreeSet; +use std::path::Path; +use std::path::PathBuf; +use std::process::Output; +use std::time::Duration; + +use codex_git_utils::get_git_repo_root; +use tokio::process::Command; +use tokio::time::timeout; + +use super::CheckStatus; +use super::DoctorCheck; +use super::DoctorIssue; + +const GIT_COMMAND_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 2); + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +struct GitCheckInputs { + selected_git: Option, + git_candidates: Vec, + git_version: Option, + git_exec_path: Option, + git_build_options: Option, + repo_root: Option, + git_entry: Option, + branch: Option, + core_fsmonitor: Option, +} + +pub(super) async fn git_check(cwd: &Path) -> DoctorCheck { + let selected_git = which::which("git").ok(); + let git_candidates = git_candidates(); + let repo_root = get_git_repo_root(cwd); + + let (git_version, git_exec_path, git_build_options, branch, core_fsmonitor) = + if let Some(git_path) = selected_git.as_deref() { + let (version, exec_path, build_options, branch, fsmonitor) = tokio::join!( + git_output(git_path, cwd, &["--version"]), + git_output(git_path, cwd, &["--exec-path"]), + git_output(git_path, cwd, &["version", "--build-options"]), + git_output(git_path, cwd, &["rev-parse", "--abbrev-ref", "HEAD"]), + git_output(git_path, cwd, &["config", "--get", "core.fsmonitor"]), + ); + (version, exec_path, build_options, branch, fsmonitor) + } else { + (None, None, None, None, None) + }; + + git_check_from_inputs(GitCheckInputs { + selected_git, + git_candidates, + git_version, + git_exec_path, + git_build_options, + git_entry: repo_root.as_deref().map(git_entry_summary), + repo_root, + branch, + core_fsmonitor, + }) +} + +fn git_check_from_inputs(inputs: GitCheckInputs) -> DoctorCheck { + let mut details = Vec::new(); + match inputs.selected_git.as_deref() { + Some(path) => details.push(format!("selected git: {}", path.display())), + None => details.push("selected git: not found".to_string()), + } + details.push(format!("PATH git entries: {}", inputs.git_candidates.len())); + for (index, path) in inputs.git_candidates.iter().enumerate() { + details.push(format!("PATH git #{}: {}", index + 1, path.display())); + } + push_optional_detail(&mut details, "git version", inputs.git_version.as_deref()); + push_optional_detail( + &mut details, + "git exec path", + inputs.git_exec_path.as_deref(), + ); + push_optional_detail( + &mut details, + "git build options", + inputs.git_build_options.as_deref(), + ); + match inputs.repo_root.as_deref() { + Some(root) => { + details.push("repo detected: true".to_string()); + details.push(format!("repo root: {}", root.display())); + } + None => details.push("repo detected: false".to_string()), + } + push_optional_detail(&mut details, ".git entry", inputs.git_entry.as_deref()); + push_optional_detail( + &mut details, + "git branch", + normalized_branch(inputs.branch.as_deref()), + ); + push_optional_detail( + &mut details, + "core.fsmonitor", + inputs + .core_fsmonitor + .as_deref() + .filter(|value| !value.is_empty()), + ); + + let mut check = DoctorCheck::new( + "git.environment", + "git", + CheckStatus::Ok, + git_summary(&inputs), + ) + .details(details); + + if inputs.selected_git.is_some() && inputs.git_version.is_none() { + check.status = CheckStatus::Warning; + check.summary = "Git executable found but could not be run".to_string(); + check = check.issue( + DoctorIssue::new( + CheckStatus::Warning, + "Git executable was found on PATH but did not return a version", + ) + .expected("git --version succeeds") + .remedy("Fix the selected Git executable or PATH so Codex can inspect Git metadata.") + .field("git version") + .field("selected git"), + ); + } else if inputs.selected_git.is_none() && inputs.repo_root.is_some() { + check.status = CheckStatus::Warning; + check.summary = "Git repository detected but git executable was not found".to_string(); + check = check.issue( + DoctorIssue::new( + CheckStatus::Warning, + "Git repository detected but git executable was not found", + ) + .expected("git available on PATH") + .remedy("Install Git or fix PATH so Codex can inspect repository metadata.") + .field("selected git"), + ); + } else if let Some(cause) = + old_windows_git_warning(inputs.git_version.as_deref(), cfg!(windows)) + { + check.status = CheckStatus::Warning; + check.summary = cause.clone(); + check = check.issue( + DoctorIssue::new(CheckStatus::Warning, cause) + .measured(inputs.git_version.unwrap_or_else(|| "unknown".to_string())) + .expected("current Git for Windows") + .remedy( + "Update Git for Windows or the bundled Git executable Codex resolves first.", + ) + .field("git version") + .field("selected git"), + ); + } + + check +} + +fn git_summary(inputs: &GitCheckInputs) -> String { + match inputs.git_version.as_deref() { + Some(version) => version.to_string(), + None if inputs.selected_git.is_some() => { + "git executable found; version unavailable".to_string() + } + None => "git executable not found".to_string(), + } +} + +fn push_optional_detail(details: &mut Vec, label: &str, value: Option<&str>) { + if let Some(value) = value { + details.push(format!("{label}: {value}")); + } +} + +fn normalized_branch(branch: Option<&str>) -> Option<&str> { + match branch { + Some("HEAD") => Some("detached HEAD"), + Some(value) if !value.is_empty() => Some(value), + _ => None, + } +} + +fn git_candidates() -> Vec { + let Ok(candidates) = which::which_all("git") else { + return Vec::new(); + }; + let mut seen = BTreeSet::new(); + candidates + .filter(|candidate| seen.insert(candidate.clone())) + .collect() +} + +async fn git_output(git_path: &Path, cwd: &Path, args: &[&str]) -> Option { + let mut command = Command::new(git_path); + command + .env("GIT_OPTIONAL_LOCKS", "0") + .args(args) + .current_dir(cwd) + .kill_on_drop(true); + let output = timeout(GIT_COMMAND_TIMEOUT, command.output()) + .await + .ok()? + .ok()?; + command_output_text(output) +} + +fn command_output_text(output: Output) -> Option { + if !output.status.success() { + return None; + } + let stdout = String::from_utf8_lossy(&output.stdout); + let normalized = stdout + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .collect::>() + .join("; "); + if normalized.is_empty() { + None + } else { + Some(normalized) + } +} + +fn git_entry_summary(repo_root: &Path) -> String { + let entry = repo_root.join(".git"); + match std::fs::metadata(&entry) { + Ok(metadata) if metadata.is_dir() => "directory".to_string(), + Ok(metadata) if metadata.is_file() => std::fs::read_to_string(&entry) + .ok() + .and_then(|contents| { + contents + .strip_prefix("gitdir:") + .map(str::trim) + .map(|path| format!("file -> {path}")) + }) + .unwrap_or_else(|| "file".to_string()), + Ok(_) => "other".to_string(), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => "missing".to_string(), + Err(err) => format!("unreadable ({err})"), + } +} + +fn old_windows_git_warning(version: Option<&str>, is_windows: bool) -> Option { + if !is_windows { + return None; + } + let version = version?; + if version.to_ascii_lowercase().contains("msysgit") { + return Some("old msysgit installation may corrupt Windows TUI rendering".to_string()); + } + let parsed = parse_git_version(version)?; + if parsed.major < 2 || (parsed.major == 2 && parsed.minor <= 34) { + return Some("old Git for Windows may corrupt Windows TUI rendering".to_string()); + } + None +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct ParsedGitVersion { + major: u32, + minor: u32, + patch: u32, +} + +fn parse_git_version(version: &str) -> Option { + let version = version.strip_prefix("git version ")?; + let numeric = version + .split_whitespace() + .next()? + .split(".windows.") + .next() + .unwrap_or(version); + let mut parts = numeric.split('.'); + Some(ParsedGitVersion { + major: parts.next()?.parse().ok()?, + minor: parts.next()?.parse().ok()?, + patch: parts.next().unwrap_or("0").parse().ok()?, + }) +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn parses_git_for_windows_version() { + assert_eq!( + parse_git_version("git version 2.34.1.windows.1"), + Some(ParsedGitVersion { + major: 2, + minor: 34, + patch: 1, + }) + ); + assert_eq!( + parse_git_version("git version 2.54.0.windows.1"), + Some(ParsedGitVersion { + major: 2, + minor: 54, + patch: 0, + }) + ); + } + + #[test] + fn classifies_old_windows_git() { + assert_eq!( + old_windows_git_warning( + Some("git version 2.34.1.windows.1"), + /*is_windows*/ true + ) + .as_deref(), + Some("old Git for Windows may corrupt Windows TUI rendering") + ); + assert_eq!( + old_windows_git_warning( + Some("git version 2.54.0.windows.1"), + /*is_windows*/ true + ), + None + ); + assert_eq!( + old_windows_git_warning( + Some("git version 2.34.1.windows.1"), + /*is_windows*/ false + ), + None + ); + } + + #[test] + fn warns_when_git_repo_has_no_git_executable() { + let check = git_check_from_inputs(GitCheckInputs { + repo_root: Some(PathBuf::from("/repo")), + ..GitCheckInputs::default() + }); + + assert_eq!(check.status, CheckStatus::Warning); + assert_eq!( + check.summary, + "Git repository detected but git executable was not found" + ); + } + + #[test] + fn warns_when_selected_git_cannot_report_version() { + let check = git_check_from_inputs(GitCheckInputs { + selected_git: Some(PathBuf::from("/usr/bin/git")), + repo_root: Some(PathBuf::from("/repo")), + ..GitCheckInputs::default() + }); + + assert_eq!(check.status, CheckStatus::Warning); + assert_eq!(check.summary, "Git executable found but could not be run"); + } + + #[test] + fn reports_git_candidates_and_repo_metadata() { + let check = git_check_from_inputs(GitCheckInputs { + selected_git: Some(PathBuf::from("/usr/bin/git")), + git_candidates: vec![PathBuf::from("/usr/bin/git"), PathBuf::from("/opt/bin/git")], + git_version: Some("git version 2.54.0".to_string()), + git_exec_path: Some("/usr/libexec/git-core".to_string()), + repo_root: Some(PathBuf::from("/repo")), + git_entry: Some("directory".to_string()), + branch: Some("main".to_string()), + core_fsmonitor: Some("false".to_string()), + ..GitCheckInputs::default() + }); + + assert_eq!(check.status, CheckStatus::Ok); + assert!(check.details.contains(&"PATH git entries: 2".to_string())); + assert!(check.details.contains(&"git branch: main".to_string())); + assert!(check.details.contains(&"core.fsmonitor: false".to_string())); + } +} diff --git a/codex-rs/cli/src/doctor/output.rs b/codex-rs/cli/src/doctor/output.rs index da5fd20ce8..3f6e2f05d2 100644 --- a/codex-rs/cli/src/doctor/output.rs +++ b/codex-rs/cli/src/doctor/output.rs @@ -25,7 +25,10 @@ const SEPARATOR_WIDTH: usize = 61; const GROUPS: &[OutputGroup] = &[ OutputGroup { title: "Environment", - keys: &["runtime", "install", "search", "terminal", "state"], + keys: &[ + "system", "runtime", "install", "search", "git", "terminal", "title", "state", + "threads", + ], }, OutputGroup { title: "Configuration", @@ -611,12 +614,15 @@ fn auth_reachability_note(report: &DoctorReport) -> Option { None } -fn display_summary(check: &DoctorCheck, _options: HumanOutputOptions) -> String { +fn display_summary(check: &DoctorCheck, options: HumanOutputOptions) -> String { match check.category.as_str() { + "system" => system_summary(check), "runtime" => runtime_summary(check), "install" if check.status == CheckStatus::Ok => "consistent".to_string(), "search" => search_summary(check), + "git" => git_summary(check), "terminal" => terminal_summary(check), + "title" => title_summary(check, options), "state" => state_summary(check), "config" if check.status == CheckStatus::Ok => "loaded".to_string(), "mcp" => mcp_summary(check), @@ -628,6 +634,10 @@ fn display_summary(check: &DoctorCheck, _options: HumanOutputOptions) -> String } } +fn system_summary(check: &DoctorCheck) -> String { + detail::detail_value(check, "os language").unwrap_or_else(|| check.summary.clone()) +} + fn runtime_summary(check: &DoctorCheck) -> String { if detail::detail_value(check, "current executable") .is_some_and(|path| path.contains("/target/debug/")) @@ -649,6 +659,12 @@ fn search_summary(check: &DoctorCheck) -> String { } } +fn git_summary(check: &DoctorCheck) -> String { + detail::detail_value(check, "git version") + .or_else(|| detail::detail_value(check, "selected git")) + .unwrap_or_else(|| check.summary.clone()) +} + fn terminal_summary(check: &DoctorCheck) -> String { let mut parts = Vec::new(); if let Some(terminal) = detail::detail_value(check, "terminal") { @@ -668,11 +684,29 @@ fn terminal_summary(check: &DoctorCheck) -> String { } } +fn title_summary(check: &DoctorCheck, options: HumanOutputOptions) -> String { + let source = detail::detail_value(check, "terminal title source"); + let project = detail::detail_value(check, "terminal title project value"); + match (source, project) { + (Some(source), Some(project)) => { + let separator = if options.ascii { " | " } else { " · " }; + format!("{source}{separator}project {project}") + } + (Some(source), None) => source, + _ => check.summary.clone(), + } +} + fn state_summary(check: &DoctorCheck) -> String { - let state_ok = - detail::detail_value(check, "state DB integrity").is_some_and(|value| value == "ok"); - let log_ok = detail::detail_value(check, "log DB integrity").is_some_and(|value| value == "ok"); - if state_ok && log_ok { + let databases_ok = [ + "state DB integrity", + "log DB integrity", + "goals DB integrity", + "memories DB integrity", + ] + .into_iter() + .all(|label| detail::detail_value(check, label).is_some_and(|value| value == "ok")); + if check.status == CheckStatus::Ok && databases_ok { "databases healthy".to_string() } else { check.summary.clone() @@ -1092,6 +1126,14 @@ mod tests { fn sample_report() -> DoctorReport { let checks = vec![ + DoctorCheck::new( + "system.environment", + "system", + CheckStatus::Ok, + "OS language en-US", + ) + .detail("os: macOS 15.0") + .detail("os language: en-US"), DoctorCheck::new( "runtime.provenance", "runtime", @@ -1110,12 +1152,30 @@ mod tests { CheckStatus::Ok, "search is OK (bundled)", ), + DoctorCheck::new( + "git.environment", + "git", + CheckStatus::Ok, + "git version 2.54.0", + ) + .detail("selected git: /usr/bin/git") + .detail("git version: git version 2.54.0") + .detail("repo detected: true"), DoctorCheck::new( "terminal.env", "terminal", CheckStatus::Warning, "narrow terminal", ), + DoctorCheck::new( + "terminal.title", + "title", + CheckStatus::Ok, + "terminal title default", + ) + .detail("terminal title source: default") + .detail("terminal title items: activity, project-name") + .detail("terminal title project value: codex"), DoctorCheck::new( "state.paths", "state", @@ -1183,11 +1243,22 @@ Notes ───────────────────────────────────────────────────────────── Environment + ✓ system en-US + os macOS 15.0 + OS language en-US ✓ runtime running local build on darwin-arm64 ✓ install consistent managed by npm: no · bun: no · package root — ✓ search search is OK (bundled) + ✓ git git version 2.54.0 + selected git /usr/bin/git + version git version 2.54.0 + repo detected true ⚠ terminal narrow terminal + ✓ title default · project codex + title source default + title items activity, project-name + project value codex ✓ state state paths inspectable Configuration @@ -1206,7 +1277,7 @@ Background Server ✓ app-server background server is not running {} -9 ok · 2 notes · 1 warn · 1 fail failed +12 ok · 2 notes · 1 warn · 1 fail failed --summary compact output --all expand truncated lists --json redacted report @@ -1216,6 +1287,14 @@ Background Server assert_eq!(rendered, expected); } + #[test] + fn render_human_report_snapshot_covers_environment_rows() { + insta::assert_snapshot!( + "doctor_human_report_environment_rows", + render_human_report(&sample_report(), detailed_no_color_unicode_options()) + ); + } + #[test] fn render_human_report_supports_summary_output_without_color() { let rendered = render_human_report(&sample_report(), summary_no_color_unicode_options()); @@ -1229,10 +1308,13 @@ Notes ───────────────────────────────────────────────────────────── Environment + ✓ system en-US ✓ runtime running local build on darwin-arm64 ✓ install consistent ✓ search search is OK (bundled) + ✓ git git version 2.54.0 ⚠ terminal narrow terminal + ✓ title default · project codex ✓ state state paths inspectable Configuration @@ -1250,7 +1332,7 @@ Background Server ✓ app-server background server is not running {} -9 ok · 2 notes · 1 warn · 1 fail failed +12 ok · 2 notes · 1 warn · 1 fail failed Run codex doctor without --summary for detailed diagnostics. --all expand truncated lists --json redacted report @@ -1260,6 +1342,59 @@ Run codex doctor without --summary for detailed diagnostics. assert_eq!(rendered, expected); } + #[test] + fn render_human_report_includes_threads_row_in_environment() { + let mut report = sample_report(); + report.checks.push(DoctorCheck::new( + "state.rollout_db_parity", + "threads", + CheckStatus::Warning, + "rollout files and state DB thread inventory differ", + )); + + let rendered = render_human_report(&report, summary_no_color_unicode_options()); + + let threads_line = rendered + .lines() + .find(|line| line.contains("threads")) + .expect("threads row should be rendered"); + assert!( + threads_line.contains("rollout files and state DB thread inventory differ"), + "{threads_line}" + ); + } + + #[test] + fn render_human_report_includes_memories_db_in_state_health_summary() { + let report = DoctorReport { + schema_version: 1, + generated_at: "0s since unix epoch".to_string(), + overall_status: CheckStatus::Ok, + codex_version: "0.0.0".to_string(), + checks: vec![ + DoctorCheck::new( + "state.paths", + "state", + CheckStatus::Ok, + "state paths inspectable", + ) + .detail("state DB: /tmp/state.sqlite") + .detail("state DB integrity: ok") + .detail("log DB: /tmp/logs.sqlite") + .detail("log DB integrity: ok") + .detail("goals DB: /tmp/goals.sqlite") + .detail("goals DB integrity: ok") + .detail("memories DB: /tmp/memories.sqlite") + .detail("memories DB integrity: ok"), + ], + }; + + let rendered = render_human_report(&report, detailed_no_color_unicode_options()); + + assert!(rendered.contains("✓ state databases healthy")); + assert!(rendered.contains("memories DB /tmp/memories.sqlite · integrity ok")); + } + #[test] fn render_human_report_supports_ascii_output() { let rendered = render_human_report( @@ -1281,10 +1416,13 @@ Notes ------------------------------------------------------------- Environment + [ok] system en-US [ok] runtime running local build on darwin-arm64 [ok] install consistent [ok] search search is OK (bundled) + [ok] git git version 2.54.0 [!!] terminal narrow terminal + [ok] title default | project codex [ok] state state paths inspectable Configuration @@ -1302,7 +1440,7 @@ Background Server [ok] app-server background server is not running {} -9 ok | 2 notes | 1 warn | 1 fail failed +12 ok | 2 notes | 1 warn | 1 fail failed Run codex doctor without --summary for detailed diagnostics. --all expand truncated lists --json redacted report diff --git a/codex-rs/cli/src/doctor/output/detail.rs b/codex-rs/cli/src/doctor/output/detail.rs index 4b5f49991b..6c6cf8633c 100644 --- a/codex-rs/cli/src/doctor/output/detail.rs +++ b/codex-rs/cli/src/doctor/output/detail.rs @@ -37,8 +37,11 @@ struct ParsedDetail { pub(super) fn detail_lines(check: &DoctorCheck, options: HumanOutputOptions) -> Vec { let parsed = parsed_details(check); let details = match check.category.as_str() { + "system" => system_details(&parsed), "runtime" => runtime_details(&parsed), "install" => install_details(&parsed, options), + "git" => git_details(&parsed, options), + "title" => title_details(&parsed), "config" => config_details(&parsed, options), "state" => state_details(&parsed), _ => generic_details(&parsed), @@ -52,6 +55,30 @@ pub(super) fn detail_lines(check: &DoctorCheck, options: HumanOutputOptions) -> details } +fn system_details(parsed: &[ParsedDetail]) -> Vec { + let mut out = Vec::new(); + push_row_if_present(&mut out, parsed, "os", "os"); + push_row_if_present(&mut out, parsed, "os language", "OS language"); + push_row_if_present(&mut out, parsed, "LC_ALL", "LC_ALL"); + push_row_if_present(&mut out, parsed, "LC_CTYPE", "LC_CTYPE"); + push_row_if_present(&mut out, parsed, "LANG", "LANG"); + push_remaining( + &mut out, + parsed, + &[ + "os", + "os type", + "os version", + "os language", + "LC_ALL", + "LC_CTYPE", + "LANG", + ], + &[], + ); + out +} + pub(super) fn detail_value(check: &DoctorCheck, label: &str) -> Option { parsed_details(check) .into_iter() @@ -232,6 +259,97 @@ fn install_details(parsed: &[ParsedDetail], options: HumanOutputOptions) -> Vec< out } +fn git_details(parsed: &[ParsedDetail], options: HumanOutputOptions) -> Vec { + let mut out = Vec::new(); + push_row_if_present(&mut out, parsed, "selected git", "selected git"); + push_row_if_present(&mut out, parsed, "git version", "version"); + push_row_if_present(&mut out, parsed, "git exec path", "exec path"); + push_row_if_present(&mut out, parsed, "repo detected", "repo detected"); + push_row_if_present(&mut out, parsed, "repo root", "repo root"); + push_row_if_present(&mut out, parsed, ".git entry", ".git entry"); + push_row_if_present(&mut out, parsed, "git branch", "branch"); + push_row_if_present(&mut out, parsed, "core.fsmonitor", "core.fsmonitor"); + + let path_entries = numbered_values(parsed, "PATH git #"); + if !path_entries.is_empty() { + let total = path_entries.len(); + let shown = if options.show_all { + total + } else { + total.min(3) + }; + out.push(HumanDetail::Row { + label: format!("PATH entries ({total})"), + value: path_entries[0].clone(), + expected: None, + }); + out.extend( + path_entries + .iter() + .skip(1) + .take(shown.saturating_sub(1)) + .cloned() + .map(HumanDetail::Continuation), + ); + if shown < total { + out.push(HumanDetail::Continuation( + "… (full list with --all)".to_string(), + )); + } + } + + push_remaining( + &mut out, + parsed, + &[ + "selected git", + "PATH git entries", + "git version", + "git exec path", + "git build options", + "repo detected", + "repo root", + ".git entry", + "git branch", + "core.fsmonitor", + ], + &["PATH git #"], + ); + out +} + +fn title_details(parsed: &[ParsedDetail]) -> Vec { + let mut out = Vec::new(); + push_row_if_present(&mut out, parsed, "terminal title source", "title source"); + push_row_if_present(&mut out, parsed, "terminal title items", "title items"); + push_row_if_present(&mut out, parsed, "terminal title activity", "activity item"); + push_row_if_present( + &mut out, + parsed, + "terminal title project source", + "project source", + ); + push_row_if_present( + &mut out, + parsed, + "terminal title project value", + "project value", + ); + push_remaining( + &mut out, + parsed, + &[ + "terminal title source", + "terminal title items", + "terminal title activity", + "terminal title project source", + "terminal title project value", + ], + &[], + ); + out +} + fn config_details(parsed: &[ParsedDetail], options: HumanOutputOptions) -> Vec { let mut out = Vec::new(); if let Some(model) = value(parsed, "model") { @@ -294,6 +412,8 @@ fn state_details(parsed: &[ParsedDetail]) -> Vec { push_row_if_present(&mut out, parsed, "sqlite home", "sqlite home"); push_database_row(&mut out, parsed, "state DB"); push_database_row(&mut out, parsed, "log DB"); + push_database_row(&mut out, parsed, "goals DB"); + push_database_row(&mut out, parsed, "memories DB"); for (source, label) in [ ("active rollout files", "active rollouts"), @@ -317,8 +437,12 @@ fn state_details(parsed: &[ParsedDetail]) -> Vec { "sqlite home", "state DB", "log DB", + "goals DB", "state DB integrity", "log DB integrity", + "goals DB integrity", + "memories DB", + "memories DB integrity", "active rollout files", "archived rollout files", ], diff --git a/codex-rs/cli/src/doctor/runtime.rs b/codex-rs/cli/src/doctor/runtime.rs index 96e806762c..14afa70e4c 100644 --- a/codex-rs/cli/src/doctor/runtime.rs +++ b/codex-rs/cli/src/doctor/runtime.rs @@ -3,12 +3,13 @@ //! Runtime diagnostics answer provenance questions that are hard to infer from //! user reports: which binary is running, which install channel it resembles, //! which platform it targets, and whether the search command comes from bundled -//! standalone resources or from PATH. +//! package files or from PATH. use std::env; use std::process::Command; use codex_install_context::InstallContext; +use codex_install_context::InstallMethod; use super::CheckStatus; use super::DoctorCheck; @@ -49,9 +50,10 @@ pub(super) fn runtime_check() -> DoctorCheck { /// Verifies that the search command selected by the install context is usable. /// -/// Standalone installs should point at a bundled ripgrep binary, while local or -/// package-managed installs usually resolve rg from PATH. A warning here means -/// features that depend on file search may degrade even when the CLI launches. +/// Package-layout installs should point at a bundled ripgrep binary, while local +/// installs without that layout usually resolve rg from PATH. A warning here +/// means features that depend on file search may degrade even when the CLI +/// launches. pub(super) fn search_check() -> DoctorCheck { let current_exe = env::current_exe().ok(); let install_context = doctor_install_context(current_exe.as_deref()); @@ -109,28 +111,40 @@ pub(super) fn search_check() -> DoctorCheck { }; let mut check = DoctorCheck::new("runtime.search", "search", status, summary).details(details); if status != CheckStatus::Ok { - check = check.remediation("Install ripgrep or repair the bundled standalone resources."); + check = check.remediation("Install ripgrep or repair the bundled Codex package."); } check } fn install_method_name(context: &InstallContext) -> &'static str { - match context { - InstallContext::Standalone { .. } => "standalone", - InstallContext::Npm => "npm", - InstallContext::Bun => "bun", - InstallContext::Brew => "brew", - InstallContext::Other => "local build", + match &context.method { + InstallMethod::Standalone { .. } => "standalone", + InstallMethod::Npm => "npm", + InstallMethod::Bun => "bun", + InstallMethod::Brew => "brew", + InstallMethod::Other => "local build", } } fn search_provider(context: &InstallContext) -> &'static str { - match context { - InstallContext::Standalone { + let rg_command = context.rg_command(); + let from_package_layout = context + .package_layout + .as_ref() + .and_then(|package_layout| package_layout.path_dir.as_ref()) + .is_some_and(|path_dir| rg_command.starts_with(path_dir)); + let from_legacy_standalone = matches!( + &context.method, + InstallMethod::Standalone { resources_dir: Some(resources_dir), .. - } if context.rg_command().starts_with(resources_dir) => "bundled", - _ => "system", + } if rg_command.starts_with(resources_dir) + ); + + if from_package_layout || from_legacy_standalone { + "bundled" + } else { + "system" } } diff --git a/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap b/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap new file mode 100644 index 0000000000..84d3441fb2 --- /dev/null +++ b/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap @@ -0,0 +1,50 @@ +--- +source: cli/src/doctor/output.rs +expression: "render_human_report(&sample_report(), detailed_no_color_unicode_options())" +--- +Codex Doctor v0.0.0 + +Notes + ⚠ terminal narrow terminal + ✗ auth token expired - Run `codex login`. +───────────────────────────────────────────────────────────── + +Environment + ✓ system en-US + os macOS 15.0 + OS language en-US + ✓ runtime running local build on darwin-arm64 + ✓ install consistent + managed by npm: no · bun: no · package root — + ✓ search search is OK (bundled) + ✓ git git version 2.54.0 + selected git /usr/bin/git + version git version 2.54.0 + repo detected true + ⚠ terminal narrow terminal + ✓ title default · project codex + title source default + title items activity, project-name + project value codex + ✓ state state paths inspectable + +Configuration + ✗ auth token expired — Run `codex login`. + OPENAI_API_KEY present + +Updates + ✓ updates update configuration is locally consistent + +Connectivity + ✓ network network environment readable + ✓ websocket Responses WebSocket handshake succeeded + ✓ reachability active provider endpoints are reachable over HTTP + +Background Server + ✓ app-server background server is not running + +───────────────────────────────────────────────────────────── +12 ok · 2 notes · 1 warn · 1 fail failed + +--summary compact output --all expand truncated lists +--json redacted report diff --git a/codex-rs/cli/src/doctor/system.rs b/codex-rs/cli/src/doctor/system.rs new file mode 100644 index 0000000000..c72b76561d --- /dev/null +++ b/codex-rs/cli/src/doctor/system.rs @@ -0,0 +1,112 @@ +use std::collections::BTreeMap; +use std::env; + +use super::DoctorCheck; +use super::LOCALE_ENV_VARS; + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +struct SystemCheckInputs { + os: String, + os_type: String, + os_version: String, + os_language: Option, + locale_env: BTreeMap, +} + +impl SystemCheckInputs { + fn detect() -> Self { + let info = os_info::get(); + let locale_env = LOCALE_ENV_VARS + .iter() + .filter_map(|name| { + env::var(name) + .ok() + .map(|value| ((*name).to_string(), value)) + }) + .collect(); + Self { + os: info.to_string(), + os_type: info.os_type().to_string(), + os_version: info.version().to_string(), + os_language: sys_locale::get_locale(), + locale_env, + } + } +} + +pub(super) fn system_check() -> DoctorCheck { + system_check_from_inputs(SystemCheckInputs::detect()) +} + +fn system_check_from_inputs(inputs: SystemCheckInputs) -> DoctorCheck { + let mut details = vec![ + format!("os: {}", inputs.os), + format!("os type: {}", inputs.os_type), + format!("os version: {}", inputs.os_version), + ]; + if let Some(language) = inputs.os_language.as_deref() { + details.push(format!("os language: {language}")); + } else { + details.push("os language: unavailable".to_string()); + } + for name in LOCALE_ENV_VARS { + if let Some(value) = inputs.locale_env.get(*name) { + details.push(format!("{name}: {value}")); + } + } + + let summary = inputs + .os_language + .as_deref() + .map(|language| format!("OS language {language}")) + .unwrap_or_else(|| "OS language unavailable".to_string()); + DoctorCheck::new( + "system.environment", + "system", + super::CheckStatus::Ok, + summary, + ) + .details(details) +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn system_check_reports_os_language_and_locale_env() { + let mut locale_env = BTreeMap::new(); + locale_env.insert("LANG".to_string(), "en_US.UTF-8".to_string()); + let check = system_check_from_inputs(SystemCheckInputs { + os: "macOS 15.0".to_string(), + os_type: "macos".to_string(), + os_version: "15.0".to_string(), + os_language: Some("en-US".to_string()), + locale_env, + }); + + assert_eq!(check.summary, "OS language en-US"); + assert!(check.details.contains(&"os language: en-US".to_string())); + assert!(check.details.contains(&"LANG: en_US.UTF-8".to_string())); + } + + #[test] + fn system_check_handles_missing_os_language() { + let check = system_check_from_inputs(SystemCheckInputs { + os: "Linux".to_string(), + os_type: "linux".to_string(), + os_version: "unknown".to_string(), + os_language: None, + locale_env: BTreeMap::new(), + }); + + assert_eq!(check.summary, "OS language unavailable"); + assert!( + check + .details + .contains(&"os language: unavailable".to_string()) + ); + } +} diff --git a/codex-rs/cli/src/doctor/thread_inventory.rs b/codex-rs/cli/src/doctor/thread_inventory.rs new file mode 100644 index 0000000000..9a8eef6ee4 --- /dev/null +++ b/codex-rs/cli/src/doctor/thread_inventory.rs @@ -0,0 +1,921 @@ +//! Doctor check that compares rollout files against the SQLite thread inventory. + +use super::CheckStatus; +use super::Config; +use super::DoctorCheck; +use super::DoctorIssue; +use codex_protocol::protocol::InternalSessionSource; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use codex_rollout::RolloutRecorder; +use codex_state::ThreadStateAuditRow; +use codex_utils_path::normalize_for_path_comparison; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::collections::HashSet; +use std::ffi::OsStr; +use std::path::Path; +use std::path::PathBuf; + +const MAX_PARITY_SCAN_FILES: usize = 10_000; +const SAMPLE_LIMIT: usize = 5; +const SUMMARY_LIMIT: usize = 8; +const CHECK_ID: &str = "state.rollout_db_parity"; +const CHECK_CATEGORY: &str = "threads"; + +#[derive(Clone, Debug)] +struct RolloutAuditFile { + path: PathBuf, + key: PathBuf, + archived: bool, + thread_id: String, +} + +#[derive(Default)] +struct RolloutScan { + files: Vec, + scan_errors: Vec, + malformed_names: Vec, + reached_scan_cap: bool, +} + +enum RolloutThreadId { + Id(String), + MalformedName, + Unusable(String), +} + +impl RolloutScan { + fn candidate_count(&self) -> usize { + self.files.len() + self.malformed_names.len() + self.scan_errors.len() + } + + fn reached_candidate_cap(&self) -> bool { + self.candidate_count() >= MAX_PARITY_SCAN_FILES + } + + fn record_malformed_name(&mut self, path: PathBuf) { + if self.reached_candidate_cap() { + self.reached_scan_cap = true; + return; + } + self.malformed_names.push(path); + self.reached_scan_cap = self.reached_candidate_cap(); + } + + fn record_scan_error(&mut self, message: String) { + if self.reached_candidate_cap() { + self.reached_scan_cap = true; + return; + } + self.scan_errors.push(message); + self.reached_scan_cap = self.reached_candidate_cap(); + } + + fn active_count(&self) -> usize { + self.files.iter().filter(|file| !file.archived).count() + } + + fn archived_count(&self) -> usize { + self.files.iter().filter(|file| file.archived).count() + } +} + +pub(super) async fn thread_inventory_check(config: &Config) -> DoctorCheck { + thread_inventory_check_for_roots( + config.codex_home.as_path(), + config.sqlite_home.as_path(), + config.model_provider_id.as_str(), + ) + .await +} + +async fn thread_inventory_check_for_roots( + codex_home: &Path, + sqlite_home: &Path, + default_provider: &str, +) -> DoctorCheck { + let scan = scan_rollout_files(codex_home).await; + let state_db_path = codex_state::state_db_path(sqlite_home); + + let mut details = vec![ + format!("default model provider: {default_provider}"), + format!("rollout DB active files: {}", scan.active_count()), + format!("rollout DB archived files: {}", scan.archived_count()), + format!("rollout DB scan errors: {}", scan.scan_errors.len()), + format!( + "rollout DB malformed file names: {}", + scan.malformed_names.len() + ), + format!("rollout DB scan cap reached: {}", scan.reached_scan_cap), + ]; + push_samples( + &mut details, + "rollout DB scan error sample", + scan.scan_errors.iter().map(String::as_str), + ); + push_samples( + &mut details, + "rollout DB malformed file sample", + scan.malformed_names + .iter() + .map(|path| path.display().to_string()), + ); + + if !state_db_path.is_file() { + details.push("rollout DB rows: skipped (state DB missing)".to_string()); + return missing_state_db_check(scan, details); + } + + let rows = match codex_state::read_thread_state_audit_rows(&state_db_path).await { + Ok(rows) => rows, + Err(err) => { + details.push(format!("rollout DB read error: {err}")); + return DoctorCheck::new( + CHECK_ID, + CHECK_CATEGORY, + CheckStatus::Warning, + "state database thread inventory could not be read", + ) + .details(details) + .issue( + DoctorIssue::new( + CheckStatus::Warning, + "state DB thread rows could not be queried", + ) + .measured(err.to_string()) + .expected("readable threads table"), + ); + } + }; + + parity_check_from_scan_and_rows(codex_home, scan, rows, details) +} + +fn missing_state_db_check(scan: RolloutScan, details: Vec) -> DoctorCheck { + if scan.files.is_empty() + && scan.scan_errors.is_empty() + && scan.malformed_names.is_empty() + && !scan.reached_scan_cap + { + return DoctorCheck::new( + CHECK_ID, + CHECK_CATEGORY, + CheckStatus::Ok, + "no rollout/state DB inventory to compare", + ) + .details(details); + } + + let summary = if scan.files.is_empty() { + "rollout scan was incomplete or found bad files" + } else { + "state DB is missing while rollout files exist" + }; + let mut check = + DoctorCheck::new(CHECK_ID, CHECK_CATEGORY, CheckStatus::Warning, summary).details(details); + + if !scan.files.is_empty() { + check = check + .issue( + DoctorIssue::new( + CheckStatus::Warning, + "rollout files exist but the state DB is missing", + ) + .measured(format!("{} rollout files", scan.files.len())) + .expected("state DB contains matching thread rows") + .remedy("Start Codex with no state DB present so startup backfill can create it from rollout files."), + ) + .remediation( + "Start Codex with no state DB present so startup backfill can create it from rollout files.", + ); + } + if !scan.scan_errors.is_empty() || !scan.malformed_names.is_empty() || scan.reached_scan_cap { + check = check.issue( + DoctorIssue::new( + CheckStatus::Warning, + "rollout scan was incomplete or found bad files", + ) + .measured(format!( + "{} scan errors, {} malformed names, scan cap reached: {}", + scan.scan_errors.len(), + scan.malformed_names.len(), + scan.reached_scan_cap + )) + .expected("rollout directories are fully scannable") + .remedy("Check file permissions and unexpected files under CODEX_HOME sessions."), + ); + } + check +} + +fn parity_check_from_scan_and_rows( + codex_home: &Path, + scan: RolloutScan, + rows: Vec, + mut details: Vec, +) -> DoctorCheck { + let rollout_by_key = scan + .files + .iter() + .map(|file| (file.key.clone(), file)) + .collect::>(); + let mut rows_by_key: HashMap> = HashMap::new(); + for row in &rows { + rows_by_key + .entry(path_key(&row.rollout_path)) + .or_default() + .push(row); + } + + let missing_active = missing_rollout_paths(&scan.files, &rows_by_key, /*archived*/ false); + let missing_archived = missing_rollout_paths(&scan.files, &rows_by_key, /*archived*/ true); + let scan_complete = !scan.reached_scan_cap; + let stale_rows = if scan_complete { + rows.iter() + .filter(|row| !row.rollout_path.is_file()) + .collect::>() + } else { + Vec::new() + }; + let archive_mismatches = if scan_complete { + rows.iter() + .filter_map(|row| { + let expected_archived = rollout_by_key + .get(&path_key(&row.rollout_path)) + .map(|file| file.archived) + .or_else(|| { + row.rollout_path + .is_file() + .then(|| archived_from_rollout_path(codex_home, &row.rollout_path)) + .flatten() + })?; + (expected_archived != row.archived).then_some(row) + }) + .collect::>() + } else { + Vec::new() + }; + let duplicate_rollout_thread_ids = duplicate_rollout_thread_ids(&scan.files); + let duplicate_db_paths = duplicate_db_paths(&rows_by_key); + let archived_rows = rows.iter().filter(|row| row.archived).count(); + let active_rows = rows.len() - archived_rows; + + details.extend([ + format!("rollout DB rows: {}", rows.len()), + format!("rollout DB active rows: {active_rows}"), + format!("rollout DB archived rows: {archived_rows}"), + format!("rollout DB missing active rows: {}", missing_active.len()), + format!( + "rollout DB missing archived rows: {}", + missing_archived.len() + ), + format!( + "rollout DB stale rows: {}", + count_or_skipped(stale_rows.len(), scan_complete) + ), + format!( + "rollout DB archive mismatches: {}", + count_or_skipped(archive_mismatches.len(), scan_complete) + ), + format!( + "rollout DB duplicate rollout thread ids: {}", + duplicate_rollout_thread_ids.len() + ), + format!( + "rollout DB duplicate DB paths: {}", + duplicate_db_paths.len() + ), + format!( + "rollout DB model providers: {}", + count_summary(rows.iter().map(|row| row.model_provider.as_str())) + ), + format!( + "rollout DB sources: {}", + count_summary(rows.iter().map(|row| source_category(&row.source))) + ), + ]); + push_path_samples( + &mut details, + "rollout DB missing active sample", + missing_active.iter().copied(), + ); + push_path_samples( + &mut details, + "rollout DB missing archived sample", + missing_archived.iter().copied(), + ); + push_path_samples( + &mut details, + "rollout DB stale row sample", + stale_rows.iter().map(|row| row.rollout_path.as_path()), + ); + push_path_samples( + &mut details, + "rollout DB archive mismatch sample", + archive_mismatches + .iter() + .map(|row| row.rollout_path.as_path()), + ); + push_samples( + &mut details, + "rollout DB duplicate rollout thread id sample", + duplicate_rollout_thread_ids.iter().map(String::as_str), + ); + push_path_samples( + &mut details, + "rollout DB duplicate DB path sample", + duplicate_db_paths.iter().map(PathBuf::as_path), + ); + + let status = if scan.scan_errors.is_empty() + && scan.malformed_names.is_empty() + && !scan.reached_scan_cap + && missing_active.is_empty() + && missing_archived.is_empty() + && stale_rows.is_empty() + && archive_mismatches.is_empty() + && duplicate_rollout_thread_ids.is_empty() + && duplicate_db_paths.is_empty() + { + CheckStatus::Ok + } else { + CheckStatus::Warning + }; + + let summary = if status == CheckStatus::Ok { + "rollout files and state DB thread inventory agree" + } else { + "rollout files and state DB thread inventory differ" + }; + let mut check = DoctorCheck::new(CHECK_ID, CHECK_CATEGORY, status, summary).details(details); + + if !missing_active.is_empty() || !missing_archived.is_empty() { + check = check.issue( + DoctorIssue::new( + CheckStatus::Warning, + "rollout files are missing from the state DB", + ) + .measured(format!( + "{} active, {} archived", + missing_active.len(), + missing_archived.len() + )) + .expected("every rollout file has a matching threads row"), + ); + } + if !stale_rows.is_empty() { + check = check.issue( + DoctorIssue::new( + CheckStatus::Warning, + "state DB rows point at missing or unusable rollout files", + ) + .measured(format!("{} stale rows", stale_rows.len())) + .expected("every state DB rollout path is a file on disk"), + ); + } + if !archive_mismatches.is_empty() { + check = check.issue( + DoctorIssue::new( + CheckStatus::Warning, + "state DB archive flags disagree with rollout file locations", + ) + .measured(format!("{} mismatched rows", archive_mismatches.len())) + .expected( + "rows under archived_sessions are archived and rows under sessions are active", + ), + ); + } + if !duplicate_rollout_thread_ids.is_empty() || !duplicate_db_paths.is_empty() { + check = check.issue( + DoctorIssue::new( + CheckStatus::Warning, + "duplicate thread inventory entries found", + ) + .measured(format!( + "{} duplicate rollout thread ids, {} duplicate DB paths", + duplicate_rollout_thread_ids.len(), + duplicate_db_paths.len() + )) + .expected("one rollout path and thread id per thread") + .remedy("Attach the doctor report to a bug report so support can inspect samples."), + ); + } + if !scan.scan_errors.is_empty() || !scan.malformed_names.is_empty() || scan.reached_scan_cap { + check = check.issue( + DoctorIssue::new( + CheckStatus::Warning, + "rollout scan was incomplete or found bad files", + ) + .measured(format!( + "{} scan errors, {} malformed names, scan cap reached: {}", + scan.scan_errors.len(), + scan.malformed_names.len(), + scan.reached_scan_cap + )) + .expected("rollout directories are fully scannable") + .remedy("Check file permissions and unexpected files under CODEX_HOME sessions."), + ); + } + check +} + +async fn scan_rollout_files(codex_home: &Path) -> RolloutScan { + let mut scan = RolloutScan::default(); + scan_rollout_root( + &codex_home.join("sessions"), + /*archived*/ false, + &mut scan, + ) + .await; + scan_rollout_root( + &codex_home.join("archived_sessions"), + /*archived*/ true, + &mut scan, + ) + .await; + scan +} + +async fn scan_rollout_root(root: &Path, archived: bool, scan: &mut RolloutScan) { + let mut dirs = vec![root.to_path_buf()]; + while let Some(dir) = dirs.pop() { + if scan.reached_scan_cap { + return; + } + let entries = match std::fs::read_dir(&dir) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue, + Err(err) => { + scan.record_scan_error(format!("{} ({err})", dir.display())); + continue; + } + }; + for entry in entries { + if scan.reached_scan_cap { + return; + } + let entry = match entry { + Ok(entry) => entry, + Err(err) => { + scan.record_scan_error(format!("{} ({err})", dir.display())); + continue; + } + }; + let path = entry.path(); + let file_type = match entry.file_type() { + Ok(file_type) => file_type, + Err(err) => { + scan.record_scan_error(format!("{} ({err})", path.display())); + continue; + } + }; + if file_type.is_dir() { + dirs.push(path); + continue; + } + if !file_type.is_file() || !is_rollout_file(&path) { + continue; + } + if scan.candidate_count() >= MAX_PARITY_SCAN_FILES { + scan.reached_scan_cap = true; + return; + } + let thread_id = match thread_id_from_rollout(&path).await { + RolloutThreadId::Id(thread_id) => thread_id, + RolloutThreadId::MalformedName => { + scan.record_malformed_name(path.clone()); + continue; + } + RolloutThreadId::Unusable(reason) => { + scan.record_scan_error(format!("{} ({reason})", path.display())); + continue; + } + }; + scan.files.push(RolloutAuditFile { + key: path_key(&path), + path, + archived, + thread_id, + }); + } + } +} + +async fn thread_id_from_rollout(path: &Path) -> RolloutThreadId { + let items = match RolloutRecorder::load_rollout_items(path).await { + Ok((items, _, _)) => items, + Err(err) => return RolloutThreadId::Unusable(err.to_string()), + }; + if items.is_empty() { + return RolloutThreadId::Unusable("no parseable rollout items".to_string()); + } + codex_rollout::builder_from_items(items.as_slice(), path) + .map(|builder| RolloutThreadId::Id(builder.id.to_string())) + .unwrap_or(RolloutThreadId::MalformedName) +} + +fn is_rollout_file(path: &Path) -> bool { + path.extension() == Some(OsStr::new("jsonl")) + && path + .file_name() + .and_then(OsStr::to_str) + .is_some_and(|name| name.starts_with("rollout-")) +} + +fn count_or_skipped(count: usize, complete: bool) -> String { + if complete { + count.to_string() + } else { + "skipped (scan cap reached)".to_string() + } +} + +fn path_key(path: &Path) -> PathBuf { + normalize_for_path_comparison(path).unwrap_or_else(|_| path.to_path_buf()) +} + +fn archived_from_rollout_path(codex_home: &Path, path: &Path) -> Option { + let key = path_key(path); + if key.starts_with(path_key(&codex_home.join("archived_sessions"))) { + return Some(true); + } + if key.starts_with(path_key(&codex_home.join("sessions"))) { + return Some(false); + } + None +} + +fn missing_rollout_paths<'a>( + files: &'a [RolloutAuditFile], + rows_by_key: &HashMap>, + archived: bool, +) -> Vec<&'a Path> { + files + .iter() + .filter(|file| file.archived == archived && !has_matching_thread_row(file, rows_by_key)) + .map(|file| file.path.as_path()) + .collect() +} + +fn has_matching_thread_row( + file: &RolloutAuditFile, + rows_by_key: &HashMap>, +) -> bool { + let Some(rows) = rows_by_key.get(&file.key) else { + return false; + }; + rows.iter().any(|row| row.id == file.thread_id.as_str()) +} + +fn duplicate_rollout_thread_ids(files: &[RolloutAuditFile]) -> Vec { + let mut seen = HashSet::new(); + let mut duplicates = HashSet::new(); + for thread_id in files.iter().map(|file| file.thread_id.as_str()) { + if !seen.insert(thread_id) { + duplicates.insert(thread_id.to_string()); + } + } + let mut duplicates = duplicates.into_iter().collect::>(); + duplicates.sort(); + duplicates +} + +fn duplicate_db_paths(rows_by_key: &HashMap>) -> Vec { + let mut paths = rows_by_key + .iter() + .filter(|(_, rows)| rows.len() > 1) + .map(|(path, _)| path.clone()) + .collect::>(); + paths.sort(); + paths +} + +fn source_category(source: &str) -> &'static str { + let parsed = serde_json::from_str::(source) + .or_else(|_| serde_json::from_value(serde_json::Value::String(source.to_string()))); + let Ok(source) = parsed else { + return "unparsable"; + }; + + match source { + SessionSource::Cli => "cli", + SessionSource::VSCode => "vscode", + SessionSource::Exec => "exec", + SessionSource::Mcp => "mcp", + SessionSource::Custom(_) => "custom", + SessionSource::Internal(InternalSessionSource::MemoryConsolidation) => { + "internal:memory_consolidation" + } + SessionSource::SubAgent(SubAgentSource::Review) => "subagent:review", + SessionSource::SubAgent(SubAgentSource::Compact) => "subagent:compact", + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. }) => "subagent:thread_spawn", + SessionSource::SubAgent(SubAgentSource::MemoryConsolidation) => { + "subagent:memory_consolidation" + } + SessionSource::SubAgent(SubAgentSource::Other(_)) => "subagent:other", + SessionSource::Unknown => "unknown", + } +} + +fn count_summary(values: I) -> String +where + I: Iterator, + V: Into, +{ + let mut counts = BTreeMap::::new(); + for value in values { + *counts.entry(value.into()).or_default() += 1; + } + if counts.is_empty() { + return "none".to_string(); + } + + let mut entries = counts.into_iter().collect::>(); + entries.sort_by(|(left_value, left_count), (right_value, right_count)| { + right_count + .cmp(left_count) + .then_with(|| left_value.cmp(right_value)) + }); + let omitted_categories = entries.len().saturating_sub(SUMMARY_LIMIT); + let omitted_rows = entries + .iter() + .skip(SUMMARY_LIMIT) + .map(|(_, count)| count) + .sum::(); + let mut parts = entries + .into_iter() + .take(SUMMARY_LIMIT) + .map(|(value, count)| format!("{value}={count}")) + .collect::>(); + if omitted_categories > 0 { + parts.push(format!( + "other={omitted_rows} across {omitted_categories} categories" + )); + } + parts.join(", ") +} + +fn push_path_samples<'a>( + details: &mut Vec, + label: &str, + paths: impl Iterator, +) { + push_samples(details, label, paths.map(|path| path.display().to_string())); +} + +fn push_samples(details: &mut Vec, label: &str, values: I) +where + I: Iterator, + V: ToString, +{ + for value in values.take(SAMPLE_LIMIT) { + details.push(format!("{label}: {}", value.to_string())); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_protocol::ThreadId; + use codex_protocol::protocol::RolloutItem; + use codex_protocol::protocol::RolloutLine; + use pretty_assertions::assert_eq; + use sqlx::sqlite::SqliteConnectOptions; + use sqlx::sqlite::SqlitePoolOptions; + use tempfile::TempDir; + + #[tokio::test] + async fn thread_inventory_check_ok_when_rollouts_match_db() { + let fixture = Fixture::new().await; + let active_path = fixture.write_rollout( + /*archived*/ false, + "2025-01-02T10-00-00", + "00000000-0000-0000-0000-000000000001", + ); + let archived_path = fixture.write_rollout( + /*archived*/ true, + "2025-01-02T11-00-00", + "00000000-0000-0000-0000-000000000002", + ); + fixture + .insert_thread_row( + "00000000-0000-0000-0000-000000000001", + active_path.as_path(), + /*archived*/ false, + ) + .await; + fixture + .insert_thread_row( + "00000000-0000-0000-0000-000000000002", + archived_path.as_path(), + /*archived*/ true, + ) + .await; + + let check = thread_inventory_check_for_roots( + fixture.codex_home.path(), + fixture.sqlite_home.path(), + "test-provider", + ) + .await; + + assert_eq!(check.status, CheckStatus::Ok); + assert_eq!(check.category, CHECK_CATEGORY); + assert_detail(&check, "rollout DB missing active rows", "0"); + assert_detail(&check, "rollout DB missing archived rows", "0"); + assert_detail(&check, "rollout DB stale rows", "0"); + assert_detail(&check, "rollout DB archive mismatches", "0"); + } + + #[tokio::test] + async fn thread_inventory_check_warns_for_missing_stale_and_mismatched_rows() { + let fixture = Fixture::new().await; + let missing_path = fixture.write_rollout( + /*archived*/ false, + "2025-01-02T10-00-00", + "00000000-0000-0000-0000-000000000001", + ); + let mismatched_path = fixture.write_rollout( + /*archived*/ true, + "2025-01-02T11-00-00", + "00000000-0000-0000-0000-000000000002", + ); + let stale_path = fixture + .codex_home + .path() + .join("sessions/2025/01/02/rollout-2025-01-02T12-00-00-00000000-0000-0000-0000-000000000003.jsonl"); + fixture + .insert_thread_row( + "00000000-0000-0000-0000-000000000002", + mismatched_path.as_path(), + /*archived*/ false, + ) + .await; + fixture + .insert_thread_row( + "00000000-0000-0000-0000-000000000003", + stale_path.as_path(), + /*archived*/ false, + ) + .await; + + let check = thread_inventory_check_for_roots( + fixture.codex_home.path(), + fixture.sqlite_home.path(), + "test-provider", + ) + .await; + + assert_eq!(check.status, CheckStatus::Warning); + assert_eq!(check.issues.len(), 3); + assert_detail(&check, "rollout DB missing active rows", "1"); + assert_detail(&check, "rollout DB stale rows", "1"); + assert_detail(&check, "rollout DB archive mismatches", "1"); + assert_eq!(check.remediation, None); + assert!(check.issues.iter().all(|issue| { + !issue + .remedy + .as_deref() + .is_some_and(|remedy| remedy.starts_with("Restart Codex")) + })); + assert!( + check + .details + .iter() + .any(|detail| detail.contains(missing_path.to_string_lossy().as_ref())) + ); + } + + struct Fixture { + codex_home: TempDir, + sqlite_home: TempDir, + } + + impl Fixture { + async fn new() -> Self { + let codex_home = TempDir::new().expect("codex home"); + let sqlite_home = TempDir::new().expect("sqlite home"); + let _runtime = codex_state::StateRuntime::init( + sqlite_home.path().to_path_buf(), + "test-provider".to_string(), + ) + .await + .expect("state runtime"); + Self { + codex_home, + sqlite_home, + } + } + + fn write_rollout(&self, archived: bool, timestamp: &str, thread_id: &str) -> PathBuf { + let root = if archived { + self.codex_home.path().join("archived_sessions") + } else { + self.codex_home.path().join("sessions/2025/01/02") + }; + std::fs::create_dir_all(&root).expect("rollout dir"); + let path = root.join(format!("rollout-{timestamp}-{thread_id}.jsonl")); + let rollout_line = RolloutLine { + timestamp: timestamp.to_string(), + item: RolloutItem::SessionMeta(codex_protocol::protocol::SessionMetaLine { + meta: codex_protocol::protocol::SessionMeta { + id: ThreadId::from_string(thread_id).expect("thread id"), + timestamp: timestamp.to_string(), + cwd: self.codex_home.path().to_path_buf(), + originator: "test".to_string(), + cli_version: "test".to_string(), + source: SessionSource::Cli, + model_provider: Some("test-provider".to_string()), + ..Default::default() + }, + git: None, + }), + }; + let contents = serde_json::to_string(&rollout_line).expect("rollout line"); + std::fs::write(&path, format!("{contents}\n")).expect("rollout file"); + path + } + + async fn insert_thread_row(&self, id: &str, rollout_path: &Path, archived: bool) { + let state_db_path = codex_state::state_db_path(self.sqlite_home.path()); + let options = SqliteConnectOptions::new() + .filename(state_db_path) + .create_if_missing(false); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .expect("sqlite pool"); + sqlx::query( + r#" +INSERT INTO threads ( + id, + rollout_path, + created_at, + updated_at, + source, + model_provider, + cwd, + title, + sandbox_policy, + approval_mode, + archived, + archived_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + "#, + ) + .bind(id) + .bind(rollout_path.display().to_string()) + .bind(1_i64) + .bind(1_i64) + .bind("cli") + .bind("test-provider") + .bind(self.codex_home.path().display().to_string()) + .bind("test title") + .bind("read-only") + .bind("on-request") + .bind(if archived { 1_i64 } else { 0_i64 }) + .bind(archived.then_some(1_i64)) + .execute(&pool) + .await + .expect("insert thread row"); + pool.close().await; + } + } + + fn assert_detail(check: &DoctorCheck, label: &str, expected: &str) { + let prefix = format!("{label}: "); + let actual = check + .details + .iter() + .find_map(|detail| detail.strip_prefix(&prefix)) + .expect("detail should exist"); + assert_eq!(actual, expected); + } + + #[test] + fn source_category_coarsens_structured_sources() { + assert_eq!(source_category("cli"), "cli"); + assert_eq!( + source_category(r#"{"subagent":"memory_consolidation"}"#), + "subagent:memory_consolidation" + ); + assert_eq!( + source_category( + r#"{"subagent":{"thread_spawn":{"parent_thread_id":"00000000-0000-0000-0000-000000000001","depth":2}}}"#, + ), + "subagent:thread_spawn" + ); + } + + #[test] + fn count_summary_caps_distinct_values() { + let summary = count_summary(["a", "b", "c", "d", "e", "f", "g", "h", "i"].into_iter()); + + assert_eq!( + summary, + "a=1, b=1, c=1, d=1, e=1, f=1, g=1, h=1, other=1 across 1 categories" + ); + } +} diff --git a/codex-rs/cli/src/doctor/title.rs b/codex-rs/cli/src/doctor/title.rs new file mode 100644 index 0000000000..09dc80c0a2 --- /dev/null +++ b/codex-rs/cli/src/doctor/title.rs @@ -0,0 +1,406 @@ +use std::collections::HashSet; +use std::path::Path; +use std::path::PathBuf; + +use codex_config::ConfigLayerSource; +use codex_config::ConfigLayerStackOrdering; +use codex_core::config::Config; +use codex_git_utils::get_git_repo_root; +use unicode_segmentation::UnicodeSegmentation; + +use super::CheckStatus; +use super::DoctorCheck; + +const DEFAULT_TERMINAL_TITLE_ITEMS: &[&str] = &["activity", "project-name"]; +const PROJECT_TITLE_MAX_CHARS: usize = 24; + +#[derive(Clone, Debug, Eq, PartialEq)] +struct TerminalTitleInputs { + configured_items: Option>, + cwd: PathBuf, + project_root: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ProjectTitleRoot { + source: &'static str, + path: PathBuf, +} + +pub(super) fn terminal_title_check(config: &Config) -> DoctorCheck { + terminal_title_check_from_inputs(TerminalTitleInputs { + configured_items: config.tui_terminal_title.clone(), + cwd: config.cwd.to_path_buf(), + project_root: terminal_title_project_root(config, &config.cwd), + }) +} + +fn terminal_title_check_from_inputs(inputs: TerminalTitleInputs) -> DoctorCheck { + let (source, items, invalid_items) = match inputs.configured_items { + Some(items) if items.is_empty() => ("disabled", Vec::new(), Vec::new()), + Some(items) => { + let (items, invalid_items) = parse_terminal_title_items(items); + ("configured", items, invalid_items) + } + None => ( + "default", + DEFAULT_TERMINAL_TITLE_ITEMS + .iter() + .map(ToString::to_string) + .collect(), + Vec::new(), + ), + }; + let mut details = vec![ + format!("terminal title source: {source}"), + format!( + "terminal title items: {}", + if items.is_empty() { + "none".to_string() + } else { + items.join(", ") + } + ), + format!("terminal title activity: {}", activity_enabled(&items)), + ]; + if !invalid_items.is_empty() { + details.push(format!( + "terminal title invalid items: {}", + invalid_items.join(", ") + )); + } + + if project_title_selected(&items) { + let (project_source, project_value) = + project_title_candidate(inputs.project_root, &inputs.cwd); + details.push(format!("terminal title project source: {project_source}")); + if let Some(project_value) = project_value { + details.push(format!("terminal title project value: {project_value}")); + } + } + + let status = if invalid_items.is_empty() { + CheckStatus::Ok + } else { + CheckStatus::Warning + }; + let summary = if invalid_items.is_empty() { + format!("terminal title {source}") + } else { + format!("terminal title {source} with invalid items") + }; + let mut check = DoctorCheck::new("terminal.title", "title", status, summary).details(details); + if !invalid_items.is_empty() { + check = check.issue( + super::DoctorIssue::new( + CheckStatus::Warning, + "terminal title configuration contains unknown item identifiers", + ) + .measured(invalid_items.join(", ")) + .expected("known terminal title item identifiers") + .remedy("Remove or replace the unknown entries in [tui].terminal_title.") + .field("terminal title invalid items"), + ); + } + check +} + +fn parse_terminal_title_items(items: Vec) -> (Vec, Vec) { + let mut invalid = Vec::new(); + let mut invalid_seen = HashSet::new(); + let mut parsed = Vec::new(); + for item in items { + match terminal_title_item_id(&item) { + Some(id) => parsed.push(id.to_string()), + None => { + if invalid_seen.insert(item.clone()) { + invalid.push(format!(r#""{item}""#)); + } + } + } + } + (parsed, invalid) +} + +fn terminal_title_item_id(item: &str) -> Option<&'static str> { + match item { + "app-name" => Some("app-name"), + "project-name" | "project" => Some("project-name"), + "current-dir" => Some("current-dir"), + "activity" | "spinner" => Some("activity"), + "run-state" | "status" => Some("run-state"), + "thread-title" | "thread" => Some("thread-title"), + "git-branch" => Some("git-branch"), + "context-remaining" => Some("context-remaining"), + "context-used" | "context-usage" => Some("context-used"), + "five-hour-limit" => Some("five-hour-limit"), + "weekly-limit" => Some("weekly-limit"), + "codex-version" => Some("codex-version"), + "used-tokens" => Some("used-tokens"), + "total-input-tokens" => Some("total-input-tokens"), + "total-output-tokens" => Some("total-output-tokens"), + "thread-id" | "session-id" => Some("thread-id"), + "fast-mode" => Some("fast-mode"), + "model" | "model-name" => Some("model"), + "model-with-reasoning" => Some("model-with-reasoning"), + "reasoning" => Some("reasoning"), + "task-progress" => Some("task-progress"), + _ => None, + } +} + +fn activity_enabled(items: &[String]) -> bool { + items + .iter() + .any(|item| item == "activity" || item == "spinner") +} + +fn project_title_selected(items: &[String]) -> bool { + items + .iter() + .any(|item| item == "project-name" || item == "project") +} + +fn terminal_title_project_root(config: &Config, cwd: &Path) -> Option { + if let Some(repo_root) = get_git_repo_root(cwd) { + return Some(ProjectTitleRoot { + source: "git repo root", + path: repo_root, + }); + } + + config + .config_layer_stack + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) + .iter() + .find_map(|layer| match &layer.name { + ConfigLayerSource::Project { dot_codex_folder } => dot_codex_folder + .as_path() + .parent() + .map(|root| ProjectTitleRoot { + source: "project config", + path: root.to_path_buf(), + }), + _ => None, + }) +} + +fn project_title_candidate( + project_root: Option, + cwd: &Path, +) -> (&'static str, Option) { + if let Some(project_root) = project_root { + return ( + project_root.source, + Some(truncate_title_part(path_display_name(&project_root.path))), + ); + } + ("cwd", Some(truncate_title_part(path_display_name(cwd)))) +} + +fn path_display_name(path: &Path) -> String { + path.file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| path.display().to_string()) +} + +fn truncate_title_part(value: String) -> String { + let mut graphemes = value.graphemes(true); + let head = graphemes + .by_ref() + .take(PROJECT_TITLE_MAX_CHARS) + .collect::(); + if graphemes.next().is_none() || PROJECT_TITLE_MAX_CHARS <= 3 { + return head; + } + + let mut truncated = head + .graphemes(true) + .take(PROJECT_TITLE_MAX_CHARS - 3) + .collect::(); + truncated.push_str("..."); + truncated +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn terminal_title_reports_default_items_and_git_project_name() { + let check = terminal_title_check_from_inputs(TerminalTitleInputs { + configured_items: None, + cwd: PathBuf::from("/repo/subdir"), + project_root: Some(ProjectTitleRoot { + source: "git repo root", + path: PathBuf::from("/repo"), + }), + }); + + assert_eq!(check.summary, "terminal title default"); + assert!( + check + .details + .contains(&"terminal title items: activity, project-name".to_string()) + ); + assert!( + check + .details + .contains(&"terminal title project source: git repo root".to_string()) + ); + assert!( + check + .details + .contains(&"terminal title project value: repo".to_string()) + ); + } + + #[test] + fn terminal_title_reports_disabled_configuration() { + let check = terminal_title_check_from_inputs(TerminalTitleInputs { + configured_items: Some(Vec::new()), + cwd: PathBuf::from("/workspace"), + project_root: None, + }); + + assert_eq!(check.summary, "terminal title disabled"); + assert!( + check + .details + .contains(&"terminal title items: none".to_string()) + ); + assert!( + check + .details + .contains(&"terminal title activity: false".to_string()) + ); + assert!( + !check + .details + .iter() + .any(|detail| detail.starts_with("terminal title project ")) + ); + } + + #[test] + fn terminal_title_reports_project_config_fallback() { + let check = terminal_title_check_from_inputs(TerminalTitleInputs { + configured_items: Some(vec!["project".to_string()]), + cwd: PathBuf::from("/workspace/project/subdir"), + project_root: Some(ProjectTitleRoot { + source: "project config", + path: PathBuf::from("/workspace/project"), + }), + }); + + assert_eq!(check.summary, "terminal title configured"); + assert!( + check + .details + .contains(&"terminal title project source: project config".to_string()) + ); + assert!( + check + .details + .contains(&"terminal title project value: project".to_string()) + ); + } + + #[test] + fn terminal_title_omits_project_when_project_item_is_not_selected() { + let check = terminal_title_check_from_inputs(TerminalTitleInputs { + configured_items: Some(vec!["model".to_string()]), + cwd: PathBuf::from("/workspace/project"), + project_root: Some(ProjectTitleRoot { + source: "project config", + path: PathBuf::from("/workspace/project"), + }), + }); + + assert_eq!(check.summary, "terminal title configured"); + assert!( + !check + .details + .iter() + .any(|detail| detail.starts_with("terminal title project ")) + ); + } + + #[test] + fn terminal_title_warns_for_invalid_configured_items() { + let check = terminal_title_check_from_inputs(TerminalTitleInputs { + configured_items: Some(vec![ + "project".to_string(), + "bogus".to_string(), + "activity".to_string(), + "bogus".to_string(), + ]), + cwd: PathBuf::from("/workspace/project"), + project_root: Some(ProjectTitleRoot { + source: "project config", + path: PathBuf::from("/workspace/project"), + }), + }); + + assert_eq!(check.status, CheckStatus::Warning); + assert_eq!( + check.summary, + "terminal title configured with invalid items" + ); + assert!( + check + .details + .contains(&"terminal title items: project-name, activity".to_string()) + ); + assert!( + check + .details + .contains(&r#"terminal title invalid items: "bogus""#.to_string()) + ); + assert_eq!(check.issues.len(), 1); + } + + #[test] + fn terminal_title_warns_when_all_configured_items_are_invalid() { + let check = terminal_title_check_from_inputs(TerminalTitleInputs { + configured_items: Some(vec!["bogus".to_string()]), + cwd: PathBuf::from("/workspace/project"), + project_root: None, + }); + + assert_eq!(check.status, CheckStatus::Warning); + assert!( + check + .details + .contains(&"terminal title items: none".to_string()) + ); + assert!( + check + .details + .contains(&r#"terminal title invalid items: "bogus""#.to_string()) + ); + } + + #[test] + fn terminal_title_project_value_uses_tui_truncation_shape() { + let check = terminal_title_check_from_inputs(TerminalTitleInputs { + configured_items: Some(vec!["project".to_string()]), + cwd: PathBuf::from("/workspace/abcdefghijklmnopqrstuvwxyz"), + project_root: Some(ProjectTitleRoot { + source: "project config", + path: PathBuf::from("/workspace/abcdefghijklmnopqrstuvwxyz"), + }), + }); + + assert!( + check + .details + .contains(&"terminal title project value: abcdefghijklmnopqrstu...".to_string()) + ); + } +} diff --git a/codex-rs/cli/src/doctor/updates.rs b/codex-rs/cli/src/doctor/updates.rs index 3d728fc07b..246eac2b39 100644 --- a/codex-rs/cli/src/doctor/updates.rs +++ b/codex-rs/cli/src/doctor/updates.rs @@ -10,6 +10,7 @@ use std::path::Path; use codex_core::config::Config; use codex_install_context::InstallContext; +use codex_install_context::InstallMethod; use serde::Deserialize; use super::CheckStatus; @@ -129,22 +130,22 @@ fn push_cached_version_details(details: &mut Vec, version_file: &Path) { } fn update_action_label(context: &InstallContext) -> &'static str { - match context { - InstallContext::Npm => "npm install -g @openai/codex", - InstallContext::Bun => "bun install -g @openai/codex", - InstallContext::Brew => "brew upgrade --cask codex", - InstallContext::Standalone { .. } => "standalone installer", - InstallContext::Other => "manual or unknown", + match &context.method { + InstallMethod::Npm => "npm install -g @openai/codex", + InstallMethod::Bun => "bun install -g @openai/codex", + InstallMethod::Brew => "brew upgrade --cask codex", + InstallMethod::Standalone { .. } => "standalone installer", + InstallMethod::Other => "manual or unknown", } } fn fetch_latest_version(context: &InstallContext) -> Result { - match context { - InstallContext::Brew => fetch_homebrew_cask_version(), - InstallContext::Npm - | InstallContext::Bun - | InstallContext::Standalone { .. } - | InstallContext::Other => fetch_latest_github_release_version(), + match &context.method { + InstallMethod::Brew => fetch_homebrew_cask_version(), + InstallMethod::Npm + | InstallMethod::Bun + | InstallMethod::Standalone { .. } + | InstallMethod::Other => fetch_latest_github_release_version(), } } @@ -216,11 +217,17 @@ mod tests { #[test] fn update_action_labels_install_contexts() { assert_eq!( - update_action_label(&InstallContext::Npm), + update_action_label(&InstallContext { + method: InstallMethod::Npm, + package_layout: None, + }), "npm install -g @openai/codex" ); assert_eq!( - update_action_label(&InstallContext::Other), + update_action_label(&InstallContext { + method: InstallMethod::Other, + package_layout: None, + }), "manual or unknown" ); } diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index 5bea8ce78d..5e2ba0caa5 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -5,11 +5,12 @@ pub(crate) mod login; use clap::Parser; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; +use codex_utils_cli::ProfileV2Name; use std::path::PathBuf; pub use debug_sandbox::run_command_under_landlock; pub use debug_sandbox::run_command_under_seatbelt; -pub use debug_sandbox::run_command_under_windows; +pub use debug_sandbox::run_command_under_windows_sandbox; pub use login::read_access_token_from_stdin; pub use login::read_api_key_from_stdin; pub use login::run_login_status; @@ -20,14 +21,18 @@ pub use login::run_login_with_device_code; pub use login::run_login_with_device_code_fallback_to_browser; pub use login::run_logout; -// TODO: Deduplicate these shared sandbox options if we remove the explicit -// `codex sandbox ` platform subcommands. +// These command structs share common sandbox options, but remain separate +// because each host backend has a slightly different option surface. #[derive(Debug, Parser)] pub struct SeatbeltCommand { /// Named permissions profile to apply from the active configuration stack. #[arg(long = "permissions-profile", value_name = "NAME")] pub permissions_profile: Option, + /// Layer $CODEX_HOME/.config.toml on top of the base user config. + #[arg(long = "profile", short = 'p')] + pub config_profile: Option, + /// Working directory used for profile resolution and command execution. #[arg( short = 'C', @@ -72,6 +77,10 @@ pub struct LandlockCommand { #[arg(long = "permissions-profile", value_name = "NAME")] pub permissions_profile: Option, + /// Layer $CODEX_HOME/.config.toml on top of the base user config. + #[arg(long = "profile", short = 'p')] + pub config_profile: Option, + /// Working directory used for profile resolution and command execution. #[arg( short = 'C', @@ -103,6 +112,10 @@ pub struct WindowsCommand { #[arg(long = "permissions-profile", value_name = "NAME")] pub permissions_profile: Option, + /// Layer $CODEX_HOME/.config.toml on top of the base user config. + #[arg(long = "profile", short = 'p')] + pub config_profile: Option, + /// Working directory used for profile resolution and command execution. #[arg( short = 'C', diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index de5f52f4e7..2a5ce171b7 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -10,9 +10,6 @@ use codex_arg0::Arg0DispatchPaths; use codex_arg0::arg0_dispatch_or_else; use codex_chatgpt::apply_command::ApplyCommand; use codex_chatgpt::apply_command::run_apply_command; -use codex_cli::LandlockCommand; -use codex_cli::SeatbeltCommand; -use codex_cli::WindowsCommand; use codex_cli::read_access_token_from_stdin; use codex_cli::read_api_key_from_stdin; use codex_cli::run_login_status; @@ -30,7 +27,7 @@ use codex_responses_api_proxy::Args as ResponsesApiProxyArgs; use codex_rollout_trace::REDUCED_STATE_FILE_NAME; use codex_rollout_trace::replay_bundle; use codex_state::StateRuntime; -use codex_state::state_db_path; +use codex_state::memories_db_path; use codex_tui::AppExitInfo; use codex_tui::Cli as TuiCli; use codex_tui::ExitReason; @@ -38,7 +35,8 @@ use codex_tui::UpdateAction; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; use codex_utils_cli::ProfileV2Name; -use codex_utils_cli::resume_command; +use codex_utils_cli::SharedCliOptions; +use codex_utils_cli::resume_hint; use owo_colors::OwoColorize; use std::io::IsTerminal; use std::path::PathBuf; @@ -52,13 +50,19 @@ mod doctor; mod marketplace_cmd; mod mcp_cmd; mod plugin_cmd; +mod remote_control_cmd; +#[cfg(target_os = "windows")] +mod sandbox_setup; +mod state_db_recovery; #[cfg(not(windows))] mod wsl_paths; use crate::mcp_cmd::McpCli; use crate::plugin_cmd::PluginCli; use crate::plugin_cmd::PluginSubcommand; +use crate::remote_control_cmd::RemoteControlCommand; use doctor::DoctorCommand; +use state_db_recovery as local_state_db; use codex_config::LoaderOverrides; use codex_core::build_models_manager; @@ -71,6 +75,8 @@ use codex_features::FEATURES; use codex_features::Stage; use codex_features::is_known_feature_key; use codex_login::AuthManager; +use codex_login::CodexAuth; +use codex_login::read_codex_access_token_from_env; use codex_memories_write::clear_memory_roots_contents; use codex_models_manager::bundled_models_response; use codex_models_manager::manager::RefreshStrategy; @@ -154,7 +160,7 @@ enum Subcommand { Doctor(DoctorCommand), /// Run commands within a Codex-provided sandbox. - Sandbox(SandboxArgs), + Sandbox(HostSandboxArgs), /// Debugging tools. Debug(DebugCommand), @@ -170,6 +176,12 @@ enum Subcommand { /// Resume a previous interactive session (picker by default; use --last to continue the most recent). Resume(ResumeCommand), + /// Archive a saved session by id or session name. + Archive(SessionArchiveCommand), + + /// Unarchive a saved session by id or session name. + Unarchive(SessionArchiveCommand), + /// Fork a previous interactive session (picker by default; use --last to fork the most recent). Fork(ForkCommand), @@ -291,7 +303,7 @@ struct DebugTraceReduceCommand { #[derive(Debug, Parser)] struct ResumeCommand { - /// Conversation/session id (UUID) or thread name. UUIDs take precedence if it parses. + /// Session id (UUID) or session name. UUIDs take precedence if it parses. /// If omitted, use --last to pick the most recent recorded session. #[arg(value_name = "SESSION_ID")] session_id: Option, @@ -315,6 +327,32 @@ struct ResumeCommand { config_overrides: TuiCli, } +#[derive(Debug, Parser)] +struct SessionArchiveCommand { + /// Session id (UUID) or session name. UUIDs take precedence if it parses. + #[arg(value_name = "SESSION")] + target: String, + + #[clap(flatten)] + remote: InteractiveRemoteOptions, + + #[clap(flatten)] + config_overrides: SessionArchiveConfigOverrides, +} + +#[derive(Debug, Args, Clone, Default)] +struct SessionArchiveConfigOverrides { + #[clap(flatten)] + shared: SharedCliOptions, + + /// Error out when config.toml contains fields that are not recognized by this version of Codex. + #[arg(long = "strict-config", default_value_t = false)] + strict_config: bool, + + #[clap(flatten)] + config_overrides: CliConfigOverrides, +} + #[derive(Debug, Parser)] struct ForkCommand { /// Conversation/session id (UUID). When provided, forks this session. @@ -337,24 +375,29 @@ struct ForkCommand { config_overrides: TuiCli, } +#[cfg(target_os = "macos")] +type HostSandboxArgs = codex_cli::SeatbeltCommand; +#[cfg(target_os = "linux")] +type HostSandboxArgs = codex_cli::LandlockCommand; +#[cfg(target_os = "windows")] +type HostSandboxArgs = codex_cli::WindowsCommand; + +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] +type HostSandboxArgs = UnsupportedSandboxArgs; + +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] #[derive(Debug, Parser)] -struct SandboxArgs { - #[command(subcommand)] - cmd: SandboxCommand, -} +struct UnsupportedSandboxArgs { + /// Layer $CODEX_HOME/.config.toml on top of the base user config. + #[arg(long = "profile", short = 'p')] + pub config_profile: Option, -#[derive(Debug, clap::Subcommand)] -enum SandboxCommand { - /// Run a command under Seatbelt (macOS only). - #[clap(visible_alias = "seatbelt")] - Macos(SeatbeltCommand), + #[clap(skip)] + pub config_overrides: CliConfigOverrides, - /// Run a command under the Linux sandbox (bubblewrap by default). - #[clap(visible_alias = "landlock")] - Linux(LandlockCommand), - - /// Run a command under Windows restricted token (Windows only). - Windows(WindowsCommand), + /// Full command args to run under the host sandbox. + #[arg(trailing_var_arg = true)] + pub command: Vec, } #[derive(Debug, Parser)] @@ -444,6 +487,10 @@ struct AppServerCommand { )] listen: codex_app_server::AppServerTransport, + /// Use stdio as the transport (equivalent to `--listen stdio://`). + #[arg(long = "stdio", conflicts_with = "listen")] + stdio: bool, + /// Enable remote control for this app-server process. #[arg(long = "remote-control", hide = true)] remote_control: bool, @@ -472,21 +519,29 @@ struct AppServerCommand { #[derive(Debug, Parser)] struct ExecServerCommand { + /// Error out when config.toml contains fields that are not recognized by this version of Codex. + #[arg(long = "strict-config", default_value_t = false)] + strict_config: bool, + /// Transport endpoint URL. Supported values: `ws://IP:PORT` (default), `stdio`, `stdio://`. #[arg(long = "listen", value_name = "URL", conflicts_with = "remote")] listen: Option, - /// Register this exec-server as a remote executor using the given base URL. - #[arg(long = "remote", value_name = "URL", requires = "executor_id")] + /// Register this exec-server as a remote environment using the given base URL. + #[arg(long = "remote", value_name = "URL", requires = "environment_id")] remote: Option, - /// Executor id to attach to when registering remotely. - #[arg(long = "executor-id", value_name = "ID")] - executor_id: Option, + /// Environment id to attach to when registering remotely. + #[arg(long = "environment-id", value_name = "ID")] + environment_id: Option, - /// Human-readable executor name. + /// Human-readable environment name. #[arg(long = "name", value_name = "NAME")] name: Option, + + /// Use Agent Identity auth from CODEX_ACCESS_TOKEN for remote registration. + #[arg(long = "use-agent-identity-auth", requires = "remote")] + use_agent_identity_auth: bool, } #[derive(Debug, clap::Subcommand)] @@ -557,21 +612,6 @@ struct AppServerBootstrapCommand { remote_control: bool, } -#[derive(Debug, Args)] -struct RemoteControlCommand { - #[command(subcommand)] - subcommand: Option, -} - -#[derive(Debug, Clone, Copy, clap::Subcommand)] -enum RemoteControlSubcommand { - /// Start the app-server daemon with remote control enabled. - Start, - - /// Stop the app-server daemon. - Stop, -} - #[derive(Debug, Args)] struct GenerateTsCommand { /// Output directory where .ts files will be written @@ -621,6 +661,7 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec Vec anyhow::Result<()> { cmd.run() } +async fn run_session_archive_cli_command( + action: codex_tui::SessionArchiveAction, + cmd: SessionArchiveCommand, + mut interactive: TuiCli, + root_config_overrides: CliConfigOverrides, + root_remote: Option, + root_remote_auth_token_env: Option, + arg0_paths: Arg0DispatchPaths, +) -> anyhow::Result { + let SessionArchiveCommand { + target, + remote, + config_overrides, + } = cmd; + interactive = + finalize_session_archive_interactive(interactive, root_config_overrides, config_overrides); + let explicit_remote_endpoint = resolve_remote_endpoint( + remote.remote.or(root_remote), + remote.remote_auth_token_env.or(root_remote_auth_token_env), + )?; + codex_tui::run_session_archive_command( + action, + target, + codex_tui::SessionArchiveCommandOptions { + cli: interactive, + arg0_paths, + explicit_remote_endpoint, + }, + ) + .await + .map_err(|err| anyhow::anyhow!("{err}")) +} + async fn run_debug_app_server_command(cmd: DebugAppServerCommand) -> anyhow::Result<()> { match cmd.subcommand { DebugAppServerSubcommand::SendMessageV2(cmd) => { @@ -918,7 +992,9 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { )?; // Propagate any root-level config overrides (e.g. `-c key=value`). prepend_config_flags(&mut mcp_cli.config_overrides, root_config_overrides.clone()); - mcp_cli.run().await?; + let loader_overrides = + loader_overrides_for_profile(interactive.config_profile_v2.as_ref())?; + mcp_cli.run(loader_overrides).await?; } Some(Subcommand::Plugin(plugin_cli)) => { reject_remote_mode_for_subcommand( @@ -961,6 +1037,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { subcommand, strict_config: app_server_strict_config, listen, + stdio, remote_control, analytics_default_enabled, auth, @@ -974,7 +1051,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { )?; match subcommand { None => { - let transport = listen; + let transport = if stdio { + codex_app_server::AppServerTransport::Stdio + } else { + listen + }; let auth = auth.try_into_settings()?; let runtime_options = codex_app_server::AppServerRuntimeOptions { remote_control_enabled: remote_control, @@ -1061,24 +1142,18 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { } } Some(Subcommand::RemoteControl(remote_control_cli)) => { - let subcommand_name = remote_control_subcommand_name(&remote_control_cli); + let subcommand_name = remote_control_cli.subcommand_name(); reject_remote_mode_for_subcommand( root_remote.as_deref(), root_remote_auth_token_env.as_deref(), subcommand_name, )?; - match remote_control_cli - .subcommand - .unwrap_or(RemoteControlSubcommand::Start) - { - RemoteControlSubcommand::Start => { - let output = codex_app_server_daemon::ensure_remote_control_started().await?; - println!("{}", serde_json::to_string(&output)?); - } - RemoteControlSubcommand::Stop => { - print_app_server_daemon_output(AppServerLifecycleCommand::Stop).await?; - } - } + remote_control_cmd::run( + remote_control_cli, + arg0_paths.clone(), + root_config_overrides, + ) + .await?; } #[cfg(any(target_os = "macos", target_os = "windows"))] Some(Subcommand::App(app_cli)) => { @@ -1117,6 +1192,32 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { .await?; handle_app_exit(exit_info)?; } + Some(Subcommand::Archive(cmd)) => { + let output = run_session_archive_cli_command( + codex_tui::SessionArchiveAction::Archive, + cmd, + interactive, + root_config_overrides.clone(), + root_remote.clone(), + root_remote_auth_token_env.clone(), + arg0_paths.clone(), + ) + .await?; + println!("{output}"); + } + Some(Subcommand::Unarchive(cmd)) => { + let output = run_session_archive_cli_command( + codex_tui::SessionArchiveAction::Unarchive, + cmd, + interactive, + root_config_overrides.clone(), + root_remote.clone(), + root_remote_auth_token_env.clone(), + arg0_paths.clone(), + ) + .await?; + println!("{output}"); + } Some(Subcommand::Fork(ForkCommand { session_id, last, @@ -1242,56 +1343,58 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { codex_cloud_tasks::run_main(cloud_cli, arg0_paths.codex_linux_sandbox_exe.clone()) .await?; } - Some(Subcommand::Sandbox(sandbox_args)) => match sandbox_args.cmd { - SandboxCommand::Macos(mut seatbelt_cli) => { + Some(Subcommand::Sandbox(mut sandbox_cli)) => { + #[cfg(target_os = "windows")] + if let Some(setup_cli) = sandbox_setup::parse_setup_command(&sandbox_cli.command)? { reject_remote_mode_for_subcommand( root_remote.as_deref(), root_remote_auth_token_env.as_deref(), - "sandbox macos", + "sandbox setup", )?; - prepend_config_flags( - &mut seatbelt_cli.config_overrides, - root_config_overrides.clone(), - ); - codex_cli::run_command_under_seatbelt( - seatbelt_cli, - arg0_paths.codex_linux_sandbox_exe.clone(), - ) - .await?; + sandbox_setup::run(setup_cli).await?; + return Ok(()); } - SandboxCommand::Linux(mut landlock_cli) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "sandbox linux", - )?; - prepend_config_flags( - &mut landlock_cli.config_overrides, - root_config_overrides.clone(), - ); - codex_cli::run_command_under_landlock( - landlock_cli, - arg0_paths.codex_linux_sandbox_exe.clone(), - ) - .await?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "sandbox", + )?; + let config_profile = sandbox_cli + .config_profile + .as_ref() + .or(interactive.config_profile_v2.as_ref()); + let loader_overrides = loader_overrides_for_profile(config_profile)?; + prepend_config_flags( + &mut sandbox_cli.config_overrides, + root_config_overrides.clone(), + ); + #[cfg(target_os = "macos")] + codex_cli::run_command_under_seatbelt( + sandbox_cli, + arg0_paths.codex_linux_sandbox_exe.clone(), + loader_overrides, + ) + .await?; + #[cfg(target_os = "linux")] + codex_cli::run_command_under_landlock( + sandbox_cli, + arg0_paths.codex_linux_sandbox_exe.clone(), + loader_overrides, + ) + .await?; + #[cfg(target_os = "windows")] + codex_cli::run_command_under_windows_sandbox( + sandbox_cli, + arg0_paths.codex_linux_sandbox_exe.clone(), + loader_overrides, + ) + .await?; + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + let _ = loader_overrides; + anyhow::bail!("`codex sandbox` is not supported on this operating system"); } - SandboxCommand::Windows(mut windows_cli) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "sandbox windows", - )?; - prepend_config_flags( - &mut windows_cli.config_overrides, - root_config_overrides.clone(), - ); - codex_cli::run_command_under_windows( - windows_cli, - arg0_paths.codex_linux_sandbox_exe.clone(), - ) - .await?; - } - }, + } Some(Subcommand::Debug(DebugCommand { subcommand })) => match subcommand { DebugSubcommand::Models(cmd) => { reject_remote_mode_for_subcommand( @@ -1337,7 +1440,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { root_remote_auth_token_env.as_deref(), "debug clear-memories", )?; - run_debug_clear_memories_command(&root_config_overrides, &interactive).await?; + run_debug_clear_memories_command(&root_config_overrides).await?; } }, Some(Subcommand::Execpolicy(ExecpolicyCommand { sub })) => match sub { @@ -1386,7 +1489,9 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { root_remote_auth_token_env.as_deref(), "exec-server", )?; - run_exec_server_command(cmd, &arg0_paths).await?; + let strict_config = cmd.strict_config || root_strict_config; + run_exec_server_command(cmd, &arg0_paths, &root_config_overrides, strict_config) + .await?; } Some(Subcommand::Features(FeaturesCli { sub })) => match sub { FeaturesSubcommand::List => { @@ -1395,7 +1500,6 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { root_remote_auth_token_env.as_deref(), "features list", )?; - // Respect root-level `-c` overrides plus top-level flags like `--profile`. let mut cli_kv_overrides = root_config_overrides .parse_overrides() .map_err(anyhow::Error::msg)?; @@ -1408,15 +1512,8 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { )); } - // Thread through relevant top-level flags (at minimum, `--profile`). - let overrides = ConfigOverrides { - config_profile: interactive.config_profile.clone(), - ..Default::default() - }; - let config = ConfigBuilder::default() .cli_overrides(cli_kv_overrides) - .harness_overrides(overrides) .build() .await?; let mut rows = Vec::with_capacity(FEATURES.len()); @@ -1442,7 +1539,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { root_remote_auth_token_env.as_deref(), "features enable", )?; - enable_feature_in_config(&interactive, &feature).await?; + enable_feature_in_config(&feature).await?; } FeaturesSubcommand::Disable(FeatureSetArgs { feature }) => { reject_remote_mode_for_subcommand( @@ -1450,7 +1547,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { root_remote_auth_token_env.as_deref(), "features disable", )?; - disable_feature_in_config(&interactive, &feature).await?; + disable_feature_in_config(&feature).await?; } }, } @@ -1470,12 +1567,16 @@ fn profile_v2_for_subcommand<'a>( Subcommand::Exec(_) | Subcommand::Review(_) | Subcommand::Resume(_) + | Subcommand::Archive(_) + | Subcommand::Unarchive(_) | Subcommand::Fork(_) + | Subcommand::Mcp(_) + | Subcommand::Sandbox(_) | Subcommand::Debug(DebugCommand { subcommand: DebugSubcommand::PromptInput(_), }) => Ok(Some(profile_v2)), _ => anyhow::bail!( - "--profile-v2 only applies to runtime commands: `codex`, `codex exec`, `codex review`, `codex resume`, `codex fork`, and `codex debug prompt-input`." + "--profile only applies to runtime commands and `codex mcp`: `codex`, `codex exec`, `codex review`, `codex resume`, `codex archive`, `codex unarchive`, `codex fork`, `codex mcp`, `codex sandbox`, and `codex debug prompt-input`." ), } } @@ -1483,6 +1584,8 @@ fn profile_v2_for_subcommand<'a>( async fn run_exec_server_command( cmd: ExecServerCommand, arg0_paths: &Arg0DispatchPaths, + root_config_overrides: &CliConfigOverrides, + strict_config: bool, ) -> anyhow::Result<()> { let codex_self_exe = arg0_paths .codex_self_exe @@ -1493,44 +1596,163 @@ async fn run_exec_server_command( arg0_paths.codex_linux_sandbox_exe.clone(), )?; if let Some(base_url) = cmd.remote { - let executor_id = cmd - .executor_id - .ok_or_else(|| anyhow::anyhow!("--executor-id is required when --remote is set"))?; - let mut remote_config = - codex_exec_server::RemoteExecutorConfig::new(base_url, executor_id)?; + let environment_id = cmd + .environment_id + .ok_or_else(|| anyhow::anyhow!("--environment-id is required when --remote is set"))?; + let config = load_exec_server_config(root_config_overrides, strict_config).await?; + let auth_provider = + load_exec_server_remote_auth_provider(&config, &base_url, cmd.use_agent_identity_auth) + .await?; + let mut remote_config = codex_exec_server::RemoteEnvironmentConfig::new( + base_url, + environment_id, + auth_provider, + )?; if let Some(name) = cmd.name { remote_config.name = name; } - codex_exec_server::run_remote_executor(remote_config, runtime_paths).await?; - return Ok(()); + codex_exec_server::run_remote_environment(remote_config, runtime_paths).await?; + Ok(()) + } else { + if strict_config { + // Local exec-server startup does not consume Config, but strict + // mode should still reject unknown fields before opening a listener. + let _validated_config = + load_exec_server_config(root_config_overrides, strict_config).await?; + } + let listen_url = cmd + .listen + .as_deref() + .unwrap_or(codex_exec_server::DEFAULT_LISTEN_URL); + codex_exec_server::run_main(listen_url, runtime_paths) + .await + .map_err(anyhow::Error::from_boxed) } - let listen_url = cmd - .listen - .as_deref() - .unwrap_or(codex_exec_server::DEFAULT_LISTEN_URL); - codex_exec_server::run_main(listen_url, runtime_paths) - .await - .map_err(anyhow::Error::from_boxed) } -async fn enable_feature_in_config(interactive: &TuiCli, feature: &str) -> anyhow::Result<()> { +async fn load_exec_server_remote_auth_provider( + config: &codex_core::config::Config, + base_url: &str, + use_agent_identity_auth: bool, +) -> anyhow::Result { + if use_agent_identity_auth { + let agent_identity_jwt = read_codex_access_token_from_env().ok_or_else(|| { + anyhow::anyhow!("CODEX_ACCESS_TOKEN is required when --use-agent-identity-auth is set") + })?; + let auth = + CodexAuth::from_agent_identity_jwt(&agent_identity_jwt, Some(&config.chatgpt_base_url)) + .await?; + return Ok(codex_model_provider::auth_provider_from_auth(&auth)); + } + + let auth = load_exec_server_remote_auth( + config, + "remote exec-server registration requires ChatGPT authentication or API key authentication; run `codex login` or set CODEX_API_KEY", + ) + .await?; + + if !is_supported_exec_server_remote_auth(&auth) { + anyhow::bail!( + "remote exec-server registration requires ChatGPT authentication or API key authentication; Agent Identity auth requires --use-agent-identity-auth" + ); + } + + if auth.is_api_key_auth() { + validate_api_key_remote_host(base_url)?; + } + + Ok(codex_model_provider::auth_provider_from_auth(&auth)) +} + +fn is_supported_exec_server_remote_auth(auth: &CodexAuth) -> bool { + auth.is_chatgpt_auth() || auth.is_api_key_auth() +} + +fn validate_api_key_remote_host(base_url: &str) -> anyhow::Result<()> { + let url = url::Url::parse(base_url) + .map_err(|err| anyhow::anyhow!("invalid remote exec-server registration URL: {err}"))?; + let host = url.host().ok_or_else(|| { + anyhow::anyhow!("remote exec-server registration URL must include a host") + })?; + + let is_loopback = match &host { + url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"), + url::Host::Ipv4(ip) => ip.is_loopback(), + url::Host::Ipv6(ip) => ip.is_loopback(), + }; + let is_openai_host = match &host { + url::Host::Domain(host) => ["openai.com", "openai.org"].into_iter().any(|domain| { + host.eq_ignore_ascii_case(domain) + || host.to_ascii_lowercase().ends_with(&format!(".{domain}")) + }), + _ => false, + }; + let is_allowed = match url.scheme() { + "https" => is_loopback || is_openai_host, + "http" => is_loopback, + _ => false, + }; + + if !is_allowed { + anyhow::bail!( + "remote exec-server API-key authentication is restricted to HTTPS openai.com and openai.org hosts and subdomains or loopback hosts" + ); + } + + Ok(()) +} + +async fn load_exec_server_config( + root_config_overrides: &CliConfigOverrides, + strict_config: bool, +) -> anyhow::Result { + let cli_kv_overrides = root_config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + Ok(ConfigBuilder::default() + .cli_overrides(cli_kv_overrides) + .strict_config(strict_config) + .build() + .await?) +} + +async fn load_exec_server_remote_auth( + config: &codex_core::config::Config, + missing_auth_error: &'static str, +) -> anyhow::Result { + let auth_manager = + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ true).await; + + let auth = match auth_manager.auth().await { + Some(auth) => auth, + None => { + auth_manager.reload().await; + auth_manager + .auth() + .await + .ok_or_else(|| anyhow::anyhow!(missing_auth_error))? + } + }; + + Ok(auth) +} + +async fn enable_feature_in_config(feature: &str) -> anyhow::Result<()> { FeatureToggles::validate_feature(feature)?; let codex_home = find_codex_home()?; ConfigEditsBuilder::new(&codex_home) - .with_profile(interactive.config_profile.as_deref()) .set_feature_enabled(feature, /*enabled*/ true) .apply() .await?; println!("Enabled feature `{feature}` in config.toml."); - maybe_print_under_development_feature_warning(&codex_home, interactive, feature); + maybe_print_under_development_feature_warning(&codex_home, feature); Ok(()) } -async fn disable_feature_in_config(interactive: &TuiCli, feature: &str) -> anyhow::Result<()> { +async fn disable_feature_in_config(feature: &str) -> anyhow::Result<()> { FeatureToggles::validate_feature(feature)?; let codex_home = find_codex_home()?; ConfigEditsBuilder::new(&codex_home) - .with_profile(interactive.config_profile.as_deref()) .set_feature_enabled(feature, /*enabled*/ false) .apply() .await?; @@ -1554,15 +1776,7 @@ fn loader_overrides_for_profile( } } -fn maybe_print_under_development_feature_warning( - codex_home: &std::path::Path, - interactive: &TuiCli, - feature: &str, -) { - if interactive.config_profile.is_some() { - return; - } - +fn maybe_print_under_development_feature_warning(codex_home: &std::path::Path, feature: &str) { let Some(spec) = FEATURES.iter().find(|spec| spec.key == feature) else { return; }; @@ -1620,7 +1834,6 @@ async fn run_debug_prompt_input_command( }; let overrides = ConfigOverrides { model: shared.model, - config_profile: shared.config_profile, approval_policy, sandbox_mode, cwd: shared.cwd, @@ -1644,7 +1857,7 @@ async fn run_debug_prompt_input_command( .images .into_iter() .chain(cmd.images) - .map(|path| UserInput::LocalImage { path }) + .map(|path| UserInput::LocalImage { path, detail: None }) .collect::>(); if let Some(prompt) = cmd.prompt.or(interactive.prompt) { input.push(UserInput::Text { @@ -1688,37 +1901,25 @@ async fn run_debug_models_command( async fn run_debug_clear_memories_command( root_config_overrides: &CliConfigOverrides, - interactive: &TuiCli, ) -> anyhow::Result<()> { let cli_kv_overrides = root_config_overrides .parse_overrides() .map_err(anyhow::Error::msg)?; - let overrides = ConfigOverrides { - config_profile: interactive.config_profile.clone(), - ..Default::default() - }; let config = ConfigBuilder::default() .cli_overrides(cli_kv_overrides) - .harness_overrides(overrides) .build() .await?; - let state_path = state_db_path(config.sqlite_home.as_path()); - let mut cleared_state_db = false; - if tokio::fs::try_exists(&state_path).await? { - let state_db = - StateRuntime::init(config.sqlite_home.clone(), config.model_provider_id.clone()) - .await?; - state_db.clear_memory_data().await?; - cleared_state_db = true; - } + let memories_path = memories_db_path(config.sqlite_home.as_path()); + let cleared_memories_db = + StateRuntime::clear_memory_data_in_sqlite_home(config.sqlite_home.as_path()).await?; clear_memory_roots_contents(&config.codex_home).await?; - let mut message = if cleared_state_db { - format!("Cleared memory state from {}.", state_path.display()) + let mut message = if cleared_memories_db { + format!("Cleared memory state from {}.", memories_path.display()) } else { - format!("No state db found at {}.", state_path.display()) + format!("No memories db found at {}.", memories_path.display()) }; message.push_str(&format!( " Cleared memory directories under {}.", @@ -1793,16 +1994,17 @@ fn unsupported_subcommand_name_for_strict_config( | Some(Subcommand::Exec(_)) | Some(Subcommand::Review(_)) | Some(Subcommand::McpServer(_)) + | Some(Subcommand::ExecServer(_)) | Some(Subcommand::Resume(_)) + | Some(Subcommand::Archive(_)) + | Some(Subcommand::Unarchive(_)) | Some(Subcommand::Fork(_)) | Some(Subcommand::Doctor(_)) => None, Some(Subcommand::AppServer(app_server)) if app_server.subcommand.is_none() => None, Some(Subcommand::AppServer(app_server)) => { Some(app_server_subcommand_name(app_server.subcommand.as_ref())) } - Some(Subcommand::RemoteControl(remote_control)) => { - Some(remote_control_subcommand_name(remote_control)) - } + Some(Subcommand::RemoteControl(remote_control)) => Some(remote_control.subcommand_name()), Some(Subcommand::Mcp(_)) => Some("mcp"), Some(Subcommand::Plugin(_)) => Some("plugin"), #[cfg(any(target_os = "macos", target_os = "windows"))] @@ -1818,7 +2020,6 @@ fn unsupported_subcommand_name_for_strict_config( Some(Subcommand::Apply(_)) => Some("apply"), Some(Subcommand::ResponsesApiProxy(_)) => Some("responses-api-proxy"), Some(Subcommand::StdioToUds(_)) => Some("stdio-to-uds"), - Some(Subcommand::ExecServer(_)) => Some("exec-server"), Some(Subcommand::Features(_)) => Some("features"), } } @@ -1855,14 +2056,6 @@ fn reject_remote_mode_for_app_server_subcommand( reject_remote_mode_for_subcommand(remote, remote_auth_token_env, subcommand_name) } -fn remote_control_subcommand_name(command: &RemoteControlCommand) -> &'static str { - match command.subcommand { - None => "remote-control", - Some(RemoteControlSubcommand::Start) => "remote-control start", - Some(RemoteControlSubcommand::Stop) => "remote-control stop", - } -} - fn app_server_subcommand_name(subcommand: Option<&AppServerSubcommand>) -> &'static str { match subcommand { None => "app-server", @@ -1952,6 +2145,60 @@ async fn run_interactive_tui( } } + let remote_endpoint = match resolve_remote_endpoint(remote, remote_auth_token_env) { + Ok(remote_endpoint) => remote_endpoint, + Err(err) if is_remote_auth_usage_error(&err) => { + return Ok(AppExitInfo::fatal(err.to_string())); + } + Err(err) => return Err(err), + }; + let start_tui = || { + codex_tui::run_main( + interactive.clone(), + arg0_paths.clone(), + codex_config::LoaderOverrides::default(), + remote_endpoint.clone(), + ) + }; + let mut attempted_repair = false; + loop { + let err = match start_tui().await { + Ok(exit_info) => return Ok(exit_info), + Err(err) => err, + }; + let Some(startup_error) = local_state_db::startup_error(&err) else { + return Err(err); + }; + if local_state_db::is_locked(startup_error.detail()) { + local_state_db::print_locked_guidance(startup_error); + return Ok(AppExitInfo::fatal(startup_error.to_string())); + } + if attempted_repair { + local_state_db::print_diagnostic_guidance(startup_error); + return Ok(AppExitInfo::fatal(startup_error.to_string())); + } + if !local_state_db::confirm_repair(startup_error)? { + local_state_db::print_diagnostic_guidance(startup_error); + return Ok(AppExitInfo::fatal(startup_error.to_string())); + } + + match local_state_db::repair_files(startup_error).await { + Ok(backups) => local_state_db::print_repair_backups(&backups), + Err(repair_err) => { + local_state_db::print_diagnostic_guidance(startup_error); + return Ok(AppExitInfo::fatal(format!( + "failed to repair Codex local data automatically: {repair_err}" + ))); + } + } + attempted_repair = true; + } +} + +fn resolve_remote_endpoint( + remote: Option, + remote_auth_token_env: Option, +) -> std::io::Result> { let mut remote_endpoint = remote .as_deref() .map(codex_tui::resolve_remote_addr) @@ -1959,12 +2206,12 @@ async fn run_interactive_tui( .map_err(std::io::Error::other)?; if let Some(remote_auth_token_env) = remote_auth_token_env { let Some(endpoint) = remote_endpoint.as_mut() else { - return Ok(AppExitInfo::fatal( + return Err(std::io::Error::other( "`--remote-auth-token-env` requires `--remote`.", )); }; if !codex_tui::remote_addr_supports_auth_token(endpoint) { - return Ok(AppExitInfo::fatal( + return Err(std::io::Error::other( "`--remote-auth-token-env` requires a `wss://` or loopback `ws://` remote.", )); } @@ -1974,19 +2221,18 @@ async fn run_interactive_tui( auth_token: slot, .. } = endpoint else { - return Ok(AppExitInfo::fatal( + return Err(std::io::Error::other( "`--remote-auth-token-env` requires a `wss://` or loopback `ws://` remote.", )); }; *slot = Some(auth_token); } - codex_tui::run_main( - interactive, - arg0_paths, - codex_config::LoaderOverrides::default(), - remote_endpoint, - ) - .await + Ok(remote_endpoint) +} + +fn is_remote_auth_usage_error(err: &std::io::Error) -> bool { + err.to_string() + .starts_with("`--remote-auth-token-env` requires") } fn confirm(prompt: &str) -> std::io::Result { @@ -2052,9 +2298,31 @@ fn finalize_fork_interactive( interactive } -/// Merge flags provided to `codex resume`/`codex fork` so they take precedence over any -/// root-level flags. Only overrides fields explicitly set on the subcommand-scoped -/// CLI. Also appends `-c key=value` overrides with highest precedence. +fn finalize_session_archive_interactive( + mut interactive: TuiCli, + root_config_overrides: CliConfigOverrides, + archive_cli: SessionArchiveConfigOverrides, +) -> TuiCli { + let SessionArchiveConfigOverrides { + shared, + strict_config, + config_overrides, + } = archive_cli; + interactive.shared.apply_subcommand_overrides(shared); + if strict_config { + interactive.strict_config = true; + } + interactive + .config_overrides + .raw_overrides + .extend(config_overrides.raw_overrides); + prepend_config_flags(&mut interactive.config_overrides, root_config_overrides); + interactive +} + +/// Merge flags provided to runtime wrapper commands so they take precedence over any root-level +/// flags. Only overrides fields explicitly set on the subcommand-scoped CLI. Also appends +/// `-c key=value` overrides with highest precedence. fn merge_interactive_cli_flags(interactive: &mut TuiCli, subcommand_cli: TuiCli) { let TuiCli { shared, @@ -2102,6 +2370,63 @@ mod tests { use codex_tui::TokenUsage; use pretty_assertions::assert_eq; + #[test] + fn exec_server_remote_auth_accepts_api_key_auth() { + let auth = CodexAuth::from_api_key("sk-test"); + + assert!(is_supported_exec_server_remote_auth(&auth)); + } + + #[test] + fn exec_server_remote_api_key_auth_accepts_https_openai_domains() { + for base_url in [ + "https://openai.com/api", + "https://service.openai.com/api", + "https://openai.org/api", + "https://service.openai.org/api", + ] { + assert!(validate_api_key_remote_host(base_url).is_ok()); + } + } + + #[test] + fn exec_server_remote_api_key_auth_accepts_http_loopback() { + for base_url in [ + "http://localhost:8098/api", + "http://127.0.0.1:8098/api", + "http://[::1]:8098/api", + ] { + assert!(validate_api_key_remote_host(base_url).is_ok()); + } + } + + #[test] + fn exec_server_remote_api_key_auth_rejects_http_openai_domain() { + for base_url in [ + "http://service.openai.com/api", + "http://service.openai.org/api", + ] { + let error = validate_api_key_remote_host(base_url) + .expect_err("reject plaintext OpenAI destination"); + + assert_eq!( + error.to_string(), + "remote exec-server API-key authentication is restricted to HTTPS openai.com and openai.org hosts and subdomains or loopback hosts" + ); + } + } + + #[test] + fn exec_server_remote_api_key_auth_rejects_suffix_spoof() { + let error = validate_api_key_remote_host("https://service.openai.org.evil.example/api") + .expect_err("reject suffix spoof"); + + assert_eq!( + error.to_string(), + "remote exec-server API-key authentication is restricted to HTTPS openai.com and openai.org hosts and subdomains or loopback hosts" + ); + } + fn finalize_resume_from_args(args: &[&str]) -> TuiCli { let cli = MultitoolCli::try_parse_from(args).expect("parse"); let MultitoolCli { @@ -2159,6 +2484,32 @@ mod tests { finalize_fork_interactive(interactive, root_overrides, session_id, last, all, fork_cli) } + fn finalize_archive_from_args(args: &[&str]) -> (String, TuiCli, InteractiveRemoteOptions) { + let cli = MultitoolCli::try_parse_from(args).expect("parse"); + let MultitoolCli { + interactive, + config_overrides: root_overrides, + subcommand, + feature_toggles: _, + remote: _, + } = cli; + + let Subcommand::Archive(SessionArchiveCommand { + target, + remote, + config_overrides: archive_cli, + }) = subcommand.expect("archive present") + else { + unreachable!() + }; + + ( + target, + finalize_session_archive_interactive(interactive, root_overrides, archive_cli), + remote, + ) + } + fn profile_v2_for_args(args: &[&str]) -> anyhow::Result> { let cli = MultitoolCli::try_parse_from(args).expect("parse"); let Some(subcommand) = cli.subcommand.as_ref() else { @@ -2173,32 +2524,41 @@ mod tests { #[test] fn profile_v2_is_rejected_for_config_management_subcommands() { - assert!( - profile_v2_for_args(&["codex", "--profile-v2", "work", "features", "list"]).is_err() - ); + assert!(profile_v2_for_args(&["codex", "--profile", "work", "features", "list"]).is_err()); } #[test] fn profile_v2_is_allowed_for_runtime_subcommands() { assert_eq!( - profile_v2_for_args(&["codex", "--profile-v2", "work", "resume"]) + profile_v2_for_args(&["codex", "--profile", "work", "resume"]) .expect("resume supports profile-v2") .as_deref(), Some("work") ); assert_eq!( - profile_v2_for_args(&["codex", "--profile-v2", "work", "debug", "prompt-input"]) + profile_v2_for_args(&["codex", "--profile", "work", "debug", "prompt-input"]) .expect("debug prompt-input supports profile-v2") .as_deref(), Some("work") ); + assert_eq!( + profile_v2_for_args(&["codex", "--profile", "work", "mcp", "list"]) + .expect("mcp supports profile-v2") + .as_deref(), + Some("work") + ); + assert_eq!( + profile_v2_for_args(&["codex", "--profile", "work", "sandbox"]) + .expect("sandbox supports config profile") + .as_deref(), + Some("work") + ); } #[test] fn profile_v2_rejects_non_plain_names_at_parse_time() { assert!( - MultitoolCli::try_parse_from(["codex", "--profile-v2", "nested/work", "resume"]) - .is_err() + MultitoolCli::try_parse_from(["codex", "--profile", "nested/work", "resume"]).is_err() ); } @@ -2221,7 +2581,7 @@ mod tests { } #[test] - fn exec_resume_accepts_output_last_message_flag_after_subcommand() { + fn exec_resume_accepts_output_flags_after_subcommand() { let cli = MultitoolCli::try_parse_from([ "codex", "exec", @@ -2229,6 +2589,8 @@ mod tests { "session-123", "-o", "/tmp/resume-output.md", + "--output-schema", + "/tmp/schema.json", "re-review", ]) .expect("parse should succeed"); @@ -2244,6 +2606,10 @@ mod tests { exec.last_message_file, Some(std::path::PathBuf::from("/tmp/resume-output.md")) ); + assert_eq!( + exec.output_schema, + Some(std::path::PathBuf::from("/tmp/schema.json")) + ); assert_eq!(args.session_id.as_deref(), Some("session-123")); assert_eq!(args.prompt.as_deref(), Some("re-review")); } @@ -2415,11 +2781,46 @@ mod tests { } #[test] - fn sandbox_macos_parses_permissions_profile() { + fn archive_merges_scoped_tui_flags() { + let (target, interactive, remote) = finalize_archive_from_args( + [ + "codex", + "-C", + "/root", + "archive", + "--remote", + "unix://archive.sock", + "--strict-config", + "--dangerously-bypass-hook-trust", + "-m", + "gpt-5.1-test", + "-p", + "work", + "-C", + "/archive", + "my-thread", + ] + .as_ref(), + ); + + assert_eq!(target, "my-thread"); + assert_eq!(remote.remote.as_deref(), Some("unix://archive.sock")); + assert_eq!(interactive.model.as_deref(), Some("gpt-5.1-test")); + assert_eq!(interactive.config_profile_v2.as_deref(), Some("work")); + assert_eq!( + interactive.cwd.as_deref(), + Some(std::path::Path::new("/archive")) + ); + assert!(interactive.strict_config); + assert!(interactive.bypass_hook_trust); + } + + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] + #[test] + fn sandbox_parses_permissions_profile() { let cli = MultitoolCli::try_parse_from([ "codex", "sandbox", - "macos", "--permissions-profile", ":workspace", "--", @@ -2427,20 +2828,33 @@ mod tests { ]) .expect("parse"); - let Some(Subcommand::Sandbox(SandboxArgs { - cmd: SandboxCommand::Macos(command), - })) = cli.subcommand - else { - panic!("expected sandbox macos command"); + let Some(Subcommand::Sandbox(command)) = cli.subcommand else { + panic!("expected sandbox command"); }; assert_eq!(command.permissions_profile.as_deref(), Some(":workspace")); assert_eq!(command.command, vec!["echo"]); } + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] #[test] - fn sandbox_macos_rejects_explicit_profile_controls_without_profile() { - let err = MultitoolCli::try_parse_from(["codex", "sandbox", "macos", "-C", "/tmp"]) + fn sandbox_parses_config_profile() { + let cli = + MultitoolCli::try_parse_from(["codex", "sandbox", "--profile", "work", "--", "echo"]) + .expect("parse"); + + let Some(Subcommand::Sandbox(command)) = cli.subcommand else { + panic!("expected sandbox command"); + }; + + assert_eq!(command.config_profile.as_deref(), Some("work")); + assert_eq!(command.command, vec!["echo"]); + } + + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] + #[test] + fn sandbox_rejects_explicit_profile_controls_without_profile() { + let err = MultitoolCli::try_parse_from(["codex", "sandbox", "-C", "/tmp"]) .expect_err("parse should fail"); assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument); @@ -2493,8 +2907,7 @@ mod tests { #[test] fn sandbox_full_auto_no_longer_parses() { - let result = - MultitoolCli::try_parse_from(["codex", "sandbox", "linux", "--full-auto", "--"]); + let result = MultitoolCli::try_parse_from(["codex", "sandbox", "--full-auto", "--"]); assert!(result.is_err()); } @@ -2558,7 +2971,7 @@ mod tests { } #[test] - fn format_exit_messages_uses_id_even_when_thread_has_name() { + fn format_exit_messages_names_picker_item_when_thread_has_name() { let exit_info = sample_exit_info( Some("123e4567-e89b-12d3-a456-426614174000"), Some("my-thread"), @@ -2568,8 +2981,7 @@ mod tests { lines, vec![ "Token usage: total=2 input=0 output=2".to_string(), - "To continue this session, run codex resume 123e4567-e89b-12d3-a456-426614174000" - .to_string(), + "To continue this session, run codex resume, then select my-thread (123e4567-e89b-12d3-a456-426614174000)".to_string(), ] ); } @@ -2644,8 +3056,6 @@ mod tests { "-m", "gpt-5.1-test", "-p", - "my-profile", - "--profile-v2", "my-config", "-C", "/tmp", @@ -2658,7 +3068,6 @@ mod tests { assert_eq!(interactive.model.as_deref(), Some("gpt-5.1-test")); assert!(interactive.oss); - assert_eq!(interactive.config_profile.as_deref(), Some("my-profile")); assert_eq!(interactive.config_profile_v2.as_deref(), Some("my-config")); assert_matches!( interactive.sandbox_mode, @@ -2792,6 +3201,25 @@ mod tests { .. })) ); + + let cli = MultitoolCli::try_parse_from(["codex", "exec-server", "--strict-config"]) + .expect("parse"); + assert_matches!( + cli.subcommand, + Some(Subcommand::ExecServer(ExecServerCommand { + strict_config: true, + .. + })) + ); + } + + #[test] + fn root_strict_config_is_supported_for_exec_server() { + let cli = MultitoolCli::try_parse_from(["codex", "--strict-config", "exec-server"]) + .expect("parse"); + + reject_root_strict_config_for_subcommand(cli.interactive.strict_config, &cli.subcommand) + .expect("exec-server should support root --strict-config"); } #[test] @@ -2843,12 +3271,10 @@ mod tests { fn reject_remote_flag_for_remote_control() { let cli = MultitoolCli::try_parse_from(["codex", "--remote", "unix://", "remote-control"]) .expect("parse"); - assert_matches!( - cli.subcommand, - Some(Subcommand::RemoteControl(RemoteControlCommand { - subcommand: None - })) - ); + let Some(Subcommand::RemoteControl(remote_control)) = &cli.subcommand else { + panic!("expected remote-control subcommand"); + }; + assert_eq!(remote_control.subcommand_name(), "remote-control"); let err = reject_remote_mode_for_subcommand( cli.remote.remote.as_deref(), @@ -2990,6 +3416,25 @@ mod tests { ); } + #[test] + fn app_server_stdio_flag_parses() { + let app_server = app_server_from_args(["codex", "app-server", "--stdio"].as_ref()); + assert!(app_server.stdio); + } + + #[test] + fn app_server_stdio_flag_conflicts_with_listen() { + let err = MultitoolCli::try_parse_from([ + "codex", + "app-server", + "--stdio", + "--listen", + "stdio://", + ]) + .expect_err("--stdio and --listen should be rejected together"); + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); + } + #[test] fn app_server_listen_unix_socket_url_parses() { let app_server = diff --git a/codex-rs/cli/src/marketplace_cmd.rs b/codex-rs/cli/src/marketplace_cmd.rs index 2ec948eacb..95dba06f17 100644 --- a/codex-rs/cli/src/marketplace_cmd.rs +++ b/codex-rs/cli/src/marketplace_cmd.rs @@ -6,14 +6,15 @@ use codex_core::config::Config; use codex_core::config::find_codex_home; use codex_core_plugins::PluginMarketplaceUpgradeOutcome; use codex_core_plugins::PluginsManager; -use codex_core_plugins::installed_marketplaces::marketplace_install_root; -use codex_core_plugins::installed_marketplaces::resolve_configured_marketplace_root; +use codex_core_plugins::marketplace::marketplace_root_dir; use codex_core_plugins::marketplace_add::MarketplaceAddRequest; use codex_core_plugins::marketplace_add::add_marketplace; use codex_core_plugins::marketplace_remove::MarketplaceRemoveRequest; use codex_core_plugins::marketplace_remove::remove_marketplace; -use codex_plugin::validate_plugin_segment; use codex_utils_cli::CliConfigOverrides; +use std::collections::HashSet; + +use crate::plugin_cmd::configured_marketplace_snapshot_issues; #[derive(Debug, Parser)] #[command(bin_name = "codex plugin marketplace")] @@ -30,7 +31,7 @@ enum MarketplaceSubcommand { /// Add a local or Git marketplace to the configured marketplace sources. Add(AddMarketplaceArgs), - /// List configured marketplace names and their local snapshot roots. + /// List plugin marketplaces Codex is currently considering and their roots. List, /// Refresh configured Git marketplace snapshots. @@ -150,39 +151,77 @@ async fn run_list(overrides: Vec<(String, toml::Value)>) -> Result<()> { let config = Config::load_with_cli_overrides(overrides) .await .context("failed to load configuration")?; - let configured_marketplaces = config - .config_layer_stack - .get_active_user_layer() - .and_then(|layer| layer.config.get("marketplaces")) - .and_then(toml::Value::as_table); - let Some(configured_marketplaces) = configured_marketplaces else { - println!("No configured plugin marketplaces."); - return Ok(()); - }; - - if configured_marketplaces.is_empty() { - println!("No configured plugin marketplaces."); + let manager = PluginsManager::new(config.codex_home.to_path_buf()); + let plugins_input = config.plugins_config_input(); + let marketplace_listing = manager + .discover_marketplaces_for_config(&plugins_input, &[]) + .context("failed to list plugin marketplaces")?; + let mut load_issues = configured_marketplace_snapshot_issues( + config.codex_home.as_path(), + &plugins_input, + &marketplace_listing.errors, + /*marketplace_name*/ None, + ); + let mut issue_paths = load_issues + .iter() + .map(|issue| issue.path.clone()) + .collect::>(); + for error in &marketplace_listing.errors { + if issue_paths.insert(error.path.to_path_buf()) { + load_issues.push(crate::plugin_cmd::ConfiguredMarketplaceSnapshotIssue { + marketplace_name: error.path.display().to_string(), + path: error.path.to_path_buf(), + message: error.message.clone(), + }); + } + } + if !load_issues.is_empty() { + let issue_lines = load_issues + .iter() + .map(|issue| { + format!( + "- `{}` at {}: {}", + issue.marketplace_name, + issue.path.display(), + issue.message + ) + }) + .collect::>() + .join("\n"); + bail!("failed to load marketplace(s):\n{issue_lines}"); + } + let marketplaces = marketplace_listing.marketplaces; + if marketplaces.is_empty() { + println!("No plugin marketplaces in scope."); return Ok(()); } - let default_install_root = marketplace_install_root(config.codex_home.as_path()); - for (marketplace_name, marketplace) in configured_marketplaces { - if !marketplace.is_table() { - eprintln!("Ignoring invalid marketplace `{marketplace_name}`: expected table."); + let mut seen_roots = HashSet::new(); + let mut rows = Vec::new(); + for marketplace in marketplaces { + let Ok(root) = marketplace_root_dir(&marketplace.path) else { + continue; + }; + if !seen_roots.insert(root.clone()) { continue; } - if let Err(err) = validate_plugin_segment(marketplace_name, "marketplace name") { - eprintln!("Ignoring invalid marketplace `{marketplace_name}`: {err}."); - continue; - } - let root = resolve_configured_marketplace_root( + rows.push((marketplace.name, root)); + } + + let marketplace_width = rows + .iter() + .map(|(name, _)| name.len()) + .max() + .unwrap_or("MARKETPLACE".len()) + .max("MARKETPLACE".len()); + + println!("{:".to_string()); - println!("{marketplace_name}\t{root}"); + root.display() + ); } Ok(()) diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 5c66a27d84..0103782653 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -8,9 +8,12 @@ use anyhow::bail; use clap::ArgGroup; use codex_config::types::AppToolApproval; use codex_config::types::McpServerConfig; +use codex_config::types::McpServerOAuthConfig; use codex_config::types::McpServerTransportConfig; use codex_core::McpManager; use codex_core::config::Config; +use codex_core::config::ConfigBuilder; +use codex_core::config::LoaderOverrides; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config::find_codex_home; use codex_core::config::load_global_mcp_servers; @@ -132,6 +135,14 @@ pub struct AddMcpStreamableHttpArgs { requires = "url" )] pub bearer_token_env_var: Option, + + /// Optional OAuth client identifier to use for this MCP server. + #[arg(long = "oauth-client-id", value_name = "CLIENT_ID", requires = "url")] + pub oauth_client_id: Option, + + /// Optional OAuth resource parameter to include during MCP login. + #[arg(long = "oauth-resource", value_name = "RESOURCE", requires = "url")] + pub oauth_resource: Option, } #[derive(Debug, clap::Parser)] @@ -157,12 +168,16 @@ pub struct LogoutArgs { } impl McpCli { - pub async fn run(self) -> Result<()> { + pub async fn run(self, loader_overrides: LoaderOverrides) -> Result<()> { let McpCli { config_overrides, subcommand, } = self; + if loader_overrides.user_config_profile.is_some() { + validate_profile_v2_migration(&config_overrides, loader_overrides).await?; + } + match subcommand { McpSubcommand::List(args) => { run_list(&config_overrides, args).await?; @@ -239,6 +254,22 @@ async fn perform_oauth_login_retry_without_scopes( } } +async fn validate_profile_v2_migration( + config_overrides: &CliConfigOverrides, + loader_overrides: LoaderOverrides, +) -> Result<()> { + let overrides = config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + ConfigBuilder::default() + .cli_overrides(overrides) + .loader_overrides(loader_overrides) + .build() + .await + .context("failed to load configuration")?; + Ok(()) +} + async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Result<()> { // Validate any provided overrides even though they are not currently applied. let overrides = config_overrides @@ -260,7 +291,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re .await .with_context(|| format!("failed to load MCP servers from {}", codex_home.display()))?; - let transport = match transport_args { + let (transport, oauth_client_id, oauth_resource) = match transport_args { AddMcpTransportArgs { stdio: Some(stdio), .. } => { @@ -275,33 +306,43 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re } else { Some(stdio.env.into_iter().collect::>()) }; - McpServerTransportConfig::Stdio { - command: command_bin, - args: command_args, - env: env_map, - env_vars: Vec::new(), - cwd: None, - } + ( + McpServerTransportConfig::Stdio { + command: command_bin, + args: command_args, + env: env_map, + env_vars: Vec::new(), + cwd: None, + }, + None, + None, + ) } AddMcpTransportArgs { streamable_http: Some(AddMcpStreamableHttpArgs { url, bearer_token_env_var, + oauth_client_id, + oauth_resource, }), .. - } => McpServerTransportConfig::StreamableHttp { - url, - bearer_token_env_var, - http_headers: None, - env_http_headers: None, - }, + } => ( + McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var, + http_headers: None, + env_http_headers: None, + }, + oauth_client_id, + oauth_resource, + ), AddMcpTransportArgs { .. } => bail!("exactly one of --command or --url must be provided"), }; let new_entry = McpServerConfig { transport: transport.clone(), - experimental_environment: None, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, @@ -312,8 +353,12 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re enabled_tools: None, disabled_tools: None, scopes: None, - oauth: None, - oauth_resource: None, + oauth: oauth_client_id + .clone() + .map(|client_id| McpServerOAuthConfig { + client_id: Some(client_id), + }), + oauth_resource: oauth_resource.clone(), tools: HashMap::new(), }; @@ -342,8 +387,8 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re oauth_config.http_headers, oauth_config.env_http_headers, &resolved_scopes, - /*oauth_client_id*/ None, - /*oauth_resource*/ None, + oauth_client_id.as_deref(), + oauth_resource.as_deref(), config.mcp_oauth_callback_port, config.mcp_oauth_callback_url.as_deref(), ) @@ -492,7 +537,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> let effective_mcp_servers = mcp_manager.effective_servers(&config, /*auth*/ None).await; let mut entries: Vec<_> = mcp_servers.iter().collect(); - entries.sort_by(|(a, _), (b, _)| a.cmp(b)); + entries.sort_by_key(|(name, _)| *name); let auth_statuses = compute_auth_statuses( effective_mcp_servers.iter(), config.mcp_oauth_credentials_store_mode, @@ -861,7 +906,7 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re let headers_display = match http_headers { Some(map) if !map.is_empty() => { let mut pairs: Vec<_> = map.iter().collect(); - pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs.sort_by_key(|(name, _)| *name); pairs .into_iter() .map(|(k, _)| format!("{k}=*****")) @@ -874,7 +919,7 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re let env_headers_display = match env_http_headers { Some(map) if !map.is_empty() => { let mut pairs: Vec<_> = map.iter().collect(); - pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs.sort_by_key(|(name, _)| *name); pairs .into_iter() .map(|(k, var)| format!("{k}={var}")) diff --git a/codex-rs/cli/src/plugin_cmd.rs b/codex-rs/cli/src/plugin_cmd.rs index 593fe644b2..f8c559e506 100644 --- a/codex-rs/cli/src/plugin_cmd.rs +++ b/codex-rs/cli/src/plugin_cmd.rs @@ -5,6 +5,7 @@ use clap::Parser; use codex_core::config::Config; use codex_core::config::find_codex_home; use codex_core_plugins::ConfiguredMarketplace; +use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME; use codex_core_plugins::PluginInstallRequest; use codex_core_plugins::PluginsConfigInput; use codex_core_plugins::PluginsManager; @@ -15,10 +16,14 @@ use codex_core_plugins::marketplace::find_marketplace_manifest_path; use codex_plugin::PluginId; use codex_plugin::validate_plugin_segment; use codex_utils_cli::CliConfigOverrides; +use std::path::Path; use std::path::PathBuf; use crate::marketplace_cmd::MarketplaceCli; +const OPENAI_BUNDLED_ALPHA_MARKETPLACE_NAME: &str = "openai-bundled-alpha"; +const OPENAI_PRIMARY_RUNTIME_MARKETPLACE_NAME: &str = "openai-primary-runtime"; + #[derive(Debug, Parser)] #[command(bin_name = "codex plugin")] pub struct PluginCli { @@ -168,9 +173,13 @@ pub async fn run_plugin_list( println!("No marketplace plugins found."); } } else { - for marketplace in marketplaces { - println!("Marketplace `{}`", marketplace.name); - println!("Path: {}", marketplace.path.as_path().display()); + for (index, marketplace) in marketplaces.into_iter().enumerate() { + let mut rows = Vec::new(); + let mut plugin_width = "PLUGIN".len(); + let mut status_width = "STATUS".len(); + let mut installed_version_width = "VERSION".len(); + let mut path_width = "PATH".len(); + for plugin in &marketplace.plugins { let state = if plugin.installed && plugin.enabled { "installed, enabled" @@ -179,7 +188,51 @@ pub async fn run_plugin_list( } else { "not installed" }; - println!(" {} ({state})", plugin.id); + let installed_version = plugin.installed_version.clone().unwrap_or_default(); + let path = match &plugin.source { + codex_core_plugins::marketplace::MarketplacePluginSource::Local { path } => { + path.as_path().display().to_string() + } + codex_core_plugins::marketplace::MarketplacePluginSource::Git { + url, + path, + ref_name, + sha, + } => { + let mut parts = vec![url.clone()]; + if let Some(path) = path { + parts.push(format!("path `{path}`")); + } + if let Some(ref_name) = ref_name { + parts.push(format!("ref `{ref_name}`")); + } + if let Some(sha) = sha { + parts.push(format!("sha `{sha}`")); + } + parts.join(", ") + } + }; + plugin_width = plugin_width.max(plugin.id.len()); + status_width = status_width.max(state.len()); + installed_version_width = installed_version_width.max(installed_version.len()); + path_width = path_width.max(path.len()); + rows.push((plugin.id.clone(), state, installed_version, path)); + } + + if index > 0 { + println!(); + } + println!("Marketplace `{}`", marketplace.name); + println!("{}", marketplace.path.as_path().display()); + println!(); + println!( + "{:, ) -> Vec { - let Some(user_layer) = plugins_input.config_layer_stack.get_active_user_layer() else { + let Some(user_config) = plugins_input.config_layer_stack.effective_user_config() else { return Vec::new(); }; - let Some(configured_marketplaces) = user_layer - .config + let Some(configured_marketplaces) = user_config .get("marketplaces") .and_then(toml::Value::as_table) else { @@ -367,9 +419,33 @@ fn configured_marketplace_snapshot_issues( if marketplace_name.is_some_and(|name| configured_name != name) { continue; } - if !marketplace.is_table() - || validate_plugin_segment(configured_name, "marketplace name").is_err() + if !marketplace.is_table() { + issues.push(ConfiguredMarketplaceSnapshotIssue { + marketplace_name: configured_name.clone(), + path: PathBuf::from(""), + message: "configured marketplace entry must be a table".to_string(), + }); + continue; + } + if let Err(err) = validate_plugin_segment(configured_name, "marketplace name") { + issues.push(ConfiguredMarketplaceSnapshotIssue { + marketplace_name: configured_name.clone(), + path: PathBuf::from(""), + message: err.to_string(), + }); + continue; + } + if marketplace.get("source_type").and_then(toml::Value::as_str) == Some("local") + && marketplace + .get("source") + .and_then(toml::Value::as_str) + .is_none_or(str::is_empty) { + issues.push(ConfiguredMarketplaceSnapshotIssue { + marketplace_name: configured_name.clone(), + path: PathBuf::from(""), + message: "configured local marketplace source is missing or empty".to_string(), + }); continue; } let Some(root) = resolve_configured_marketplace_root( @@ -381,11 +457,16 @@ fn configured_marketplace_snapshot_issues( }; match find_marketplace_manifest_path(&root) { Some(path) => manifest_paths.push((configured_name.clone(), path)), - None => issues.push(ConfiguredMarketplaceSnapshotIssue { - marketplace_name: configured_name.clone(), - path: root, - message: "marketplace root does not contain a supported manifest".to_string(), - }), + None => { + if is_implicit_system_marketplace_root(configured_name, codex_home, &root) { + continue; + } + issues.push(ConfiguredMarketplaceSnapshotIssue { + marketplace_name: configured_name.clone(), + path: root, + message: "marketplace root does not contain a supported manifest".to_string(), + }); + } } } @@ -403,3 +484,41 @@ fn configured_marketplace_snapshot_issues( } issues } + +fn is_implicit_system_marketplace_root( + marketplace_name: &str, + _codex_home: &Path, + root: &Path, +) -> bool { + if matches!( + marketplace_name, + OPENAI_BUNDLED_MARKETPLACE_NAME | OPENAI_BUNDLED_ALPHA_MARKETPLACE_NAME + ) && path_ends_with(root, &[".tmp", "bundled-marketplaces", marketplace_name]) + { + return true; + } + + marketplace_name == OPENAI_PRIMARY_RUNTIME_MARKETPLACE_NAME + && path_ends_with( + root, + &[ + "codex-runtimes", + "codex-primary-runtime", + "plugins", + marketplace_name, + ], + ) +} + +fn path_ends_with(path: &Path, suffix: &[&str]) -> bool { + let path_components = path + .components() + .map(|component| component.as_os_str().to_string_lossy().into_owned()) + .collect::>(); + path_components.as_slice().ends_with( + &suffix + .iter() + .map(std::string::ToString::to_string) + .collect::>(), + ) +} diff --git a/codex-rs/cli/src/remote_control_cmd.rs b/codex-rs/cli/src/remote_control_cmd.rs new file mode 100644 index 0000000000..1ff8bd7595 --- /dev/null +++ b/codex-rs/cli/src/remote_control_cmd.rs @@ -0,0 +1,695 @@ +use std::io::Write; +use std::time::Duration; + +use anyhow::Context; +use clap::Args; +use codex_app_server::AppServerRuntimeOptions; +use codex_app_server::AppServerTransport; +use codex_app_server::AppServerWebsocketAuthSettings; +use codex_app_server_daemon::LifecycleCommand as AppServerLifecycleCommand; +use codex_app_server_daemon::LifecycleOutput as AppServerLifecycleOutput; +use codex_app_server_daemon::LifecycleStatus as AppServerLifecycleStatus; +use codex_app_server_daemon::RemoteControlReadyOutput as AppServerRemoteControlReadyOutput; +use codex_app_server_daemon::RemoteControlReadyStatus as AppServerRemoteControlReadyStatus; +use codex_app_server_daemon::RemoteControlStartOutput as AppServerRemoteControlStartOutput; +use codex_app_server_protocol::RemoteControlConnectionStatus; +use codex_arg0::Arg0DispatchPaths; +use codex_config::LoaderOverrides; +use codex_protocol::protocol::SessionSource; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_cli::CliConfigOverrides; +use serde::Serialize; +use tokio::sync::watch; +use tokio::task::JoinHandle; +use tokio::time::timeout; + +const FOREGROUND_SOCKET_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const FOREGROUND_SOCKET_CONNECT_RETRY_DELAY: Duration = Duration::from_millis(50); +const FOREGROUND_APP_SERVER_ABORT_TIMEOUT: Duration = Duration::from_secs(1); + +#[derive(Debug, Args)] +pub(crate) struct RemoteControlCommand { + /// Emit machine-readable JSON. + #[arg(long = "json", global = true)] + json: bool, + + #[command(subcommand)] + subcommand: Option, +} + +impl RemoteControlCommand { + pub(crate) fn subcommand_name(&self) -> &'static str { + match self.subcommand { + None => "remote-control", + Some(RemoteControlSubcommand::Start) => "remote-control start", + Some(RemoteControlSubcommand::Stop) => "remote-control stop", + } + } +} + +#[derive(Debug, Clone, Copy, clap::Subcommand)] +enum RemoteControlSubcommand { + /// Start the app-server daemon with remote control enabled. + Start, + + /// Stop the app-server daemon. + Stop, +} + +pub(crate) async fn run( + command: RemoteControlCommand, + arg0_paths: Arg0DispatchPaths, + root_config_overrides: CliConfigOverrides, +) -> anyhow::Result<()> { + match command.subcommand { + None => { + print_remote_control_progress( + command.json, + "Starting app-server with remote control enabled...", + )?; + run_foreground_remote_control(command.json, arg0_paths, root_config_overrides).await?; + } + Some(RemoteControlSubcommand::Start) => { + print_remote_control_progress( + command.json, + "Starting app-server daemon with remote control enabled...", + )?; + let output = codex_app_server_daemon::ensure_remote_control_ready().await?; + print_remote_control_start_output(&output, command.json)?; + } + Some(RemoteControlSubcommand::Stop) => { + print_remote_control_progress(command.json, "Stopping remote control...")?; + let output = codex_app_server_daemon::run(AppServerLifecycleCommand::Stop).await?; + print_remote_control_stop_output(&output, command.json)?; + } + } + Ok(()) +} + +fn print_remote_control_progress(json: bool, message: &str) -> anyhow::Result<()> { + if json { + return Ok(()); + } + + println!("{message}"); + std::io::stdout() + .flush() + .context("failed to flush remote-control progress message")?; + Ok(()) +} + +async fn run_foreground_remote_control( + json: bool, + arg0_paths: Arg0DispatchPaths, + root_config_overrides: CliConfigOverrides, +) -> anyhow::Result<()> { + let socket_dir = tempfile::Builder::new() + .prefix("codex-rc-") + .tempdir_in("/tmp") + .or_else(|_| tempfile::tempdir()) + .context("failed to create private app-server socket directory")?; + let socket_path = socket_dir.path().join("rc.sock"); + let socket_path = AbsolutePathBuf::from_absolute_path(&socket_path) + .context("private app-server socket path was not absolute")?; + let transport = AppServerTransport::UnixSocket { + socket_path: socket_path.clone(), + }; + let runtime_options = AppServerRuntimeOptions { + remote_control_enabled: true, + install_shutdown_signal_handler: false, + ..Default::default() + }; + let (stop_rx, stop_signal_task) = foreground_stop_signal(); + let mut app_server_task = tokio::spawn(codex_app_server::run_main_with_transport_options( + arg0_paths, + root_config_overrides, + LoaderOverrides::default(), + /*strict_config*/ false, + /*default_analytics_enabled*/ false, + transport, + SessionSource::VSCode, + AppServerWebsocketAuthSettings::default(), + runtime_options, + )); + + let summary = match wait_for_foreground_remote_control_start( + &mut app_server_task, + wait_for_foreground_remote_control_ready(socket_path), + stop_rx.clone(), + ) + .await + { + ForegroundStartupResult::Ready(summary) => summary, + ForegroundStartupResult::Stopped => { + abort_foreground_app_server(app_server_task).await; + stop_signal_task.abort(); + return Ok(()); + } + ForegroundStartupResult::ReadyFailed(error) => { + abort_foreground_app_server(app_server_task).await; + stop_signal_task.abort(); + return Err(error); + } + ForegroundStartupResult::AppServerExited(error) => { + stop_signal_task.abort(); + return Err(error); + } + }; + + if *stop_rx.borrow() { + abort_foreground_app_server(app_server_task).await; + stop_signal_task.abort(); + return Ok(()); + } + + if let Err(error) = print_foreground_ready_output(&summary, json) { + abort_foreground_app_server(app_server_task).await; + stop_signal_task.abort(); + return Err(error); + } + + let result = wait_for_foreground_app_server(app_server_task, stop_rx).await; + stop_signal_task.abort(); + result +} + +fn foreground_stop_signal() -> (watch::Receiver, JoinHandle<()>) { + let (stop_tx, stop_rx) = watch::channel(false); + let task = tokio::spawn(async move { + if let Err(err) = tokio::signal::ctrl_c().await { + eprintln!("failed to listen for Ctrl-C: {err}"); + } + let _ = stop_tx.send(true); + }); + (stop_rx, task) +} + +enum ForegroundStartupResult { + Ready(AppServerRemoteControlReadyStatus), + Stopped, + ReadyFailed(anyhow::Error), + AppServerExited(anyhow::Error), +} + +async fn wait_for_foreground_remote_control_start( + app_server_task: &mut JoinHandle>, + ready: impl std::future::Future>, + mut stop_rx: watch::Receiver, +) -> ForegroundStartupResult { + tokio::pin!(ready); + + tokio::select! { + ready_result = &mut ready => match ready_result { + Ok(summary) => ForegroundStartupResult::Ready(summary), + Err(error) => ForegroundStartupResult::ReadyFailed(error), + }, + app_server_result = app_server_task => { + ForegroundStartupResult::AppServerExited( + foreground_app_server_exited_before_ready(app_server_result) + ) + } + _ = wait_for_stop_signal(&mut stop_rx) => ForegroundStartupResult::Stopped, + } +} + +async fn wait_for_foreground_app_server( + mut app_server_task: JoinHandle>, + mut stop_rx: watch::Receiver, +) -> anyhow::Result<()> { + tokio::select! { + app_server_result = &mut app_server_task => { + app_server_result + .context("foreground app-server task failed to join")? + .context("foreground app-server exited with an error")?; + } + _ = wait_for_stop_signal(&mut stop_rx) => { + abort_foreground_app_server(app_server_task).await; + } + } + + Ok(()) +} + +async fn wait_for_stop_signal(stop_rx: &mut watch::Receiver) { + if *stop_rx.borrow() { + return; + } + let _ = stop_rx.wait_for(|stopped| *stopped).await; +} + +fn foreground_app_server_exited_before_ready( + result: Result, tokio::task::JoinError>, +) -> anyhow::Error { + match result { + Ok(Ok(())) => { + anyhow::anyhow!("foreground app-server exited before remote control became ready") + } + Ok(Err(error)) => anyhow::Error::new(error) + .context("foreground app-server exited before remote control became ready"), + Err(error) => anyhow::Error::new(error) + .context("foreground app-server task failed before remote control became ready"), + } +} + +async fn abort_foreground_app_server(app_server_task: JoinHandle>) { + app_server_task.abort(); + let _ = timeout(FOREGROUND_APP_SERVER_ABORT_TIMEOUT, app_server_task).await; +} + +async fn wait_for_foreground_remote_control_ready( + socket_path: AbsolutePathBuf, +) -> anyhow::Result { + codex_app_server_daemon::enable_remote_control_on_socket( + socket_path.as_path(), + FOREGROUND_SOCKET_CONNECT_TIMEOUT, + FOREGROUND_SOCKET_CONNECT_RETRY_DELAY, + ) + .await +} + +fn print_remote_control_start_output( + output: &AppServerRemoteControlReadyOutput, + json: bool, +) -> anyhow::Result<()> { + ensure_remote_control_startable(&output.remote_control)?; + if json { + println!( + "{}", + serde_json::to_string(&RemoteControlStartJsonOutput::daemon(output))? + ); + return Ok(()); + } + + for line in remote_control_start_human_lines( + &output.remote_control, + RemoteControlHumanOutputMode::Daemon, + )? { + println!("{line}"); + } + for line in daemon_app_server_human_lines(&output.daemon) { + println!("{line}"); + } + Ok(()) +} + +fn print_foreground_ready_output( + summary: &AppServerRemoteControlReadyStatus, + json: bool, +) -> anyhow::Result<()> { + if json { + ensure_remote_control_startable(summary)?; + println!( + "{}", + serde_json::to_string(&RemoteControlStartJsonOutput::foreground(summary))? + ); + return Ok(()); + } + + for line in remote_control_start_human_lines(summary, RemoteControlHumanOutputMode::Foreground)? + { + println!("{line}"); + } + Ok(()) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct RemoteControlStartJsonOutput<'a> { + mode: RemoteControlModeJson, + status: RemoteControlConnectionStatus, + server_name: &'a str, + environment_id: Option<&'a str>, + timed_out: bool, + #[serde(skip_serializing_if = "Option::is_none")] + daemon: Option<&'a AppServerRemoteControlStartOutput>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +enum RemoteControlModeJson { + Foreground, + Daemon, +} + +impl<'a> RemoteControlStartJsonOutput<'a> { + fn foreground(summary: &'a AppServerRemoteControlReadyStatus) -> Self { + Self { + mode: RemoteControlModeJson::Foreground, + status: summary.status, + server_name: &summary.server_name, + environment_id: summary.environment_id.as_deref(), + timed_out: summary.timed_out, + daemon: None, + } + } + + fn daemon(output: &'a AppServerRemoteControlReadyOutput) -> Self { + let remote_control = &output.remote_control; + Self { + mode: RemoteControlModeJson::Daemon, + status: remote_control.status, + server_name: &remote_control.server_name, + environment_id: remote_control.environment_id.as_deref(), + timed_out: remote_control.timed_out, + daemon: Some(&output.daemon), + } + } +} + +fn remote_control_start_human_message( + output: &AppServerRemoteControlReadyStatus, +) -> anyhow::Result { + ensure_remote_control_startable(output)?; + match output.status { + RemoteControlConnectionStatus::Connected => Ok(format!( + "This machine is available for remote control as {}.", + output.server_name + )), + RemoteControlConnectionStatus::Connecting => Ok(format!( + "Remote control is enabled on {} and still connecting.", + output.server_name + )), + RemoteControlConnectionStatus::Errored | RemoteControlConnectionStatus::Disabled => { + unreachable!("errored and disabled statuses are rejected before formatting") + } + } +} + +fn ensure_remote_control_startable( + output: &AppServerRemoteControlReadyStatus, +) -> anyhow::Result<()> { + match output.status { + RemoteControlConnectionStatus::Connected | RemoteControlConnectionStatus::Connecting => { + Ok(()) + } + RemoteControlConnectionStatus::Errored => { + anyhow::bail!( + "Remote control is enabled on {} but the connection is errored.", + output.server_name + ); + } + RemoteControlConnectionStatus::Disabled => { + anyhow::bail!("Remote control is disabled on {}.", output.server_name); + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RemoteControlHumanOutputMode { + Foreground, + Daemon, +} + +fn remote_control_start_human_lines( + summary: &AppServerRemoteControlReadyStatus, + mode: RemoteControlHumanOutputMode, +) -> anyhow::Result> { + let mut lines = vec![remote_control_start_human_message(summary)?]; + match mode { + RemoteControlHumanOutputMode::Foreground => { + lines.push("Press Ctrl-C to stop.".to_string()); + } + RemoteControlHumanOutputMode::Daemon => {} + } + Ok(lines) +} + +fn daemon_app_server_human_lines(output: &AppServerRemoteControlStartOutput) -> Vec { + let (managed_codex_path, managed_codex_version) = daemon_app_server_identity(output); + vec![ + "Daemon used app-server:".to_string(), + format!(" path: {}", managed_codex_path.display()), + format!(" version: {}", managed_codex_version.unwrap_or("unknown")), + ] +} + +fn daemon_app_server_identity( + output: &AppServerRemoteControlStartOutput, +) -> (&std::path::Path, Option<&str>) { + match output { + AppServerRemoteControlStartOutput::Bootstrap(output) => ( + &output.managed_codex_path, + output.managed_codex_version.as_deref(), + ), + AppServerRemoteControlStartOutput::Start(output) => ( + &output.managed_codex_path, + output.managed_codex_version.as_deref(), + ), + } +} + +fn print_remote_control_stop_output( + output: &AppServerLifecycleOutput, + json: bool, +) -> anyhow::Result<()> { + if json { + println!("{}", serde_json::to_string(output)?); + return Ok(()); + } + + println!("{}", remote_control_stop_human_message(output)); + Ok(()) +} + +fn remote_control_stop_human_message(output: &AppServerLifecycleOutput) -> String { + match output.status { + AppServerLifecycleStatus::Stopped => "Remote control stopped.".to_string(), + AppServerLifecycleStatus::NotRunning => "Remote control is not running.".to_string(), + AppServerLifecycleStatus::Started + | AppServerLifecycleStatus::Restarted + | AppServerLifecycleStatus::AlreadyRunning + | AppServerLifecycleStatus::Running => { + format!( + "Remote control stop completed with status {:?}.", + output.status + ) + } + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use serde_json::json; + use std::path::PathBuf; + + use super::*; + + fn remote_control_status( + status: RemoteControlConnectionStatus, + ) -> AppServerRemoteControlReadyStatus { + AppServerRemoteControlReadyStatus { + status, + server_name: "owen-mbp".to_string(), + environment_id: Some("env_test".to_string()), + timed_out: status == RemoteControlConnectionStatus::Connecting, + } + } + + fn daemon_ready_output( + status: RemoteControlConnectionStatus, + ) -> AppServerRemoteControlReadyOutput { + AppServerRemoteControlReadyOutput { + daemon: AppServerRemoteControlStartOutput::Start(AppServerLifecycleOutput { + status: AppServerLifecycleStatus::Started, + backend: None, + pid: Some(42), + managed_codex_path: PathBuf::from("/opt/codex/bin/codex"), + managed_codex_version: Some("1.0.0".to_string()), + socket_path: PathBuf::from("/tmp/app-server-control.sock"), + cli_version: Some("1.0.0".to_string()), + app_server_version: Some("2.0.0".to_string()), + }), + remote_control: AppServerRemoteControlReadyStatus { + status, + server_name: "owen-mbp".to_string(), + environment_id: Some("env_test".to_string()), + timed_out: status == RemoteControlConnectionStatus::Connecting, + }, + } + } + + #[test] + fn remote_control_human_start_messages_use_server_name() { + assert_eq!( + remote_control_start_human_message(&remote_control_status( + RemoteControlConnectionStatus::Connected + )) + .expect("connected message"), + "This machine is available for remote control as owen-mbp." + ); + assert_eq!( + remote_control_start_human_message(&remote_control_status( + RemoteControlConnectionStatus::Connecting + )) + .expect("connecting message"), + "Remote control is enabled on owen-mbp and still connecting." + ); + assert_eq!( + remote_control_start_human_message(&remote_control_status( + RemoteControlConnectionStatus::Errored + )) + .expect_err("errored status should fail") + .to_string(), + "Remote control is enabled on owen-mbp but the connection is errored." + ); + assert_eq!( + remote_control_start_human_message(&remote_control_status( + RemoteControlConnectionStatus::Disabled + )) + .expect_err("disabled status should fail") + .to_string(), + "Remote control is disabled on owen-mbp." + ); + } + + #[test] + fn remote_control_human_lines_include_foreground_stop_hint_only() { + let summary = remote_control_status(RemoteControlConnectionStatus::Connected); + + assert_eq!( + remote_control_start_human_lines(&summary, RemoteControlHumanOutputMode::Foreground) + .expect("foreground lines"), + vec![ + "This machine is available for remote control as owen-mbp.".to_string(), + "Press Ctrl-C to stop.".to_string(), + ] + ); + assert_eq!( + remote_control_start_human_lines(&summary, RemoteControlHumanOutputMode::Daemon) + .expect("daemon lines"), + vec!["This machine is available for remote control as owen-mbp.".to_string()] + ); + } + + #[test] + fn daemon_app_server_human_lines_include_path_and_version() { + assert_eq!( + daemon_app_server_human_lines( + &daemon_ready_output(RemoteControlConnectionStatus::Connected).daemon + ), + vec![ + "Daemon used app-server:".to_string(), + " path: /opt/codex/bin/codex".to_string(), + " version: 1.0.0".to_string(), + ] + ); + } + + #[test] + fn remote_control_json_output_marks_foreground_or_daemon() { + let foreground_summary = remote_control_status(RemoteControlConnectionStatus::Connected); + assert_eq!( + serde_json::to_value(RemoteControlStartJsonOutput::foreground( + &foreground_summary + )) + .expect("foreground JSON"), + json!({ + "mode": "foreground", + "status": "connected", + "serverName": "owen-mbp", + "environmentId": "env_test", + "timedOut": false, + }) + ); + + let daemon_output = daemon_ready_output(RemoteControlConnectionStatus::Connected); + assert_eq!( + serde_json::to_value(RemoteControlStartJsonOutput::daemon(&daemon_output)) + .expect("daemon JSON"), + json!({ + "mode": "daemon", + "status": "connected", + "serverName": "owen-mbp", + "environmentId": "env_test", + "timedOut": false, + "daemon": { + "status": "started", + "pid": 42, + "managedCodexPath": "/opt/codex/bin/codex", + "managedCodexVersion": "1.0.0", + "socketPath": "/tmp/app-server-control.sock", + "cliVersion": "1.0.0", + "appServerVersion": "2.0.0", + }, + }) + ); + } + + #[test] + fn remote_control_daemon_json_rejects_unstartable_status() { + assert_eq!( + print_remote_control_start_output( + &daemon_ready_output(RemoteControlConnectionStatus::Errored), + /*json*/ true + ) + .expect_err("errored daemon status should fail") + .to_string(), + "Remote control is enabled on owen-mbp but the connection is errored." + ); + } + + #[tokio::test] + async fn foreground_wait_aborts_app_server_on_stop_signal() { + let app_server_task = tokio::spawn(std::future::pending::>()); + let (stop_tx, stop_rx) = tokio::sync::watch::channel(false); + stop_tx.send(true).expect("send stop signal"); + + tokio::time::timeout( + std::time::Duration::from_secs(1), + wait_for_foreground_app_server(app_server_task, stop_rx), + ) + .await + .expect("foreground wait should return after stop signal") + .expect("stop signal should shut down cleanly"); + } + + #[tokio::test] + async fn foreground_start_wait_stops_before_ready() { + let mut app_server_task = tokio::spawn(std::future::pending::>()); + let (stop_tx, stop_rx) = tokio::sync::watch::channel(false); + stop_tx.send(true).expect("send stop signal"); + + let startup = tokio::time::timeout( + std::time::Duration::from_secs(1), + wait_for_foreground_remote_control_start( + &mut app_server_task, + std::future::pending::>(), + stop_rx, + ), + ) + .await + .expect("foreground startup wait should return after stop signal"); + + assert!(matches!(startup, ForegroundStartupResult::Stopped)); + app_server_task.abort(); + let _ = app_server_task.await; + } + + #[tokio::test] + async fn foreground_start_wait_reports_app_server_exit_before_ready() { + let mut app_server_task = + tokio::spawn(async { Err(std::io::Error::other("startup failed before socket bind")) }); + let (_stop_tx, stop_rx) = tokio::sync::watch::channel(false); + + let startup = tokio::time::timeout( + std::time::Duration::from_secs(1), + wait_for_foreground_remote_control_start( + &mut app_server_task, + std::future::pending::>(), + stop_rx, + ), + ) + .await + .expect("foreground startup wait should return after app-server exits"); + + let ForegroundStartupResult::AppServerExited(error) = startup else { + panic!("expected app-server exit before ready"); + }; + + assert_eq!( + error.to_string(), + "foreground app-server exited before remote control became ready" + ); + } +} diff --git a/codex-rs/cli/src/sandbox_setup.rs b/codex-rs/cli/src/sandbox_setup.rs new file mode 100644 index 0000000000..958411c428 --- /dev/null +++ b/codex-rs/cli/src/sandbox_setup.rs @@ -0,0 +1,206 @@ +use std::path::PathBuf; + +use clap::ArgAction; +use clap::ArgGroup; +use clap::Parser; +use codex_core::config::edit::ConfigEditsBuilder; +use codex_core::config::find_codex_home; + +#[derive(Debug, Parser)] +#[command(group( + ArgGroup::new("sandbox_user") + .required(true) + .args(["user", "current_user"]) +))] +pub(crate) struct SandboxSetupCommand { + /// Set up the elevated Windows sandbox. + #[arg(long = "elevated", action = ArgAction::SetTrue)] + elevated_sandbox_level: bool, + + /// Windows user that will run Codex after managed deployment. + #[arg( + long = "user", + value_name = "USER", + conflicts_with = "current_user", + requires = "codex_home" + )] + user: Option, + + /// Use the current Windows user as the Codex user. + #[arg( + long = "current-user", + default_value_t = false, + conflicts_with = "user" + )] + current_user: bool, + + /// CODEX_HOME for the Codex user. Required with --user. + #[arg(long = "codex-home", value_name = "DIR")] + codex_home: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SandboxSetupLevel { + Elevated, +} + +impl SandboxSetupCommand { + fn setup_level(&self) -> anyhow::Result { + if self.elevated_sandbox_level { + Ok(SandboxSetupLevel::Elevated) + } else { + anyhow::bail!("`codex sandbox setup` currently requires --elevated"); + } + } +} + +pub(crate) async fn run(cmd: SandboxSetupCommand) -> anyhow::Result<()> { + match cmd.setup_level()? { + SandboxSetupLevel::Elevated => run_elevated(cmd).await, + } +} + +pub(crate) fn parse_setup_command( + sandbox_command: &[String], +) -> anyhow::Result> { + if sandbox_command + .first() + .is_none_or(|command| command != "setup") + { + return Ok(None); + } + + SandboxSetupCommand::try_parse_from(sandbox_command.iter().map(String::as_str)) + .map(Some) + .map_err(anyhow::Error::from) +} + +async fn run_elevated(cmd: SandboxSetupCommand) -> anyhow::Result<()> { + let identity = resolve_sandbox_setup_identity(&cmd)?; + + codex_core::windows_sandbox::run_elevated_provisioning_setup( + identity.codex_home.as_path(), + identity.real_user.as_str(), + )?; + ConfigEditsBuilder::new(identity.codex_home.as_path()) + .set_windows_sandbox_mode("elevated") + .apply() + .await + .map_err(|err| { + anyhow::anyhow!( + "sandbox provisioning succeeded, but failed to persist elevated sandbox config: {err}" + ) + })?; + + println!( + "Windows elevated sandbox setup completed for {} at {}.", + identity.real_user, + identity.codex_home.display() + ); + Ok(()) +} + +struct SandboxSetupIdentity { + real_user: String, + codex_home: PathBuf, +} + +fn resolve_sandbox_setup_identity( + cmd: &SandboxSetupCommand, +) -> anyhow::Result { + if cmd.current_user { + let real_user = std::env::var("USERNAME") + .or_else(|_| std::env::var("USER")) + .map_err(|err| { + anyhow::anyhow!("failed to determine current user from environment: {err}") + })?; + let codex_home = match cmd.codex_home.clone() { + Some(codex_home) => codex_home, + None => find_codex_home()?.to_path_buf(), + }; + return Ok(SandboxSetupIdentity { + real_user, + codex_home, + }); + } + + let real_user = cmd + .user + .clone() + .ok_or_else(|| anyhow::anyhow!("--user or --current-user is required"))?; + let codex_home = cmd + .codex_home + .clone() + .ok_or_else(|| anyhow::anyhow!("--codex-home is required with --user"))?; + Ok(SandboxSetupIdentity { + real_user, + codex_home, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_managed_user_identity() { + let command = SandboxSetupCommand::try_parse_from([ + "setup", + "--elevated", + "--user", + "DOMAIN\\alice", + "--codex-home", + r"C:\Users\alice\.codex", + ]) + .expect("parse"); + + assert!(command.elevated_sandbox_level); + assert_eq!(command.user.as_deref(), Some(r"DOMAIN\alice")); + assert!(!command.current_user); + assert_eq!( + command.codex_home.as_deref(), + Some(std::path::Path::new(r"C:\Users\alice\.codex")) + ); + } + + #[test] + fn requires_explicit_user_identity() { + let err = SandboxSetupCommand::try_parse_from(["setup", "--elevated"]) + .expect_err("parse should fail"); + + assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument); + } + + #[test] + fn requires_codex_home_for_managed_user() { + let err = + SandboxSetupCommand::try_parse_from(["setup", "--elevated", "--user", "DOMAIN\\alice"]) + .expect_err("parse should fail"); + + assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument); + } + + #[test] + fn parses_setup_from_sandbox_command_args() { + let command = parse_setup_command(&[ + "setup".to_string(), + "--elevated".to_string(), + "--user".to_string(), + r"DOMAIN\alice".to_string(), + "--codex-home".to_string(), + r"C:\Users\alice\.codex".to_string(), + ]) + .expect("parse") + .expect("setup command"); + + assert_eq!(command.user.as_deref(), Some(r"DOMAIN\alice")); + } + + #[test] + fn ignores_non_setup_sandbox_command_args() { + let command = + parse_setup_command(&["echo".to_string(), "hello".to_string()]).expect("parse"); + + assert!(command.is_none()); + } +} diff --git a/codex-rs/cli/src/state_db_recovery.rs b/codex-rs/cli/src/state_db_recovery.rs new file mode 100644 index 0000000000..7aeffaca3a --- /dev/null +++ b/codex-rs/cli/src/state_db_recovery.rs @@ -0,0 +1,185 @@ +//! CLI recovery for local state database startup failures. +//! +//! This keeps user-facing repair and lock-contention handling out of the main +//! CLI dispatch path while preserving the TUI startup error as the boundary type. + +use codex_tui::LocalStateDbStartupError; +use std::path::PathBuf; + +pub(crate) fn startup_error(err: &std::io::Error) -> Option<&LocalStateDbStartupError> { + err.get_ref() + .and_then(|err| err.downcast_ref::()) +} + +pub(crate) fn is_locked(detail: &str) -> bool { + let detail = detail.to_ascii_lowercase(); + detail.contains("database is locked") || detail.contains("database is busy") +} + +pub(crate) fn confirm_repair(startup_error: &LocalStateDbStartupError) -> std::io::Result { + eprintln!("Codex couldn't start because its local database appears to be damaged."); + eprintln!("Codex can try a safe repair by backing up those files and rebuilding them."); + print_technical_details(startup_error); + crate::confirm("Repair Codex local data now? [y/N]: ") +} + +pub(crate) async fn repair_files( + startup_error: &LocalStateDbStartupError, +) -> std::io::Result> { + let state_db_path = startup_error.state_db_path(); + let sqlite_home = state_db_path.parent().ok_or_else(|| { + std::io::Error::other("state database path does not have a parent directory") + })?; + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |duration| duration.as_secs()); + let repair_suffix = format!("codex-repair-{timestamp}"); + let mut backups = Vec::new(); + + match tokio::fs::metadata(sqlite_home).await { + Ok(metadata) if metadata.is_dir() => {} + Ok(_) => { + backups.push(backup_path(sqlite_home, &repair_suffix).await?); + tokio::fs::create_dir_all(sqlite_home).await?; + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + tokio::fs::create_dir_all(sqlite_home).await?; + } + Err(err) => return Err(err), + } + + for path in codex_state::runtime_db_paths(sqlite_home) + .into_iter() + .flat_map(|db| sqlite_paths(db.path.as_path())) + { + if tokio::fs::try_exists(path.as_path()).await? { + backups.push(backup_path(path.as_path(), &repair_suffix).await?); + } + } + + if backups.is_empty() { + return Err(std::io::Error::other( + "no repairable Codex local data files were found", + )); + } + + Ok(backups) +} + +pub(crate) fn print_repair_backups(backups: &[PathBuf]) { + eprintln!("Backed up Codex local data before repair:"); + for backup in backups { + eprintln!(" {}", backup.display()); + } + eprintln!("Retrying startup with rebuilt local data..."); +} + +pub(crate) fn print_diagnostic_guidance(startup_error: &LocalStateDbStartupError) { + eprintln!("Codex couldn't start because its local database appears to be damaged."); + eprintln!("Run `codex doctor` to check your setup and get next-step guidance."); + eprintln!("If this keeps happening, share the technical details below when asking for help."); + print_technical_details(startup_error); +} + +pub(crate) fn print_locked_guidance(startup_error: &LocalStateDbStartupError) { + eprintln!("Codex couldn't start because another Codex process is using its local data."); + eprintln!("Quit any other copies of Codex that may still be running, then try again."); + print_technical_details(startup_error); +} + +fn sqlite_paths(db_path: &std::path::Path) -> Vec { + let mut wal_path = db_path.as_os_str().to_os_string(); + wal_path.push("-wal"); + let mut shm_path = db_path.as_os_str().to_os_string(); + shm_path.push("-shm"); + vec![ + db_path.to_path_buf(), + PathBuf::from(wal_path), + PathBuf::from(shm_path), + ] +} + +async fn backup_path(path: &std::path::Path, repair_suffix: &str) -> std::io::Result { + let file_name = path.file_name().ok_or_else(|| { + std::io::Error::other(format!( + "cannot create a repair backup name for {}", + path.display() + )) + })?; + let mut sequence = 0; + loop { + let mut backup_name = file_name.to_os_string(); + backup_name.push(format!(".{repair_suffix}.{sequence}.bak")); + let backup_path = path.with_file_name(backup_name); + if !tokio::fs::try_exists(backup_path.as_path()).await? { + tokio::fs::rename(path, backup_path.as_path()).await?; + return Ok(backup_path); + } + sequence += 1; + } +} + +fn print_technical_details(startup_error: &LocalStateDbStartupError) { + eprintln!("Technical details:"); + eprintln!(" Location: {}", startup_error.state_db_path().display()); + eprintln!(" Cause: {}", startup_error.detail()); +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + #[tokio::test] + async fn repair_backs_up_owned_database_files() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let state_path = codex_state::state_db_path(temp_dir.path()); + let logs_path = codex_state::logs_db_path(temp_dir.path()); + let goals_path = codex_state::goals_db_path(temp_dir.path()); + let state_sidecars = sqlite_paths(state_path.as_path()); + tokio::fs::write(state_path.as_path(), b"state").await?; + tokio::fs::write(state_sidecars[1].as_path(), b"state-wal").await?; + tokio::fs::write(logs_path.as_path(), b"logs").await?; + tokio::fs::write(goals_path.as_path(), b"goals").await?; + + let startup_error = + LocalStateDbStartupError::new(state_path.clone(), "corrupt".to_string()); + let backups = repair_files(&startup_error).await?; + + assert_eq!(backups.len(), 4); + assert!(!tokio::fs::try_exists(state_path.as_path()).await?); + assert!(!tokio::fs::try_exists(state_sidecars[1].as_path()).await?); + assert!(!tokio::fs::try_exists(logs_path.as_path()).await?); + assert!(!tokio::fs::try_exists(goals_path.as_path()).await?); + for backup in backups { + assert!(tokio::fs::try_exists(backup.as_path()).await?); + } + Ok(()) + } + + #[tokio::test] + async fn repair_replaces_blocking_sqlite_home_file() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let sqlite_home = temp_dir.path().join("sqlite-home"); + tokio::fs::write(sqlite_home.as_path(), b"not-a-directory").await?; + let startup_error = LocalStateDbStartupError::new( + codex_state::state_db_path(sqlite_home.as_path()), + "File exists".to_string(), + ); + + let backups = repair_files(&startup_error).await?; + + assert_eq!(backups.len(), 1); + assert!(tokio::fs::metadata(sqlite_home.as_path()).await?.is_dir()); + assert!(tokio::fs::try_exists(backups[0].as_path()).await?); + Ok(()) + } + + #[test] + fn lock_failures_skip_repair() { + assert!(is_locked("database is locked")); + assert!(is_locked("database is busy")); + assert!(!is_locked("database disk image is malformed")); + } +} diff --git a/codex-rs/cli/tests/debug_clear_memories.rs b/codex-rs/cli/tests/debug_clear_memories.rs index 9d5e114dbf..e39d736ae0 100644 --- a/codex-rs/cli/tests/debug_clear_memories.rs +++ b/codex-rs/cli/tests/debug_clear_memories.rs @@ -2,6 +2,7 @@ use std::path::Path; use anyhow::Result; use codex_state::StateRuntime; +use codex_state::memories_db_path; use codex_state::state_db_path; use predicates::str::contains; use sqlx::SqlitePool; @@ -23,6 +24,9 @@ async fn debug_clear_memories_resets_state_and_removes_memory_dir() -> Result<() let thread_id = "00000000-0000-0000-0000-000000000123"; let db_path = state_db_path(codex_home.path()); let pool = SqlitePool::connect(&format!("sqlite://{}", db_path.display())).await?; + let memories_db_path = memories_db_path(codex_home.path()); + let memories_pool = + SqlitePool::connect(&format!("sqlite://{}", memories_db_path.display())).await?; sqlx::query( r#" @@ -74,7 +78,7 @@ INSERT INTO stage1_outputs ( "#, ) .bind(thread_id) - .execute(&pool) + .execute(&memories_pool) .await?; sqlx::query( @@ -99,13 +103,14 @@ INSERT INTO jobs ( "#, ) .bind(thread_id) - .execute(&pool) + .execute(&memories_pool) .await?; let memory_root = codex_home.path().join("memories"); std::fs::create_dir_all(&memory_root)?; std::fs::write(memory_root.join("memory_summary.md"), "stale memory")?; pool.close().await; + memories_pool.close().await; let mut cmd = codex_command(codex_home.path())?; cmd.args(["debug", "clear-memories"]) @@ -113,7 +118,7 @@ INSERT INTO jobs ( .success() .stdout(contains("Cleared memory state")); - let pool = SqlitePool::connect(&format!("sqlite://{}", db_path.display())).await?; + let pool = SqlitePool::connect(&format!("sqlite://{}", memories_db_path.display())).await?; let stage1_outputs_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM stage1_outputs") .fetch_one(&pool) .await?; @@ -131,3 +136,54 @@ INSERT INTO jobs ( Ok(()) } + +#[tokio::test] +async fn debug_clear_memories_resets_memories_db_without_state_db() -> Result<()> { + let codex_home = TempDir::new()?; + let runtime = + StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string()).await?; + drop(runtime); + + let db_path = state_db_path(codex_home.path()); + let memories_db_path = memories_db_path(codex_home.path()); + let memories_pool = + SqlitePool::connect(&format!("sqlite://{}", memories_db_path.display())).await?; + + sqlx::query( + r#" +INSERT INTO stage1_outputs ( + thread_id, + source_updated_at, + raw_memory, + rollout_summary, + generated_at, + rollout_slug, + usage_count, + last_usage, + selected_for_phase2, + selected_for_phase2_source_updated_at +) VALUES ('00000000-0000-0000-0000-000000000123', 1, 'raw', 'summary', 1, NULL, 0, NULL, 0, NULL) + "#, + ) + .execute(&memories_pool) + .await?; + + memories_pool.close().await; + std::fs::remove_file(&db_path)?; + + let mut cmd = codex_command(codex_home.path())?; + cmd.args(["debug", "clear-memories"]) + .assert() + .success() + .stdout(contains("Cleared memory state")); + + let pool = SqlitePool::connect(&format!("sqlite://{}", memories_db_path.display())).await?; + let stage1_outputs_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM stage1_outputs") + .fetch_one(&pool) + .await?; + assert_eq!(stage1_outputs_count, 0); + pool.close().await; + assert!(!db_path.exists()); + + Ok(()) +} diff --git a/codex-rs/cli/tests/exec_server.rs b/codex-rs/cli/tests/exec_server.rs new file mode 100644 index 0000000000..0afe0d37b5 --- /dev/null +++ b/codex-rs/cli/tests/exec_server.rs @@ -0,0 +1,35 @@ +use std::path::Path; + +use anyhow::Result; +use predicates::str::contains; +use tempfile::TempDir; + +fn codex_command(codex_home: &Path) -> Result { + let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); + cmd.env("CODEX_HOME", codex_home); + Ok(cmd) +} + +#[test] +fn strict_config_rejects_unknown_config_fields_for_exec_server() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#" +foo = "bar" +"#, + )?; + + let mut cmd = codex_command(codex_home.path())?; + cmd.args([ + "exec-server", + "--strict-config", + "--listen", + "http://127.0.0.1:0", + ]) + .assert() + .failure() + .stderr(contains("unknown configuration field")); + + Ok(()) +} diff --git a/codex-rs/cli/tests/mcp_add_remove.rs b/codex-rs/cli/tests/mcp_add_remove.rs index 15afaf0828..d0fc5f327d 100644 --- a/codex-rs/cli/tests/mcp_add_remove.rs +++ b/codex-rs/cli/tests/mcp_add_remove.rs @@ -68,6 +68,28 @@ async fn add_and_remove_server_updates_global_config() -> Result<()> { Ok(()) } +#[tokio::test] +async fn profile_mcp_reports_legacy_profile_migration() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[profiles.work] +model = "gpt-5" +"#, + )?; + + let mut list_cmd = codex_command(codex_home.path())?; + list_cmd + .args(["--profile", "work", "mcp", "list"]) + .assert() + .failure() + .stderr(contains("--profile `work` cannot be used")) + .stderr(contains("[profiles.work]")) + .stderr(contains("work.config.toml")); + + Ok(()) +} + #[tokio::test] async fn add_with_env_preserves_key_order_and_values() -> Result<()> { let codex_home = TempDir::new()?; @@ -176,6 +198,42 @@ async fn add_streamable_http_with_custom_env_var() -> Result<()> { Ok(()) } +#[tokio::test] +async fn add_streamable_http_with_oauth_options() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut add_cmd = codex_command(codex_home.path())?; + add_cmd + .args([ + "mcp", + "add", + "oauth-server", + "--url", + "https://example.com/mcp", + "--oauth-client-id", + "eci-prd-pub-codex-123", + "--oauth-resource", + "https://resource.example.com", + ]) + .assert() + .success(); + + let servers = load_global_mcp_servers(codex_home.path()).await?; + let oauth_server = servers + .get("oauth-server") + .expect("oauth server should exist"); + assert_eq!( + oauth_server.oauth_client_id(), + Some("eci-prd-pub-codex-123") + ); + assert_eq!( + oauth_server.oauth_resource.as_deref(), + Some("https://resource.example.com") + ); + + Ok(()) +} + #[tokio::test] async fn add_streamable_http_rejects_removed_flag() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/cli/tests/plugin_cli.rs b/codex-rs/cli/tests/plugin_cli.rs index 9829e4df07..5719b8f103 100644 --- a/codex-rs/cli/tests/plugin_cli.rs +++ b/codex-rs/cli/tests/plugin_cli.rs @@ -7,9 +7,21 @@ use predicates::str::contains; use std::path::Path; use tempfile::TempDir; +const MARKETPLACE_HEADER: &str = "MARKETPLACE"; +const MARKETPLACE_LIST_HEADER: &str = "MARKETPLACE ROOT"; + +fn marketplace_list_row(marketplace_name: &str, root: &Path) -> String { + format!( + "{marketplace_name: Result { let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?); cmd.env("CODEX_HOME", codex_home); + cmd.env("HOME", codex_home); Ok(cmd) } @@ -40,11 +52,30 @@ plugins = true Ok(()) } -fn write_marketplace_source(source: &Path) -> Result<()> { - std::fs::create_dir_all(source.join(".agents/plugins"))?; - std::fs::create_dir_all(source.join("plugins/sample/.codex-plugin"))?; +fn write_marketplace_source_with_manifest(source: &Path, marketplace_manifest: &str) -> Result<()> { + std::fs::create_dir_all(source.join(".agents").join("plugins"))?; + std::fs::create_dir_all(source.join("plugins").join("sample").join(".codex-plugin"))?; std::fs::write( - source.join(".agents/plugins/marketplace.json"), + source + .join(".agents") + .join("plugins") + .join("marketplace.json"), + marketplace_manifest, + )?; + std::fs::write( + source + .join("plugins") + .join("sample") + .join(".codex-plugin") + .join("plugin.json"), + r#"{"name":"sample","version":"1.2.3","description":"Sample plugin"}"#, + )?; + Ok(()) +} + +fn write_marketplace_source(source: &Path) -> Result<()> { + write_marketplace_source_with_manifest( + source, r#"{ "name": "debug", "plugins": [ @@ -57,12 +88,28 @@ fn write_marketplace_source(source: &Path) -> Result<()> { } ] }"#, - )?; - std::fs::write( - source.join("plugins/sample/.codex-plugin/plugin.json"), - r#"{"name":"sample","description":"Sample plugin"}"#, - )?; - Ok(()) + ) +} + +fn write_marketplace_source_with_explicit_empty_products(source: &Path) -> Result<()> { + write_marketplace_source_with_manifest( + source, + r#"{ + "name": "debug", + "plugins": [ + { + "name": "sample", + "source": { + "source": "local", + "path": "./plugins/sample" + }, + "policy": { + "products": [] + } + } + ] +}"#, + ) } fn setup_local_marketplace() -> Result<(TempDir, TempDir)> { @@ -87,6 +134,20 @@ fn setup_unconfigured_local_marketplace() -> Result<(TempDir, TempDir)> { Ok((codex_home, source)) } +fn setup_local_marketplace_with_explicit_empty_products() -> Result<(TempDir, TempDir)> { + let codex_home = TempDir::new()?; + let source = TempDir::new()?; + write_plugins_enabled_config(codex_home.path())?; + write_marketplace_source_with_explicit_empty_products(source.path())?; + let source_path = source.path().to_string_lossy().into_owned(); + record_user_marketplace( + codex_home.path(), + "debug", + &configured_local_marketplace(&source_path), + )?; + Ok((codex_home, source)) +} + fn setup_configured_marketplace_without_manifest() -> Result<(TempDir, TempDir)> { let codex_home = TempDir::new()?; let source = TempDir::new()?; @@ -104,9 +165,13 @@ fn setup_configured_marketplace_with_malformed_manifest() -> Result<(TempDir, Te let codex_home = TempDir::new()?; let source = TempDir::new()?; write_plugins_enabled_config(codex_home.path())?; - std::fs::create_dir_all(source.path().join(".agents/plugins"))?; + std::fs::create_dir_all(source.path().join(".agents").join("plugins"))?; std::fs::write( - source.path().join(".agents/plugins/marketplace.json"), + source + .path() + .join(".agents") + .join("plugins") + .join("marketplace.json"), "{not valid json", )?; let source_path = source.path().to_string_lossy().into_owned(); @@ -118,30 +183,328 @@ fn setup_configured_marketplace_with_malformed_manifest() -> Result<(TempDir, Te Ok((codex_home, source)) } +fn setup_local_marketplace_with_implicit_system_roots() -> Result<(TempDir, TempDir, TempDir)> { + let (codex_home, source) = setup_local_marketplace()?; + + let bundled_root = codex_home + .path() + .join(".tmp") + .join("bundled-marketplaces") + .join("openai-bundled"); + std::fs::create_dir_all(&bundled_root)?; + let bundled_source = bundled_root.display().to_string(); + record_user_marketplace( + codex_home.path(), + "openai-bundled", + &configured_local_marketplace(&bundled_source), + )?; + + let cache_home = TempDir::new()?; + let runtime_root = cache_home + .path() + .join("codex-runtimes") + .join("codex-primary-runtime") + .join("plugins") + .join("openai-primary-runtime"); + std::fs::create_dir_all(&runtime_root)?; + let runtime_source = runtime_root.display().to_string(); + record_user_marketplace( + codex_home.path(), + "openai-primary-runtime", + &configured_local_marketplace(&runtime_source), + )?; + + Ok((codex_home, source, cache_home)) +} + +fn setup_custom_marketplace_under_implicit_system_root() -> Result<(TempDir, std::path::PathBuf)> { + let codex_home = TempDir::new()?; + write_plugins_enabled_config(codex_home.path())?; + + let custom_root = codex_home + .path() + .join(".tmp") + .join("bundled-marketplaces") + .join("custom-marketplace"); + std::fs::create_dir_all(&custom_root)?; + let custom_source = custom_root.display().to_string(); + record_user_marketplace( + codex_home.path(), + "custom-marketplace", + &configured_local_marketplace(&custom_source), + )?; + + Ok((codex_home, custom_root)) +} + +fn remove_installed_plugin_config(codex_home: &Path, plugin_key: &str) -> Result<()> { + let config_path = codex_home.join(CONFIG_TOML_FILE); + let plugin_header = format!("[plugins.\"{plugin_key}\"]"); + let config = std::fs::read_to_string(&config_path)?; + let mut rewritten = Vec::new(); + let mut skipping = false; + + for line in config.lines() { + if line == plugin_header { + skipping = true; + continue; + } + if skipping && line.starts_with('[') { + skipping = false; + } + if !skipping { + rewritten.push(line); + } + } + + std::fs::write(config_path, format!("{}\n", rewritten.join("\n")))?; + Ok(()) +} + +fn setup_configured_local_marketplace_with_missing_source() -> Result { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[marketplaces.debug] +source_type = "local" +"#, + )?; + Ok(codex_home) +} + +fn setup_configured_local_marketplace_with_invalid_name() -> Result { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[marketplaces."bad/name"] +source_type = "local" +source = "/tmp/debug" +"#, + )?; + Ok(codex_home) +} + +fn assert_configured_marketplace_snapshot_failure( + assert: assert_cmd::assert::Assert, + source: &Path, + detail: &str, +) { + assert + .failure() + .stderr(contains( + "failed to load configured marketplace snapshot(s):", + )) + .stderr(contains("`debug`")) + .stderr(contains(source.display().to_string())) + .stderr(contains(detail)); +} + +fn assert_marketplace_failure( + assert: assert_cmd::assert::Assert, + marketplace_name: &str, + source: &Path, + detail: &str, +) { + assert + .failure() + .stderr(contains("failed to load marketplace(s):")) + .stderr(contains(format!("`{marketplace_name}`"))) + .stderr(contains(source.display().to_string())) + .stderr(contains(detail)); +} + #[tokio::test] async fn marketplace_list_shows_configured_marketplace_names() -> Result<()> { let (codex_home, source) = setup_local_marketplace()?; + let expected_row = marketplace_list_row("debug", source.path()); codex_command(codex_home.path())? .args(["plugin", "marketplace", "list"]) .assert() .success() - .stdout(contains("debug")) - .stdout(contains(source.path().display().to_string())); + .stdout(contains(MARKETPLACE_LIST_HEADER)) + .stdout(contains(&expected_row)) + .stdout(contains("\t").not()); Ok(()) } #[tokio::test] -async fn plugin_list_shows_plugins_grouped_by_marketplace() -> Result<()> { - let (codex_home, _source) = setup_local_marketplace()?; +async fn marketplace_list_includes_home_marketplace_when_present() -> Result<()> { + let codex_home = TempDir::new()?; + let home = TempDir::new()?; + write_marketplace_source(home.path())?; + write_plugins_enabled_config(codex_home.path())?; + let expected_row = marketplace_list_row("debug", home.path()); + + codex_command(codex_home.path())? + .env("HOME", home.path()) + .args(["plugin", "marketplace", "list"]) + .assert() + .success() + .stdout(contains(MARKETPLACE_LIST_HEADER)) + .stdout(contains(&expected_row)) + .stdout(contains("\t").not()); + + Ok(()) +} + +#[tokio::test] +async fn marketplace_list_includes_root_when_plugins_are_filtered_out() -> Result<()> { + let (codex_home, source) = setup_local_marketplace_with_explicit_empty_products()?; + let expected_row = marketplace_list_row("debug", source.path()); + + codex_command(codex_home.path())? + .args(["plugin", "marketplace", "list"]) + .assert() + .success() + .stdout(contains(MARKETPLACE_LIST_HEADER)) + .stdout(contains(&expected_row)); + + Ok(()) +} + +#[tokio::test] +async fn marketplace_list_fails_when_configured_marketplace_snapshot_is_missing() -> Result<()> { + let (codex_home, source) = setup_configured_marketplace_without_manifest()?; + + assert_marketplace_failure( + codex_command(codex_home.path())? + .args(["plugin", "marketplace", "list"]) + .assert(), + "debug", + source.path(), + "marketplace root does not contain a supported manifest", + ); + + Ok(()) +} + +#[tokio::test] +async fn marketplace_list_fails_when_configured_marketplace_name_is_invalid() -> Result<()> { + let codex_home = setup_configured_local_marketplace_with_invalid_name()?; + + assert_marketplace_failure( + codex_command(codex_home.path())? + .args(["plugin", "marketplace", "list"]) + .assert(), + "bad/name", + Path::new(""), + "marketplace name", + ); + + Ok(()) +} + +#[tokio::test] +async fn marketplace_list_fails_when_configured_local_marketplace_source_is_missing() -> Result<()> +{ + let codex_home = setup_configured_local_marketplace_with_missing_source()?; + + codex_command(codex_home.path())? + .args(["plugin", "marketplace", "list"]) + .assert() + .failure() + .stderr(contains("failed to load marketplace(s):")) + .stderr(contains("`debug`")) + .stderr(contains("")) + .stderr(contains( + "configured local marketplace source is missing or empty", + )); + + Ok(()) +} + +#[tokio::test] +async fn marketplace_list_fails_when_home_marketplace_is_malformed() -> Result<()> { + let codex_home = TempDir::new()?; + let home = TempDir::new()?; + write_plugins_enabled_config(codex_home.path())?; + std::fs::create_dir_all(home.path().join(".agents/plugins"))?; + let home_marketplace_path = home + .path() + .join(".agents") + .join("plugins") + .join("marketplace.json"); + std::fs::write(&home_marketplace_path, "{not valid json")?; + + codex_command(codex_home.path())? + .env("HOME", home.path()) + .args(["plugin", "marketplace", "list"]) + .assert() + .failure() + .stderr(contains("failed to load marketplace(s):")) + .stderr(contains(home_marketplace_path.display().to_string())) + .stderr(contains("key must be a string")); + + Ok(()) +} + +#[tokio::test] +async fn marketplace_list_fails_when_configured_marketplace_snapshot_is_malformed() -> Result<()> { + let (codex_home, source) = setup_configured_marketplace_with_malformed_manifest()?; + + assert_marketplace_failure( + codex_command(codex_home.path())? + .args(["plugin", "marketplace", "list"]) + .assert(), + "debug", + source.path(), + "key must be a string", + ); + + Ok(()) +} + +#[tokio::test] +async fn plugin_list_prints_plugins_in_a_table() -> Result<()> { + let (codex_home, source) = setup_local_marketplace()?; + let marketplace_manifest = source + .path() + .join(".agents") + .join("plugins") + .join("marketplace.json"); + let plugin_path = source.path().join("plugins").join("sample"); codex_command(codex_home.path())? .args(["plugin", "list"]) .assert() .success() .stdout(contains("Marketplace `debug`")) - .stdout(contains("sample@debug (not installed)")); + .stdout(contains("PLUGIN")) + .stdout(contains("STATUS")) + .stdout(contains("VERSION")) + .stdout(contains("PATH")) + .stdout(contains(marketplace_manifest.display().to_string())) + .stdout(contains("sample@debug")) + .stdout(contains("not installed")) + .stdout(contains(plugin_path.display().to_string())); + + Ok(()) +} + +#[tokio::test] +async fn plugin_list_shows_installed_version_when_plugin_is_installed() -> Result<()> { + let (codex_home, _source) = setup_local_marketplace()?; + + codex_command(codex_home.path())? + .args(["plugin", "add", "sample@debug"]) + .assert() + .success(); + + codex_command(codex_home.path())? + .args(["plugin", "list"]) + .assert() + .success() + .stdout(contains("sample@debug")) + .stdout(contains("1.2.3")) + .stdout(contains("installed, enabled")); Ok(()) } @@ -151,10 +514,10 @@ async fn plugin_list_excludes_unconfigured_repo_local_marketplaces() -> Result<( let (codex_home, source) = setup_unconfigured_local_marketplace()?; codex_command_in(codex_home.path(), source.path())? - .args(["plugin", "list"]) + .args(["plugin", "list", "--marketplace", "debug"]) .assert() .success() - .stdout(contains("No marketplace plugins found.")) + .stdout(contains("No plugins found in marketplace `debug`.")) .stdout(predicates::str::is_match("sample@debug").unwrap().not()); Ok(()) @@ -164,6 +527,47 @@ async fn plugin_list_excludes_unconfigured_repo_local_marketplaces() -> Result<( async fn plugin_list_fails_when_configured_marketplace_snapshot_is_missing() -> Result<()> { let (codex_home, source) = setup_configured_marketplace_without_manifest()?; + assert_configured_marketplace_snapshot_failure( + codex_command(codex_home.path())? + .args(["plugin", "list"]) + .assert(), + source.path(), + "marketplace root does not contain a supported manifest", + ); + + Ok(()) +} + +#[tokio::test] +async fn plugin_list_ignores_implicit_system_marketplace_roots_without_manifests() -> Result<()> { + let (codex_home, source, cache_home) = setup_local_marketplace_with_implicit_system_roots()?; + + codex_command(codex_home.path())? + .env("XDG_CACHE_HOME", cache_home.path()) + .args(["plugin", "list"]) + .assert() + .success() + .stdout(contains("Marketplace `debug`")) + .stdout(contains( + source + .path() + .join(".agents") + .join("plugins") + .join("marketplace.json") + .display() + .to_string(), + )) + .stderr( + predicates::str::contains("failed to load configured marketplace snapshot(s):").not(), + ); + + Ok(()) +} + +#[tokio::test] +async fn plugin_list_fails_for_custom_marketplace_under_system_root() -> Result<()> { + let (codex_home, custom_root) = setup_custom_marketplace_under_implicit_system_root()?; + codex_command(codex_home.path())? .args(["plugin", "list"]) .assert() @@ -171,8 +575,8 @@ async fn plugin_list_fails_when_configured_marketplace_snapshot_is_missing() -> .stderr(contains( "failed to load configured marketplace snapshot(s):", )) - .stderr(contains("`debug`")) - .stderr(contains(source.path().display().to_string())) + .stderr(contains("`custom-marketplace`")) + .stderr(contains(custom_root.display().to_string())) .stderr(contains( "marketplace root does not contain a supported manifest", )); @@ -180,6 +584,28 @@ async fn plugin_list_fails_when_configured_marketplace_snapshot_is_missing() -> Ok(()) } +#[tokio::test] +async fn plugin_list_hides_version_for_cached_but_unconfigured_plugin() -> Result<()> { + let (codex_home, _source) = setup_local_marketplace()?; + + codex_command(codex_home.path())? + .args(["plugin", "add", "sample@debug"]) + .assert() + .success(); + + remove_installed_plugin_config(codex_home.path(), "sample@debug")?; + + codex_command(codex_home.path())? + .args(["plugin", "list"]) + .assert() + .success() + .stdout(contains("sample@debug")) + .stdout(contains("not installed")) + .stdout(predicates::str::contains("1.2.3").not()); + + Ok(()) +} + #[tokio::test] async fn plugin_add_and_remove_updates_installed_plugin_config() -> Result<()> { let (codex_home, _source) = setup_local_marketplace()?; @@ -224,18 +650,15 @@ async fn plugin_add_rejects_unconfigured_repo_local_marketplaces() -> Result<()> #[tokio::test] async fn plugin_add_fails_when_configured_marketplace_snapshot_is_malformed() -> Result<()> { - let (codex_home, _source) = setup_configured_marketplace_with_malformed_manifest()?; + let (codex_home, source) = setup_configured_marketplace_with_malformed_manifest()?; - codex_command(codex_home.path())? - .args(["plugin", "add", "sample@debug"]) - .assert() - .failure() - .stderr(contains( - "failed to load configured marketplace snapshot(s):", - )) - .stderr(contains("`debug`")) - .stderr(contains("invalid marketplace file")) - .stderr(contains("key must be a string")); + assert_configured_marketplace_snapshot_failure( + codex_command(codex_home.path())? + .args(["plugin", "add", "sample@debug"]) + .assert(), + source.path(), + "key must be a string", + ); Ok(()) } @@ -258,7 +681,7 @@ async fn plugin_add_reinstalls_from_configured_marketplace_snapshot() -> Result< assert!( codex_home .path() - .join("plugins/cache/debug/sample/local/.codex-plugin/plugin.json") + .join("plugins/cache/debug/sample/1.2.3/.codex-plugin/plugin.json") .is_file() ); @@ -311,7 +734,7 @@ async fn plugin_add_rejects_cached_plugins_without_authorizing_marketplace_snaps assert!( codex_home .path() - .join("plugins/cache/debug/sample/local/.codex-plugin/plugin.json") + .join("plugins/cache/debug/sample/1.2.3/.codex-plugin/plugin.json") .is_file() ); diff --git a/codex-rs/cloud-requirements/Cargo.toml b/codex-rs/cloud-requirements/Cargo.toml index cc7aefc478..f4b2d2dbcb 100644 --- a/codex-rs/cloud-requirements/Cargo.toml +++ b/codex-rs/cloud-requirements/Cargo.toml @@ -17,6 +17,7 @@ codex-core = { workspace = true } codex-login = { workspace = true } codex-otel = { workspace = true } codex-protocol = { workspace = true } +codex-utils-absolute-path = { workspace = true } hmac = "0.12.1" serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index 8812762cbe..0beb91780c 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -25,11 +25,13 @@ use codex_login::AuthManager; use codex_login::CodexAuth; use codex_login::RefreshTokenError; use codex_protocol::account::PlanType; +use codex_utils_absolute_path::AbsolutePathBufGuard; use hmac::Hmac; use hmac::Mac; use serde::Deserialize; use serde::Serialize; use sha2::Sha256; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; @@ -138,10 +140,12 @@ struct CloudRequirementsCacheSignedPayload { } impl CloudRequirementsCacheSignedPayload { - fn requirements(&self) -> Option { - self.contents - .as_deref() - .and_then(|contents| parse_cloud_requirements(contents).ok().flatten()) + fn requirements(&self, requirements_base_dir: &Path) -> Option { + self.contents.as_deref().and_then(|contents| { + parse_cloud_requirements(contents, requirements_base_dir) + .ok() + .flatten() + }) } } fn sign_cache_payload(payload_bytes: &[u8]) -> Option { @@ -258,6 +262,7 @@ impl RequirementsFetcher for BackendRequirementsFetcher { struct CloudRequirementsService { auth_manager: Arc, fetcher: Arc, + requirements_base_dir: PathBuf, cache_path: PathBuf, timeout: Duration, } @@ -272,6 +277,7 @@ impl CloudRequirementsService { Self { auth_manager, fetcher, + requirements_base_dir: codex_home.clone(), cache_path: codex_home.join(CLOUD_REQUIREMENTS_CACHE_FILENAME), timeout, } @@ -351,7 +357,7 @@ impl CloudRequirementsService { path = %self.cache_path.display(), "Using cached cloud requirements" ); - return Ok(signed_payload.requirements()); + return Ok(signed_payload.requirements(&self.requirements_base_dir)); } Err(cache_load_status) => { self.log_cache_load_status(&cache_load_status); @@ -482,24 +488,26 @@ impl CloudRequirementsService { }; let requirements = match contents.as_deref() { - Some(contents) => match parse_cloud_requirements(contents) { - Ok(requirements) => requirements, - Err(err) => { - tracing::error!(error = %err, "Failed to parse cloud requirements"); - emit_fetch_final_metric( - trigger, - "error", - "parse_error", - attempt, - last_status_code, - ); - return Err(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Parse, - /*status_code*/ None, - format_cloud_requirements_parse_failed_message(contents, &err), - )); + Some(contents) => { + match parse_cloud_requirements(contents, &self.requirements_base_dir) { + Ok(requirements) => requirements, + Err(err) => { + tracing::error!(error = %err, "Failed to parse cloud requirements"); + emit_fetch_final_metric( + trigger, + "error", + "parse_error", + attempt, + last_status_code, + ); + return Err(CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::Parse, + /*status_code*/ None, + format_cloud_requirements_parse_failed_message(contents, &err), + )); + } } - }, + } None => None, }; @@ -738,11 +746,13 @@ pub async fn cloud_requirements_loader_for_storage( fn parse_cloud_requirements( contents: &str, + requirements_base_dir: &Path, ) -> Result, toml::de::Error> { if contents.trim().is_empty() { return Ok(None); } + let _guard = AbsolutePathBufGuard::new(requirements_base_dir); let requirements: ConfigRequirementsToml = toml::from_str(contents)?; if requirements.is_empty() { Ok(None) @@ -1038,7 +1048,11 @@ mod tests { } fn parse_for_fetch(contents: Option<&str>) -> Option { - contents.and_then(|contents| parse_cloud_requirements(contents).ok().flatten()) + contents.and_then(|contents| { + parse_cloud_requirements(contents, &std::env::temp_dir()) + .ok() + .flatten() + }) } fn request_error() -> FetchAttemptError { @@ -1202,9 +1216,13 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, + computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1285,9 +1303,13 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, + computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1319,9 +1341,13 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, + computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1370,9 +1396,13 @@ mod tests { allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, + computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1387,6 +1417,35 @@ mod tests { ); } + #[tokio::test] + async fn fetch_cloud_requirements_resolves_relative_deny_read_globs_from_codex_home() { + let codex_home = tempdir().expect("tempdir"); + let service = CloudRequirementsService::new( + auth_manager_with_plan("enterprise").await, + Arc::new(StaticFetcher { + contents: Some( + r#" +[permissions.filesystem] +deny_read = ["./sensitive/**/*.txt"] +"# + .to_string(), + ), + }), + codex_home.path().to_path_buf(), + CLOUD_REQUIREMENTS_TIMEOUT, + ); + let deny_read = format!("{}/sensitive/**/*.txt", codex_home.path().display()); + let expected = toml::from_str::(&format!( + r#" +[permissions.filesystem] +deny_read = [{deny_read:?}] +"# + )) + .expect("parse expected cloud requirements"); + + assert_eq!(service.fetch().await, Ok(Some(expected))); + } + #[tokio::test] async fn fetch_cloud_requirements_parses_apps_requirements_toml() { let result = parse_for_fetch(Some( @@ -1521,9 +1580,13 @@ command = "sample-mcp" allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, + computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1602,9 +1665,13 @@ command = "sample-mcp" allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, + computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1681,9 +1748,13 @@ command = "sample-mcp" allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, + computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1888,9 +1959,13 @@ command = "sample-mcp" allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, + computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1929,9 +2004,13 @@ command = "sample-mcp" allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, + computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1990,9 +2069,13 @@ command = "sample-mcp" allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, + computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2047,9 +2130,13 @@ command = "sample-mcp" allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, + computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2106,9 +2193,13 @@ command = "sample-mcp" allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, + computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2166,9 +2257,13 @@ command = "sample-mcp" allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, + computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2221,14 +2316,22 @@ command = "sample-mcp" .signed_payload .contents .as_deref() - .and_then(|contents| parse_cloud_requirements(contents).ok().flatten()), + .and_then(|contents| { + parse_cloud_requirements(contents, codex_home.path()) + .ok() + .flatten() + }), Some(ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, + computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2316,9 +2419,13 @@ command = "sample-mcp" allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, + computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2343,14 +2450,22 @@ command = "sample-mcp" .signed_payload .contents .as_deref() - .and_then(|contents| parse_cloud_requirements(contents).ok().flatten()), + .and_then(|contents| { + parse_cloud_requirements(contents, codex_home.path()) + .ok() + .flatten() + }), Some(ConfigRequirementsToml { allowed_approval_policies: Some(vec![AskForApproval::OnRequest]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, + computer_use: None, + windows: None, guardian_policy_config: None, feature_requirements: None, hooks: None, diff --git a/codex-rs/code-mode/Cargo.toml b/codex-rs/code-mode/Cargo.toml index 19d6c3ab45..879404d8aa 100644 --- a/codex-rs/code-mode/Cargo.toml +++ b/codex-rs/code-mode/Cargo.toml @@ -16,8 +16,6 @@ sandbox = ["v8/v8_enable_sandbox"] workspace = true [dependencies] -async-channel = { workspace = true } -async-trait = { workspace = true } codex-protocol = { workspace = true } deno_core_icudata = { workspace = true } serde = { workspace = true, features = ["derive"] } diff --git a/codex-rs/code-mode/src/description.rs b/codex-rs/code-mode/src/description.rs index 0c2813e51a..50fcc034ba 100644 --- a/codex-rs/code-mode/src/description.rs +++ b/codex-rs/code-mode/src/description.rs @@ -7,7 +7,7 @@ use std::collections::BTreeMap; use crate::PUBLIC_TOOL_NAME; const MAX_JS_SAFE_INTEGER: u64 = (1_u64 << 53) - 1; -const DEFERRED_NESTED_TOOLS_GUIDANCE: &str = r#"Some nested MCP/app tools may be omitted from this description. They are still available on the global `tools` object and listed in `ALL_TOOLS`. +const DEFERRED_NESTED_TOOLS_GUIDANCE: &str = r#"Some deferred nested tools may be omitted from this description. They are still available on the global `tools` object and listed in `ALL_TOOLS`. To find one, filter `ALL_TOOLS` by `name` and `description`."#; const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/compose tool calls - Evaluates the provided JavaScript code in a fresh V8 isolate as an async module. @@ -17,8 +17,8 @@ const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/co - Runs raw JavaScript -- no Node, no file system, no network access, no console. - Accepts raw JavaScript source text, not JSON, quoted strings, or markdown code fences. - You may optionally start the tool input with a first-line pragma like `// @exec: {"yield_time_ms": 10000, "max_output_tokens": 1000}`. -- `yield_time_ms` asks `exec` to yield early after that many milliseconds if the script is still running. -- `max_output_tokens` sets the token budget for direct `exec` results. By default the result is truncated to 10000 tokens. +- `yield_time_ms` asks `exec` to yield early if the script is still running. Defaults to 10000 ms. +- `max_output_tokens` sets the token budget for direct `exec` results. Defaults to 10000 tokens. - When the JS code is fully evaluated, the isolate's lifetime ends and unawaited promises are silently discarded. - Global helpers: @@ -34,9 +34,9 @@ const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/co - `yield_control()`: yields the accumulated output to the model immediately while the script keeps running."#; const WAIT_DESCRIPTION_TEMPLATE: &str = r#"- Use `wait` only after `exec` returns `Script running with cell ID ...`. - `cell_id` identifies the running `exec` cell to resume. -- `yield_time_ms` controls how long to wait for more output before yielding again. If omitted, `wait` uses its default wait timeout. -- `max_tokens` limits how much new output this wait call returns. -- `terminate: true` stops the running cell instead of waiting for more output. +- `yield_time_ms` controls how long to wait for more output before yielding again. Defaults to 10000 ms. +- `max_tokens` limits how much new output this wait call returns. Defaults to 10000 tokens. +- `terminate: true` stops the running cell; false or omitted waits for output. - `wait` returns only the new output since the last yield, or the final completion or termination result for that cell. - If the cell is still running, `wait` may yield again with the same `cell_id`. - If the cell has already finished, `wait` returns the completed result and closes the cell."#; @@ -649,7 +649,7 @@ fn render_json_schema_object(map: &serde_json::Map) -> String .unwrap_or_default(); let mut sorted_properties = properties.iter().collect::>(); - sorted_properties.sort_unstable_by(|(name_a, _), (name_b, _)| name_a.cmp(name_b)); + sorted_properties.sort_unstable_by_key(|(name_a, _)| *name_a); if sorted_properties .iter() .any(|(_, value)| has_property_description(value)) @@ -1091,7 +1091,7 @@ bar" /*deferred_tools_available*/ true, ); - assert!(description.contains("Some nested MCP/app tools may be omitted")); + assert!(description.contains("Some deferred nested tools may be omitted")); assert!(description.contains("filter `ALL_TOOLS` by `name` and `description`")); assert!(!description.contains("do not print the full `ALL_TOOLS` array")); } diff --git a/codex-rs/code-mode/src/lib.rs b/codex-rs/code-mode/src/lib.rs index 3da8c77325..37ba1d2a91 100644 --- a/codex-rs/code-mode/src/lib.rs +++ b/codex-rs/code-mode/src/lib.rs @@ -29,9 +29,18 @@ pub use runtime::WaitOutcome; pub use runtime::WaitRequest; pub use runtime::WaitToPendingOutcome; pub use runtime::WaitToPendingRequest; +pub use service::CellId; pub use service::CodeModeService; -pub use service::CodeModeTurnHost; -pub use service::CodeModeTurnWorker; +pub use service::CodeModeSession; +pub use service::CodeModeSessionDelegate; +pub use service::CodeModeSessionProvider; +pub use service::CodeModeSessionProviderFuture; +pub use service::CodeModeSessionResultFuture; +pub use service::InProcessCodeModeSessionProvider; +pub use service::NoopCodeModeSessionDelegate; +pub use service::NotificationFuture; +pub use service::StartedCell; +pub use service::ToolInvocationFuture; pub const PUBLIC_TOOL_NAME: &str = "exec"; pub const WAIT_TOOL_NAME: &str = "wait"; diff --git a/codex-rs/code-mode/src/runtime/callbacks.rs b/codex-rs/code-mode/src/runtime/callbacks.rs index c3a648ae32..7d8a28662a 100644 --- a/codex-rs/code-mode/src/runtime/callbacks.rs +++ b/codex-rs/code-mode/src/runtime/callbacks.rs @@ -157,7 +157,8 @@ pub(super) fn store_callback( } }; if let Some(state) = scope.get_slot_mut::() { - state.stored_values.insert(key, serialized); + state.stored_values.insert(key.clone(), serialized.clone()); + state.stored_value_writes.insert(key, serialized); } } diff --git a/codex-rs/code-mode/src/runtime/mod.rs b/codex-rs/code-mode/src/runtime/mod.rs index 2b605fa573..21757328ee 100644 --- a/codex-rs/code-mode/src/runtime/mod.rs +++ b/codex-rs/code-mode/src/runtime/mod.rs @@ -19,6 +19,7 @@ use crate::description::EnabledToolMetadata; use crate::description::ToolDefinition; use crate::description::enabled_tool_metadata; use crate::response::FunctionCallOutputContentItem; +use crate::service::CellId; pub const DEFAULT_EXEC_YIELD_TIME_MS: u64 = 10_000; pub const DEFAULT_WAIT_YIELD_TIME_MS: u64 = 10_000; @@ -27,29 +28,22 @@ const EXIT_SENTINEL: &str = "__codex_code_mode_exit__"; #[derive(Clone, Debug)] pub struct ExecuteRequest { - /// Runtime cell id for this execution. - /// - /// Callers allocate this before execution so tracing, waits, and nested tool - /// calls can refer to the cell as soon as JavaScript starts. - pub cell_id: String, pub tool_call_id: String, pub enabled_tools: Vec, pub source: String, - pub stored_values: HashMap, pub yield_time_ms: Option, pub max_output_tokens: Option, } #[derive(Clone, Debug)] pub struct WaitRequest { - pub cell_id: String, + pub cell_id: CellId, pub yield_time_ms: u64, - pub terminate: bool, } #[derive(Clone, Debug)] pub struct WaitToPendingRequest { - pub cell_id: String, + pub cell_id: CellId, } /// Result of waiting on a code-mode cell. @@ -74,7 +68,7 @@ pub enum ExecuteToPendingOutcome { /// The cell is waiting for more runtime input after draining the runtime /// input queue that was ready at the pending boundary. Pending { - cell_id: String, + cell_id: CellId, content_items: Vec, /// Runtime tool-call ids emitted before this paused execution frontier /// sealed. Hosts can use these ids to drain their tool-call transport @@ -106,17 +100,16 @@ impl From for RuntimeResponse { #[derive(Debug, PartialEq, Serialize)] pub enum RuntimeResponse { Yielded { - cell_id: String, + cell_id: CellId, content_items: Vec, }, Terminated { - cell_id: String, + cell_id: CellId, content_items: Vec, }, Result { - cell_id: String, + cell_id: CellId, content_items: Vec, - stored_values: HashMap, error_text: Option, }, } @@ -128,23 +121,13 @@ pub enum RuntimeResponse { /// if their tool-call graph requires globally unique ids. #[derive(Debug)] pub struct CodeModeNestedToolCall { - pub cell_id: String, + pub cell_id: CellId, pub runtime_tool_call_id: String, pub tool_name: ToolName, pub tool_kind: CodeModeToolKind, pub input: Option, } -#[derive(Debug)] -pub(crate) enum TurnMessage { - ToolCall(CodeModeNestedToolCall), - Notify { - cell_id: String, - call_id: String, - text: String, - }, -} - #[derive(Debug)] pub(crate) enum RuntimeCommand { ToolResponse { id: String, result: JsonValue }, @@ -182,12 +165,13 @@ pub(crate) enum RuntimeEvent { text: String, }, Result { - stored_values: HashMap, + stored_value_writes: HashMap, error_text: Option, }, } pub(crate) fn spawn_runtime( + stored_values: HashMap, request: ExecuteRequest, event_tx: mpsc::UnboundedSender, pending_mode: PendingRuntimeMode, @@ -214,7 +198,7 @@ pub(crate) fn spawn_runtime( tool_call_id: request.tool_call_id, enabled_tools, source: request.source, - stored_values: request.stored_values, + stored_values, }; thread::spawn(move || { @@ -248,6 +232,7 @@ pub(super) struct RuntimeState { pending_tool_calls: HashMap>, pending_timeouts: HashMap, stored_values: HashMap, + stored_value_writes: HashMap, enabled_tools: Vec, next_tool_call_id: u64, next_timeout_id: u64, @@ -259,7 +244,7 @@ pub(super) struct RuntimeState { pub(super) enum CompletionState { Pending, Completed { - stored_values: HashMap, + stored_value_writes: HashMap, error_text: Option, }, } @@ -305,6 +290,7 @@ fn run_runtime( pending_tool_calls: HashMap::new(), pending_timeouts: HashMap::new(), stored_values: config.stored_values, + stored_value_writes: HashMap::new(), enabled_tools: config.enabled_tools, next_tool_call_id: 1, next_timeout_id: 1, @@ -330,10 +316,10 @@ fn run_runtime( match module_loader::completion_state(scope, pending_promise.as_ref()) { CompletionState::Completed { - stored_values, + stored_value_writes, error_text, } => { - send_result(&event_tx, stored_values, error_text); + send_result(&event_tx, stored_value_writes, error_text); return; } CompletionState::Pending => {} @@ -375,10 +361,10 @@ fn run_runtime( scope.perform_microtask_checkpoint(); match module_loader::completion_state(scope, pending_promise.as_ref()) { CompletionState::Completed { - stored_values, + stored_value_writes, error_text, } => { - send_result(&event_tx, stored_values, error_text); + send_result(&event_tx, stored_value_writes, error_text); return; } CompletionState::Pending => {} @@ -422,21 +408,21 @@ fn capture_scope_send_error( event_tx: &mpsc::UnboundedSender, error_text: Option, ) { - let stored_values = scope + let stored_value_writes = scope .get_slot::() - .map(|state| state.stored_values.clone()) + .map(|state| state.stored_value_writes.clone()) .unwrap_or_default(); - send_result(event_tx, stored_values, error_text); + send_result(event_tx, stored_value_writes, error_text); } fn send_result( event_tx: &mpsc::UnboundedSender, - stored_values: HashMap, + stored_value_writes: HashMap, error_text: Option, ) { let _ = event_tx.send(RuntimeEvent::Result { - stored_values, + stored_value_writes, error_text, }); } @@ -459,11 +445,9 @@ mod tests { fn execute_request(source: &str) -> ExecuteRequest { ExecuteRequest { - cell_id: "1".to_string(), tool_call_id: "call_1".to_string(), enabled_tools: Vec::new(), source: source.to_string(), - stored_values: HashMap::new(), yield_time_ms: Some(1), max_output_tokens: None, } @@ -473,6 +457,7 @@ mod tests { async fn terminate_execution_stops_cpu_bound_module() { let (event_tx, mut event_rx) = mpsc::unbounded_channel(); let (_runtime_tx, _runtime_control_tx, runtime_terminate_handle) = spawn_runtime( + HashMap::new(), execute_request("while (true) {}"), event_tx, PendingRuntimeMode::Continue, @@ -491,14 +476,9 @@ mod tests { .await .unwrap() .unwrap(); - let RuntimeEvent::Result { - stored_values, - error_text, - } = result_event - else { + let RuntimeEvent::Result { error_text, .. } = result_event else { panic!("expected runtime result after termination"); }; - assert_eq!(stored_values, HashMap::new()); assert!(error_text.is_some()); assert!( @@ -513,6 +493,7 @@ mod tests { async fn pending_mode_freezes_runtime_commands_until_resume() { let (event_tx, mut event_rx) = mpsc::unbounded_channel(); let (runtime_tx, runtime_control_tx, _runtime_terminate_handle) = spawn_runtime( + HashMap::new(), execute_request( r#" await new Promise((resolve) => setTimeout(resolve, 60_000)); diff --git a/codex-rs/code-mode/src/runtime/module_loader.rs b/codex-rs/code-mode/src/runtime/module_loader.rs index 83ce3d347a..00185edb58 100644 --- a/codex-rs/code-mode/src/runtime/module_loader.rs +++ b/codex-rs/code-mode/src/runtime/module_loader.rs @@ -104,14 +104,14 @@ pub(super) fn completion_state( scope: &mut v8::PinScope<'_, '_>, pending_promise: Option<&v8::Global>, ) -> CompletionState { - let stored_values = scope + let stored_value_writes = scope .get_slot::() - .map(|state| state.stored_values.clone()) + .map(|state| state.stored_value_writes.clone()) .unwrap_or_default(); let Some(pending_promise) = pending_promise else { return CompletionState::Completed { - stored_values, + stored_value_writes, error_text: None, }; }; @@ -120,7 +120,7 @@ pub(super) fn completion_state( match promise.state() { v8::PromiseState::Pending => CompletionState::Pending, v8::PromiseState::Fulfilled => CompletionState::Completed { - stored_values, + stored_value_writes, error_text: None, }, v8::PromiseState::Rejected => { @@ -131,7 +131,7 @@ pub(super) fn completion_state( Some(value_to_error_text(scope, result)) }; CompletionState::Completed { - stored_values, + stored_value_writes, error_text, } } diff --git a/codex-rs/code-mode/src/service.rs b/codex-rs/code-mode/src/service.rs index 44e4be4939..6589e6ec5a 100644 --- a/codex-rs/code-mode/src/service.rs +++ b/codex-rs/code-mode/src/service.rs @@ -1,14 +1,20 @@ use std::collections::HashMap; +use std::fmt; +use std::future::Future; +use std::pin::Pin; use std::sync::Arc; +use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicU64; use std::sync::atomic::Ordering; use std::time::Duration; -use async_trait::async_trait; +use serde::Deserialize; +use serde::Serialize; use serde_json::Value as JsonValue; use tokio::sync::Mutex; use tokio::sync::mpsc; use tokio::sync::oneshot; +use tokio::task::JoinSet; use tokio_util::sync::CancellationToken; use tracing::warn; @@ -22,35 +28,162 @@ use crate::runtime::RuntimeCommand; use crate::runtime::RuntimeControlCommand; use crate::runtime::RuntimeEvent; use crate::runtime::RuntimeResponse; -use crate::runtime::TurnMessage; use crate::runtime::WaitOutcome; use crate::runtime::WaitRequest; use crate::runtime::WaitToPendingOutcome; use crate::runtime::WaitToPendingRequest; use crate::runtime::spawn_runtime; -#[async_trait] -pub trait CodeModeTurnHost: Send + Sync { - async fn invoke_tool( - &self, +pub type CodeModeSessionResultFuture<'a, T> = + Pin> + Send + 'a>>; +pub type CodeModeSessionProviderFuture<'a> = + CodeModeSessionResultFuture<'a, Arc>; +pub type ToolInvocationFuture<'a> = + Pin> + Send + 'a>>; +pub type NotificationFuture<'a> = Pin> + Send + 'a>>; + +#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +pub struct CellId(String); + +impl CellId { + pub fn new(value: String) -> Self { + Self(value) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl AsRef for CellId { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl fmt::Display for CellId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +pub struct StartedCell { + pub cell_id: CellId, + initial_response_rx: oneshot::Receiver, +} + +impl StartedCell { + pub async fn initial_response(self) -> Result { + self.initial_response_rx + .await + .map_err(|_| "exec runtime ended unexpectedly".to_string()) + } +} + +/// Host callbacks used by a code-mode session while cells are executing. +pub trait CodeModeSessionDelegate: Send + Sync { + fn invoke_tool<'a>( + &'a self, invocation: CodeModeNestedToolCall, cancellation_token: CancellationToken, - ) -> Result; + ) -> ToolInvocationFuture<'a>; - async fn notify(&self, call_id: String, cell_id: String, text: String) -> Result<(), String>; + fn notify<'a>( + &'a self, + call_id: String, + cell_id: CellId, + text: String, + cancellation_token: CancellationToken, + ) -> NotificationFuture<'a>; + + /// Releases delegate state associated with a cell after it reaches a terminal state. + fn cell_closed(&self, cell_id: &CellId); +} + +pub struct NoopCodeModeSessionDelegate; + +impl CodeModeSessionDelegate for NoopCodeModeSessionDelegate { + fn invoke_tool<'a>( + &'a self, + _invocation: CodeModeNestedToolCall, + cancellation_token: CancellationToken, + ) -> ToolInvocationFuture<'a> { + Box::pin(async move { + cancellation_token.cancelled().await; + Err("code mode nested tools are unavailable".to_string()) + }) + } + + fn notify<'a>( + &'a self, + _call_id: String, + _cell_id: CellId, + _text: String, + _cancellation_token: CancellationToken, + ) -> NotificationFuture<'a> { + Box::pin(async { Ok(()) }) + } + + fn cell_closed(&self, _cell_id: &CellId) {} +} + +/// A durable code-mode session owned by one Codex thread. +/// +/// Cells executed in the same session share stored values. Separate sessions +/// must keep those values isolated. Implementations may execute cells +/// in-process or remotely. +pub trait CodeModeSession: Send + Sync { + fn execute<'a>( + &'a self, + request: ExecuteRequest, + ) -> CodeModeSessionResultFuture<'a, StartedCell>; + + fn wait<'a>(&'a self, request: WaitRequest) -> CodeModeSessionResultFuture<'a, WaitOutcome>; + + fn terminate<'a>(&'a self, cell_id: CellId) -> CodeModeSessionResultFuture<'a, WaitOutcome>; + + fn shutdown<'a>(&'a self) -> CodeModeSessionResultFuture<'a, ()>; +} + +/// Creates code-mode sessions for one Codex thread. +/// +/// Providers choose where a session executes and receive the host delegate that +/// the session should use for nested tool calls and notifications. +pub trait CodeModeSessionProvider: Send + Sync { + fn create_session<'a>( + &'a self, + delegate: Arc, + ) -> CodeModeSessionProviderFuture<'a>; +} + +#[derive(Default)] +pub struct InProcessCodeModeSessionProvider; + +impl CodeModeSessionProvider for InProcessCodeModeSessionProvider { + fn create_session<'a>( + &'a self, + delegate: Arc, + ) -> CodeModeSessionProviderFuture<'a> { + Box::pin(async move { + let session: Arc = + Arc::new(CodeModeService::with_delegate(delegate)); + Ok(session) + }) + } } #[derive(Clone)] -struct SessionHandle { - control_tx: mpsc::UnboundedSender, +struct CellHandle { + control_tx: mpsc::UnboundedSender, runtime_tx: std::sync::mpsc::Sender, + cancellation_token: CancellationToken, } struct Inner { stored_values: Mutex>, - sessions: Mutex>, - turn_message_tx: async_channel::Sender, - turn_message_rx: async_channel::Receiver, + cells: Mutex>, + delegate: Arc, + shutting_down: AtomicBool, next_cell_id: AtomicU64, } @@ -60,54 +193,50 @@ pub struct CodeModeService { impl CodeModeService { pub fn new() -> Self { - let (turn_message_tx, turn_message_rx) = async_channel::unbounded(); + Self::with_delegate(Arc::new(NoopCodeModeSessionDelegate)) + } + pub fn with_delegate(delegate: Arc) -> Self { Self { inner: Arc::new(Inner { stored_values: Mutex::new(HashMap::new()), - sessions: Mutex::new(HashMap::new()), - turn_message_tx, - turn_message_rx, + cells: Mutex::new(HashMap::new()), + delegate, + shutting_down: AtomicBool::new(false), next_cell_id: AtomicU64::new(1), }), } } - pub async fn stored_values(&self) -> HashMap { - self.inner.stored_values.lock().await.clone() + fn allocate_cell_id(&self) -> CellId { + CellId::new( + self.inner + .next_cell_id + .fetch_add(1, Ordering::Relaxed) + .to_string(), + ) } - pub async fn replace_stored_values(&self, values: HashMap) { - *self.inner.stored_values.lock().await = values; - } - - /// Reserves the runtime cell id for a future `execute` request. - /// - /// The runtime can issue nested tool calls before the first `execute` - /// response is returned. Hosts that need a parent trace object for those - /// nested calls should allocate the cell id up front and pass it back on the - /// `ExecuteRequest`. - pub fn allocate_cell_id(&self) -> String { - self.inner - .next_cell_id - .fetch_add(1, Ordering::Relaxed) - .to_string() - } - - pub async fn execute(&self, request: ExecuteRequest) -> Result { + pub async fn execute(&self, request: ExecuteRequest) -> Result { + if self.inner.shutting_down.load(Ordering::Acquire) { + return Err("code mode session is shutting down".to_string()); + } let initial_yield_time_ms = request.yield_time_ms.unwrap_or(DEFAULT_EXEC_YIELD_TIME_MS); let (response_tx, response_rx) = oneshot::channel(); - self.start_session( + let cell_id = self.allocate_cell_id(); + self.start_cell( + cell_id.clone(), request, - SessionResponseSender::Runtime(response_tx), + CellResponseSender::Runtime(response_tx), Some(initial_yield_time_ms), PendingRuntimeMode::Continue, ) .await?; - response_rx - .await - .map_err(|_| "exec runtime ended unexpectedly".to_string()) + Ok(StartedCell { + cell_id, + initial_response_rx: response_rx, + }) } pub async fn execute_to_pending( @@ -115,9 +244,11 @@ impl CodeModeService { request: ExecuteRequest, ) -> Result { let (response_tx, response_rx) = oneshot::channel(); - self.start_session( + let cell_id = self.allocate_cell_id(); + self.start_cell( + cell_id, request, - SessionResponseSender::ExecuteToPending(response_tx), + CellResponseSender::ExecuteToPending(response_tx), /*initial_yield_time_ms*/ None, PendingRuntimeMode::PauseUntilResumed, ) @@ -128,46 +259,50 @@ impl CodeModeService { .map_err(|_| "exec runtime ended unexpectedly".to_string()) } - async fn start_session( + async fn start_cell( &self, + cell_id: CellId, request: ExecuteRequest, - initial_response_tx: SessionResponseSender, + initial_response_tx: CellResponseSender, initial_yield_time_ms: Option, pending_mode: PendingRuntimeMode, ) -> Result<(), String> { - let cell_id = request.cell_id.clone(); let (event_tx, event_rx) = mpsc::unbounded_channel(); let (control_tx, control_rx) = mpsc::unbounded_channel(); + let stored_values = self.inner.stored_values.lock().await.clone(); + let cancellation_token = CancellationToken::new(); let (runtime_tx, runtime_control_tx, runtime_terminate_handle) = { - let mut sessions = self.inner.sessions.lock().await; - if sessions.contains_key(&cell_id) { + let mut cells = self.inner.cells.lock().await; + if self.inner.shutting_down.load(Ordering::Acquire) { + return Err("code mode session is shutting down".to_string()); + } + if cells.contains_key(&cell_id) { return Err(format!("exec cell {cell_id} already exists")); } let (runtime_tx, runtime_control_tx, runtime_terminate_handle) = - spawn_runtime(request, event_tx, pending_mode)?; + spawn_runtime(stored_values, request, event_tx, pending_mode)?; - // Keep the session registry locked through insertion so a - // caller-owned cell id cannot race with another execute and replace - // a live runtime. - sessions.insert( + cells.insert( cell_id.clone(), - SessionHandle { + CellHandle { control_tx, runtime_tx: runtime_tx.clone(), + cancellation_token: cancellation_token.clone(), }, ); (runtime_tx, runtime_control_tx, runtime_terminate_handle) }; - tokio::spawn(run_session_control( + tokio::spawn(run_cell_control( Arc::clone(&self.inner), - SessionControlContext { - cell_id: cell_id.clone(), + CellControlContext { + cell_id, runtime_tx, runtime_control_tx, pending_mode, runtime_terminate_handle, + cancellation_token, }, event_rx, control_rx, @@ -179,34 +314,44 @@ impl CodeModeService { } pub async fn wait(&self, request: WaitRequest) -> Result { - let cell_id = request.cell_id.clone(); - let handle = self - .inner - .sessions - .lock() - .await - .get(&request.cell_id) - .cloned(); + let WaitRequest { + cell_id, + yield_time_ms, + } = request; + let handle = self.inner.cells.lock().await.get(&cell_id).cloned(); let Some(handle) = handle else { return Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))); }; let (response_tx, response_rx) = oneshot::channel(); - let control_message = if request.terminate { - SessionControlCommand::Terminate { response_tx } - } else { - SessionControlCommand::Poll { - yield_time_ms: request.yield_time_ms, - response_tx, - } + let control_message = CellControlCommand::Poll { + yield_time_ms, + response_tx, }; if handle.control_tx.send(control_message).is_err() { return Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))); } match response_rx.await { Ok(response) => Ok(WaitOutcome::LiveCell(response)), - Err(_) => Ok(WaitOutcome::MissingCell(missing_cell_response( - request.cell_id, - ))), + Err(_) => Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))), + } + } + + pub async fn terminate(&self, cell_id: CellId) -> Result { + let handle = self.inner.cells.lock().await.get(&cell_id).cloned(); + let Some(handle) = handle else { + return Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))); + }; + let (response_tx, response_rx) = oneshot::channel(); + if handle + .control_tx + .send(CellControlCommand::Terminate { response_tx }) + .is_err() + { + return Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))); + } + match response_rx.await { + Ok(response) => Ok(WaitOutcome::LiveCell(response)), + Err(_) => Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))), } } @@ -214,14 +359,8 @@ impl CodeModeService { &self, request: WaitToPendingRequest, ) -> Result { - let cell_id = request.cell_id.clone(); - let handle = self - .inner - .sessions - .lock() - .await - .get(&request.cell_id) - .cloned(); + let cell_id = request.cell_id; + let handle = self.inner.cells.lock().await.get(&cell_id).cloned(); let Some(handle) = handle else { return Ok(WaitToPendingOutcome::MissingCell(missing_cell_response( cell_id, @@ -230,7 +369,7 @@ impl CodeModeService { let (response_tx, response_rx) = oneshot::channel(); if handle .control_tx - .send(SessionControlCommand::PollToPending { response_tx }) + .send(CellControlCommand::PollToPending { response_tx }) .is_err() { return Ok(WaitToPendingOutcome::MissingCell(missing_cell_response( @@ -240,74 +379,33 @@ impl CodeModeService { match response_rx.await { Ok(response) => Ok(WaitToPendingOutcome::LiveCell(response)), Err(_) => Ok(WaitToPendingOutcome::MissingCell(missing_cell_response( - request.cell_id, + cell_id, ))), } } - pub fn start_turn_worker(&self, host: Arc) -> CodeModeTurnWorker { - let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); - let inner = Arc::clone(&self.inner); - let turn_message_rx = self.inner.turn_message_rx.clone(); - - tokio::spawn(async move { - loop { - let next_message = tokio::select! { - _ = &mut shutdown_rx => break, - message = turn_message_rx.recv() => message.ok(), - }; - let Some(next_message) = next_message else { - break; - }; - match next_message { - TurnMessage::Notify { - cell_id, - call_id, - text, - } => { - if let Err(err) = host.notify(call_id, cell_id.clone(), text).await { - warn!( - "failed to deliver code mode notification for cell {cell_id}: {err}" - ); - } - } - TurnMessage::ToolCall(invocation) => { - let host = Arc::clone(&host); - let inner = Arc::clone(&inner); - tokio::spawn(async move { - let cell_id = invocation.cell_id.clone(); - let runtime_tool_call_id = invocation.runtime_tool_call_id.clone(); - let response = - host.invoke_tool(invocation, CancellationToken::new()).await; - let runtime_tx = inner - .sessions - .lock() - .await - .get(&cell_id) - .map(|handle| handle.runtime_tx.clone()); - let Some(runtime_tx) = runtime_tx else { - return; - }; - let command = match response { - Ok(result) => RuntimeCommand::ToolResponse { - id: runtime_tool_call_id, - result, - }, - Err(error_text) => RuntimeCommand::ToolError { - id: runtime_tool_call_id, - error_text, - }, - }; - let _ = runtime_tx.send(command); - }); - } - } - } - }); - - CodeModeTurnWorker { - shutdown_tx: Some(shutdown_tx), + pub async fn shutdown(&self) -> Result<(), String> { + self.inner.shutting_down.store(true, Ordering::Release); + let handles = self + .inner + .cells + .lock() + .await + .values() + .cloned() + .collect::>(); + for handle in handles { + handle.cancellation_token.cancel(); + let (response_tx, _response_rx) = oneshot::channel(); + let _ = handle + .control_tx + .send(CellControlCommand::Terminate { response_tx }); + let _ = handle.runtime_tx.send(RuntimeCommand::Terminate); } + while !self.inner.cells.lock().await.is_empty() { + tokio::task::yield_now().await; + } + Ok(()) } } @@ -317,19 +415,44 @@ impl Default for CodeModeService { } } -pub struct CodeModeTurnWorker { - shutdown_tx: Option>, -} - -impl Drop for CodeModeTurnWorker { +impl Drop for CodeModeService { fn drop(&mut self) { - if let Some(shutdown_tx) = self.shutdown_tx.take() { - let _ = shutdown_tx.send(()); + self.inner.shutting_down.store(true, Ordering::Release); + if let Ok(cells) = self.inner.cells.try_lock() { + for handle in cells.values() { + handle.cancellation_token.cancel(); + let (response_tx, _response_rx) = oneshot::channel(); + let _ = handle + .control_tx + .send(CellControlCommand::Terminate { response_tx }); + let _ = handle.runtime_tx.send(RuntimeCommand::Terminate); + } } } } -enum SessionControlCommand { +impl CodeModeSession for CodeModeService { + fn execute<'a>( + &'a self, + request: ExecuteRequest, + ) -> CodeModeSessionResultFuture<'a, StartedCell> { + Box::pin(CodeModeService::execute(self, request)) + } + + fn wait<'a>(&'a self, request: WaitRequest) -> CodeModeSessionResultFuture<'a, WaitOutcome> { + Box::pin(CodeModeService::wait(self, request)) + } + + fn terminate<'a>(&'a self, cell_id: CellId) -> CodeModeSessionResultFuture<'a, WaitOutcome> { + Box::pin(CodeModeService::terminate(self, cell_id)) + } + + fn shutdown<'a>(&'a self) -> CodeModeSessionResultFuture<'a, ()> { + Box::pin(CodeModeService::shutdown(self)) + } +} + +enum CellControlCommand { Poll { yield_time_ms: u64, response_tx: oneshot::Sender, @@ -342,58 +465,56 @@ enum SessionControlCommand { }, } -enum SessionResponseSender { +enum CellResponseSender { Runtime(oneshot::Sender), ExecuteToPending(oneshot::Sender), } struct PendingResult { content_items: Vec, - stored_values: HashMap, error_text: Option, } -struct SessionControlContext { - cell_id: String, +struct CellControlContext { + cell_id: CellId, runtime_tx: std::sync::mpsc::Sender, runtime_control_tx: std::sync::mpsc::Sender, pending_mode: PendingRuntimeMode, runtime_terminate_handle: v8::IsolateHandle, + cancellation_token: CancellationToken, } -fn missing_cell_response(cell_id: String) -> RuntimeResponse { +fn missing_cell_response(cell_id: CellId) -> RuntimeResponse { RuntimeResponse::Result { error_text: Some(format!("exec cell {cell_id} not found")), cell_id, content_items: Vec::new(), - stored_values: HashMap::new(), } } -fn pending_result_response(cell_id: &str, result: PendingResult) -> RuntimeResponse { +fn pending_result_response(cell_id: &CellId, result: PendingResult) -> RuntimeResponse { RuntimeResponse::Result { - cell_id: cell_id.to_string(), + cell_id: cell_id.clone(), content_items: result.content_items, - stored_values: result.stored_values, error_text: result.error_text, } } -fn send_terminal_response(response_tx: SessionResponseSender, response: RuntimeResponse) { +fn send_terminal_response(response_tx: CellResponseSender, response: RuntimeResponse) { match response_tx { - SessionResponseSender::Runtime(response_tx) => { + CellResponseSender::Runtime(response_tx) => { let _ = response_tx.send(response); } - SessionResponseSender::ExecuteToPending(response_tx) => { + CellResponseSender::ExecuteToPending(response_tx) => { let _ = response_tx.send(ExecuteToPendingOutcome::Completed(response)); } } } fn send_or_buffer_result( - cell_id: &str, + cell_id: &CellId, result: PendingResult, - response_tx: &mut Option, + response_tx: &mut Option, pending_result: &mut Option, ) -> bool { if let Some(response_tx) = response_tx.take() { @@ -407,42 +528,41 @@ fn send_or_buffer_result( } fn send_yield_response( - cell_id: &str, + cell_id: &CellId, content_items: &mut Vec, - response_tx: &mut Option, + response_tx: &mut Option, ) { let Some(current_response_tx) = response_tx.take() else { return; }; match current_response_tx { - SessionResponseSender::Runtime(response_tx) => { + CellResponseSender::Runtime(response_tx) => { let _ = response_tx.send(RuntimeResponse::Yielded { - cell_id: cell_id.to_string(), + cell_id: cell_id.clone(), content_items: std::mem::take(content_items), }); } - SessionResponseSender::ExecuteToPending(execute_to_pending_tx) => { - *response_tx = Some(SessionResponseSender::ExecuteToPending( - execute_to_pending_tx, - )); + CellResponseSender::ExecuteToPending(execute_to_pending_tx) => { + *response_tx = Some(CellResponseSender::ExecuteToPending(execute_to_pending_tx)); } } } -async fn run_session_control( +async fn run_cell_control( inner: Arc, - context: SessionControlContext, + context: CellControlContext, mut event_rx: mpsc::UnboundedReceiver, - mut control_rx: mpsc::UnboundedReceiver, - initial_response_tx: SessionResponseSender, + mut control_rx: mpsc::UnboundedReceiver, + initial_response_tx: CellResponseSender, initial_yield_time_ms: Option, ) { - let SessionControlContext { + let CellControlContext { cell_id, runtime_tx, runtime_control_tx, pending_mode, runtime_terminate_handle, + cancellation_token, } = context; let mut content_items = Vec::new(); let mut pending_tool_call_ids = Vec::new(); @@ -451,6 +571,7 @@ async fn run_session_control( let mut termination_requested = false; let mut runtime_closed = false; let mut yield_timer: Option>> = None; + let mut notification_tasks = JoinSet::new(); loop { tokio::select! { @@ -476,7 +597,6 @@ async fn run_session_control( if pending_result.is_none() { let result = PendingResult { content_items: std::mem::take(&mut content_items), - stored_values: HashMap::new(), error_text: Some("exec runtime ended unexpectedly".to_string()), }; if send_or_buffer_result( @@ -499,11 +619,11 @@ async fn run_session_control( RuntimeEvent::Pending => { if let Some(current_response_tx) = response_tx.take() { match current_response_tx { - SessionResponseSender::Runtime(runtime_response_tx) => { + CellResponseSender::Runtime(runtime_response_tx) => { response_tx = - Some(SessionResponseSender::Runtime(runtime_response_tx)); + Some(CellResponseSender::Runtime(runtime_response_tx)); } - SessionResponseSender::ExecuteToPending(response_tx) => { + CellResponseSender::ExecuteToPending(response_tx) => { let _ = response_tx.send(ExecuteToPendingOutcome::Pending { cell_id: cell_id.clone(), content_items: std::mem::take(&mut content_items), @@ -523,11 +643,26 @@ async fn run_session_control( send_yield_response(&cell_id, &mut content_items, &mut response_tx); } RuntimeEvent::Notify { call_id, text } => { - let _ = inner.turn_message_tx.send(TurnMessage::Notify { - cell_id: cell_id.clone(), - call_id, - text, - }).await; + let delegate = Arc::clone(&inner.delegate); + let cell_id = cell_id.clone(); + let cancellation_token = cancellation_token.child_token(); + notification_tasks.spawn(async move { + tokio::select! { + result = delegate.notify( + call_id, + cell_id.clone(), + text, + cancellation_token.clone(), + ) => { + if let Err(err) = result { + warn!( + "failed to deliver code mode notification for cell {cell_id}: {err}" + ); + } + } + _ = cancellation_token.cancelled() => {} + } + }); } RuntimeEvent::ToolCall { id, @@ -540,18 +675,28 @@ async fn run_session_control( } let tool_call = CodeModeNestedToolCall { cell_id: cell_id.clone(), - runtime_tool_call_id: id, + runtime_tool_call_id: id.clone(), tool_name: name, tool_kind: kind, input, }; - let _ = inner - .turn_message_tx - .send(TurnMessage::ToolCall(tool_call)) - .await; + let delegate = Arc::clone(&inner.delegate); + let runtime_tx = runtime_tx.clone(); + let cancellation_token = cancellation_token.child_token(); + tokio::spawn(async move { + let response = tokio::select! { + response = delegate.invoke_tool(tool_call, cancellation_token.clone()) => response, + _ = cancellation_token.cancelled() => return, + }; + let command = match response { + Ok(result) => RuntimeCommand::ToolResponse { id, result }, + Err(error_text) => RuntimeCommand::ToolError { id, error_text }, + }; + let _ = runtime_tx.send(command); + }); } RuntimeEvent::Result { - stored_values, + stored_value_writes, error_text, } => { yield_timer = None; @@ -565,9 +710,14 @@ async fn run_session_control( } break; } + drain_notification_tasks(&mut notification_tasks).await; + inner + .stored_values + .lock() + .await + .extend(stored_value_writes); let result = PendingResult { content_items: std::mem::take(&mut content_items), - stored_values, error_text, }; if send_or_buffer_result( @@ -581,12 +731,19 @@ async fn run_session_control( } } } + task_result = notification_tasks.join_next(), if !notification_tasks.is_empty() => { + if let Some(Err(err)) = task_result + && !err.is_cancelled() + { + warn!("code mode notification task failed: {err}"); + } + } maybe_command = control_rx.recv() => { let Some(command) = maybe_command else { break; }; match command { - SessionControlCommand::Poll { + CellControlCommand::Poll { yield_time_ms, response_tx: next_response_tx, } => { @@ -594,11 +751,11 @@ async fn run_session_control( let _ = next_response_tx.send(pending_result_response(&cell_id, result)); break; } - response_tx = Some(SessionResponseSender::Runtime(next_response_tx)); + response_tx = Some(CellResponseSender::Runtime(next_response_tx)); yield_timer = Some(Box::pin(tokio::time::sleep(Duration::from_millis(yield_time_ms)))); resume_paused_runtime(&runtime_control_tx, pending_mode); } - SessionControlCommand::PollToPending { + CellControlCommand::PollToPending { response_tx: next_response_tx, } => { if let Some(result) = pending_result.take() { @@ -608,18 +765,19 @@ async fn run_session_control( break; } response_tx = - Some(SessionResponseSender::ExecuteToPending(next_response_tx)); + Some(CellResponseSender::ExecuteToPending(next_response_tx)); yield_timer = None; resume_paused_runtime(&runtime_control_tx, pending_mode); } - SessionControlCommand::Terminate { response_tx: next_response_tx } => { + CellControlCommand::Terminate { response_tx: next_response_tx } => { if let Some(result) = pending_result.take() { let _ = next_response_tx.send(pending_result_response(&cell_id, result)); break; } - response_tx = Some(SessionResponseSender::Runtime(next_response_tx)); + response_tx = Some(CellResponseSender::Runtime(next_response_tx)); termination_requested = true; + cancellation_token.cancel(); yield_timer = None; let _ = runtime_tx.send(RuntimeCommand::Terminate); terminate_paused_runtime(&runtime_control_tx, pending_mode); @@ -653,8 +811,21 @@ async fn run_session_control( } let _ = runtime_tx.send(RuntimeCommand::Terminate); + cancellation_token.cancel(); + drain_notification_tasks(&mut notification_tasks).await; terminate_paused_runtime(&runtime_control_tx, pending_mode); - inner.sessions.lock().await.remove(&cell_id); + inner.cells.lock().await.remove(&cell_id); + inner.delegate.cell_closed(&cell_id); +} + +async fn drain_notification_tasks(notification_tasks: &mut JoinSet<()>) { + while let Some(result) = notification_tasks.join_next().await { + if let Err(err) = result + && !err.is_cancelled() + { + warn!("code mode notification task failed: {err}"); + } + } } fn resume_paused_runtime( @@ -680,6 +851,7 @@ mod tests { use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::AtomicU64; + use std::sync::atomic::Ordering; use std::time::Duration; use codex_protocol::ToolName; @@ -688,19 +860,21 @@ mod tests { use tokio::sync::mpsc; use tokio::sync::oneshot; + use super::CellControlCommand; + use super::CellControlContext; + use super::CellId; + use super::CellResponseSender; use super::CodeModeService; use super::Inner; + use super::NoopCodeModeSessionDelegate; use super::PendingRuntimeMode; use super::RuntimeCommand; use super::RuntimeResponse; - use super::SessionControlCommand; - use super::SessionControlContext; - use super::SessionResponseSender; use super::WaitOutcome; use super::WaitRequest; use super::WaitToPendingOutcome; use super::WaitToPendingRequest; - use super::run_session_control; + use super::run_cell_control; use crate::CodeModeToolKind; use crate::FunctionCallOutputContentItem; use crate::ToolDefinition; @@ -711,23 +885,34 @@ mod tests { fn execute_request(source: &str) -> ExecuteRequest { ExecuteRequest { - cell_id: "1".to_string(), tool_call_id: "call_1".to_string(), enabled_tools: Vec::new(), source: source.to_string(), - stored_values: HashMap::new(), yield_time_ms: Some(1), max_output_tokens: None, } } + fn cell_id(value: &str) -> CellId { + CellId::new(value.to_string()) + } + + async fn execute(service: &CodeModeService, request: ExecuteRequest) -> RuntimeResponse { + service + .execute(request) + .await + .unwrap() + .initial_response() + .await + .unwrap() + } + fn test_inner() -> Arc { - let (turn_message_tx, turn_message_rx) = async_channel::unbounded(); Arc::new(Inner { stored_values: Mutex::new(HashMap::new()), - sessions: Mutex::new(HashMap::new()), - turn_message_tx, - turn_message_rx, + cells: Mutex::new(HashMap::new()), + delegate: Arc::new(NoopCodeModeSessionDelegate), + shutting_down: std::sync::atomic::AtomicBool::new(false), next_cell_id: AtomicU64::new(1), }) } @@ -736,28 +921,138 @@ mod tests { async fn synchronous_exit_returns_successfully() { let service = CodeModeService::new(); - let response = service - .execute(ExecuteRequest { + let response = execute( + &service, + ExecuteRequest { source: r#"text("before"); exit(); text("after");"#.to_string(), yield_time_ms: None, ..execute_request("") - }) - .await - .unwrap(); + }, + ) + .await; assert_eq!( response, RuntimeResponse::Result { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: vec![FunctionCallOutputContentItem::InputText { text: "before".to_string(), }], - stored_values: HashMap::new(), error_text: None, } ); } + #[tokio::test] + async fn stored_values_are_shared_between_cells_but_not_sessions() { + let first_session = CodeModeService::new(); + let second_session = CodeModeService::new(); + + let write_response = execute( + &first_session, + ExecuteRequest { + source: r#"store("key", "visible");"#.to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + let same_session = execute( + &first_session, + ExecuteRequest { + source: r#"text(String(load("key")));"#.to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + let other_session = execute( + &second_session, + ExecuteRequest { + source: r#"text(String(load("key")));"#.to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + write_response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: Vec::new(), + error_text: None, + } + ); + assert_eq!( + same_session, + RuntimeResponse::Result { + cell_id: cell_id("2"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "visible".to_string(), + }], + error_text: None, + } + ); + assert_eq!( + other_session, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "undefined".to_string(), + }], + error_text: None, + } + ); + } + + #[tokio::test] + async fn shutdown_interrupts_cpu_bound_cells() { + let service = CodeModeService::new(); + + let cell = service + .execute(ExecuteRequest { + source: "while (true) {}".to_string(), + ..execute_request("") + }) + .await + .unwrap(); + assert_eq!( + cell.initial_response().await.unwrap(), + RuntimeResponse::Yielded { + cell_id: cell_id("1"), + content_items: Vec::new(), + } + ); + + tokio::time::timeout(Duration::from_secs(1), service.shutdown()) + .await + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn start_cell_rejects_new_cell_after_shutdown_begins() { + let service = CodeModeService::new(); + service.inner.shutting_down.store(true, Ordering::Release); + let (response_tx, _response_rx) = oneshot::channel(); + + let error = service + .start_cell( + cell_id("late-cell"), + execute_request(""), + CellResponseSender::Runtime(response_tx), + Some(/*initial_yield_time_ms*/ 1), + PendingRuntimeMode::Continue, + ) + .await + .unwrap_err(); + + assert_eq!(error, "code mode session is shutting down".to_string()); + assert!(service.inner.cells.lock().await.is_empty()); + } + #[tokio::test] async fn execute_to_pending_returns_completed_for_synchronous_results() { let service = CodeModeService::new(); @@ -774,11 +1069,10 @@ mod tests { assert_eq!( response, ExecuteToPendingOutcome::Completed(RuntimeResponse::Result { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: vec![FunctionCallOutputContentItem::InputText { text: "done".to_string(), }], - stored_values: HashMap::new(), error_text: None, }) ); @@ -803,7 +1097,7 @@ mod tests { assert_eq!( response, ExecuteToPendingOutcome::Pending { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: vec![FunctionCallOutputContentItem::InputText { text: "before".to_string(), }], @@ -811,19 +1105,12 @@ mod tests { } ); - let termination = service - .wait(WaitRequest { - cell_id: "1".to_string(), - yield_time_ms: 1, - terminate: true, - }) - .await - .unwrap(); + let termination = service.terminate(cell_id("1")).await.unwrap(); assert_eq!( termination, WaitOutcome::LiveCell(RuntimeResponse::Terminated { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: Vec::new(), }) ); @@ -859,25 +1146,18 @@ await Promise.all([ assert_eq!( response, ExecuteToPendingOutcome::Pending { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: Vec::new(), pending_tool_call_ids: vec!["tool-1".to_string(), "tool-2".to_string()], } ); - let termination = service - .wait(WaitRequest { - cell_id: "1".to_string(), - yield_time_ms: 1, - terminate: true, - }) - .await - .unwrap(); + let termination = service.terminate(cell_id("1")).await.unwrap(); assert_eq!( termination, WaitOutcome::LiveCell(RuntimeResponse::Terminated { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: Vec::new(), }) ); @@ -916,7 +1196,7 @@ await Promise.all([ assert_eq!( initial_response, ExecuteToPendingOutcome::Pending { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: Vec::new(), pending_tool_call_ids: vec!["tool-1".to_string(), "tool-2".to_string()], } @@ -924,10 +1204,10 @@ await Promise.all([ let runtime_tx = service .inner - .sessions + .cells .lock() .await - .get("1") + .get(&cell_id("1")) .unwrap() .runtime_tx .clone(); @@ -938,7 +1218,7 @@ await Promise.all([ let resumed_response = tokio::time::timeout( Duration::from_secs(1), service.wait_to_pending(WaitToPendingRequest { - cell_id: "1".to_string(), + cell_id: cell_id("1"), }), ) .await @@ -948,25 +1228,18 @@ await Promise.all([ assert_eq!( resumed_response, WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Pending { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: Vec::new(), pending_tool_call_ids: vec!["tool-3".to_string()], }) ); - let termination = service - .wait(WaitRequest { - cell_id: "1".to_string(), - yield_time_ms: 1, - terminate: true, - }) - .await - .unwrap(); + let termination = service.terminate(cell_id("1")).await.unwrap(); assert_eq!( termination, WaitOutcome::LiveCell(RuntimeResponse::Terminated { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: Vec::new(), }) ); @@ -993,7 +1266,7 @@ await new Promise(() => {}); assert_eq!( initial_response, ExecuteToPendingOutcome::Pending { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: Vec::new(), pending_tool_call_ids: Vec::new(), } @@ -1001,10 +1274,10 @@ await new Promise(() => {}); let runtime_tx = service .inner - .sessions + .cells .lock() .await - .get("1") + .get(&cell_id("1")) .unwrap() .runtime_tx .clone(); @@ -1015,7 +1288,7 @@ await new Promise(() => {}); let resumed_response = tokio::time::timeout( Duration::from_secs(1), service.wait_to_pending(WaitToPendingRequest { - cell_id: "1".to_string(), + cell_id: cell_id("1"), }), ) .await @@ -1025,7 +1298,7 @@ await new Promise(() => {}); assert_eq!( resumed_response, WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Pending { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: vec![FunctionCallOutputContentItem::InputText { text: "after".to_string(), }], @@ -1033,19 +1306,12 @@ await new Promise(() => {}); }) ); - let termination = service - .wait(WaitRequest { - cell_id: "1".to_string(), - yield_time_ms: 1, - terminate: true, - }) - .await - .unwrap(); + let termination = service.terminate(cell_id("1")).await.unwrap(); assert_eq!( termination, WaitOutcome::LiveCell(RuntimeResponse::Terminated { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: Vec::new(), }) ); @@ -1071,7 +1337,7 @@ text("done"); assert_eq!( initial_response, ExecuteToPendingOutcome::Pending { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: Vec::new(), pending_tool_call_ids: Vec::new(), } @@ -1079,10 +1345,10 @@ text("done"); let runtime_tx = service .inner - .sessions + .cells .lock() .await - .get("1") + .get(&cell_id("1")) .unwrap() .runtime_tx .clone(); @@ -1093,7 +1359,7 @@ text("done"); let resumed_response = tokio::time::timeout( Duration::from_secs(1), service.wait_to_pending(WaitToPendingRequest { - cell_id: "1".to_string(), + cell_id: cell_id("1"), }), ) .await @@ -1104,11 +1370,10 @@ text("done"); resumed_response, WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Completed( RuntimeResponse::Result { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: vec![FunctionCallOutputContentItem::InputText { text: "done".to_string(), }], - stored_values: HashMap::new(), error_text: None, } )) @@ -1119,23 +1384,23 @@ text("done"); async fn v8_console_is_not_exposed_on_global_this() { let service = CodeModeService::new(); - let response = service - .execute(ExecuteRequest { + let response = execute( + &service, + ExecuteRequest { source: r#"text(String(Object.hasOwn(globalThis, "console")));"#.to_string(), yield_time_ms: None, ..execute_request("") - }) - .await - .unwrap(); + }, + ) + .await; assert_eq!( response, RuntimeResponse::Result { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: vec![FunctionCallOutputContentItem::InputText { text: "false".to_string(), }], - stored_values: HashMap::new(), error_text: None, } ); @@ -1145,8 +1410,9 @@ text("done"); async fn date_locale_string_formats_with_icu_data() { let service = CodeModeService::new(); - let response = service - .execute(ExecuteRequest { + let response = execute( + &service, + ExecuteRequest { source: r#" const value = new Date("2025-01-02T03:04:05Z") .toLocaleString("fr-FR", { @@ -1164,18 +1430,17 @@ text(value); .to_string(), yield_time_ms: None, ..execute_request("") - }) - .await - .unwrap(); + }, + ) + .await; assert_eq!( response, RuntimeResponse::Result { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: vec![FunctionCallOutputContentItem::InputText { text: "jeudi 2 janvier \u{e0} 03:04:05".to_string(), }], - stored_values: HashMap::new(), error_text: None, } ); @@ -1185,8 +1450,9 @@ text(value); async fn intl_date_time_format_formats_with_icu_data() { let service = CodeModeService::new(); - let response = service - .execute(ExecuteRequest { + let response = execute( + &service, + ExecuteRequest { source: r#" const formatter = new Intl.DateTimeFormat("fr-FR", { weekday: "long", @@ -1203,18 +1469,17 @@ text(formatter.format(new Date("2025-01-02T03:04:05Z"))); .to_string(), yield_time_ms: None, ..execute_request("") - }) - .await - .unwrap(); + }, + ) + .await; assert_eq!( response, RuntimeResponse::Result { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: vec![FunctionCallOutputContentItem::InputText { text: "jeudi 2 janvier \u{e0} 03:04:05".to_string(), }], - stored_values: HashMap::new(), error_text: None, } ); @@ -1224,8 +1489,9 @@ text(formatter.format(new Date("2025-01-02T03:04:05Z"))); async fn output_helpers_return_undefined() { let service = CodeModeService::new(); - let response = service - .execute(ExecuteRequest { + let response = execute( + &service, + ExecuteRequest { source: r#" const returnsUndefined = [ text("first"), @@ -1237,14 +1503,14 @@ text(JSON.stringify(returnsUndefined)); .to_string(), yield_time_ms: None, ..execute_request("") - }) - .await - .unwrap(); + }, + ) + .await; assert_eq!( response, RuntimeResponse::Result { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: vec![ FunctionCallOutputContentItem::InputText { text: "first".to_string(), @@ -1257,7 +1523,6 @@ text(JSON.stringify(returnsUndefined)); text: "[true,true,true]".to_string(), }, ], - stored_values: HashMap::new(), error_text: None, } ); @@ -1267,8 +1532,9 @@ text(JSON.stringify(returnsUndefined)); async fn image_helper_accepts_raw_mcp_image_block_with_original_detail() { let service = CodeModeService::new(); - let response = service - .execute(ExecuteRequest { + let response = execute( + &service, + ExecuteRequest { source: r#" image({ type: "image", @@ -1280,19 +1546,18 @@ image({ .to_string(), yield_time_ms: None, ..execute_request("") - }) - .await - .unwrap(); + }, + ) + .await; assert_eq!( response, RuntimeResponse::Result { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: vec![FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==".to_string(), detail: Some(crate::ImageDetail::Original), }], - stored_values: HashMap::new(), error_text: None, } ); @@ -1302,13 +1567,14 @@ image({ async fn image_helper_second_arg_overrides_explicit_object_detail() { let service = CodeModeService::new(); - let response = service - .execute(ExecuteRequest { + let response = execute( + &service, + ExecuteRequest { source: r#" image( { image_url: "https://example.com/image.jpg", - detail: "low", + detail: "high", }, "original", ); @@ -1316,19 +1582,18 @@ image( .to_string(), yield_time_ms: None, ..execute_request("") - }) - .await - .unwrap(); + }, + ) + .await; assert_eq!( response, RuntimeResponse::Result { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: vec![FunctionCallOutputContentItem::InputImage { image_url: "https://example.com/image.jpg".to_string(), detail: Some(crate::ImageDetail::Original), }], - stored_values: HashMap::new(), error_text: None, } ); @@ -1338,8 +1603,9 @@ image( async fn image_helper_second_arg_overrides_raw_mcp_image_detail() { let service = CodeModeService::new(); - let response = service - .execute(ExecuteRequest { + let response = execute( + &service, + ExecuteRequest { source: r#" image( { @@ -1348,36 +1614,101 @@ image( mimeType: "image/png", _meta: { "codex/imageDetail": "original" }, }, - "low", + "high", ); "# .to_string(), yield_time_ms: None, ..execute_request("") - }) - .await - .unwrap(); + }, + ) + .await; assert_eq!( response, RuntimeResponse::Result { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: vec![FunctionCallOutputContentItem::InputImage { image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==".to_string(), + detail: Some(crate::ImageDetail::High), + }], + error_text: None, + } + ); + } + + #[tokio::test] + async fn image_helper_accepts_low_detail() { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source: r#" +image({ + image_url: "https://example.com/image.jpg", + detail: "low", +}); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: vec![FunctionCallOutputContentItem::InputImage { + image_url: "https://example.com/image.jpg".to_string(), detail: Some(crate::ImageDetail::Low), }], - stored_values: HashMap::new(), error_text: None, } ); } + #[tokio::test] + async fn image_helper_rejects_unsupported_detail() { + let service = CodeModeService::new(); + + let response = execute( + &service, + ExecuteRequest { + source: r#" +image({ + image_url: "https://example.com/image.jpg", + detail: "medium", +}); +"# + .to_string(), + yield_time_ms: None, + ..execute_request("") + }, + ) + .await; + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id: cell_id("1"), + content_items: Vec::new(), + error_text: Some( + "image detail must be one of: auto, low, high, original".to_string() + ), + } + ); + } + #[tokio::test] async fn image_helper_rejects_raw_mcp_result_container() { let service = CodeModeService::new(); - let response = service - .execute(ExecuteRequest { + let response = execute( + &service, + ExecuteRequest { source: r#" image({ content: [ @@ -1394,16 +1725,15 @@ image({ .to_string(), yield_time_ms: None, ..execute_request("") - }) - .await - .unwrap(); + }, + ) + .await; assert_eq!( response, RuntimeResponse::Result { - cell_id: "1".to_string(), + cell_id: cell_id("1"), content_items: Vec::new(), - stored_values: HashMap::new(), error_text: Some( "image expects a non-empty image URL string, an object with image_url and optional detail, or a raw MCP image block".to_string(), ), @@ -1417,9 +1747,8 @@ image({ let response = service .wait(WaitRequest { - cell_id: "missing".to_string(), + cell_id: cell_id("missing"), yield_time_ms: 1, - terminate: false, }) .await .unwrap(); @@ -1427,9 +1756,8 @@ image({ assert_eq!( response, WaitOutcome::MissingCell(RuntimeResponse::Result { - cell_id: "missing".to_string(), + cell_id: cell_id("missing"), content_items: Vec::new(), - stored_values: HashMap::new(), error_text: Some("exec cell missing not found".to_string()), }) ); @@ -1443,6 +1771,7 @@ image({ let (initial_response_tx, initial_response_rx) = oneshot::channel(); let (runtime_event_tx, _runtime_event_rx) = mpsc::unbounded_channel(); let (runtime_tx, runtime_control_tx, runtime_terminate_handle) = spawn_runtime( + HashMap::new(), ExecuteRequest { source: "await new Promise(() => {})".to_string(), yield_time_ms: None, @@ -1453,18 +1782,19 @@ image({ ) .unwrap(); - tokio::spawn(run_session_control( + tokio::spawn(run_cell_control( inner, - SessionControlContext { - cell_id: "cell-1".to_string(), + CellControlContext { + cell_id: cell_id("cell-1"), runtime_tx: runtime_tx.clone(), runtime_control_tx, pending_mode: PendingRuntimeMode::Continue, runtime_terminate_handle, + cancellation_token: tokio_util::sync::CancellationToken::new(), }, event_rx, control_rx, - SessionResponseSender::Runtime(initial_response_tx), + CellResponseSender::Runtime(initial_response_tx), Some(/*initial_yield_time_ms*/ 60_000), )); @@ -1473,14 +1803,14 @@ image({ assert_eq!( initial_response_rx.await.unwrap(), RuntimeResponse::Yielded { - cell_id: "cell-1".to_string(), + cell_id: cell_id("cell-1"), content_items: Vec::new(), } ); let (terminate_response_tx, terminate_response_rx) = oneshot::channel(); control_tx - .send(SessionControlCommand::Terminate { + .send(CellControlCommand::Terminate { response_tx: terminate_response_tx, }) .unwrap(); @@ -1497,7 +1827,7 @@ image({ assert_eq!( terminate_response.await, RuntimeResponse::Terminated { - cell_id: "cell-1".to_string(), + cell_id: cell_id("cell-1"), content_items: Vec::new(), } ); diff --git a/codex-rs/codex-api/Cargo.toml b/codex-rs/codex-api/Cargo.toml index 08f70cf33c..07d855725a 100644 --- a/codex-rs/codex-api/Cargo.toml +++ b/codex-rs/codex-api/Cargo.toml @@ -16,6 +16,7 @@ codex-utils-rustls-provider = { workspace = true } futures = { workspace = true } http = { workspace = true } reqwest = { workspace = true, features = ["json", "stream"] } +schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/codex-rs/codex-api/src/api_bridge.rs b/codex-rs/codex-api/src/api_bridge.rs index 4f37c2ff9b..1c34d8bbf2 100644 --- a/codex-rs/codex-api/src/api_bridge.rs +++ b/codex-rs/codex-api/src/api_bridge.rs @@ -2,6 +2,7 @@ use crate::TransportError; use crate::error::ApiError; use crate::rate_limits::parse_promo_message; use crate::rate_limits::parse_rate_limit_for_limit; +use crate::rate_limits::parse_rate_limit_reached_type; use base64::Engine; use chrono::DateTime; use chrono::Utc; @@ -85,6 +86,8 @@ pub fn map_api_error(err: ApiError) -> CodexErr { parse_rate_limit_for_limit(map, limit_id.as_deref()) }); let promo_message = headers.as_ref().and_then(parse_promo_message); + let rate_limit_reached_type = + headers.as_ref().and_then(parse_rate_limit_reached_type); let resets_at = err .error .resets_at @@ -94,6 +97,7 @@ pub fn map_api_error(err: ApiError) -> CodexErr { resets_at, rate_limits: rate_limits.map(Box::new), promo_message, + rate_limit_reached_type, }); } else if err.error.error_type.as_deref() == Some("usage_not_included") { return CodexErr::UsageNotIncluded; @@ -123,7 +127,7 @@ pub fn map_api_error(err: ApiError) -> CodexErr { status: http::StatusCode::INTERNAL_SERVER_ERROR, request_id: None, }), - TransportError::Timeout => CodexErr::Timeout, + TransportError::Timeout => CodexErr::RequestTimeout, TransportError::Network(msg) | TransportError::Build(msg) => { CodexErr::Stream(msg, None) } diff --git a/codex-rs/codex-api/src/api_bridge_tests.rs b/codex-rs/codex-api/src/api_bridge_tests.rs index af7e34a649..101e5566fe 100644 --- a/codex-rs/codex-api/src/api_bridge_tests.rs +++ b/codex-rs/codex-api/src/api_bridge_tests.rs @@ -194,6 +194,37 @@ fn map_api_error_does_not_fallback_limit_name_to_limit_id() { ); } +#[test] +fn map_api_error_ignores_unparseable_rate_limit_reached_type_headers() { + let values = [ + http::HeaderValue::from_static("future_rate_limit_reached_type"), + http::HeaderValue::from_bytes(&[0xff]).expect("valid opaque header value"), + ]; + + for value in values { + let mut headers = HeaderMap::new(); + headers.insert("x-codex-rate-limit-reached-type", value); + let body = serde_json::json!({ + "error": { + "type": "usage_limit_reached", + "plan_type": "pro", + } + }) + .to_string(); + let err = map_api_error(ApiError::Transport(TransportError::Http { + status: http::StatusCode::TOO_MANY_REQUESTS, + url: Some("http://example.com/v1/responses".to_string()), + headers: Some(headers), + body: Some(body), + })); + + let CodexErr::UsageLimitReached(usage_limit) = err else { + panic!("expected CodexErr::UsageLimitReached, got {err:?}"); + }; + assert_eq!(usage_limit.rate_limit_reached_type, None); + } +} + #[test] fn map_api_error_extracts_identity_auth_details_from_headers() { let mut headers = HeaderMap::new(); diff --git a/codex-rs/codex-api/src/endpoint/compact.rs b/codex-rs/codex-api/src/endpoint/compact.rs index 336e958d4e..a3da854844 100644 --- a/codex-rs/codex-api/src/endpoint/compact.rs +++ b/codex-rs/codex-api/src/endpoint/compact.rs @@ -11,6 +11,7 @@ use http::Method; use serde::Deserialize; use serde_json::to_value; use std::sync::Arc; +use std::time::Duration; pub struct CompactClient { session: EndpointSession, @@ -37,10 +38,19 @@ impl CompactClient { &self, body: serde_json::Value, extra_headers: HeaderMap, + request_timeout: Duration, ) -> Result, ApiError> { let resp = self .session - .execute(Method::POST, Self::path(), extra_headers, Some(body)) + .execute_with( + Method::POST, + Self::path(), + extra_headers, + Some(body), + |req| { + req.timeout = Some(request_timeout); + }, + ) .await?; let parsed: CompactHistoryResponse = serde_json::from_slice(&resp.body).map_err(|e| ApiError::Stream(e.to_string()))?; @@ -51,10 +61,11 @@ impl CompactClient { &self, input: &CompactionInput<'_>, extra_headers: HeaderMap, + request_timeout: Duration, ) -> Result, ApiError> { let body = to_value(input) .map_err(|e| ApiError::Stream(format!("failed to encode compaction input: {e}")))?; - self.compact(body, extra_headers).await + self.compact(body, extra_headers, request_timeout).await } } diff --git a/codex-rs/codex-api/src/endpoint/images.rs b/codex-rs/codex-api/src/endpoint/images.rs new file mode 100644 index 0000000000..9d1bd41eea --- /dev/null +++ b/codex-rs/codex-api/src/endpoint/images.rs @@ -0,0 +1,302 @@ +use crate::auth::SharedAuthProvider; +use crate::endpoint::session::EndpointSession; +use crate::error::ApiError; +use crate::images::ImageEditRequest; +use crate::images::ImageGenerationRequest; +use crate::images::ImageResponse; +use crate::provider::Provider; +use codex_client::HttpTransport; +use codex_client::RequestTelemetry; +use http::HeaderMap; +use http::Method; +use serde::Serialize; +use serde_json::to_value; +use std::sync::Arc; + +pub struct ImagesClient { + session: EndpointSession, +} + +impl ImagesClient { + pub fn new(transport: T, provider: Provider, auth: SharedAuthProvider) -> Self { + Self { + session: EndpointSession::new(transport, provider, auth), + } + } + + pub fn with_telemetry(self, request: Option>) -> Self { + Self { + session: self.session.with_request_telemetry(request), + } + } + + pub async fn generate( + &self, + request: &ImageGenerationRequest, + extra_headers: HeaderMap, + ) -> Result { + self.post_image_request( + "images/generations", + request, + extra_headers, + "image generation", + ) + .await + } + + pub async fn edit( + &self, + request: &ImageEditRequest, + extra_headers: HeaderMap, + ) -> Result { + self.post_image_request("images/edits", request, extra_headers, "image edit") + .await + } + + async fn post_image_request( + &self, + path: &str, + request: &R, + extra_headers: HeaderMap, + operation: &str, + ) -> Result { + let body = to_value(request) + .map_err(|e| ApiError::Stream(format!("failed to encode {operation} request: {e}")))?; + let resp = self + .session + .execute(Method::POST, path, extra_headers, Some(body)) + .await?; + serde_json::from_slice(&resp.body) + .map_err(|e| ApiError::Stream(format!("failed to decode {operation} response: {e}"))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::AuthProvider; + use crate::images::ImageBackground; + use crate::images::ImageData; + use crate::images::ImageQuality; + use crate::images::ImageUrl; + use crate::provider::RetryConfig; + use async_trait::async_trait; + use codex_client::Request; + use codex_client::RequestBody; + use codex_client::Response; + use codex_client::StreamResponse; + use codex_client::TransportError; + use http::StatusCode; + use pretty_assertions::assert_eq; + use serde_json::json; + use std::sync::Mutex; + use std::time::Duration; + + #[derive(Clone, Default)] + struct DummyAuth; + + impl AuthProvider for DummyAuth { + fn add_auth_headers(&self, _headers: &mut HeaderMap) {} + } + + #[derive(Clone)] + struct CapturingTransport { + last_request: Arc>>, + response_body: Arc>, + } + + impl CapturingTransport { + fn new(response_body: Vec) -> Self { + Self { + last_request: Arc::new(Mutex::new(None)), + response_body: Arc::new(response_body), + } + } + } + + #[async_trait] + impl HttpTransport for CapturingTransport { + async fn execute(&self, req: Request) -> Result { + *self.last_request.lock().expect("lock request store") = Some(req); + Ok(Response { + status: StatusCode::OK, + headers: HeaderMap::new(), + body: self.response_body.as_ref().clone().into(), + }) + } + + async fn stream(&self, _req: Request) -> Result { + Err(TransportError::Build("stream should not run".to_string())) + } + } + + fn provider() -> Provider { + Provider { + name: "test".to_string(), + base_url: "https://example.com/api/codex".to_string(), + query_params: None, + headers: HeaderMap::new(), + retry: RetryConfig { + max_attempts: 1, + base_delay: Duration::from_millis(1), + retry_429: false, + retry_5xx: true, + retry_transport: true, + }, + stream_idle_timeout: Duration::from_secs(1), + } + } + + fn response_body() -> Vec { + serde_json::to_vec(&json!({ + "created": 1778832973u64, + "background": "opaque", + "data": [{"b64_json": "REDACT"}], + "output_format": "png", + "quality": "medium", + "size": "1024x1536", + "usage": { + "input_tokens": 1474, + "input_tokens_details": { + "image_tokens": 1457, + "text_tokens": 17, + }, + "output_tokens": 1372, + "output_tokens_details": { + "image_tokens": 1372, + "text_tokens": 0, + }, + "total_tokens": 2846, + } + })) + .expect("serialize response") + } + + fn expected_response() -> ImageResponse { + ImageResponse { + created: 1778832973, + background: Some(ImageBackground::Opaque), + data: vec![ImageData { + b64_json: "REDACT".to_string(), + }], + quality: Some(ImageQuality::Medium), + size: Some("1024x1536".to_string()), + } + } + + fn captured_request(transport: &CapturingTransport) -> Request { + transport + .last_request + .lock() + .expect("lock request store") + .clone() + .expect("request should be captured") + } + + #[tokio::test] + async fn generate_posts_typed_request_and_parses_image_response() { + let transport = CapturingTransport::new(response_body()); + let client = ImagesClient::new(transport.clone(), provider(), Arc::new(DummyAuth)); + + let response = client + .generate( + &ImageGenerationRequest { + prompt: "a red fox in a field".to_string(), + background: Some(ImageBackground::Opaque), + model: "gpt-image-1.5".to_string(), + n: None, + quality: Some(ImageQuality::Medium), + size: Some("1024x1536".to_string()), + }, + HeaderMap::new(), + ) + .await + .expect("image generation request should succeed"); + + assert_eq!(response, expected_response()); + + let request = captured_request(&transport); + assert_eq!( + request.url, + "https://example.com/api/codex/images/generations" + ); + assert_eq!( + request.body.as_ref().and_then(RequestBody::json), + Some(&json!({ + "prompt": "a red fox in a field", + "background": "opaque", + "model": "gpt-image-1.5", + "quality": "medium", + "size": "1024x1536", + })) + ); + } + + #[tokio::test] + async fn edit_posts_typed_request_and_parses_image_response() { + let transport = CapturingTransport::new(response_body()); + let client = ImagesClient::new(transport.clone(), provider(), Arc::new(DummyAuth)); + + let response = client + .edit( + &ImageEditRequest { + images: vec![ImageUrl { + image_url: "data:image/png;base64,Zm9v".to_string(), + }], + prompt: "add a red hat".to_string(), + background: None, + model: "gpt-image-1.5".to_string(), + n: None, + quality: None, + size: None, + }, + HeaderMap::new(), + ) + .await + .expect("image edit request should succeed"); + + assert_eq!(response, expected_response()); + + let request = captured_request(&transport); + assert_eq!(request.url, "https://example.com/api/codex/images/edits"); + assert_eq!( + request.body.as_ref().and_then(RequestBody::json), + Some(&json!({ + "images": [{"image_url": "data:image/png;base64,Zm9v"}], + "prompt": "add a red hat", + "model": "gpt-image-1.5", + })) + ); + } + + #[tokio::test] + async fn image_response_requires_image_data() { + let transport = CapturingTransport::new( + serde_json::to_vec(&json!({"created": 1778832973u64})).expect("serialize response"), + ); + let client = ImagesClient::new(transport, provider(), Arc::new(DummyAuth)); + + let error = client + .generate( + &ImageGenerationRequest { + prompt: "a red fox in a field".to_string(), + background: None, + model: "gpt-image-1.5".to_string(), + n: None, + quality: None, + size: None, + }, + HeaderMap::new(), + ) + .await + .expect_err("image response without data should fail"); + + let ApiError::Stream(message) = error else { + panic!("expected image response decode error"); + }; + assert!( + message.starts_with("failed to decode image generation response: missing field `data`"), + "{message}" + ); + } +} diff --git a/codex-rs/codex-api/src/endpoint/mod.rs b/codex-rs/codex-api/src/endpoint/mod.rs index 70c52b9580..106c5d73ff 100644 --- a/codex-rs/codex-api/src/endpoint/mod.rs +++ b/codex-rs/codex-api/src/endpoint/mod.rs @@ -1,13 +1,16 @@ pub(crate) mod compact; +pub(crate) mod images; pub(crate) mod memories; pub(crate) mod models; pub(crate) mod realtime_call; pub(crate) mod realtime_websocket; pub(crate) mod responses; pub(crate) mod responses_websocket; +pub(crate) mod search; mod session; pub use compact::CompactClient; +pub use images::ImagesClient; pub use memories::MemoriesClient; pub use models::ModelsClient; pub use realtime_call::RealtimeCallClient; @@ -27,3 +30,4 @@ pub use responses_websocket::ResponsesWebsocketClient; pub use responses_websocket::ResponsesWebsocketClose; pub use responses_websocket::ResponsesWebsocketConnection; pub use responses_websocket::ResponsesWebsocketProbe; +pub use search::SearchClient; diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs index 9fcca1c3e3..ad26549a26 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs @@ -993,6 +993,22 @@ mod tests { ); } + #[test] + fn parse_v1_input_transcript_turn_marked_event() { + let payload = json!({ + "type": "conversation.input_transcript.turn_marked", + "transcript": "hello realtime" + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str(), RealtimeEventParser::V1), + Some(RealtimeEvent::InputTranscriptDone(RealtimeTranscriptDone { + text: "hello realtime".to_string(), + })) + ); + } + #[test] fn parse_output_transcript_delta_event() { let payload = json!({ @@ -1581,6 +1597,10 @@ mod tests { .expect("text"); let third_json: Value = serde_json::from_str(&third).expect("json"); assert_eq!(third_json["type"], "conversation.item.create"); + assert_eq!( + third_json["item"]["content"][0]["type"], + Value::String("input_text".to_string()) + ); assert_eq!(third_json["item"]["content"][0]["text"], "hello agent"); let fourth = ws diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v1.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v1.rs index 19e4fa203a..0f1a269082 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v1.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods_v1.rs @@ -21,7 +21,7 @@ pub(super) fn conversation_item_create_message(text: String) -> RealtimeOutbound r#type: ConversationItemType::Message, role: ConversationRole::User, content: vec![ConversationItemContent { - r#type: ConversationContentType::Text, + r#type: ConversationContentType::InputText, text, }], }), diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs index d689f6ea96..5df4c0c503 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs @@ -199,7 +199,6 @@ pub(super) struct ConversationItemContent { #[derive(Debug, Clone, Copy, Serialize)] #[serde(rename_all = "snake_case")] pub(super) enum ConversationContentType { - Text, InputText, } diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol_v1.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol_v1.rs index 3c1d25aed7..a464852244 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol_v1.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol_v1.rs @@ -43,7 +43,8 @@ pub(super) fn parse_realtime_event_v1(payload: &str) -> Option { | "conversation.item.input_audio_transcription.delta" => { parse_transcript_delta_event(&parsed, "delta").map(RealtimeEvent::InputTranscriptDelta) } - "conversation.item.input_audio_transcription.completed" => { + "conversation.input_transcript.turn_marked" + | "conversation.item.input_audio_transcription.completed" => { parse_transcript_done_event(&parsed, "transcript") .map(RealtimeEvent::InputTranscriptDone) } diff --git a/codex-rs/codex-api/src/endpoint/search.rs b/codex-rs/codex-api/src/endpoint/search.rs new file mode 100644 index 0000000000..d01fbfb780 --- /dev/null +++ b/codex-rs/codex-api/src/endpoint/search.rs @@ -0,0 +1,264 @@ +use crate::auth::SharedAuthProvider; +use crate::endpoint::session::EndpointSession; +use crate::error::ApiError; +use crate::provider::Provider; +use crate::search::SearchRequest; +use crate::search::SearchResponse; +use codex_client::HttpTransport; +use codex_client::RequestTelemetry; +use http::HeaderMap; +use http::Method; +use serde_json::to_value; +use std::sync::Arc; + +pub struct SearchClient { + session: EndpointSession, +} + +impl SearchClient { + pub fn new(transport: T, provider: Provider, auth: SharedAuthProvider) -> Self { + Self { + session: EndpointSession::new(transport, provider, auth), + } + } + + pub fn with_telemetry(self, request: Option>) -> Self { + Self { + session: self.session.with_request_telemetry(request), + } + } + + fn path() -> &'static str { + "alpha/search" + } + + pub async fn search( + &self, + request: &SearchRequest, + extra_headers: HeaderMap, + ) -> Result { + let body = to_value(request) + .map_err(|e| ApiError::Stream(format!("failed to encode search request: {e}")))?; + let resp = self + .session + .execute(Method::POST, Self::path(), extra_headers, Some(body)) + .await?; + serde_json::from_slice(&resp.body) + .map_err(|e| ApiError::Stream(format!("failed to decode search response: {e}"))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::AuthProvider; + use crate::provider::RetryConfig; + use crate::search::AllowedCaller; + use crate::search::ApproximateLocation; + use crate::search::LocationType; + use crate::search::OpenOperation; + use crate::search::SearchCommands; + use crate::search::SearchContextSize; + use crate::search::SearchFilters; + use crate::search::SearchImageSettings; + use crate::search::SearchInput; + use crate::search::SearchQuery; + use crate::search::SearchSettings; + use async_trait::async_trait; + use codex_client::Request; + use codex_client::RequestBody; + use codex_client::Response; + use codex_client::StreamResponse; + use codex_client::TransportError; + use codex_protocol::models::ContentItem; + use codex_protocol::models::ResponseItem; + use http::StatusCode; + use pretty_assertions::assert_eq; + use serde_json::json; + use std::sync::Mutex; + use std::time::Duration; + + #[derive(Clone, Default)] + struct DummyAuth; + + impl AuthProvider for DummyAuth { + fn add_auth_headers(&self, _headers: &mut HeaderMap) {} + } + + #[derive(Clone)] + struct CapturingTransport { + last_request: Arc>>, + response_body: Arc>, + } + + impl CapturingTransport { + fn new(response_body: Vec) -> Self { + Self { + last_request: Arc::new(Mutex::new(None)), + response_body: Arc::new(response_body), + } + } + } + + #[async_trait] + impl HttpTransport for CapturingTransport { + async fn execute(&self, req: Request) -> Result { + *self.last_request.lock().expect("lock request store") = Some(req); + Ok(Response { + status: StatusCode::OK, + headers: HeaderMap::new(), + body: self.response_body.as_ref().clone().into(), + }) + } + + async fn stream(&self, _req: Request) -> Result { + Err(TransportError::Build("stream should not run".to_string())) + } + } + + fn provider() -> Provider { + Provider { + name: "test".to_string(), + base_url: "https://example.com/v1".to_string(), + query_params: None, + headers: HeaderMap::new(), + retry: RetryConfig { + max_attempts: 1, + base_delay: Duration::from_millis(1), + retry_429: false, + retry_5xx: true, + retry_transport: true, + }, + stream_idle_timeout: Duration::from_secs(1), + } + } + + #[tokio::test] + async fn search_posts_typed_request_and_parses_encrypted_output() { + let transport = CapturingTransport::new( + serde_json::to_vec(&json!({"encrypted_output": "ciphertext"})) + .expect("serialize response"), + ); + let client = SearchClient::new(transport.clone(), provider(), Arc::new(DummyAuth)); + + let response = client + .search( + &SearchRequest { + id: "search-session".to_string(), + model: "gpt-test".to_string(), + reasoning: None, + input: Some(SearchInput::Items(vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ + ContentItem::InputText { + text: "find this".to_string(), + }, + ContentItem::InputImage { + image_url: "https://example.com/image.png".to_string(), + detail: None, + }, + ], + phase: None, + }])), + commands: Some(SearchCommands { + search_query: Some(vec![SearchQuery { + q: "OpenAI news".to_string(), + recency: Some(7), + domains: Some(vec!["openai.com".to_string()]), + }]), + open: Some(vec![OpenOperation { + ref_id: "https://openai.com".to_string(), + lineno: Some(12), + }]), + ..Default::default() + }), + settings: Some(SearchSettings { + user_location: Some(ApproximateLocation { + r#type: LocationType::Approximate, + country: Some("US".to_string()), + region: None, + city: Some("San Francisco".to_string()), + timezone: None, + }), + search_context_size: Some(SearchContextSize::Low), + filters: Some(SearchFilters { + allowed_domains: Some(vec!["openai.com".to_string()]), + blocked_domains: Some(vec!["example.com".to_string()]), + }), + image_settings: Some(SearchImageSettings { + max_results: Some(4), + caption: Some(true), + }), + allowed_callers: Some(vec![AllowedCaller::Direct]), + external_web_access: Some(true), + }), + max_output_tokens: Some(2500), + }, + HeaderMap::new(), + ) + .await + .expect("search request should succeed"); + + assert_eq!( + response, + SearchResponse { + encrypted_output: "ciphertext".to_string(), + } + ); + + let request = transport + .last_request + .lock() + .expect("lock request store") + .clone() + .expect("request should be captured"); + let body = request + .body + .as_ref() + .and_then(RequestBody::json) + .expect("request body should be JSON"); + assert_eq!( + body, + &json!({ + "id": "search-session", + "model": "gpt-test", + "input": [{ + "type": "message", + "role": "user", + "content": [ + {"type": "input_text", "text": "find this"}, + { + "type": "input_image", + "image_url": "https://example.com/image.png" + } + ] + }], + "commands": { + "search_query": [{ + "q": "OpenAI news", + "recency": 7, + "domains": ["openai.com"] + }], + "open": [{"ref_id": "https://openai.com", "lineno": 12}] + }, + "settings": { + "user_location": { + "type": "approximate", + "country": "US", + "city": "San Francisco" + }, + "search_context_size": "low", + "filters": { + "allowed_domains": ["openai.com"], + "blocked_domains": ["example.com"] + }, + "image_settings": {"max_results": 4, "caption": true}, + "allowed_callers": ["direct"], + "external_web_access": true + }, + "max_output_tokens": 2500 + }) + ); + } +} diff --git a/codex-rs/codex-api/src/images.rs b/codex-rs/codex-api/src/images.rs new file mode 100644 index 0000000000..f915a5f78d --- /dev/null +++ b/codex-rs/codex-api/src/images.rs @@ -0,0 +1,70 @@ +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct ImageGenerationRequest { + pub prompt: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub background: Option, + pub model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub n: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub quality: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct ImageEditRequest { + pub images: Vec, + pub prompt: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub background: Option, + pub model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub n: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub quality: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ImageUrl { + pub image_url: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ImageBackground { + Transparent, + Opaque, + Auto, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ImageQuality { + Low, + Medium, + High, + Auto, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct ImageResponse { + pub created: u64, + pub data: Vec, + #[serde(default)] + pub background: Option, + #[serde(default)] + pub quality: Option, + #[serde(default)] + pub size: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct ImageData { + pub b64_json: String, +} diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index 99470cac59..08176d8dec 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -4,9 +4,11 @@ pub(crate) mod common; pub(crate) mod endpoint; pub(crate) mod error; pub(crate) mod files; +pub(crate) mod images; pub(crate) mod provider; pub(crate) mod rate_limits; pub(crate) mod requests; +pub(crate) mod search; pub(crate) mod sse; pub(crate) mod telemetry; @@ -40,6 +42,7 @@ pub use crate::common::WS_REQUEST_HEADER_TRACESTATE_CLIENT_METADATA_KEY; pub use crate::common::create_text_param_for_request; pub use crate::common::response_create_client_metadata; pub use crate::endpoint::CompactClient; +pub use crate::endpoint::ImagesClient; pub use crate::endpoint::MemoriesClient; pub use crate::endpoint::ModelsClient; pub use crate::endpoint::RealtimeCallClient; @@ -58,13 +61,46 @@ pub use crate::endpoint::ResponsesWebsocketClient; pub use crate::endpoint::ResponsesWebsocketClose; pub use crate::endpoint::ResponsesWebsocketConnection; pub use crate::endpoint::ResponsesWebsocketProbe; +pub use crate::endpoint::SearchClient; pub use crate::endpoint::session_update_session_json; pub use crate::error::ApiError; pub use crate::files::upload_local_file; +pub use crate::images::ImageBackground; +pub use crate::images::ImageData; +pub use crate::images::ImageEditRequest; +pub use crate::images::ImageGenerationRequest; +pub use crate::images::ImageQuality; +pub use crate::images::ImageResponse; +pub use crate::images::ImageUrl; pub use crate::provider::Provider; pub use crate::provider::RetryConfig; pub use crate::provider::is_azure_responses_provider; pub use crate::requests::Compression; +pub use crate::search::AllowedCaller; +pub use crate::search::ApproximateLocation; +pub use crate::search::ClickOperation; +pub use crate::search::FinanceAssetType; +pub use crate::search::FinanceOperation; +pub use crate::search::FindOperation; +pub use crate::search::LocationType; +pub use crate::search::OpenOperation; +pub use crate::search::ScreenshotOperation; +pub use crate::search::SearchCommands; +pub use crate::search::SearchContextSize; +pub use crate::search::SearchFilters; +pub use crate::search::SearchImageSettings; +pub use crate::search::SearchInput; +pub use crate::search::SearchQuery; +pub use crate::search::SearchRequest; +pub use crate::search::SearchResponse; +pub use crate::search::SearchResponseLength; +pub use crate::search::SearchSettings; +pub use crate::search::SportsFunction; +pub use crate::search::SportsLeague; +pub use crate::search::SportsOperation; +pub use crate::search::SportsToolName; +pub use crate::search::TimeOperation; +pub use crate::search::WeatherOperation; pub use crate::telemetry::SseTelemetry; pub use crate::telemetry::WebsocketTelemetry; pub use codex_protocol::protocol::RealtimeAudioFrame; diff --git a/codex-rs/codex-api/src/rate_limits.rs b/codex-rs/codex-api/src/rate_limits.rs index 979500cdab..a2ad876671 100644 --- a/codex-rs/codex-api/src/rate_limits.rs +++ b/codex-rs/codex-api/src/rate_limits.rs @@ -1,5 +1,6 @@ use codex_protocol::account::PlanType; use codex_protocol::protocol::CreditsSnapshot; +use codex_protocol::protocol::RateLimitReachedType; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; use http::HeaderMap; @@ -178,6 +179,13 @@ pub fn parse_promo_message(headers: &HeaderMap) -> Option { .map(std::string::ToString::to_string) } +pub(crate) fn parse_rate_limit_reached_type(headers: &HeaderMap) -> Option { + parse_header_str(headers, "x-codex-rate-limit-reached-type")? + .trim() + .parse() + .ok() +} + fn parse_rate_limit_window( headers: &HeaderMap, used_percent_header: &str, diff --git a/codex-rs/codex-api/src/search.rs b/codex-rs/codex-api/src/search.rs new file mode 100644 index 0000000000..061b3ac8c6 --- /dev/null +++ b/codex-rs/codex-api/src/search.rs @@ -0,0 +1,284 @@ +use crate::common::Reasoning; +use codex_protocol::models::ResponseItem; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, Serialize, PartialEq)] +pub struct SearchRequest { + pub id: String, + pub model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub input: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub commands: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub settings: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_output_tokens: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq)] +#[serde(untagged)] +pub enum SearchInput { + Text(String), + Items(Vec), +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, JsonSchema)] +pub struct SearchCommands { + /// Query the internet search engine for a given list of queries. + #[serde(skip_serializing_if = "Option::is_none")] + pub search_query: Option>, + /// Query the image search engine for a given list of queries. + #[serde(skip_serializing_if = "Option::is_none")] + pub image_query: Option>, + /// Open pages by reference id or URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub open: Option>, + /// Open links from previously opened pages. + #[serde(skip_serializing_if = "Option::is_none")] + pub click: Option>, + /// Find text patterns in pages. + #[serde(skip_serializing_if = "Option::is_none")] + pub find: Option>, + /// Take screenshots of PDF pages. + #[serde(skip_serializing_if = "Option::is_none")] + pub screenshot: Option>, + /// Look up prices for the given stock symbols. + #[serde(skip_serializing_if = "Option::is_none")] + pub finance: Option>, + /// Look up weather forecasts. + #[serde(skip_serializing_if = "Option::is_none")] + pub weather: Option>, + /// Look up sports schedules and standings. + #[serde(skip_serializing_if = "Option::is_none")] + pub sports: Option>, + /// Get time for the given UTC offsets. + #[serde(skip_serializing_if = "Option::is_none")] + pub time: Option>, + /// Set the length of the response to be returned. + #[serde(skip_serializing_if = "Option::is_none")] + pub response_length: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] +pub struct SearchQuery { + /// Search query. + pub q: String, + /// Whether to filter by recency, as a number of recent days. + #[serde(skip_serializing_if = "Option::is_none")] + pub recency: Option, + /// Whether to filter by a specific list of domains. + #[serde(skip_serializing_if = "Option::is_none")] + pub domains: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] +pub struct OpenOperation { + /// Reference id or URL to open. + pub ref_id: String, + /// Line number to position the page at. + #[serde(skip_serializing_if = "Option::is_none")] + pub lineno: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +pub struct ClickOperation { + /// Reference id containing the numbered link. + pub ref_id: String, + /// Numbered link id to open. + pub id: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +pub struct FindOperation { + /// Reference id or URL to search within. + pub ref_id: String, + /// Text pattern to find. + pub pattern: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +pub struct ScreenshotOperation { + /// Reference id or URL to screenshot. + pub ref_id: String, + /// Zero-indexed PDF page number. + pub pageno: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] +pub struct FinanceOperation { + /// Ticker symbol to look up. + pub ticker: String, + /// Asset type to look up. + pub r#type: FinanceAssetType, + /// ISO 3166-1 alpha-3 country code, "OTC", or "" for cryptocurrency. + #[serde(skip_serializing_if = "Option::is_none")] + pub market: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum FinanceAssetType { + Equity, + Fund, + Crypto, + Index, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] +pub struct WeatherOperation { + /// Location in "Country, Area, City" format. + pub location: String, + /// Start date in YYYY-MM-DD format. Defaults to today. + #[serde(skip_serializing_if = "Option::is_none")] + pub start: Option, + /// Number of days to return. Defaults to 7. + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] +pub struct SportsOperation { + /// Tool name for sports requests. + #[serde(skip_serializing_if = "Option::is_none")] + pub tool: Option, + /// Sports function to call. + pub r#fn: SportsFunction, + /// League to look up. + pub league: SportsLeague, + /// Team to look up, using the common 3 or 4 letter alias used in broadcasts. + #[serde(skip_serializing_if = "Option::is_none")] + pub team: Option, + /// Opponent to use with `team` when narrowing the lookup. + #[serde(skip_serializing_if = "Option::is_none")] + pub opponent: Option, + /// Start date in YYYY-MM-DD format. + #[serde(skip_serializing_if = "Option::is_none")] + pub date_from: Option, + /// End date in YYYY-MM-DD format. + #[serde(skip_serializing_if = "Option::is_none")] + pub date_to: Option, + /// Number of games to return. + #[serde(skip_serializing_if = "Option::is_none")] + pub num_games: Option, + /// Locale for the lookup. + #[serde(skip_serializing_if = "Option::is_none")] + pub locale: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum SportsToolName { + Sports, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum SportsFunction { + Schedule, + Standings, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum SportsLeague { + Nba, + Wnba, + Nfl, + Nhl, + Mlb, + Epl, + Ncaamb, + Ncaawb, + Ipl, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +pub struct TimeOperation { + /// UTC offset formatted like "+03:00". + pub utc_offset: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum SearchResponseLength { + Short, + Medium, + Long, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct SearchSettings { + #[serde(skip_serializing_if = "Option::is_none")] + pub user_location: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub search_context_size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub filters: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub image_settings: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub allowed_callers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub external_web_access: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ApproximateLocation { + pub r#type: LocationType, + #[serde(skip_serializing_if = "Option::is_none")] + pub country: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub region: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub city: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub timezone: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum LocationType { + Approximate, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum SearchContextSize { + Low, + Medium, + High, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct SearchFilters { + #[serde(skip_serializing_if = "Option::is_none")] + pub allowed_domains: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub blocked_domains: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct SearchImageSettings { + #[serde(skip_serializing_if = "Option::is_none")] + pub max_results: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub caption: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AllowedCaller { + Direct, + Shell, + CodeInterpreter, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct SearchResponse { + pub encrypted_output: String, +} diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index d2b31180b9..742b39d5fd 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -76,6 +76,7 @@ async fn models_client_hits_models_endpoint() { priority: 1, additional_speed_tiers: Vec::new(), service_tiers: Vec::new(), + default_service_tier: None, upgrade: None, base_instructions: "base instructions".to_string(), model_messages: None, @@ -97,6 +98,8 @@ async fn models_client_hits_models_endpoint() { input_modalities: default_input_modalities(), used_fallback_model_metadata: false, supports_search_tool: false, + auto_review_model_override: None, + tool_mode: None, }], }; diff --git a/codex-rs/codex-backend-openapi-models/src/models/config_bundle_response.rs b/codex-rs/codex-backend-openapi-models/src/models/config_bundle_response.rs new file mode 100644 index 0000000000..9337d57a2a --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/config_bundle_response.rs @@ -0,0 +1,40 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct ConfigBundleResponse { + #[serde( + rename = "config_toml", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub config_toml: Option>>, + #[serde( + rename = "requirements_toml", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub requirements_toml: Option>>, +} + +impl ConfigBundleResponse { + pub fn new() -> ConfigBundleResponse { + ConfigBundleResponse { + config_toml: None, + requirements_toml: None, + } + } +} diff --git a/codex-rs/codex-backend-openapi-models/src/models/delivered_config_toml.rs b/codex-rs/codex-backend-openapi-models/src/models/delivered_config_toml.rs new file mode 100644 index 0000000000..0183eced47 --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/delivered_config_toml.rs @@ -0,0 +1,32 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct DeliveredConfigToml { + #[serde( + rename = "enterprise_managed", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub enterprise_managed: Option>>, +} + +impl DeliveredConfigToml { + pub fn new() -> DeliveredConfigToml { + DeliveredConfigToml { + enterprise_managed: None, + } + } +} diff --git a/codex-rs/codex-backend-openapi-models/src/models/delivered_requirements_toml.rs b/codex-rs/codex-backend-openapi-models/src/models/delivered_requirements_toml.rs new file mode 100644 index 0000000000..953087d734 --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/delivered_requirements_toml.rs @@ -0,0 +1,32 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct DeliveredRequirementsToml { + #[serde( + rename = "enterprise_managed", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub enterprise_managed: Option>>, +} + +impl DeliveredRequirementsToml { + pub fn new() -> DeliveredRequirementsToml { + DeliveredRequirementsToml { + enterprise_managed: None, + } + } +} diff --git a/codex-rs/codex-backend-openapi-models/src/models/delivered_toml_fragment.rs b/codex-rs/codex-backend-openapi-models/src/models/delivered_toml_fragment.rs new file mode 100644 index 0000000000..4d44a0e1f7 --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/delivered_toml_fragment.rs @@ -0,0 +1,28 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct DeliveredTomlFragment { + #[serde(rename = "id")] + pub id: String, + #[serde(rename = "name")] + pub name: String, + #[serde(rename = "contents")] + pub contents: String, +} + +impl DeliveredTomlFragment { + pub fn new(id: String, name: String, contents: String) -> DeliveredTomlFragment { + DeliveredTomlFragment { id, name, contents } + } +} diff --git a/codex-rs/codex-backend-openapi-models/src/models/mod.rs b/codex-rs/codex-backend-openapi-models/src/models/mod.rs index c881822b37..f0504a9c82 100644 --- a/codex-rs/codex-backend-openapi-models/src/models/mod.rs +++ b/codex-rs/codex-backend-openapi-models/src/models/mod.rs @@ -4,9 +4,21 @@ // The process for this will change // Config +pub(crate) mod config_bundle_response; +pub use self::config_bundle_response::ConfigBundleResponse; + pub(crate) mod config_file_response; pub use self::config_file_response::ConfigFileResponse; +pub(crate) mod delivered_config_toml; +pub use self::delivered_config_toml::DeliveredConfigToml; + +pub(crate) mod delivered_requirements_toml; +pub use self::delivered_requirements_toml::DeliveredRequirementsToml; + +pub(crate) mod delivered_toml_fragment; +pub use self::delivered_toml_fragment::DeliveredTomlFragment; + // Cloud Tasks pub(crate) mod code_task_details_response; pub use self::code_task_details_response::CodeTaskDetailsResponse; diff --git a/codex-rs/codex-client/src/custom_ca.rs b/codex-rs/codex-client/src/custom_ca.rs index 7a8b2f27bd..1a211beedf 100644 --- a/codex-rs/codex-client/src/custom_ca.rs +++ b/codex-rs/codex-client/src/custom_ca.rs @@ -477,15 +477,13 @@ impl ConfiguredCaBundle { })?; certificates.push(CertificateDer::from(cert_der.to_vec())); } - SectionKind::Crl => { - if !logged_crl_presence { - info!( - source_env = self.source_env, - ca_path = %self.path.display(), - "ignoring X509 CRL entries found in custom CA bundle" - ); - logged_crl_presence = true; - } + SectionKind::Crl if !logged_crl_presence => { + info!( + source_env = self.source_env, + ca_path = %self.path.display(), + "ignoring X509 CRL entries found in custom CA bundle" + ); + logged_crl_presence = true; } _ => {} } diff --git a/codex-rs/codex-mcp/src/codex_apps.rs b/codex-rs/codex-mcp/src/codex_apps.rs index 772f3fc42f..3196a143e8 100644 --- a/codex-rs/codex-mcp/src/codex_apps.rs +++ b/codex-rs/codex-mcp/src/codex_apps.rs @@ -12,7 +12,9 @@ use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::runtime::emit_duration; use crate::tools::MCP_TOOLS_CACHE_WRITE_DURATION_METRIC; use crate::tools::ToolInfo; +use anyhow::Context; use codex_login::CodexAuth; +use codex_protocol::mcp::McpServerInfo; use codex_utils_plugins::mcp_connector::is_connector_id_allowed; use codex_utils_plugins::mcp_connector::sanitize_name; use serde::Deserialize; @@ -20,8 +22,6 @@ use serde::Serialize; use sha1::Digest; use sha1::Sha1; -pub(crate) const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 2; - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CodexAppsToolsCacheKey { pub(crate) account_id: Option, @@ -44,11 +44,19 @@ pub(crate) struct CodexAppsToolsCacheContext { } impl CodexAppsToolsCacheContext { - pub(crate) fn cache_path(&self) -> PathBuf { + pub(crate) fn tools_cache_path(&self) -> PathBuf { + self.cache_path_in(CODEX_APPS_TOOLS_CACHE_DIR) + } + + pub(crate) fn server_info_cache_path(&self) -> PathBuf { + self.cache_path_in(CODEX_APPS_SERVER_INFO_CACHE_DIR) + } + + fn cache_path_in(&self, cache_dir: &str) -> PathBuf { let user_key_json = serde_json::to_string(&self.user_key).unwrap_or_default(); let user_key_hash = sha1_hex(&user_key_json); self.codex_home - .join(CODEX_APPS_TOOLS_CACHE_DIR) + .join(cache_dir) .join(format!("{user_key_hash}.json")) } } @@ -127,15 +135,16 @@ pub(crate) fn normalize_codex_apps_callable_namespace( if server_name == CODEX_APPS_MCP_SERVER_NAME && let Some(connector_name) = connector_name { - format!("mcp__{}__{}", server_name, sanitize_name(connector_name)) + format!("{}__{}", server_name, sanitize_name(connector_name)) } else { - format!("mcp__{server_name}__") + server_name.to_string() } } pub(crate) fn write_cached_codex_apps_tools_if_needed( server_name: &str, cache_context: Option<&CodexAppsToolsCacheContext>, + server_info: &McpServerInfo, tools: &[ToolInfo], ) { if server_name != CODEX_APPS_MCP_SERVER_NAME { @@ -145,6 +154,9 @@ pub(crate) fn write_cached_codex_apps_tools_if_needed( if let Some(cache_context) = cache_context { let cache_write_start = Instant::now(); write_cached_codex_apps_tools(cache_context, tools); + if let Err(err) = write_cached_codex_apps_server_info(cache_context, server_info) { + tracing::warn!("failed to write Codex Apps server info cache: {err:#}"); + } emit_duration( MCP_TOOLS_CACHE_WRITE_DURATION_METRIC, cache_write_start.elapsed(), @@ -169,6 +181,17 @@ pub(crate) fn load_startup_cached_codex_apps_tools_snapshot( } } +pub(crate) fn load_startup_cached_codex_apps_server_info( + server_name: &str, + cache_context: Option<&CodexAppsToolsCacheContext>, +) -> Option { + if server_name != CODEX_APPS_MCP_SERVER_NAME { + return None; + } + + load_cached_codex_apps_server_info(cache_context?) +} + #[cfg(test)] pub(crate) fn read_cached_codex_apps_tools( cache_context: &CodexAppsToolsCacheContext, @@ -182,7 +205,7 @@ pub(crate) fn read_cached_codex_apps_tools( pub(crate) fn load_cached_codex_apps_tools( cache_context: &CodexAppsToolsCacheContext, ) -> CachedCodexAppsToolsLoad { - let cache_path = cache_context.cache_path(); + let cache_path = cache_context.tools_cache_path(); let bytes = match std::fs::read(cache_path) { Ok(bytes) => bytes, Err(err) if err.kind() == std::io::ErrorKind::NotFound => { @@ -204,7 +227,7 @@ pub(crate) fn write_cached_codex_apps_tools( cache_context: &CodexAppsToolsCacheContext, tools: &[ToolInfo], ) { - let cache_path = cache_context.cache_path(); + let cache_path = cache_context.tools_cache_path(); if let Some(parent) = cache_path.parent() && std::fs::create_dir_all(parent).is_err() { @@ -220,6 +243,42 @@ pub(crate) fn write_cached_codex_apps_tools( let _ = std::fs::write(cache_path, bytes); } +pub(crate) fn load_cached_codex_apps_server_info( + cache_context: &CodexAppsToolsCacheContext, +) -> Option { + let bytes = std::fs::read(cache_context.server_info_cache_path()).ok()?; + let cache: CodexAppsServerInfoDiskCache = serde_json::from_slice(&bytes).ok()?; + (cache.schema_version == CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION) + .then_some(cache.server_info) +} + +fn write_cached_codex_apps_server_info( + cache_context: &CodexAppsToolsCacheContext, + server_info: &McpServerInfo, +) -> anyhow::Result<()> { + let cache_path = cache_context.server_info_cache_path(); + if let Some(parent) = cache_path.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!( + "failed to create Codex Apps server info cache directory `{}`", + parent.display() + ) + })?; + } + let bytes = serde_json::to_vec_pretty(&CodexAppsServerInfoDiskCache { + schema_version: CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION, + server_info: server_info.clone(), + }) + .context("failed to serialize Codex Apps server info cache")?; + std::fs::write(&cache_path, bytes).with_context(|| { + format!( + "failed to write Codex Apps server info cache `{}`", + cache_path.display() + ) + })?; + Ok(()) +} + pub(crate) fn filter_disallowed_codex_apps_tools(tools: Vec) -> Vec { tools .into_iter() @@ -237,7 +296,17 @@ struct CodexAppsToolsDiskCache { tools: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +struct CodexAppsServerInfoDiskCache { + schema_version: u8, + server_info: McpServerInfo, +} + const CODEX_APPS_TOOLS_CACHE_DIR: &str = "cache/codex_apps_tools"; +pub(crate) const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 3; + +const CODEX_APPS_SERVER_INFO_CACHE_DIR: &str = "cache/codex_apps_server_info"; +const CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION: u8 = 1; fn sha1_hex(s: &str) -> String { let mut hasher = Sha1::new(); diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index c5593a5598..d15a2e1500 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -9,6 +9,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; +use std::sync::atomic::Ordering; use std::time::Duration; use std::time::Instant; @@ -27,13 +28,13 @@ use crate::rmcp_client::MCP_TOOLS_LIST_DURATION_METRIC; use crate::rmcp_client::ManagedClient; use crate::rmcp_client::StartupOutcomeError; use crate::rmcp_client::list_tools_for_client_uncached; -use crate::runtime::McpRuntimeEnvironment; +use crate::runtime::McpRuntimeContext; use crate::runtime::emit_duration; use crate::server::EffectiveMcpServer; use crate::server::McpServerMetadata; use crate::tools::ToolInfo; use crate::tools::filter_tools; -use crate::tools::normalize_tools_for_model; +use crate::tools::normalize_tools_for_model_with_prefix; use crate::tools::tool_with_model_visible_input_schema; use anyhow::Context; use anyhow::Result; @@ -44,6 +45,7 @@ use codex_config::McpServerTransportConfig; use codex_config::types::OAuthCredentialsStoreMode; use codex_login::CodexAuth; use codex_protocol::mcp::CallToolResult; +use codex_protocol::mcp::McpServerInfo; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; @@ -62,16 +64,50 @@ use rmcp::model::ReadResourceResult; use rmcp::model::RequestId; use rmcp::model::Resource; use rmcp::model::ResourceTemplate; +use serde_json::Value as JsonValue; use tokio::task::JoinSet; use tokio_util::sync::CancellationToken; +use tracing::Instrument; use tracing::instrument; +use tracing::trace; +use tracing::trace_span; use tracing::warn; +const MCP_UI_META_KEY: &str = "ui"; +const MCP_UI_VISIBILITY_META_KEY: &str = "visibility"; +const MCP_UI_MODEL_VISIBILITY: &str = "model"; + +/// Returns whether a tool may be included in model-facing tool declarations. +/// +/// Tools without visibility metadata remain visible. +/// Tools with visibility metadata are hidden unless they explicitly include `model`. +/// +/// +pub fn tool_is_model_visible(tool: &ToolInfo) -> bool { + let Some(visibility) = tool + .tool + .meta + .as_deref() + .and_then(|meta| meta.get(MCP_UI_META_KEY)) + .and_then(JsonValue::as_object) + .and_then(|ui| ui.get(MCP_UI_VISIBILITY_META_KEY)) + .and_then(JsonValue::as_array) + else { + return true; + }; + + visibility + .iter() + .any(|target| target.as_str() == Some(MCP_UI_MODEL_VISIBILITY)) +} + /// A thin wrapper around a set of running [`RmcpClient`] instances. pub struct McpConnectionManager { clients: HashMap, server_metadata: HashMap, + tool_plugin_provenance: Arc, host_owned_codex_apps_enabled: bool, + prefix_mcp_tool_names: bool, elicitation_requests: ElicitationRequestManager, startup_cancellation_token: CancellationToken, } @@ -80,14 +116,29 @@ impl McpConnectionManager { pub fn new_uninitialized( approval_policy: &Constrained, permission_profile: &Constrained, + prefix_mcp_tool_names: bool, + ) -> Self { + Self::new_uninitialized_with_permission_profile( + approval_policy, + permission_profile.get(), + prefix_mcp_tool_names, + ) + } + + pub fn new_uninitialized_with_permission_profile( + approval_policy: &Constrained, + permission_profile: &PermissionProfile, + prefix_mcp_tool_names: bool, ) -> Self { Self { clients: HashMap::new(), server_metadata: HashMap::new(), + tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), host_owned_codex_apps_enabled: false, + prefix_mcp_tool_names, elicitation_requests: ElicitationRequestManager::new( approval_policy.value(), - permission_profile.get().clone(), + permission_profile.clone(), /*reviewer*/ None, ), startup_cancellation_token: CancellationToken::new(), @@ -129,6 +180,11 @@ impl McpConnectionManager { .is_none_or(|metadata| metadata.pollutes_memory) } + pub fn plugin_id_for_mcp_server_name(&self, server_name: &str) -> Option<&str> { + self.tool_plugin_provenance + .plugin_id_for_mcp_server_name(server_name) + } + pub fn is_host_owned_codex_apps_server(&self, server_name: &str) -> bool { self.host_owned_codex_apps_enabled && server_name == CODEX_APPS_MCP_SERVER_NAME } @@ -162,10 +218,11 @@ impl McpConnectionManager { submit_id: String, tx_event: Sender, initial_permission_profile: PermissionProfile, - runtime_environment: McpRuntimeEnvironment, + runtime_context: McpRuntimeContext, codex_home: PathBuf, codex_apps_tools_cache_key: CodexAppsToolsCacheKey, host_owned_codex_apps_enabled: bool, + prefix_mcp_tool_names: bool, client_elicitation_capability: ElicitationCapability, tool_plugin_provenance: ToolPluginProvenance, auth: Option<&CodexAuth>, @@ -234,7 +291,7 @@ impl McpConnectionManager { elicitation_requests.clone(), codex_apps_tools_cache_context, Arc::clone(&tool_plugin_provenance), - runtime_environment.clone(), + runtime_context.clone(), runtime_auth_provider, client_elicitation_capability.clone(), ); @@ -276,7 +333,9 @@ impl McpConnectionManager { let manager = Self { clients, server_metadata, + tool_plugin_provenance, host_owned_codex_apps_enabled, + prefix_mcp_tool_names, elicitation_requests: elicitation_requests.clone(), startup_cancellation_token: cancel_token.clone(), }; @@ -353,20 +412,69 @@ impl McpConnectionManager { } /// Returns all tools with model-visible names normalized. - #[instrument(level = "trace", skip_all)] + #[instrument(level = "trace", skip_all, fields(mcp_server_count = self.clients.len()))] pub async fn list_all_tools(&self) -> Vec { let mut tools = Vec::new(); - for managed_client in self.clients.values() { - let Some(server_tools) = managed_client.listed_tools().await else { + for (server_name, managed_client) in &self.clients { + let has_cached_tool_info_snapshot = managed_client.cached_tool_info_snapshot.is_some(); + let startup_complete = managed_client + .startup_complete + .load(std::sync::atomic::Ordering::Acquire); + trace!( + server_name = %server_name, + has_cached_tool_info_snapshot, + startup_complete, + "waiting for MCP server tools while building tool list" + ); + let Some(server_tools) = managed_client + .listed_tools() + .instrument(trace_span!( + "list_tools_for_server", + server_name = %server_name, + has_cached_tool_info_snapshot, + startup_complete + )) + .await + else { continue; }; + trace!( + server_name = %server_name, + tool_count = server_tools.len(), + "listed MCP server tools while building tool list" + ); tools.extend( server_tools .into_iter() .map(|tool| self.with_server_metadata(tool)), ); } - normalize_tools_for_model(tools) + normalize_tools_for_model_with_prefix(tools, self.prefix_mcp_tool_names) + } + + /// Returns presentation metadata without waiting for uncached clients still initializing. + /// Cached values will be used if available and the server is still starting up. + pub async fn list_available_server_infos(&self) -> HashMap { + let mut server_infos = HashMap::new(); + for (server_name, client) in &self.clients { + if !client.startup_complete.load(Ordering::Acquire) { + if let Some(server_info) = client.cached_server_info.clone() { + server_infos.insert(server_name.clone(), server_info); + } + continue; + } + match client.client().await { + Ok(managed_client) => { + server_infos.insert(server_name.clone(), managed_client.server_info); + } + Err(_) => { + if let Some(server_info) = client.cached_server_info.clone() { + server_infos.insert(server_name.clone(), server_info); + } + } + } + } + server_infos } /// Force-refresh codex apps tools by bypassing the in-process cache. @@ -404,6 +512,7 @@ impl McpConnectionManager { write_cached_codex_apps_tools_if_needed( CODEX_APPS_MCP_SERVER_NAME, managed_client.codex_apps_tools_cache_context.as_ref(), + &managed_client.server_info, &tools, ); emit_duration( @@ -417,7 +526,10 @@ impl McpConnectionManager { tool.tool = tool_with_model_visible_input_schema(&tool.tool); self.with_server_metadata(tool) }); - Ok(normalize_tools_for_model(tools)) + Ok(normalize_tools_for_model_with_prefix( + tools, + self.prefix_mcp_tool_names, + )) } fn with_server_metadata(&self, mut tool: ToolInfo) -> ToolInfo { @@ -455,9 +567,8 @@ impl McpConnectionManager { let mut cursor: Option = None; loop { - let params = cursor.as_ref().map(|next| PaginatedRequestParams { - meta: None, - cursor: Some(next.clone()), + let params = cursor.as_ref().map(|next| { + PaginatedRequestParams::default().with_cursor(Some(next.clone())) }); let response = match client.list_resources(params, timeout).await { Ok(result) => result, @@ -521,9 +632,8 @@ impl McpConnectionManager { let mut cursor: Option = None; loop { - let params = cursor.as_ref().map(|next| PaginatedRequestParams { - meta: None, - cursor: Some(next.clone()), + let params = cursor.as_ref().map(|next| { + PaginatedRequestParams::default().with_cursor(Some(next.clone())) }); let response = match client.list_resource_templates(params, timeout).await { Ok(result) => result, diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 50b1bf6c12..60a0026eeb 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -1,9 +1,11 @@ use super::*; use crate::codex_apps::CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION; use crate::codex_apps::CodexAppsToolsCacheContext; +use crate::codex_apps::load_startup_cached_codex_apps_server_info; use crate::codex_apps::load_startup_cached_codex_apps_tools_snapshot; use crate::codex_apps::read_cached_codex_apps_tools; use crate::codex_apps::write_cached_codex_apps_tools; +use crate::codex_apps::write_cached_codex_apps_tools_if_needed; use crate::declared_openai_file_input_param_names; use crate::elicitation::ElicitationRequestManager; use crate::elicitation::elicitation_is_rejected_by_policy; @@ -14,11 +16,13 @@ use crate::server::McpServerOrigin; use crate::tools::ToolFilter; use crate::tools::ToolInfo; use crate::tools::filter_tools; -use crate::tools::normalize_tools_for_model; +use crate::tools::normalize_tools_for_model_with_prefix; use crate::tools::tool_with_model_visible_input_schema; use codex_config::Constrained; use codex_config::McpServerConfig; +use codex_exec_server::EnvironmentManager; use codex_protocol::ToolName; +use codex_protocol::mcp::McpServerInfo; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::McpAuthStatus; @@ -36,25 +40,18 @@ use std::sync::Arc; use tempfile::tempdir; fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo { - let tool_namespace = format!("mcp__{server_name}__"); ToolInfo { server_name: server_name.to_string(), supports_parallel_tool_calls: false, server_origin: None, callable_name: tool_name.to_string(), - callable_namespace: tool_namespace, + callable_namespace: server_name.to_string(), namespace_description: None, - tool: Tool { - name: tool_name.to_string().into(), - title: None, - description: Some(format!("Test tool: {tool_name}").into()), - input_schema: Arc::new(JsonObject::default()), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }, + tool: Tool::new( + tool_name.to_string(), + format!("Test tool: {tool_name}"), + Arc::new(JsonObject::default()), + ), connector_id: None, connector_name: None, plugin_display_names: Vec::new(), @@ -88,6 +85,17 @@ fn create_codex_apps_tools_cache_context( } } +fn create_test_server_info(title: &str) -> McpServerInfo { + McpServerInfo { + name: "codex-apps".to_string(), + title: Some(title.to_string()), + version: "1.0.0".to_string(), + description: None, + icons: None, + website_url: None, + } +} + fn model_tool_names(tools: &[ToolInfo]) -> HashSet { tools .iter() @@ -96,7 +104,10 @@ fn model_tool_names(tools: &[ToolInfo]) -> HashSet { } fn model_tool_name_len(name: &ToolName) -> usize { - name.namespace.as_deref().map_or(0, str::len) + name.name.len() + name.namespace + .as_deref() + .map_or(0, |namespace| namespace.len() + "__".len()) + + name.name.len() } fn is_code_mode_compatible_tool_name(name: &ToolName) -> bool { @@ -300,13 +311,14 @@ fn test_normalize_tools_short_non_duplicated_names() { create_test_tool("server1", "tool2"), ]; - let model_tools = normalize_tools_for_model(tools); + let model_tools = + normalize_tools_for_model_with_prefix(tools, /*prefix_mcp_tool_names*/ true); assert_eq!( model_tool_names(&model_tools), HashSet::from([ - ToolName::namespaced("mcp__server1__", "tool1"), - ToolName::namespaced("mcp__server1__", "tool2") + ToolName::namespaced("mcp__server1", "tool1"), + ToolName::namespaced("mcp__server1", "tool2") ]) ); } @@ -318,12 +330,13 @@ fn test_normalize_tools_duplicated_names_skipped() { create_test_tool("server1", "duplicate_tool"), ]; - let model_tools = normalize_tools_for_model(tools); + let model_tools = + normalize_tools_for_model_with_prefix(tools, /*prefix_mcp_tool_names*/ true); // Only the first tool should remain, the second is skipped assert_eq!( model_tool_names(&model_tools), - HashSet::from([ToolName::namespaced("mcp__server1__", "duplicate_tool")]) + HashSet::from([ToolName::namespaced("mcp__server1", "duplicate_tool")]) ); } @@ -342,7 +355,8 @@ fn test_normalize_tools_long_names_same_server() { ), ]; - let model_tools = normalize_tools_for_model(tools); + let model_tools = + normalize_tools_for_model_with_prefix(tools, /*prefix_mcp_tool_names*/ true); assert_eq!(model_tools.len(), 2); @@ -352,7 +366,7 @@ fn test_normalize_tools_long_names_same_server() { assert!( names .iter() - .all(|name| name.namespace.as_deref() == Some("mcp__my_server__")) + .all(|name| name.namespace.as_deref() == Some("mcp__my_server")) ); assert!( names.iter().all(is_code_mode_compatible_tool_name), @@ -364,14 +378,15 @@ fn test_normalize_tools_long_names_same_server() { fn test_normalize_tools_sanitizes_invalid_characters() { let tools = vec![create_test_tool("server.one", "tool.two-three")]; - let model_tools = normalize_tools_for_model(tools); + let model_tools = + normalize_tools_for_model_with_prefix(tools, /*prefix_mcp_tool_names*/ true); assert_eq!(model_tools.len(), 1); let tool = model_tools.into_iter().next().expect("one tool"); let model_name = tool.canonical_tool_name(); assert_eq!( model_name, - ToolName::namespaced("mcp__server_one__", "tool_two_three") + ToolName::namespaced("mcp__server_one", "tool_two_three") ); assert_eq!( ToolName::namespaced(tool.callable_namespace.clone(), tool.callable_name.clone()), @@ -380,7 +395,7 @@ fn test_normalize_tools_sanitizes_invalid_characters() { // The callable parts are sanitized for model-visible tool calls, but the raw // MCP name is preserved for the actual MCP call. assert_eq!(tool.server_name, "server.one"); - assert_eq!(tool.callable_namespace, "mcp__server_one__"); + assert_eq!(tool.callable_namespace, "mcp__server_one"); assert_eq!(tool.callable_name, "tool_two_three"); assert_eq!(tool.tool.name, "tool.two-three"); @@ -394,15 +409,16 @@ fn test_normalize_tools_sanitizes_invalid_characters() { fn test_normalize_tools_keeps_hyphenated_mcp_tools_callable() { let tools = vec![create_test_tool("music-studio", "get-strudel-guide")]; - let model_tools = normalize_tools_for_model(tools); + let model_tools = + normalize_tools_for_model_with_prefix(tools, /*prefix_mcp_tool_names*/ true); assert_eq!(model_tools.len(), 1); let tool = model_tools.into_iter().next().expect("one tool"); assert_eq!( tool.canonical_tool_name(), - ToolName::namespaced("mcp__music_studio__", "get_strudel_guide") + ToolName::namespaced("mcp__music_studio", "get_strudel_guide") ); - assert_eq!(tool.callable_namespace, "mcp__music_studio__"); + assert_eq!(tool.callable_namespace, "mcp__music_studio"); assert_eq!(tool.callable_name, "get_strudel_guide"); assert_eq!(tool.tool.name, "get-strudel-guide"); } @@ -414,7 +430,8 @@ fn test_normalize_tools_disambiguates_sanitized_namespace_collisions() { create_test_tool("basic_server", "query"), ]; - let model_tools = normalize_tools_for_model(tools); + let model_tools = + normalize_tools_for_model_with_prefix(tools, /*prefix_mcp_tool_names*/ true); assert_eq!(model_tools.len(), 2); let mut namespaces = model_tools @@ -444,7 +461,8 @@ fn test_normalize_tools_disambiguates_sanitized_tool_name_collisions() { create_test_tool("server", "tool_name"), ]; - let model_tools = normalize_tools_for_model(tools); + let model_tools = + normalize_tools_for_model_with_prefix(tools, /*prefix_mcp_tool_names*/ true); assert_eq!(model_tools.len(), 2); let raw_tool_names = model_tools @@ -578,8 +596,8 @@ fn codex_apps_tools_cache_is_scoped_per_user() { assert_eq!(read_user_1[0].callable_name, "one"); assert_eq!(read_user_2[0].callable_name, "two"); assert_ne!( - cache_context_user_1.cache_path(), - cache_context_user_2.cache_path(), + cache_context_user_1.tools_cache_path(), + cache_context_user_2.tools_cache_path(), "each user should get an isolated cache file" ); } @@ -623,7 +641,7 @@ fn codex_apps_tools_cache_is_ignored_when_schema_version_mismatches() { Some("account-one"), Some("user-one"), ); - let cache_path = cache_context.cache_path(); + let cache_path = cache_context.tools_cache_path(); if let Some(parent) = cache_path.parent() { std::fs::create_dir_all(parent).expect("create parent"); } @@ -645,7 +663,7 @@ fn codex_apps_tools_cache_is_ignored_when_json_is_invalid() { Some("account-one"), Some("user-one"), ); - let cache_path = cache_context.cache_path(); + let cache_path = cache_context.tools_cache_path(); if let Some(parent) = cache_path.parent() { std::fs::create_dir_all(parent).expect("create parent"); } @@ -666,21 +684,112 @@ fn startup_cached_codex_apps_tools_loads_from_disk_cache() { CODEX_APPS_MCP_SERVER_NAME, "calendar_search", )]; - write_cached_codex_apps_tools(&cache_context, &cached_tools); + let server_info = create_test_server_info("Codex Apps"); + write_cached_codex_apps_tools_if_needed( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + &server_info, + &cached_tools, + ); - let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot( + let startup_tools = load_startup_cached_codex_apps_tools_snapshot( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + ) + .expect("expected startup snapshot to load from cache"); + let cached_server_info = load_startup_cached_codex_apps_server_info( CODEX_APPS_MCP_SERVER_NAME, Some(&cache_context), ); - let startup_tools = startup_snapshot.expect("expected startup snapshot to load from cache"); assert_eq!(startup_tools.len(), 1); assert_eq!(startup_tools[0].server_name, CODEX_APPS_MCP_SERVER_NAME); assert_eq!(startup_tools[0].callable_name, "calendar_search"); + assert_eq!(cached_server_info, Some(server_info)); +} + +#[test] +fn startup_cached_codex_apps_tools_loads_without_server_info_cache() { + let codex_home = tempdir().expect("tempdir"); + let cache_context = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let cache_path = cache_context.tools_cache_path(); + if let Some(parent) = cache_path.parent() { + std::fs::create_dir_all(parent).expect("create parent"); + } + let bytes = serde_json::to_vec_pretty(&serde_json::json!({ + "schema_version": CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION, + "tools": [create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "calendar_search")], + })) + .expect("serialize"); + std::fs::write(cache_path, bytes).expect("write"); + + let startup_tools = load_startup_cached_codex_apps_tools_snapshot( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + ) + .expect("legacy startup snapshot should remain available"); + let cached_server_info = load_startup_cached_codex_apps_server_info( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + ); + + assert_eq!(startup_tools.len(), 1); + assert_eq!(startup_tools[0].callable_name, "calendar_search"); + assert_eq!(cached_server_info, None); +} + +#[test] +fn codex_apps_server_info_cache_survives_legacy_tools_cache_write() { + let codex_home = tempdir().expect("tempdir"); + let cache_context = create_codex_apps_tools_cache_context( + codex_home.path().to_path_buf(), + Some("account-one"), + Some("user-one"), + ); + let server_info = create_test_server_info("Codex Apps"); + write_cached_codex_apps_tools_if_needed( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + &server_info, + &[create_test_tool( + CODEX_APPS_MCP_SERVER_NAME, + "calendar_search", + )], + ); + + let cache_path = cache_context.tools_cache_path(); + if let Some(parent) = cache_path.parent() { + std::fs::create_dir_all(parent).expect("create parent"); + } + let bytes = serde_json::to_vec_pretty(&serde_json::json!({ + "schema_version": CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION - 1, + "tools": [create_test_tool(CODEX_APPS_MCP_SERVER_NAME, "calendar_search")], + })) + .expect("serialize"); + std::fs::write(cache_path, bytes).expect("write legacy tools cache"); + + assert_eq!( + load_startup_cached_codex_apps_server_info( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + ), + Some(server_info) + ); + assert!( + load_startup_cached_codex_apps_tools_snapshot( + CODEX_APPS_MCP_SERVER_NAME, + Some(&cache_context), + ) + .is_none() + ); } #[tokio::test] -async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() { +async fn list_all_tools_uses_cached_tool_info_snapshot_while_client_is_pending() { let startup_tools = vec![create_test_tool( CODEX_APPS_MCP_SERVER_NAME, "calendar_create_event", @@ -690,13 +799,17 @@ async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() { .shared(); let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); let permission_profile = Constrained::allow_any(PermissionProfile::default()); - let mut manager = - McpConnectionManager::new_uninitialized(&approval_policy, &permission_profile); + let mut manager = McpConnectionManager::new_uninitialized( + &approval_policy, + &permission_profile, + /*prefix_mcp_tool_names*/ true, + ); manager.clients.insert( CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: pending_client, - startup_snapshot: Some(startup_tools), + cached_tool_info_snapshot: Some(startup_tools), + cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), @@ -708,7 +821,7 @@ async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() { .iter() .find(|tool| { tool.canonical_tool_name() - == ToolName::namespaced("mcp__codex_apps__", "calendar_create_event") + == ToolName::namespaced("mcp__codex_apps", "calendar_create_event") }) .expect("tool from startup cache"); assert_eq!(tool.server_name, CODEX_APPS_MCP_SERVER_NAME); @@ -716,19 +829,146 @@ async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() { } #[tokio::test] -async fn list_all_tools_blocks_while_client_is_pending_without_startup_snapshot() { +async fn list_available_server_infos_uses_cache_while_client_is_pending() { let pending_client = futures::future::pending::>() .boxed() .shared(); let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); let permission_profile = Constrained::allow_any(PermissionProfile::default()); - let mut manager = - McpConnectionManager::new_uninitialized(&approval_policy, &permission_profile); + let mut manager = McpConnectionManager::new_uninitialized( + &approval_policy, + &permission_profile, + /*prefix_mcp_tool_names*/ true, + ); + let server_info = create_test_server_info("Codex Apps"); manager.clients.insert( CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: pending_client, - startup_snapshot: None, + cached_tool_info_snapshot: Some(Vec::new()), + cached_server_info: Some(server_info.clone()), + startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), + cancel_token: CancellationToken::new(), + }, + ); + + let timeout_result = tokio::time::timeout( + Duration::from_millis(10), + manager.list_available_server_infos(), + ) + .await; + let server_infos = timeout_result.expect("server info lookup should not block on startup"); + assert_eq!( + server_infos.get(CODEX_APPS_MCP_SERVER_NAME), + Some(&server_info) + ); +} + +#[tokio::test] +async fn list_all_tools_accepts_canonical_namespaced_tool_names() { + let startup_tools = vec![create_test_tool("rmcp", "echo")]; + let pending_client = futures::future::pending::>() + .boxed() + .shared(); + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let permission_profile = Constrained::allow_any(PermissionProfile::default()); + let mut manager = McpConnectionManager::new_uninitialized( + &approval_policy, + &permission_profile, + /*prefix_mcp_tool_names*/ false, + ); + manager.clients.insert( + "rmcp".to_string(), + AsyncManagedClient { + client: pending_client, + cached_tool_info_snapshot: Some(startup_tools), + cached_server_info: None, + startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), + cancel_token: CancellationToken::new(), + }, + ); + + let tools = manager.list_all_tools().await; + let tool = tools + .iter() + .find(|tool| tool.canonical_tool_name() == ToolName::namespaced("rmcp", "echo")) + .expect("split MCP tool namespace and name should resolve"); + + let expected = ("rmcp", "rmcp", "echo", "echo"); + assert_eq!( + ( + tool.server_name.as_str(), + tool.callable_namespace.as_str(), + tool.callable_name.as_str(), + tool.tool.name.as_ref(), + ), + expected + ); +} + +#[tokio::test] +async fn list_all_tools_applies_legacy_mcp_prefix_by_default() { + let startup_tools = vec![create_test_tool("rmcp", "echo")]; + let pending_client = futures::future::pending::>() + .boxed() + .shared(); + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let permission_profile = Constrained::allow_any(PermissionProfile::default()); + let mut manager = McpConnectionManager::new_uninitialized( + &approval_policy, + &permission_profile, + /*prefix_mcp_tool_names*/ true, + ); + manager.clients.insert( + "rmcp".to_string(), + AsyncManagedClient { + client: pending_client, + cached_tool_info_snapshot: Some(startup_tools), + cached_server_info: None, + startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), + cancel_token: CancellationToken::new(), + }, + ); + + let tools = manager.list_all_tools().await; + let tool = tools + .iter() + .find(|tool| tool.canonical_tool_name() == ToolName::namespaced("mcp__rmcp", "echo")) + .expect("legacy-prefixed MCP tool name should resolve"); + + let expected = ("rmcp", "mcp__rmcp", "echo", "echo"); + assert_eq!( + ( + tool.server_name.as_str(), + tool.callable_namespace.as_str(), + tool.callable_name.as_str(), + tool.tool.name.as_ref(), + ), + expected + ); +} + +#[tokio::test] +async fn list_all_tools_blocks_while_client_is_pending_without_cached_tool_info_snapshot() { + let pending_client = futures::future::pending::>() + .boxed() + .shared(); + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let permission_profile = Constrained::allow_any(PermissionProfile::default()); + let mut manager = McpConnectionManager::new_uninitialized( + &approval_policy, + &permission_profile, + /*prefix_mcp_tool_names*/ true, + ); + manager.clients.insert( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + AsyncManagedClient { + client: pending_client, + cached_tool_info_snapshot: None, + cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), @@ -741,19 +981,23 @@ async fn list_all_tools_blocks_while_client_is_pending_without_startup_snapshot( } #[tokio::test] -async fn list_all_tools_does_not_block_when_startup_snapshot_cache_hit_is_empty() { +async fn list_all_tools_does_not_block_when_cached_tool_info_snapshot_is_empty() { let pending_client = futures::future::pending::>() .boxed() .shared(); let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); let permission_profile = Constrained::allow_any(PermissionProfile::default()); - let mut manager = - McpConnectionManager::new_uninitialized(&approval_policy, &permission_profile); + let mut manager = McpConnectionManager::new_uninitialized( + &approval_policy, + &permission_profile, + /*prefix_mcp_tool_names*/ true, + ); manager.clients.insert( CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: pending_client, - startup_snapshot: Some(Vec::new()), + cached_tool_info_snapshot: Some(Vec::new()), + cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), @@ -767,11 +1011,12 @@ async fn list_all_tools_does_not_block_when_startup_snapshot_cache_hit_is_empty( } #[tokio::test] -async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() { +async fn list_all_tools_uses_cached_tool_info_snapshot_when_client_startup_fails() { let startup_tools = vec![create_test_tool( CODEX_APPS_MCP_SERVER_NAME, "calendar_create_event", )]; + let server_info = create_test_server_info("Codex Apps"); let failed_client = futures::future::ready::>(Err( StartupOutcomeError::Failed { error: "startup failed".to_string(), @@ -781,14 +1026,18 @@ async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() { .shared(); let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); let permission_profile = Constrained::allow_any(PermissionProfile::default()); - let mut manager = - McpConnectionManager::new_uninitialized(&approval_policy, &permission_profile); + let mut manager = McpConnectionManager::new_uninitialized( + &approval_policy, + &permission_profile, + /*prefix_mcp_tool_names*/ true, + ); let startup_complete = Arc::new(std::sync::atomic::AtomicBool::new(true)); manager.clients.insert( CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: failed_client, - startup_snapshot: Some(startup_tools), + cached_tool_info_snapshot: Some(startup_tools), + cached_server_info: Some(server_info.clone()), startup_complete, tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), @@ -800,11 +1049,18 @@ async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() { .iter() .find(|tool| { tool.canonical_tool_name() - == ToolName::namespaced("mcp__codex_apps__", "calendar_create_event") + == ToolName::namespaced("mcp__codex_apps", "calendar_create_event") }) .expect("tool from startup cache"); assert_eq!(tool.server_name, CODEX_APPS_MCP_SERVER_NAME); assert_eq!(tool.callable_name, "calendar_create_event"); + assert_eq!( + manager + .list_available_server_infos() + .await + .get(CODEX_APPS_MCP_SERVER_NAME), + Some(&server_info) + ); } #[tokio::test] @@ -816,8 +1072,11 @@ async fn list_all_tools_adds_server_metadata_to_cached_tools() { .shared(); let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); let permission_profile = Constrained::allow_any(PermissionProfile::default()); - let mut manager = - McpConnectionManager::new_uninitialized(&approval_policy, &permission_profile); + let mut manager = McpConnectionManager::new_uninitialized( + &approval_policy, + &permission_profile, + /*prefix_mcp_tool_names*/ true, + ); manager.server_metadata.insert( server_name.to_string(), McpServerMetadata { @@ -832,7 +1091,8 @@ async fn list_all_tools_adds_server_metadata_to_cached_tools() { server_name.to_string(), AsyncManagedClient { client: pending_client, - startup_snapshot: Some(startup_tools), + cached_tool_info_snapshot: Some(startup_tools), + cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), cancel_token: CancellationToken::new(), @@ -847,6 +1107,112 @@ async fn list_all_tools_adds_server_metadata_to_cached_tools() { assert_eq!(tool.server_origin.as_deref(), Some("https://docs.example")); } +#[tokio::test] +async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() { + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let (tx_event, rx_event) = async_channel::unbounded(); + drop(rx_event); + let codex_home = tempdir().expect("tempdir"); + let mcp_servers = HashMap::from([ + ( + "stdio".to_string(), + EffectiveMcpServer::configured(McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth: None, + oauth_resource: None, + tools: HashMap::new(), + }), + ), + ( + "http".to_string(), + EffectiveMcpServer::configured(McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "http://127.0.0.1:1".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth: None, + oauth_resource: None, + tools: HashMap::new(), + }), + ), + ]); + + let (manager, cancel_token) = McpConnectionManager::new( + &mcp_servers, + OAuthCredentialsStoreMode::default(), + HashMap::new(), + &approval_policy, + String::new(), + tx_event, + PermissionProfile::default(), + McpRuntimeContext::new( + Arc::new(EnvironmentManager::without_environments()), + PathBuf::from("/tmp"), + ), + codex_home.path().to_path_buf(), + CodexAppsToolsCacheKey { + account_id: None, + chatgpt_user_id: None, + is_workspace_account: false, + }, + /*host_owned_codex_apps_enabled*/ false, + /*prefix_mcp_tool_names*/ true, + ElicitationCapability::default(), + ToolPluginProvenance::default(), + /*auth*/ None, + /*elicitation_reviewer*/ None, + ) + .await; + + assert!(manager.clients.contains_key("stdio")); + assert!(manager.clients.contains_key("http")); + assert!( + !manager + .wait_for_server_ready("stdio", Duration::from_millis(10)) + .await + ); + let failures = manager + .required_startup_failures(&["stdio".to_string()]) + .await; + assert_eq!(failures.len(), 1); + assert_eq!(failures[0].server, "stdio"); + assert_eq!( + failures[0].error, + "local stdio MCP server `stdio` requires a local environment" + ); + cancel_token.cancel(); +} + #[test] fn elicitation_capability_uses_2025_06_18_shape_for_form_only_support() { let capability = Some(ElicitationCapability::default()); @@ -882,7 +1248,7 @@ fn mcp_init_error_display_prompts_for_github_pat() { http_headers: None, env_http_headers: None, }, - experimental_environment: None, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, @@ -935,7 +1301,7 @@ fn mcp_init_error_display_reports_generic_errors() { http_headers: None, env_http_headers: None, }, - experimental_environment: None, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index 9d1fe223d6..83419175d4 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -1,9 +1,10 @@ pub use connection_manager::McpConnectionManager; +pub use connection_manager::tool_is_model_visible; pub use elicitation::ElicitationReviewRequest; pub use elicitation::ElicitationReviewer; pub use elicitation::ElicitationReviewerHandle; pub use rmcp_client::MCP_SANDBOX_STATE_META_CAPABILITY; -pub use runtime::McpRuntimeEnvironment; +pub use runtime::McpRuntimeContext; pub use runtime::SandboxState; pub use tools::ToolInfo; diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 0d24718ba9..51a6f18686 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -25,6 +25,7 @@ use codex_config::types::ApprovalsReviewer; use codex_config::types::OAuthCredentialsStoreMode; use codex_login::CodexAuth; use codex_plugin::PluginCapabilitySummary; +use codex_protocol::mcp::McpServerInfo; use codex_protocol::mcp::Resource; use codex_protocol::mcp::ResourceTemplate; use codex_protocol::mcp::Tool; @@ -38,7 +39,7 @@ use serde_json::Value; use crate::codex_apps::codex_apps_tools_cache_key; use crate::connection_manager::McpConnectionManager; -use crate::runtime::McpRuntimeEnvironment; +use crate::runtime::McpRuntimeContext; use crate::server::EffectiveMcpServer; pub const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps"; @@ -109,6 +110,8 @@ pub struct McpConfig { pub chatgpt_base_url: String, /// Optional path override for the host-owned apps MCP server. pub apps_mcp_path_override: Option, + /// Optional product SKU forwarded to the host-owned apps MCP server. + pub apps_mcp_product_sku: Option, /// Codex home directory used for MCP OAuth state and app-tool cache files. pub codex_home: PathBuf, /// Preferred credential store for MCP OAuth tokens. @@ -130,12 +133,17 @@ pub struct McpConfig { /// ChatGPT auth is checked separately at runtime before the host-owned apps /// MCP server is added. pub apps_enabled: bool, + /// Whether model-visible MCP tool namespaces should keep the legacy + /// `mcp__` prefix. + pub prefix_mcp_tool_names: bool, /// Client-side elicitation capabilities advertised during MCP initialization. pub client_elicitation_capability: ElicitationCapability, /// Config-backed MCP servers keyed by server name. /// /// Runtime-only additions are merged later by [`effective_mcp_servers`]. pub configured_mcp_servers: HashMap, + /// Winning plugin owner for plugin-provided MCP servers, keyed by server name. + pub plugin_ids_by_mcp_server_name: HashMap, /// Plugin metadata used to attribute MCP tools/connectors to plugin display names. pub plugin_capability_summaries: Vec, } @@ -144,6 +152,7 @@ pub struct McpConfig { pub struct ToolPluginProvenance { plugin_display_names_by_connector_id: HashMap>, plugin_display_names_by_mcp_server_name: HashMap>, + plugin_ids_by_mcp_server_name: HashMap, } impl ToolPluginProvenance { @@ -161,9 +170,15 @@ impl ToolPluginProvenance { .unwrap_or(&[]) } - fn from_capability_summaries(capability_summaries: &[PluginCapabilitySummary]) -> Self { + pub fn plugin_id_for_mcp_server_name(&self, server_name: &str) -> Option<&str> { + self.plugin_ids_by_mcp_server_name + .get(server_name) + .map(String::as_str) + } + + fn from_config(config: &McpConfig) -> Self { let mut tool_plugin_provenance = Self::default(); - for plugin in capability_summaries { + for plugin in &config.plugin_capability_summaries { for connector_id in &plugin.app_connector_ids { tool_plugin_provenance .plugin_display_names_by_connector_id @@ -193,6 +208,8 @@ impl ToolPluginProvenance { plugin_names.sort_unstable(); plugin_names.dedup(); } + tool_plugin_provenance.plugin_ids_by_mcp_server_name = + config.plugin_ids_by_mcp_server_name.clone(); tool_plugin_provenance } @@ -242,13 +259,13 @@ pub fn effective_mcp_servers_from_configured( } pub fn tool_plugin_provenance(config: &McpConfig) -> ToolPluginProvenance { - ToolPluginProvenance::from_capability_summaries(&config.plugin_capability_summaries) + ToolPluginProvenance::from_config(config) } pub async fn read_mcp_resource( config: &McpConfig, auth: Option<&CodexAuth>, - runtime_environment: McpRuntimeEnvironment, + runtime_context: McpRuntimeContext, server: &str, uri: &str, ) -> anyhow::Result { @@ -271,10 +288,11 @@ pub async fn read_mcp_resource( String::new(), tx_event, PermissionProfile::default(), - runtime_environment, + runtime_context, config.codex_home.clone(), codex_apps_tools_cache_key(auth), host_owned_codex_apps_enabled, + config.prefix_mcp_tool_names, config.client_elicitation_capability.clone(), tool_plugin_provenance(config), auth, @@ -283,13 +301,7 @@ pub async fn read_mcp_resource( .await; let result = manager - .read_resource( - server, - ReadResourceRequestParams { - meta: None, - uri: uri.to_string(), - }, - ) + .read_resource(server, ReadResourceRequestParams::new(uri)) .await; cancel_token.cancel(); result @@ -297,17 +309,19 @@ pub async fn read_mcp_resource( #[derive(Debug, Clone)] pub struct McpServerStatusSnapshot { + pub server_infos: HashMap, pub tools_by_server: HashMap>, pub resources: HashMap>, pub resource_templates: HashMap>, pub auth_statuses: HashMap, + pub server_names: Vec, } pub async fn collect_mcp_server_status_snapshot_with_detail( config: &McpConfig, auth: Option<&CodexAuth>, submit_id: String, - runtime_environment: McpRuntimeEnvironment, + runtime_context: McpRuntimeContext, detail: McpSnapshotDetail, ) -> McpServerStatusSnapshot { let mcp_servers = effective_mcp_servers(config, auth); @@ -315,10 +329,12 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( let tool_plugin_provenance = tool_plugin_provenance(config); if mcp_servers.is_empty() { return McpServerStatusSnapshot { + server_infos: HashMap::new(), tools_by_server: HashMap::new(), resources: HashMap::new(), resource_templates: HashMap::new(), auth_statuses: HashMap::new(), + server_names: Vec::new(), }; } @@ -329,6 +345,8 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( ) .await; + let server_names = mcp_servers.keys().cloned().collect(); + let (tx_event, rx_event) = unbounded(); drop(rx_event); @@ -340,10 +358,11 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( submit_id, tx_event, PermissionProfile::default(), - runtime_environment, + runtime_context, config.codex_home.clone(), codex_apps_tools_cache_key(auth), host_owned_codex_apps_enabled, + config.prefix_mcp_tool_names, config.client_elicitation_capability.clone(), tool_plugin_provenance, auth, @@ -354,6 +373,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( let snapshot = collect_mcp_server_status_snapshot_from_manager( &mcp_connection_manager, auth_status_entries, + server_names, detail, ) .await; @@ -427,15 +447,18 @@ fn codex_apps_mcp_url_for_base_url(base_url: &str, apps_mcp_path_override: Optio fn codex_apps_mcp_server_config(config: &McpConfig) -> McpServerConfig { let url = codex_apps_mcp_url(config); + let http_headers = config.apps_mcp_product_sku.as_ref().map(|product_sku| { + HashMap::from([("X-OpenAI-Product-Sku".to_string(), product_sku.clone())]) + }); McpServerConfig { transport: McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var: codex_apps_mcp_bearer_token_env_var(), - http_headers: None, + http_headers, env_http_headers: None, }, - experimental_environment: None, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, @@ -559,6 +582,7 @@ fn convert_mcp_resource_templates( async fn collect_mcp_server_status_snapshot_from_manager( mcp_connection_manager: &McpConnectionManager, auth_status_entries: HashMap, + server_names: Vec, detail: McpSnapshotDetail, ) -> McpServerStatusSnapshot { let (tools, resources, resource_templates) = tokio::join!( @@ -578,6 +602,7 @@ async fn collect_mcp_server_status_snapshot_from_manager( } }, ); + let server_infos = mcp_connection_manager.list_available_server_infos().await; let mut tools_by_server = HashMap::>::new(); for tool_info in tools { @@ -593,10 +618,12 @@ async fn collect_mcp_server_status_snapshot_from_manager( } McpServerStatusSnapshot { + server_infos, tools_by_server, resources: convert_mcp_resources(resources), resource_templates: convert_mcp_resource_templates(resource_templates), auth_statuses: auth_statuses_from_entries(&auth_status_entries), + server_names, } } diff --git a/codex-rs/codex-mcp/src/mcp/mod_tests.rs b/codex-rs/codex-mcp/src/mcp/mod_tests.rs index 286c191b29..d4d888f202 100644 --- a/codex-rs/codex-mcp/src/mcp/mod_tests.rs +++ b/codex-rs/codex-mcp/src/mcp/mod_tests.rs @@ -18,6 +18,7 @@ fn test_mcp_config(codex_home: PathBuf) -> McpConfig { McpConfig { chatgpt_base_url: "https://chatgpt.com".to_string(), apps_mcp_path_override: None, + apps_mcp_product_sku: None, codex_home, mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode::default(), mcp_oauth_callback_port: None, @@ -27,8 +28,10 @@ fn test_mcp_config(codex_home: PathBuf) -> McpConfig { codex_linux_sandbox_exe: None, use_legacy_landlock: false, apps_enabled: false, + prefix_mcp_tool_names: true, client_elicitation_capability: ElicitationCapability::default(), configured_mcp_servers: HashMap::new(), + plugin_ids_by_mcp_server_name: HashMap::new(), plugin_capability_summaries: Vec::new(), } } @@ -123,7 +126,10 @@ fn mcp_prompt_auto_approval_rejects_auto_mode_in_default_permission_mode() { #[test] fn tool_plugin_provenance_collects_app_and_mcp_sources() { - let provenance = ToolPluginProvenance::from_capability_summaries(&[ + let mut config = test_mcp_config(PathBuf::new()); + config.plugin_ids_by_mcp_server_name = + HashMap::from([("alpha".to_string(), "alpha@test".to_string())]); + config.plugin_capability_summaries = vec![ PluginCapabilitySummary { display_name: "alpha-plugin".to_string(), app_connector_ids: vec![AppConnectorId("connector_example".to_string())], @@ -139,7 +145,8 @@ fn tool_plugin_provenance_collects_app_and_mcp_sources() { mcp_server_names: vec!["beta".to_string()], ..PluginCapabilitySummary::default() }, - ]); + ]; + let provenance = tool_plugin_provenance(&config); assert_eq!( provenance, @@ -158,8 +165,17 @@ fn tool_plugin_provenance_collects_app_and_mcp_sources() { ("alpha".to_string(), vec!["alpha-plugin".to_string()]), ("beta".to_string(), vec!["beta-plugin".to_string()]), ]), + plugin_ids_by_mcp_server_name: HashMap::from([( + "alpha".to_string(), + "alpha@test".to_string(), + )]), } ); + assert_eq!( + provenance.plugin_id_for_mcp_server_name("alpha"), + Some("alpha@test") + ); + assert_eq!(provenance.plugin_id_for_mcp_server_name("beta"), None); } #[test] @@ -251,6 +267,40 @@ fn codex_apps_server_config_uses_configured_apps_mcp_path_override() { assert_eq!(url, "https://chatgpt.com/backend-api/custom/mcp"); } +#[test] +fn codex_apps_server_config_forwards_configured_product_sku_header() { + let mut config = test_mcp_config(PathBuf::from("/tmp")); + config.apps_mcp_product_sku = Some("tpp".to_string()); + config.apps_enabled = true; + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + + let servers = with_codex_apps_mcp(HashMap::new(), Some(&auth), &config); + let server = servers + .get(CODEX_APPS_MCP_SERVER_NAME) + .expect("codex apps should be present when apps is enabled"); + let config = server + .configured_config() + .expect("codex apps should use configured transport"); + + match &config.transport { + McpServerTransportConfig::StreamableHttp { + http_headers, + env_http_headers, + .. + } => { + assert_eq!( + http_headers, + &Some(HashMap::from([( + "X-OpenAI-Product-Sku".to_string(), + "tpp".to_string(), + )])) + ); + assert!(env_http_headers.is_none()); + } + other => panic!("expected streamable http transport, got {other:?}"), + } +} + #[tokio::test] async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() { let codex_home = tempfile::tempdir().expect("tempdir"); @@ -267,7 +317,7 @@ async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() { http_headers: None, env_http_headers: None, }, - experimental_environment: None, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, @@ -292,7 +342,7 @@ async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() { http_headers: None, env_http_headers: None, }, - experimental_environment: None, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index fcc9d1c86e..78078720e7 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -20,6 +20,7 @@ use crate::codex_apps::CachedCodexAppsToolsLoad; use crate::codex_apps::CodexAppsToolsCacheContext; use crate::codex_apps::filter_disallowed_codex_apps_tools; use crate::codex_apps::load_cached_codex_apps_tools; +use crate::codex_apps::load_startup_cached_codex_apps_server_info; use crate::codex_apps::load_startup_cached_codex_apps_tools_snapshot; use crate::codex_apps::normalize_codex_apps_callable_name; use crate::codex_apps::normalize_codex_apps_callable_namespace; @@ -28,7 +29,7 @@ use crate::codex_apps::write_cached_codex_apps_tools_if_needed; use crate::elicitation::ElicitationRequestManager; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::ToolPluginProvenance; -use crate::runtime::McpRuntimeEnvironment; +use crate::runtime::McpRuntimeContext; use crate::runtime::emit_duration; use crate::server::EffectiveMcpServer; use crate::server::McpServerLaunch; @@ -47,6 +48,7 @@ use codex_config::McpServerTransportConfig; use codex_config::types::OAuthCredentialsStoreMode; use codex_exec_server::HttpClient; use codex_exec_server::ReqwestHttpClient; +use codex_protocol::mcp::McpServerInfo; use codex_protocol::protocol::Event; use codex_rmcp_client::ExecutorStdioServerLauncher; use codex_rmcp_client::LocalStdioServerLauncher; @@ -85,6 +87,7 @@ const UNTRUSTED_CONNECTOR_META_KEYS: &[&str] = &[ #[derive(Clone)] pub(crate) struct ManagedClient { pub(crate) client: Arc, + pub(crate) server_info: McpServerInfo, pub(crate) tools: Vec, pub(crate) tool_filter: ToolFilter, pub(crate) tool_timeout: Option, @@ -123,7 +126,8 @@ impl ManagedClient { #[derive(Clone)] pub(crate) struct AsyncManagedClient { pub(crate) client: Shared>>, - pub(crate) startup_snapshot: Option>, + pub(crate) cached_tool_info_snapshot: Option>, + pub(crate) cached_server_info: Option, pub(crate) startup_complete: Arc, pub(crate) tool_plugin_provenance: Arc, pub(crate) cancel_token: CancellationToken, @@ -142,7 +146,7 @@ impl AsyncManagedClient { elicitation_requests: ElicitationRequestManager, codex_apps_tools_cache_context: Option, tool_plugin_provenance: Arc, - runtime_environment: McpRuntimeEnvironment, + runtime_context: McpRuntimeContext, runtime_auth_provider: Option, client_elicitation_capability: ElicitationCapability, ) -> Self { @@ -150,11 +154,16 @@ impl AsyncManagedClient { .configured_config() .map(ToolFilter::from_config) .unwrap_or_default(); - let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot( + let cached_tool_info_snapshot = load_startup_cached_codex_apps_tools_snapshot( &server_name, codex_apps_tools_cache_context.as_ref(), - ) - .map(|tools| filter_tools(tools, &tool_filter)); + ); + let cached_tool_info_snapshot = + cached_tool_info_snapshot.map(|tools| filter_tools(tools, &tool_filter)); + let cached_server_info = load_startup_cached_codex_apps_server_info( + &server_name, + codex_apps_tools_cache_context.as_ref(), + ); let startup_tool_filter = tool_filter; let startup_complete = Arc::new(AtomicBool::new(false)); let startup_complete_for_fut = Arc::clone(&startup_complete); @@ -170,7 +179,7 @@ impl AsyncManagedClient { &server_name, server.clone(), store_mode, - runtime_environment, + runtime_context, runtime_auth_provider, ) .await?, @@ -207,7 +216,7 @@ impl AsyncManagedClient { outcome }; let client = fut.boxed().shared(); - if startup_snapshot.is_some() { + if cached_tool_info_snapshot.is_some() { let startup_task = client.clone(); tokio::spawn(async move { let _ = startup_task.await; @@ -216,7 +225,8 @@ impl AsyncManagedClient { Self { client, - startup_snapshot, + cached_tool_info_snapshot, + cached_server_info, startup_complete, tool_plugin_provenance, cancel_token, @@ -238,9 +248,9 @@ impl AsyncManagedClient { } } - fn startup_snapshot_while_initializing(&self) -> Option> { + fn cached_tool_info_snapshot_while_initializing(&self) -> Option> { if !self.startup_complete.load(Ordering::Acquire) { - return self.startup_snapshot.clone(); + return self.cached_tool_info_snapshot.clone(); } None } @@ -298,12 +308,13 @@ impl AsyncManagedClient { }; // Keep cache payloads raw; plugin provenance is resolved per-session at read time. - let tools = if let Some(startup_tools) = self.startup_snapshot_while_initializing() { + let tools = if let Some(startup_tools) = self.cached_tool_info_snapshot_while_initializing() + { Some(startup_tools) } else { match self.client().await { Ok(client) => Some(client.listed_tools()), - Err(_) => self.startup_snapshot.clone(), + Err(_) => self.cached_tool_info_snapshot.clone(), } }; tools.map(annotate_tools) @@ -470,26 +481,13 @@ async fn start_server_task( codex_apps_tools_cache_context, client_elicitation_capability, } = params; - let params = InitializeRequestParams { - meta: None, - capabilities: ClientCapabilities { - experimental: None, - extensions: None, - roots: None, - sampling: None, - elicitation: Some(client_elicitation_capability), - tasks: None, - }, - client_info: Implementation { - name: "codex-mcp-client".to_owned(), - version: env!("CARGO_PKG_VERSION").to_owned(), - title: Some("Codex".into()), - description: None, - icons: None, - website_url: None, - }, - protocol_version: ProtocolVersion::V_2025_06_18, - }; + let mut capabilities = ClientCapabilities::default(); + capabilities.elicitation = Some(client_elicitation_capability); + let params = InitializeRequestParams::new( + capabilities, + Implementation::new("codex-mcp-client", env!("CARGO_PKG_VERSION")).with_title("Codex"), + ) + .with_protocol_version(ProtocolVersion::V_2025_06_18); let send_elicitation = elicitation_requests.make_sender(server_name.clone(), tx_event); @@ -519,9 +517,11 @@ async fn start_server_task( fetch_start.elapsed(), &[], ); + let server_info = mcp_server_info_from_implementation(initialize_result.server_info); write_cached_codex_apps_tools_if_needed( &server_name, codex_apps_tools_cache_context.as_ref(), + &server_info, &tools, ); if server_name == CODEX_APPS_MCP_SERVER_NAME { @@ -535,6 +535,7 @@ async fn start_server_task( let managed = ManagedClient { client: Arc::clone(&client), + server_info, tools, tool_timeout: Some(tool_timeout), tool_filter, @@ -546,6 +547,22 @@ async fn start_server_task( Ok(managed) } +fn mcp_server_info_from_implementation(server_info: Implementation) -> McpServerInfo { + McpServerInfo { + name: server_info.name, + title: server_info.title, + version: server_info.version, + description: server_info.description, + icons: server_info.icons.map(|icons| { + icons + .into_iter() + .filter_map(|icon| serde_json::to_value(icon).ok()) + .collect() + }), + website_url: server_info.website_url, + } +} + struct StartServerTaskParams { startup_timeout: Option, // TODO: cancel_token should handle this. tool_timeout: Duration, @@ -560,33 +577,17 @@ async fn make_rmcp_client( server_name: &str, server: EffectiveMcpServer, store_mode: OAuthCredentialsStoreMode, - runtime_environment: McpRuntimeEnvironment, + runtime_context: McpRuntimeContext, runtime_auth_provider: Option, ) -> Result { let config = match server.launch() { McpServerLaunch::Configured(config) => config.as_ref().clone(), }; - let McpServerConfig { - transport, - experimental_environment, - .. - } = config; - let remote_environment = match experimental_environment.as_deref() { - None | Some("local") => false, - Some("remote") => { - if !runtime_environment.environment().is_remote() { - return Err(StartupOutcomeError::from(anyhow!( - "remote MCP server `{server_name}` requires a remote environment" - ))); - } - true - } - Some(environment) => { - return Err(StartupOutcomeError::from(anyhow!( - "unsupported experimental_environment `{environment}` for MCP server `{server_name}`" - ))); - } - }; + let resolved_environment = runtime_context + .resolve_server_environment(server_name, &config) + .map_err(|err| StartupOutcomeError::from(anyhow!(err)))?; + let is_local_environment = config.is_local_environment(); + let McpServerConfig { transport, .. } = config; match transport { McpServerTransportConfig::Stdio { @@ -603,20 +604,24 @@ async fn make_rmcp_client( .map(|(key, value)| (key.into(), value.into())) .collect::>() }); - let launcher = if remote_environment { - Arc::new(ExecutorStdioServerLauncher::new( - runtime_environment.environment().get_exec_backend(), - runtime_environment.fallback_cwd(), - )) - } else { + let launcher = if is_local_environment { + // TODO(starr): Unify local stdio MCP launch with + // `ExecutorStdioServerLauncher` once the executor-backed path + // preserves `LocalStdioServerLauncher` semantics. Arc::new(LocalStdioServerLauncher::new( - runtime_environment.fallback_cwd(), + runtime_context.local_stdio_fallback_cwd(), + )) as Arc + } else { + let Some(environment) = resolved_environment.as_ref() else { + unreachable!( + "non-local stdio MCP servers resolve an environment before launch" + ); + }; + Arc::new(ExecutorStdioServerLauncher::new( + environment.get_exec_backend(), )) as Arc }; - // `RmcpClient` always sees a launched MCP stdio server. The - // launcher hides whether that means a local child process or an - // executor process whose stdin/stdout bytes cross the process API. RmcpClient::new_stdio_client(command_os, args_os, env_os, &env_vars, cwd, launcher) .await .map_err(|err| StartupOutcomeError::from(anyhow!(err))) @@ -627,11 +632,10 @@ async fn make_rmcp_client( env_http_headers, bearer_token_env_var, } => { - let http_client: Arc = if remote_environment { - runtime_environment.environment().get_http_client() - } else { - Arc::new(ReqwestHttpClient) - }; + let http_client = resolved_environment.as_ref().map_or_else( + || Arc::new(ReqwestHttpClient) as Arc, + |environment| environment.get_http_client(), + ); let resolved_bearer_token = match resolve_bearer_token(server_name, bearer_token_env_var.as_deref()) { Ok(token) => token, @@ -660,32 +664,27 @@ mod tests { use rmcp::model::Meta; fn tool_with_connector_meta() -> RmcpTool { - RmcpTool { - name: "capture_file_upload".to_string().into(), - title: None, - description: Some("test tool".to_string().into()), - input_schema: Arc::new(JsonObject::default()), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: Some(Meta( - serde_json::json!({ - "connector_id": "connector_gmail", - "connector_name": "Gmail", - "connector_display_name": "Gmail", - "connector_description": "Mail connector", - "connectorDescription": "Mail connector", - "connectorFutureField": "future connector metadata", - "CONNECTOR_UPPERCASE": "uppercase connector metadata", - "openai/fileParams": ["file"], - "custom": "kept" - }) - .as_object() - .expect("object") - .clone(), - )), - } + RmcpTool::new( + "capture_file_upload", + "test tool", + Arc::new(JsonObject::default()), + ) + .with_meta(Meta( + serde_json::json!({ + "connector_id": "connector_gmail", + "connector_name": "Gmail", + "connector_display_name": "Gmail", + "connector_description": "Mail connector", + "connectorDescription": "Mail connector", + "connectorFutureField": "future connector metadata", + "CONNECTOR_UPPERCASE": "uppercase connector metadata", + "openai/fileParams": ["file"], + "custom": "kept" + }) + .as_object() + .expect("object") + .clone(), + )) } #[test] diff --git a/codex-rs/codex-mcp/src/runtime.rs b/codex-rs/codex-mcp/src/runtime.rs index 4284c96ff6..5404bf1eb5 100644 --- a/codex-rs/codex-mcp/src/runtime.rs +++ b/codex-rs/codex-mcp/src/runtime.rs @@ -10,6 +10,7 @@ use std::sync::Arc; use std::time::Duration; use codex_exec_server::Environment; +use codex_exec_server::EnvironmentManager; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::SandboxPolicy; @@ -28,39 +29,268 @@ pub struct SandboxState { pub use_legacy_landlock: bool, } -/// Runtime placement information used when starting MCP server transports. +/// Runtime context used when resolving per-server MCP environments. /// -/// `McpConfig` describes what servers exist. This value describes where those -/// servers should run for the current caller. Keep it explicit at manager -/// construction time so status/snapshot paths and real sessions make the same -/// local-vs-remote decision. `fallback_cwd` is not a per-server override; it is -/// used when a stdio server omits `cwd` and the launcher needs a concrete -/// process working directory. +/// `McpConfig` describes what servers exist. This value carries the canonical +/// environment registry plus the local stdio fallback cwd used when a local +/// stdio server omits its own working directory. #[derive(Clone)] -pub struct McpRuntimeEnvironment { - environment: Arc, - fallback_cwd: PathBuf, +pub struct McpRuntimeContext { + environment_manager: Arc, + local_stdio_fallback_cwd: PathBuf, } -impl McpRuntimeEnvironment { - pub fn new(environment: Arc, fallback_cwd: PathBuf) -> Self { +impl McpRuntimeContext { + pub fn new( + environment_manager: Arc, + local_stdio_fallback_cwd: PathBuf, + ) -> Self { Self { - environment, - fallback_cwd, + environment_manager, + local_stdio_fallback_cwd, } } - pub(crate) fn environment(&self) -> Arc { - Arc::clone(&self.environment) + pub(crate) fn local_stdio_fallback_cwd(&self) -> PathBuf { + self.local_stdio_fallback_cwd.clone() } - pub(crate) fn fallback_cwd(&self) -> PathBuf { - self.fallback_cwd.clone() + pub(crate) fn resolve_server_environment( + &self, + server_name: &str, + config: &codex_config::McpServerConfig, + ) -> Result>, String> { + // Resolve `"local"` through the shared registry when available. Local + // HTTP is the one current exception: it can use the ambient HTTP client + // even when no local Environment is configured. + if let Some(environment) = self + .environment_manager + .get_environment(&config.environment_id) + { + if !config.is_local_environment() { + ensure_remote_stdio_cwd(server_name, config)?; + } + return Ok(Some(environment)); + } + + if config.is_local_environment() { + return match config.transport { + codex_config::McpServerTransportConfig::Stdio { .. } => Err(format!( + "local stdio MCP server `{server_name}` requires a local environment" + )), + codex_config::McpServerTransportConfig::StreamableHttp { .. } => Ok(None), + }; + } + + Err(format!( + "MCP server `{server_name}` references unknown environment id `{}`", + config.environment_id + )) } } +fn ensure_remote_stdio_cwd( + server_name: &str, + config: &codex_config::McpServerConfig, +) -> Result<(), String> { + let codex_config::McpServerTransportConfig::Stdio { cwd, .. } = &config.transport else { + return Ok(()); + }; + let Some(cwd) = cwd else { + return Err(format!( + "remote stdio MCP server `{server_name}` requires an absolute cwd" + )); + }; + if cwd.is_absolute() { + return Ok(()); + } + Err(format!( + "remote stdio MCP server `{server_name}` requires an absolute cwd, got `{}`", + cwd.display() + )) +} + pub(crate) fn emit_duration(metric: &str, duration: Duration, tags: &[(&str, &str)]) { if let Some(metrics) = codex_otel::global() { let _ = metrics.record_duration(metric, duration, tags); } } + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID; + use codex_config::McpServerConfig; + use codex_config::McpServerTransportConfig; + use codex_exec_server::EnvironmentManager; + use pretty_assertions::assert_eq; + + use super::*; + + fn stdio_server(environment_id: &str) -> McpServerConfig { + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }, + environment_id: environment_id.to_string(), + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: None, + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth: None, + oauth_resource: None, + tools: HashMap::new(), + } + } + + fn http_server(environment_id: &str) -> McpServerConfig { + McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "http://127.0.0.1:1".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + environment_id: environment_id.to_string(), + ..stdio_server(environment_id) + } + } + + #[test] + fn local_stdio_requires_local_stdio_availability() { + let runtime_context = McpRuntimeContext::new( + Arc::new(EnvironmentManager::without_environments()), + PathBuf::from("/tmp"), + ); + + let error = match runtime_context + .resolve_server_environment("stdio", &stdio_server(DEFAULT_MCP_SERVER_ENVIRONMENT_ID)) + { + Ok(_) => panic!("local stdio MCP should require a local environment"), + Err(error) => error, + }; + assert_eq!( + error, + "local stdio MCP server `stdio` requires a local environment" + ); + } + + #[test] + fn local_http_does_not_require_local_stdio_availability() { + let runtime_context = McpRuntimeContext::new( + Arc::new(EnvironmentManager::without_environments()), + PathBuf::from("/tmp"), + ); + + let resolved_runtime = match runtime_context + .resolve_server_environment("http", &http_server(DEFAULT_MCP_SERVER_ENVIRONMENT_ID)) + { + Ok(resolved_runtime) => resolved_runtime, + Err(error) => panic!("local HTTP MCP should resolve: {error}"), + }; + assert!(resolved_runtime.is_none()); + } + + #[test] + fn unknown_explicit_environment_is_rejected() { + let runtime_context = McpRuntimeContext::new( + Arc::new(EnvironmentManager::without_environments()), + PathBuf::from("/tmp"), + ); + + let error = + match runtime_context.resolve_server_environment("stdio", &stdio_server("remote")) { + Ok(_) => panic!("unknown MCP environment should fail"), + Err(error) => error, + }; + assert_eq!( + error, + "MCP server `stdio` references unknown environment id `remote`" + ); + } + + #[tokio::test] + async fn explicit_remote_stdio_and_http_accept_named_environment() { + let runtime_context = McpRuntimeContext::new( + Arc::new( + EnvironmentManager::create_for_tests( + Some("ws://127.0.0.1:8765".to_string()), + /*local_runtime_paths*/ None, + ) + .await, + ), + PathBuf::from("/tmp"), + ); + + let mut remote_stdio = stdio_server("remote"); + let McpServerTransportConfig::Stdio { cwd, .. } = &mut remote_stdio.transport else { + unreachable!("stdio helper should build stdio transport"); + }; + *cwd = Some(std::env::temp_dir()); + for resolved_runtime in [ + runtime_context.resolve_server_environment("stdio", &remote_stdio), + runtime_context.resolve_server_environment("http", &http_server("remote")), + ] { + let resolved_runtime = match resolved_runtime { + Ok(resolved_runtime) => resolved_runtime, + Err(error) => panic!("remote MCP should resolve: {error}"), + }; + assert!(resolved_runtime.is_some()); + } + } + + #[tokio::test] + async fn local_stdio_accepts_local_environment_when_available() { + let runtime_context = McpRuntimeContext::new( + Arc::new(EnvironmentManager::default_for_tests()), + PathBuf::from("/tmp"), + ); + + let resolved_runtime = match runtime_context + .resolve_server_environment("stdio", &stdio_server(DEFAULT_MCP_SERVER_ENVIRONMENT_ID)) + { + Ok(resolved_runtime) => resolved_runtime, + Err(error) => panic!("local stdio MCP should resolve: {error}"), + }; + assert!(resolved_runtime.is_some()); + } + + #[tokio::test] + async fn remote_stdio_requires_absolute_cwd() { + let runtime_context = McpRuntimeContext::new( + Arc::new( + EnvironmentManager::create_for_tests( + Some("ws://127.0.0.1:8765".to_string()), + /*local_runtime_paths*/ None, + ) + .await, + ), + PathBuf::from("/tmp"), + ); + let mut remote_stdio = stdio_server("remote"); + let McpServerTransportConfig::Stdio { cwd, .. } = &mut remote_stdio.transport else { + unreachable!("stdio helper should build stdio transport"); + }; + *cwd = Some(PathBuf::from("relative")); + + let error = match runtime_context.resolve_server_environment("stdio", &remote_stdio) { + Ok(_) => panic!("remote stdio MCP should require absolute cwd"), + Err(error) => error, + }; + assert_eq!( + error, + "remote stdio MCP server `stdio` requires an absolute cwd, got `relative`" + ); + } +} diff --git a/codex-rs/codex-mcp/src/tools.rs b/codex-rs/codex-mcp/src/tools.rs index 5b2fba2ef8..83cbfd5e74 100644 --- a/codex-rs/codex-mcp/src/tools.rs +++ b/codex-rs/codex-mcp/src/tools.rs @@ -25,6 +25,8 @@ use crate::mcp::sanitize_responses_api_tool_name; pub(crate) const MCP_TOOLS_CACHE_WRITE_DURATION_METRIC: &str = "codex.mcp.tools.cache_write.duration_ms"; +const LEGACY_MCP_TOOL_NAME_PREFIX: &str = "mcp__"; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ToolInfo { /// Raw MCP server name used for routing the tool call. @@ -141,7 +143,13 @@ pub(crate) fn filter_tools(tools: Vec, filter: &ToolFilter) -> Vec(tools: I) -> Vec +/// +/// When `prefix_mcp_tool_names` is true, the historical `mcp__` namespace +/// prefix is added without restoring the old trailing `__` namespace suffix. +pub(crate) fn normalize_tools_for_model_with_prefix( + tools: I, + prefix_mcp_tool_names: bool, +) -> Vec where I: IntoIterator, { @@ -163,8 +171,13 @@ where continue; } + let callable_namespace = callable_namespace_with_prefix( + &sanitize_responses_api_tool_name(&tool.callable_namespace), + prefix_mcp_tool_names, + ); + candidates.push(CallableToolCandidate { - callable_namespace: sanitize_responses_api_tool_name(&tool.callable_namespace), + callable_namespace, callable_name: sanitize_responses_api_tool_name(&tool.callable_name), raw_namespace_identity, raw_tool_identity, @@ -226,6 +239,7 @@ where &candidate.callable_name, &candidate.raw_tool_identity, &mut used_names, + MCP_TOOL_NAME_DELIMITER.len(), ); candidate.tool.callable_namespace = callable_namespace; candidate.tool.callable_name = callable_name; @@ -248,6 +262,14 @@ const MAX_TOOL_NAME_LENGTH: usize = 64; const CALLABLE_NAME_HASH_LEN: usize = 12; const META_OPENAI_FILE_PARAMS: &str = "openai/fileParams"; +fn callable_namespace_with_prefix(namespace: &str, prefix_mcp_tool_names: bool) -> String { + if !prefix_mcp_tool_names || namespace.starts_with(LEGACY_MCP_TOOL_NAME_PREFIX) { + namespace.to_string() + } else { + format!("{LEGACY_MCP_TOOL_NAME_PREFIX}{namespace}") + } +} + fn mask_input_schema_for_file_path_params(input_schema: &mut JsonValue, file_params: &[String]) { let Some(properties) = input_schema .as_object_mut() @@ -331,9 +353,10 @@ fn fit_callable_parts_with_hash( namespace: &str, tool_name: &str, raw_identity: &str, + reserved_len: usize, ) -> (String, String) { let suffix = callable_name_hash_suffix(raw_identity); - let max_tool_len = MAX_TOOL_NAME_LENGTH.saturating_sub(namespace.len()); + let max_tool_len = MAX_TOOL_NAME_LENGTH.saturating_sub(namespace.len() + reserved_len); if max_tool_len >= suffix.len() { let prefix_len = max_tool_len - suffix.len(); return ( @@ -342,7 +365,7 @@ fn fit_callable_parts_with_hash( ); } - let max_namespace_len = MAX_TOOL_NAME_LENGTH - suffix.len(); + let max_namespace_len = MAX_TOOL_NAME_LENGTH.saturating_sub(suffix.len() + reserved_len); (truncate_name(namespace, max_namespace_len), suffix) } @@ -351,9 +374,10 @@ fn unique_callable_parts( tool_name: &str, raw_identity: &str, used_names: &mut HashSet, + reserved_len: usize, ) -> (String, String) { let model_name = format!("{namespace}{tool_name}"); - if model_name.len() <= MAX_TOOL_NAME_LENGTH && used_names.insert(model_name) { + if model_name.len() + reserved_len <= MAX_TOOL_NAME_LENGTH && used_names.insert(model_name) { return (namespace.to_string(), tool_name.to_string()); } @@ -365,7 +389,7 @@ fn unique_callable_parts( format!("{raw_identity}\0{attempt}") }; let (namespace, tool_name) = - fit_callable_parts_with_hash(namespace, tool_name, &hash_input); + fit_callable_parts_with_hash(namespace, tool_name, &hash_input, reserved_len); let model_name = format!("{namespace}{tool_name}"); if used_names.insert(model_name) { return (namespace, tool_name); diff --git a/codex-rs/config/Cargo.toml b/codex-rs/config/Cargo.toml index c345f31c6e..f77a76b764 100644 --- a/codex-rs/config/Cargo.toml +++ b/codex-rs/config/Cargo.toml @@ -28,6 +28,7 @@ codex-utils-path = { workspace = true } dunce = { workspace = true } futures = { workspace = true, features = ["alloc", "std"] } gethostname = { workspace = true } +indexmap = { workspace = true, features = ["serde"] } multimap = { workspace = true } prost = "0.14.3" schemars = { workspace = true } @@ -38,7 +39,7 @@ serde_path_to_error = { workspace = true } sha2 = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["fs"] } -toml = { workspace = true } +toml = { workspace = true, features = ["preserve_order"] } toml_edit = { workspace = true } tonic = { workspace = true } tonic-prost = { workspace = true } diff --git a/codex-rs/config/src/cloud_config_layers.rs b/codex-rs/config/src/cloud_config_layers.rs new file mode 100644 index 0000000000..bdb11631e4 --- /dev/null +++ b/codex-rs/config/src/cloud_config_layers.rs @@ -0,0 +1,108 @@ +//! Conversion from cloud-delivered config TOML fragments into config stack layers. +//! +//! Backend fragments arrive in backend priority order. This module parses each +//! fragment, resolves relative path fields against the cloud config base +//! directory, and returns layers in `ConfigLayerStack` order. + +use crate::ConfigLayerEntry; +use crate::ConfigLayerSource; +use crate::TomlValue; +use crate::loader::resolve_relative_paths_in_config_toml; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::fmt; +use std::io; +use thiserror::Error; + +/// Config fragment delivered by the cloud config bundle. +/// +/// The bundle orders fragments from highest precedence to lowest precedence. +/// This module returns config layers in stack order, so callers can append the +/// result between system and user config without re-sorting. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CloudConfigFragment { + pub id: String, + pub name: String, + pub contents: String, +} + +impl CloudConfigFragment { + fn source_ref(&self) -> CloudConfigFragmentSource { + CloudConfigFragmentSource { + id: self.id.clone(), + name: self.name.clone(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CloudConfigFragmentSource { + pub id: String, + pub name: String, +} + +impl fmt::Display for CloudConfigFragmentSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} ({})", self.name, self.id) + } +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum CloudConfigLayerError { + #[error("failed to parse cloud config fragment {fragment}: {message}")] + Parse { + fragment: CloudConfigFragmentSource, + message: String, + }, + #[error("invalid cloud config fragment {fragment}: {message}")] + Invalid { + fragment: CloudConfigFragmentSource, + message: String, + }, +} + +pub fn cloud_config_layers_from_fragments( + fragments: impl IntoIterator, + base_dir: &AbsolutePathBuf, +) -> Result, CloudConfigLayerError> { + let mut layers = Vec::new(); + for fragment in fragments { + let source_ref = fragment.source_ref(); + let raw_toml = fragment.contents; + let value: TomlValue = + toml::from_str(&raw_toml).map_err(|err| CloudConfigLayerError::Parse { + fragment: source_ref.clone(), + message: err.to_string(), + })?; + let resolved = + resolve_relative_paths_in_config_toml(value, base_dir.as_path()).map_err(|err| { + CloudConfigLayerError::Invalid { + fragment: source_ref.clone(), + message: err.to_string(), + } + })?; + layers.push(ConfigLayerEntry::new_with_raw_toml( + ConfigLayerSource::EnterpriseManaged { + id: fragment.id, + name: fragment.name, + }, + resolved, + raw_toml, + base_dir.clone(), + )); + } + + // Bundle fragments arrive highest-priority first, while ConfigLayerStack + // folds lowest-priority to highest-priority. + layers.reverse(); + Ok(layers) +} + +impl From for io::Error { + fn from(error: CloudConfigLayerError) -> Self { + io::Error::new(io::ErrorKind::InvalidData, error) + } +} + +#[cfg(test)] +#[path = "cloud_config_layers_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/cloud_config_layers_tests.rs b/codex-rs/config/src/cloud_config_layers_tests.rs new file mode 100644 index 0000000000..c7f685fa67 --- /dev/null +++ b/codex-rs/config/src/cloud_config_layers_tests.rs @@ -0,0 +1,210 @@ +use super::*; +use crate::CONFIG_TOML_FILE; +use crate::ConfigLayerStack; +use crate::ConfigLayerStackOrdering; +use crate::ConfigRequirements; +use crate::ConfigRequirementsToml; +use crate::config_toml::ConfigToml; +use crate::first_layer_config_error_from_entries; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::test_support::PathBufExt; +use codex_utils_absolute_path::test_support::test_path_buf; +use pretty_assertions::assert_eq; +use std::path::Path; + +fn fragment(id: &str, name: &str, contents: &str) -> CloudConfigFragment { + CloudConfigFragment { + id: id.to_string(), + name: name.to_string(), + contents: contents.to_string(), + } +} + +fn toml(contents: &str) -> TomlValue { + toml::from_str(contents).expect("test TOML should parse") +} + +fn base_dir() -> AbsolutePathBuf { + test_path_buf("/var/lib/codex").abs() +} + +#[test] +fn layers_are_returned_in_stack_order() { + let base_dir = base_dir(); + let layers = cloud_config_layers_from_fragments( + vec![ + fragment("high", "High priority", "model = \"cloud-high\""), + fragment("low", "Low priority", "model_provider = \"cloud-low\""), + ], + &base_dir, + ) + .expect("cloud config layers should compose"); + + assert_eq!( + layers + .iter() + .map(|layer| layer.name.clone()) + .collect::>(), + vec![ + ConfigLayerSource::EnterpriseManaged { + id: "low".to_string(), + name: "Low priority".to_string(), + }, + ConfigLayerSource::EnterpriseManaged { + id: "high".to_string(), + name: "High priority".to_string(), + }, + ] + ); +} + +#[test] +fn enterprise_layers_precede_user_and_override_system() { + let base_dir = base_dir(); + let mut layers = vec![ConfigLayerEntry::new( + ConfigLayerSource::System { + file: test_path_buf("/etc/codex/config.toml").abs(), + }, + toml( + r#" +model = "system" +model_provider = "system" +review_model = "system-review" +"#, + ), + )]; + layers.extend( + cloud_config_layers_from_fragments( + vec![ + fragment("high", "High priority", "model_provider = \"cloud-high\""), + fragment("low", "Low priority", "review_model = \"cloud-low-review\""), + ], + &base_dir, + ) + .expect("cloud config layers should compose"), + ); + layers.push(ConfigLayerEntry::new( + ConfigLayerSource::User { + file: test_path_buf("/home/alice/.codex/config.toml").abs(), + profile: None, + }, + toml("model = \"user\""), + )); + + let stack = ConfigLayerStack::new( + layers, + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("stack should be ordered"); + + assert_eq!( + stack + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ false + ) + .iter() + .map(|layer| layer.name.clone()) + .collect::>(), + vec![ + ConfigLayerSource::System { + file: test_path_buf("/etc/codex/config.toml").abs(), + }, + ConfigLayerSource::EnterpriseManaged { + id: "low".to_string(), + name: "Low priority".to_string(), + }, + ConfigLayerSource::EnterpriseManaged { + id: "high".to_string(), + name: "High priority".to_string(), + }, + ConfigLayerSource::User { + file: test_path_buf("/home/alice/.codex/config.toml").abs(), + profile: None, + }, + ] + ); + assert_eq!( + stack.effective_config(), + toml( + r#" +model = "user" +model_provider = "cloud-high" +review_model = "cloud-low-review" +"#, + ) + ); +} + +#[test] +fn relative_absolute_path_fields_resolve_against_base_dir() { + let base_dir = base_dir(); + let layers = cloud_config_layers_from_fragments( + vec![fragment( + "cfg_123", + "Base policy", + "model_instructions_file = \"instructions.md\"", + )], + &base_dir, + ) + .expect("relative paths should match existing MDM semantics"); + + let path = layers[0] + .config + .get("model_instructions_file") + .and_then(TomlValue::as_str) + .expect("path should be present"); + let expected = + AbsolutePathBuf::resolve_path_against_base("instructions.md", base_dir.as_path()); + assert_eq!(path, expected.to_string_lossy()); +} + +#[test] +fn home_relative_path_fields_are_allowed_and_resolved() { + let base_dir = base_dir(); + let layers = cloud_config_layers_from_fragments( + vec![fragment( + "cfg_123", + "Base policy", + "model_instructions_file = \"~/instructions.md\"", + )], + &base_dir, + ) + .expect("home-relative paths should be accepted"); + + let path = layers[0] + .config + .get("model_instructions_file") + .and_then(TomlValue::as_str) + .expect("path should be present"); + let expected = + AbsolutePathBuf::resolve_path_against_base("~/instructions.md", base_dir.as_path()); + assert_eq!(path, expected.to_string_lossy()); +} + +#[tokio::test] +async fn raw_toml_diagnostics_use_enterprise_layer_name() { + let base_dir = base_dir(); + let layers = cloud_config_layers_from_fragments( + vec![fragment( + "cfg_123", + "Base policy", + "model_instructions_file = \"instructions.md\"\nmodel = 1", + )], + &base_dir, + ) + .expect("cloud config layers should parse"); + + let error = first_layer_config_error_from_entries::(&layers, CONFIG_TOML_FILE) + .await + .expect("invalid raw TOML should produce a layer diagnostic"); + + assert_eq!( + error.path, + Path::new("enterprise-managed (Base policy, cfg_123)").to_path_buf() + ); + assert_eq!(error.range.start.line, 2); + assert_eq!(error.range.start.column, 9); + assert!(error.message.contains("invalid type: integer `1`")); +} diff --git a/codex-rs/config/src/config_layer_source.rs b/codex-rs/config/src/config_layer_source.rs new file mode 100644 index 0000000000..7bff779b19 --- /dev/null +++ b/codex-rs/config/src/config_layer_source.rs @@ -0,0 +1,31 @@ +use codex_app_server_protocol::ConfigLayerSource; + +pub fn format_config_layer_source(source: &ConfigLayerSource, config_toml_file: &str) -> String { + match source { + ConfigLayerSource::Mdm { domain, key } => { + format!("MDM ({domain}:{key})") + } + ConfigLayerSource::System { file } => { + format!("system ({})", file.as_path().display()) + } + ConfigLayerSource::EnterpriseManaged { id, name } => { + format!("enterprise-managed ({name}, {id})") + } + ConfigLayerSource::User { file, .. } => { + format!("user ({})", file.as_path().display()) + } + ConfigLayerSource::Project { dot_codex_folder } => { + format!( + "project ({}/{config_toml_file})", + dot_codex_folder.as_path().display() + ) + } + ConfigLayerSource::SessionFlags => "session-flags".to_string(), + ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => { + format!("legacy managed_config.toml ({})", file.as_path().display()) + } + ConfigLayerSource::LegacyManagedConfigTomlFromMdm => { + "legacy managed_config.toml (MDM)".to_string() + } + } +} diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 9faa846137..86d64e4153 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -19,17 +19,67 @@ use crate::Constrained; use crate::ConstraintError; use crate::ManagedHooksRequirementsToml; use crate::mcp_types::AppToolApproval; +use crate::permissions_toml::PermissionProfileToml; +use crate::types::WindowsSandboxModeToml; #[derive(Debug, Clone, PartialEq, Eq)] pub enum RequirementSource { Unknown, - MdmManagedPreferences { domain: String, key: String }, + MdmManagedPreferences { + domain: String, + key: String, + }, CloudRequirements, - SystemRequirementsToml { file: AbsolutePathBuf }, - LegacyManagedConfigTomlFromFile { file: AbsolutePathBuf }, + /// Multiple requirements layers contributed to the final value. Sources are + /// stored highest-priority first, matching the order surfaced in errors. + Composite { + sources: Vec, + }, + /// A backend-delivered enterprise-managed layer. `id` is the stable backend + /// identifier; `name` is the admin-facing display name. + EnterpriseManaged { + id: String, + name: String, + }, + SystemRequirementsToml { + file: AbsolutePathBuf, + }, + LegacyManagedConfigTomlFromFile { + file: AbsolutePathBuf, + }, LegacyManagedConfigTomlFromMdm, } +impl RequirementSource { + pub fn composite(sources: impl IntoIterator) -> Self { + let mut flattened = Vec::new(); + for source in sources { + source.append_to_composite(&mut flattened); + } + + match flattened.len() { + 0 => RequirementSource::Unknown, + 1 => flattened.remove(0), + _ => RequirementSource::Composite { sources: flattened }, + } + } + + fn append_to_composite(self, flattened: &mut Vec) { + match self { + RequirementSource::Composite { sources } => { + for source in sources { + source.append_to_composite(flattened); + } + } + source => { + if !flattened.contains(&source) { + flattened.push(source); + } + } + } + } +} + impl fmt::Display for RequirementSource { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -40,6 +90,19 @@ impl fmt::Display for RequirementSource { RequirementSource::CloudRequirements => { write!(f, "cloud requirements") } + RequirementSource::Composite { sources } => { + write!(f, "requirements layers: ")?; + for (index, source) in sources.iter().enumerate() { + if index > 0 { + write!(f, ", ")?; + } + write!(f, "{source}")?; + } + Ok(()) + } + RequirementSource::EnterpriseManaged { id, name } => { + write!(f, "enterprise-managed requirements {name} ({id})") + } RequirementSource::SystemRequirementsToml { file } => { write!(f, "{}", file.as_path().display()) } @@ -86,8 +149,11 @@ pub struct ConfigRequirements { pub approval_policy: ConstrainedWithSource, pub approvals_reviewer: ConstrainedWithSource, pub permission_profile: ConstrainedWithSource, + pub windows_sandbox_mode: ConstrainedWithSource>, pub web_search_mode: ConstrainedWithSource, pub allow_managed_hooks_only: Option>, + pub allow_appshots: Option>, + pub computer_use: Option>, pub feature_requirements: Option>, pub managed_hooks: Option>, pub mcp_servers: Option>>, @@ -117,11 +183,17 @@ impl Default for ConfigRequirements { Constrained::allow_any(PermissionProfile::read_only()), /*source*/ None, ), + windows_sandbox_mode: ConstrainedWithSource::new( + Constrained::allow_any(/*initial_value*/ None), + /*source*/ None, + ), web_search_mode: ConstrainedWithSource::new( Constrained::allow_any(WebSearchMode::Cached), /*source*/ None, ), allow_managed_hooks_only: None, + allow_appshots: None, + computer_use: None, feature_requirements: None, managed_hooks: None, mcp_servers: None, @@ -240,14 +312,14 @@ impl NetworkUnixSocketPermissionsToml { #[serde(rename_all = "lowercase")] pub enum NetworkUnixSocketPermissionToml { Allow, - None, + Deny, } impl std::fmt::Display for NetworkUnixSocketPermissionToml { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let permission = match self { Self::Allow => "allow", - Self::None => "none", + Self::Deny => "deny", }; f.write_str(permission) } @@ -430,14 +502,58 @@ impl From for NetworkConstraints { } } -#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct FilesystemRequirementsToml { pub deny_read: Option>, } +#[derive(Deserialize)] +struct RawFilesystemRequirementsToml { + deny_read: Option>, + description: Option, + extends: Option, + workspace_roots: Option, + filesystem: Option, + network: Option, +} + +impl<'de> Deserialize<'de> for FilesystemRequirementsToml { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = RawFilesystemRequirementsToml::deserialize(deserializer)?; + let RawFilesystemRequirementsToml { + deny_read, + description, + extends, + workspace_roots, + filesystem, + network, + } = raw; + + if description.is_some() + || extends.is_some() + || workspace_roots.is_some() + || filesystem.is_some() + || network.is_some() + { + return Err(D::Error::custom( + "`permissions.filesystem` is reserved for requirements-level filesystem constraints and cannot define a profile", + )); + } + + Ok(Self { deny_read }) + } +} + #[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] pub struct PermissionsRequirementsToml { pub filesystem: Option, + // For legacy reasons, `filesystem` stays reserved for requirements-level + // filesystem constraints and cannot name a profile. + #[serde(default, flatten)] + pub profiles: BTreeMap, } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -588,6 +704,28 @@ impl fmt::Display for WebSearchModeRequirement { } } +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct ComputerUseRequirementsToml { + pub allow_locked_computer_use: Option, +} + +impl ComputerUseRequirementsToml { + pub fn is_empty(&self) -> bool { + self.allow_locked_computer_use.is_none() + } +} + +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct WindowsRequirementsToml { + pub allowed_sandbox_implementations: Option>, +} + +impl WindowsRequirementsToml { + pub fn is_empty(&self) -> bool { + self.allowed_sandbox_implementations.is_none() + } +} + #[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] pub struct FeatureRequirementsToml { #[serde(flatten)] @@ -688,9 +826,13 @@ pub struct ConfigRequirementsToml { pub allowed_approval_policies: Option>, pub allowed_approvals_reviewers: Option>, pub allowed_sandbox_modes: Option>, + pub allowed_permissions: Option>, pub remote_sandbox_config: Option>, pub allowed_web_search_modes: Option>, pub allow_managed_hooks_only: Option, + pub allow_appshots: Option, + pub computer_use: Option, + pub windows: Option, #[serde(rename = "features", alias = "feature_requirements")] pub feature_requirements: Option, pub hooks: Option, @@ -738,8 +880,12 @@ pub struct ConfigRequirementsWithSources { pub allowed_approval_policies: Option>>, pub allowed_approvals_reviewers: Option>>, pub allowed_sandbox_modes: Option>>, + pub allowed_permissions: Option>>, pub allowed_web_search_modes: Option>>, pub allow_managed_hooks_only: Option>, + pub allow_appshots: Option>, + pub computer_use: Option>, + pub windows: Option>, pub feature_requirements: Option>, pub hooks: Option>, pub mcp_servers: Option>>, @@ -774,9 +920,13 @@ impl ConfigRequirementsWithSources { allowed_approval_policies: _, allowed_approvals_reviewers: _, allowed_sandbox_modes: _, + allowed_permissions: _, remote_sandbox_config: _, allowed_web_search_modes: _, allow_managed_hooks_only: _, + allow_appshots: _, + computer_use: _, + windows: _, feature_requirements: _, hooks: _, mcp_servers: _, @@ -805,8 +955,12 @@ impl ConfigRequirementsWithSources { allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, + allowed_permissions, allowed_web_search_modes, allow_managed_hooks_only, + allow_appshots, + computer_use, + windows, feature_requirements, hooks, mcp_servers, @@ -833,8 +987,12 @@ impl ConfigRequirementsWithSources { allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, + allowed_permissions, allowed_web_search_modes, allow_managed_hooks_only, + allow_appshots, + computer_use, + windows, feature_requirements, hooks, mcp_servers, @@ -850,9 +1008,13 @@ impl ConfigRequirementsWithSources { allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value), allowed_approvals_reviewers: allowed_approvals_reviewers.map(|sourced| sourced.value), allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value), + allowed_permissions: allowed_permissions.map(|sourced| sourced.value), remote_sandbox_config: None, allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value), allow_managed_hooks_only: allow_managed_hooks_only.map(|sourced| sourced.value), + allow_appshots: allow_appshots.map(|sourced| sourced.value), + computer_use: computer_use.map(|sourced| sourced.value), + windows: windows.map(|sourced| sourced.value), feature_requirements: feature_requirements.map(|sourced| sourced.value), hooks: hooks.map(|sourced| sourced.value), mcp_servers: mcp_servers.map(|sourced| sourced.value), @@ -882,7 +1044,7 @@ fn hostname_matches_any_pattern(hostname: &str, patterns: &[String]) -> bool { /// Currently, `external-sandbox` is not supported in config.toml, but it is /// supported through programmatic use. -#[derive(Deserialize, Debug, Clone, Copy, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq)] pub enum SandboxModeRequirement { #[serde(rename = "read-only")] ReadOnly, @@ -934,9 +1096,19 @@ impl ConfigRequirementsToml { self.allowed_approval_policies.is_none() && self.allowed_approvals_reviewers.is_none() && self.allowed_sandbox_modes.is_none() + && self.allowed_permissions.is_none() && self.remote_sandbox_config.is_none() && self.allowed_web_search_modes.is_none() && self.allow_managed_hooks_only.is_none() + && self.allow_appshots.is_none() + && self + .computer_use + .as_ref() + .is_none_or(ComputerUseRequirementsToml::is_empty) + && self + .windows + .as_ref() + .is_none_or(WindowsRequirementsToml::is_empty) && self .feature_requirements .as_ref() @@ -969,12 +1141,19 @@ impl TryFrom for ConfigRequirements { type Error = ConstraintError; fn try_from(toml: ConfigRequirementsWithSources) -> Result { + // Profile catalog selection remains on ConfigRequirementsToml for + // config loading and requirements API projection. The normalized + // constraints below only need the compiled PermissionProfile envelope. let ConfigRequirementsWithSources { allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, + allowed_permissions: _, allowed_web_search_modes, allow_managed_hooks_only, + allow_appshots, + computer_use, + windows, feature_requirements, hooks, mcp_servers, @@ -1084,6 +1263,44 @@ impl TryFrom for ConfigRequirements { /*source*/ None, ), }; + let windows_sandbox_mode = match windows { + Some(Sourced { + value: + WindowsRequirementsToml { + allowed_sandbox_implementations: Some(implementations), + }, + source: requirement_source, + }) => { + if implementations.is_empty() { + return Err(ConstraintError::empty_field( + "windows.allowed_sandbox_implementations", + )); + } + // Prefer elevated when both Windows sandbox implementations are allowed. + let initial_value = if implementations.contains(&WindowsSandboxModeToml::Elevated) { + WindowsSandboxModeToml::Elevated + } else { + WindowsSandboxModeToml::Unelevated + }; + + let requirement_source_for_error = requirement_source.clone(); + let constrained = + Constrained::new(Some(initial_value), move |candidate| match candidate { + Some(candidate) if implementations.contains(candidate) => Ok(()), + _ => Err(ConstraintError::InvalidValue { + field_name: "windows.sandbox", + candidate: format!("{candidate:?}"), + allowed: format!("{implementations:?}"), + requirement_source: requirement_source_for_error.clone(), + }), + })?; + ConstrainedWithSource::new(constrained, Some(requirement_source)) + } + Some(_) | None => ConstrainedWithSource::new( + Constrained::allow_any(/*initial_value*/ None), + /*source*/ None, + ), + }; let exec_policy = match rules { Some(Sourced { value, source }) => { let policy = value.to_requirements_policy().map_err(|err| { @@ -1209,8 +1426,11 @@ impl TryFrom for ConfigRequirements { approval_policy, approvals_reviewer, permission_profile, + windows_sandbox_mode, web_search_mode, allow_managed_hooks_only, + allow_appshots, + computer_use, feature_requirements, managed_hooks, mcp_servers, @@ -1255,8 +1475,7 @@ mod tests { use codex_execpolicy::Decision; use codex_execpolicy::Evaluation; use codex_execpolicy::RuleMatch; - use codex_protocol::protocol::NetworkAccess; - use codex_protocol::protocol::SandboxPolicy; + use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; use pretty_assertions::assert_eq; @@ -1272,8 +1491,39 @@ mod tests { )?) } - fn profile_from_sandbox_policy(sandbox_policy: &SandboxPolicy) -> PermissionProfile { - PermissionProfile::from_legacy_sandbox_policy(sandbox_policy) + #[test] + fn composite_requirement_source_flattens_and_deduplicates_sources() { + let mdm_source = RequirementSource::MdmManagedPreferences { + domain: "com.openai.codex".to_string(), + key: "requirements_toml_base64".to_string(), + }; + let legacy_source = RequirementSource::LegacyManagedConfigTomlFromMdm; + + assert_eq!( + RequirementSource::composite([ + mdm_source.clone(), + RequirementSource::composite([legacy_source.clone(), mdm_source.clone()]), + ]), + RequirementSource::Composite { + sources: vec![mdm_source, legacy_source], + } + ); + } + + #[test] + fn composite_requirement_source_display_lists_sources_in_priority_order() { + let source = RequirementSource::composite([ + RequirementSource::MdmManagedPreferences { + domain: "com.openai.codex".to_string(), + key: "requirements_toml_base64".to_string(), + }, + RequirementSource::LegacyManagedConfigTomlFromMdm, + ]); + + assert_eq!( + source.to_string(), + "requirements layers: MDM com.openai.codex:requirements_toml_base64, MDM managed_config.toml (legacy)" + ); } fn with_unknown_source(toml: ConfigRequirementsToml) -> ConfigRequirementsWithSources { @@ -1281,9 +1531,13 @@ mod tests { allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, + allowed_permissions, remote_sandbox_config: _, allowed_web_search_modes, allow_managed_hooks_only, + allow_appshots, + computer_use, + windows, feature_requirements, hooks, mcp_servers, @@ -1302,10 +1556,16 @@ mod tests { .map(|value| Sourced::new(value, RequirementSource::Unknown)), allowed_sandbox_modes: allowed_sandbox_modes .map(|value| Sourced::new(value, RequirementSource::Unknown)), + allowed_permissions: allowed_permissions + .map(|value| Sourced::new(value, RequirementSource::Unknown)), allowed_web_search_modes: allowed_web_search_modes .map(|value| Sourced::new(value, RequirementSource::Unknown)), allow_managed_hooks_only: allow_managed_hooks_only .map(|value| Sourced::new(value, RequirementSource::Unknown)), + allow_appshots: allow_appshots + .map(|value| Sourced::new(value, RequirementSource::Unknown)), + computer_use: computer_use.map(|value| Sourced::new(value, RequirementSource::Unknown)), + windows: windows.map(|value| Sourced::new(value, RequirementSource::Unknown)), feature_requirements: feature_requirements .map(|value| Sourced::new(value, RequirementSource::Unknown)), hooks: hooks.map(|value| Sourced::new(value, RequirementSource::Unknown)), @@ -1348,6 +1608,106 @@ mod tests { Ok(()) } + #[test] + fn deserialize_managed_permission_profiles() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" + allowed_permissions = ["managed-standard", "managed-build"] + + [permissions.managed-standard] + extends = ":workspace" + + [permissions.managed-build] + extends = "managed-standard" + "#, + )?; + + assert_eq!( + requirements.allowed_permissions, + Some(vec![ + "managed-standard".to_string(), + "managed-build".to_string(), + ]) + ); + let permissions = requirements + .permissions + .as_ref() + .expect("managed permission profiles"); + assert!(permissions.profiles.contains_key("managed-standard")); + assert!( + permissions + .profiles + .get("managed-build") + .and_then(|profile| profile.extends.as_deref()) + .is_some() + ); + assert!(!requirements.is_empty()); + Ok(()) + } + + #[test] + fn deserialize_allow_appshots() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" + allow_appshots = true + "#, + )?; + + assert_eq!(requirements.allow_appshots, Some(true)); + assert!(!requirements.is_empty()); + Ok(()) + } + + #[test] + fn filesystem_requirements_table_cannot_define_a_permission_profile() { + let err = from_str::( + r#" + [permissions.filesystem] + extends = ":workspace" + "#, + ) + .expect_err("filesystem requirements cannot define a permission profile"); + + assert!( + err.to_string().contains( + "`permissions.filesystem` is reserved for requirements-level filesystem constraints and cannot define a profile" + ), + "unexpected error: {err:#}" + ); + } + + #[test] + fn allow_appshots_false_is_still_configured() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" + allow_appshots = false + "#, + )?; + + assert_eq!(requirements.allow_appshots, Some(false)); + assert!(!requirements.is_empty()); + Ok(()) + } + + #[test] + fn deserialize_computer_use_requirements() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" + [computer_use] + allow_locked_computer_use = false + "#, + )?; + + assert_eq!( + requirements.computer_use, + Some(ComputerUseRequirementsToml { + allow_locked_computer_use: Some(false), + }) + ); + assert!(!requirements.is_empty()); + Ok(()) + } + #[test] fn merge_unset_fields_copies_every_field_and_sets_sources() { let mut target = ConfigRequirementsWithSources::default(); @@ -1367,6 +1727,9 @@ mod tests { let feature_requirements = FeatureRequirementsToml { entries: BTreeMap::from([("personality".to_string(), true)]), }; + let computer_use = ComputerUseRequirementsToml { + allow_locked_computer_use: Some(false), + }; let enforce_residency = ResidencyRequirement::Us; let enforce_source = source.clone(); let guardian_policy_config = "Use the company-managed guardian policy.".to_string(); @@ -1377,9 +1740,13 @@ mod tests { allowed_approval_policies: Some(allowed_approval_policies.clone()), allowed_approvals_reviewers: Some(allowed_approvals_reviewers.clone()), allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()), + allowed_permissions: Some(vec!["managed".to_string()]), remote_sandbox_config: None, allowed_web_search_modes: Some(allowed_web_search_modes.clone()), allow_managed_hooks_only: Some(true), + allow_appshots: Some(false), + computer_use: Some(computer_use.clone()), + windows: None, feature_requirements: Some(feature_requirements.clone()), hooks: None, mcp_servers: None, @@ -1406,6 +1773,10 @@ mod tests { source.clone(), )), allowed_sandbox_modes: Some(Sourced::new(allowed_sandbox_modes, source.clone(),)), + allowed_permissions: Some(Sourced::new( + vec!["managed".to_string()], + source.clone(), + )), allowed_web_search_modes: Some(Sourced::new( allowed_web_search_modes, enforce_source.clone(), @@ -1414,6 +1785,9 @@ mod tests { /*value*/ true, enforce_source.clone(), )), + allow_appshots: Some(Sourced::new(/*value*/ false, enforce_source.clone(),)), + computer_use: Some(Sourced::new(computer_use, enforce_source.clone())), + windows: None, feature_requirements: Some(Sourced::new( feature_requirements, enforce_source.clone(), @@ -1455,8 +1829,12 @@ mod tests { )), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, + computer_use: None, + windows: None, feature_requirements: None, hooks: None, mcp_servers: None, @@ -1503,8 +1881,12 @@ mod tests { )), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, + computer_use: None, + windows: None, feature_requirements: None, hooks: None, mcp_servers: None, @@ -1963,9 +2345,7 @@ allowed_approvals_reviewers = ["user"] assert_eq!( requirements .permission_profile - .can_set(&profile_from_sandbox_policy( - &SandboxPolicy::DangerFullAccess, - )), + .can_set(&PermissionProfile::Disabled), Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: "DangerFullAccess".into(), @@ -1989,14 +2369,20 @@ allowed_approvals_reviewers = ["user"] } #[test] - fn constraint_error_includes_cloud_requirements_source() -> Result<()> { + fn constraint_error_includes_composite_requirement_source() -> Result<()> { let source: ConfigRequirementsToml = from_str( r#" allowed_approval_policies = ["on-request"] "#, )?; - let source_location = RequirementSource::CloudRequirements; + let source_location = RequirementSource::composite([ + RequirementSource::MdmManagedPreferences { + domain: "com.openai.codex".to_string(), + key: "requirements_toml_base64".to_string(), + }, + RequirementSource::LegacyManagedConfigTomlFromMdm, + ]); let mut target = ConfigRequirementsWithSources::default(); target.merge_unset_fields(source_location.clone(), source); @@ -2110,9 +2496,7 @@ allowed_approvals_reviewers = ["user"] assert!( requirements .permission_profile - .can_set(&profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy() - )) + .can_set(&PermissionProfile::read_only()) .is_ok() ); @@ -2148,6 +2532,71 @@ allowed_approvals_reviewers = ["user"] Ok(()) } + #[test] + fn deserialize_allowed_windows_sandbox_implementations() -> Result<()> { + let toml_str = r#" + [windows] + allowed_sandbox_implementations = ["elevated"] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!( + requirements.windows_sandbox_mode.value(), + Some(WindowsSandboxModeToml::Elevated) + ); + assert!( + requirements + .windows_sandbox_mode + .can_set(&Some(WindowsSandboxModeToml::Elevated)) + .is_ok() + ); + assert!( + requirements + .windows_sandbox_mode + .can_set(&Some(WindowsSandboxModeToml::Unelevated)) + .is_err() + ); + assert!(requirements.windows_sandbox_mode.can_set(&None).is_err()); + + Ok(()) + } + + #[test] + fn empty_allowed_windows_sandbox_implementations_is_rejected() -> Result<()> { + let toml_str = r#" + [windows] + allowed_sandbox_implementations = [] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + + assert_eq!( + ConfigRequirements::try_from(with_unknown_source(config)), + Err(ConstraintError::EmptyField { + field_name: "windows.allowed_sandbox_implementations".to_string(), + }) + ); + + Ok(()) + } + + #[test] + fn allowed_windows_sandbox_implementations_prefer_elevated_fallback() -> Result<()> { + let toml_str = r#" + [windows] + allowed_sandbox_implementations = ["unelevated", "elevated"] + "#; + let config: ConfigRequirementsToml = from_str(toml_str)?; + let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; + + assert_eq!( + requirements.windows_sandbox_mode.value(), + Some(WindowsSandboxModeToml::Elevated) + ); + + Ok(()) + } + #[test] fn deserialize_legacy_allowed_approvals_reviewer() -> Result<()> { let toml_str = r#" @@ -2195,29 +2644,25 @@ allowed_approvals_reviewers = ["user"] assert!( requirements .permission_profile - .can_set(&profile_from_sandbox_policy( - &SandboxPolicy::new_read_only_policy() - )) + .can_set(&PermissionProfile::read_only()) .is_ok() ); - let workspace_write_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::from_absolute_path(root)?], - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }; + let workspace_write_profile = PermissionProfile::workspace_write_with( + &[AbsolutePathBuf::from_absolute_path(root)?], + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ); assert!( requirements .permission_profile - .can_set(&profile_from_sandbox_policy(&workspace_write_policy)) + .can_set(&workspace_write_profile) .is_ok() ); assert_eq!( requirements .permission_profile - .can_set(&profile_from_sandbox_policy( - &SandboxPolicy::DangerFullAccess, - )), + .can_set(&PermissionProfile::Disabled), Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: "DangerFullAccess".into(), @@ -2228,11 +2673,9 @@ allowed_approvals_reviewers = ["user"] assert_eq!( requirements .permission_profile - .can_set(&profile_from_sandbox_policy( - &SandboxPolicy::ExternalSandbox { - network_access: NetworkAccess::Restricted, - } - )), + .can_set(&PermissionProfile::External { + network: NetworkSandboxPolicy::Restricted, + }), Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: "ExternalSandbox".into(), @@ -2313,24 +2756,22 @@ allowed_approvals_reviewers = ["user"] let requirements = ConfigRequirements::try_from(requirements_with_sources)?; let root = if cfg!(windows) { "C:\\repo" } else { "/repo" }; - let workspace_write_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::from_absolute_path(root)?], - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }; + let workspace_write_profile = PermissionProfile::workspace_write_with( + &[AbsolutePathBuf::from_absolute_path(root)?], + NetworkSandboxPolicy::Restricted, + /*exclude_tmpdir_env_var*/ false, + /*exclude_slash_tmp*/ false, + ); assert!( requirements .permission_profile - .can_set(&profile_from_sandbox_policy(&workspace_write_policy)) + .can_set(&workspace_write_profile) .is_ok() ); assert_eq!( requirements .permission_profile - .can_set(&profile_from_sandbox_policy( - &SandboxPolicy::DangerFullAccess, - )), + .can_set(&PermissionProfile::Disabled), Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: "DangerFullAccess".into(), @@ -2361,9 +2802,7 @@ allowed_approvals_reviewers = ["user"] assert_eq!( requirements .permission_profile - .can_set(&profile_from_sandbox_policy( - &SandboxPolicy::DangerFullAccess, - )), + .can_set(&PermissionProfile::Disabled), Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: "DangerFullAccess".into(), @@ -2402,9 +2841,7 @@ allowed_approvals_reviewers = ["user"] assert_eq!( requirements .permission_profile - .can_set(&profile_from_sandbox_policy( - &SandboxPolicy::new_workspace_write_policy(), - )), + .can_set(&PermissionProfile::workspace_write()), Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: "WorkspaceWrite".into(), @@ -2671,6 +3108,7 @@ command = "python3 /enterprise/hooks/pre.py" [experimental_network.unix_sockets] "/tmp/example.sock" = "allow" + "/tmp/blocked.sock" = "deny" "#; let source = RequirementSource::CloudRequirements; @@ -2715,10 +3153,16 @@ command = "python3 /enterprise/hooks/pre.py" assert_eq!( sourced_network.value.unix_sockets.as_ref(), Some(&NetworkUnixSocketPermissionsToml { - entries: BTreeMap::from([( - "/tmp/example.sock".to_string(), - NetworkUnixSocketPermissionToml::Allow, - )]), + entries: BTreeMap::from([ + ( + "/tmp/blocked.sock".to_string(), + NetworkUnixSocketPermissionToml::Deny, + ), + ( + "/tmp/example.sock".to_string(), + NetworkUnixSocketPermissionToml::Allow, + ), + ]), }) ); assert_eq!(sourced_network.value.allow_local_binding, Some(false)); @@ -2856,7 +3300,7 @@ command = "python3 /enterprise/hooks/pre.py" ), ( "/tmp/ignored.sock".to_string(), - NetworkUnixSocketPermissionToml::None, + NetworkUnixSocketPermissionToml::Deny, ), ]), }; diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index e2c9bfeb0f..0765819fb7 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -27,9 +27,6 @@ use crate::types::ToolSuggestConfig; use crate::types::Tui; use crate::types::UriBasedFileOpener; use crate::types::WindowsToml; -use codex_app_server_protocol::ForcedChatgptWorkspaceIds as ApiForcedChatgptWorkspaceIds; -use codex_app_server_protocol::Tools; -use codex_app_server_protocol::UserSavedConfig; use codex_features::FeaturesToml; use codex_model_provider_info::AMAZON_BEDROCK_PROVIDER_ID; use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID; @@ -38,6 +35,7 @@ use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR; use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_model_provider_info::OPENAI_PROVIDER_ID; +use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; @@ -58,6 +56,7 @@ use serde::Deserialize; use serde::Deserializer; use serde::Serialize; use serde::de::Error as SerdeError; +use serde_json::Value as JsonValue; const RESERVED_MODEL_PROVIDER_IDS: [&str; 4] = [ AMAZON_BEDROCK_PROVIDER_ID, @@ -88,6 +87,10 @@ const fn default_hide_agent_reasoning() -> Option { Some(false) } +const fn default_true() -> bool { + true +} + /// Backward-compatible shape for ChatGPT workspace login restrictions in config.toml. #[derive(Serialize, Debug, Clone, PartialEq, JsonSchema)] #[serde(untagged)] @@ -103,13 +106,6 @@ impl ForcedChatgptWorkspaceIds { Self::Multiple(values) => values, } } - - pub fn into_api(self) -> ApiForcedChatgptWorkspaceIds { - match self { - Self::Single(value) => ApiForcedChatgptWorkspaceIds::Single(value), - Self::Multiple(values) => ApiForcedChatgptWorkspaceIds::Multiple(values), - } - } } impl<'de> Deserialize<'de> for ForcedChatgptWorkspaceIds { @@ -155,6 +151,10 @@ pub struct ConfigToml { /// Token usage threshold triggering auto-compaction of conversation history. pub model_auto_compact_token_limit: Option, + /// Controls whether the auto-compaction limit applies to the full context or + /// only to tokens after the carried prefix in the current compaction window. + pub model_auto_compact_token_limit_scope: Option, + /// Default approval policy for executing commands. pub approval_policy: Option, @@ -295,9 +295,6 @@ pub struct ConfigToml { #[schemars(skip)] pub js_repl_node_module_dirs: Option>, - /// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution. - pub zsh_path: Option, - /// Profile to use from the `profiles` map. pub profile: Option, @@ -313,7 +310,8 @@ pub struct ConfigToml { /// Defaults to `$CODEX_SQLITE_HOME` when set. Otherwise uses `$CODEX_HOME`. pub sqlite_home: Option, - /// Directory where Codex writes log files, for example `codex-tui.log`. + /// Directory where Codex writes log files. Setting this value explicitly + /// also enables the TUI text log in this directory. /// Defaults to `$CODEX_HOME/log`. pub log_dir: Option, @@ -353,12 +351,15 @@ pub struct ConfigToml { pub personality: Option, /// Optional explicit service tier request id for new turns (for example - /// `priority` or `flex`; legacy `fast` also works). + /// `default`, `priority`, or `flex`; legacy `fast` also works). pub service_tier: Option, /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: Option, + /// Optional product SKU forwarded on host-owned Codex Apps MCP requests. + pub apps_mcp_product_sku: Option, + /// Base URL override for the built-in `openai` model provider. pub openai_base_url: Option, @@ -474,6 +475,10 @@ pub struct ConfigToml { #[serde(default)] pub apps: Option, + /// Opaque desktop settings stored alongside the rest of config.toml. + #[serde(default)] + pub desktop: Option>, + /// OTEL configuration. pub otel: Option, @@ -485,10 +490,6 @@ pub struct ConfigToml { /// See [`crate::types::Notice`] for more details pub notice: Option, - /// Legacy, now use features - /// Deprecated: ignored. Use `model_instructions_file`. - #[schemars(skip)] - pub experimental_instructions_file: Option, pub experimental_compact_prompt_file: Option, pub experimental_use_unified_exec_tool: Option, /// Preferred OSS provider for local models, e.g. "lmstudio" or "ollama". @@ -543,33 +544,6 @@ pub struct AutoReviewToml { pub policy: Option, } -impl From for UserSavedConfig { - fn from(config_toml: ConfigToml) -> Self { - let profiles = config_toml - .profiles - .into_iter() - .map(|(k, v)| (k, v.into())) - .collect(); - - Self { - approval_policy: config_toml.approval_policy, - sandbox_mode: config_toml.sandbox_mode, - sandbox_settings: config_toml.sandbox_workspace_write.map(From::from), - forced_chatgpt_workspace_id: config_toml - .forced_chatgpt_workspace_id - .map(ForcedChatgptWorkspaceIds::into_api), - forced_login_method: config_toml.forced_login_method, - model: config_toml.model, - model_reasoning_effort: config_toml.model_reasoning_effort, - model_reasoning_summary: config_toml.model_reasoning_summary, - model_verbosity: config_toml.model_verbosity, - tools: config_toml.tools.map(From::from), - profile: config_toml.profile, - profiles, - } - } -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct ProjectConfig { @@ -647,6 +621,14 @@ pub struct ToolsToml { deserialize_with = "deserialize_optional_web_search_tool_config" )] pub web_search: Option, + pub experimental_request_user_input: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct ExperimentalRequestUserInput { + #[serde(default = "default_true")] + pub enabled: bool, } #[derive(Deserialize)] @@ -720,14 +702,6 @@ pub struct AgentRoleToml { pub nickname_candidates: Option>, } -impl From for Tools { - fn from(tools_toml: ToolsToml) -> Self { - Self { - web_search: tools_toml.web_search.is_some().then_some(true), - } - } -} - #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct GhostSnapshotToml { @@ -859,27 +833,6 @@ impl ConfigToml { None } - - pub fn get_config_profile( - &self, - override_profile: Option, - ) -> Result { - let profile = override_profile.or_else(|| self.profile.clone()); - - match profile { - Some(key) => { - if let Some(profile) = self.profiles.get(key.as_str()) { - return Ok(profile.clone()); - } - - Err(std::io::Error::new( - std::io::ErrorKind::NotFound, - format!("config profile `{key}` not found"), - )) - } - None => Ok(ConfigProfile::default()), - } - } } /// Canonicalize the path and convert it to a string to be used as a key in the @@ -920,7 +873,7 @@ fn project_config_for_lookup_key( .iter() .filter(|(key, _)| normalize_project_lookup_key((*key).clone()) == lookup_key) .collect(); - normalized_matches.sort_by(|(left, _), (right, _)| left.cmp(right)); + normalized_matches.sort_by_key(|(key, _)| *key); normalized_matches .first() .map(|(_, project_config)| (**project_config).clone()) diff --git a/codex-rs/config/src/diagnostics.rs b/codex-rs/config/src/diagnostics.rs index 3fc593dcdf..ca7df712d6 100644 --- a/codex-rs/config/src/diagnostics.rs +++ b/codex-rs/config/src/diagnostics.rs @@ -4,6 +4,7 @@ use crate::ConfigLayerEntry; use crate::ConfigLayerStack; use crate::ConfigLayerStackOrdering; +use crate::format_config_layer_source; use codex_app_server_protocol::ConfigLayerSource; use codex_utils_absolute_path::AbsolutePathBufGuard; use serde::de::DeserializeOwned; @@ -89,7 +90,6 @@ impl std::error::Error for ConfigLoadError { #[derive(Clone, Copy)] pub(crate) enum ConfigDiagnosticSource<'a> { Path(&'a Path), - #[cfg(any(target_os = "macos", test))] DisplayName(&'a str), } @@ -97,7 +97,6 @@ impl ConfigDiagnosticSource<'_> { pub(crate) fn to_path_buf(self) -> PathBuf { match self { ConfigDiagnosticSource::Path(path) => path.to_path_buf(), - #[cfg(any(target_os = "macos", test))] ConfigDiagnosticSource::DisplayName(name) => PathBuf::from(name), } } @@ -201,6 +200,27 @@ where I: IntoIterator, { for layer in layers { + if let Some(contents) = layer.raw_toml() { + let source_name = format_config_layer_source(&layer.name, config_toml_file); + let Some(base_dir) = layer.raw_toml_base_dir() else { + tracing::debug!( + "Skipping raw TOML diagnostics for {source_name} because it has no base directory" + ); + continue; + }; + // Match the base directory used when the raw non-file layer was + // parsed into the runtime layer so diagnostics resolve relative + // path fields with the same semantics. + let _absolute_path_base = AbsolutePathBufGuard::new(base_dir.as_path()); + if let Some(error) = config_error_from_typed_toml_for_source::( + ConfigDiagnosticSource::DisplayName(&source_name), + contents, + ) { + return Some(error); + } + continue; + } + let Some(path) = config_path_for_layer(layer, config_toml_file) else { continue; }; @@ -235,6 +255,7 @@ fn config_path_for_layer(layer: &ConfigLayerEntry, config_toml_file: &str) -> Op } ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => Some(file.to_path_buf()), ConfigLayerSource::Mdm { .. } + | ConfigLayerSource::EnterpriseManaged { .. } | ConfigLayerSource::SessionFlags | ConfigLayerSource::LegacyManagedConfigTomlFromMdm => None, } diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs index 869761bdaa..eee66896af 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -45,6 +45,10 @@ pub struct HookEventsToml { pub session_start: Vec, #[serde(rename = "UserPromptSubmit", default)] pub user_prompt_submit: Vec, + #[serde(rename = "SubagentStart", default)] + pub subagent_start: Vec, + #[serde(rename = "SubagentStop", default)] + pub subagent_stop: Vec, #[serde(rename = "Stop", default)] pub stop: Vec, } @@ -59,6 +63,8 @@ impl HookEventsToml { post_compact, session_start, user_prompt_submit, + subagent_start, + subagent_stop, stop, } = self; pre_tool_use.is_empty() @@ -68,6 +74,8 @@ impl HookEventsToml { && post_compact.is_empty() && session_start.is_empty() && user_prompt_submit.is_empty() + && subagent_start.is_empty() + && subagent_stop.is_empty() && stop.is_empty() } @@ -80,6 +88,8 @@ impl HookEventsToml { post_compact, session_start, user_prompt_submit, + subagent_start, + subagent_stop, stop, } = self; [ @@ -90,6 +100,8 @@ impl HookEventsToml { post_compact, session_start, user_prompt_submit, + subagent_start, + subagent_stop, stop, ] .into_iter() @@ -98,7 +110,7 @@ impl HookEventsToml { .sum() } - pub fn into_matcher_groups(self) -> [(HookEventName, Vec); 8] { + pub fn into_matcher_groups(self) -> [(HookEventName, Vec); 10] { [ (HookEventName::PreToolUse, self.pre_tool_use), (HookEventName::PermissionRequest, self.permission_request), @@ -107,6 +119,8 @@ impl HookEventsToml { (HookEventName::PostCompact, self.post_compact), (HookEventName::SessionStart, self.session_start), (HookEventName::UserPromptSubmit, self.user_prompt_submit), + (HookEventName::SubagentStart, self.subagent_start), + (HookEventName::SubagentStop, self.subagent_stop), (HookEventName::Stop, self.stop), ] } diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index 3b49db638f..cb3984729b 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -1,4 +1,6 @@ +mod cloud_config_layers; mod cloud_requirements; +mod config_layer_source; mod config_requirements; pub mod config_toml; mod constraint; @@ -18,6 +20,7 @@ mod plugin_edit; pub mod profile_toml; mod project_root_markers; mod requirements_exec_policy; +mod requirements_layers; pub mod schema; mod skills_config; mod state; @@ -28,6 +31,10 @@ pub mod types; pub const CONFIG_TOML_FILE: &str = "config.toml"; +pub use cloud_config_layers::CloudConfigFragment; +pub use cloud_config_layers::CloudConfigFragmentSource; +pub use cloud_config_layers::CloudConfigLayerError; +pub use cloud_config_layers::cloud_config_layers_from_fragments; pub use cloud_requirements::CloudRequirementsLoadError; pub use cloud_requirements::CloudRequirementsLoadErrorCode; pub use cloud_requirements::CloudRequirementsLoader; @@ -35,10 +42,12 @@ pub use codex_app_server_protocol::ConfigLayerSource; pub use codex_protocol::config_types::ProfileV2Name; pub use codex_protocol::config_types::ProfileV2NameParseError; pub use codex_utils_absolute_path::AbsolutePathBuf; +pub use config_layer_source::format_config_layer_source; pub use config_requirements::AppRequirementToml; pub use config_requirements::AppToolRequirementToml; pub use config_requirements::AppToolsRequirementsToml; pub use config_requirements::AppsRequirementsToml; +pub use config_requirements::ComputerUseRequirementsToml; pub use config_requirements::ConfigRequirements; pub use config_requirements::ConfigRequirementsToml; pub use config_requirements::ConfigRequirementsWithSources; @@ -61,6 +70,7 @@ pub use config_requirements::ResidencyRequirement; pub use config_requirements::SandboxModeRequirement; pub use config_requirements::Sourced; pub use config_requirements::WebSearchModeRequirement; +pub use config_requirements::WindowsRequirementsToml; pub use config_requirements::sandbox_mode_requirement_for_permission_profile; pub use constraint::Constrained; pub use constraint::ConstraintError; @@ -93,6 +103,7 @@ pub use marketplace_edit::remove_user_marketplace_config; pub use mcp_edit::ConfigEditsBuilder; pub use mcp_edit::load_global_mcp_servers; pub use mcp_types::AppToolApproval; +pub use mcp_types::DEFAULT_MCP_SERVER_ENVIRONMENT_ID; pub use mcp_types::McpServerConfig; pub use mcp_types::McpServerDisabledReason; pub use mcp_types::McpServerEnvVar; @@ -114,6 +125,8 @@ pub use requirements_exec_policy::RequirementsExecPolicyParseError; pub use requirements_exec_policy::RequirementsExecPolicyPatternTokenToml; pub use requirements_exec_policy::RequirementsExecPolicyPrefixRuleToml; pub use requirements_exec_policy::RequirementsExecPolicyToml; +pub use requirements_layers::RequirementsLayerEntry; +pub use requirements_layers::compose_requirements; pub use skills_config::BundledSkillsConfig; pub use skills_config::SkillConfig; pub use skills_config::SkillsConfig; diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index 37cb41b3e1..a657af0f36 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -1,6 +1,8 @@ mod layer_io; #[cfg(target_os = "macos")] mod macos; +#[cfg(test)] +mod tests; use self::layer_io::LoadedConfigLayers; use crate::CONFIG_TOML_FILE; @@ -59,6 +61,7 @@ const DEFAULT_PROGRAM_DATA_DIR_WINDOWS: &str = r"C:\ProgramData"; const PROJECT_LOCAL_CONFIG_DENYLIST: &[&str] = &[ "openai_base_url", "chatgpt_base_url", + "apps_mcp_product_sku", "model_provider", "model_providers", "notify", @@ -211,19 +214,40 @@ pub async fn load_config_layers_state( // Add the base user config layer. When profile-v2 is selected, add the // profile config as a second user layer on top so the profile only needs to // contain overrides. - let base_user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home); - layers.push( - load_user_config_layer( - fs, - &base_user_file, - /*profile*/ None, - ignore_user_config, - strict_config, - ) - .await?, - ); - let active_user_file = overrides.user_config_path(codex_home)?; + let base_user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home); + let base_user_layer = load_user_config_layer( + fs, + &base_user_file, + /*profile*/ None, + ignore_user_config, + strict_config, + ) + .await?; + if let Some(active_user_profile) = active_user_profile.as_ref() + && let Some(base_user_config) = base_user_layer.config.as_table() + { + let legacy_profile_is_selected = base_user_config + .get("profile") + .and_then(TomlValue::as_str) + .is_some_and(|profile| profile == active_user_profile.as_str()); + let legacy_profile_table_exists = base_user_config + .get("profiles") + .and_then(TomlValue::as_table) + .is_some_and(|profiles| profiles.contains_key(active_user_profile.as_str())); + if legacy_profile_is_selected || legacy_profile_table_exists { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "--profile `{active_user_profile}` cannot be used while {} contains legacy `profile = \"{active_user_profile}\"` or `[profiles.{active_user_profile}]` config; move those settings into {} and remove the legacy profile selector/table. See https://developers.openai.com/codex/config-advanced#profiles for more information.", + base_user_file.as_path().display(), + active_user_file.as_path().display() + ), + )); + } + } + layers.push(base_user_layer); + if active_user_file != base_user_file { layers.push( load_user_config_layer( @@ -342,13 +366,18 @@ pub async fn load_config_layers_state( // paths, starting with `./`, but a path starting with `~/` _is_ a // supported use case. Because resolve_relative_paths_in_config_toml() // relies on AbsolutePathBufGuard to resolve `~/`, we must supply a - // value for base_dir, so codex_home is as good a value as any. - let managed_config = - resolve_relative_paths_in_config_toml(config.managed_config, codex_home)?; + // value for base_dir. Preserve that same base on the layer so later + // raw-TOML diagnostics parse with the same path semantics. + let raw_toml_base_dir = AbsolutePathBuf::from_absolute_path(codex_home)?; + let managed_config = resolve_relative_paths_in_config_toml( + config.managed_config, + raw_toml_base_dir.as_path(), + )?; layers.push(ConfigLayerEntry::new_with_raw_toml( ConfigLayerSource::LegacyManagedConfigTomlFromMdm, managed_config, config.raw_toml, + raw_toml_base_dir, )); } @@ -982,7 +1011,7 @@ fn project_trust_for_lookup_key( .iter() .filter(|(key, _)| normalize_project_trust_lookup_key((*key).clone()) == lookup_key) .collect(); - normalized_matches.sort_by(|(left, _), (right, _)| left.cmp(right)); + normalized_matches.sort_by_key(|(key, _)| *key); normalized_matches .first() .map(|(key, trust_level)| ((**key).clone(), **trust_level)) diff --git a/codex-rs/config/src/loader/tests.rs b/codex-rs/config/src/loader/tests.rs new file mode 100644 index 0000000000..9d4511a8a6 --- /dev/null +++ b/codex-rs/config/src/loader/tests.rs @@ -0,0 +1,252 @@ +use super::*; +use async_trait::async_trait; +use codex_file_system::CopyOptions; +use codex_file_system::CreateDirectoryOptions; +use codex_file_system::FileMetadata; +use codex_file_system::FileSystemResult; +use codex_file_system::FileSystemSandboxContext; +use codex_file_system::ReadDirectoryEntry; +use codex_file_system::RemoveOptions; +use pretty_assertions::assert_eq; +use std::path::Path; +use tempfile::tempdir; + +struct TestFileSystem; + +#[async_trait] +impl ExecutorFileSystem for TestFileSystem { + async fn canonicalize( + &self, + path: &AbsolutePathBuf, + _sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult { + path.canonicalize() + } + + async fn join( + &self, + base_path: &AbsolutePathBuf, + path: &Path, + ) -> FileSystemResult { + Ok(base_path.join(path)) + } + + async fn parent(&self, path: &AbsolutePathBuf) -> FileSystemResult> { + Ok(path.parent()) + } + + async fn read_file( + &self, + path: &AbsolutePathBuf, + _sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult> { + tokio::fs::read(path.as_path()).await + } + + async fn write_file( + &self, + _path: &AbsolutePathBuf, + _contents: Vec, + _sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult<()> { + unimplemented!("test filesystem only supports reads") + } + + async fn create_directory( + &self, + _path: &AbsolutePathBuf, + _create_directory_options: CreateDirectoryOptions, + _sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult<()> { + unimplemented!("test filesystem only supports reads") + } + + async fn get_metadata( + &self, + _path: &AbsolutePathBuf, + _sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult { + unimplemented!("test filesystem only supports reads") + } + + async fn read_directory( + &self, + _path: &AbsolutePathBuf, + _sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult> { + unimplemented!("test filesystem only supports reads") + } + + async fn remove( + &self, + _path: &AbsolutePathBuf, + _remove_options: RemoveOptions, + _sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult<()> { + unimplemented!("test filesystem only supports reads") + } + + async fn copy( + &self, + _source_path: &AbsolutePathBuf, + _destination_path: &AbsolutePathBuf, + _copy_options: CopyOptions, + _sandbox: Option<&FileSystemSandboxContext>, + ) -> FileSystemResult<()> { + unimplemented!("test filesystem only supports reads") + } +} + +#[tokio::test] +async fn profile_v2_rejects_matching_legacy_profile_in_base_user_config() { + let tmp = tempdir().expect("tempdir"); + let selected_config = tmp.path().join("work.config.toml"); + + std::fs::write( + tmp.path().join(CONFIG_TOML_FILE), + r#" +model = "gpt-main" + +[profiles.work] +model = "gpt-work" +"#, + ) + .expect("write default user config"); + std::fs::write(&selected_config, r#"model = "gpt-work-v2""#) + .expect("write selected user config"); + + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.user_config_path = Some(AbsolutePathBuf::resolve_path_against_base( + "work.config.toml", + tmp.path(), + )); + overrides.user_config_profile = Some("work".parse().expect("profile-v2 name")); + + let err = load_config_layers_state( + &TestFileSystem, + tmp.path(), + /*cwd*/ None, + &[], + overrides, + CloudRequirementsLoader::default(), + &crate::NoopThreadConfigLoader, + ) + .await + .expect_err("profile-v2 should reject a matching legacy profile in base user config"); + + assert_eq!( + err.kind(), + io::ErrorKind::InvalidData, + "a matching legacy profile should be a hard config error" + ); + let message = err.to_string(); + assert!( + message.contains("--profile `work` cannot be used"), + "unexpected error message: {message}" + ); + assert!( + message.contains("config.toml"), + "unexpected error message: {message}" + ); + assert!( + message.contains("[profiles.work]"), + "unexpected error message: {message}" + ); + assert!( + message.contains("https://developers.openai.com/codex/config-advanced#profiles"), + "unexpected error message: {message}" + ); +} + +#[tokio::test] +async fn profile_v2_rejects_matching_legacy_profile_selector_in_base_user_config() { + let tmp = tempdir().expect("tempdir"); + let selected_config = tmp.path().join("work.config.toml"); + + std::fs::write( + tmp.path().join(CONFIG_TOML_FILE), + r#" +profile = "work" +model = "gpt-main" +"#, + ) + .expect("write default user config"); + std::fs::write(&selected_config, r#"model = "gpt-work-v2""#) + .expect("write selected user config"); + + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.user_config_path = Some(AbsolutePathBuf::resolve_path_against_base( + "work.config.toml", + tmp.path(), + )); + overrides.user_config_profile = Some("work".parse().expect("profile-v2 name")); + + let err = load_config_layers_state( + &TestFileSystem, + tmp.path(), + /*cwd*/ None, + &[], + overrides, + CloudRequirementsLoader::default(), + &crate::NoopThreadConfigLoader, + ) + .await + .expect_err("profile-v2 should reject a matching legacy profile selector"); + + assert_eq!( + err.kind(), + io::ErrorKind::InvalidData, + "a matching legacy profile selector should be a hard config error" + ); + let message = err.to_string(); + assert!( + message.contains("--profile `work` cannot be used"), + "unexpected error message: {message}" + ); + assert!( + message.contains("profile = \"work\""), + "unexpected error message: {message}" + ); + assert!( + message.contains("work.config.toml"), + "unexpected error message: {message}" + ); +} + +#[tokio::test] +async fn profile_v2_allows_unrelated_legacy_profiles_in_base_user_config() { + let tmp = tempdir().expect("tempdir"); + let selected_config = tmp.path().join("work.config.toml"); + + std::fs::write( + tmp.path().join(CONFIG_TOML_FILE), + r#" +model = "gpt-main" + +[profiles.dev] +model = "gpt-dev" +"#, + ) + .expect("write default user config"); + std::fs::write(&selected_config, r#"model = "gpt-work-v2""#) + .expect("write selected user config"); + + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.user_config_path = Some(AbsolutePathBuf::resolve_path_against_base( + "work.config.toml", + tmp.path(), + )); + overrides.user_config_profile = Some("work".parse().expect("profile-v2 name")); + + load_config_layers_state( + &TestFileSystem, + tmp.path(), + /*cwd*/ None, + &[], + overrides, + CloudRequirementsLoader::default(), + &crate::NoopThreadConfigLoader, + ) + .await + .expect("profile-v2 should allow unrelated legacy profiles in base user config"); +} diff --git a/codex-rs/config/src/mcp_edit.rs b/codex-rs/config/src/mcp_edit.rs index aaad3444fd..9f007de113 100644 --- a/codex-rs/config/src/mcp_edit.rs +++ b/codex-rs/config/src/mcp_edit.rs @@ -175,8 +175,8 @@ fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem { if !config.enabled { entry["enabled"] = value(false); } - if let Some(environment) = &config.experimental_environment { - entry["experimental_environment"] = value(environment.clone()); + if !config.is_local_environment() { + entry["environment_id"] = value(config.environment_id.clone()); } if config.required { entry["required"] = value(true); @@ -230,7 +230,7 @@ fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem { let mut tools = TomlTable::new(); tools.set_implicit(false); let mut tool_entries: Vec<_> = config.tools.iter().collect(); - tool_entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + tool_entries.sort_by_key(|(name, _)| *name); for (name, tool_config) in tool_entries { let mut tool_entry = TomlTable::new(); tool_entry.set_implicit(false); @@ -280,7 +280,7 @@ where I: IntoIterator, { let mut entries: Vec<_> = pairs.into_iter().collect(); - entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + entries.sort_by_key(|(key, _)| *key); let mut table = TomlTable::new(); table.set_implicit(false); for (key, value_str) in entries { diff --git a/codex-rs/config/src/mcp_edit_tests.rs b/codex-rs/config/src/mcp_edit_tests.rs index 0215e7b808..030b0a0233 100644 --- a/codex-rs/config/src/mcp_edit_tests.rs +++ b/codex-rs/config/src/mcp_edit_tests.rs @@ -23,7 +23,7 @@ async fn replace_mcp_servers_serializes_per_tool_approval_overrides() -> anyhow: env_vars: Vec::new(), cwd: None, }, - experimental_environment: None, + environment_id: crate::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: true, @@ -101,7 +101,7 @@ async fn replace_mcp_servers_serializes_oauth_client_id() -> anyhow::Result<()> http_headers: None, env_http_headers: None, }, - experimental_environment: None, + environment_id: crate::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, diff --git a/codex-rs/config/src/mcp_types.rs b/codex-rs/config/src/mcp_types.rs index 8dfceec37e..0d30ba8b23 100644 --- a/codex-rs/config/src/mcp_types.rs +++ b/codex-rs/config/src/mcp_types.rs @@ -13,6 +13,9 @@ use serde::de::Error as SerdeError; use crate::RequirementSource; +/// Effective MCP environment id when config omits `environment_id`. +pub const DEFAULT_MCP_SERVER_ENVIRONMENT_ID: &str = "local"; + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum AppToolApproval { @@ -128,9 +131,8 @@ pub struct McpServerConfig { #[serde(flatten)] pub transport: McpServerTransportConfig, - /// Experimental environment selector for where Codex should start this MCP server. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub experimental_environment: Option, + /// Effective environment id for where Codex should start this MCP server. + pub environment_id: String, /// When `false`, Codex skips initializing this MCP server. #[serde(default = "default_enabled")] @@ -190,6 +192,10 @@ pub struct McpServerConfig { } impl McpServerConfig { + pub fn is_local_environment(&self) -> bool { + self.environment_id == DEFAULT_MCP_SERVER_ENVIRONMENT_ID + } + pub fn oauth_client_id(&self) -> Option<&str> { self.oauth .as_ref() @@ -231,7 +237,7 @@ pub struct RawMcpServerConfig { // shared #[serde(default)] - pub experimental_environment: Option, + pub environment_id: Option, #[serde(default)] pub startup_timeout_sec: Option, #[serde(default)] @@ -279,7 +285,7 @@ impl TryFrom for McpServerConfig { url, bearer_token, bearer_token_env_var, - experimental_environment, + environment_id, startup_timeout_sec, startup_timeout_ms, tool_timeout_sec, @@ -350,9 +356,13 @@ impl TryFrom for McpServerConfig { return Err("invalid transport".to_string()); }; + let environment_id = + environment_id.unwrap_or_else(|| DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string()); + validate_remote_stdio_cwd(&transport, &environment_id)?; + Ok(Self { transport, - experimental_environment, + environment_id, startup_timeout_sec, tool_timeout_sec, enabled: enabled.unwrap_or_else(default_enabled), @@ -385,6 +395,30 @@ const fn default_enabled() -> bool { true } +fn validate_remote_stdio_cwd( + transport: &McpServerTransportConfig, + environment_id: &str, +) -> Result<(), String> { + if environment_id == DEFAULT_MCP_SERVER_ENVIRONMENT_ID { + return Ok(()); + } + let McpServerTransportConfig::Stdio { cwd, .. } = transport else { + return Ok(()); + }; + let Some(cwd) = cwd else { + return Err(format!( + "remote stdio MCP servers require an absolute cwd when environment_id is `{environment_id}`" + )); + }; + if cwd.is_absolute() { + return Ok(()); + } + Err(format!( + "remote stdio MCP servers require an absolute cwd when environment_id is `{environment_id}`, got `{}`", + cwd.display() + )) +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] #[serde(untagged, deny_unknown_fields, rename_all = "snake_case")] pub enum McpServerTransportConfig { diff --git a/codex-rs/config/src/mcp_types_tests.rs b/codex-rs/config/src/mcp_types_tests.rs index f397115425..5f3933ce43 100644 --- a/codex-rs/config/src/mcp_types_tests.rs +++ b/codex-rs/config/src/mcp_types_tests.rs @@ -51,6 +51,62 @@ fn deserialize_stdio_command_server_config_with_args() { assert!(cfg.enabled); } +#[test] +fn deserialize_remote_stdio_server_requires_absolute_cwd() { + let missing_cwd = toml::from_str::( + r#" + command = "echo" + environment_id = "remote" + "#, + ) + .expect_err("remote stdio MCP should require cwd"); + assert!( + missing_cwd + .to_string() + .contains("remote stdio MCP servers require an absolute cwd"), + "unexpected error: {missing_cwd}" + ); + + let relative_cwd = toml::from_str::( + r#" + command = "echo" + environment_id = "remote" + cwd = "relative" + "#, + ) + .expect_err("remote stdio MCP should require absolute cwd"); + assert!( + relative_cwd.to_string().contains("got `relative`"), + "unexpected error: {relative_cwd}" + ); +} + +#[test] +fn deserialize_remote_stdio_server_accepts_absolute_cwd() { + let cwd = std::env::temp_dir(); + let cfg: McpServerConfig = match toml::from_str(&format!( + r#" + command = "echo" + environment_id = "remote" + cwd = {cwd:?} + "# + )) { + Ok(cfg) => cfg, + Err(error) => panic!("remote stdio MCP should accept absolute cwd: {error}"), + }; + + assert_eq!( + cfg.transport, + McpServerTransportConfig::Stdio { + command: "echo".to_string(), + args: vec![], + env: None, + env_vars: Vec::new(), + cwd: Some(cwd), + } + ); +} + #[test] fn deserialize_stdio_command_server_config_with_arg_with_args_and_env() { let cfg: McpServerConfig = toml::from_str( @@ -402,7 +458,7 @@ fn deserialize_ignores_unknown_server_fields() { env_vars: Vec::new(), cwd: None, }, - experimental_environment: None, + environment_id: crate::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, diff --git a/codex-rs/config/src/merge.rs b/codex-rs/config/src/merge.rs index 020adb0313..94d6701546 100644 --- a/codex-rs/config/src/merge.rs +++ b/codex-rs/config/src/merge.rs @@ -1,5 +1,6 @@ use crate::key_aliases::normalize_key_aliases; use crate::key_aliases::normalized_with_key_aliases; +use codex_network_proxy::normalize_host; use toml::Value as TomlValue; /// Merge config `overlay` into `base`, giving `overlay` precedence. @@ -14,6 +15,10 @@ fn merge_toml_values_at_path(base: &mut TomlValue, overlay: &TomlValue, path: &m normalize_key_aliases(path, base_table); let mut overlay_table = overlay_table.clone(); normalize_key_aliases(path, &mut overlay_table); + if is_permission_network_domains_path(path) { + normalize_network_domain_keys(base_table); + normalize_network_domain_keys(&mut overlay_table); + } for (key, value) in overlay_table { path.push(key.clone()); @@ -29,6 +34,21 @@ fn merge_toml_values_at_path(base: &mut TomlValue, overlay: &TomlValue, path: &m } } +fn is_permission_network_domains_path(path: &[String]) -> bool { + matches!( + path, + [permissions, _, network, domains] + if permissions == "permissions" && network == "network" && domains == "domains" + ) +} + +fn normalize_network_domain_keys(table: &mut toml::map::Map) { + let entries = std::mem::take(table); + for (pattern, value) in entries { + table.insert(normalize_host(&pattern), value); + } +} + #[cfg(test)] #[path = "merge_tests.rs"] mod tests; diff --git a/codex-rs/config/src/merge_tests.rs b/codex-rs/config/src/merge_tests.rs index 0bb92c0015..f9da6e7ebc 100644 --- a/codex-rs/config/src/merge_tests.rs +++ b/codex-rs/config/src/merge_tests.rs @@ -98,3 +98,29 @@ disable_on_external_context = true ); assert_eq!(base, expected); } + +#[test] +fn merge_toml_values_normalizes_permission_network_domains_before_overlaying() { + let mut base = parse_toml( + r#" +[permissions.dev.network.domains] +"example.com" = "deny" +"#, + ); + let overlay = parse_toml( + r#" +[permissions.dev.network.domains] +"EXAMPLE.COM" = "allow" +"#, + ); + + merge_toml_values(&mut base, &overlay); + + let expected = parse_toml( + r#" +[permissions.dev.network.domains] +"example.com" = "allow" +"#, + ); + assert_eq!(base, expected); +} diff --git a/codex-rs/config/src/permissions_toml.rs b/codex-rs/config/src/permissions_toml.rs index cee68d7abb..0baec2a5cc 100644 --- a/codex-rs/config/src/permissions_toml.rs +++ b/codex-rs/config/src/permissions_toml.rs @@ -1,14 +1,23 @@ use std::collections::BTreeMap; +use crate::merge::merge_toml_values; +use codex_network_proxy::InjectedHeaderConfig; +use codex_network_proxy::MitmHookActionsConfig; +use codex_network_proxy::MitmHookBodyConfig; +use codex_network_proxy::MitmHookConfig; +use codex_network_proxy::MitmHookMatchConfig; use codex_network_proxy::NetworkDomainPermission as ProxyNetworkDomainPermission; use codex_network_proxy::NetworkMode; use codex_network_proxy::NetworkProxyConfig; use codex_network_proxy::NetworkUnixSocketPermission as ProxyNetworkUnixSocketPermission; use codex_network_proxy::normalize_host; use codex_protocol::permissions::FileSystemAccessMode; +use indexmap::IndexMap; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use thiserror::Error; +use toml::Value as TomlValue; #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] pub struct PermissionsToml { @@ -20,15 +29,204 @@ impl PermissionsToml { pub fn is_empty(&self) -> bool { self.entries.is_empty() } + + pub fn resolve_profile( + &self, + profile_name: &str, + mut parent_profile: F, + ) -> Result + where + F: FnMut(&str) -> Option, + { + let mut profile_names = Vec::new(); + let mut profiles = Vec::new(); + let mut next_profile_name = profile_name.to_string(); + let mut referenced_by: Option = None; + + loop { + if let Some(cycle_start) = profile_names + .iter() + .position(|name| name == &next_profile_name) + { + let cycle = profile_names[cycle_start..] + .iter() + .cloned() + .chain(std::iter::once(next_profile_name)) + .collect::>(); + return Err(PermissionProfileResolutionError::Cycle { cycle }); + } + + let profile = self + .entries + .get(&next_profile_name) + .cloned() + .or_else(|| parent_profile(&next_profile_name)) + .ok_or_else(|| { + referenced_by.as_deref().map_or_else( + || PermissionProfileResolutionError::UndefinedProfile { + profile_name: next_profile_name.clone(), + }, + |referenced_by| { + if next_profile_name.starts_with(':') { + PermissionProfileResolutionError::UnsupportedBuiltInParent { + profile_name: referenced_by.to_string(), + parent_profile_name: next_profile_name.clone(), + } + } else { + PermissionProfileResolutionError::UndefinedParent { + profile_name: referenced_by.to_string(), + parent_profile_name: next_profile_name.clone(), + } + } + }, + ) + })?; + let parent_profile_name = profile.extends.clone(); + + profile_names.push(next_profile_name.clone()); + + if let Some(parent_profile_name) = parent_profile_name { + profiles.push(profile); + referenced_by = Some(next_profile_name); + next_profile_name = parent_profile_name; + continue; + } + + let profile = profiles + .into_iter() + .rev() + .try_fold(profile, merge_permission_profiles)?; + return Ok(ResolvedPermissionProfileToml { + profile, + inherited_profile_names: profile_names.into_iter().skip(1).collect(), + }); + } + } } #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct PermissionProfileToml { + pub description: Option, + pub extends: Option, + pub workspace_roots: Option, pub filesystem: Option, pub network: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedPermissionProfileToml { + pub profile: PermissionProfileToml, + /// Names of profiles inherited while resolving `profile`, ordered from the + /// selected profile's direct parent to the farthest ancestor. + /// + /// Callers use this to preserve which built-in baseline contributed the + /// resolved permissions after the parent profiles have been merged away. + pub inherited_profile_names: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum PermissionProfileResolutionError { + #[error("default_permissions refers to undefined profile `{profile_name}`")] + UndefinedProfile { profile_name: String }, + #[error( + "permissions profile `{profile_name}` extends undefined profile `{parent_profile_name}`" + )] + UndefinedParent { + profile_name: String, + parent_profile_name: String, + }, + #[error( + "permissions profile `{profile_name}` cannot extend unsupported built-in profile `{parent_profile_name}`" + )] + UnsupportedBuiltInParent { + profile_name: String, + parent_profile_name: String, + }, + #[error( + "permissions profile inheritance cycle detected: {}", + cycle.join(" -> ") + )] + Cycle { cycle: Vec }, + #[error("failed to serialize permissions profile while resolving inheritance: {source}")] + SerializeProfileToml { + #[source] + source: toml::ser::Error, + }, + #[error( + "failed to deserialize merged permissions profile while resolving inheritance: {source}" + )] + DeserializeProfileToml { + #[source] + source: toml::de::Error, + }, +} + +fn merge_permission_profiles( + mut parent: PermissionProfileToml, + mut child: PermissionProfileToml, +) -> Result { + let merges_network_domains = parent + .network + .as_ref() + .and_then(|network| network.domains.as_ref()) + .is_some() + && child + .network + .as_ref() + .and_then(|network| network.domains.as_ref()) + .is_some(); + + // Description and inheritance metadata belong to the selected profile + // declaration, so an inherited profile must not fill those gaps. + parent.description = None; + parent.extends = None; + + if merges_network_domains { + normalize_profile_network_domains(&mut parent); + normalize_profile_network_domains(&mut child); + } + + let mut merged = TomlValue::try_from(parent) + .map_err(|source| PermissionProfileResolutionError::SerializeProfileToml { source })?; + let child = TomlValue::try_from(child) + .map_err(|source| PermissionProfileResolutionError::SerializeProfileToml { source })?; + merge_toml_values(&mut merged, &child); + merged + .try_into() + .map_err(|source| PermissionProfileResolutionError::DeserializeProfileToml { source }) +} + +fn normalize_profile_network_domains(profile: &mut PermissionProfileToml) { + let Some(domains) = profile + .network + .as_mut() + .and_then(|network| network.domains.as_mut()) + else { + return; + }; + + let entries = std::mem::take(&mut domains.entries); + domains.entries = entries + .into_iter() + .map(|(pattern, permission)| (normalize_host(&pattern), permission)) + .collect(); +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +pub struct WorkspaceRootsToml { + #[serde(flatten)] + pub entries: BTreeMap, +} + +impl WorkspaceRootsToml { + pub fn enabled_roots(&self) -> impl Iterator { + self.entries + .iter() + .filter_map(|(path, enabled)| (*enabled).then_some(path)) + } +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] pub struct FilesystemPermissionsToml { /// Optional maximum depth for expanding unreadable glob patterns on @@ -129,14 +327,14 @@ impl NetworkUnixSocketPermissionsToml { #[serde(rename_all = "lowercase")] pub enum NetworkUnixSocketPermissionToml { Allow, - None, + Deny, } impl std::fmt::Display for NetworkUnixSocketPermissionToml { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let permission = match self { Self::Allow => "allow", - Self::None => "none", + Self::Deny => "deny", }; f.write_str(permission) } @@ -158,6 +356,38 @@ pub struct NetworkToml { pub domains: Option, pub unix_sockets: Option, pub allow_local_binding: Option, + pub mitm: Option, +} + +#[derive(Serialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct NetworkMitmToml { + #[schemars(with = "Option>")] + pub hooks: Option>, + #[schemars(with = "Option>")] + pub actions: Option>, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct NetworkMitmTomlUnchecked { + pub hooks: Option>, + pub actions: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct NetworkMitmHookToml { + pub host: String, + pub methods: Vec, + pub path_prefixes: Vec, + #[serde(default)] + pub query: BTreeMap>, + #[serde(default)] + pub headers: BTreeMap>, + #[schemars(with = "Option")] + pub body: Option, + pub action: Vec, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -167,6 +397,114 @@ enum NetworkModeSchema { Full, } +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[serde(default)] +pub struct NetworkMitmActionToml { + pub strip_request_headers: Vec, + pub inject_request_headers: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[serde(default)] +pub struct NetworkMitmInjectedHeaderToml { + pub name: String, + pub secret_env_var: Option, + pub secret_file: Option, + pub prefix: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(transparent)] +struct MitmHookBodyConfigSchema(pub serde_json::Value); + +impl<'de> Deserialize<'de> for NetworkMitmToml { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let unchecked = NetworkMitmTomlUnchecked::deserialize(deserializer)?; + let mitm = Self { + hooks: unchecked.hooks, + actions: unchecked.actions, + }; + mitm.validate_action_definitions() + .map_err(serde::de::Error::custom)?; + Ok(mitm) + } +} + +impl NetworkMitmToml { + pub fn validate_action_definitions(&self) -> Result<(), String> { + if let Some(actions) = self.actions.as_ref() { + for (action_name, action) in actions { + if action.is_empty() { + return Err(format!( + "network.mitm.actions.{action_name} must define at least one operation" + )); + } + } + } + + let Some(hooks) = self.hooks.as_ref() else { + return Ok(()); + }; + + for (hook_name, hook) in hooks { + if hook.action.is_empty() { + return Err(format!( + "network.mitm.hooks.{hook_name}.action must not be empty" + )); + } + } + + Ok(()) + } + + pub fn validate_action_references( + &self, + actions_by_name: &IndexMap, + ) -> Result<(), String> { + self.validate_action_definitions()?; + + let Some(hooks) = self.hooks.as_ref() else { + return Ok(()); + }; + + for (hook_name, hook) in hooks { + for action_name in &hook.action { + if !actions_by_name.contains_key(action_name) { + return Err(format!( + "network.mitm.hooks.{hook_name}.action references undefined action `{action_name}`" + )); + } + } + } + + Ok(()) + } + + pub fn to_runtime_hooks( + &self, + actions_by_name: Option<&IndexMap>, + ) -> Vec { + self.hooks + .as_ref() + .map(|hooks| { + hooks + .values() + .map(|hook| hook.to_runtime(actions_by_name)) + .collect() + }) + .unwrap_or_default() + } +} + +impl NetworkMitmActionToml { + pub fn is_empty(&self) -> bool { + self.strip_request_headers.is_empty() && self.inject_request_headers.is_empty() + } +} + impl NetworkToml { pub fn apply_to_network_proxy_config(&self, config: &mut NetworkProxyConfig) { if let Some(enabled) = self.enabled { @@ -209,7 +547,7 @@ impl NetworkToml { NetworkUnixSocketPermissionToml::Allow => { ProxyNetworkUnixSocketPermission::Allow } - NetworkUnixSocketPermissionToml::None => ProxyNetworkUnixSocketPermission::None, + NetworkUnixSocketPermissionToml::Deny => ProxyNetworkUnixSocketPermission::Deny, }; proxy_unix_sockets.entries.insert(path.clone(), permission); } @@ -219,6 +557,11 @@ impl NetworkToml { if let Some(allow_local_binding) = self.allow_local_binding { config.network.allow_local_binding = allow_local_binding; } + if let Some(mitm) = self.mitm.as_ref() { + config.network.mitm_hooks = mitm.to_runtime_hooks(mitm.actions.as_ref()); + } + config.network.mitm = + config.network.mode == NetworkMode::Limited || !config.network.mitm_hooks.is_empty(); } pub fn to_network_proxy_config(&self) -> NetworkProxyConfig { @@ -228,6 +571,61 @@ impl NetworkToml { } } +impl NetworkMitmHookToml { + fn to_runtime( + &self, + actions_by_name: Option<&IndexMap>, + ) -> MitmHookConfig { + MitmHookConfig { + host: self.host.clone(), + matcher: MitmHookMatchConfig { + methods: self.methods.clone(), + path_prefixes: self.path_prefixes.clone(), + query: self.query.clone(), + headers: self.headers.clone(), + body: self.body.clone(), + }, + actions: self.selected_actions(actions_by_name), + } + } + + fn selected_actions( + &self, + actions_by_name: Option<&IndexMap>, + ) -> MitmHookActionsConfig { + let Some(actions_by_name) = actions_by_name else { + return MitmHookActionsConfig::default(); + }; + + let mut selected = MitmHookActionsConfig::default(); + for action_name in &self.action { + if let Some(action) = actions_by_name.get(action_name) { + selected + .strip_request_headers + .extend(action.strip_request_headers.clone()); + selected.inject_request_headers.extend( + action + .inject_request_headers + .iter() + .map(NetworkMitmInjectedHeaderToml::to_runtime), + ); + } + } + selected + } +} + +impl NetworkMitmInjectedHeaderToml { + fn to_runtime(&self) -> InjectedHeaderConfig { + InjectedHeaderConfig { + name: self.name.clone(), + secret_env_var: self.secret_env_var.clone(), + secret_file: self.secret_file.clone(), + prefix: self.prefix.clone(), + } + } +} + pub fn overlay_network_domain_permissions( config: &mut NetworkProxyConfig, domains: &NetworkDomainPermissionsToml, diff --git a/codex-rs/config/src/profile_toml.rs b/codex-rs/config/src/profile_toml.rs index e8e6320490..7d13c02a41 100644 --- a/codex-rs/config/src/profile_toml.rs +++ b/codex-rs/config/src/profile_toml.rs @@ -24,7 +24,7 @@ use codex_protocol::protocol::AskForApproval; pub struct ConfigProfile { pub model: Option, /// Optional explicit service tier request id for new turns (for example - /// `priority` or `flex`; legacy `fast` also works). + /// `default`, `priority`, or `flex`; legacy `fast` also works). pub service_tier: Option, /// The key in the `model_providers` map identifying the /// [`ModelProviderInfo`] to use. @@ -48,11 +48,6 @@ pub struct ConfigProfile { /// Deprecated: ignored. #[schemars(skip)] pub js_repl_node_module_dirs: Option>, - /// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution. - pub zsh_path: Option, - /// Deprecated: ignored. Use `model_instructions_file`. - #[schemars(skip)] - pub experimental_instructions_file: Option, pub experimental_compact_prompt_file: Option, pub include_permissions_instructions: Option, pub include_apps_instructions: Option, @@ -84,17 +79,3 @@ pub struct ProfileTui { #[serde(default)] pub session_picker_view: Option, } - -impl From for codex_app_server_protocol::Profile { - fn from(config_profile: ConfigProfile) -> Self { - Self { - model: config_profile.model, - model_provider: config_profile.model_provider, - approval_policy: config_profile.approval_policy, - model_reasoning_effort: config_profile.model_reasoning_effort, - model_reasoning_summary: config_profile.model_reasoning_summary, - model_verbosity: config_profile.model_verbosity, - chatgpt_base_url: config_profile.chatgpt_base_url, - } - } -} diff --git a/codex-rs/config/src/requirements_layers/hooks.rs b/codex-rs/config/src/requirements_layers/hooks.rs new file mode 100644 index 0000000000..94aefd3285 --- /dev/null +++ b/codex-rs/config/src/requirements_layers/hooks.rs @@ -0,0 +1,237 @@ +//! Hook events are append-only across requirements layers. The managed hook +//! directory is different: only one directory is usable on a given platform, so +//! conflicting values for the active platform fail closed. The inactive platform +//! field is first-filled to allow the same layer stack to carry OS-specific +//! directories. + +use crate::HookEventsToml; +use crate::ManagedHooksRequirementsToml; +use crate::RequirementSource; +use crate::Sourced; +use std::collections::BTreeMap; +use std::path::PathBuf; + +use super::stack::composition_conflict; +use super::stack::merge_output_source; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +pub(super) enum HookDirectoryField { + #[default] + ManagedDir, + WindowsManagedDir, +} + +impl HookDirectoryField { + pub(super) fn current_platform() -> Self { + if cfg!(windows) { + Self::WindowsManagedDir + } else { + Self::ManagedDir + } + } + + fn field_name(self) -> &'static str { + match self { + Self::ManagedDir => "hooks.managed_dir", + Self::WindowsManagedDir => "hooks.windows_managed_dir", + } + } + + fn inactive(self) -> Self { + match self { + Self::ManagedDir => Self::WindowsManagedDir, + Self::WindowsManagedDir => Self::ManagedDir, + } + } +} + +pub(super) struct HookMergeState { + directory_field: HookDirectoryField, + dir_sources: BTreeMap, +} + +impl HookMergeState { + pub(super) fn new(directory_field: HookDirectoryField) -> Self { + Self { + directory_field, + dir_sources: BTreeMap::new(), + } + } + + pub(super) fn merge( + &mut self, + target: &mut Option>, + incoming: Option, + source: &RequirementSource, + ) -> Result<(), super::stack::RequirementsCompositionError> { + let Some(mut incoming) = incoming.filter(|value| !value.is_empty()) else { + return Ok(()); + }; + let Some(existing) = target.as_mut() else { + self.track_singleton_source( + HookDirectoryField::ManagedDir, + &incoming.managed_dir, + source, + ); + self.track_singleton_source( + HookDirectoryField::WindowsManagedDir, + &incoming.windows_managed_dir, + source, + ); + *target = Some(Sourced::new(incoming, source.clone())); + return Ok(()); + }; + + let active_field = self.directory_field; + let inactive_field = active_field.inactive(); + let incoming_active_dir = take_hook_dir(&mut incoming, active_field); + let incoming_inactive_dir = take_hook_dir(&mut incoming, inactive_field); + let mut changed = false; + changed |= self.merge_active_singleton( + active_field, + hook_dir_mut(&mut existing.value, active_field), + incoming_active_dir, + source, + )?; + changed |= self.fill_singleton( + inactive_field, + hook_dir_mut(&mut existing.value, inactive_field), + incoming_inactive_dir, + source, + ); + changed |= append_hook_events(&mut existing.value.hooks, incoming.hooks); + if changed { + merge_output_source(&mut existing.source, source); + } + Ok(()) + } + + fn track_singleton_source( + &mut self, + field: HookDirectoryField, + value: &Option, + source: &RequirementSource, + ) { + if value.is_some() { + self.dir_sources + .entry(field) + .or_insert_with(|| source.clone()); + } + } + + fn merge_active_singleton( + &mut self, + field: HookDirectoryField, + existing: &mut Option, + incoming: Option, + incoming_source: &RequirementSource, + ) -> Result { + let Some(incoming) = incoming else { + return Ok(false); + }; + + match existing { + Some(existing_value) if existing_value != &incoming => { + let existing_source = self + .dir_sources + .get(&field) + .cloned() + .unwrap_or_else(|| incoming_source.clone()); + Err(composition_conflict( + field.field_name().to_string(), + existing_source, + incoming_source.clone(), + format!( + "`{}` conflicts with `{}`", + existing_value.display(), + incoming.display() + ), + )) + } + Some(_) => Ok(false), + None => { + *existing = Some(incoming); + self.dir_sources + .entry(field) + .or_insert_with(|| incoming_source.clone()); + Ok(true) + } + } + } + + fn fill_singleton( + &mut self, + field: HookDirectoryField, + existing: &mut Option, + incoming: Option, + incoming_source: &RequirementSource, + ) -> bool { + if existing.is_none() + && let Some(incoming) = incoming + { + *existing = Some(incoming); + self.dir_sources + .entry(field) + .or_insert_with(|| incoming_source.clone()); + true + } else { + false + } + } +} + +fn take_hook_dir( + hooks: &mut ManagedHooksRequirementsToml, + field: HookDirectoryField, +) -> Option { + match field { + HookDirectoryField::ManagedDir => hooks.managed_dir.take(), + HookDirectoryField::WindowsManagedDir => hooks.windows_managed_dir.take(), + } +} + +fn hook_dir_mut( + hooks: &mut ManagedHooksRequirementsToml, + field: HookDirectoryField, +) -> &mut Option { + match field { + HookDirectoryField::ManagedDir => &mut hooks.managed_dir, + HookDirectoryField::WindowsManagedDir => &mut hooks.windows_managed_dir, + } +} + +fn append_hook_events(existing: &mut HookEventsToml, incoming: HookEventsToml) -> bool { + // Destructure without `..` so new hook events cannot be introduced without + // deciding whether requirements layer merging should append them. + let HookEventsToml { + pre_tool_use, + permission_request, + post_tool_use, + pre_compact, + post_compact, + session_start, + user_prompt_submit, + subagent_start, + subagent_stop, + stop, + } = incoming; + + let mut changed = false; + changed |= append_vec(&mut existing.pre_tool_use, pre_tool_use); + changed |= append_vec(&mut existing.permission_request, permission_request); + changed |= append_vec(&mut existing.post_tool_use, post_tool_use); + changed |= append_vec(&mut existing.pre_compact, pre_compact); + changed |= append_vec(&mut existing.post_compact, post_compact); + changed |= append_vec(&mut existing.session_start, session_start); + changed |= append_vec(&mut existing.user_prompt_submit, user_prompt_submit); + changed |= append_vec(&mut existing.subagent_start, subagent_start); + changed |= append_vec(&mut existing.subagent_stop, subagent_stop); + changed |= append_vec(&mut existing.stop, stop); + changed +} + +fn append_vec(existing: &mut Vec, mut incoming: Vec) -> bool { + let changed = !incoming.is_empty(); + existing.append(&mut incoming); + changed +} diff --git a/codex-rs/config/src/requirements_layers/layer.rs b/codex-rs/config/src/requirements_layers/layer.rs new file mode 100644 index 0000000000..d92024e117 --- /dev/null +++ b/codex-rs/config/src/requirements_layers/layer.rs @@ -0,0 +1,191 @@ +use crate::ConfigRequirementsToml; +use crate::ManagedHooksRequirementsToml; +use crate::RequirementSource; +use crate::RequirementsExecPolicyToml; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::AbsolutePathBufGuard; +use toml::Value as TomlValue; + +use super::stack::RequirementsCompositionError; + +#[derive(Clone, Debug)] +pub struct RequirementsLayerEntry { + pub(super) source: RequirementSource, + toml: RequirementsLayerToml, + base_dir: Option, +} + +impl RequirementsLayerEntry { + pub fn from_toml(source: RequirementSource, contents: impl Into) -> Self { + Self { + source, + toml: RequirementsLayerToml::String(contents.into()), + base_dir: None, + } + } + + pub fn from_toml_value(source: RequirementSource, value: TomlValue) -> Self { + Self { + source, + toml: RequirementsLayerToml::Value(value), + base_dir: None, + } + } + + pub fn with_base_dir(mut self, base_dir: AbsolutePathBuf) -> Self { + self.base_dir = Some(base_dir); + self + } +} + +#[derive(Clone, Debug)] +enum RequirementsLayerToml { + String(String), + Value(TomlValue), +} + +#[derive(Clone, Debug)] +pub(super) struct ComposableRequirementsLayer { + pub(super) source: RequirementSource, + pub(super) regular_toml: TomlValue, + pub(super) domain_fields: DomainMergedRequirementsFields, +} + +impl ComposableRequirementsLayer { + pub(super) fn from_entry( + layer: RequirementsLayerEntry, + hostname: Option<&str>, + ) -> Result { + let RequirementsLayerEntry { + source, + toml, + base_dir, + } = layer; + let (mut regular_toml, mut requirements) = { + let _guard = base_dir + .as_ref() + .map(|base_dir| AbsolutePathBufGuard::new(base_dir.as_path())); + let regular_toml = parse_layer_toml(&toml, &source)?; + let requirements = parse_layer_requirements(&toml, &source)?; + (regular_toml, requirements) + }; + + requirements.apply_remote_sandbox_config(hostname); + materialize_remote_sandbox_config(&mut regular_toml, &requirements)?; + strip_special_fields(&mut regular_toml); + + Ok(Self { + source, + regular_toml, + domain_fields: DomainMergedRequirementsFields { + rules: requirements.rules, + hooks: requirements.hooks, + permissions: requirements.permissions, + }, + }) + } +} + +#[derive(Clone, Debug)] +pub(super) struct DomainMergedRequirementsFields { + pub(super) rules: Option, + pub(super) hooks: Option, + pub(super) permissions: Option, +} + +fn parse_layer_toml( + toml: &RequirementsLayerToml, + source: &RequirementSource, +) -> Result { + match toml { + RequirementsLayerToml::String(contents) => { + toml::from_str(contents).map_err(|err: toml::de::Error| { + RequirementsCompositionError::Parse { + layer_source: source.clone(), + message: err.to_string(), + } + }) + } + RequirementsLayerToml::Value(value) => Ok(value.clone()), + } +} + +fn parse_layer_requirements( + toml: &RequirementsLayerToml, + source: &RequirementSource, +) -> Result { + match toml { + RequirementsLayerToml::String(contents) => { + toml::from_str(contents).map_err(|err: toml::de::Error| { + RequirementsCompositionError::Parse { + layer_source: source.clone(), + message: err.to_string(), + } + }) + } + RequirementsLayerToml::Value(value) => { + value.clone().try_into().map_err(|err: toml::de::Error| { + RequirementsCompositionError::Parse { + layer_source: source.clone(), + message: err.to_string(), + } + }) + } + } +} + +fn materialize_remote_sandbox_config( + layer_toml: &mut TomlValue, + requirements: &ConfigRequirementsToml, +) -> Result<(), RequirementsCompositionError> { + remove_top_level_field(layer_toml, "remote_sandbox_config"); + let Some(allowed_sandbox_modes) = requirements.allowed_sandbox_modes.as_ref() else { + return Ok(()); + }; + let Some(table) = layer_toml.as_table_mut() else { + return Ok(()); + }; + table.insert( + "allowed_sandbox_modes".to_string(), + toml_value_from_serializable(allowed_sandbox_modes)?, + ); + Ok(()) +} + +fn toml_value_from_serializable( + value: T, +) -> Result { + TomlValue::try_from(value).map_err(|err| RequirementsCompositionError::ComposedParse { + message: err.to_string(), + }) +} + +fn strip_special_fields(layer_toml: &mut TomlValue) { + remove_top_level_field(layer_toml, "rules"); + remove_top_level_field(layer_toml, "hooks"); + remove_nested_field_and_prune_empty(layer_toml, &["permissions", "filesystem", "deny_read"]); +} + +fn remove_top_level_field(value: &mut TomlValue, key: &str) -> Option { + value.as_table_mut()?.remove(key) +} + +fn remove_nested_field_and_prune_empty(value: &mut TomlValue, path: &[&str]) -> Option { + let (key, remaining) = path.split_first()?; + let table = value.as_table_mut()?; + if remaining.is_empty() { + return table.remove(*key); + } + + let removed = table + .get_mut(*key) + .and_then(|child| remove_nested_field_and_prune_empty(child, remaining)); + if table + .get(*key) + .and_then(TomlValue::as_table) + .is_some_and(toml::map::Map::is_empty) + { + table.remove(*key); + } + removed +} diff --git a/codex-rs/config/src/requirements_layers/mod.rs b/codex-rs/config/src/requirements_layers/mod.rs new file mode 100644 index 0000000000..52a23a29db --- /dev/null +++ b/codex-rs/config/src/requirements_layers/mod.rs @@ -0,0 +1,8 @@ +mod hooks; +mod layer; +mod permissions; +mod rules; +mod stack; + +pub use layer::RequirementsLayerEntry; +pub use stack::compose_requirements; diff --git a/codex-rs/config/src/requirements_layers/permissions.rs b/codex-rs/config/src/requirements_layers/permissions.rs new file mode 100644 index 0000000000..e4dd2f0ef2 --- /dev/null +++ b/codex-rs/config/src/requirements_layers/permissions.rs @@ -0,0 +1,82 @@ +//! `permissions.filesystem.deny_read` is intentionally additive across +//! requirements layers. Other `[permissions]` content stays in the regular TOML +//! merge path so permission profile tables follow config-style precedence. + +use crate::FilesystemDenyReadPattern; +use crate::RequirementSource; +use crate::Sourced; +use crate::config_requirements::FilesystemRequirementsToml; +use crate::config_requirements::PermissionsRequirementsToml; + +use super::stack::merge_output_source; + +#[derive(Default)] +pub(super) struct DenyReadMergeState { + deny_read: Vec, + source: Option, +} + +impl DenyReadMergeState { + pub(super) fn merge( + &mut self, + incoming: Option, + source: &RequirementSource, + ) { + let Some(incoming_deny_read) = incoming + .and_then(|permissions| permissions.filesystem) + .and_then(|filesystem| filesystem.deny_read) + .filter(|deny_read| !deny_read.is_empty()) + else { + return; + }; + + for pattern in incoming_deny_read { + if !self.deny_read.contains(&pattern) { + self.deny_read.push(pattern); + self.merge_source(source); + } + } + } + + pub(super) fn apply_to(self, target: &mut Option>) { + if self.deny_read.is_empty() { + return; + } + + let source = self.source.unwrap_or(RequirementSource::Unknown); + let Some(existing) = target.as_mut() else { + *target = Some(Sourced::new( + PermissionsRequirementsToml { + filesystem: Some(FilesystemRequirementsToml { + deny_read: Some(self.deny_read), + }), + profiles: Default::default(), + }, + source, + )); + return; + }; + + let filesystem = existing + .value + .filesystem + .get_or_insert_with(Default::default); + let deny_read = filesystem.deny_read.get_or_insert_with(Vec::new); + for pattern in self.deny_read { + if !deny_read.contains(&pattern) { + deny_read.push(pattern); + } + } + if existing.source != source { + existing.source = RequirementSource::composite([existing.source.clone(), source]); + } + } + + fn merge_source(&mut self, source: &RequirementSource) { + let Some(existing) = self.source.as_mut() else { + self.source = Some(source.clone()); + return; + }; + merge_output_source(existing, source); + } +} diff --git a/codex-rs/config/src/requirements_layers/rules.rs b/codex-rs/config/src/requirements_layers/rules.rs new file mode 100644 index 0000000000..1d73493a91 --- /dev/null +++ b/codex-rs/config/src/requirements_layers/rules.rs @@ -0,0 +1,26 @@ +//! Requirements rules are additive across layers. Higher-priority rules are +//! appended first so the final rule order keeps priority visible. + +use crate::RequirementSource; +use crate::RequirementsExecPolicyToml; +use crate::Sourced; + +use super::stack::merge_output_source; + +pub(super) fn merge( + target: &mut Option>, + incoming: Option, + source: &RequirementSource, +) { + let Some(incoming) = incoming else { + return; + }; + let Some(existing) = target.as_mut() else { + *target = Some(Sourced::new(incoming, source.clone())); + return; + }; + + let RequirementsExecPolicyToml { prefix_rules } = incoming; + existing.value.prefix_rules.extend(prefix_rules); + merge_output_source(&mut existing.source, source); +} diff --git a/codex-rs/config/src/requirements_layers/stack.rs b/codex-rs/config/src/requirements_layers/stack.rs new file mode 100644 index 0000000000..e93dd1c1da --- /dev/null +++ b/codex-rs/config/src/requirements_layers/stack.rs @@ -0,0 +1,286 @@ +//! Requirements layers are composed in the same order as config layers: lowest +//! precedence first, highest precedence last. Most fields use the same +//! TOML-level merge policy as config: lower-priority layers provide defaults, +//! and higher-priority layers override scalar/list values while recursively +//! extending tables. +//! +//! A few fields carry domain-specific meaning that raw TOML replacement would +//! break: +//! - `remote_sandbox_config` is evaluated within each layer before merging. +//! - `rules.prefix_rules` append high-priority rules first. +//! - `hooks` append high-priority event groups first while failing closed on +//! active managed-dir conflicts. +//! - `permissions.filesystem.deny_read` is a high-priority-first union across +//! layers. + +use crate::ConfigRequirementsToml; +use crate::ConfigRequirementsWithSources; +use crate::RequirementSource; +use crate::Sourced; +use crate::merge::merge_toml_values; +use std::io; +use thiserror::Error; +use toml::Value as TomlValue; + +use super::hooks::HookDirectoryField; +use super::hooks::HookMergeState; +use super::layer::ComposableRequirementsLayer; +use super::layer::RequirementsLayerEntry; +use super::permissions::DenyReadMergeState; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum RequirementsCompositionError { + #[error("failed to parse requirements layer {layer_source}: {message}")] + Parse { + layer_source: RequirementSource, + message: String, + }, + #[error("failed to parse merged requirements: {message}")] + ComposedParse { message: String }, + #[error( + "failed to compose requirements field `{field}` between {existing_source} and {incoming_source}: {message}" + )] + Conflict { + field: String, + existing_source: RequirementSource, + incoming_source: RequirementSource, + message: String, + }, +} + +impl From for io::Error { + fn from(error: RequirementsCompositionError) -> Self { + io::Error::new(io::ErrorKind::InvalidData, error) + } +} + +pub fn compose_requirements( + layers: impl IntoIterator, +) -> Result, RequirementsCompositionError> { + let hostname = crate::host_name(); + compose_requirements_for_hostname(layers, hostname.as_deref()) +} + +pub(super) fn compose_requirements_for_hostname( + layers: impl IntoIterator, + hostname: Option<&str>, +) -> Result, RequirementsCompositionError> { + compose_requirements_for_hostname_and_hook_directory( + layers, + hostname, + HookDirectoryField::current_platform(), + ) +} + +pub(super) fn compose_requirements_for_hostname_and_hook_directory( + layers: impl IntoIterator, + hostname: Option<&str>, + hook_directory_field: HookDirectoryField, +) -> Result, RequirementsCompositionError> { + let mut stack = RequirementsLayerStack::new(hook_directory_field); + for layer in layers { + stack.add_layer(layer, hostname)?; + } + stack.compose() +} + +struct RequirementsLayerStack { + layers: Vec, + hook_directory_field: HookDirectoryField, +} + +impl RequirementsLayerStack { + fn new(hook_directory_field: HookDirectoryField) -> Self { + Self { + layers: Vec::new(), + hook_directory_field, + } + } + + fn add_layer( + &mut self, + layer: RequirementsLayerEntry, + hostname: Option<&str>, + ) -> Result<(), RequirementsCompositionError> { + self.layers + .push(ComposableRequirementsLayer::from_entry(layer, hostname)?); + Ok(()) + } + + fn compose( + self, + ) -> Result, RequirementsCompositionError> { + let Self { + layers, + hook_directory_field, + } = self; + + let mut merged_toml = TomlValue::Table(toml::map::Map::new()); + for layer in &layers { + merge_toml_values(&mut merged_toml, &layer.regular_toml); + } + + let requirements: ConfigRequirementsToml = + merged_toml.try_into().map_err(|err: toml::de::Error| { + RequirementsCompositionError::ComposedParse { + message: err.to_string(), + } + })?; + let mut output = ConfigRequirementsWithSources::default(); + populate_merged_regular_fields_with_sources(&mut output, requirements, &layers); + let mut rules = None; + let mut hooks = HookMergeState::new(hook_directory_field); + let mut hooks_output = None; + let mut deny_read = DenyReadMergeState::default(); + // Regular TOML fields are folded low-to-high like config. These custom + // fields append or union values, so process them high-to-low to keep + // priority order visible in the output. + for layer in layers.iter().rev() { + let domain_fields = &layer.domain_fields; + super::rules::merge(&mut rules, domain_fields.rules.clone(), &layer.source); + hooks.merge( + &mut hooks_output, + domain_fields.hooks.clone(), + &layer.source, + )?; + deny_read.merge(domain_fields.permissions.clone(), &layer.source); + } + output.rules = rules; + output.hooks = hooks_output; + deny_read.apply_to(&mut output.permissions); + + let output_is_empty = output.clone().into_toml().is_empty(); + Ok((!output_is_empty).then_some(output)) + } +} + +fn populate_merged_regular_fields_with_sources( + output: &mut ConfigRequirementsWithSources, + requirements: ConfigRequirementsToml, + layers: &[ComposableRequirementsLayer], +) { + macro_rules! set_sourced { + ($field:ident, $keys:expr) => { + if let Some(value) = $field { + output.$field = Some(Sourced::new( + value, + source_for_top_level_keys(layers, $keys), + )); + } + }; + } + + // Destructure without `..` so every new requirements field must choose + // whether it belongs in the regular TOML merge path or in a special merger. + let ConfigRequirementsToml { + allowed_approval_policies, + allowed_approvals_reviewers, + allowed_sandbox_modes, + allowed_permissions, + remote_sandbox_config: _, + allowed_web_search_modes, + allow_managed_hooks_only, + allow_appshots, + computer_use, + windows, + feature_requirements, + hooks: _, + mcp_servers, + plugins, + apps, + rules: _, + enforce_residency, + network, + permissions, + guardian_policy_config, + } = requirements; + + set_sourced!(allowed_approval_policies, &["allowed_approval_policies"]); + set_sourced!( + allowed_approvals_reviewers, + &["allowed_approvals_reviewers"] + ); + set_sourced!(allowed_sandbox_modes, &["allowed_sandbox_modes"]); + set_sourced!(allowed_permissions, &["allowed_permissions"]); + set_sourced!(allowed_web_search_modes, &["allowed_web_search_modes"]); + set_sourced!(allow_managed_hooks_only, &["allow_managed_hooks_only"]); + set_sourced!(allow_appshots, &["allow_appshots"]); + set_sourced!(computer_use, &["computer_use"]); + set_sourced!(windows, &["windows"]); + set_sourced!(feature_requirements, &["features", "feature_requirements"]); + set_sourced!(mcp_servers, &["mcp_servers"]); + set_sourced!(plugins, &["plugins"]); + set_sourced!(apps, &["apps"]); + set_sourced!(enforce_residency, &["enforce_residency"]); + set_sourced!(network, &["experimental_network"]); + set_sourced!(permissions, &["permissions"]); + + if let Some(guardian_policy_config) = + guardian_policy_config.filter(|value| !value.trim().is_empty()) + { + output.guardian_policy_config = Some(Sourced::new( + guardian_policy_config, + source_for_top_level_keys(layers, &["guardian_policy_config"]), + )); + } +} + +fn source_for_top_level_keys( + layers: &[ComposableRequirementsLayer], + keys: &[&str], +) -> RequirementSource { + let matching_layers = layers + .iter() + .filter_map(|layer| { + top_level_value_for_keys(&layer.regular_toml, keys).map(|value| (&layer.source, value)) + }) + .collect::>(); + let Some((winning_source, winning_value)) = matching_layers.last() else { + return RequirementSource::Unknown; + }; + let winning_source = (*winning_source).clone(); + + if !winning_value.is_table() { + return winning_source; + } + + let table_sources = matching_layers + .into_iter() + .rev() + .filter_map(|(source, value)| value.is_table().then_some(source.clone())) + .collect::>(); + if table_sources.len() > 1 { + RequirementSource::composite(table_sources) + } else { + winning_source + } +} + +fn top_level_value_for_keys<'a>(value: &'a TomlValue, keys: &[&str]) -> Option<&'a TomlValue> { + let table = value.as_table()?; + keys.iter().find_map(|key| table.get(*key)) +} + +pub(super) fn merge_output_source(existing: &mut RequirementSource, incoming: &RequirementSource) { + if existing != incoming { + *existing = RequirementSource::composite([existing.clone(), incoming.clone()]); + } +} + +pub(super) fn composition_conflict( + field: String, + existing_source: RequirementSource, + incoming_source: RequirementSource, + message: impl Into, +) -> RequirementsCompositionError { + RequirementsCompositionError::Conflict { + field, + existing_source, + incoming_source, + message: message.into(), + } +} + +#[cfg(test)] +#[path = "stack_tests.rs"] +mod tests; diff --git a/codex-rs/config/src/requirements_layers/stack_tests.rs b/codex-rs/config/src/requirements_layers/stack_tests.rs new file mode 100644 index 0000000000..8acfd5f0d5 --- /dev/null +++ b/codex-rs/config/src/requirements_layers/stack_tests.rs @@ -0,0 +1,922 @@ +use super::super::RequirementsLayerEntry; +use super::super::hooks::HookDirectoryField; +use super::RequirementsCompositionError; +use super::compose_requirements_for_hostname; +use super::compose_requirements_for_hostname_and_hook_directory; +use crate::ConfigRequirementsToml; +use crate::ConfigRequirementsWithSources; +use crate::RequirementSource; +use crate::Sourced; +use codex_protocol::protocol::AskForApproval; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use std::collections::BTreeMap; + +fn layer(id: &str, name: &str, contents: &str) -> RequirementsLayerEntry { + RequirementsLayerEntry::from_toml( + RequirementSource::EnterpriseManaged { + id: id.to_string(), + name: name.to_string(), + }, + contents, + ) +} + +fn compose( + layers: Vec, +) -> Result, RequirementsCompositionError> { + Ok( + compose_requirements_for_hostname(layers, /*hostname*/ None)? + .map(ConfigRequirementsWithSources::into_toml), + ) +} + +fn compose_with_hook_directory_field( + layers: Vec, + hook_directory_field: HookDirectoryField, +) -> Result, RequirementsCompositionError> { + Ok(compose_requirements_for_hostname_and_hook_directory( + layers, + /*hostname*/ None, + hook_directory_field, + )? + .map(ConfigRequirementsWithSources::into_toml)) +} + +fn expected_requirements(contents: impl AsRef) -> ConfigRequirementsToml { + toml::from_str(contents.as_ref()).expect("parse expected requirements TOML") +} + +#[test] +fn empty_layers_compose_to_none() { + let composed = compose(Vec::new()).expect("compose empty layers"); + assert_eq!(composed, None); +} + +#[test] +fn top_level_values_use_toml_priority() { + let composed = compose(vec![ + layer( + "req_low", + "Low", + r#" +allowed_approval_policies = ["on-request"] +allowed_sandbox_modes = ["workspace-write"] +"#, + ), + layer( + "req_high", + "High", + r#" +allowed_approval_policies = ["never"] +allowed_sandbox_modes = ["read-only"] +"#, + ), + ]) + .expect("compose requirements") + .expect("requirements present"); + + assert_eq!( + composed, + expected_requirements( + r#" +allowed_approval_policies = ["never"] +allowed_sandbox_modes = ["read-only"] +"# + ) + ); +} + +#[test] +fn composition_strategy_applies_to_non_cloud_layers() { + let mdm_source = RequirementSource::MdmManagedPreferences { + domain: "com.openai.codex".to_string(), + key: "requirements_toml_base64".to_string(), + }; + let system_file = if cfg!(windows) { + "C:\\requirements.toml" + } else { + "/etc/codex/requirements.toml" + }; + let system_source = RequirementSource::SystemRequirementsToml { + file: AbsolutePathBuf::from_absolute_path(system_file).expect("absolute path"), + }; + let high_path = if cfg!(windows) { + "C:\\secret" + } else { + "/secret" + }; + let low_path = if cfg!(windows) { + "C:\\other-secret" + } else { + "/other-secret" + }; + + let composed = compose_requirements_for_hostname( + vec![ + RequirementsLayerEntry::from_toml( + system_source, + format!( + r#" +allowed_approval_policies = ["on-request"] + +[features] +shared = false +system = true + +[[rules.prefix_rules]] +pattern = [{{ token = "npm" }}] +decision = "prompt" + +[permissions.filesystem] +deny_read = [{low_path:?}] +"# + ), + ), + RequirementsLayerEntry::from_toml( + mdm_source.clone(), + format!( + r#" +allowed_approval_policies = ["never"] + +[features] +shared = true + +[[rules.prefix_rules]] +pattern = [{{ token = "git" }}] +decision = "forbidden" + +[permissions.filesystem] +deny_read = [{high_path:?}] +"# + ), + ), + ], + /*hostname*/ None, + ) + .expect("compose requirements") + .expect("requirements present"); + + assert_eq!( + composed.clone().into_toml(), + expected_requirements(format!( + r#" +allowed_approval_policies = ["never"] + +[features] +shared = true +system = true + +[[rules.prefix_rules]] +pattern = [{{ token = "git" }}] +decision = "forbidden" + +[[rules.prefix_rules]] +pattern = [{{ token = "npm" }}] +decision = "prompt" + +[permissions.filesystem] +deny_read = [{high_path:?}, {low_path:?}] +"# + )) + ); + assert_eq!( + composed.allowed_approval_policies, + Some(Sourced::new(vec![AskForApproval::Never], mdm_source)) + ); +} + +#[test] +fn single_regular_layer_keeps_enterprise_managed_source() { + let composed = compose_requirements_for_hostname( + vec![layer( + "req_1", + "Security baseline", + r#" +allow_managed_hooks_only = true +"#, + )], + /*hostname*/ None, + ) + .expect("compose requirements") + .expect("requirements present"); + + assert_eq!( + composed.allow_managed_hooks_only, + Some(Sourced::new( + /*value*/ true, + RequirementSource::EnterpriseManaged { + id: "req_1".to_string(), + name: "Security baseline".to_string(), + }, + )) + ); +} + +#[test] +fn regular_toml_merge_recurses_into_tables() { + let composed = compose(vec![ + layer( + "req_low", + "Low", + r#" +[features] +beta = false +shared = false + +[apps.connector_1] +enabled = false + +[apps.connector_1.tools.search] +approval_mode = "prompt" + +[apps.connector_1.tools.list] +approval_mode = "prompt" +"#, + ), + layer( + "req_high", + "High", + r#" +[features] +alpha = true +shared = true + +[apps.connector_1] +enabled = true + +[apps.connector_1.tools.search] +approval_mode = "approve" +"#, + ), + ]) + .expect("compose requirements") + .expect("requirements present"); + + assert_eq!( + composed, + expected_requirements( + r#" +[features] +alpha = true +beta = false +shared = true + +[apps.connector_1] +enabled = true + +[apps.connector_1.tools.list] +approval_mode = "prompt" + +[apps.connector_1.tools.search] +approval_mode = "approve" +"# + ) + ); +} + +#[test] +fn merged_table_source_is_composite_in_priority_order() { + let high_source = RequirementSource::EnterpriseManaged { + id: "req_high".to_string(), + name: "High".to_string(), + }; + let low_source = RequirementSource::EnterpriseManaged { + id: "req_low".to_string(), + name: "Low".to_string(), + }; + let composed = compose_requirements_for_hostname( + vec![ + RequirementsLayerEntry::from_toml( + low_source.clone(), + r#" +[features] +beta = true +"#, + ), + RequirementsLayerEntry::from_toml( + high_source.clone(), + r#" +[features] +alpha = true +"#, + ), + ], + /*hostname*/ None, + ) + .expect("compose requirements") + .expect("requirements present"); + + assert_eq!( + composed.feature_requirements.expect("features"), + Sourced::new( + crate::FeatureRequirementsToml { + entries: BTreeMap::from([("alpha".to_string(), true), ("beta".to_string(), true),]), + }, + RequirementSource::composite([high_source, low_source]), + ) + ); +} + +#[test] +fn mcp_requirements_use_regular_toml_merge() { + let composed = compose(vec![ + layer( + "req_low", + "Low", + r#" +[mcp_servers.shared.identity] +command = "low-mcp" + +[mcp_servers.low.identity] +url = "https://low.example.com/mcp" +"#, + ), + layer( + "req_high", + "High", + r#" +[mcp_servers.shared.identity] +command = "high-mcp" +"#, + ), + ]) + .expect("compose requirements") + .expect("requirements present"); + + assert_eq!( + composed, + expected_requirements( + r#" +[mcp_servers.low.identity] +url = "https://low.example.com/mcp" + +[mcp_servers.shared.identity] +command = "high-mcp" +"# + ) + ); +} + +#[test] +fn network_maps_use_regular_toml_merge() { + let composed = compose(vec![ + layer( + "req_low", + "Low", + r#" +[experimental_network.domains] +"example.com" = "deny" +"low.example.com" = "deny" +"internal.example.com" = "allow" + +[experimental_network.unix_sockets] +"/tmp/shared.sock" = "deny" +"/tmp/low.sock" = "allow" +"/tmp/admin.sock" = "allow" +"#, + ), + layer( + "req_high", + "High", + r#" +[experimental_network.domains] +"example.com" = "allow" +"high.example.com" = "allow" +"internal.example.com" = "deny" + +[experimental_network.unix_sockets] +"/tmp/shared.sock" = "allow" +"/tmp/high.sock" = "allow" +"/tmp/admin.sock" = "deny" +"#, + ), + ]) + .expect("compose requirements") + .expect("requirements present"); + + assert_eq!( + composed, + expected_requirements( + r#" +[experimental_network.domains] +"example.com" = "allow" +"high.example.com" = "allow" +"internal.example.com" = "deny" +"low.example.com" = "deny" + +[experimental_network.unix_sockets] +"/tmp/admin.sock" = "deny" +"/tmp/high.sock" = "allow" +"/tmp/low.sock" = "allow" +"/tmp/shared.sock" = "allow" +"# + ) + ); +} + +#[test] +fn windows_requirements_use_regular_toml_merge() { + let composed = compose(vec![ + layer( + "req_low", + "Low", + r#" +[windows] +allowed_sandbox_implementations = ["unelevated"] +"#, + ), + layer( + "req_high", + "High", + r#" +[windows] +allowed_sandbox_implementations = ["elevated"] +"#, + ), + ]) + .expect("compose requirements") + .expect("requirements present"); + + assert_eq!( + composed, + expected_requirements( + r#" +[windows] +allowed_sandbox_implementations = ["elevated"] +"# + ) + ); +} + +#[test] +fn remote_sandbox_config_is_applied_per_layer() { + let composed = compose_requirements_for_hostname( + vec![ + layer( + "req_low", + "Low", + r#" +allowed_sandbox_modes = ["read-only"] +"#, + ), + layer( + "req_high", + "High", + r#" +[[remote_sandbox_config]] +hostname_patterns = ["build-*.example.com"] +allowed_sandbox_modes = ["workspace-write"] +"#, + ), + ], + Some("BUILD-01.EXAMPLE.COM."), + ) + .expect("compose requirements") + .expect("requirements present") + .into_toml(); + + assert_eq!( + composed, + expected_requirements( + r#" +allowed_sandbox_modes = ["workspace-write"] +"# + ) + ); +} + +#[test] +fn unmatched_remote_sandbox_config_does_not_shadow_lower_layers() { + let composed = compose_requirements_for_hostname( + vec![ + layer( + "req_low", + "Low", + r#" +allowed_sandbox_modes = ["read-only"] +"#, + ), + layer( + "req_high", + "High", + r#" +[[remote_sandbox_config]] +hostname_patterns = ["mac-*.example.com"] +allowed_sandbox_modes = ["workspace-write"] +"#, + ), + ], + Some("linux-01.example.com"), + ) + .expect("compose requirements") + .expect("requirements present") + .into_toml(); + + assert_eq!( + composed, + expected_requirements( + r#" +allowed_sandbox_modes = ["read-only"] +"# + ) + ); +} + +#[test] +fn rules_are_appended_in_priority_order() { + let composed = compose(vec![ + layer( + "req_low", + "Low", + r#" +[[rules.prefix_rules]] +pattern = [{ token = "npm" }] +decision = "prompt" +"#, + ), + layer( + "req_high", + "High", + r#" +[[rules.prefix_rules]] +pattern = [{ token = "git" }] +decision = "forbidden" +"#, + ), + ]) + .expect("compose requirements") + .expect("requirements present"); + + assert_eq!( + composed, + expected_requirements( + r#" +[[rules.prefix_rules]] +pattern = [{ token = "git" }] +decision = "forbidden" + +[[rules.prefix_rules]] +pattern = [{ token = "npm" }] +decision = "prompt" +"# + ) + ); +} + +#[test] +fn hooks_append_groups_and_reject_conflicting_managed_dirs() { + let composed = compose_with_hook_directory_field( + vec![ + layer( + "req_low", + "Low", + r#" +[hooks] +managed_dir = "/managed/hooks" + +[[hooks.PreToolUse]] +matcher = "Bash" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "low" +"#, + ), + layer( + "req_high", + "High", + r#" +[hooks] +managed_dir = "/managed/hooks" + +[[hooks.PreToolUse]] +matcher = "Edit" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "high" +"#, + ), + ], + HookDirectoryField::ManagedDir, + ) + .expect("compose requirements") + .expect("requirements present"); + + assert_eq!( + composed, + expected_requirements( + r#" +[hooks] +managed_dir = "/managed/hooks" + +[[hooks.PreToolUse]] +matcher = "Edit" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "high" + +[[hooks.PreToolUse]] +matcher = "Bash" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "low" +"# + ) + ); + + let err = compose_with_hook_directory_field( + vec![ + layer( + "req_low", + "Low", + r#" +[hooks] +managed_dir = "/managed/low" +"#, + ), + layer( + "req_high", + "High", + r#" +[hooks] +managed_dir = "/managed/high" +"#, + ), + ], + HookDirectoryField::ManagedDir, + ) + .expect_err("conflicting managed dirs should fail closed"); + assert!(err.to_string().contains("hooks.managed_dir")); + assert!(err.to_string().contains("High (req_high)")); + assert!(err.to_string().contains("Low (req_low)")); +} + +#[test] +fn active_windows_managed_dir_conflicts_fail_closed() { + let err = compose_with_hook_directory_field( + vec![ + layer( + "req_low", + "Low", + r#" +[hooks] +windows_managed_dir = 'C:\managed\low' +"#, + ), + layer( + "req_high", + "High", + r#" +[hooks] +windows_managed_dir = 'C:\managed\high' +"#, + ), + ], + HookDirectoryField::WindowsManagedDir, + ) + .expect_err("conflicting windows managed dirs should fail closed"); + + assert!(err.to_string().contains("hooks.windows_managed_dir")); + assert!(err.to_string().contains("High (req_high)")); + assert!(err.to_string().contains("Low (req_low)")); +} + +#[test] +fn inactive_hook_dir_conflicts_do_not_fail_composition() { + let composed = compose_with_hook_directory_field( + vec![ + layer( + "req_low", + "Low", + r#" +[hooks] +managed_dir = "/managed/hooks" +windows_managed_dir = 'C:\managed\low' + +[[hooks.PreToolUse]] +matcher = "Bash" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "low" +"#, + ), + layer( + "req_high", + "High", + r#" +[hooks] +managed_dir = "/managed/hooks" +windows_managed_dir = 'C:\managed\high' + +[[hooks.PreToolUse]] +matcher = "Edit" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "high" +"#, + ), + ], + HookDirectoryField::ManagedDir, + ) + .expect("inactive windows managed dir conflict should not fail") + .expect("requirements present"); + + assert_eq!( + composed, + expected_requirements( + r#" +[hooks] +managed_dir = "/managed/hooks" +windows_managed_dir = 'C:\managed\high' + +[[hooks.PreToolUse]] +matcher = "Edit" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "high" + +[[hooks.PreToolUse]] +matcher = "Bash" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "low" +"# + ) + ); + + let composed = compose_with_hook_directory_field( + vec![ + layer( + "req_low", + "Low", + r#" +[hooks] +managed_dir = "/managed/low" +windows_managed_dir = 'C:\managed\hooks' + +[[hooks.PreToolUse]] +matcher = "Bash" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "low" +"#, + ), + layer( + "req_high", + "High", + r#" +[hooks] +managed_dir = "/managed/high" +windows_managed_dir = 'C:\managed\hooks' + +[[hooks.PreToolUse]] +matcher = "Edit" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "high" +"#, + ), + ], + HookDirectoryField::WindowsManagedDir, + ) + .expect("inactive managed dir conflict should not fail") + .expect("requirements present"); + + assert_eq!( + composed, + expected_requirements( + r#" +[hooks] +managed_dir = "/managed/high" +windows_managed_dir = 'C:\managed\hooks' + +[[hooks.PreToolUse]] +matcher = "Edit" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "high" + +[[hooks.PreToolUse]] +matcher = "Bash" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "low" +"# + ) + ); +} + +#[test] +fn permissions_deny_read_unions_while_profiles_use_regular_toml_merge() { + let high_path = if cfg!(windows) { + "C:\\secret" + } else { + "/secret" + }; + let low_path = if cfg!(windows) { + "C:\\other-secret" + } else { + "/other-secret" + }; + let composed = compose(vec![ + layer( + "req_low", + "Low", + &format!( + r#" +[permissions.filesystem] +deny_read = [{high_path:?}, {low_path:?}] + +[permissions.managed-standard] +description = "Low profile" +extends = ":workspace" +"# + ), + ), + layer( + "req_high", + "High", + &format!( + r#" +[permissions.filesystem] +deny_read = [{high_path:?}] + +[permissions.managed-standard] +description = "High profile" +"# + ), + ), + ]) + .expect("compose requirements") + .expect("requirements present"); + + assert_eq!( + composed, + expected_requirements(format!( + r#" +[permissions.filesystem] +deny_read = [{high_path:?}, {low_path:?}] + +[permissions.managed-standard] +description = "High profile" +extends = ":workspace" +"# + )) + ); +} + +#[test] +fn deny_read_only_layers_do_not_leave_empty_permissions_tables() { + let path = if cfg!(windows) { + "C:\\secret" + } else { + "/secret" + }; + let composed = compose(vec![layer( + "req_high", + "High", + &format!( + r#" +[permissions.filesystem] +deny_read = [{path:?}] +"# + ), + )]) + .expect("compose requirements") + .expect("requirements present"); + + assert_eq!( + composed, + expected_requirements(format!( + r#" +[permissions.filesystem] +deny_read = [{path:?}] +"# + )) + ); +} + +#[test] +fn parse_error_names_layer() { + let err = compose(vec![layer( + "req_bad", + "Bad layer", + "allowed_approval_policies = [1]", + )]) + .expect_err("invalid layer should fail"); + + assert!(err.to_string().contains("Bad layer (req_bad)")); + assert!(err.to_string().contains("allowed_approval_policies")); +} diff --git a/codex-rs/config/src/schema.rs b/codex-rs/config/src/schema.rs index df0de3b1cd..e0d2c70671 100644 --- a/codex-rs/config/src/schema.rs +++ b/codex-rs/config/src/schema.rs @@ -99,7 +99,7 @@ pub fn canonicalize(value: &Value) -> Value { Value::Array(items) => Value::Array(items.iter().map(canonicalize).collect()), Value::Object(map) => { let mut entries: Vec<_> = map.iter().collect(); - entries.sort_by(|(left, _), (right, _)| left.cmp(right)); + entries.sort_by_key(|(key, _)| *key); let mut sorted = Map::with_capacity(map.len()); for (key, child) in entries { sorted.insert(key.clone(), canonicalize(child)); diff --git a/codex-rs/config/src/state.rs b/codex-rs/config/src/state.rs index 3ec0bfb314..e4d03c3d3c 100644 --- a/codex-rs/config/src/state.rs +++ b/codex-rs/config/src/state.rs @@ -97,33 +97,47 @@ impl LoaderOverrides { pub struct ConfigLayerEntry { pub name: ConfigLayerSource, pub config: TomlValue, - pub raw_toml: Option, pub version: String, pub disabled_reason: Option, + raw_toml: Option, hooks_config_folder_override: Option, } +#[derive(Debug, Clone, PartialEq)] +struct RawTomlLayer { + contents: String, + base_dir: AbsolutePathBuf, +} + impl ConfigLayerEntry { pub fn new(name: ConfigLayerSource, config: TomlValue) -> Self { let version = version_for_toml(&config); Self { name, config, - raw_toml: None, version, disabled_reason: None, + raw_toml: None, hooks_config_folder_override: None, } } - pub fn new_with_raw_toml(name: ConfigLayerSource, config: TomlValue, raw_toml: String) -> Self { + pub fn new_with_raw_toml( + name: ConfigLayerSource, + config: TomlValue, + raw_toml: String, + raw_toml_base_dir: AbsolutePathBuf, + ) -> Self { let version = version_for_toml(&config); Self { name, config, - raw_toml: Some(raw_toml), version, disabled_reason: None, + raw_toml: Some(RawTomlLayer { + contents: raw_toml, + base_dir: raw_toml_base_dir, + }), hooks_config_folder_override: None, } } @@ -137,9 +151,9 @@ impl ConfigLayerEntry { Self { name, config, - raw_toml: None, version, disabled_reason: Some(disabled_reason.into()), + raw_toml: None, hooks_config_folder_override: None, } } @@ -149,7 +163,13 @@ impl ConfigLayerEntry { } pub fn raw_toml(&self) -> Option<&str> { - self.raw_toml.as_deref() + self.raw_toml + .as_ref() + .map(|raw_toml| raw_toml.contents.as_str()) + } + + pub fn raw_toml_base_dir(&self) -> Option<&AbsolutePathBuf> { + self.raw_toml.as_ref().map(|raw_toml| &raw_toml.base_dir) } pub(crate) fn with_hooks_config_folder_override( @@ -181,6 +201,7 @@ impl ConfigLayerEntry { match &self.name { ConfigLayerSource::Mdm { .. } => None, ConfigLayerSource::System { file } => file.parent(), + ConfigLayerSource::EnterpriseManaged { .. } => None, ConfigLayerSource::User { file, .. } => file.parent(), ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder.clone()), ConfigLayerSource::SessionFlags => None, diff --git a/codex-rs/config/src/strict_config_tests.rs b/codex-rs/config/src/strict_config_tests.rs index 4621b6b2e5..4d5a62df25 100644 --- a/codex-rs/config/src/strict_config_tests.rs +++ b/codex-rs/config/src/strict_config_tests.rs @@ -110,3 +110,18 @@ foo = true"#; ) ); } + +#[test] +fn strict_config_accepts_opaque_desktop_keys() { + let path = Path::new("/tmp/config.toml"); + let contents = r#" +[desktop] +appearanceTheme = "dark" + +[desktop.workspace] +collapsed = true"#; + + let error = config_error_from_ignored_toml_fields::(path, contents); + + assert_eq!(error, None); +} diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 5b1e7d4cc8..93392f1f7e 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -23,6 +23,9 @@ use serde::Serialize; use serde::de::Error as SerdeError; use std::collections::BTreeMap; +/// Highest function key supported by portable TUI keymap configuration. +pub const MAX_FUNCTION_KEY: u8 = 24; + /// Normalized string representation of a single key event (for example `ctrl-a`). /// /// The parser accepts a small alias set (for example `escape` -> `esc`, @@ -115,6 +118,8 @@ pub struct TuiGlobalKeymap { #[serde(deny_unknown_fields)] #[schemars(deny_unknown_fields)] pub struct TuiChatKeymap { + /// Interrupt the active turn. + pub interrupt_turn: Option, /// Decrease the active reasoning effort. pub decrease_reasoning_effort: Option, /// Increase the active reasoning effort. @@ -221,8 +226,12 @@ pub struct TuiVimNormalKeymap { pub move_line_end: Option, /// Delete character under cursor (`x`). pub delete_char: Option, + /// Delete character under cursor and enter insert mode (`s`). + pub substitute_char: Option, /// Delete from cursor to end of line (`D`). pub delete_to_line_end: Option, + /// Change from cursor to end of line and enter insert mode (`C`). + pub change_to_line_end: Option, /// Yank the entire line (`Y`). pub yank_line: Option, /// Paste after cursor (`p`). @@ -231,6 +240,8 @@ pub struct TuiVimNormalKeymap { pub start_delete_operator: Option, /// Begin yank operator; next key selects motion (`y`). pub start_yank_operator: Option, + /// Begin change operator; next keys select a text object. + pub start_change_operator: Option, /// Cancel a pending operator and return to normal mode. pub cancel_operator: Option, } @@ -266,10 +277,39 @@ pub struct TuiVimOperatorKeymap { pub motion_line_start: Option, /// Motion: to end of line (`$`). pub motion_line_end: Option, + /// Select an inner text object after an operator. + pub select_inner_text_object: Option, + /// Select an around text object after an operator. + pub select_around_text_object: Option, /// Cancel the pending operator and return to normal mode. pub cancel: Option, } +/// Vim text-object keybindings for modal editing inside text areas. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +pub struct TuiVimTextObjectKeymap { + /// Text object: word. + pub word: Option, + /// Text object: whitespace-delimited WORD. + pub big_word: Option, + /// Text object: parentheses. + pub parentheses: Option, + /// Text object: brackets. + pub brackets: Option, + /// Text object: braces. + pub braces: Option, + /// Text object: double quotes. + pub double_quote: Option, + /// Text object: single quotes. + pub single_quote: Option, + /// Text object: backticks. + pub backtick: Option, + /// Cancel the pending text-object command. + pub cancel: Option, +} + /// Pager context keybindings for transcript and static overlays. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] #[serde(deny_unknown_fields)] @@ -374,6 +414,8 @@ pub struct TuiKeymap { #[serde(default)] pub vim_operator: TuiVimOperatorKeymap, #[serde(default)] + pub vim_text_object: TuiVimTextObjectKeymap, + #[serde(default)] pub pager: TuiPagerKeymap, #[serde(default)] pub list: TuiListKeymap, @@ -513,14 +555,14 @@ fn normalize_key_name(key: &str, original: &str) -> Result { if let Some(number) = alias.strip_prefix('f') && let Ok(number) = number.parse::() - && (1..=12).contains(&number) + && (1..=MAX_FUNCTION_KEY).contains(&number) { return Ok(alias.to_string()); } Err(format!( "unknown key `{key}` in keybinding `{original}`. \ -Use a printable character (for example `a`), function keys (`f1`-`f12`), \ +Use a printable character (for example `a`), function keys (`f1`-`f{MAX_FUNCTION_KEY}`), \ or one of: enter, tab, backspace, esc, delete, arrows, home/end, page-up/page-down, space, minus.\n\ See the Codex keymap documentation for supported actions and examples." )) @@ -560,6 +602,20 @@ mod tests { ); } + #[test] + fn misspelled_vim_text_object_action_is_rejected() { + let toml_input = r#" + [vim_text_object] + double_quotes = "shift-quote" + "#; + let err = toml::from_str::(toml_input) + .expect_err("expected unknown vim text object action"); + assert!( + err.to_string().contains("double_quotes"), + "expected error to mention misspelled field, got: {err}" + ); + } + #[test] fn removed_backtrack_actions_are_rejected() { for (context, action) in [ @@ -621,4 +677,11 @@ mod tests { assert_eq!(keymap, expected_keymap); } } + + #[test] + fn function_keys_through_f24_are_accepted() { + assert_eq!(normalize_keybinding_spec("F13"), Ok("f13".to_string())); + assert_eq!(normalize_keybinding_spec("f24"), Ok("f24".to_string())); + assert!(normalize_keybinding_spec("f25").is_err()); + } } diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index c7d0a5876b..598b7fdee7 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -31,6 +31,7 @@ use serde::Serialize; pub use crate::tui_keymap::KeybindingSpec; pub use crate::tui_keymap::KeybindingsSpec; +pub use crate::tui_keymap::MAX_FUNCTION_KEY; pub use crate::tui_keymap::TuiApprovalKeymap; pub use crate::tui_keymap::TuiChatKeymap; pub use crate::tui_keymap::TuiComposerKeymap; @@ -266,6 +267,8 @@ pub struct MemoriesToml { pub generate_memories: Option, /// When `false`, skip injecting memory usage instructions into developer prompts. pub use_memories: Option, + /// When `true`, expose dedicated memory tools through the extension tool surface. + pub dedicated_tools: Option, /// Maximum number of recent raw memories retained for global consolidation. #[schemars(range(min = 1, max = 4096))] pub max_raw_memories_for_consolidation: Option, @@ -293,6 +296,7 @@ pub struct MemoriesConfig { pub disable_on_external_context: bool, pub generate_memories: bool, pub use_memories: bool, + pub dedicated_tools: bool, pub max_raw_memories_for_consolidation: usize, pub max_unused_days: i64, pub max_rollout_age_days: i64, @@ -309,6 +313,7 @@ impl Default for MemoriesConfig { disable_on_external_context: false, generate_memories: true, use_memories: true, + dedicated_tools: false, max_raw_memories_for_consolidation: DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_CONSOLIDATION, max_unused_days: DEFAULT_MEMORIES_MAX_UNUSED_DAYS, max_rollout_age_days: DEFAULT_MEMORIES_MAX_ROLLOUT_AGE_DAYS, @@ -330,6 +335,7 @@ impl From for MemoriesConfig { .unwrap_or(defaults.disable_on_external_context), generate_memories: toml.generate_memories.unwrap_or(defaults.generate_memories), use_memories: toml.use_memories.unwrap_or(defaults.use_memories), + dedicated_tools: toml.dedicated_tools.unwrap_or(defaults.dedicated_tools), max_raw_memories_for_consolidation: toml .max_raw_memories_for_consolidation .unwrap_or(defaults.max_raw_memories_for_consolidation) diff --git a/codex-rs/connectors/src/metadata.rs b/codex-rs/connectors/src/metadata.rs index 64becb766d..0a7eebe4e4 100644 --- a/codex-rs/connectors/src/metadata.rs +++ b/codex-rs/connectors/src/metadata.rs @@ -5,7 +5,11 @@ pub fn connector_display_label(connector: &AppInfo) -> String { } pub fn connector_mention_slug(connector: &AppInfo) -> String { - crate::connector_name_slug(&connector_display_label(connector)) + connector_mention_slug_from_name(&connector_display_label(connector)) +} + +pub fn connector_mention_slug_from_name(name: &str) -> String { + crate::connector_name_slug(name) } pub fn connector_install_url(name: &str, connector_id: &str) -> String { diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index 04ebaf8e7e..e87ee82f30 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -58,6 +58,7 @@ pub use codex_models_manager::manager::SharedModelsManager; pub use codex_protocol::ThreadId; pub use codex_protocol::config_types::AltScreenMode; pub use codex_protocol::config_types::ApprovalsReviewer; +pub use codex_protocol::config_types::AutoCompactTokenLimitScope; pub use codex_protocol::config_types::CollaborationModeMask; pub use codex_protocol::config_types::ShellEnvironmentPolicy; pub use codex_protocol::config_types::WebSearchMode; diff --git a/codex-rs/core-plugins/Cargo.toml b/codex-rs/core-plugins/Cargo.toml index 352d6e5714..d0096e4455 100644 --- a/codex-rs/core-plugins/Cargo.toml +++ b/codex-rs/core-plugins/Cargo.toml @@ -31,7 +31,9 @@ codex-utils-plugins = { workspace = true } chrono = { workspace = true } dirs = { workspace = true } flate2 = { workspace = true } +indexmap = { workspace = true, features = ["serde"] } reqwest = { workspace = true } +semver = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tar = { workspace = true } diff --git a/codex-rs/core-plugins/src/lib.rs b/codex-rs/core-plugins/src/lib.rs index b724699039..4e59162e34 100644 --- a/codex-rs/core-plugins/src/lib.rs +++ b/codex-rs/core-plugins/src/lib.rs @@ -6,6 +6,7 @@ pub mod marketplace; pub mod marketplace_add; pub mod marketplace_remove; pub mod marketplace_upgrade; +mod plugin_bundle_archive; pub mod remote; pub mod remote_bundle; pub mod remote_legacy; diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index fc4193aa67..d51771085d 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -5,6 +5,7 @@ use crate::manifest::load_plugin_manifest; use crate::marketplace::MarketplacePluginSource; use crate::marketplace::list_marketplaces; use crate::marketplace::load_marketplace; +use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; use crate::remote::RemoteInstalledPlugin; use crate::store::PluginStore; use crate::store::plugin_version_for_source; @@ -32,6 +33,7 @@ use codex_protocol::protocol::Product; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_plugins::find_plugin_manifest_path; +use indexmap::IndexMap; use serde::Deserialize; use serde_json::Map as JsonMap; use serde_json::Value as JsonValue; @@ -99,7 +101,7 @@ impl PluginMcpFile { #[serde(rename_all = "camelCase")] struct PluginAppFile { #[serde(default)] - apps: HashMap, + apps: IndexMap, } #[derive(Debug, Default, Deserialize)] @@ -112,11 +114,15 @@ pub async fn load_plugins_from_layer_stack( extra_plugins: HashMap, store: &PluginStore, restriction_product: Option, - plugin_hooks_enabled: bool, + prefer_remote_curated_conflicts: bool, ) -> PluginLoadOutcome { let skill_config_rules = skill_config_rules_from_stack(config_layer_stack); - let mut configured_plugins = configured_plugins_from_stack(config_layer_stack); - configured_plugins.extend(extra_plugins); + let configured_plugins = merge_configured_plugins_with_remote_installed( + configured_plugins_from_stack(config_layer_stack), + extra_plugins, + store, + prefer_remote_curated_conflicts, + ); let mut configured_plugins: Vec<_> = configured_plugins.into_iter().collect(); configured_plugins.sort_unstable_by(|(a, _), (b, _)| a.cmp(b)); @@ -129,7 +135,6 @@ pub async fn load_plugins_from_layer_stack( store, restriction_product, &skill_config_rules, - plugin_hooks_enabled, ) .await; for name in loaded_plugin.mcp_servers.keys() { @@ -150,6 +155,61 @@ pub async fn load_plugins_from_layer_stack( PluginLoadOutcome::from_plugins(plugins) } +fn merge_configured_plugins_with_remote_installed( + mut configured_plugins: HashMap, + extra_plugins: HashMap, + store: &PluginStore, + prefer_remote_curated_conflicts: bool, +) -> HashMap { + let local_curated_installed_plugin_keys = configured_plugins + .keys() + .filter_map(|plugin_key| { + installed_plugin_name_for_marketplace( + plugin_key, + OPENAI_CURATED_MARKETPLACE_NAME, + store, + ) + .map(|plugin_name| (plugin_name, plugin_key.clone())) + }) + .collect::>(); + + for (plugin_key, plugin_config) in extra_plugins { + let remote_curated_plugin_name = installed_plugin_name_for_marketplace( + &plugin_key, + REMOTE_GLOBAL_MARKETPLACE_NAME, + store, + ); + let local_curated_plugin_key = remote_curated_plugin_name + .as_ref() + .and_then(|plugin_name| local_curated_installed_plugin_keys.get(plugin_name)); + + if let Some(local_curated_plugin_key) = local_curated_plugin_key { + if prefer_remote_curated_conflicts { + configured_plugins.remove(local_curated_plugin_key); + } else { + continue; + } + } + + configured_plugins.insert(plugin_key, plugin_config); + } + + configured_plugins +} + +fn installed_plugin_name_for_marketplace( + plugin_key: &str, + marketplace_name: &str, + store: &PluginStore, +) -> Option { + let plugin_id = PluginId::parse(plugin_key).ok()?; + if plugin_id.marketplace_name != marketplace_name { + return None; + } + store.active_plugin_root(&plugin_id)?; + Some(plugin_id.plugin_name) +} + pub fn remote_installed_plugins_to_config( plugins: &[RemoteInstalledPlugin], store: &PluginStore, @@ -499,7 +559,6 @@ async fn load_plugin( store: &PluginStore, restriction_product: Option, skill_config_rules: &SkillConfigRules, - plugin_hooks_enabled: bool, ) -> LoadedPlugin { let plugin_id = PluginId::parse(&config_name); let active_plugin_root = plugin_id @@ -597,16 +656,14 @@ async fn load_plugin( } loaded_plugin.mcp_servers = mcp_servers; loaded_plugin.apps = load_plugin_apps(plugin_root.as_path()).await; - if plugin_hooks_enabled { - let (hook_sources, hook_load_warnings) = load_plugin_hooks( - &plugin_root, - &loaded_plugin_id, - &store.plugin_data_root(&loaded_plugin_id), - manifest_paths, - ); - loaded_plugin.hook_sources = hook_sources; - loaded_plugin.hook_load_warnings = hook_load_warnings; - } + let (hook_sources, hook_load_warnings) = load_plugin_hooks( + &plugin_root, + &loaded_plugin_id, + &store.plugin_data_root(&loaded_plugin_id), + manifest_paths, + ); + loaded_plugin.hook_sources = hook_sources; + loaded_plugin.hook_load_warnings = hook_load_warnings; loaded_plugin } @@ -660,6 +717,7 @@ pub async fn load_plugin_skills( scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: Some(plugin_id.as_key()), + plugin_root: Some(plugin_root.clone()), }) .collect::>(); let outcome = load_skills_from_roots(roots).await; @@ -887,10 +945,7 @@ async fn load_apps_from_paths( } }; - let mut apps: Vec = parsed.apps.into_values().collect(); - apps.sort_unstable_by(|left, right| left.id.cmp(&right.id)); - - connector_ids.extend(apps.into_iter().filter_map(|app| { + connector_ids.extend(parsed.apps.into_values().filter_map(|app| { if app.id.trim().is_empty() { warn!( plugin = %plugin_root.display(), @@ -902,7 +957,8 @@ async fn load_apps_from_paths( } })); } - connector_ids.dedup(); + let mut seen_connector_ids = HashSet::new(); + connector_ids.retain(|connector_id| seen_connector_ids.insert(connector_id.0.clone())); connector_ids } diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index f6d5a6d88a..ff596273f9 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -22,6 +22,7 @@ use crate::manifest::load_plugin_manifest; use crate::marketplace::MarketplaceError; use crate::marketplace::MarketplaceInterface; use crate::marketplace::MarketplaceListError; +use crate::marketplace::MarketplaceListOutcome; use crate::marketplace::MarketplacePluginAuthPolicy; use crate::marketplace::MarketplacePluginPolicy; use crate::marketplace::MarketplacePluginSource; @@ -37,6 +38,7 @@ use crate::marketplace_upgrade::configured_git_marketplace_names; use crate::marketplace_upgrade::upgrade_configured_git_marketplaces; use crate::remote::RemoteInstalledPlugin; use crate::remote::RemotePluginCatalogError; +use crate::remote::RemotePluginScope; use crate::remote::RemotePluginServiceConfig; use crate::remote_legacy::RemotePluginFetchError; use crate::remote_legacy::RemotePluginMutationError; @@ -88,7 +90,6 @@ pub struct PluginsConfigInput { pub config_layer_stack: ConfigLayerStack, pub plugins_enabled: bool, pub remote_plugin_enabled: bool, - pub plugin_hooks_enabled: bool, pub chatgpt_base_url: String, } @@ -97,14 +98,12 @@ impl PluginsConfigInput { config_layer_stack: ConfigLayerStack, plugins_enabled: bool, remote_plugin_enabled: bool, - plugin_hooks_enabled: bool, chatgpt_base_url: String, ) -> Self { Self { config_layer_stack, plugins_enabled, remote_plugin_enabled, - plugin_hooks_enabled, chatgpt_base_url, } } @@ -262,6 +261,7 @@ pub struct ConfiguredMarketplacePlugin { pub id: String, pub name: String, pub local_version: Option, + pub installed_version: Option, pub source: MarketplacePluginSource, pub policy: MarketplacePluginPolicy, pub interface: Option, @@ -412,7 +412,6 @@ pub struct PluginsManager { #[derive(Clone)] struct CachedPluginLoadOutcome { config_version: String, - plugin_hooks_enabled: bool, outcome: PluginLoadOutcome, } @@ -483,12 +482,8 @@ impl PluginsManager { return PluginLoadOutcome::default(); } - let plugin_hooks_enabled = config.plugin_hooks_enabled; let config_version = version_for_toml(&config.config_layer_stack.effective_config()); - if !force_reload - && let Some(outcome) = - self.cached_enabled_outcome(&config_version, plugin_hooks_enabled) - { + if !force_reload && let Some(outcome) = self.cached_enabled_outcome(&config_version) { return outcome; } @@ -497,7 +492,7 @@ impl PluginsManager { self.remote_installed_plugin_configs(), &self.store, self.restriction_product, - plugin_hooks_enabled, + config.remote_plugin_enabled, ) .await; log_plugin_load_errors(&outcome); @@ -507,7 +502,6 @@ impl PluginsManager { }; *cache = Some(CachedPluginLoadOutcome { config_version, - plugin_hooks_enabled, outcome: outcome.clone(), }); outcome @@ -535,7 +529,6 @@ impl PluginsManager { &self, config_layer_stack: &ConfigLayerStack, config: &PluginsConfigInput, - plugin_hooks_feature_enabled: bool, ) -> PluginLoadOutcome { if !config.plugins_enabled { return PluginLoadOutcome::default(); @@ -545,7 +538,7 @@ impl PluginsManager { self.remote_installed_plugin_configs(), &self.store, self.restriction_product, - plugin_hooks_feature_enabled, + config.remote_plugin_enabled, ) .await } @@ -556,31 +549,21 @@ impl PluginsManager { config_layer_stack: &ConfigLayerStack, config: &PluginsConfigInput, ) -> Vec { - self.plugins_for_layer_stack(config_layer_stack, config, config.plugin_hooks_enabled) + self.plugins_for_layer_stack(config_layer_stack, config) .await .effective_plugin_skill_roots() } - fn cached_enabled_outcome( - &self, - config_version: &str, - plugin_hooks_enabled: bool, - ) -> Option { + fn cached_enabled_outcome(&self, config_version: &str) -> Option { match self.cached_enabled_outcome.read() { Ok(cache) => cache .as_ref() - .filter(|cached| { - cached.config_version == config_version - && cached.plugin_hooks_enabled == plugin_hooks_enabled - }) + .filter(|cached| cached.config_version == config_version) .map(|cached| cached.outcome.clone()), Err(err) => err .into_inner() .as_ref() - .filter(|cached| { - cached.config_version == config_version - && cached.plugin_hooks_enabled == plugin_hooks_enabled - }) + .filter(|cached| cached.config_version == config_version) .map(|cached| cached.outcome.clone()), } } @@ -597,6 +580,39 @@ impl PluginsManager { remote_installed_plugins_to_config(plugins, &self.store) } + pub fn build_remote_installed_plugin_marketplaces_from_cache( + &self, + visible_scopes: &[RemotePluginScope], + ) -> Option> { + let cache = match self.remote_installed_plugins_cache.read() { + Ok(cache) => cache, + Err(err) => err.into_inner(), + }; + let plugins = cache.as_ref()?; + Some(crate::remote::group_remote_installed_plugins_by_marketplaces(plugins, visible_scopes)) + } + + pub async fn build_and_cache_remote_installed_plugin_marketplaces( + &self, + config: &PluginsConfigInput, + auth: Option<&CodexAuth>, + visible_scopes: &[RemotePluginScope], + on_effective_plugins_changed: Option>, + ) -> Result, RemotePluginCatalogError> { + let plugins = crate::remote::fetch_remote_installed_plugins( + &remote_plugin_service_config(config), + auth, + ) + .await?; + let marketplaces = + crate::remote::group_remote_installed_plugins_by_marketplaces(&plugins, visible_scopes); + let changed = self.write_remote_installed_plugins_cache(plugins); + if changed && let Some(on_effective_plugins_changed) = on_effective_plugins_changed { + on_effective_plugins_changed(); + } + Ok(marketplaces) + } + fn write_remote_installed_plugins_cache(&self, plugins: Vec) -> bool { let mut cache = match self.remote_installed_plugins_cache.write() { Ok(cache) => cache, @@ -674,7 +690,7 @@ impl PluginsManager { ); } - fn maybe_start_remote_installed_plugin_bundle_sync( + pub fn maybe_start_remote_installed_plugin_bundle_sync( self: &Arc, config: &PluginsConfigInput, auth: Option, @@ -1173,7 +1189,7 @@ impl PluginsManager { let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config); let marketplace_outcome = - list_marketplaces(&self.marketplace_roots(config, additional_roots))?; + self.discover_marketplaces_for_config(config, additional_roots)?; let mut seen_plugin_keys = HashSet::new(); let marketplaces = marketplace_outcome .marketplaces @@ -1191,15 +1207,21 @@ impl PluginsManager { if !self.restriction_product_matches(plugin.policy.products.as_deref()) { return None; } + let plugin_id = + PluginId::new(plugin.name.clone(), marketplace_name.clone()).ok(); let installed = installed_plugins.contains(&plugin_key); + let installed_version = installed.then_some(()).and_then(|_| { + plugin_id + .as_ref() + .and_then(|plugin_id| self.store.active_plugin_version(plugin_id)) + }); let enabled = enabled_plugins.contains(&plugin_key); let mut interface = plugin.interface; let mut local_version = plugin.local_version; if installed && matches!(&plugin.source, MarketplacePluginSource::Git { .. }) - && let Ok(plugin_id) = - PluginId::new(plugin.name.clone(), marketplace_name.clone()) - && let Some(plugin_root) = self.store.active_plugin_root(&plugin_id) + && let Some(plugin_id) = plugin_id.as_ref() + && let Some(plugin_root) = self.store.active_plugin_root(plugin_id) && let Some(manifest) = load_plugin_manifest(plugin_root.as_path()) { local_version = manifest.version.clone(); @@ -1217,6 +1239,7 @@ impl PluginsManager { // plugin entries from duplicate marketplace files intentionally // resolve to the first discovered source. id: plugin_key, + installed_version, installed, enabled, name: plugin.name, @@ -1244,6 +1267,18 @@ impl PluginsManager { }) } + pub fn discover_marketplaces_for_config( + &self, + config: &PluginsConfigInput, + additional_roots: &[AbsolutePathBuf], + ) -> Result { + if !config.plugins_enabled { + return Ok(MarketplaceListOutcome::default()); + } + + list_marketplaces(&self.marketplace_roots(config, additional_roots)) + } + pub async fn read_plugin_for_config( &self, config: &PluginsConfigInput, @@ -1264,6 +1299,12 @@ impl PluginsManager { let marketplace_name = plugin.plugin_id.marketplace_name.clone(); let plugin_key = plugin.plugin_id.as_key(); let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config); + let installed = installed_plugins.contains(&plugin_key); + let installed_version = if installed { + self.store.active_plugin_version(&plugin.plugin_id) + } else { + None + }; let plugin = self .read_plugin_detail_for_marketplace_plugin( config, @@ -1275,6 +1316,7 @@ impl PluginsManager { .manifest .as_ref() .and_then(|manifest| manifest.version.clone()), + installed_version, source: plugin.source, policy: plugin.policy, interface: plugin.interface, @@ -1283,7 +1325,7 @@ impl PluginsManager { .as_ref() .map(|manifest| manifest.keywords.clone()) .unwrap_or_default(), - installed: installed_plugins.contains(&plugin_key), + installed, enabled: enabled_plugins.contains(&plugin_key), }, ) @@ -1389,20 +1431,16 @@ impl PluginsManager { ), ) .await; - let hooks = if config.plugin_hooks_enabled { - let plugin_data_root = self.store.plugin_data_root(&plugin_id); - let (hook_sources, _hook_load_warnings) = - load_plugin_hooks(&source_path, &plugin_id, &plugin_data_root, &manifest.paths); - plugin_hook_declarations(&hook_sources) - .into_iter() - .map(|hook| PluginHookSummary { - key: hook.key, - event_name: hook.event_name, - }) - .collect() - } else { - Vec::new() - }; + let plugin_data_root = self.store.plugin_data_root(&plugin_id); + let (hook_sources, _hook_load_warnings) = + load_plugin_hooks(&source_path, &plugin_id, &plugin_data_root, &manifest.paths); + let hooks = plugin_hook_declarations(&hook_sources) + .into_iter() + .map(|hook| PluginHookSummary { + key: hook.key, + event_name: hook.event_name, + }) + .collect(); let apps = load_plugin_apps(source_path.as_path()).await; let mut mcp_server_names = load_plugin_mcp_servers(source_path.as_path()) .await diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 449c87b186..f4b6c49da0 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -7,6 +7,7 @@ use crate::loader::refresh_non_curated_plugin_cache; use crate::loader::refresh_non_curated_plugin_cache_force_reinstall; use crate::marketplace::MarketplacePluginInstallPolicy; use crate::remote::RemoteInstalledPlugin; +use crate::remote::RemotePluginScope; use crate::startup_sync::curated_plugins_repo_path; use crate::test_support::TEST_CURATED_PLUGIN_CACHE_VERSION; use crate::test_support::TEST_CURATED_PLUGIN_SHA; @@ -100,18 +101,6 @@ fn run_git(repo: &Path, args: &[&str]) { } fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String { - plugin_config_toml_with_plugin_hooks( - enabled, - plugins_feature_enabled, - /*plugin_hooks_feature_enabled*/ false, - ) -} - -fn plugin_config_toml_with_plugin_hooks( - enabled: bool, - plugins_feature_enabled: bool, - plugin_hooks_feature_enabled: bool, -) -> String { let mut root = toml::map::Map::new(); let mut features = toml::map::Map::new(); @@ -119,10 +108,6 @@ fn plugin_config_toml_with_plugin_hooks( "plugins".to_string(), Value::Boolean(plugins_feature_enabled), ); - features.insert( - "plugin_hooks".to_string(), - Value::Boolean(plugin_hooks_feature_enabled), - ); root.insert("features".to_string(), Value::Table(features)); let mut plugin = toml::map::Map::new(); @@ -147,6 +132,36 @@ async fn load_config(codex_home: &Path, cwd: &Path) -> PluginsConfigInput { load_plugins_config_input(codex_home, cwd).await } +fn remote_installed_linear_plugin() -> RemoteInstalledPlugin { + remote_installed_plugin("linear") +} + +fn remote_installed_plugin(name: &str) -> RemoteInstalledPlugin { + RemoteInstalledPlugin { + marketplace_name: "openai-curated-remote".to_string(), + id: format!("plugins~Plugin_{name}"), + name: name.to_string(), + enabled: true, + install_policy: codex_app_server_protocol::PluginInstallPolicy::Available, + auth_policy: codex_app_server_protocol::PluginAuthPolicy::OnUse, + availability: codex_app_server_protocol::PluginAvailability::Available, + interface: None, + keywords: Vec::new(), + } +} + +fn write_cached_plugin(codex_home: &Path, marketplace_name: &str, plugin_name: &str) { + write_plugin_with_version( + &codex_home + .join("plugins/cache") + .join(marketplace_name) + .join(plugin_name), + "local", + plugin_name, + /*manifest_version*/ Some("local"), + ); +} + #[tokio::test] async fn load_plugins_loads_default_skills_and_mcp_servers() { let codex_home = TempDir::new().unwrap(); @@ -220,7 +235,7 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() { http_headers: None, env_http_headers: None, }, - experimental_environment: None, + environment_id: "local".to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, @@ -334,38 +349,6 @@ approval_mode = "approve" ); } -#[tokio::test] -async fn remote_installed_cache_adds_plugin_skill_roots_without_remote_plugin_flag() { - let codex_home = TempDir::new().unwrap(); - let plugin_base = codex_home - .path() - .join("plugins/cache/chatgpt-global/linear"); - write_plugin(&plugin_base, "local", "linear"); - write_file( - &codex_home.path().join(CONFIG_TOML_FILE), - r#"[features] -plugins = true -"#, - ); - - let config = load_config(codex_home.path(), codex_home.path()).await; - let manager = PluginsManager::new(codex_home.path().to_path_buf()); - manager.write_remote_installed_plugins_cache(vec![RemoteInstalledPlugin { - marketplace_name: "chatgpt-global".to_string(), - id: "plugins~Plugin_linear".to_string(), - name: "linear".to_string(), - enabled: true, - }]); - - let outcome = manager.plugins_for_config(&config).await; - assert_eq!( - outcome.effective_skill_roots(), - vec![AbsolutePathBuf::try_from(plugin_base.join("local/skills")).unwrap()] - ); - assert_eq!(outcome.plugins().len(), 1); - assert_eq!(outcome.plugins()[0].config_name, "linear@chatgpt-global"); -} - #[tokio::test] async fn remote_installed_cache_ignores_plugins_missing_local_cache() { let codex_home = TempDir::new().unwrap(); @@ -379,17 +362,171 @@ remote_plugin = true let config = load_config(codex_home.path(), codex_home.path()).await; let manager = PluginsManager::new(codex_home.path().to_path_buf()); - manager.write_remote_installed_plugins_cache(vec![RemoteInstalledPlugin { - marketplace_name: "chatgpt-global".to_string(), - id: "plugins~Plugin_linear".to_string(), - name: "linear".to_string(), - enabled: true, - }]); + manager.write_remote_installed_plugins_cache(vec![remote_installed_linear_plugin()]); let outcome = manager.plugins_for_config(&config).await; assert_eq!(outcome, PluginLoadOutcome::default()); } +#[tokio::test] +async fn remote_installed_cache_prefers_local_curated_conflicts_when_remote_plugin_disabled() { + let codex_home = TempDir::new().unwrap(); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +remote_plugin = false + +[plugins."linear@openai-curated"] +enabled = true + +[plugins."calendar@openai-curated"] +enabled = true +"#, + ); + write_cached_plugin(codex_home.path(), "openai-curated", "linear"); + write_cached_plugin(codex_home.path(), "openai-curated", "calendar"); + write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear"); + write_cached_plugin(codex_home.path(), "openai-curated-remote", "remote-only"); + + let config = load_config(codex_home.path(), codex_home.path()).await; + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + manager.write_remote_installed_plugins_cache(vec![ + remote_installed_plugin("linear"), + remote_installed_plugin("remote-only"), + ]); + + let outcome = manager.plugins_for_config(&config).await; + assert_eq!( + outcome + .plugins() + .iter() + .map(|plugin| plugin.config_name.clone()) + .collect::>(), + vec![ + "calendar@openai-curated".to_string(), + "linear@openai-curated".to_string(), + "remote-only@openai-curated-remote".to_string(), + ] + ); +} + +#[tokio::test] +async fn remote_installed_cache_prefers_remote_curated_conflicts_when_remote_plugin_enabled() { + let codex_home = TempDir::new().unwrap(); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +remote_plugin = true + +[plugins."linear@openai-curated"] +enabled = true + +[plugins."calendar@openai-curated"] +enabled = true +"#, + ); + write_cached_plugin(codex_home.path(), "openai-curated", "linear"); + write_cached_plugin(codex_home.path(), "openai-curated", "calendar"); + write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear"); + write_cached_plugin(codex_home.path(), "openai-curated-remote", "remote-only"); + + let config = load_config(codex_home.path(), codex_home.path()).await; + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + manager.write_remote_installed_plugins_cache(vec![ + remote_installed_plugin("linear"), + remote_installed_plugin("remote-only"), + ]); + + let outcome = manager.plugins_for_config(&config).await; + assert_eq!( + outcome + .plugins() + .iter() + .map(|plugin| plugin.config_name.clone()) + .collect::>(), + vec![ + "calendar@openai-curated".to_string(), + "linear@openai-curated-remote".to_string(), + "remote-only@openai-curated-remote".to_string(), + ] + ); +} + +#[tokio::test] +async fn build_remote_installed_plugin_marketplaces_from_cache_uses_remote_metadata() { + let codex_home = TempDir::new().unwrap(); + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + let mut plugin = remote_installed_linear_plugin(); + plugin.install_policy = codex_app_server_protocol::PluginInstallPolicy::InstalledByDefault; + plugin.auth_policy = codex_app_server_protocol::PluginAuthPolicy::OnInstall; + plugin.interface = Some(codex_app_server_protocol::PluginInterface { + display_name: Some("Linear".to_string()), + short_description: Some("Track remote work".to_string()), + long_description: None, + developer_name: None, + category: None, + capabilities: Vec::new(), + website_url: None, + privacy_policy_url: None, + terms_of_service_url: None, + default_prompt: None, + brand_color: Some("#111111".to_string()), + composer_icon: None, + composer_icon_url: None, + logo: None, + logo_url: None, + screenshots: Vec::new(), + screenshot_urls: Vec::new(), + }); + plugin.keywords = vec!["issues".to_string()]; + manager.write_remote_installed_plugins_cache(vec![plugin]); + + let marketplaces = manager + .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, "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@openai-curated-remote"); + assert_eq!(plugin.remote_plugin_id, "plugins~Plugin_linear"); + assert_eq!(plugin.name, "linear"); + assert_eq!(plugin.installed, true); + assert_eq!(plugin.enabled, true); + assert_eq!( + plugin.install_policy, + codex_app_server_protocol::PluginInstallPolicy::InstalledByDefault + ); + assert_eq!( + plugin.auth_policy, + codex_app_server_protocol::PluginAuthPolicy::OnInstall + ); + assert_eq!(plugin.keywords, vec!["issues".to_string()]); + assert_eq!( + plugin + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()), + Some("Linear") + ); + assert_eq!( + plugin + .interface + .as_ref() + .and_then(|interface| interface.short_description.as_deref()), + Some("Track remote work") + ); + assert_eq!( + manager + .build_remote_installed_plugin_marketplaces_from_cache(&[RemotePluginScope::Workspace]) + .expect("remote installed cache should be present"), + Vec::new() + ); +} + #[tokio::test] async fn load_plugins_resolves_disabled_skill_names_against_loaded_plugin_skills() { let codex_home = TempDir::new().unwrap(); @@ -687,7 +824,7 @@ async fn load_plugins_uses_manifest_configured_component_paths() { http_headers: None, env_http_headers: None, }, - experimental_environment: None, + environment_id: "local".to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, @@ -799,7 +936,7 @@ async fn load_plugins_ignores_manifest_component_paths_without_dot_slash() { http_headers: None, env_http_headers: None, }, - experimental_environment: None, + environment_id: "local".to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, @@ -950,6 +1087,50 @@ async fn effective_apps_dedupes_connector_ids_across_plugins() { ); } +#[tokio::test] +async fn effective_apps_preserves_app_config_order() { + let codex_home = TempDir::new().unwrap(); + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ); + write_file( + &plugin_root.join(".app.json"), + r#"{ + "apps": { + "slack": { + "id": "connector_slack" + }, + "github": { + "id": "connector_github" + }, + "slack-copy": { + "id": "connector_slack" + } + } +}"#, + ); + + let outcome = load_plugins_from_config( + &plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true), + codex_home.path(), + ) + .await; + + assert_eq!( + outcome.effective_apps(), + vec![ + AppConnectorId("connector_slack".to_string()), + AppConnectorId("connector_github".to_string()), + ] + ); +} + #[test] fn capability_index_filters_inactive_and_zero_capability_plugins() { let codex_home = TempDir::new().unwrap(); @@ -961,7 +1142,7 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() { http_headers: None, env_http_headers: None, }, - experimental_environment: None, + environment_id: "local".to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, @@ -1082,61 +1263,6 @@ async fn load_plugins_returns_empty_when_feature_disabled() { assert_eq!(outcome, PluginLoadOutcome::default()); } -#[tokio::test] -async fn plugins_for_config_reloads_when_plugin_hooks_enablement_changes() { - let codex_home = TempDir::new().unwrap(); - let plugin_root = codex_home - .path() - .join("plugins/cache") - .join("test/sample/local"); - - write_file( - &plugin_root.join(".codex-plugin/plugin.json"), - r#"{"name":"sample"}"#, - ); - write_file( - &plugin_root.join("hooks/hooks.json"), - r#"{ - "hooks": { - "PreToolUse": [ - { - "hooks": [{ "type": "command", "command": "echo plugin hook" }] - } - ] - } -}"#, - ); - - let manager = PluginsManager::new(codex_home.path().to_path_buf()); - write_file( - &codex_home.path().join(CONFIG_TOML_FILE), - &plugin_config_toml_with_plugin_hooks( - /*enabled*/ true, /*plugins_feature_enabled*/ true, - /*plugin_hooks_feature_enabled*/ false, - ), - ); - let config_without_plugin_hooks = load_config(codex_home.path(), codex_home.path()).await; - let without_plugin_hooks = manager - .plugins_for_config(&config_without_plugin_hooks) - .await; - assert!( - without_plugin_hooks - .effective_plugin_hook_sources() - .is_empty() - ); - - write_file( - &codex_home.path().join(CONFIG_TOML_FILE), - &plugin_config_toml_with_plugin_hooks( - /*enabled*/ true, /*plugins_feature_enabled*/ true, - /*plugin_hooks_feature_enabled*/ true, - ), - ); - let config_with_plugin_hooks = load_config(codex_home.path(), codex_home.path()).await; - let with_plugin_hooks = manager.plugins_for_config(&config_with_plugin_hooks).await; - assert_eq!(with_plugin_hooks.effective_plugin_hook_sources().len(), 1); -} - #[tokio::test] async fn load_plugins_rejects_invalid_plugin_keys() { let codex_home = TempDir::new().unwrap(); @@ -1548,6 +1674,7 @@ enabled = false id: "enabled-plugin@debug".to_string(), name: "enabled-plugin".to_string(), local_version: None, + installed_version: Some("local".to_string()), source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(tmp.path().join("repo/enabled-plugin")) .unwrap(), @@ -1566,6 +1693,7 @@ enabled = false id: "disabled-plugin@debug".to_string(), name: "disabled-plugin".to_string(), local_version: None, + installed_version: Some("local".to_string()), source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(tmp.path().join("repo/disabled-plugin"),) .unwrap(), @@ -1687,6 +1815,7 @@ plugins = true id: "default-plugin@debug".to_string(), name: "default-plugin".to_string(), local_version: None, + installed_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(tmp.path().join("repo/default-plugin")).unwrap(), }, @@ -1978,7 +2107,6 @@ async fn read_plugin_for_config_installed_git_source_reads_from_cache_without_cl &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true -plugin_hooks = true [plugins."toolkit@debug"] enabled = true @@ -2119,6 +2247,7 @@ enabled = true id: "toolkit@debug".to_string(), name: "toolkit".to_string(), local_version: None, + installed_version: Some("local".to_string()), source: MarketplacePluginSource::Git { url: missing_remote_repo_url, path: Some("plugins/toolkit".to_string()), @@ -2236,6 +2365,7 @@ plugins = true id: "linear@openai-curated".to_string(), name: "linear".to_string(), local_version: None, + installed_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(curated_root.join("plugins/linear")).unwrap(), }, @@ -2531,6 +2661,7 @@ enabled = false id: "dup-plugin@debug".to_string(), name: "dup-plugin".to_string(), local_version: None, + installed_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(tmp.path().join("repo-a/from-a")).unwrap(), }, @@ -2562,6 +2693,7 @@ enabled = false id: "b-only-plugin@debug".to_string(), name: "b-only-plugin".to_string(), local_version: None, + installed_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(tmp.path().join("repo-b/from-b-only")).unwrap(), }, @@ -2647,6 +2779,7 @@ enabled = true id: "sample-plugin@debug".to_string(), name: "sample-plugin".to_string(), local_version: None, + installed_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(tmp.path().join("repo/sample-plugin")).unwrap(), }, @@ -3682,7 +3815,7 @@ async fn load_plugins_ignores_project_config_files() { std::collections::HashMap::new(), &PluginStore::new(codex_home.path().to_path_buf()), Some(Product::Codex), - /*plugin_hooks_enabled*/ false, + /*prefer_remote_curated_conflicts*/ false, ) .await; diff --git a/codex-rs/core-plugins/src/marketplace.rs b/codex-rs/core-plugins/src/marketplace.rs index d85cf2c16d..49925a673f 100644 --- a/codex-rs/core-plugins/src/marketplace.rs +++ b/codex-rs/core-plugins/src/marketplace.rs @@ -698,7 +698,8 @@ pub fn plugin_interface_with_marketplace_category( interface } -fn marketplace_root_dir( +#[doc(hidden)] +pub fn marketplace_root_dir( marketplace_path: &AbsolutePathBuf, ) -> Result { for relative_path in MARKETPLACE_MANIFEST_RELATIVE_PATHS { diff --git a/codex-rs/core-plugins/src/marketplace_upgrade.rs b/codex-rs/core-plugins/src/marketplace_upgrade.rs index 9dda8a9a3e..010694c8b3 100644 --- a/codex-rs/core-plugins/src/marketplace_upgrade.rs +++ b/codex-rs/core-plugins/src/marketplace_upgrade.rs @@ -6,6 +6,7 @@ use self::activation::installed_marketplace_metadata_matches; use self::activation::write_installed_marketplace_metadata; use self::git::clone_git_source; use self::git::git_remote_revision; +use crate::marketplace::find_marketplace_manifest_path; use crate::marketplace::validate_marketplace_root; use codex_config::CONFIG_TOML_FILE; use codex_config::ConfigLayerStack; @@ -176,9 +177,7 @@ fn upgrade_configured_git_marketplace( MARKETPLACE_UPGRADE_GIT_TIMEOUT, )?; let destination = install_root.join(&marketplace.name); - if destination - .join(".agents/plugins/marketplace.json") - .is_file() + if find_marketplace_manifest_path(&destination).is_some() && marketplace.last_revision.as_deref() == Some(remote_revision.as_str()) && installed_marketplace_metadata_matches(&destination, marketplace, &remote_revision) { diff --git a/codex-rs/core-plugins/src/plugin_bundle_archive.rs b/codex-rs/core-plugins/src/plugin_bundle_archive.rs new file mode 100644 index 0000000000..b29117d102 --- /dev/null +++ b/codex-rs/core-plugins/src/plugin_bundle_archive.rs @@ -0,0 +1,315 @@ +use flate2::Compression; +use flate2::read::GzDecoder; +use flate2::write::GzEncoder; +use std::fmt; +use std::fs; +use std::io; +use std::io::Read; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; +use tar::Archive; + +#[derive(Debug, thiserror::Error)] +pub(crate) enum PluginBundlePackError { + #[error("invalid plugin path `{path}`: {reason}")] + InvalidPluginPath { path: PathBuf, reason: String }, + + #[error("plugin archive would be {bytes} bytes, exceeding maximum size of {max_bytes} bytes")] + ArchiveTooLarge { bytes: usize, max_bytes: usize }, + + #[error("failed to archive plugin bundle: {source}")] + Io { + #[source] + source: io::Error, + }, +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum PluginBundleUnpackError { + #[error( + "plugin bundle extracted size would be {bytes} bytes, exceeding maximum total size of {max_bytes} bytes" + )] + ExtractedBundleTooLarge { bytes: u64, max_bytes: u64 }, + + #[error("{context}: {source}")] + Io { + context: &'static str, + #[source] + source: io::Error, + }, + + #[error("{0}")] + InvalidBundle(String), +} + +impl PluginBundleUnpackError { + fn io(context: &'static str, source: io::Error) -> Self { + Self::Io { context, source } + } +} + +pub(crate) fn pack_plugin_bundle_tar_gz( + plugin_path: &Path, + max_bytes: usize, +) -> Result, PluginBundlePackError> { + if !plugin_path.is_dir() { + return Err(PluginBundlePackError::InvalidPluginPath { + path: plugin_path.to_path_buf(), + reason: "expected a plugin directory".to_string(), + }); + } + if !plugin_path.join(".codex-plugin/plugin.json").is_file() { + return Err(PluginBundlePackError::InvalidPluginPath { + path: plugin_path.to_path_buf(), + reason: "missing .codex-plugin/plugin.json".to_string(), + }); + } + + let encoder = GzEncoder::new(SizeLimitedBuffer::new(max_bytes), Compression::default()); + let mut archive = tar::Builder::new(encoder); + append_plugin_tree(&mut archive, plugin_path, plugin_path).map_err(archive_io_error)?; + let encoder = archive.into_inner().map_err(archive_io_error)?; + encoder + .finish() + .map(SizeLimitedBuffer::into_inner) + .map_err(archive_io_error) +} + +fn append_plugin_tree( + archive: &mut tar::Builder, + plugin_root: &Path, + current: &Path, +) -> io::Result<()> { + let mut entries = fs::read_dir(current)?.collect::, io::Error>>()?; + entries.sort_by_key(fs::DirEntry::file_name); + for entry in entries { + let path = entry.path(); + let file_type = entry.file_type()?; + let relative_path = path.strip_prefix(plugin_root).map_err(|err| { + io::Error::other(format!( + "failed to compute plugin archive path for `{}`: {err}", + path.display() + )) + })?; + if file_type.is_dir() { + archive.append_dir(relative_path, &path)?; + append_plugin_tree(archive, plugin_root, &path)?; + } else if file_type.is_file() { + archive.append_path_with_name(&path, relative_path)?; + } else { + return Err(io::Error::other(format!( + "unsupported plugin archive entry type: {}", + path.display() + ))); + } + } + Ok(()) +} + +fn archive_io_error(source: io::Error) -> PluginBundlePackError { + if let Some(limit) = source + .get_ref() + .and_then(|err| err.downcast_ref::()) + { + return PluginBundlePackError::ArchiveTooLarge { + bytes: limit.bytes, + max_bytes: limit.max_bytes, + }; + } + + PluginBundlePackError::Io { source } +} + +pub(crate) fn unpack_plugin_bundle_tar_gz( + bytes: &[u8], + destination: &Path, + max_total_bytes: u64, +) -> Result<(), PluginBundleUnpackError> { + fs::create_dir_all(destination).map_err(|source| { + PluginBundleUnpackError::io( + "failed to create plugin bundle extraction directory", + source, + ) + })?; + + let archive = GzDecoder::new(std::io::Cursor::new(bytes)); + let mut archive = Archive::new(archive); + unpack_plugin_bundle_tar(&mut archive, destination, max_total_bytes) +} + +fn unpack_plugin_bundle_tar( + archive: &mut Archive, + destination: &Path, + max_total_bytes: u64, +) -> Result<(), PluginBundleUnpackError> { + let mut extracted_bytes = 0u64; + let entries = archive.entries().map_err(|source| { + PluginBundleUnpackError::io("failed to read plugin bundle tar", source) + })?; + for entry in entries { + let mut entry = entry.map_err(|source| { + PluginBundleUnpackError::io("failed to read plugin bundle tar entry", source) + })?; + let entry_type = entry.header().entry_type(); + let entry_size = entry.size(); + let entry_path = entry + .path() + .map_err(|source| { + PluginBundleUnpackError::io("failed to read plugin bundle tar entry path", source) + })? + .into_owned(); + let output_path = checked_tar_output_path(destination, &entry_path)?; + + if entry_type.is_dir() { + fs::create_dir_all(&output_path).map_err(|source| { + PluginBundleUnpackError::io("failed to create plugin bundle directory", source) + })?; + continue; + } + + if entry_type.is_file() { + enforce_total_extracted_size(entry_size, &mut extracted_bytes, max_total_bytes)?; + let Some(parent) = output_path.parent() else { + return Err(PluginBundleUnpackError::InvalidBundle(format!( + "plugin bundle output path has no parent: {}", + output_path.display() + ))); + }; + fs::create_dir_all(parent).map_err(|source| { + PluginBundleUnpackError::io("failed to create plugin bundle directory", source) + })?; + entry.unpack(&output_path).map_err(|source| { + PluginBundleUnpackError::io("failed to unpack plugin bundle entry", source) + })?; + continue; + } + + if entry_type.is_hard_link() || entry_type.is_symlink() { + return Err(PluginBundleUnpackError::InvalidBundle(format!( + "plugin bundle tar entry `{}` is a link", + entry_path.display() + ))); + } + + return Err(PluginBundleUnpackError::InvalidBundle(format!( + "plugin bundle tar entry `{}` has unsupported type {:?}", + entry_path.display(), + entry_type + ))); + } + + Ok(()) +} + +fn checked_tar_output_path( + destination: &Path, + entry_name: &Path, +) -> Result { + let mut output_path = destination.to_path_buf(); + let mut has_component = false; + for component in entry_name.components() { + match component { + std::path::Component::Normal(component) => { + has_component = true; + output_path.push(component); + } + std::path::Component::CurDir => {} + std::path::Component::ParentDir + | std::path::Component::RootDir + | std::path::Component::Prefix(_) => { + return Err(PluginBundleUnpackError::InvalidBundle(format!( + "plugin bundle tar entry `{}` escapes extraction root", + entry_name.display() + ))); + } + } + } + if !has_component { + return Err(PluginBundleUnpackError::InvalidBundle( + "plugin bundle tar entry has an empty path".to_string(), + )); + } + Ok(output_path) +} + +fn enforce_total_extracted_size( + entry_size: u64, + extracted_bytes: &mut u64, + max_total_bytes: u64, +) -> Result<(), PluginBundleUnpackError> { + let next_total = extracted_bytes.checked_add(entry_size).ok_or( + PluginBundleUnpackError::ExtractedBundleTooLarge { + bytes: u64::MAX, + max_bytes: max_total_bytes, + }, + )?; + if next_total > max_total_bytes { + return Err(PluginBundleUnpackError::ExtractedBundleTooLarge { + bytes: next_total, + max_bytes: max_total_bytes, + }); + } + *extracted_bytes = next_total; + Ok(()) +} + +struct SizeLimitedBuffer { + bytes: Vec, + max_bytes: usize, +} + +impl SizeLimitedBuffer { + fn new(max_bytes: usize) -> Self { + Self { + bytes: Vec::new(), + max_bytes, + } + } + + fn into_inner(self) -> Vec { + self.bytes + } +} + +impl Write for SizeLimitedBuffer { + fn write(&mut self, buf: &[u8]) -> io::Result { + let next_len = self.bytes.len().checked_add(buf.len()).ok_or_else(|| { + io::Error::other(ArchiveSizeLimitExceeded { + bytes: usize::MAX, + max_bytes: self.max_bytes, + }) + })?; + if next_len > self.max_bytes { + return Err(io::Error::other(ArchiveSizeLimitExceeded { + bytes: next_len, + max_bytes: self.max_bytes, + })); + } + + self.bytes.extend_from_slice(buf); + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +#[derive(Debug)] +struct ArchiveSizeLimitExceeded { + bytes: usize, + max_bytes: usize, +} + +impl fmt::Display for ArchiveSizeLimitExceeded { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "archive would be {} bytes, exceeding maximum size of {} bytes", + self.bytes, self.max_bytes + ) + } +} + +impl std::error::Error for ArchiveSizeLimitExceeded {} diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 4dde224039..16ae512931 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -12,6 +12,7 @@ use codex_plugin::PluginId; use codex_utils_absolute_path::AbsolutePathBuf; use reqwest::RequestBuilder; use serde::Deserialize; +use serde_json::Value as JsonValue; use std::collections::BTreeMap; use std::collections::BTreeSet; use std::collections::HashSet; @@ -27,7 +28,7 @@ pub use remote_installed_plugin_sync::RemoteInstalledPluginBundleSyncError; pub use remote_installed_plugin_sync::RemoteInstalledPluginBundleSyncOutcome; pub use remote_installed_plugin_sync::RemotePluginCacheMutationGuard; pub use remote_installed_plugin_sync::mark_remote_plugin_cache_mutation_in_flight; -pub use remote_installed_plugin_sync::maybe_start_remote_installed_plugin_bundle_sync; +pub(crate) use remote_installed_plugin_sync::maybe_start_remote_installed_plugin_bundle_sync; pub use remote_installed_plugin_sync::sync_remote_installed_plugin_bundles_once; pub use share::RemotePluginShareAccessPolicy; pub use share::RemotePluginShareDiscoverability; @@ -46,23 +47,46 @@ 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; const INVALID_REQUEST_ERROR_CODE: i64 = -32600; +const REMOTE_INSTALLED_MARKETPLACE_DISPLAY_ORDER: [(&str, &str); 5] = [ + ( + REMOTE_GLOBAL_MARKETPLACE_NAME, + REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME, + ), + ( + REMOTE_WORKSPACE_MARKETPLACE_NAME, + REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME, + ), + ( + REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME, + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_DISPLAY_NAME, + ), + ( + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME, + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_DISPLAY_NAME, + ), + ( + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME, + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_DISPLAY_NAME, + ), +]; #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemotePluginServiceConfig { @@ -89,6 +113,11 @@ pub struct RemoteInstalledPlugin { pub id: String, pub name: String, pub enabled: bool, + pub install_policy: PluginInstallPolicy, + pub auth_policy: PluginAuthPolicy, + pub availability: PluginAvailability, + pub interface: Option, + pub keywords: Vec, } #[derive(Debug, Clone, PartialEq)] @@ -131,6 +160,7 @@ pub struct RemotePluginDetail { pub description: Option, pub release_version: Option, pub bundle_download_url: Option, + pub app_manifest: Option, pub skills: Vec, pub app_ids: Vec, } @@ -264,7 +294,7 @@ pub enum RemotePluginCatalogError { } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)] -enum RemotePluginScope { +pub enum RemotePluginScope { #[serde(rename = "GLOBAL")] Global, #[serde(rename = "WORKSPACE")] @@ -364,6 +394,8 @@ struct RemotePluginReleaseResponse { #[serde(default)] app_ids: Vec, #[serde(default)] + app_manifest: Option, + #[serde(default)] keywords: Vec, interface: RemotePluginReleaseInterfaceResponse, #[serde(default)] @@ -568,6 +600,31 @@ pub async fn fetch_remote_marketplaces( Ok(marketplaces) } +pub async fn fetch_openai_curated_remote_collection_marketplace( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, +) -> Result, RemotePluginCatalogError> { + let auth = ensure_chatgpt_auth(auth)?; + let scope = RemotePluginScope::Global; + let (directory_plugins, installed_plugins) = tokio::try_join!( + fetch_directory_plugins_for_scope_with_collection( + config, + auth, + scope, + OPENAI_CURATED_REMOTE_COLLECTION_KEY, + ), + fetch_installed_plugins_for_scope(config, auth, scope), + )?; + + build_remote_marketplace( + REMOTE_GLOBAL_MARKETPLACE_NAME, + REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME, + directory_plugins, + installed_plugins, + /*include_installed_only*/ false, + ) +} + fn build_remote_marketplace( name: &str, display_name: &str, @@ -608,13 +665,7 @@ fn build_remote_marketplace( }) .map(|(plugin, installed_plugin)| build_remote_plugin_summary(plugin, installed_plugin)) .collect::, _>>()?; - plugins.sort_by(|left, right| { - remote_plugin_display_name(left) - .to_ascii_lowercase() - .cmp(&remote_plugin_display_name(right).to_ascii_lowercase()) - .then_with(|| remote_plugin_display_name(left).cmp(remote_plugin_display_name(right))) - .then_with(|| left.id.cmp(&right.id)) - }); + sort_remote_plugin_summaries_by_display_name(&mut plugins); Ok(Some(RemoteMarketplace { name: name.to_string(), display_name: display_name.to_string(), @@ -622,7 +673,7 @@ fn build_remote_marketplace( })) } -pub async fn fetch_remote_installed_plugins( +pub(crate) async fn fetch_remote_installed_plugins( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, ) -> Result, RemotePluginCatalogError> { @@ -642,7 +693,7 @@ pub async fn fetch_remote_installed_plugins( let mut installed_plugins = [global, workspace] .into_iter() .flat_map(|(_scope, plugins)| plugins) - .map(|plugin| remote_installed_plugin_to_info(&plugin)) + .map(|plugin| remote_installed_plugin_to_cache_entry(&plugin)) .collect::, _>>()?; installed_plugins.sort_by(|left, right| { left.marketplace_name @@ -652,6 +703,55 @@ pub async fn fetch_remote_installed_plugins( Ok(installed_plugins) } +pub fn group_remote_installed_plugins_by_marketplaces( + plugins: &[RemoteInstalledPlugin], + visible_scopes: &[RemotePluginScope], +) -> Vec { + let mut plugins_by_marketplace = BTreeMap::>::new(); + + for plugin in plugins { + if !RemotePluginScope::from_marketplace_name(&plugin.marketplace_name) + .is_some_and(|scope| visible_scopes.contains(&scope)) + { + continue; + } + let Ok(plugin_id) = PluginId::new(plugin.name.clone(), plugin.marketplace_name.clone()) + else { + continue; + }; + let plugin_summary = RemotePluginSummary { + id: plugin_id.as_key(), + remote_plugin_id: plugin.id.clone(), + name: plugin.name.clone(), + share_context: None, + installed: true, + enabled: plugin.enabled, + install_policy: plugin.install_policy, + auth_policy: plugin.auth_policy, + availability: plugin.availability, + interface: plugin.interface.clone(), + keywords: plugin.keywords.clone(), + }; + plugins_by_marketplace + .entry(plugin.marketplace_name.clone()) + .or_default() + .push(plugin_summary); + } + + REMOTE_INSTALLED_MARKETPLACE_DISPLAY_ORDER + .into_iter() + .filter_map(|(marketplace_name, display_name)| { + let mut marketplace_plugins = plugins_by_marketplace.remove(marketplace_name)?; + sort_remote_plugin_summaries_by_display_name(&mut marketplace_plugins); + Some(RemoteMarketplace { + name: marketplace_name.to_string(), + display_name: display_name.to_string(), + plugins: marketplace_plugins, + }) + }) + .collect() +} + pub async fn fetch_remote_plugin_detail( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, @@ -796,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, }) @@ -982,7 +1083,7 @@ fn remote_plugin_share_context( } } -fn remote_installed_plugin_to_info( +fn remote_installed_plugin_to_cache_entry( installed_plugin: &RemotePluginInstalledItem, ) -> Result { let plugin = &installed_plugin.plugin; @@ -994,6 +1095,11 @@ fn remote_installed_plugin_to_info( id: plugin.id.clone(), name: plugin.name.clone(), enabled: installed_plugin.enabled, + install_policy: plugin.installation_policy, + auth_policy: plugin.authentication_policy, + availability: plugin.availability, + interface: remote_plugin_interface_to_info(plugin), + keywords: plugin.release.keywords.clone(), }) } @@ -1068,6 +1174,18 @@ fn remote_plugin_display_name(plugin: &RemotePluginSummary) -> &str { .unwrap_or(&plugin.name) } +fn sort_remote_plugin_summaries_by_display_name(plugins: &mut [RemotePluginSummary]) { + plugins.sort_by(|left, right| { + let left_display_name = remote_plugin_display_name(left); + let right_display_name = remote_plugin_display_name(right); + left_display_name + .to_ascii_lowercase() + .cmp(&right_display_name.to_ascii_lowercase()) + .then_with(|| left_display_name.cmp(right_display_name)) + .then_with(|| left.id.cmp(&right.id)) + }); +} + fn non_empty_string(value: Option<&str>) -> Option { value.and_then(|value| { let value = value.trim(); @@ -1087,12 +1205,40 @@ async fn fetch_directory_plugins_for_scope( config: &RemotePluginServiceConfig, auth: &CodexAuth, scope: RemotePluginScope, +) -> Result, RemotePluginCatalogError> { + fetch_directory_plugins_for_scope_with_optional_collection( + config, auth, scope, /*collection*/ None, + ) + .await +} + +async fn fetch_directory_plugins_for_scope_with_collection( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + scope: RemotePluginScope, + collection: &str, +) -> Result, RemotePluginCatalogError> { + fetch_directory_plugins_for_scope_with_optional_collection( + config, + auth, + scope, + Some(collection), + ) + .await +} + +async fn fetch_directory_plugins_for_scope_with_optional_collection( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + scope: RemotePluginScope, + collection: Option<&str>, ) -> Result, RemotePluginCatalogError> { let mut plugins = Vec::new(); let mut page_token = None; loop { let response = - get_remote_plugin_list_page(config, auth, scope, page_token.as_deref()).await?; + get_remote_plugin_list_page(config, auth, scope, page_token.as_deref(), collection) + .await?; plugins.extend(response.plugins); let Some(next_page_token) = response.pagination.next_page_token else { break; @@ -1162,6 +1308,7 @@ async fn get_remote_plugin_list_page( auth: &CodexAuth, scope: RemotePluginScope, page_token: Option<&str>, + collection: Option<&str>, ) -> Result { let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/ps/plugins/list"); @@ -1169,6 +1316,9 @@ async fn get_remote_plugin_list_page( let mut request = authenticated_request(client.get(&url), auth)?; request = request.query(&[("scope", scope.api_value())]); request = request.query(&[("limit", REMOTE_PLUGIN_LIST_PAGE_LIMIT)]); + if let Some(collection) = collection { + request = request.query(&[("collection", collection)]); + } if let Some(page_token) = page_token { request = request.query(&[("pageToken", page_token)]); } diff --git a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs index b13ca7a630..fe3a597b0f 100644 --- a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs +++ b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs @@ -78,7 +78,7 @@ pub struct RemotePluginCacheMutationGuard { key: RemotePluginCacheMutationKey, } -pub fn maybe_start_remote_installed_plugin_bundle_sync( +pub(crate) fn maybe_start_remote_installed_plugin_bundle_sync( codex_home: PathBuf, config: RemotePluginServiceConfig, auth: Option, @@ -208,6 +208,7 @@ pub async fn sync_remote_installed_plugin_bundles_once( &plugin.name, release_version, plugin.release.bundle_download_url.as_deref(), + plugin.release.app_manifest.clone(), ) { Ok(bundle) => bundle, Err(err) => { @@ -511,7 +512,7 @@ mod tests { &installed_plugin_names_by_marketplace, ) .expect("cleanup after install guard is dropped"); - assert_eq!(removed, vec!["linear@chatgpt-global".to_string()]); + assert_eq!(removed, vec!["linear@openai-curated-remote".to_string()]); assert!(!cached_manifest.exists()); } diff --git a/codex-rs/core-plugins/src/remote/share.rs b/codex-rs/core-plugins/src/remote/share.rs index 9b5a2572a4..c400d27c47 100644 --- a/codex-rs/core-plugins/src/remote/share.rs +++ b/codex-rs/core-plugins/src/remote/share.rs @@ -1,18 +1,15 @@ use super::*; +use crate::plugin_bundle_archive::PluginBundlePackError; +use crate::plugin_bundle_archive::pack_plugin_bundle_tar_gz; use codex_login::CodexAuth; use codex_login::default_client::build_reqwest_client; use codex_utils_absolute_path::AbsolutePathBuf; -use flate2::Compression; -use flate2::write::GzEncoder; use reqwest::RequestBuilder; use reqwest::StatusCode; use serde::Deserialize; use serde::Serialize; use std::collections::BTreeMap; -use std::fmt; -use std::fs; use std::io; -use std::io::Write; use std::path::Path; use tracing::warn; @@ -477,140 +474,20 @@ fn archive_plugin_for_upload_with_limit( plugin_path: &Path, max_bytes: usize, ) -> Result, RemotePluginCatalogError> { - if !plugin_path.is_dir() { - return Err(RemotePluginCatalogError::InvalidPluginPath { + pack_plugin_bundle_tar_gz(plugin_path, max_bytes).map_err(|err| match err { + PluginBundlePackError::InvalidPluginPath { path, reason } => { + RemotePluginCatalogError::InvalidPluginPath { path, reason } + } + PluginBundlePackError::ArchiveTooLarge { bytes, max_bytes } => { + RemotePluginCatalogError::ArchiveTooLarge { bytes, max_bytes } + } + PluginBundlePackError::Io { source } => RemotePluginCatalogError::Archive { path: plugin_path.to_path_buf(), - reason: "expected a plugin directory".to_string(), - }); - } - if !plugin_path.join(".codex-plugin/plugin.json").is_file() { - return Err(RemotePluginCatalogError::InvalidPluginPath { - path: plugin_path.to_path_buf(), - reason: "missing .codex-plugin/plugin.json".to_string(), - }); - } - - let encoder = GzEncoder::new(SizeLimitedBuffer::new(max_bytes), Compression::default()); - let mut archive = tar::Builder::new(encoder); - append_plugin_tree(&mut archive, plugin_path, plugin_path) - .map_err(|source| archive_error(plugin_path, source))?; - let encoder = archive - .into_inner() - .map_err(|source| archive_error(plugin_path, source))?; - encoder - .finish() - .map(SizeLimitedBuffer::into_inner) - .map_err(|source| archive_error(plugin_path, source)) + source, + }, + }) } -fn append_plugin_tree( - archive: &mut tar::Builder, - plugin_root: &Path, - current: &Path, -) -> io::Result<()> { - let mut entries = fs::read_dir(current)?.collect::, io::Error>>()?; - entries.sort_by_key(fs::DirEntry::file_name); - for entry in entries { - let path = entry.path(); - let file_type = entry.file_type()?; - let relative_path = path.strip_prefix(plugin_root).map_err(|err| { - io::Error::other(format!( - "failed to compute plugin archive path for `{}`: {err}", - path.display() - )) - })?; - if file_type.is_dir() { - archive.append_dir(relative_path, &path)?; - append_plugin_tree(archive, plugin_root, &path)?; - } else if file_type.is_file() { - archive.append_path_with_name(&path, relative_path)?; - } else { - return Err(io::Error::other(format!( - "unsupported plugin archive entry type: {}", - path.display() - ))); - } - } - Ok(()) -} - -fn archive_error(plugin_path: &Path, source: io::Error) -> RemotePluginCatalogError { - if let Some(limit) = source - .get_ref() - .and_then(|err| err.downcast_ref::()) - { - return RemotePluginCatalogError::ArchiveTooLarge { - bytes: limit.bytes, - max_bytes: limit.max_bytes, - }; - } - - RemotePluginCatalogError::Archive { - path: plugin_path.to_path_buf(), - source, - } -} - -struct SizeLimitedBuffer { - bytes: Vec, - max_bytes: usize, -} - -impl SizeLimitedBuffer { - fn new(max_bytes: usize) -> Self { - Self { - bytes: Vec::new(), - max_bytes, - } - } - - fn into_inner(self) -> Vec { - self.bytes - } -} - -impl Write for SizeLimitedBuffer { - fn write(&mut self, buf: &[u8]) -> io::Result { - let next_len = self.bytes.len().checked_add(buf.len()).ok_or_else(|| { - io::Error::other(ArchiveSizeLimitExceeded { - bytes: usize::MAX, - max_bytes: self.max_bytes, - }) - })?; - if next_len > self.max_bytes { - return Err(io::Error::other(ArchiveSizeLimitExceeded { - bytes: next_len, - max_bytes: self.max_bytes, - })); - } - - self.bytes.extend_from_slice(buf); - Ok(buf.len()) - } - - fn flush(&mut self) -> io::Result<()> { - Ok(()) - } -} - -#[derive(Debug)] -struct ArchiveSizeLimitExceeded { - bytes: usize, - max_bytes: usize, -} - -impl fmt::Display for ArchiveSizeLimitExceeded { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "archive would be {} bytes, exceeding maximum size of {} bytes", - self.bytes, self.max_bytes - ) - } -} - -impl std::error::Error for ArchiveSizeLimitExceeded {} - async fn send_and_expect_status( request: RequestBuilder, url_for_error: &str, diff --git a/codex-rs/core-plugins/src/remote/share/checkout.rs b/codex-rs/core-plugins/src/remote/share/checkout.rs index 8aabccff8b..6b12d2588d 100644 --- a/codex-rs/core-plugins/src/remote/share/checkout.rs +++ b/codex-rs/core-plugins/src/remote/share/checkout.rs @@ -85,6 +85,7 @@ pub async fn checkout_remote_plugin_share( &plugin_name, detail.release_version.as_deref(), detail.bundle_download_url.as_deref(), + /*app_manifest*/ None, ) .map_err(|err| { RemotePluginCatalogError::UnexpectedResponse(format!( diff --git a/codex-rs/core-plugins/src/remote/share/tests.rs b/codex-rs/core-plugins/src/remote/share/tests.rs index f62051374e..e33d3c5d51 100644 --- a/codex-rs/core-plugins/src/remote/share/tests.rs +++ b/codex-rs/core-plugins/src/remote/share/tests.rs @@ -326,6 +326,34 @@ fn archive_plugin_for_upload_places_manifest_at_archive_root() { ); } +#[test] +fn archive_plugin_for_upload_round_trips_through_plugin_bundle_archive_with_long_paths() { + let temp_dir = TempDir::new().unwrap(); + let plugin_path = write_test_plugin(temp_dir.path(), "demo-plugin"); + let long_skill_path = Path::new("skills") + .join(["segment"; 40].join("/")) + .join("SKILL.md"); + write_file(&plugin_path.join(&long_skill_path), "# Long path skill\n"); + + let archive_bytes = archive_plugin_for_upload(&plugin_path).unwrap(); + let destination = TempDir::new().unwrap(); + crate::plugin_bundle_archive::unpack_plugin_bundle_tar_gz( + &archive_bytes, + destination.path(), + /*max_total_bytes*/ 1024 * 1024, + ) + .expect("extract shared plugin archive"); + + assert_eq!( + fs::read_to_string(destination.path().join(".codex-plugin/plugin.json")).unwrap(), + r#"{"name":"demo-plugin"}"# + ); + assert_eq!( + fs::read_to_string(destination.path().join(long_skill_path)).unwrap(), + "# Long path skill\n" + ); +} + #[tokio::test] async fn save_remote_plugin_share_updates_existing_workspace_plugin() { let codex_home = TempDir::new().unwrap(); diff --git a/codex-rs/core-plugins/src/remote_bundle.rs b/codex-rs/core-plugins/src/remote_bundle.rs index ed7cd56a86..cb60d60b77 100644 --- a/codex-rs/core-plugins/src/remote_bundle.rs +++ b/codex-rs/core-plugins/src/remote_bundle.rs @@ -1,3 +1,6 @@ +use crate::plugin_bundle_archive::PluginBundleUnpackError; +use crate::plugin_bundle_archive::unpack_plugin_bundle_tar_gz; +use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; use crate::store::PluginInstallResult; use crate::store::PluginStore; use crate::store::PluginStoreError; @@ -7,16 +10,14 @@ use codex_plugin::PluginId; use codex_plugin::PluginIdError; use codex_utils_absolute_path::AbsolutePathBuf; 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; use std::path::Path; use std::path::PathBuf; use std::time::Duration; -use tar::Archive; use url::Host; use url::Url; @@ -33,6 +34,7 @@ const TEST_ALLOW_LOOPBACK_HTTP_REMOTE_PLUGIN_BUNDLES_ENV: &str = pub struct ValidatedRemotePluginBundle { pub plugin_id: PluginId, pub plugin_version: String, + app_manifest: Option, bundle_download_url: String, } @@ -137,6 +139,7 @@ pub fn validate_remote_plugin_bundle( plugin_name: &str, release_version: Option<&str>, bundle_download_url: Option<&str>, + app_manifest: Option, ) -> Result { let plugin_id = PluginId::new(plugin_name.to_string(), remote_marketplace_name.to_string()) .map_err(|source| RemotePluginBundleInstallError::InvalidPluginId { @@ -187,6 +190,7 @@ pub fn validate_remote_plugin_bundle( Ok(ValidatedRemotePluginBundle { plugin_id, plugin_version, + app_manifest, bundle_download_url, }) } @@ -367,6 +371,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 +441,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, @@ -452,146 +541,17 @@ fn extract_plugin_bundle_tar_gz_with_limits( destination: &Path, max_total_bytes: u64, ) -> Result<(), RemotePluginBundleInstallError> { - fs::create_dir_all(destination).map_err(|source| { - RemotePluginBundleInstallError::io( - "failed to create remote plugin bundle extraction directory", - source, - ) - })?; - - let archive = GzDecoder::new(std::io::Cursor::new(bytes)); - let mut archive = Archive::new(archive); - extract_plugin_bundle_tar(&mut archive, destination, max_total_bytes) -} - -fn extract_plugin_bundle_tar( - archive: &mut Archive, - destination: &Path, - max_total_bytes: u64, -) -> Result<(), RemotePluginBundleInstallError> { - let mut extracted_bytes = 0u64; - let entries = archive.entries().map_err(|source| { - RemotePluginBundleInstallError::io("failed to read remote plugin bundle tar", source) - })?; - let entries = entries.raw(true); - for entry in entries { - let mut entry = entry.map_err(|source| { - RemotePluginBundleInstallError::io( - "failed to read remote plugin bundle tar entry", - source, - ) - })?; - let entry_type = entry.header().entry_type(); - let entry_size = entry.size(); - let entry_path = entry.path().map_err(|source| { - RemotePluginBundleInstallError::io( - "failed to read remote plugin bundle tar entry path", - source, - ) - })?; - let entry_path = entry_path.into_owned(); - let output_path = checked_tar_output_path(destination, &entry_path)?; - - if entry_type.is_dir() { - fs::create_dir_all(&output_path).map_err(|source| { - RemotePluginBundleInstallError::io( - "failed to create remote plugin bundle directory", - source, - ) - })?; - continue; + unpack_plugin_bundle_tar_gz(bytes, destination, max_total_bytes).map_err(|err| match err { + PluginBundleUnpackError::ExtractedBundleTooLarge { bytes, max_bytes } => { + RemotePluginBundleInstallError::ExtractedBundleTooLarge { bytes, max_bytes } } - - if entry_type.is_file() { - enforce_total_extracted_size(entry_size, &mut extracted_bytes, max_total_bytes)?; - let Some(parent) = output_path.parent() else { - return Err(RemotePluginBundleInstallError::InvalidBundle(format!( - "remote plugin bundle output path has no parent: {}", - output_path.display() - ))); - }; - fs::create_dir_all(parent).map_err(|source| { - RemotePluginBundleInstallError::io( - "failed to create remote plugin bundle directory", - source, - ) - })?; - entry.unpack(&output_path).map_err(|source| { - RemotePluginBundleInstallError::io( - "failed to unpack remote plugin bundle entry", - source, - ) - })?; - continue; + PluginBundleUnpackError::Io { context, source } => { + RemotePluginBundleInstallError::io(context, source) } - - if entry_type.is_hard_link() || entry_type.is_symlink() { - return Err(RemotePluginBundleInstallError::InvalidBundle(format!( - "remote plugin bundle tar entry `{}` is a link", - entry_path.display() - ))); + PluginBundleUnpackError::InvalidBundle(message) => { + RemotePluginBundleInstallError::InvalidBundle(message) } - - return Err(RemotePluginBundleInstallError::InvalidBundle(format!( - "remote plugin bundle tar entry `{}` has unsupported type {:?}", - entry_path.display(), - entry_type - ))); - } - - Ok(()) -} - -fn checked_tar_output_path( - destination: &Path, - entry_name: &Path, -) -> Result { - let mut output_path = destination.to_path_buf(); - let mut has_component = false; - for component in entry_name.components() { - match component { - std::path::Component::Normal(component) => { - has_component = true; - output_path.push(component); - } - std::path::Component::CurDir => {} - std::path::Component::ParentDir - | std::path::Component::RootDir - | std::path::Component::Prefix(_) => { - return Err(RemotePluginBundleInstallError::InvalidBundle(format!( - "remote plugin bundle tar entry `{}` escapes extraction root", - entry_name.display() - ))); - } - } - } - if !has_component { - return Err(RemotePluginBundleInstallError::InvalidBundle( - "remote plugin bundle tar entry has an empty path".to_string(), - )); - } - Ok(output_path) -} - -fn enforce_total_extracted_size( - entry_size: u64, - extracted_bytes: &mut u64, - max_total_bytes: u64, -) -> Result<(), RemotePluginBundleInstallError> { - let next_total = extracted_bytes.checked_add(entry_size).ok_or( - RemotePluginBundleInstallError::ExtractedBundleTooLarge { - bytes: u64::MAX, - max_bytes: max_total_bytes, - }, - )?; - if next_total > max_total_bytes { - return Err(RemotePluginBundleInstallError::ExtractedBundleTooLarge { - bytes: next_total, - max_bytes: max_total_bytes, - }); - } - *extracted_bytes = next_total; - Ok(()) + }) } fn find_extracted_plugin_root( @@ -616,6 +576,7 @@ mod tests { use flate2::Compression; use flate2::write::GzEncoder; use pretty_assertions::assert_eq; + use std::io::Write; use tempfile::tempdir; const REMOTE_PLUGIN_ID: &str = "plugins~Plugin_00000000000000000000000000000000"; @@ -624,15 +585,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 +606,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 +624,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 +642,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 +660,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"); @@ -735,7 +701,7 @@ mod tests { ) .expect_err("invalid tar.gz should be rejected"); - assert!(format!("{err}").contains("failed to read remote plugin bundle tar")); + assert!(format!("{err}").contains("failed to read plugin bundle tar")); } #[test] @@ -755,6 +721,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"); @@ -794,8 +832,11 @@ mod tests { #[test] fn extraction_rejects_tar_path_traversal() { let destination = tempdir().expect("tempdir"); - let err = checked_tar_output_path(destination.path(), Path::new("../evil.txt")) - .expect_err("tar path traversal should be rejected"); + let err = extract_plugin_bundle_tar_gz( + &tar_gz_bytes_with_raw_path("../evil.txt", b"evil", /*mode*/ 0o644), + destination.path(), + ) + .expect_err("tar path traversal should be rejected"); assert!(format!("{err}").contains("escapes extraction root")); } @@ -820,20 +861,20 @@ mod tests { } #[test] - fn extraction_rejects_pax_metadata_entries() { + fn extraction_supports_gnu_long_name_entries() { let destination = tempdir().expect("tempdir"); - let err = extract_plugin_bundle_tar_gz( - &tar_gz_bytes_with_entry_type( - tar::EntryType::XHeader, - "PaxHeaders.0/linear", - b"18 path=linear\n", - /*mode*/ 0o644, - ), + let long_path = format!("{}/file.txt", ["segment"; 40].join("/")); + + extract_plugin_bundle_tar_gz( + &tar_gz_bytes(&[(long_path.as_str(), b"long", /*mode*/ 0o644)]), destination.path(), ) - .expect_err("pax metadata entries should be rejected"); + .expect("extract bundle with GNU long name entry"); - assert!(format!("{err}").contains("unsupported type")); + assert_eq!( + std::fs::read(destination.path().join(long_path)).expect("read extracted file"), + b"long" + ); } #[cfg(unix)] @@ -866,10 +907,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") } @@ -883,16 +925,25 @@ mod tests { finish_tar_gz(tar) } - fn tar_gz_bytes_with_entry_type( - entry_type: tar::EntryType, - path: &str, - contents: &[u8], - mode: u32, - ) -> Vec { - let encoder = GzEncoder::new(Vec::new(), Compression::default()); - let mut tar = tar::Builder::new(encoder); - append_tar_entry(&mut tar, entry_type, path, contents, mode); - finish_tar_gz(tar) + fn tar_gz_bytes_with_raw_path(path: &str, contents: &[u8], mode: u32) -> Vec { + let mut header = tar::Header::new_gnu(); + header.set_entry_type(tar::EntryType::Regular); + header.set_size(contents.len() as u64); + header.set_mode(mode); + header.as_mut_bytes()[..path.len()].copy_from_slice(path.as_bytes()); + header.set_cksum(); + + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + encoder + .write_all(header.as_bytes()) + .expect("write tar header"); + encoder.write_all(contents).expect("write tar contents"); + let padding = (512 - (contents.len() % 512)) % 512; + encoder + .write_all(&vec![0; padding]) + .expect("write tar padding"); + encoder.write_all(&[0; 1024]).expect("write tar terminator"); + encoder.finish().expect("finish gzip") } fn append_tar_entry( diff --git a/codex-rs/core-plugins/src/store.rs b/codex-rs/core-plugins/src/store.rs index fe662a142e..19c3b0116f 100644 --- a/codex-rs/core-plugins/src/store.rs +++ b/codex-rs/core-plugins/src/store.rs @@ -4,8 +4,10 @@ use codex_plugin::PluginId; use codex_plugin::validate_plugin_segment; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_plugins::find_plugin_manifest_path; +use semver::Version; use serde::Deserialize; use serde_json::Value as JsonValue; +use std::cmp::Ordering; use std::fs; use std::io; use std::path::Path; @@ -75,7 +77,7 @@ impl PluginStore { }) .filter(|version| validate_plugin_version_segment(version).is_ok()) .collect::>(); - discovered_versions.sort_unstable(); + discovered_versions.sort_unstable_by(|left, right| compare_plugin_versions(left, right)); if discovered_versions.is_empty() { None } else if discovered_versions @@ -286,6 +288,15 @@ fn replace_plugin_root_atomically( let staged_version_root = staged_root.join(plugin_version); copy_dir_recursive(source, &staged_version_root)?; + let target_version_root = target_root.join(plugin_version); + if target_root.exists() && !target_version_root.exists() { + fs::rename(&staged_version_root, &target_version_root).map_err(|err| { + PluginStoreError::io("failed to activate updated plugin cache version", err) + })?; + remove_old_plugin_versions(target_root, plugin_version)?; + return Ok(()); + } + if target_root.exists() { let backup_dir = tempfile::Builder::new() .prefix("plugin-backup-") @@ -322,6 +333,52 @@ fn replace_plugin_root_atomically( Ok(()) } +fn remove_old_plugin_versions( + target_root: &Path, + plugin_version: &str, +) -> Result<(), PluginStoreError> { + let Ok(entries) = fs::read_dir(target_root) else { + return Ok(()); + }; + + for entry in entries.filter_map(Result::ok) { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_dir() { + continue; + } + let Ok(version) = entry.file_name().into_string() else { + continue; + }; + if version == plugin_version || validate_plugin_version_segment(&version).is_err() { + continue; + } + + if fs::remove_dir_all(entry.path()).is_err() + && old_plugin_version_would_stay_active(&version, plugin_version) + { + return Err(PluginStoreError::Invalid(format!( + "failed to activate updated plugin cache version `{plugin_version}` while `{version}` remains active" + ))); + } + } + + Ok(()) +} + +fn old_plugin_version_would_stay_active(old_version: &str, new_version: &str) -> bool { + old_version == DEFAULT_PLUGIN_VERSION + || compare_plugin_versions(old_version, new_version).is_gt() +} + +fn compare_plugin_versions(left: &str, right: &str) -> Ordering { + match (Version::parse(left), Version::parse(right)) { + (Ok(left), Ok(right)) => left.cmp(&right), + _ => left.cmp(right), + } +} + fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), PluginStoreError> { fs::create_dir_all(target) .map_err(|err| PluginStoreError::io("failed to create plugin target directory", err))?; diff --git a/codex-rs/core-plugins/src/store_tests.rs b/codex-rs/core-plugins/src/store_tests.rs index 0ba6b0d2c6..200055fe6c 100644 --- a/codex-rs/core-plugins/src/store_tests.rs +++ b/codex-rs/core-plugins/src/store_tests.rs @@ -247,7 +247,7 @@ fn active_plugin_version_prefers_default_local_version_when_multiple_versions_ex } #[test] -fn active_plugin_version_returns_last_sorted_version_when_default_is_missing() { +fn active_plugin_version_returns_latest_version_when_default_is_missing() { let tmp = tempdir().unwrap(); write_plugin( &tmp.path().join("plugins/cache/debug"), @@ -268,6 +268,76 @@ fn active_plugin_version_returns_last_sorted_version_when_default_is_missing() { ); } +#[test] +fn active_plugin_version_compares_semver_versions_semantically() { + let tmp = tempdir().unwrap(); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/9.0.0", + "sample-plugin", + ); + write_plugin( + &tmp.path().join("plugins/cache/debug"), + "sample-plugin/10.0.0", + "sample-plugin", + ); + let store = PluginStore::new(tmp.path().to_path_buf()); + let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(); + + assert_eq!( + store.active_plugin_version(&plugin_id), + Some("10.0.0".to_string()) + ); +} + +#[test] +fn install_with_new_version_keeps_existing_plugin_root_and_prunes_old_versions() { + let tmp = tempdir().unwrap(); + let store = PluginStore::new(tmp.path().to_path_buf()); + let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(); + + write_plugin_with_version(tmp.path(), "v1", "sample-plugin", Some("1.0.0")); + store + .install( + AbsolutePathBuf::try_from(tmp.path().join("v1")).unwrap(), + plugin_id.clone(), + ) + .unwrap(); + + write_plugin_with_version(tmp.path(), "v2", "sample-plugin", Some("2.0.0")); + store + .install( + AbsolutePathBuf::try_from(tmp.path().join("v2")).unwrap(), + plugin_id.clone(), + ) + .unwrap(); + + assert_eq!( + store.active_plugin_version(&plugin_id), + Some("2.0.0".to_string()) + ); + assert!( + tmp.path() + .join("plugins/cache/debug/sample-plugin/2.0.0") + .is_dir() + ); + assert!( + !tmp.path() + .join("plugins/cache/debug/sample-plugin/1.0.0") + .exists() + ); +} + +#[test] +fn old_plugin_version_would_stay_active_for_local_or_later_versions() { + assert!(old_plugin_version_would_stay_active( + DEFAULT_PLUGIN_VERSION, + "1.0.0" + )); + assert!(old_plugin_version_would_stay_active("10.0.0", "9.0.0")); + assert!(!old_plugin_version_would_stay_active("1.0.0", "2.0.0")); +} + #[test] fn plugin_root_rejects_path_separators_in_key_segments() { let err = PluginId::parse("../../etc@debug").unwrap_err(); diff --git a/codex-rs/core-plugins/src/test_support.rs b/codex-rs/core-plugins/src/test_support.rs index 6be2fbf0db..9e37a14312 100644 --- a/codex-rs/core-plugins/src/test_support.rs +++ b/codex-rs/core-plugins/src/test_support.rs @@ -120,11 +120,6 @@ pub(crate) async fn load_plugins_config(codex_home: &Path, cwd: &Path) -> Plugin "remote_plugin", /*default_enabled*/ false, ), - feature_enabled( - &effective_config, - "plugin_hooks", - /*default_enabled*/ false, - ), "https://chatgpt.com/backend-api/".to_string(), ) } diff --git a/codex-rs/core-skills/src/env_var_dependencies.rs b/codex-rs/core-skills/src/env_var_dependencies.rs deleted file mode 100644 index 4d8e57164c..0000000000 --- a/codex-rs/core-skills/src/env_var_dependencies.rs +++ /dev/null @@ -1,30 +0,0 @@ -use crate::SkillMetadata; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SkillDependencyInfo { - pub skill_name: String, - pub name: String, - pub description: Option, -} - -pub fn collect_env_var_dependencies( - mentioned_skills: &[SkillMetadata], -) -> Vec { - let mut dependencies = Vec::new(); - for skill in mentioned_skills { - let Some(skill_dependencies) = &skill.dependencies else { - continue; - }; - for tool in &skill_dependencies.tools { - if tool.r#type != "env_var" || tool.value.is_empty() { - continue; - } - dependencies.push(SkillDependencyInfo { - skill_name: skill.name.clone(), - name: tool.value.clone(), - description: tool.description.clone(), - }); - } - } - dependencies -} diff --git a/codex-rs/core-skills/src/injection.rs b/codex-rs/core-skills/src/injection.rs index df62f42e85..45979fa5c4 100644 --- a/codex-rs/core-skills/src/injection.rs +++ b/codex-rs/core-skills/src/injection.rs @@ -132,7 +132,7 @@ pub fn collect_explicit_skill_mentions( let mut blocked_plain_names: HashSet = HashSet::new(); for input in inputs { - if let UserInput::Skill { name, path } = input { + if let UserInput::Skill { name, path, .. } = input { blocked_plain_names.insert(name.clone()); let Ok(path) = AbsolutePathBuf::relative_to_current_dir(path) else { continue; diff --git a/codex-rs/core-skills/src/lib.rs b/codex-rs/core-skills/src/lib.rs index eec3a5f054..1577662bc5 100644 --- a/codex-rs/core-skills/src/lib.rs +++ b/codex-rs/core-skills/src/lib.rs @@ -1,5 +1,4 @@ pub mod config_rules; -mod env_var_dependencies; pub mod injection; pub(crate) mod invocation_utils; pub mod loader; @@ -10,8 +9,6 @@ pub mod remote; pub mod render; pub mod system; -pub use env_var_dependencies::SkillDependencyInfo; -pub use env_var_dependencies::collect_env_var_dependencies; pub(crate) use invocation_utils::build_implicit_skill_path_indexes; pub use invocation_utils::detect_implicit_skill_invocation_for_command; pub use manager::SkillsLoadInput; diff --git a/codex-rs/core-skills/src/loader.rs b/codex-rs/core-skills/src/loader.rs index 2473f7108c..d8b05c604f 100644 --- a/codex-rs/core-skills/src/loader.rs +++ b/codex-rs/core-skills/src/loader.rs @@ -30,6 +30,7 @@ use std::error::Error; use std::fmt; use std::io; use std::path::Component; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use toml::Value as TomlValue; @@ -154,6 +155,7 @@ pub struct SkillRoot { pub scope: SkillScope, pub file_system: Arc, pub plugin_id: Option, + pub plugin_root: Option, } pub async fn load_skills_from_roots(roots: I) -> SkillLoadOutcome @@ -174,6 +176,7 @@ where &root_path, root.scope, root.plugin_id.as_deref(), + root.plugin_root.as_ref(), &mut outcome, ) .await; @@ -232,6 +235,7 @@ pub(crate) async fn skill_roots( config_layer_stack: &ConfigLayerStack, cwd: &AbsolutePathBuf, plugin_skill_roots: Vec, + extra_skill_roots: Vec, ) -> Vec { let home_dir = home_dir().and_then(|path| AbsolutePathBuf::from_absolute_path_checked(path).ok()); @@ -241,6 +245,7 @@ pub(crate) async fn skill_roots( cwd, home_dir.as_ref(), plugin_skill_roots, + extra_skill_roots, ) .await } @@ -251,6 +256,7 @@ async fn skill_roots_with_home_dir( cwd: &AbsolutePathBuf, home_dir: Option<&AbsolutePathBuf>, plugin_skill_roots: Vec, + extra_skill_roots: Vec, ) -> Vec { let mut roots = skill_roots_from_layer_stack_inner(config_layer_stack, home_dir, fs.clone()); roots.extend(plugin_skill_roots.into_iter().map(|root| SkillRoot { @@ -258,6 +264,14 @@ async fn skill_roots_with_home_dir( scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: Some(root.plugin_id), + plugin_root: Some(root.plugin_root), + })); + roots.extend(extra_skill_roots.into_iter().map(|path| SkillRoot { + path, + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, + plugin_root: None, })); roots.extend(repo_agents_skill_roots(fs, config_layer_stack, cwd).await); dedupe_skill_roots_by_path(&mut roots); @@ -287,6 +301,7 @@ fn skill_roots_from_layer_stack_inner( scope: SkillScope::Repo, file_system: Arc::clone(repo_fs), plugin_id: None, + plugin_root: None, }); } } @@ -298,6 +313,7 @@ fn skill_roots_from_layer_stack_inner( scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_root: None, }); // `$HOME/.agents/skills` (user-installed skills). @@ -307,6 +323,7 @@ fn skill_roots_from_layer_stack_inner( scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_root: None, }); } @@ -317,6 +334,7 @@ fn skill_roots_from_layer_stack_inner( scope: SkillScope::System, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_root: None, }); } ConfigLayerSource::System { .. } => { @@ -327,9 +345,11 @@ fn skill_roots_from_layer_stack_inner( scope: SkillScope::Admin, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_root: None, }); } ConfigLayerSource::Mdm { .. } + | ConfigLayerSource::EnterpriseManaged { .. } | ConfigLayerSource::SessionFlags | ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } | ConfigLayerSource::LegacyManagedConfigTomlFromMdm => {} @@ -359,6 +379,7 @@ async fn repo_agents_skill_roots( scope: SkillScope::Repo, file_system: Arc::clone(&fs), plugin_id: None, + plugin_root: None, }), Ok(_) => {} Err(err) if err.kind() == io::ErrorKind::NotFound => {} @@ -458,9 +479,11 @@ async fn discover_skills_under_root( root: &AbsolutePathBuf, scope: SkillScope, plugin_id: Option<&str>, + plugin_root: Option<&AbsolutePathBuf>, outcome: &mut SkillLoadOutcome, ) { let root = canonicalize_for_skill_identity(root); + let plugin_root = plugin_root.map(canonicalize_for_skill_identity); match fs.get_metadata(&root, /*sandbox*/ None).await { Ok(metadata) if metadata.is_directory => {} @@ -570,7 +593,7 @@ async fn discover_skills_under_root( } if metadata.is_file && file_name == SKILLS_FILENAME { - match parse_skill_file(fs, &path, scope, plugin_id).await { + match parse_skill_file(fs, &path, scope, plugin_id, plugin_root.as_ref()).await { Ok(skill) => { outcome.skills.push(skill); } @@ -601,6 +624,7 @@ async fn parse_skill_file( path: &AbsolutePathBuf, scope: SkillScope, plugin_id: Option<&str>, + plugin_root: Option<&AbsolutePathBuf>, ) -> Result { let contents = fs .read_file_text(path, /*sandbox*/ None) @@ -634,7 +658,7 @@ async fn parse_skill_file( interface, dependencies, policy, - } = load_skill_metadata(fs, path).await; + } = load_skill_metadata(fs, path, plugin_root).await; validate_len(&name, MAX_NAME_LEN, "name")?; validate_len(&description, MAX_DESCRIPTION_LEN, "description")?; @@ -687,6 +711,7 @@ async fn namespaced_skill_name( async fn load_skill_metadata( fs: &dyn ExecutorFileSystem, skill_path: &AbsolutePathBuf, + plugin_root: Option<&AbsolutePathBuf>, ) -> LoadedSkillMetadata { // Fail open: optional metadata should not block loading SKILL.md. let Some(skill_dir) = skill_path.parent() else { @@ -744,7 +769,7 @@ async fn load_skill_metadata( policy, } = parsed; LoadedSkillMetadata { - interface: resolve_interface(interface, &skill_dir), + interface: resolve_interface(interface, &skill_dir, plugin_root), dependencies: resolve_dependencies(dependencies), policy: resolve_policy(policy), } @@ -753,6 +778,7 @@ async fn load_skill_metadata( fn resolve_interface( interface: Option, skill_dir: &AbsolutePathBuf, + plugin_root: Option<&AbsolutePathBuf>, ) -> Option { let interface = interface?; let interface = SkillInterface { @@ -766,8 +792,18 @@ fn resolve_interface( MAX_SHORT_DESCRIPTION_LEN, "interface.short_description", ), - icon_small: resolve_asset_path(skill_dir, "interface.icon_small", interface.icon_small), - icon_large: resolve_asset_path(skill_dir, "interface.icon_large", interface.icon_large), + icon_small: resolve_asset_path( + skill_dir, + plugin_root, + "interface.icon_small", + interface.icon_small, + ), + icon_large: resolve_asset_path( + skill_dir, + plugin_root, + "interface.icon_large", + interface.icon_large, + ), brand_color: resolve_color_str(interface.brand_color, "interface.brand_color"), default_prompt: resolve_str( interface.default_prompt, @@ -845,10 +881,12 @@ fn resolve_dependency_tool(tool: DependencyTool) -> Option fn resolve_asset_path( skill_dir: &AbsolutePathBuf, + plugin_root: Option<&AbsolutePathBuf>, field: &'static str, path: Option, ) -> Option { - // Icons must be relative paths under the skill's assets/ directory; otherwise return None. + // Icons must stay under the skill's assets directory. Plugin skills may + // also share icons from the plugin-level assets directory. let path = path?; if path.as_os_str().is_empty() { return None; @@ -869,8 +907,7 @@ fn resolve_asset_path( Component::CurDir => {} Component::Normal(component) => normalized.push(component), Component::ParentDir => { - tracing::warn!("ignoring {field}: icon path must not contain '..'"); - return None; + return resolve_plugin_shared_asset_path(skill_dir, plugin_root, field, &path); } _ => { tracing::warn!("ignoring {field}: icon path must be under assets/"); @@ -891,6 +928,48 @@ fn resolve_asset_path( Some(skill_dir.join(normalized)) } +fn resolve_plugin_shared_asset_path( + skill_dir: &AbsolutePathBuf, + plugin_root: Option<&AbsolutePathBuf>, + field: &'static str, + path: &Path, +) -> Option { + let Some(plugin_root) = plugin_root else { + tracing::warn!("ignoring {field}: icon path must not contain '..'"); + return None; + }; + + let plugin_assets_dir = lexically_normalize(plugin_root.join("assets").as_path()); + let resolved = lexically_normalize(skill_dir.join(path).as_path()); + if !resolved.starts_with(&plugin_assets_dir) { + tracing::warn!("ignoring {field}: icon path with '..' must resolve under plugin assets/"); + return None; + } + + AbsolutePathBuf::try_from(resolved) + .map_err(|err| { + tracing::warn!("ignoring {field}: icon path must resolve to an absolute path: {err}"); + err + }) + .ok() +} + +fn lexically_normalize(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + Component::Prefix(_) | Component::RootDir | Component::Normal(_) => { + normalized.push(component.as_os_str()); + } + } + } + normalized +} + fn sanitize_single_line(raw: &str) -> String { raw.split_whitespace().collect::>().join(" ") } @@ -983,7 +1062,15 @@ pub(crate) async fn skill_roots_from_layer_stack( cwd: &AbsolutePathBuf, home_dir: Option<&AbsolutePathBuf>, ) -> Vec { - skill_roots_with_home_dir(Some(fs), config_layer_stack, cwd, home_dir, Vec::new()).await + skill_roots_with_home_dir( + Some(fs), + config_layer_stack, + cwd, + home_dir, + Vec::new(), + Vec::new(), + ) + .await } #[cfg(test)] diff --git a/codex-rs/core-skills/src/loader_tests.rs b/codex-rs/core-skills/src/loader_tests.rs index 65a0b5a4f1..69e357d93d 100644 --- a/codex-rs/core-skills/src/loader_tests.rs +++ b/codex-rs/core-skills/src/loader_tests.rs @@ -399,11 +399,6 @@ async fn loads_skill_dependencies_metadata_from_yaml() { { "dependencies": { "tools": [ - { - "type": "env_var", - "value": "GITHUB_TOKEN", - "description": "GitHub API token with repo scopes" - }, { "type": "mcp", "value": "github", @@ -446,14 +441,6 @@ async fn loads_skill_dependencies_metadata_from_yaml() { interface: None, dependencies: Some(SkillDependencies { tools: vec![ - SkillToolDependency { - r#type: "env_var".to_string(), - value: "GITHUB_TOKEN".to_string(), - description: Some("GitHub API token with repo scopes".to_string()), - transport: None, - command: None, - url: None, - }, SkillToolDependency { r#type: "mcp".to_string(), value: "github".to_string(), @@ -835,6 +822,116 @@ async fn drops_interface_when_icons_are_invalid() { ); } +#[tokio::test] +async fn loads_plugin_skill_interface_icons_from_shared_plugin_assets() { + let root = tempfile::tempdir().expect("tempdir"); + let plugin_root = root.path().join("plugins/twilio-developer-kit"); + let skill_path = write_skill_at( + &plugin_root.join("skills"), + "twilio-send-message", + "send-message", + "send messages", + ); + let skill_dir = skill_path.parent().expect("skill dir"); + fs::create_dir_all(plugin_root.join("assets")).unwrap(); + fs::write(plugin_root.join("assets/logo.svg"), "").unwrap(); + write_skill_interface_at( + skill_dir, + r##" +interface: + icon_small: "../../assets/logo.svg" + icon_large: "../../assets/logo.svg" +"##, + ); + + let plugin_root_abs = plugin_root.abs(); + let outcome = load_skills_from_roots([SkillRoot { + path: plugin_root.join("skills").abs(), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: Some("twilio-developer-kit@test".to_string()), + plugin_root: Some(plugin_root_abs.clone()), + }]) + .await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + let expected_icon_path = normalized(&plugin_root.join("assets/logo.svg")); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "send-message".to_string(), + description: "send messages".to_string(), + short_description: None, + interface: Some(SkillInterface { + display_name: None, + short_description: None, + icon_small: Some(expected_icon_path.clone()), + icon_large: Some(expected_icon_path), + brand_color: None, + default_prompt: None, + }), + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + plugin_id: Some("twilio-developer-kit@test".to_string()), + }] + ); +} + +#[tokio::test] +async fn drops_plugin_skill_interface_icons_that_escape_shared_plugin_assets() { + let root = tempfile::tempdir().expect("tempdir"); + let plugin_root = root.path().join("plugins/twilio-developer-kit"); + let skill_path = write_skill_at( + &plugin_root.join("skills"), + "twilio-send-message", + "send-message", + "send messages", + ); + let skill_dir = skill_path.parent().expect("skill dir"); + write_skill_interface_at( + skill_dir, + r##" +interface: + icon_small: "../../other/logo.svg" +"##, + ); + + let outcome = load_skills_from_roots([SkillRoot { + path: plugin_root.join("skills").abs(), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: Some("twilio-developer-kit@test".to_string()), + plugin_root: Some(plugin_root.abs()), + }]) + .await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![SkillMetadata { + name: "send-message".to_string(), + description: "send messages".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(&skill_path), + scope: SkillScope::User, + plugin_id: Some("twilio-developer-kit@test".to_string()), + }] + ); +} + #[cfg(unix)] fn symlink_dir(target: &Path, link: &Path) { std::os::unix::fs::symlink(target, link).unwrap(); @@ -956,6 +1053,7 @@ async fn loads_skills_via_symlinked_subdir_for_admin_scope() { scope: SkillScope::Admin, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_root: None, }]) .await; @@ -1037,6 +1135,7 @@ async fn system_scope_ignores_symlinked_subdir() { scope: SkillScope::System, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_root: None, }]) .await; assert!( @@ -1070,6 +1169,7 @@ async fn respects_max_scan_depth_for_user_scope() { scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_root: None, }]) .await; @@ -1176,6 +1276,7 @@ async fn namespaces_plugin_skills_using_plugin_name() { scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: Some("sample@test".to_string()), + plugin_root: Some(plugin_root.abs()), }]) .await; @@ -1498,12 +1599,14 @@ async fn deduplicates_by_path_preferring_first_root() { scope: SkillScope::Repo, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_root: None, }, SkillRoot { path: root.path().abs(), scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), plugin_id: None, + plugin_root: None, }, ]) .await; @@ -1797,6 +1900,7 @@ async fn skill_roots_include_admin_with_lowest_priority() { &cfg.config_layer_stack, &cfg.cwd, Vec::new(), + Vec::new(), ) .await .into_iter() diff --git a/codex-rs/core-skills/src/manager.rs b/codex-rs/core-skills/src/manager.rs index db19322acf..185eaa9320 100644 --- a/codex-rs/core-skills/src/manager.rs +++ b/codex-rs/core-skills/src/manager.rs @@ -51,6 +51,7 @@ impl SkillsLoadInput { pub struct SkillsManager { codex_home: AbsolutePathBuf, restriction_product: Option, + extra_roots: RwLock>, cache_by_cwd: RwLock>, cache_by_config: RwLock>, } @@ -68,6 +69,7 @@ impl SkillsManager { let manager = Self { codex_home, restriction_product, + extra_roots: RwLock::new(Vec::new()), cache_by_cwd: RwLock::new(HashMap::new()), cache_by_config: RwLock::new(HashMap::new()), }; @@ -81,6 +83,17 @@ impl SkillsManager { manager } + pub fn set_extra_roots(&self, extra_roots: Vec) { + { + let mut roots = self + .extra_roots + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *roots = extra_roots; + } + self.clear_cache(); + } + /// Load skills for an already-constructed [`Config`], avoiding any additional config-layer /// loading. /// @@ -118,6 +131,7 @@ impl SkillsManager { &input.config_layer_stack, &input.cwd, input.effective_skill_roots.clone(), + self.extra_roots(), ) .await; if !input.bundled_skills_enabled { @@ -145,6 +159,7 @@ impl SkillsManager { &input.config_layer_stack, &input.cwd, input.effective_skill_roots.clone(), + self.extra_roots(), ) .await; if !bundled_skills_enabled_from_stack(&input.config_layer_stack) { @@ -214,6 +229,13 @@ impl SkillsManager { Err(err) => err.into_inner().get(cache_key).cloned(), } } + + fn extra_roots(&self) -> Vec { + match self.extra_roots.read() { + Ok(roots) => roots.clone(), + Err(err) => err.into_inner().clone(), + } + } } #[derive(Debug, Clone, PartialEq, Eq, Hash)] diff --git a/codex-rs/core-skills/src/manager_tests.rs b/codex-rs/core-skills/src/manager_tests.rs index 15b06b393a..713b94d7b1 100644 --- a/codex-rs/core-skills/src/manager_tests.rs +++ b/codex-rs/core-skills/src/manager_tests.rs @@ -15,6 +15,7 @@ use codex_utils_plugins::PluginSkillRoot; use pretty_assertions::assert_eq; use std::collections::HashSet; use std::fs; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use tempfile::TempDir; @@ -54,6 +55,21 @@ fn write_plugin_skill( skill_path } +fn plugin_skill_root_for_skill_path(skill_path: &Path, plugin_id: &str) -> PluginSkillRoot { + let skills_root = skill_path + .parent() + .and_then(Path::parent) + .expect("plugin skill should live under a skills root"); + let plugin_root = skills_root + .parent() + .expect("plugin skills root should live under a plugin root"); + PluginSkillRoot { + path: skills_root.abs(), + plugin_id: plugin_id.to_string(), + plugin_root: plugin_root.abs(), + } +} + fn test_skill(name: &str, path: PathBuf) -> SkillMetadata { SkillMetadata { name: name.to_string(), @@ -146,18 +162,11 @@ async fn skills_for_config_with_stack( skills_manager: &SkillsManager, cwd: &TempDir, config_layer_stack: &ConfigLayerStack, - effective_skill_roots: &[AbsolutePathBuf], + effective_skill_roots: &[PluginSkillRoot], ) -> SkillLoadOutcome { let skills_input = SkillsLoadInput::new( cwd.path().abs(), - effective_skill_roots - .iter() - .cloned() - .map(|path| PluginSkillRoot { - path, - plugin_id: "test-plugin@test".to_string(), - }) - .collect(), + effective_skill_roots.to_vec(), config_layer_stack.clone(), bundled_skills_enabled_from_stack(config_layer_stack), ); @@ -212,6 +221,128 @@ async fn skills_for_config_reuses_cache_for_same_effective_config() { assert_eq!(outcome2.skills, outcome1.skills); } +#[tokio::test] +async fn set_extra_roots_replaces_runtime_roots_and_clears_cache() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let cwd = tempfile::tempdir().expect("tempdir"); + let extra_root = tempfile::tempdir().expect("tempdir"); + let config_layer_stack = config_stack(&codex_home, ""); + let skills_manager = SkillsManager::new( + codex_home.path().abs(), + /*bundled_skills_enabled*/ true, + ); + + let skills_input = SkillsLoadInput::new( + cwd.path().abs(), + Vec::new(), + config_layer_stack.clone(), + bundled_skills_enabled_from_stack(&config_layer_stack), + ); + let empty_outcome = skills_manager + .skills_for_cwd( + &skills_input, + /*force_reload*/ false, + Some(Arc::clone(&LOCAL_FS)), + ) + .await; + assert!( + empty_outcome + .skills + .iter() + .all(|skill| skill.name != "runtime-skill") + ); + + let extra_skills_root = extra_root.path().join("skills"); + let skill_dir = extra_skills_root.join("runtime-skill"); + fs::create_dir_all(&skill_dir).expect("create skill dir"); + fs::write( + skill_dir.join("SKILL.md"), + "---\nname: runtime-skill\ndescription: runtime skill\n---\n\n# Body\n", + ) + .expect("write skill"); + skills_manager.set_extra_roots(vec![extra_skills_root.abs()]); + + let runtime_outcome = skills_manager + .skills_for_cwd( + &skills_input, + /*force_reload*/ false, + Some(Arc::clone(&LOCAL_FS)), + ) + .await; + assert!( + runtime_outcome + .skills + .iter() + .any(|skill| skill.name == "runtime-skill") + ); + + skills_manager.set_extra_roots(vec![extra_root.path().join("missing-skills").abs()]); + let replaced_outcome = skills_manager + .skills_for_cwd( + &skills_input, + /*force_reload*/ false, + Some(Arc::clone(&LOCAL_FS)), + ) + .await; + assert_eq!(replaced_outcome.errors, Vec::new()); + assert!( + replaced_outcome + .skills + .iter() + .all(|skill| skill.name != "runtime-skill") + ); +} + +#[tokio::test] +async fn set_extra_roots_applies_to_config_loads_and_empty_clears() { + let codex_home = tempfile::tempdir().expect("tempdir"); + let cwd = tempfile::tempdir().expect("tempdir"); + let extra_root = tempfile::tempdir().expect("tempdir"); + let config_layer_stack = config_stack(&codex_home, ""); + let skills_manager = SkillsManager::new( + codex_home.path().abs(), + /*bundled_skills_enabled*/ true, + ); + + let empty_outcome = + skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await; + assert!( + empty_outcome + .skills + .iter() + .all(|skill| skill.name != "runtime-skill") + ); + + let extra_skills_root = extra_root.path().join("skills"); + let skill_dir = extra_skills_root.join("runtime-skill"); + fs::create_dir_all(&skill_dir).expect("create skill dir"); + fs::write( + skill_dir.join("SKILL.md"), + "---\nname: runtime-skill\ndescription: runtime skill\n---\n\n# Body\n", + ) + .expect("write skill"); + skills_manager.set_extra_roots(vec![extra_skills_root.abs()]); + + let runtime_outcome = + skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await; + assert!( + runtime_outcome + .skills + .iter() + .any(|skill| skill.name == "runtime-skill") + ); + + skills_manager.set_extra_roots(Vec::new()); + let cleared_outcome = + skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await; + assert!( + cleared_outcome + .skills + .iter() + .all(|skill| skill.name != "runtime-skill") + ); +} + #[tokio::test] async fn skills_for_config_disables_plugin_skills_by_name() { let codex_home = tempfile::tempdir().expect("tempdir"); @@ -228,11 +359,7 @@ async fn skills_for_config_disables_plugin_skills_by_name() { &codex_home, &name_toggle_config("sample:sample-search", /*enabled*/ false), ); - let plugin_skill_root = skill_path - .parent() - .and_then(std::path::Path::parent) - .expect("plugin skill should live under a skills root") - .abs(); + let plugin_skill_root = plugin_skill_root_for_skill_path(&skill_path, "test-plugin@test"); let skills_manager = SkillsManager::new( codex_home.path().abs(), /*bundled_skills_enabled*/ true, diff --git a/codex-rs/core/BUILD.bazel b/codex-rs/core/BUILD.bazel index 361e958e86..478699c0a1 100644 --- a/codex-rs/core/BUILD.bazel +++ b/codex-rs/core/BUILD.bazel @@ -17,10 +17,6 @@ codex_rust_crate( # that relies on env!("CARGO_MANIFEST_DIR"). "CARGO_MANIFEST_DIR": "codex-rs/core", }, - integration_compile_data_extra = [ - "//codex-rs/apply-patch:apply_patch_tool_instructions.md", - "templates/realtime/backend_prompt.md", - ], integration_test_timeout = "long", test_data_extra = [ "config.schema.json", diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 8472918dca..4f6ab2e777 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -47,12 +47,14 @@ codex-shell-command = { workspace = true } codex-execpolicy = { workspace = true } codex-git-utils = { workspace = true } codex-hooks = { workspace = true } +codex-install-context = { workspace = true } codex-network-proxy = { workspace = true } codex-otel = { workspace = true } codex-plugin = { workspace = true } codex-model-provider = { workspace = true } codex-protocol = { workspace = true } codex-response-debug-context = { workspace = true } +codex-prompts = { workspace = true } codex-rollout = { workspace = true } codex-rollout-trace = { workspace = true } codex-rmcp-client = { workspace = true } @@ -71,7 +73,6 @@ codex-utils-plugins = { workspace = true } codex-utils-pty = { workspace = true } codex-utils-string = { workspace = true } codex-utils-stream-parser = { workspace = true } -codex-utils-template = { workspace = true } codex-windows-sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" } csv = { workspace = true } dirs = { workspace = true } diff --git a/codex-rs/core/README.md b/codex-rs/core/README.md index 3283ba2c3e..57b9e53f6b 100644 --- a/codex-rs/core/README.md +++ b/codex-rs/core/README.md @@ -22,7 +22,7 @@ Seatbelt also keeps the legacy default preferences read access ### Linux -Expects the binary containing `codex-core` to run the equivalent of `codex sandbox linux` (legacy alias: `codex debug landlock`) when `arg0` is `codex-linux-sandbox`. See the `codex-arg0` crate for details. +Expects the binary containing `codex-core` to run the equivalent of `codex sandbox` when `arg0` is `codex-linux-sandbox`. See the `codex-arg0` crate for details. Legacy `SandboxPolicy` / `sandbox_mode` configs are still supported on Linux. They can continue to use the legacy Landlock path when the split filesystem diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index dd2b01d0af..6dd85dffc2 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -309,6 +309,25 @@ } ] }, + "AutoCompactTokenLimitScope": { + "description": "Selects which part of the active context is charged against `model_auto_compact_token_limit`.", + "oneOf": [ + { + "description": "Count the full active context against the limit.", + "enum": [ + "total" + ], + "type": "string" + }, + { + "description": "Count sampled output and later growth after the carried window prefix.", + "enum": [ + "body_after_prefix" + ], + "type": "string" + } + ] + }, "AutoReviewToml": { "properties": { "policy": { @@ -454,6 +473,9 @@ "image_generation": { "type": "boolean" }, + "imagegenext": { + "type": "boolean" + }, "in_app_browser": { "type": "boolean" }, @@ -463,6 +485,9 @@ "js_repl_tools_only": { "type": "boolean" }, + "local_thread_store_compression": { + "type": "boolean" + }, "memories": { "type": "boolean" }, @@ -481,6 +506,9 @@ "network_proxy": { "$ref": "#/definitions/FeatureToml_for_NetworkProxyConfigToml" }, + "non_prefixed_mcp_tool_names": { + "type": "boolean" + }, "personality": { "type": "boolean" }, @@ -553,6 +581,9 @@ "sqlite": { "type": "boolean" }, + "standalone_web_search": { + "type": "boolean" + }, "steer": { "type": "boolean" }, @@ -586,6 +617,9 @@ "unified_exec": { "type": "boolean" }, + "unified_exec_zsh_fork": { + "type": "boolean" + }, "use_legacy_landlock": { "type": "boolean" }, @@ -667,7 +701,7 @@ "$ref": "#/definitions/SandboxMode" }, "service_tier": { - "description": "Optional explicit service tier request id for new turns (for example `priority` or `flex`; legacy `fast` also works).", + "description": "Optional explicit service tier request id for new turns (for example `default`, `priority`, or `flex`; legacy `fast` also works).", "type": "string" }, "tools": { @@ -692,14 +726,6 @@ } ], "default": null - }, - "zsh_path": { - "allOf": [ - { - "$ref": "#/definitions/AbsolutePathBuf" - } - ], - "description": "Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution." } }, "type": "object" @@ -743,6 +769,16 @@ }, "type": "object" }, + "ExperimentalRequestUserInput": { + "additionalProperties": false, + "properties": { + "enabled": { + "default": true, + "type": "boolean" + } + }, + "type": "object" + }, "ExternalConfigMigrationPrompts": { "additionalProperties": false, "description": "Settings for notices we display to users via the tui and app-server clients (primarily the Codex IDE extension). NOTE: these are different from notifications - notices are warnings, NUX screens, acknowledgements, etc.", @@ -817,13 +853,23 @@ "type": "object" }, "FileSystemAccessMode": { - "description": "Access mode for a filesystem entry.\n\nWhen two equally specific entries target the same path, we compare these by conflict precedence rather than by capability breadth: `none` beats `write`, and `write` beats `read`.", - "enum": [ - "read", - "write", - "none" - ], - "type": "string" + "description": "Access mode for a filesystem entry.\n\nWhen two equally specific entries target the same path, we compare these by conflict precedence rather than by capability breadth: `deny` beats `write`, and `write` beats `read`.", + "oneOf": [ + { + "enum": [ + "read", + "write" + ], + "type": "string" + }, + { + "description": "`none` is a legacy input alias retained temporarily for compatibility.", + "enum": [ + "deny" + ], + "type": "string" + } + ] }, "FilesystemPermissionToml": { "anyOf": [ @@ -1093,6 +1139,20 @@ }, "type": "array" }, + "SubagentStart": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, + "SubagentStop": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, "UserPromptSubmit": { "default": [], "items": { @@ -1245,6 +1305,10 @@ "description": "Model used for memory consolidation.", "type": "string" }, + "dedicated_tools": { + "description": "When `true`, expose dedicated memory tools through the extension tool surface.", + "type": "boolean" + }, "disable_on_external_context": { "description": "When `true`, external context sources mark the thread `memory_mode` as `\"polluted\"`.", "type": "boolean" @@ -1516,6 +1580,12 @@ "subagent_usage_hint_text": { "type": "string" }, + "tool_namespace": { + "maxLength": 64, + "minLength": 1, + "pattern": "^[a-zA-Z0-9_-]+$", + "type": "string" + }, "usage_hint_enabled": { "type": "boolean" }, @@ -1535,6 +1605,118 @@ "NetworkDomainPermissionsToml": { "type": "object" }, + "NetworkMitmActionToml": { + "properties": { + "inject_request_headers": { + "default": [], + "items": { + "$ref": "#/definitions/NetworkMitmInjectedHeaderToml" + }, + "type": "array" + }, + "strip_request_headers": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, + "NetworkMitmHookToml": { + "additionalProperties": false, + "properties": { + "action": { + "items": { + "type": "string" + }, + "type": "array" + }, + "body": true, + "headers": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "default": {}, + "type": "object" + }, + "host": { + "type": "string" + }, + "methods": { + "items": { + "type": "string" + }, + "type": "array" + }, + "path_prefixes": { + "items": { + "type": "string" + }, + "type": "array" + }, + "query": { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "default": {}, + "type": "object" + } + }, + "required": [ + "action", + "host", + "methods", + "path_prefixes" + ], + "type": "object" + }, + "NetworkMitmInjectedHeaderToml": { + "properties": { + "name": { + "default": "", + "type": "string" + }, + "prefix": { + "default": null, + "type": "string" + }, + "secret_env_var": { + "default": null, + "type": "string" + }, + "secret_file": { + "default": null, + "type": "string" + } + }, + "type": "object" + }, + "NetworkMitmToml": { + "additionalProperties": false, + "properties": { + "actions": { + "additionalProperties": { + "$ref": "#/definitions/NetworkMitmActionToml" + }, + "type": "object" + }, + "hooks": { + "additionalProperties": { + "$ref": "#/definitions/NetworkMitmHookToml" + }, + "type": "object" + } + }, + "type": "object" + }, "NetworkModeSchema": { "enum": [ "limited", @@ -1607,7 +1789,7 @@ "NetworkProxyUnixSocketPermissionToml": { "enum": [ "allow", - "none" + "deny" ], "type": "string" }, @@ -1638,6 +1820,9 @@ "enabled": { "type": "boolean" }, + "mitm": { + "$ref": "#/definitions/NetworkMitmToml" + }, "mode": { "$ref": "#/definitions/NetworkModeSchema" }, @@ -1656,7 +1841,7 @@ "NetworkUnixSocketPermissionToml": { "enum": [ "allow", - "none" + "deny" ], "type": "string" }, @@ -1960,11 +2145,20 @@ "PermissionProfileToml": { "additionalProperties": false, "properties": { + "description": { + "type": "string" + }, + "extends": { + "type": "string" + }, "filesystem": { "$ref": "#/definitions/FilesystemPermissionsToml" }, "network": { "$ref": "#/definitions/NetworkToml" + }, + "workspace_roots": { + "$ref": "#/definitions/WorkspaceRootsToml" } }, "type": "object" @@ -2131,7 +2325,7 @@ }, "type": "array" }, - "experimental_environment": { + "environment_id": { "default": null, "type": "string" }, @@ -2534,6 +2728,9 @@ "ToolsToml": { "additionalProperties": false, "properties": { + "experimental_request_user_input": { + "$ref": "#/definitions/ExperimentalRequestUserInput" + }, "web_search": { "allOf": [ { @@ -2591,7 +2788,8 @@ "chat": { "decrease_reasoning_effort": null, "edit_queued_message": null, - "increase_reasoning_effort": null + "increase_reasoning_effort": null, + "interrupt_turn": null }, "composer": { "history_search_next": null, @@ -2659,6 +2857,7 @@ "append_after_cursor": null, "append_line_end": null, "cancel_operator": null, + "change_to_line_end": null, "delete_char": null, "delete_to_line_end": null, "enter_insert": null, @@ -2675,8 +2874,10 @@ "open_line_above": null, "open_line_below": null, "paste_after": null, + "start_change_operator": null, "start_delete_operator": null, "start_yank_operator": null, + "substitute_char": null, "yank_line": null }, "vim_operator": { @@ -2691,7 +2892,20 @@ "motion_word_backward": null, "motion_word_end": null, "motion_word_forward": null, + "select_around_text_object": null, + "select_inner_text_object": null, "yank_line": null + }, + "vim_text_object": { + "backtick": null, + "big_word": null, + "braces": null, + "brackets": null, + "cancel": null, + "double_quote": null, + "parentheses": null, + "single_quote": null, + "word": null } }, "description": "Keybinding overrides for the TUI.\n\nThis supports rebinding selected actions globally and by context. Context bindings take precedence over `global` bindings." @@ -2904,6 +3118,14 @@ } ], "description": "Increase the active reasoning effort." + }, + "interrupt_turn": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Interrupt the active turn." } }, "type": "object" @@ -3215,7 +3437,8 @@ "default": { "decrease_reasoning_effort": null, "edit_queued_message": null, - "increase_reasoning_effort": null + "increase_reasoning_effort": null, + "interrupt_turn": null } }, "composer": { @@ -3325,6 +3548,7 @@ "append_after_cursor": null, "append_line_end": null, "cancel_operator": null, + "change_to_line_end": null, "delete_char": null, "delete_to_line_end": null, "enter_insert": null, @@ -3341,8 +3565,10 @@ "open_line_above": null, "open_line_below": null, "paste_after": null, + "start_change_operator": null, "start_delete_operator": null, "start_yank_operator": null, + "substitute_char": null, "yank_line": null } }, @@ -3364,8 +3590,28 @@ "motion_word_backward": null, "motion_word_end": null, "motion_word_forward": null, + "select_around_text_object": null, + "select_inner_text_object": null, "yank_line": null } + }, + "vim_text_object": { + "allOf": [ + { + "$ref": "#/definitions/TuiVimTextObjectKeymap" + } + ], + "default": { + "backtick": null, + "big_word": null, + "braces": null, + "brackets": null, + "cancel": null, + "double_quote": null, + "parentheses": null, + "single_quote": null, + "word": null + } } }, "type": "object" @@ -3590,6 +3836,14 @@ ], "description": "Cancel a pending operator and return to normal mode." }, + "change_to_line_end": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Change from cursor to end of line and enter insert mode (`C`)." + }, "delete_char": { "allOf": [ { @@ -3718,6 +3972,14 @@ ], "description": "Paste after cursor (`p`)." }, + "start_change_operator": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Begin change operator; next keys select a text object." + }, "start_delete_operator": { "allOf": [ { @@ -3734,6 +3996,14 @@ ], "description": "Begin yank operator; next key selects motion (`y`)." }, + "substitute_char": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Delete character under cursor and enter insert mode (`s`)." + }, "yank_line": { "allOf": [ { @@ -3837,6 +4107,22 @@ ], "description": "Motion: to start of next word (`w`)." }, + "select_around_text_object": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Select an around text object after an operator." + }, + "select_inner_text_object": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Select an inner text object after an operator." + }, "yank_line": { "allOf": [ { @@ -3848,6 +4134,85 @@ }, "type": "object" }, + "TuiVimTextObjectKeymap": { + "additionalProperties": false, + "description": "Vim text-object keybindings for modal editing inside text areas.", + "properties": { + "backtick": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: backticks." + }, + "big_word": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: whitespace-delimited WORD." + }, + "braces": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: braces." + }, + "brackets": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: brackets." + }, + "cancel": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Cancel the pending text-object command." + }, + "double_quote": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: double quotes." + }, + "parentheses": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: parentheses." + }, + "single_quote": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: single quotes." + }, + "word": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Text object: word." + } + }, + "type": "object" + }, "UriBasedFileOpener": { "oneOf": [ { @@ -3960,6 +4325,9 @@ "type": "string" } ] + }, + "WorkspaceRootsToml": { + "type": "object" } }, "description": "Base config deserialized from ~/.codex/config.toml.", @@ -4010,6 +4378,10 @@ "default": null, "description": "Settings for app-specific controls." }, + "apps_mcp_product_sku": { + "description": "Optional product SKU forwarded on host-owned Codex Apps MCP requests.", + "type": "string" + }, "audio": { "allOf": [ { @@ -4067,6 +4439,12 @@ "description": "Default permissions profile to apply. Names starting with `:` refer to built-in profiles; other names are resolved from the `[permissions]` table.", "type": "string" }, + "desktop": { + "additionalProperties": true, + "default": null, + "description": "Opaque desktop settings stored alongside the rest of config.toml.", + "type": "object" + }, "developer_instructions": { "default": null, "description": "Developer instructions inserted as a `developer` role message.", @@ -4218,6 +4596,9 @@ "image_generation": { "type": "boolean" }, + "imagegenext": { + "type": "boolean" + }, "in_app_browser": { "type": "boolean" }, @@ -4227,6 +4608,9 @@ "js_repl_tools_only": { "type": "boolean" }, + "local_thread_store_compression": { + "type": "boolean" + }, "memories": { "type": "boolean" }, @@ -4245,6 +4629,9 @@ "network_proxy": { "$ref": "#/definitions/FeatureToml_for_NetworkProxyConfigToml" }, + "non_prefixed_mcp_tool_names": { + "type": "boolean" + }, "personality": { "type": "boolean" }, @@ -4317,6 +4704,9 @@ "sqlite": { "type": "boolean" }, + "standalone_web_search": { + "type": "boolean" + }, "steer": { "type": "boolean" }, @@ -4350,6 +4740,9 @@ "unified_exec": { "type": "boolean" }, + "unified_exec_zsh_fork": { + "type": "boolean" + }, "use_legacy_landlock": { "type": "boolean" }, @@ -4468,7 +4861,7 @@ "$ref": "#/definitions/AbsolutePathBuf" } ], - "description": "Directory where Codex writes log files, for example `codex-tui.log`. Defaults to `$CODEX_HOME/log`." + "description": "Directory where Codex writes log files. Setting this value explicitly also enables the TUI text log in this directory. Defaults to `$CODEX_HOME/log`." }, "marketplaces": { "additionalProperties": { @@ -4522,6 +4915,14 @@ "format": "int64", "type": "integer" }, + "model_auto_compact_token_limit_scope": { + "allOf": [ + { + "$ref": "#/definitions/AutoCompactTokenLimitScope" + } + ], + "description": "Controls whether the auto-compaction limit applies to the full context or only to tokens after the carried prefix in the current compaction window." + }, "model_catalog_json": { "allOf": [ { @@ -4704,7 +5105,7 @@ "description": "Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`." }, "service_tier": { - "description": "Optional explicit service tier request id for new turns (for example `priority` or `flex`; legacy `fast` also works).", + "description": "Optional explicit service tier request id for new turns (for example `default`, `priority`, or `flex`; legacy `fast` also works).", "type": "string" }, "shell_environment_policy": { @@ -4792,14 +5193,6 @@ ], "default": null, "description": "Windows-specific configuration." - }, - "zsh_path": { - "allOf": [ - { - "$ref": "#/definitions/AbsolutePathBuf" - } - ], - "description": "Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution." } }, "title": "ConfigToml", diff --git a/codex-rs/core/src/agent/agent_resolver.rs b/codex-rs/core/src/agent/agent_resolver.rs index eb806da3ae..115739c2fd 100644 --- a/codex-rs/core/src/agent/agent_resolver.rs +++ b/codex-rs/core/src/agent/agent_resolver.rs @@ -32,5 +32,5 @@ fn register_session_root(session: &Arc, turn: &Arc) { session .services .agent_control - .register_session_root(session.conversation_id, &turn.session_source); + .register_session_root(session.conversation_id, turn.parent_thread_id); } diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index d973f40e45..1f58fb437e 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -54,6 +54,7 @@ pub(crate) enum SpawnAgentForkMode { pub(crate) struct SpawnAgentOptions { pub(crate) fork_parent_spawn_call_id: Option, pub(crate) fork_mode: Option, + pub(crate) parent_thread_id: Option, pub(crate) environments: Option>, } @@ -96,7 +97,7 @@ fn agent_nickname_candidates( .collect() } -fn keep_forked_rollout_item(item: &RolloutItem) -> bool { +fn keep_forked_rollout_item(item: &RolloutItem, preserve_reference_context_item: bool) -> bool { match item { RolloutItem::ResponseItem(ResponseItem::Message { role, phase, .. }) => match role.as_str() { @@ -116,16 +117,34 @@ fn keep_forked_rollout_item(item: &RolloutItem) -> bool { | ResponseItem::WebSearchCall { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } + | ResponseItem::CompactionTrigger | ResponseItem::ContextCompaction { .. } | ResponseItem::Other, ) => false, - // A forked child gets its own runtime config, including spawned-agent - // instructions, so it must establish a fresh context diff baseline. - RolloutItem::TurnContext(_) => false, + // Full-history forks preserve the cached prompt prefix and can keep diffing + // from the parent's durable baseline. Truncated forks drop part of that prompt, + // so they must rebuild context on their first child turn. + RolloutItem::TurnContext(_) => preserve_reference_context_item, RolloutItem::Compacted(_) | RolloutItem::EventMsg(_) | RolloutItem::SessionMeta(_) => true, } } +fn is_multi_agent_v2_usage_hint_message(item: &ResponseItem, usage_hint_texts: &[String]) -> bool { + let ResponseItem::Message { role, content, .. } = item else { + return false; + }; + if role != "developer" { + return false; + } + let [ContentItem::InputText { text }] = content.as_slice() else { + return false; + }; + + usage_hint_texts + .iter() + .any(|usage_hint_text| usage_hint_text == text) +} + /// Control-plane handle for multi-agent operations. /// `AgentControl` is held by each session (via `SessionServices`). It provides capability to /// spawn new agents and the inter-agent communication layer. @@ -248,6 +267,8 @@ impl AgentControl { config.clone(), self.clone(), session_source, + options.parent_thread_id, + /*forked_from_thread_id*/ None, /*thread_source*/ Some(ThreadSource::Subagent), /*persist_extended_history*/ false, /*metrics_service_name*/ None, @@ -289,6 +310,7 @@ impl AgentControl { } }; let thread_config = new_thread.thread.codex.thread_config_snapshot().await; + let parent_thread_id = thread_config.parent_thread_id; emit_subagent_session_started( &new_thread .thread @@ -297,8 +319,9 @@ impl AgentControl { .services .analytics_events_client, client_metadata, + new_thread.thread.codex.session.session_id(), new_thread.thread_id, - /*parent_thread_id*/ None, + parent_thread_id, thread_config, subagent_source.clone(), ); @@ -395,16 +418,26 @@ impl AgentControl { forked_rollout_items = truncate_rollout_to_last_n_fork_turns(&forked_rollout_items, *last_n_turns); } - // MultiAgentV2 root/subagent usage hints are injected as standalone developer - // messages at thread start. When forking history, drop hints from the parent - // so the child gets a fresh hint that matches its own session source/config. let multi_agent_v2_usage_hint_texts_to_filter: Vec = if let Some(parent_thread) = parent_thread.as_ref() { - parent_thread - .codex - .session - .configured_multi_agent_v2_usage_hint_texts() - .await + if parent_thread.enabled(Feature::MultiAgentV2) { + let parent_config = parent_thread.codex.session.get_config().await; + [ + parent_config + .multi_agent_v2 + .root_agent_usage_hint_text + .clone(), + parent_config + .multi_agent_v2 + .subagent_usage_hint_text + .clone(), + ] + .into_iter() + .flatten() + .collect() + } else { + Vec::new() + } } else if config.features.enabled(Feature::MultiAgentV2) { [ config.multi_agent_v2.root_agent_usage_hint_text.clone(), @@ -416,19 +449,42 @@ impl AgentControl { } else { Vec::new() }; + let preserve_reference_context_item = matches!(fork_mode, SpawnAgentForkMode::FullHistory); forked_rollout_items.retain(|item| { - if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = item - && role == "developer" - && let [ContentItem::InputText { text }] = content.as_slice() - && multi_agent_v2_usage_hint_texts_to_filter - .iter() - .any(|usage_hint_text| usage_hint_text == text) - { - return false; - } - - keep_forked_rollout_item(item) + keep_forked_rollout_item(item, preserve_reference_context_item) + && !matches!( + item, + RolloutItem::ResponseItem(response_item) + if is_multi_agent_v2_usage_hint_message( + response_item, + &multi_agent_v2_usage_hint_texts_to_filter, + ) + ) }); + for item in &mut forked_rollout_items { + if let RolloutItem::Compacted(compacted) = item + && let Some(replacement_history) = compacted.replacement_history.as_mut() + { + replacement_history.retain(|response_item| { + !is_multi_agent_v2_usage_hint_message( + response_item, + &multi_agent_v2_usage_hint_texts_to_filter, + ) + }); + } + } + if preserve_reference_context_item + && config.features.enabled(Feature::MultiAgentV2) + && config.multi_agent_v2.usage_hint_enabled + && let Some(subagent_usage_hint_text) = + config.multi_agent_v2.subagent_usage_hint_text.clone() + && let Some(subagent_usage_hint_message) = + crate::context_manager::updates::build_developer_update_item(vec![ + subagent_usage_hint_text, + ]) + { + forked_rollout_items.push(RolloutItem::ResponseItem(subagent_usage_hint_message)); + } state .fork_thread_with_source( @@ -437,6 +493,8 @@ impl AgentControl { self.clone(), session_source, /*thread_source*/ Some(ThreadSource::Subagent), + /*parent_thread_id*/ Some(parent_thread_id), + /*forked_from_thread_id*/ Some(parent_thread_id), /*persist_extended_history*/ false, inherited_shell_snapshot, inherited_exec_policy, @@ -498,13 +556,12 @@ impl AgentControl { agent_nickname: None, agent_role: None, }); - match self - .resume_single_agent_from_rollout( - config.clone(), - child_thread_id, - child_session_source, - ) - .await + match Box::pin(self.resume_single_agent_from_rollout( + config.clone(), + child_thread_id, + child_session_source, + )) + .await { Ok(_) => true, Err(err) => { @@ -585,6 +642,7 @@ impl AgentControl { .history .ok_or_else(|| CodexErr::ThreadNotFound(thread_id))? .items; + let parent_thread_id = stored_thread.parent_thread_id; let resumed_thread = state .resume_thread_with_history_with_source(ResumeThreadWithHistoryOptions { @@ -596,6 +654,7 @@ impl AgentControl { }), agent_control: self.clone(), session_source, + parent_thread_id, inherited_shell_snapshot, inherited_exec_policy, }) @@ -651,22 +710,6 @@ impl AgentControl { result } - /// Append a prebuilt message to an existing agent thread outside the normal user-input path. - #[cfg(test)] - pub(crate) async fn append_message( - &self, - agent_id: ThreadId, - message: ResponseItem, - ) -> CodexResult { - let state = self.upgrade()?; - self.handle_thread_request_result( - agent_id, - &state, - state.append_message(agent_id, message).await, - ) - .await - } - pub(crate) async fn send_inter_agent_communication( &self, agent_id: ThreadId, @@ -735,15 +778,45 @@ impl AgentControl { /// agent and any live descendants reached from the in-memory tree. pub(crate) async fn close_agent(&self, agent_id: ThreadId) -> CodexResult { let state = self.upgrade()?; - if let Ok(thread) = state.get_thread(agent_id).await - && let Some(state_db_ctx) = thread.state_db() - && let Err(err) = state_db_ctx - .set_thread_spawn_edge_status(agent_id, DirectionalThreadSpawnEdgeStatus::Closed) - .await - { - warn!("failed to persist thread-spawn edge status for {agent_id}: {err}"); + let known_agent = self.state.agent_metadata_for_thread(agent_id).is_some(); + match state.get_thread(agent_id).await { + Ok(thread) => { + if let Some(state_db_ctx) = thread.state_db() + && let Err(err) = state_db_ctx + .set_thread_spawn_edge_status( + agent_id, + DirectionalThreadSpawnEdgeStatus::Closed, + ) + .await + { + warn!("failed to persist thread-spawn edge status for {agent_id}: {err}"); + } + } + Err(CodexErr::ThreadNotFound(_)) if known_agent => { + if let Some(state_db_ctx) = state.state_db() + && let Err(err) = state_db_ctx + .set_thread_spawn_edge_status( + agent_id, + DirectionalThreadSpawnEdgeStatus::Closed, + ) + .await + { + return Err(CodexErr::Fatal(format!( + "failed to persist stale thread-spawn edge status for {agent_id}: {err}" + ))); + } + } + Err(CodexErr::ThreadNotFound(_)) => {} + Err(err) => { + warn!("failed to inspect agent before close {agent_id}: {err}"); + } + } + match Box::pin(self.shutdown_agent_tree(agent_id)).await { + Err(CodexErr::ThreadNotFound(_)) | Err(CodexErr::InternalAgentDied) if known_agent => { + Ok(String::new()) + } + result => result, } - Box::pin(self.shutdown_agent_tree(agent_id)).await } /// Shut down `agent_id` and any live descendants reachable from the in-memory spawn tree. @@ -774,9 +847,9 @@ impl AgentControl { pub(crate) fn register_session_root( &self, current_thread_id: ThreadId, - current_session_source: &SessionSource, + current_parent_thread_id: Option, ) { - if thread_spawn_parent_thread_id(current_session_source).is_none() { + if current_parent_thread_id.is_none() { self.state.register_root_thread(current_thread_id); } } @@ -1116,24 +1189,16 @@ impl AgentControl { let state = self.upgrade()?; let mut children_by_parent = HashMap::>::new(); - for thread_id in state.list_thread_ids().await { - let Ok(thread) = state.get_thread(thread_id).await else { - continue; - }; - let snapshot = thread.config_snapshot().await; - let Some(parent_thread_id) = thread_spawn_parent_thread_id(&snapshot.session_source) - else { - continue; - }; + for (parent_thread_id, child_thread_id) in state.list_live_thread_spawn_edges().await { children_by_parent .entry(parent_thread_id) .or_default() .push(( - thread_id, + child_thread_id, self.state - .agent_metadata_for_thread(thread_id) + .agent_metadata_for_thread(child_thread_id) .unwrap_or(AgentMetadata { - agent_id: Some(thread_id), + agent_id: Some(child_thread_id), ..Default::default() }), )); @@ -1159,7 +1224,8 @@ impl AgentControl { child_thread_id: ThreadId, session_source: Option<&SessionSource>, ) { - let Some(parent_thread_id) = session_source.and_then(thread_spawn_parent_thread_id) else { + let Some(parent_thread_id) = session_source.and_then(SessionSource::parent_thread_id) + else { return; }; let Some(state_db_ctx) = thread.state_db() else { @@ -1204,15 +1270,6 @@ impl AgentControl { } } -fn thread_spawn_parent_thread_id(session_source: &SessionSource) -> Option { - match session_source { - SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, .. - }) => Some(*parent_thread_id), - _ => None, - } -} - fn agent_matches_prefix(agent_path: Option<&AgentPath>, prefix: &AgentPath) -> bool { if prefix.is_root() { return true; @@ -1234,9 +1291,13 @@ pub(crate) fn render_input_preview(initial_operation: &Op) -> String { .map(|item| match item { UserInput::Text { text, .. } => text.clone(), UserInput::Image { .. } => "[image]".to_string(), - UserInput::LocalImage { path } => format!("[local_image:{}]", path.display()), - UserInput::Skill { name, path } => format!("[skill:${name}]({})", path.display()), - UserInput::Mention { name, path } => format!("[mention:${name}]({path})"), + UserInput::LocalImage { path, .. } => { + format!("[local_image:{}]", path.display()) + } + UserInput::Skill { name, path, .. } => { + format!("[skill:${name}]({})", path.display()) + } + UserInput::Mention { name, path, .. } => format!("[mention:${name}]({path})"), _ => "[input]".to_string(), }) .collect::>() diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index b95aad4489..3e0242c037 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -17,6 +17,7 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::models::ContentItem; use codex_protocol::models::MessagePhase; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::InterAgentCommunication; @@ -73,6 +74,15 @@ fn assistant_message(text: &str, phase: Option) -> ResponseItem { } } +#[test] +fn register_session_root_skips_threads_with_explicit_parent() { + let control = AgentControl::default(); + + control.register_session_root(ThreadId::new(), Some(ThreadId::new())); + + assert_eq!(control.state.agent_id_for_path(&AgentPath::root()), None); +} + fn spawn_agent_call(call_id: &str) -> ResponseItem { ResponseItem::FunctionCall { id: None, @@ -270,6 +280,7 @@ async fn get_status_returns_not_found_without_manager() { async fn on_event_updates_status_from_task_started() { let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent { turn_id: "turn-1".to_string(), + trace_id: None, started_at: None, model_context_window: None, collaboration_mode_kind: ModeKind::Default, @@ -442,6 +453,8 @@ async fn send_input_submits_user_message() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), }, ); let captured = harness @@ -486,7 +499,13 @@ async fn send_inter_agent_communication_without_turn_queues_message_without_trig timeout(Duration::from_secs(5), async { loop { - if thread.codex.session.has_pending_input().await { + if thread + .codex + .session + .input_queue + .has_pending_input(&thread.codex.session.active_turn) + .await + { break; } sleep(Duration::from_millis(10)).await; @@ -508,60 +527,6 @@ async fn send_inter_agent_communication_without_turn_queues_message_without_trig )); } -#[tokio::test] -async fn append_message_records_assistant_message() { - let harness = AgentControlHarness::new().await; - let (thread_id, thread) = harness.start_thread().await; - let message = - "author: /root\nrecipient: /root/worker\nother_recipients: []\nContent: hello from tests"; - - let submission_id = harness - .control - .append_message( - thread_id, - ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::InputText { - text: message.to_string(), - }], - phase: None, - }, - ) - .await - .expect("append_message should succeed"); - assert!(!submission_id.is_empty()); - - timeout(Duration::from_secs(5), async { - loop { - let history_items = thread - .codex - .session - .clone_history() - .await - .raw_items() - .to_vec(); - let recorded = history_items.iter().any(|item| { - matches!( - item, - ResponseItem::Message { role, content, .. } - if role == "assistant" - && content.iter().any(|content_item| matches!( - content_item, - ContentItem::InputText { text } if text == message - )) - ) - }); - if recorded { - break; - } - sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("assistant message should be recorded"); -} - #[tokio::test] async fn spawn_agent_creates_thread_and_sends_prompt() { let harness = AgentControlHarness::new().await; @@ -589,6 +554,8 @@ async fn spawn_agent_creates_thread_and_sends_prompt() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), }, ); let captured = harness @@ -669,6 +636,14 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { ], ) .await; + let parent_reference_context_item = turn_context.to_turn_context_item(); + parent_thread + .codex + .session + .persist_rollout_items(&[RolloutItem::TurnContext( + parent_reference_context_item.clone(), + )]) + .await; parent_thread .codex .session @@ -720,11 +695,73 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { phase: None, }, assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)), + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "Child subagent guidance.".to_string(), + }], + phase: None, + }, ]; assert_eq!( history.raw_items(), &expected_history, - "forked child history should keep only parent user messages and assistant final answers" + "full-history forked child history should replace parent usage hints with the child subagent hint while filtering non-final assistant/tool chatter" + ); + assert_eq!( + serde_json::to_value(child_thread.codex.session.reference_context_item().await) + .expect("serialize child reference context item"), + serde_json::to_value(Some(parent_reference_context_item)) + .expect("serialize expected reference context item"), + "full-history forked child should preserve the parent diff baseline" + ); + + let mut disabled_hint_child_config = harness.config.clone(); + let _ = disabled_hint_child_config + .features + .enable(Feature::MultiAgentV2); + disabled_hint_child_config.multi_agent_v2.usage_hint_enabled = false; + disabled_hint_child_config + .multi_agent_v2 + .subagent_usage_hint_text = Some("Disabled child subagent guidance.".to_string()); + let disabled_hint_child_thread_id = harness + .control + .spawn_agent_with_metadata( + disabled_hint_child_config, + text_input("child task without hints"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + })), + SpawnAgentOptions { + fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()), + fork_mode: Some(SpawnAgentForkMode::FullHistory), + ..Default::default() + }, + ) + .await + .expect("forked spawn should honor disabled usage hints") + .thread_id; + let disabled_hint_child_thread = harness + .manager + .get_thread(disabled_hint_child_thread_id) + .await + .expect("disabled-hint child thread should be registered"); + let disabled_hint_history = disabled_hint_child_thread + .codex + .session + .clone_history() + .await; + assert!( + !history_contains_text( + disabled_hint_history.raw_items(), + "Disabled child subagent guidance.", + ), + "full-history forked child should not add subagent guidance when usage hints are disabled" ); let expected = ( @@ -737,6 +774,8 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { }], final_output_json_schema: None, responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), }, ); let captured = harness @@ -746,6 +785,129 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { .find(|entry| *entry == expected); assert_eq!(captured, Some(expected)); + let _ = harness + .control + .shutdown_live_agent(child_thread_id) + .await + .expect("child shutdown should submit"); + let _ = harness + .control + .shutdown_live_agent(disabled_hint_child_thread_id) + .await + .expect("disabled-hint child shutdown should submit"); + let _ = parent_thread + .submit(Op::Shutdown {}) + .await + .expect("parent shutdown should submit"); +} + +#[tokio::test] +async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() { + let harness = AgentControlHarness::new().await; + let mut parent_config = harness.config.clone(); + let _ = parent_config.features.enable(Feature::MultiAgentV2); + parent_config.multi_agent_v2.root_agent_usage_hint_text = + Some("Parent root guidance.".to_string()); + parent_config.multi_agent_v2.subagent_usage_hint_text = + Some("Parent subagent guidance.".to_string()); + let mut child_config = harness.config.clone(); + let _ = child_config.features.enable(Feature::MultiAgentV2); + child_config.multi_agent_v2.root_agent_usage_hint_text = + Some("Child root guidance.".to_string()); + child_config.multi_agent_v2.subagent_usage_hint_text = + Some("Child subagent guidance.".to_string()); + let new_thread = harness + .manager + .start_thread(parent_config) + .await + .expect("start parent thread"); + let parent_thread_id = new_thread.thread_id; + let parent_thread = new_thread.thread; + let turn_context = parent_thread.codex.session.new_default_turn().await; + let parent_spawn_call_id = "spawn-call-compacted-usage-hints".to_string(); + let replacement_history = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "compacted parent summary".to_string(), + }], + phase: None, + }, + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "Parent root guidance.".to_string(), + }], + phase: None, + }, + ]; + parent_thread + .codex + .session + .persist_rollout_items(&[ + RolloutItem::Compacted(CompactedItem { + message: String::new(), + replacement_history: Some(replacement_history), + }), + RolloutItem::TurnContext(turn_context.to_turn_context_item()), + RolloutItem::ResponseItem(spawn_agent_call(&parent_spawn_call_id)), + ]) + .await; + parent_thread + .codex + .session + .ensure_rollout_materialized() + .await; + parent_thread + .codex + .session + .flush_rollout() + .await + .expect("parent rollout should flush"); + + let child_thread_id = harness + .control + .spawn_agent_with_metadata( + child_config, + text_input("child task"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + })), + SpawnAgentOptions { + fork_parent_spawn_call_id: Some(parent_spawn_call_id), + fork_mode: Some(SpawnAgentForkMode::FullHistory), + ..Default::default() + }, + ) + .await + .expect("forked spawn should sanitize compacted usage hints") + .thread_id; + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should be registered"); + let history = child_thread.codex.session.clone_history().await; + assert!( + history_contains_text(history.raw_items(), "compacted parent summary"), + "forked child history should retain compacted non-hint content" + ); + assert!( + !history_contains_text(history.raw_items(), "Parent root guidance."), + "forked child history should strip stale parent hints from compacted replacement history" + ); + assert!( + history_contains_text(history.raw_items(), "Child subagent guidance."), + "full-history forked child should add the child subagent hint after compacted-history sanitization" + ); + let _ = harness .control .shutdown_live_agent(child_thread_id) @@ -873,6 +1035,13 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() { &[spawn_agent_call(&parent_spawn_call_id)], ) .await; + parent_thread + .codex + .session + .persist_rollout_items(&[RolloutItem::TurnContext( + spawn_turn_context.to_turn_context_item(), + )]) + .await; parent_thread .codex .session @@ -930,6 +1099,218 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() { history_contains_text(history.raw_items(), "current parent task"), "forked child history should keep the parent user message from the requested last-N turn window" ); + assert!( + child_thread + .codex + .session + .reference_context_item() + .await + .is_none(), + "last-N forked child should rebuild context after truncating the cached prefix" + ); + + let _ = harness + .control + .shutdown_live_agent(child_thread_id) + .await + .expect("child shutdown should submit"); + let _ = parent_thread + .submit(Op::Shutdown {}) + .await + .expect("parent shutdown should submit"); +} + +#[tokio::test] +async fn spawn_agent_fork_last_n_turns_drops_parent_startup_prefix_when_under_limit() { + let harness = AgentControlHarness::new().await; + let (parent_thread_id, parent_thread) = harness.start_thread().await; + let startup_turn_context = parent_thread.codex.session.new_default_turn().await; + parent_thread + .codex + .session + .record_conversation_items( + startup_turn_context.as_ref(), + &[ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "parent startup developer context".to_string(), + }], + phase: None, + }], + ) + .await; + parent_thread + .inject_user_message_without_turn("current parent task".to_string()) + .await; + let spawn_turn_context = parent_thread.codex.session.new_default_turn().await; + let parent_spawn_call_id = "spawn-call-last-n-under-limit".to_string(); + parent_thread + .codex + .session + .record_conversation_items( + spawn_turn_context.as_ref(), + &[spawn_agent_call(&parent_spawn_call_id)], + ) + .await; + parent_thread + .codex + .session + .ensure_rollout_materialized() + .await; + parent_thread + .codex + .session + .flush_rollout() + .await + .expect("parent rollout should flush"); + + let child_thread_id = harness + .control + .spawn_agent_with_metadata( + harness.config.clone(), + text_input("child task"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + })), + SpawnAgentOptions { + fork_parent_spawn_call_id: Some(parent_spawn_call_id), + fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)), + ..Default::default() + }, + ) + .await + .expect("bounded forked spawn should drop startup prefix") + .thread_id; + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should be registered"); + let history = child_thread.codex.session.clone_history().await; + assert!( + history_contains_text(history.raw_items(), "current parent task"), + "bounded fork should retain the requested recent parent turn" + ); + assert!( + !history_contains_text(history.raw_items(), "parent startup developer context"), + "bounded fork should drop parent startup context even when fewer turns exist than requested" + ); + assert!( + child_thread + .codex + .session + .reference_context_item() + .await + .is_none(), + "bounded forked child should still rebuild context after truncating the cached prefix" + ); + + let _ = harness + .control + .shutdown_live_agent(child_thread_id) + .await + .expect("child shutdown should submit"); + let _ = parent_thread + .submit(Op::Shutdown {}) + .await + .expect("parent shutdown should submit"); +} + +#[tokio::test] +async fn spawn_agent_fork_last_n_turns_strips_parent_usage_hints() { + let harness = AgentControlHarness::new().await; + let mut parent_config = harness.config.clone(); + let _ = parent_config.features.enable(Feature::MultiAgentV2); + parent_config.multi_agent_v2.root_agent_usage_hint_text = + Some("Parent root guidance.".to_string()); + let mut child_config = harness.config.clone(); + let _ = child_config.features.enable(Feature::MultiAgentV2); + child_config.multi_agent_v2.subagent_usage_hint_text = + Some("Child subagent guidance.".to_string()); + let new_thread = harness + .manager + .start_thread(parent_config) + .await + .expect("start parent thread"); + let parent_thread_id = new_thread.thread_id; + let parent_thread = new_thread.thread; + parent_thread + .inject_user_message_without_turn("parent task".to_string()) + .await; + let turn_context = parent_thread.codex.session.new_default_turn().await; + let parent_spawn_call_id = "spawn-call-last-n-usage-hints".to_string(); + parent_thread + .codex + .session + .record_conversation_items( + turn_context.as_ref(), + &[ + ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "Parent root guidance.".to_string(), + }], + phase: None, + }, + spawn_agent_call(&parent_spawn_call_id), + ], + ) + .await; + parent_thread + .codex + .session + .ensure_rollout_materialized() + .await; + parent_thread + .codex + .session + .flush_rollout() + .await + .expect("parent rollout should flush"); + + let child_thread_id = harness + .control + .spawn_agent_with_metadata( + child_config, + text_input("child task"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + })), + SpawnAgentOptions { + fork_parent_spawn_call_id: Some(parent_spawn_call_id), + fork_mode: Some(SpawnAgentForkMode::LastNTurns(2)), + ..Default::default() + }, + ) + .await + .expect("bounded forked spawn should sanitize parent usage hints") + .thread_id; + + let child_thread = harness + .manager + .get_thread(child_thread_id) + .await + .expect("child thread should be registered"); + let history = child_thread.codex.session.clone_history().await; + assert!( + history_contains_text(history.raw_items(), "parent task"), + "bounded fork should retain the requested recent parent turn" + ); + assert!( + !history_contains_text(history.raw_items(), "Parent root guidance."), + "bounded fork should strip stale parent root hints before the child rebuilds startup context" + ); let _ = harness .control @@ -1855,6 +2236,64 @@ async fn list_agent_subtree_thread_ids_includes_anonymous_and_closed_descendants ); } +#[tokio::test] +async fn list_agent_subtree_thread_ids_includes_live_descendants_without_state_db() { + let (_home, config) = test_config().await; + let manager = ThreadManager::with_models_provider_home_and_state_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.to_path_buf(), + std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + /*state_db*/ None, + ); + let control = manager.agent_control(); + let parent_thread_id = manager + .start_thread(config.clone()) + .await + .expect("parent should start") + .thread_id; + + let child_thread_id = control + .spawn_agent( + config.clone(), + text_input("hello child"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: Some("explorer".to_string()), + })), + ) + .await + .expect("child spawn should succeed"); + let grandchild_thread_id = control + .spawn_agent( + config, + text_input("hello grandchild"), + Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: child_thread_id, + depth: 2, + agent_path: None, + agent_nickname: None, + agent_role: Some("worker".to_string()), + })), + ) + .await + .expect("grandchild spawn should succeed"); + + let mut subtree_thread_ids = manager + .list_agent_subtree_thread_ids(parent_thread_id) + .await + .expect("live subtree should load"); + subtree_thread_ids.sort_by_key(ToString::to_string); + let mut expected_subtree_thread_ids = + vec![parent_thread_id, child_thread_id, grandchild_thread_id]; + expected_subtree_thread_ids.sort_by_key(ToString::to_string); + + assert_eq!(subtree_thread_ids, expected_subtree_thread_ids); +} + #[tokio::test] async fn shutdown_agent_tree_closes_live_descendants() { let harness = AgentControlHarness::new().await; diff --git a/codex-rs/core/src/agent/mailbox.rs b/codex-rs/core/src/agent/mailbox.rs deleted file mode 100644 index c328236475..0000000000 --- a/codex-rs/core/src/agent/mailbox.rs +++ /dev/null @@ -1,161 +0,0 @@ -use codex_protocol::protocol::InterAgentCommunication; -use std::collections::VecDeque; -use std::sync::atomic::AtomicU64; -use std::sync::atomic::Ordering; -use tokio::sync::mpsc; -use tokio::sync::watch; - -#[cfg(test)] -use codex_protocol::AgentPath; - -pub(crate) struct Mailbox { - tx: mpsc::UnboundedSender, - next_seq: AtomicU64, - seq_tx: watch::Sender, -} - -pub(crate) struct MailboxReceiver { - rx: mpsc::UnboundedReceiver, - pending_mails: VecDeque, -} - -impl Mailbox { - pub(crate) fn new() -> (Self, MailboxReceiver) { - let (tx, rx) = mpsc::unbounded_channel(); - let (seq_tx, _) = watch::channel(0); - ( - Self { - tx, - next_seq: AtomicU64::new(0), - seq_tx, - }, - MailboxReceiver { - rx, - pending_mails: VecDeque::new(), - }, - ) - } - - pub(crate) fn subscribe(&self) -> watch::Receiver { - self.seq_tx.subscribe() - } - - pub(crate) fn send(&self, communication: InterAgentCommunication) -> u64 { - let seq = self.next_seq.fetch_add(1, Ordering::Relaxed) + 1; - let _ = self.tx.send(communication); - self.seq_tx.send_replace(seq); - seq - } -} - -impl MailboxReceiver { - fn sync_pending_mails(&mut self) { - while let Ok(mail) = self.rx.try_recv() { - self.pending_mails.push_back(mail); - } - } - - pub(crate) fn has_pending(&mut self) -> bool { - self.sync_pending_mails(); - !self.pending_mails.is_empty() - } - - pub(crate) fn has_pending_trigger_turn(&mut self) -> bool { - self.sync_pending_mails(); - self.pending_mails.iter().any(|mail| mail.trigger_turn) - } - - pub(crate) fn drain(&mut self) -> Vec { - self.sync_pending_mails(); - self.pending_mails.drain(..).collect() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - fn make_mail( - author: AgentPath, - recipient: AgentPath, - content: &str, - trigger_turn: bool, - ) -> InterAgentCommunication { - InterAgentCommunication::new( - author, - recipient, - Vec::new(), - content.to_string(), - trigger_turn, - ) - } - - #[tokio::test] - async fn mailbox_assigns_monotonic_sequence_numbers() { - let (mailbox, _receiver) = Mailbox::new(); - let mut seq_rx = mailbox.subscribe(); - - let seq_a = mailbox.send(make_mail( - AgentPath::root(), - AgentPath::try_from("/root/worker").expect("agent path"), - "one", - /*trigger_turn*/ false, - )); - let seq_b = mailbox.send(make_mail( - AgentPath::root(), - AgentPath::try_from("/root/worker").expect("agent path"), - "two", - /*trigger_turn*/ false, - )); - - seq_rx.changed().await.expect("first seq update"); - assert_eq!(*seq_rx.borrow(), seq_b); - assert_eq!(seq_a, 1); - assert_eq!(seq_b, 2); - } - - #[tokio::test] - async fn mailbox_drains_in_delivery_order() { - let (mailbox, mut receiver) = Mailbox::new(); - let mail_one = make_mail( - AgentPath::root(), - AgentPath::try_from("/root/worker").expect("agent path"), - "one", - /*trigger_turn*/ false, - ); - let mail_two = make_mail( - AgentPath::try_from("/root/worker").expect("agent path"), - AgentPath::root(), - "two", - /*trigger_turn*/ false, - ); - - mailbox.send(mail_one.clone()); - mailbox.send(mail_two.clone()); - - assert_eq!(receiver.drain(), vec![mail_one, mail_two]); - assert!(!receiver.has_pending()); - } - - #[tokio::test] - async fn mailbox_tracks_pending_trigger_turn_mail() { - let (mailbox, mut receiver) = Mailbox::new(); - - mailbox.send(make_mail( - AgentPath::root(), - AgentPath::try_from("/root/worker").expect("agent path"), - "queued", - /*trigger_turn*/ false, - )); - assert!(!receiver.has_pending_trigger_turn()); - - mailbox.send(make_mail( - AgentPath::root(), - AgentPath::try_from("/root/worker").expect("agent path"), - "wake", - /*trigger_turn*/ true, - )); - assert!(receiver.has_pending_trigger_turn()); - } -} diff --git a/codex-rs/core/src/agent/mod.rs b/codex-rs/core/src/agent/mod.rs index a60fc3004a..350962dc08 100644 --- a/codex-rs/core/src/agent/mod.rs +++ b/codex-rs/core/src/agent/mod.rs @@ -1,14 +1,11 @@ pub(crate) mod agent_resolver; pub(crate) mod control; -pub(crate) mod mailbox; mod registry; pub(crate) mod role; pub(crate) mod status; pub(crate) use codex_protocol::protocol::AgentStatus; pub(crate) use control::AgentControl; -pub(crate) use mailbox::Mailbox; -pub(crate) use mailbox::MailboxReceiver; pub(crate) use registry::exceeds_thread_spawn_depth_limit; pub(crate) use registry::next_thread_spawn_depth; pub(crate) use status::agent_status_from_event; diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index 2ab16cd22a..6d49c05573 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -2,9 +2,9 @@ //! //! Roles are selected at spawn time and are loaded with the same config machinery as //! `config.toml`. This module resolves built-in and user-defined role files, inserts the role as a -//! high-precedence layer, and preserves the caller's current profile/provider unless the role -//! explicitly takes ownership of model selection. It does not decide when to spawn a sub-agent or -//! which role to use; the multi-agent tool handler owns that orchestration. +//! high-precedence layer, and preserves the caller's current provider and service tier unless the +//! role layer sets them. It does not decide when to spawn a sub-agent or which role to use; the +//! multi-agent tool handler owns that orchestration. use crate::config::AgentRoleConfig; use crate::config::Config; @@ -29,14 +29,12 @@ use toml::Value as TomlValue; pub const DEFAULT_ROLE_NAME: &str = "default"; const AGENT_TYPE_UNAVAILABLE_ERROR: &str = "agent type is currently not available"; -/// Applies a named role layer to `config` while preserving caller-owned model selection. +/// Applies a named role layer to `config` while preserving caller-owned provider settings. /// /// The role layer is inserted at session-flag precedence so it can override persisted config, but -/// the caller's current `profile` and `model_provider` remain sticky runtime choices unless the -/// role explicitly sets `profile`, explicitly sets `model_provider`, or rewrites the active -/// profile's `model_provider` in place. Rebuilding the config without those overrides would make a -/// spawned agent silently fall back to the default provider, which is the bug this preservation -/// logic avoids. +/// the caller's current `model_provider` and `service_tier` remain sticky runtime choices unless +/// the role explicitly sets the corresponding top-level config key. Rebuilding the config without +/// those overrides would make a spawned agent silently fall back to default settings. pub(crate) async fn apply_role_to_config( config: &mut Config, role_name: Option<&str>, @@ -71,14 +69,14 @@ async fn apply_role_to_config_inner( { return Ok(()); } - let (preserve_current_profile, preserve_current_provider) = - preservation_policy(config, &role_layer_toml); + let preserve_current_provider = role_layer_toml.get("model_provider").is_none(); + let preserve_current_service_tier = role_layer_toml.get("service_tier").is_none(); *config = reload::build_next_config( config, role_layer_toml, - preserve_current_profile, preserve_current_provider, + preserve_current_service_tier, ) .await?; Ok(()) @@ -128,71 +126,38 @@ pub(crate) fn resolve_role_config<'a>( .or_else(|| built_in::configs().get(role_name)) } -fn preservation_policy(config: &Config, role_layer_toml: &TomlValue) -> (bool, bool) { - let role_selects_provider = role_layer_toml.get("model_provider").is_some(); - let role_selects_profile = role_layer_toml.get("profile").is_some(); - let role_updates_active_profile_provider = config - .active_profile - .as_ref() - .and_then(|active_profile| { - role_layer_toml - .get("profiles") - .and_then(TomlValue::as_table) - .and_then(|profiles| profiles.get(active_profile)) - .and_then(TomlValue::as_table) - .map(|profile| profile.contains_key("model_provider")) - }) - .unwrap_or(false); - let preserve_current_profile = !role_selects_provider && !role_selects_profile; - let preserve_current_provider = - preserve_current_profile && !role_updates_active_profile_provider; - (preserve_current_profile, preserve_current_provider) -} - mod reload { use super::*; pub(super) async fn build_next_config( config: &Config, role_layer_toml: TomlValue, - preserve_current_profile: bool, preserve_current_provider: bool, + preserve_current_service_tier: bool, ) -> anyhow::Result { - let active_profile_name = preserve_current_profile - .then_some(config.active_profile.as_deref()) - .flatten(); - let config_layer_stack = - build_config_layer_stack(config, &role_layer_toml, active_profile_name)?; - let mut merged_config = deserialize_effective_config(config, &config_layer_stack)?; - if preserve_current_profile { - merged_config.profile = None; - } + let config_layer_stack = build_config_layer_stack(config, &role_layer_toml)?; + let merged_config = deserialize_effective_config(config, &config_layer_stack)?; - let mut next_config = Config::load_config_with_layer_stack( + let next_config = Config::load_config_with_layer_stack( LOCAL_FS.as_ref(), merged_config, - reload_overrides(config, preserve_current_provider), + reload_overrides( + config, + preserve_current_provider, + preserve_current_service_tier, + ), config.codex_home.clone(), config_layer_stack, ) .await?; - if preserve_current_profile { - next_config.active_profile = config.active_profile.clone(); - } Ok(next_config) } fn build_config_layer_stack( config: &Config, role_layer_toml: &TomlValue, - active_profile_name: Option<&str>, ) -> anyhow::Result { let mut layers = existing_layers(config); - if let Some(resolved_profile_layer) = - resolved_profile_layer(config, &layers, role_layer_toml, active_profile_name)? - { - insert_layer(&mut layers, resolved_profile_layer); - } insert_layer(&mut layers, role_layer(role_layer_toml.clone())); Ok(ConfigLayerStack::new( layers, @@ -201,34 +166,6 @@ mod reload { )?) } - fn resolved_profile_layer( - config: &Config, - existing_layers: &[ConfigLayerEntry], - role_layer_toml: &TomlValue, - active_profile_name: Option<&str>, - ) -> anyhow::Result> { - let Some(active_profile_name) = active_profile_name else { - return Ok(None); - }; - - let mut layers = existing_layers.to_vec(); - insert_layer(&mut layers, role_layer(role_layer_toml.clone())); - let merged_config = deserialize_effective_config( - config, - &ConfigLayerStack::new( - layers, - config.config_layer_stack.requirements().clone(), - config.config_layer_stack.requirements_toml().clone(), - )?, - )?; - let resolved_profile = - merged_config.get_config_profile(Some(active_profile_name.to_string()))?; - Ok(Some(ConfigLayerEntry::new( - ConfigLayerSource::SessionFlags, - TomlValue::try_from(resolved_profile)?, - ))) - } - fn deserialize_effective_config( config: &Config, config_layer_stack: &ConfigLayerStack, @@ -261,10 +198,15 @@ mod reload { ConfigLayerEntry::new(ConfigLayerSource::SessionFlags, role_layer_toml) } - fn reload_overrides(config: &Config, preserve_current_provider: bool) -> ConfigOverrides { + fn reload_overrides( + config: &Config, + preserve_current_provider: bool, + preserve_current_service_tier: bool, + ) -> ConfigOverrides { ConfigOverrides { cwd: Some(config.cwd.to_path_buf()), model_provider: preserve_current_provider.then(|| config.model_provider_id.clone()), + service_tier: preserve_current_service_tier.then(|| config.service_tier.clone()), codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), ..Default::default() @@ -323,8 +265,11 @@ pub(crate) mod spawn_tool_spec { let reasoning_effort = role_toml .get("model_reasoning_effort") .and_then(TomlValue::as_str); + let service_tier = role_toml + .get("service_tier") + .and_then(TomlValue::as_str); - match (model, reasoning_effort) { + let model_and_reasoning_note = match (model, reasoning_effort) { (Some(model), Some(reasoning_effort)) => format!( "\n- This role's model is set to `{model}` and its reasoning effort is set to `{reasoning_effort}`. These settings cannot be changed." ), @@ -339,7 +284,15 @@ pub(crate) mod spawn_tool_spec { ) } (None, None) => String::new(), - } + }; + let service_tier_note = service_tier + .map(|service_tier| { + format!( + "\n- This role's service tier is set to `{service_tier}`. If it is supported by the resolved model, it takes precedence over a valid spawn request service tier." + ) + }) + .unwrap_or_default(); + format!("{model_and_reasoning_note}{service_tier_note}") }) .unwrap_or_default(); format!("{name}: {{\n{description}{locked_settings_note}\n}}") diff --git a/codex-rs/core/src/agent/role_tests.rs b/codex-rs/core/src/agent/role_tests.rs index 1c99fb5950..5461323a36 100644 --- a/codex-rs/core/src/agent/role_tests.rs +++ b/codex-rs/core/src/agent/role_tests.rs @@ -1,12 +1,10 @@ use super::*; use crate::SkillsManager; -use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; use crate::skills_load_input_from_config; use codex_config::ConfigLayerStackOrdering; use codex_core_plugins::PluginsManager; -use codex_protocol::config_types::ReasoningSummary; -use codex_protocol::config_types::Verbosity; +use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use codex_utils_absolute_path::test_support::PathExt; use pretty_assertions::assert_eq; @@ -215,90 +213,13 @@ async fn apply_role_preserves_unspecified_keys() { } #[tokio::test] -async fn apply_role_preserves_active_profile_and_model_provider() { - let home = TempDir::new().expect("create temp dir"); - tokio::fs::write( - home.path().join(CONFIG_TOML_FILE), - r#" -[model_providers.test-provider] -name = "Test Provider" -base_url = "https://example.com/v1" -env_key = "TEST_PROVIDER_API_KEY" -wire_api = "responses" - -[profiles.test-profile] -model_provider = "test-provider" -"#, - ) - .await - .expect("write config.toml"); - let mut config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - config_profile: Some("test-profile".to_string()), - ..Default::default() - }) - .fallback_cwd(Some(home.path().to_path_buf())) - .build() - .await - .expect("load config"); +async fn apply_role_reports_explicit_service_tier() { + let (home, mut config) = test_config_with_cli_overrides(Vec::new()).await; let role_path = write_role_config( &home, - "empty-role.toml", - "developer_instructions = \"Stay focused\"", - ) - .await; - config.agent_roles.insert( - "custom".to_string(), - AgentRoleConfig { - description: None, - config_file: Some(role_path), - nickname_candidates: None, - }, - ); - - apply_role_to_config(&mut config, Some("custom")) - .await - .expect("custom role should apply"); - - assert_eq!(config.active_profile.as_deref(), Some("test-profile")); - assert_eq!(config.model_provider_id, "test-provider"); - assert_eq!(config.model_provider.name, "Test Provider"); -} - -#[tokio::test] -async fn apply_role_top_level_profile_settings_override_preserved_profile() { - let home = TempDir::new().expect("create temp dir"); - tokio::fs::write( - home.path().join(CONFIG_TOML_FILE), - r#" -[profiles.base-profile] -model = "profile-model" -model_reasoning_effort = "low" -model_reasoning_summary = "concise" -model_verbosity = "low" -"#, - ) - .await - .expect("write config.toml"); - let mut config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - config_profile: Some("base-profile".to_string()), - ..Default::default() - }) - .fallback_cwd(Some(home.path().to_path_buf())) - .build() - .await - .expect("load config"); - let role_path = write_role_config( - &home, - "top-level-profile-settings-role.toml", + "tiered-role.toml", r#"developer_instructions = "Stay focused" -model = "role-model" -model_reasoning_effort = "high" -model_reasoning_summary = "detailed" -model_verbosity = "high" +service_tier = "priority" "#, ) .await; @@ -315,178 +236,20 @@ model_verbosity = "high" .await .expect("custom role should apply"); - assert_eq!(config.active_profile.as_deref(), Some("base-profile")); - assert_eq!(config.model.as_deref(), Some("role-model")); - assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High)); assert_eq!( - config.model_reasoning_summary, - Some(ReasoningSummary::Detailed) + config.service_tier, + Some(ServiceTier::Fast.request_value().to_string()) ); - assert_eq!(config.model_verbosity, Some(Verbosity::High)); } #[tokio::test] -async fn apply_role_uses_role_profile_instead_of_current_profile() { - let home = TempDir::new().expect("create temp dir"); - tokio::fs::write( - home.path().join(CONFIG_TOML_FILE), - r#" -[model_providers.base-provider] -name = "Base Provider" -base_url = "https://base.example.com/v1" -env_key = "BASE_PROVIDER_API_KEY" -wire_api = "responses" - -[model_providers.role-provider] -name = "Role Provider" -base_url = "https://role.example.com/v1" -env_key = "ROLE_PROVIDER_API_KEY" -wire_api = "responses" - -[profiles.base-profile] -model_provider = "base-provider" - -[profiles.role-profile] -model_provider = "role-provider" -"#, - ) - .await - .expect("write config.toml"); - let mut config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - config_profile: Some("base-profile".to_string()), - ..Default::default() - }) - .fallback_cwd(Some(home.path().to_path_buf())) - .build() - .await - .expect("load config"); +async fn apply_role_preserves_existing_service_tier_without_override() { + let (home, mut config) = test_config_with_cli_overrides(Vec::new()).await; + config.service_tier = Some(ServiceTier::Fast.request_value().to_string()); let role_path = write_role_config( &home, - "profile-role.toml", - "developer_instructions = \"Stay focused\"\nprofile = \"role-profile\"", - ) - .await; - config.agent_roles.insert( - "custom".to_string(), - AgentRoleConfig { - description: None, - config_file: Some(role_path), - nickname_candidates: None, - }, - ); - - apply_role_to_config(&mut config, Some("custom")) - .await - .expect("custom role should apply"); - - assert_eq!(config.active_profile.as_deref(), Some("role-profile")); - assert_eq!(config.model_provider_id, "role-provider"); - assert_eq!(config.model_provider.name, "Role Provider"); -} - -#[tokio::test] -async fn apply_role_uses_role_model_provider_instead_of_current_profile_provider() { - let home = TempDir::new().expect("create temp dir"); - tokio::fs::write( - home.path().join(CONFIG_TOML_FILE), - r#" -[model_providers.base-provider] -name = "Base Provider" -base_url = "https://base.example.com/v1" -env_key = "BASE_PROVIDER_API_KEY" -wire_api = "responses" - -[model_providers.role-provider] -name = "Role Provider" -base_url = "https://role.example.com/v1" -env_key = "ROLE_PROVIDER_API_KEY" -wire_api = "responses" - -[profiles.base-profile] -model_provider = "base-provider" -"#, - ) - .await - .expect("write config.toml"); - let mut config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - config_profile: Some("base-profile".to_string()), - ..Default::default() - }) - .fallback_cwd(Some(home.path().to_path_buf())) - .build() - .await - .expect("load config"); - let role_path = write_role_config( - &home, - "provider-role.toml", - "developer_instructions = \"Stay focused\"\nmodel_provider = \"role-provider\"", - ) - .await; - config.agent_roles.insert( - "custom".to_string(), - AgentRoleConfig { - description: None, - config_file: Some(role_path), - nickname_candidates: None, - }, - ); - - apply_role_to_config(&mut config, Some("custom")) - .await - .expect("custom role should apply"); - - assert_eq!(config.active_profile, None); - assert_eq!(config.model_provider_id, "role-provider"); - assert_eq!(config.model_provider.name, "Role Provider"); -} - -#[tokio::test] -async fn apply_role_uses_active_profile_model_provider_update() { - let home = TempDir::new().expect("create temp dir"); - tokio::fs::write( - home.path().join(CONFIG_TOML_FILE), - r#" -[model_providers.base-provider] -name = "Base Provider" -base_url = "https://base.example.com/v1" -env_key = "BASE_PROVIDER_API_KEY" -wire_api = "responses" - -[model_providers.role-provider] -name = "Role Provider" -base_url = "https://role.example.com/v1" -env_key = "ROLE_PROVIDER_API_KEY" -wire_api = "responses" - -[profiles.base-profile] -model_provider = "base-provider" -model_reasoning_effort = "low" -"#, - ) - .await - .expect("write config.toml"); - let mut config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - config_profile: Some("base-profile".to_string()), - ..Default::default() - }) - .fallback_cwd(Some(home.path().to_path_buf())) - .build() - .await - .expect("load config"); - let role_path = write_role_config( - &home, - "profile-edit-role.toml", + "default-tier-role.toml", r#"developer_instructions = "Stay focused" - -[profiles.base-profile] -model_provider = "role-provider" -model_reasoning_effort = "high" "#, ) .await; @@ -503,10 +266,10 @@ model_reasoning_effort = "high" .await .expect("custom role should apply"); - assert_eq!(config.active_profile.as_deref(), Some("base-profile")); - assert_eq!(config.model_provider_id, "role-provider"); - assert_eq!(config.model_provider.name, "Role Provider"); - assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High)); + assert_eq!( + config.service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); } #[tokio::test] @@ -766,6 +529,31 @@ fn spawn_tool_spec_marks_role_locked_reasoning_effort_only() { )); } +#[test] +fn spawn_tool_spec_marks_role_locked_service_tier() { + let tempdir = TempDir::new().expect("create temp dir"); + let role_path = tempdir.path().join("tiered.toml"); + fs::write( + &role_path, + "developer_instructions = \"Stay fast\"\nservice_tier = \"priority\"\n", + ) + .expect("write role config"); + let user_defined_roles = BTreeMap::from([( + "tiered".to_string(), + AgentRoleConfig { + description: Some("Stay fast.".to_string()), + config_file: Some(role_path), + nickname_candidates: None, + }, + )]); + + let spec = spawn_tool_spec::build(&user_defined_roles); + + assert!(spec.contains( + "Stay fast.\n- This role's service tier is set to `priority`. If it is supported by the resolved model, it takes precedence over a valid spawn request service tier." + )); +} + #[test] fn built_in_config_file_contents_resolves_explorer_only() { assert_eq!( diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index 7a9fd74932..8048552480 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -23,16 +23,15 @@ use codex_config::merge_toml_values; use codex_config::project_root_markers_from_config; use codex_exec_server::Environment; use codex_exec_server::ExecutorFileSystem; +use codex_exec_server::LOCAL_FS; use codex_features::Feature; +use codex_prompts::HIERARCHICAL_AGENTS_MESSAGE; use codex_utils_absolute_path::AbsolutePathBuf; use dunce::canonicalize as normalize_path; use std::io; use toml::Value as TomlValue; use tracing::error; -pub(crate) const HIERARCHICAL_AGENTS_MESSAGE: &str = - include_str!("../hierarchical_agents_message.md"); - /// Default filename scanned for AGENTS.md instructions. pub const DEFAULT_AGENTS_MD_FILENAME: &str = "AGENTS.md"; /// Preferred local override for AGENTS.md instructions. @@ -58,20 +57,34 @@ impl<'a> AgentsMdManager<'a> { Self { config } } - pub(crate) fn load_global_instructions( + pub(crate) async fn load_global_instructions( + fs: &dyn ExecutorFileSystem, codex_dir: Option<&AbsolutePathBuf>, + startup_warnings: &mut Vec, ) -> Option { let base = codex_dir?; for candidate in [LOCAL_AGENTS_MD_FILENAME, DEFAULT_AGENTS_MD_FILENAME] { let path = base.join(candidate); - if let Ok(contents) = std::fs::read_to_string(&path) { - 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 @@ -82,16 +95,19 @@ impl<'a> AgentsMdManager<'a> { pub(crate) async fn user_instructions( &self, environment: Option<&Environment>, + startup_warnings: &mut Vec, ) -> Option { let fs = environment?.get_filesystem(); - self.user_instructions_with_fs(fs.as_ref()).await + self.user_instructions_with_fs(fs.as_ref(), startup_warnings) + .await } - pub(crate) async fn user_instructions_with_fs( + async fn user_instructions_with_fs( &self, fs: &dyn ExecutorFileSystem, + startup_warnings: &mut Vec, ) -> Option { - let agents_md_docs = self.read_agents_md(fs).await; + let agents_md_docs = self.read_agents_md(fs, startup_warnings).await; let mut output = String::new(); @@ -128,9 +144,15 @@ impl<'a> AgentsMdManager<'a> { /// Returns all instruction source files included in the current config. pub async fn instruction_sources(&self, fs: &dyn ExecutorFileSystem) -> Vec { - let mut paths = Self::load_global_instructions(Some(&self.config.codex_home)) - .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) => { @@ -146,7 +168,11 @@ impl<'a> AgentsMdManager<'a> { /// concatenation of all discovered docs. If no documentation file is found /// the function returns `Ok(None)`. Unexpected I/O failures bubble up as /// `Err` so callers can decide how to handle them. - async fn read_agents_md(&self, fs: &dyn ExecutorFileSystem) -> io::Result> { + async fn read_agents_md( + &self, + fs: &dyn ExecutorFileSystem, + startup_warnings: &mut Vec, + ) -> io::Result> { let max_total = self.config.project_doc_max_bytes; if max_total == 0 { @@ -178,6 +204,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); @@ -320,6 +348,20 @@ impl<'a> AgentsMdManager<'a> { } } +fn warn_invalid_utf8( + path: &AbsolutePathBuf, + data: &[u8], + source: &str, + startup_warnings: &mut Vec, +) { + if let Err(err) = std::str::from_utf8(data) { + startup_warnings.push(format!( + "{source} AGENTS.md instructions from `{}` contain invalid UTF-8: {err}. Invalid byte sequences were replaced.", + path.display() + )); + } +} + #[cfg(test)] #[path = "agents_md_tests.rs"] mod tests; diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index a3a7544823..2d6a5fa30b 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -7,12 +7,14 @@ use core_test_support::PathBufExt; use core_test_support::TempDirExt; use pretty_assertions::assert_eq; use std::fs; +use std::path::Path; use std::path::PathBuf; use tempfile::TempDir; async fn get_user_instructions(config: &Config) -> Option { + let mut warnings = Vec::new(); AgentsMdManager::new(config) - .user_instructions_with_fs(LOCAL_FS.as_ref()) + .user_instructions_with_fs(LOCAL_FS.as_ref(), &mut warnings) .await } @@ -22,6 +24,19 @@ async fn agents_md_paths(config: &Config) -> std::io::Result>, - #[serde(skip_serializing_if = "Option::is_none")] - input: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - policies: Option, - action: serde_json::Map, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct ArcMonitorResult { - outcome: ArcMonitorResultOutcome, - short_reason: String, - rationale: String, - risk_score: u8, - risk_level: ArcMonitorRiskLevel, - evidence: Vec, -} - -#[derive(Debug, Serialize, PartialEq)] -struct ArcMonitorChatMessage { - role: String, - content: serde_json::Value, -} - -#[derive(Debug, Serialize, PartialEq)] -struct ArcMonitorPolicies { - user: Option, - developer: Option, -} - -#[derive(Debug, Serialize, PartialEq)] -#[serde(deny_unknown_fields)] -struct ArcMonitorMetadata { - codex_thread_id: String, - codex_turn_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - conversation_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - protection_client_callsite: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -#[allow(dead_code)] -struct ArcMonitorEvidence { - message: String, - why: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "kebab-case")] -enum ArcMonitorResultOutcome { - Ok, - SteerModel, - AskUser, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "lowercase")] -enum ArcMonitorRiskLevel { - Low, - Medium, - High, - Critical, -} - -pub(crate) async fn monitor_action( - sess: &Session, - turn_context: &TurnContext, - action: serde_json::Value, - protection_client_callsite: &'static str, -) -> ArcMonitorOutcome { - let auth = match turn_context.auth_manager.as_ref() { - Some(auth_manager) => match auth_manager.auth().await { - Some(auth) if auth.uses_codex_backend() => Some(auth), - _ => None, - }, - None => None, - }; - let env_token = read_non_empty_env_var(CODEX_ARC_MONITOR_TOKEN); - if env_token.is_none() && auth.is_none() { - return ArcMonitorOutcome::Ok; - } - - let url = read_non_empty_env_var(CODEX_ARC_MONITOR_ENDPOINT_OVERRIDE).unwrap_or_else(|| { - format!( - "{}/codex/safety/arc", - turn_context.config.chatgpt_base_url.trim_end_matches('/') - ) - }); - let action = match action { - serde_json::Value::Object(action) => action, - _ => { - warn!("skipping safety monitor because action payload is not an object"); - return ArcMonitorOutcome::Ok; - } - }; - let body = - build_arc_monitor_request(sess, turn_context, action, protection_client_callsite).await; - let client = build_reqwest_client(); - let mut request = client.post(&url).timeout(ARC_MONITOR_TIMEOUT).json(&body); - if let Some(token) = env_token { - request = request.bearer_auth(token); - } else if let Some(auth) = auth.as_ref() { - request = - request.headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers()); - } - - let response = match request.send().await { - Ok(response) => response, - Err(err) => { - warn!(error = %err, %url, "safety monitor request failed"); - return ArcMonitorOutcome::Ok; - } - }; - let status = response.status(); - if !status.is_success() { - let response_text = response.text().await.unwrap_or_default(); - warn!( - %status, - %url, - response_text, - "safety monitor returned non-success status" - ); - return ArcMonitorOutcome::Ok; - } - - let response = match response.json::().await { - Ok(response) => response, - Err(err) => { - warn!(error = %err, %url, "failed to parse safety monitor response"); - return ArcMonitorOutcome::Ok; - } - }; - tracing::debug!( - risk_score = response.risk_score, - risk_level = ?response.risk_level, - evidence_count = response.evidence.len(), - "safety monitor completed" - ); - - let short_reason = response.short_reason.trim(); - let rationale = response.rationale.trim(); - match response.outcome { - ArcMonitorResultOutcome::Ok => ArcMonitorOutcome::Ok, - ArcMonitorResultOutcome::AskUser => { - if !short_reason.is_empty() { - ArcMonitorOutcome::AskUser(short_reason.to_string()) - } else if !rationale.is_empty() { - ArcMonitorOutcome::AskUser(rationale.to_string()) - } else { - ArcMonitorOutcome::AskUser( - "Additional confirmation is required before this tool call can continue." - .to_string(), - ) - } - } - ArcMonitorResultOutcome::SteerModel => { - if !rationale.is_empty() { - ArcMonitorOutcome::SteerModel(rationale.to_string()) - } else if !short_reason.is_empty() { - ArcMonitorOutcome::SteerModel(short_reason.to_string()) - } else { - ArcMonitorOutcome::SteerModel( - "Tool call was cancelled because of safety risks.".to_string(), - ) - } - } - } -} - -fn read_non_empty_env_var(key: &str) -> Option { - match env::var(key) { - Ok(value) => { - let value = value.trim(); - (!value.is_empty()).then(|| value.to_string()) - } - Err(env::VarError::NotPresent) => None, - Err(env::VarError::NotUnicode(_)) => { - warn!( - env_var = key, - "ignoring non-unicode safety monitor env override" - ); - None - } - } -} - -async fn build_arc_monitor_request( - sess: &Session, - turn_context: &TurnContext, - action: serde_json::Map, - protection_client_callsite: &'static str, -) -> ArcMonitorRequest { - let history = sess.clone_history().await; - let mut messages = build_arc_monitor_messages(history.raw_items()); - if messages.is_empty() { - messages.push(build_arc_monitor_message( - "user", - serde_json::Value::String( - "No prior conversation history is available for this ARC evaluation.".to_string(), - ), - )); - } - - let conversation_id = sess.conversation_id.to_string(); - ArcMonitorRequest { - metadata: ArcMonitorMetadata { - codex_thread_id: conversation_id.clone(), - codex_turn_id: turn_context.sub_id.clone(), - conversation_id: Some(conversation_id), - protection_client_callsite: Some(protection_client_callsite.to_string()), - }, - messages: Some(messages), - input: None, - policies: Some(ArcMonitorPolicies { - user: None, - developer: None, - }), - action, - } -} - -fn build_arc_monitor_messages(items: &[ResponseItem]) -> Vec { - let last_tool_call_index = items - .iter() - .enumerate() - .rev() - .find(|(_, item)| { - matches!( - item, - ResponseItem::LocalShellCall { .. } - | ResponseItem::FunctionCall { .. } - | ResponseItem::CustomToolCall { .. } - | ResponseItem::WebSearchCall { .. } - ) - }) - .map(|(index, _)| index); - let last_encrypted_reasoning_index = items - .iter() - .enumerate() - .rev() - .find(|(_, item)| { - matches!( - item, - ResponseItem::Reasoning { - encrypted_content: Some(encrypted_content), - .. - } if !encrypted_content.trim().is_empty() - ) - }) - .map(|(index, _)| index); - - items - .iter() - .enumerate() - .filter_map(|(index, item)| { - build_arc_monitor_message_item( - item, - index, - last_tool_call_index, - last_encrypted_reasoning_index, - ) - }) - .collect() -} - -fn build_arc_monitor_message_item( - item: &ResponseItem, - index: usize, - last_tool_call_index: Option, - last_encrypted_reasoning_index: Option, -) -> Option { - match item { - ResponseItem::Message { role, content, .. } if role == "user" => { - if is_contextual_user_message_content(content) { - None - } else { - content_items_to_text(content) - .map(|text| build_arc_monitor_text_message("user", "input_text", text)) - } - } - ResponseItem::Message { - role, - content, - phase: Some(MessagePhase::FinalAnswer), - .. - } if role == "assistant" => content_items_to_text(content) - .map(|text| build_arc_monitor_text_message("assistant", "output_text", text)), - ResponseItem::Message { .. } => None, - ResponseItem::Reasoning { - encrypted_content: Some(encrypted_content), - .. - } if Some(index) == last_encrypted_reasoning_index - && !encrypted_content.trim().is_empty() => - { - Some(build_arc_monitor_message( - "assistant", - serde_json::json!([{ - "type": "encrypted_reasoning", - "encrypted_content": encrypted_content, - }]), - )) - } - ResponseItem::Reasoning { .. } => None, - ResponseItem::LocalShellCall { action, .. } if Some(index) == last_tool_call_index => { - Some(build_arc_monitor_message( - "assistant", - serde_json::json!([{ - "type": "tool_call", - "tool_name": "shell", - "action": action, - }]), - )) - } - ResponseItem::FunctionCall { - name, arguments, .. - } if Some(index) == last_tool_call_index => Some(build_arc_monitor_message( - "assistant", - serde_json::json!([{ - "type": "tool_call", - "tool_name": name, - "arguments": arguments, - }]), - )), - ResponseItem::CustomToolCall { name, input, .. } if Some(index) == last_tool_call_index => { - Some(build_arc_monitor_message( - "assistant", - serde_json::json!([{ - "type": "tool_call", - "tool_name": name, - "input": input, - }]), - )) - } - ResponseItem::WebSearchCall { action, .. } if Some(index) == last_tool_call_index => { - Some(build_arc_monitor_message( - "assistant", - serde_json::json!([{ - "type": "tool_call", - "tool_name": "web_search", - "action": action, - }]), - )) - } - ResponseItem::LocalShellCall { .. } - | ResponseItem::FunctionCall { .. } - | ResponseItem::CustomToolCall { .. } - | ResponseItem::ToolSearchCall { .. } - | ResponseItem::WebSearchCall { .. } - | ResponseItem::FunctionCallOutput { .. } - | ResponseItem::CustomToolCallOutput { .. } - | ResponseItem::ToolSearchOutput { .. } - | ResponseItem::ImageGenerationCall { .. } - | ResponseItem::Compaction { .. } - | ResponseItem::ContextCompaction { .. } - | ResponseItem::Other => None, - } -} - -fn build_arc_monitor_text_message( - role: &str, - part_type: &str, - text: String, -) -> ArcMonitorChatMessage { - build_arc_monitor_message( - role, - serde_json::json!([{ - "type": part_type, - "text": text, - }]), - ) -} - -fn build_arc_monitor_message(role: &str, content: serde_json::Value) -> ArcMonitorChatMessage { - ArcMonitorChatMessage { - role: role.to_string(), - content, - } -} - -#[cfg(test)] -#[path = "arc_monitor_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/arc_monitor_tests.rs b/codex-rs/core/src/arc_monitor_tests.rs deleted file mode 100644 index 643042ec99..0000000000 --- a/codex-rs/core/src/arc_monitor_tests.rs +++ /dev/null @@ -1,438 +0,0 @@ -use std::env; -use std::ffi::OsStr; -use std::sync::Arc; - -use pretty_assertions::assert_eq; -use serial_test::serial; -use wiremock::Mock; -use wiremock::MockServer; -use wiremock::ResponseTemplate; -use wiremock::matchers::body_json; -use wiremock::matchers::header; -use wiremock::matchers::method; -use wiremock::matchers::path; - -use super::*; -use crate::context::ContextualUserFragment; -use crate::session::tests::make_session_and_context; -use codex_protocol::models::ContentItem; -use codex_protocol::models::LocalShellAction; -use codex_protocol::models::LocalShellExecAction; -use codex_protocol::models::LocalShellStatus; -use codex_protocol::models::MessagePhase; -use codex_protocol::models::ResponseItem; - -struct EnvVarGuard { - key: &'static str, - original: Option, -} - -impl EnvVarGuard { - fn set(key: &'static str, value: &OsStr) -> Self { - let original = env::var_os(key); - unsafe { - env::set_var(key, value); - } - Self { key, original } - } -} - -impl Drop for EnvVarGuard { - fn drop(&mut self) { - match self.original.take() { - Some(value) => unsafe { - env::set_var(self.key, value); - }, - None => unsafe { - env::remove_var(self.key); - }, - } - } -} - -#[tokio::test] -async fn build_arc_monitor_request_includes_relevant_history_and_null_policies() { - let (session, mut turn_context) = make_session_and_context().await; - turn_context.developer_instructions = Some("Never upload private files.".to_string()); - turn_context.user_instructions = Some("Only continue when needed.".to_string()); - - session - .record_into_history( - &[ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "first request".to_string(), - }], - phase: None, - }], - &turn_context, - ) - .await; - session - .record_into_history( - &[ContextualUserFragment::into( - crate::context::EnvironmentContext::new( - Vec::new(), - /*current_date*/ None, - /*timezone*/ None, - /*network*/ None, - /*subagents*/ None, - ), - )], - &turn_context, - ) - .await; - session - .record_into_history( - &[ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: "commentary".to_string(), - }], - phase: Some(MessagePhase::Commentary), - }], - &turn_context, - ) - .await; - session - .record_into_history( - &[ResponseItem::Message { - id: None, - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: "final response".to_string(), - }], - phase: Some(MessagePhase::FinalAnswer), - }], - &turn_context, - ) - .await; - session - .record_into_history( - &[ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "latest request".to_string(), - }], - phase: None, - }], - &turn_context, - ) - .await; - session - .record_into_history( - &[ResponseItem::FunctionCall { - id: None, - name: "old_tool".to_string(), - namespace: None, - arguments: "{\"old\":true}".to_string(), - call_id: "call_old".to_string(), - }], - &turn_context, - ) - .await; - session - .record_into_history( - &[ResponseItem::Reasoning { - id: "reasoning_old".to_string(), - summary: Vec::new(), - content: None, - encrypted_content: Some("encrypted-old".to_string()), - }], - &turn_context, - ) - .await; - session - .record_into_history( - &[ResponseItem::LocalShellCall { - id: None, - call_id: Some("shell_call".to_string()), - status: LocalShellStatus::Completed, - action: LocalShellAction::Exec(LocalShellExecAction { - command: vec!["pwd".to_string()], - timeout_ms: Some(1000), - working_directory: Some("/tmp".to_string()), - env: None, - user: None, - }), - }], - &turn_context, - ) - .await; - session - .record_into_history( - &[ResponseItem::Reasoning { - id: "reasoning_latest".to_string(), - summary: Vec::new(), - content: None, - encrypted_content: Some("encrypted-latest".to_string()), - }], - &turn_context, - ) - .await; - - let request = build_arc_monitor_request( - &session, - &turn_context, - serde_json::from_value(serde_json::json!({ "tool": "mcp_tool_call" })) - .expect("action should deserialize"), - "normal", - ) - .await; - - assert_eq!( - request, - ArcMonitorRequest { - metadata: ArcMonitorMetadata { - codex_thread_id: session.conversation_id.to_string(), - codex_turn_id: turn_context.sub_id.clone(), - conversation_id: Some(session.conversation_id.to_string()), - protection_client_callsite: Some("normal".to_string()), - }, - messages: Some(vec![ - ArcMonitorChatMessage { - role: "user".to_string(), - content: serde_json::json!([{ - "type": "input_text", - "text": "first request", - }]), - }, - ArcMonitorChatMessage { - role: "assistant".to_string(), - content: serde_json::json!([{ - "type": "output_text", - "text": "final response", - }]), - }, - ArcMonitorChatMessage { - role: "user".to_string(), - content: serde_json::json!([{ - "type": "input_text", - "text": "latest request", - }]), - }, - ArcMonitorChatMessage { - role: "assistant".to_string(), - content: serde_json::json!([{ - "type": "tool_call", - "tool_name": "shell", - "action": { - "type": "exec", - "command": ["pwd"], - "timeout_ms": 1000, - "working_directory": "/tmp", - "env": null, - "user": null, - }, - }]), - }, - ArcMonitorChatMessage { - role: "assistant".to_string(), - content: serde_json::json!([{ - "type": "encrypted_reasoning", - "encrypted_content": "encrypted-latest", - }]), - }, - ]), - input: None, - policies: Some(ArcMonitorPolicies { - user: None, - developer: None, - }), - action: serde_json::from_value(serde_json::json!({ "tool": "mcp_tool_call" })) - .expect("action should deserialize"), - } - ); -} - -#[tokio::test] -#[serial(arc_monitor_env)] -async fn monitor_action_posts_expected_arc_request() { - let server = MockServer::start().await; - let (session, mut turn_context) = make_session_and_context().await; - turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth( - codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(), - )); - turn_context.developer_instructions = Some("Developer policy".to_string()); - turn_context.user_instructions = Some("User policy".to_string()); - - let mut config = (*turn_context.config).clone(); - config.chatgpt_base_url = server.uri(); - turn_context.config = Arc::new(config); - - session - .record_into_history( - &[ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "please run the tool".to_string(), - }], - phase: None, - }], - &turn_context, - ) - .await; - - Mock::given(method("POST")) - .and(path("/codex/safety/arc")) - .and(header("authorization", "Bearer Access Token")) - .and(header("chatgpt-account-id", "account_id")) - .and(body_json(serde_json::json!({ - "metadata": { - "codex_thread_id": session.conversation_id.to_string(), - "codex_turn_id": turn_context.sub_id.clone(), - "conversation_id": session.conversation_id.to_string(), - "protection_client_callsite": "normal", - }, - "messages": [{ - "role": "user", - "content": [{ - "type": "input_text", - "text": "please run the tool", - }], - }], - "policies": { - "developer": null, - "user": null, - }, - "action": { - "tool": "mcp_tool_call", - }, - }))) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "outcome": "ask-user", - "short_reason": "needs confirmation", - "rationale": "tool call needs additional review", - "risk_score": 42, - "risk_level": "medium", - "evidence": [{ - "message": "browser_navigate", - "why": "tool call needs additional review", - }], - }))) - .expect(1) - .mount(&server) - .await; - - let outcome = monitor_action( - &session, - &turn_context, - serde_json::json!({ "tool": "mcp_tool_call" }), - "normal", - ) - .await; - - assert_eq!( - outcome, - ArcMonitorOutcome::AskUser("needs confirmation".to_string()) - ); -} - -#[tokio::test] -#[serial(arc_monitor_env)] -async fn monitor_action_uses_env_url_and_token_overrides() { - let server = MockServer::start().await; - let _url_guard = EnvVarGuard::set( - CODEX_ARC_MONITOR_ENDPOINT_OVERRIDE, - OsStr::new(&format!("{}/override/arc", server.uri())), - ); - let _token_guard = EnvVarGuard::set(CODEX_ARC_MONITOR_TOKEN, OsStr::new("override-token")); - - let (session, turn_context) = make_session_and_context().await; - session - .record_into_history( - &[ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "please run the tool".to_string(), - }], - phase: None, - }], - &turn_context, - ) - .await; - - Mock::given(method("POST")) - .and(path("/override/arc")) - .and(header("authorization", "Bearer override-token")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "outcome": "steer-model", - "short_reason": "needs approval", - "rationale": "high-risk action", - "risk_score": 96, - "risk_level": "critical", - "evidence": [{ - "message": "browser_navigate", - "why": "high-risk action", - }], - }))) - .expect(1) - .mount(&server) - .await; - - let outcome = monitor_action( - &session, - &turn_context, - serde_json::json!({ "tool": "mcp_tool_call" }), - "normal", - ) - .await; - - assert_eq!( - outcome, - ArcMonitorOutcome::SteerModel("high-risk action".to_string()) - ); -} - -#[tokio::test] -#[serial(arc_monitor_env)] -async fn monitor_action_rejects_legacy_response_fields() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path("/codex/safety/arc")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "outcome": "steer-model", - "reason": "legacy high-risk action", - "monitorRequestId": "arc_456", - }))) - .expect(1) - .mount(&server) - .await; - - let (session, mut turn_context) = make_session_and_context().await; - turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth( - codex_login::CodexAuth::create_dummy_chatgpt_auth_for_testing(), - )); - let mut config = (*turn_context.config).clone(); - config.chatgpt_base_url = server.uri(); - turn_context.config = Arc::new(config); - - session - .record_into_history( - &[ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "please run the tool".to_string(), - }], - phase: None, - }], - &turn_context, - ) - .await; - - let outcome = monitor_action( - &session, - &turn_context, - serde_json::json!({ "tool": "mcp_tool_call" }), - "normal", - ) - .await; - - assert_eq!(outcome, ArcMonitorOutcome::Ok); -} diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index f604a63458..124dfb18c0 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -146,6 +146,9 @@ const X_CODEX_WS_STREAM_REQUEST_START_MS_CLIENT_METADATA_KEY: &str = const RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06"; const RESPONSES_ENDPOINT: &str = "/responses"; const RESPONSES_COMPACT_ENDPOINT: &str = "/responses/compact"; +// `/responses/compact` is unary, so the timeout covers the full response rather than one idle +// period between stream events. +const COMPACT_REQUEST_TIMEOUT_IDLE_MULTIPLIER: u32 = 4; const MEMORIES_SUMMARIZE_ENDPOINT: &str = "/memories/trace_summarize"; #[cfg(test)] pub(crate) const WEBSOCKET_CONNECT_TIMEOUT: Duration = @@ -170,6 +173,7 @@ struct ModelClientState { provider: SharedModelProvider, auth_env_telemetry: AuthEnvTelemetry, session_source: SessionSource, + parent_thread_id: Option, model_verbosity: Option, enable_request_compression: bool, include_timing_metrics: bool, @@ -215,6 +219,7 @@ impl RequestRouteTelemetry { #[derive(Debug, Clone)] pub struct ModelClient { state: Arc, + prompt_cache_key_override: Option, } /// A turn-scoped streaming session created from a [`ModelClient`]. @@ -257,6 +262,7 @@ struct WebsocketSession { connection: Option, last_request: Option, last_response_rx: Option>, + last_response_from_untraced_warmup: bool, connection_reused: StdMutex, } @@ -316,6 +322,7 @@ impl ModelClient { installation_id: String, provider_info: ModelProviderInfo, session_source: SessionSource, + parent_thread_id: Option, model_verbosity: Option, enable_request_compression: bool, include_timing_metrics: bool, @@ -339,6 +346,7 @@ impl ModelClient { provider: model_provider, auth_env_telemetry, session_source, + parent_thread_id, model_verbosity, enable_request_compression, include_timing_metrics, @@ -348,9 +356,24 @@ impl ModelClient { disable_websockets: AtomicBool::new(false), cached_websocket_session: StdMutex::new(WebsocketSession::default()), }), + prompt_cache_key_override: None, } } + pub(crate) fn with_prompt_cache_key_override( + mut self, + prompt_cache_key_override: Option, + ) -> Self { + self.prompt_cache_key_override = prompt_cache_key_override; + self + } + + fn prompt_cache_key(&self) -> String { + self.prompt_cache_key_override + .clone() + .unwrap_or_else(|| self.state.thread_id.to_string()) + } + /// Creates a fresh turn-scoped streaming session. /// /// This constructor does not perform network I/O itself; the session opens a websocket lazily @@ -379,7 +402,7 @@ impl ModelClient { self.store_cached_websocket_session(WebsocketSession::default()); } - fn current_window_id(&self) -> String { + pub(crate) fn current_window_id(&self) -> String { let thread_id = self.state.thread_id; let window_generation = self.state.window_generation.load(Ordering::Relaxed); format!("{thread_id}:{window_generation}") @@ -437,6 +460,7 @@ impl ModelClient { settings: CompactConversationRequestSettings, session_telemetry: &SessionTelemetry, compaction_trace: &CompactionTraceContext, + turn_metadata_header: Option<&str>, ) -> Result> { if prompt.input.is_empty() { return Ok(Vec::new()); @@ -492,7 +516,7 @@ impl ModelClient { extra_headers.extend(build_responses_headers( self.state.beta_features_header.as_deref(), /*turn_state*/ None, - /*turn_metadata_header*/ None, + parse_turn_metadata_header(turn_metadata_header).as_ref(), )); extra_headers.extend(self.build_responses_identity_headers()); extra_headers.extend(build_session_headers( @@ -502,12 +526,16 @@ impl ModelClient { if let Some(header_value) = self.generate_attestation_header_for().await { extra_headers.insert(X_OAI_ATTESTATION_HEADER, header_value); } + let compact_request_timeout = client_setup + .api_provider + .stream_idle_timeout + .saturating_mul(COMPACT_REQUEST_TIMEOUT_IDLE_MULTIPLIER); let client = ApiCompactClient::new(transport, client_setup.api_provider, client_setup.api_auth) .with_telemetry(Some(request_telemetry)); let trace_attempt = compaction_trace.start_attempt(&payload); let result = client - .compact_input(&payload, extra_headers) + .compact_input(&payload, extra_headers, compact_request_timeout) .await .map_err(map_api_error); trace_attempt.record_result(result.as_deref()); @@ -612,7 +640,7 @@ impl ModelClient { fn build_responses_identity_headers(&self) -> ApiHeaderMap { let mut extra_headers = self.build_subagent_headers(); - if let Some(parent_thread_id) = parent_thread_id_header_value(&self.state.session_source) + if let Some(parent_thread_id) = parent_thread_id_header_value(self.state.parent_thread_id) && let Ok(val) = HeaderValue::from_str(&parent_thread_id) { extra_headers.insert(X_CODEX_PARENT_THREAD_ID_HEADER, val); @@ -639,7 +667,7 @@ impl ModelClient { if let Some(subagent) = subagent_header_value(&self.state.session_source) { client_metadata.insert(X_OPENAI_SUBAGENT_HEADER.to_string(), subagent); } - if let Some(parent_thread_id) = parent_thread_id_header_value(&self.state.session_source) { + if let Some(parent_thread_id) = parent_thread_id_header_value(self.state.parent_thread_id) { client_metadata.insert( X_CODEX_PARENT_THREAD_ID_HEADER.to_string(), parent_thread_id, @@ -740,9 +768,8 @@ impl ModelClient { &prompt.output_schema, prompt.output_schema_strict, ); - let prompt_cache_key = Some(self.state.thread_id.to_string()); - let service_tier = - service_tier.filter(|service_tier| model_info.supports_service_tier(service_tier)); + let prompt_cache_key = Some(self.prompt_cache_key()); + let service_tier = model_info.service_tier_for_request(service_tier); let request = ResponsesApiRequest { model: model_info.slug.clone(), instructions: instructions.clone(), @@ -931,10 +958,11 @@ impl Drop for ModelClientSession { } impl ModelClientSession { - pub(crate) fn reset_websocket_session(&mut self) { + fn reset_websocket_session(&mut self) { self.websocket_session.connection = None; self.websocket_session.last_request = None; self.websocket_session.last_response_rx = None; + self.websocket_session.last_response_from_untraced_warmup = false; self.websocket_session .set_connection_reused(/*connection_reused*/ false); } @@ -1038,28 +1066,33 @@ impl ModelClientSession { &mut self, payload: ResponseCreateWsRequest, request: &ResponsesApiRequest, - ) -> ResponsesWsRequest { + ) -> (ResponsesWsRequest, bool) { let Some(last_response) = self.get_last_response() else { - return ResponsesWsRequest::ResponseCreate(payload); + return (ResponsesWsRequest::ResponseCreate(payload), false); }; + let previous_response_id_from_untraced_warmup = + self.websocket_session.last_response_from_untraced_warmup; let Some(incremental_items) = self.get_incremental_items( request, Some(&last_response), /*allow_empty_delta*/ true, ) else { - return ResponsesWsRequest::ResponseCreate(payload); + return (ResponsesWsRequest::ResponseCreate(payload), false); }; if last_response.response_id.is_empty() { trace!("incremental request failed, no previous response id"); - return ResponsesWsRequest::ResponseCreate(payload); + return (ResponsesWsRequest::ResponseCreate(payload), false); } - ResponsesWsRequest::ResponseCreate(ResponseCreateWsRequest { - previous_response_id: Some(last_response.response_id), - input: incremental_items, - ..payload - }) + ( + ResponsesWsRequest::ResponseCreate(ResponseCreateWsRequest { + previous_response_id: Some(last_response.response_id), + input: incremental_items, + ..payload + }), + previous_response_id_from_untraced_warmup, + ) } /// Opportunistically preconnects a websocket for this turn-scoped client session. @@ -1138,6 +1171,7 @@ impl ModelClientSession { if needs_new { self.websocket_session.last_request = None; self.websocket_session.last_response_rx = None; + self.websocket_session.last_response_from_untraced_warmup = false; let turn_state = options .turn_state .clone() @@ -1406,8 +1440,8 @@ impl ModelClientSession { Err(err) => return Err(map_api_error(err)), } - let mut ws_request = self.prepare_websocket_request(ws_payload, &request); - self.websocket_session.last_request = Some(request); + let (mut ws_request, previous_response_id_from_untraced_warmup) = + self.prepare_websocket_request(ws_payload, &request); let inference_trace_attempt = if warmup { // Prewarm sends `generate=false`; it is connection setup, not a // model inference attempt that should appear in rollout traces. @@ -1416,7 +1450,16 @@ impl ModelClientSession { inference_trace.start_attempt() }; stamp_ws_stream_request_start_ms(&mut ws_request); - inference_trace_attempt.record_started(&ws_request); + if previous_response_id_from_untraced_warmup { + // The transport can reuse an untraced warmup response id and omit the + // already-sent input, but rollout replay needs the logical model-visible + // request rather than the compressed websocket delta. + inference_trace_attempt.record_started(&request); + } else { + inference_trace_attempt.record_started(&ws_request); + } + self.websocket_session.last_request = Some(request); + self.websocket_session.last_response_from_untraced_warmup = warmup; let websocket_connection = self.websocket_session.connection.as_ref().ok_or_else(|| { map_api_error(ApiError::Stream( @@ -1693,20 +1736,8 @@ fn subagent_header_value(session_source: &SessionSource) -> Option { } } -fn parent_thread_id_header_value(session_source: &SessionSource) -> Option { - match session_source { - SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, .. - }) => Some(parent_thread_id.to_string()), - SessionSource::Cli - | SessionSource::VSCode - | SessionSource::Exec - | SessionSource::Mcp - | SessionSource::Custom(_) - | SessionSource::Internal(_) - | SessionSource::SubAgent(_) - | SessionSource::Unknown => None, - } +fn parent_thread_id_header_value(parent_thread_id: Option) -> Option { + parent_thread_id.map(|parent_thread_id| parent_thread_id.to_string()) } const RESPONSE_STREAM_CHANNEL_CAPACITY: usize = 1600; diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 2061d53c80..d775f9c01c 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -2,27 +2,16 @@ pub use codex_api::ResponseEvent; use codex_config::types::Personality; use codex_protocol::error::Result; use codex_protocol::models::BaseInstructions; -use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::ResponseItem; use codex_tools::ToolSpec; use futures::Stream; -use serde::Deserialize; use serde_json::Value; -use std::collections::HashSet; use std::pin::Pin; use std::task::Context; use std::task::Poll; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; -/// Review thread system prompt. Edit `core/src/review_prompt.md` to customize. -pub const REVIEW_PROMPT: &str = include_str!("../review_prompt.md"); - -// Centralized templates for review-related user messages -pub const REVIEW_EXIT_SUCCESS_TMPL: &str = include_str!("../templates/review/exit_success.xml"); -pub const REVIEW_EXIT_INTERRUPTED_TMPL: &str = - include_str!("../templates/review/exit_interrupted.xml"); - /// API request payload for a single model turn #[derive(Debug, Clone)] pub struct Prompt { @@ -64,116 +53,10 @@ impl Default for Prompt { impl Prompt { pub(crate) fn get_formatted_input(&self) -> Vec { - let mut input = self.input.clone(); - - // when using the *Freeform* apply_patch tool specifically, tool outputs - // should be structured text, not json. Do NOT reserialize when using - // the Function tool - note that this differs from the check above for - // instructions. We declare the result as a named variable for clarity. - let is_freeform_apply_patch_tool_present = self.tools.iter().any(|tool| match tool { - ToolSpec::Freeform(f) => f.name == "apply_patch", - _ => false, - }); - if is_freeform_apply_patch_tool_present { - reserialize_shell_outputs(&mut input); - } - - input + self.input.clone() } } -fn reserialize_shell_outputs(items: &mut [ResponseItem]) { - let mut shell_call_ids: HashSet = HashSet::new(); - - items.iter_mut().for_each(|item| match item { - ResponseItem::LocalShellCall { call_id, id, .. } => { - if let Some(identifier) = call_id.clone().or_else(|| id.clone()) { - shell_call_ids.insert(identifier); - } - } - ResponseItem::CustomToolCall { - id: _, - status: _, - call_id, - name, - input: _, - } => { - if name == "apply_patch" { - shell_call_ids.insert(call_id.clone()); - } - } - ResponseItem::FunctionCall { name, call_id, .. } - if is_shell_tool_name(name) || name == "apply_patch" => - { - shell_call_ids.insert(call_id.clone()); - } - ResponseItem::FunctionCallOutput { - call_id, output, .. - } - | ResponseItem::CustomToolCallOutput { - call_id, output, .. - } => { - if shell_call_ids.remove(call_id) - && let Some(structured) = output - .text_content() - .and_then(parse_structured_shell_output) - { - output.body = FunctionCallOutputBody::Text(structured); - } - } - _ => {} - }) -} - -fn is_shell_tool_name(name: &str) -> bool { - name == "shell" -} - -#[derive(Deserialize)] -struct ExecOutputJson { - output: String, - metadata: ExecOutputMetadataJson, -} - -#[derive(Deserialize)] -struct ExecOutputMetadataJson { - exit_code: i32, - duration_seconds: f32, -} - -fn parse_structured_shell_output(raw: &str) -> Option { - let parsed: ExecOutputJson = serde_json::from_str(raw).ok()?; - Some(build_structured_output(&parsed)) -} - -fn build_structured_output(parsed: &ExecOutputJson) -> String { - let mut sections = Vec::new(); - sections.push(format!("Exit code: {}", parsed.metadata.exit_code)); - sections.push(format!( - "Wall time: {} seconds", - parsed.metadata.duration_seconds - )); - - let mut output = parsed.output.clone(); - if let Some((stripped, total_lines)) = strip_total_output_header(&parsed.output) { - sections.push(format!("Total output lines: {total_lines}")); - output = stripped.to_string(); - } - - sections.push("Output:".to_string()); - sections.push(output); - - sections.join("\n") -} - -fn strip_total_output_header(output: &str) -> Option<(&str, u32)> { - let after_prefix = output.strip_prefix("Total output lines: ")?; - let (total_segment, remainder) = after_prefix.split_once('\n')?; - let total_lines = total_segment.parse::().ok()?; - let remainder = remainder.strip_prefix('\n').unwrap_or(remainder); - Some((remainder, total_lines)) -} - pub struct ResponseStream { pub(crate) rx_event: mpsc::Receiver>, /// Signals the mapper task that the consumer stopped polling before the diff --git a/codex-rs/core/src/client_common_tests.rs b/codex-rs/core/src/client_common_tests.rs index f67e4f1fd8..45d4334222 100644 --- a/codex-rs/core/src/client_common_tests.rs +++ b/codex-rs/core/src/client_common_tests.rs @@ -3,7 +3,6 @@ use codex_api::ResponsesApiRequest; use codex_api::TextControls; use codex_api::create_text_param_for_request; use codex_protocol::config_types::ServiceTier; -use codex_protocol::models::FunctionCallOutputPayload; use pretty_assertions::assert_eq; use super::*; @@ -166,65 +165,3 @@ fn serializes_flex_service_tier_when_set() { Some("flex") ); } - -#[test] -fn reserializes_shell_outputs_for_function_and_custom_tool_calls() { - let raw_output = r#"{"output":"hello","metadata":{"exit_code":0,"duration_seconds":0.5}}"#; - let expected_output = "Exit code: 0\nWall time: 0.5 seconds\nOutput:\nhello"; - let mut items = vec![ - ResponseItem::FunctionCall { - id: None, - name: "shell".to_string(), - namespace: None, - arguments: "{}".to_string(), - call_id: "call-1".to_string(), - }, - ResponseItem::FunctionCallOutput { - call_id: "call-1".to_string(), - output: FunctionCallOutputPayload::from_text(raw_output.to_string()), - }, - ResponseItem::CustomToolCall { - id: None, - status: None, - call_id: "call-2".to_string(), - name: "apply_patch".to_string(), - input: "*** Begin Patch".to_string(), - }, - ResponseItem::CustomToolCallOutput { - call_id: "call-2".to_string(), - name: None, - output: FunctionCallOutputPayload::from_text(raw_output.to_string()), - }, - ]; - - reserialize_shell_outputs(&mut items); - - assert_eq!( - items, - vec![ - ResponseItem::FunctionCall { - id: None, - name: "shell".to_string(), - namespace: None, - arguments: "{}".to_string(), - call_id: "call-1".to_string(), - }, - ResponseItem::FunctionCallOutput { - call_id: "call-1".to_string(), - output: FunctionCallOutputPayload::from_text(expected_output.to_string()), - }, - ResponseItem::CustomToolCall { - id: None, - status: None, - call_id: "call-2".to_string(), - name: "apply_patch".to_string(), - input: "*** Begin Patch".to_string(), - }, - ResponseItem::CustomToolCallOutput { - call_id: "call-2".to_string(), - name: None, - output: FunctionCallOutputPayload::from_text(expected_output.to_string()), - }, - ] - ); -} diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index b9d9172c83..f8d904036a 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -61,6 +61,13 @@ use tracing_subscriber::registry::LookupSpan; use tracing_subscriber::util::SubscriberInitExt; fn test_model_client(session_source: SessionSource) -> ModelClient { + test_model_client_with_parent(session_source, /*parent_thread_id*/ None) +} + +fn test_model_client_with_parent( + session_source: SessionSource, + parent_thread_id: Option, +) -> ModelClient { let provider = create_oss_provider_with_base_url("https://example.com/v1", WireApi::Responses); let thread_id = ThreadId::new(); ModelClient::new( @@ -70,6 +77,7 @@ fn test_model_client(session_source: SessionSource) -> ModelClient { /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), provider, session_source, + parent_thread_id, /*model_verbosity*/ None, /*enable_request_compression*/ false, /*include_timing_metrics*/ false, @@ -272,13 +280,16 @@ fn build_subagent_headers_sets_internal_memory_consolidation_label() { #[test] fn build_ws_client_metadata_includes_window_lineage_and_turn_metadata() { let parent_thread_id = ThreadId::new(); - let client = test_model_client(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, - depth: 2, - agent_path: None, - agent_nickname: None, - agent_role: None, - })); + let client = test_model_client_with_parent( + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 2, + agent_path: None, + agent_nickname: None, + agent_role: None, + }), + Some(parent_thread_id), + ); client.advance_window_generation(); @@ -520,6 +531,7 @@ fn model_client_with_counting_attestation( /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), provider, SessionSource::Exec, + /*parent_thread_id*/ None, /*model_verbosity*/ None, /*enable_request_compression*/ false, /*include_timing_metrics*/ false, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 493771e28c..44a8c0ac6b 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -74,6 +74,8 @@ pub(crate) async fn run_codex_thread_interactive( ) -> Result { let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_ops, rx_ops) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); + let conversation_history = initial_history.unwrap_or(InitialHistory::New); + let forked_from_thread_id = conversation_history.forked_from_id(); let CodexSpawnOk { codex, .. } = Box::pin(Codex::spawn(CodexSpawnArgs { config, installation_id: parent_session.installation_id.clone(), @@ -84,8 +86,10 @@ pub(crate) async fn run_codex_thread_interactive( plugins_manager: Arc::clone(&parent_session.services.plugins_manager), mcp_manager: Arc::clone(&parent_session.services.mcp_manager), extensions: Arc::clone(&parent_session.services.extensions), - conversation_history: initial_history.unwrap_or(InitialHistory::New), + conversation_history, session_source: SessionSource::SubAgent(subagent_source.clone()), + forked_from_thread_id, + parent_thread_id: Some(parent_session.conversation_id), thread_source: Some(ThreadSource::Subagent), agent_control: parent_session.services.agent_control.clone(), dynamic_tools: Vec::new(), @@ -108,6 +112,7 @@ pub(crate) async fn run_codex_thread_interactive( emit_subagent_session_started( &parent_session.services.analytics_events_client, client_metadata, + codex.session.session_id(), codex.session.conversation_id, Some(parent_session.conversation_id), thread_config, @@ -192,6 +197,8 @@ pub(crate) async fn run_codex_thread_one_shot( items: input, final_output_json_schema, responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), }) .await?; @@ -214,6 +221,7 @@ pub(crate) async fn run_codex_thread_one_shot( .send(Submission { id: "shutdown".to_string(), op: Op::Shutdown {}, + client_user_message_id: None, trace: None, }) .await; diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 66cde8d1ea..04f6ae3965 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -129,6 +129,7 @@ async fn forward_ops_preserves_submission_trace_context() { let submission = Submission { id: "sub-1".to_string(), op: Op::Interrupt, + client_user_message_id: None, trace: Some(codex_protocol::protocol::W3cTraceContext { traceparent: Some( "00-1234567890abcdef1234567890abcdef-1234567890abcdef-01".to_string(), diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index be5b0ccb88..7a6bc69c5c 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -7,6 +7,7 @@ use crate::session::SessionSettingsUpdate; use crate::session::SteerInputError; use codex_features::Feature; use codex_otel::SessionTelemetry; +use codex_protocol::ThreadId; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; @@ -18,9 +19,9 @@ use codex_protocol::mcp::CallToolResult; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::ContentItem; use codex_protocol::models::PermissionProfile; -use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AdditionalContextEntry; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::Event; use codex_protocol::protocol::Op; @@ -41,6 +42,7 @@ use codex_thread_store::ThreadStoreError; use codex_thread_store::ThreadStoreResult; use codex_utils_absolute_path::AbsolutePathBuf; use rmcp::model::ReadResourceRequestParams; +use std::collections::BTreeMap; use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; @@ -59,10 +61,15 @@ pub struct ThreadConfigSnapshot { pub permission_profile: PermissionProfile, pub active_permission_profile: Option, pub cwd: AbsolutePathBuf, + pub workspace_roots: Vec, + pub profile_workspace_roots: Vec, pub ephemeral: bool, pub reasoning_effort: Option, + pub reasoning_summary: Option, pub personality: Option, + pub collaboration_mode: CollaborationMode, pub session_source: SessionSource, + pub parent_thread_id: Option, pub thread_source: Option, } @@ -78,10 +85,12 @@ impl ThreadConfigSnapshot { } } -/// Turn context overrides that app-server validates before starting a turn. +/// Thread settings overrides that app-server validates before starting a turn. #[derive(Clone, Default)] -pub struct CodexThreadTurnContextOverrides { +pub struct CodexThreadSettingsOverrides { pub cwd: Option, + pub workspace_roots: Option>, + pub profile_workspace_roots: Option>, pub approval_policy: Option, pub approvals_reviewer: Option, pub sandbox_policy: Option, @@ -140,7 +149,7 @@ impl CodexThread { self.codex.session_loop_termination.clone().await; } - pub(crate) fn emit_thread_resume_lifecycle(&self) { + pub(crate) async fn emit_thread_resume_lifecycle(&self) { for contributor in self .codex .session @@ -148,10 +157,12 @@ impl CodexThread { .extensions .thread_lifecycle_contributors() { - contributor.on_thread_resume(codex_extension_api::ThreadResumeInput { - session_store: &self.codex.session.services.session_extension_data, - thread_store: &self.codex.session.services.thread_extension_data, - }); + contributor + .on_thread_resume(codex_extension_api::ThreadResumeInput { + session_store: &self.codex.session.services.session_extension_data, + thread_store: &self.codex.session.services.thread_extension_data, + }) + .await; } } @@ -220,6 +231,17 @@ impl CodexThread { self.codex.submit_with_trace(op, trace).await } + pub async fn submit_user_input_with_client_user_message_id( + &self, + op: Op, + trace: Option, + client_user_message_id: Option, + ) -> CodexResult { + self.codex + .submit_user_input_with_client_user_message_id(op, trace, client_user_message_id) + .await + } + /// Persist whether this thread is eligible for future memory generation. pub async fn set_thread_memory_mode(&self, mode: ThreadMemoryMode) -> anyhow::Result<()> { self.codex.set_thread_memory_mode(mode).await @@ -228,14 +250,42 @@ impl CodexThread { pub async fn steer_input( &self, input: Vec, + additional_context: BTreeMap, expected_turn_id: Option<&str>, + client_user_message_id: Option, responsesapi_client_metadata: Option>, ) -> Result { self.codex - .steer_input(input, expected_turn_id, responsesapi_client_metadata) + .steer_input( + input, + additional_context, + expected_turn_id, + client_user_message_id, + responsesapi_client_metadata, + ) .await } + /// Injects model-visible items into the currently active turn. + /// + /// This is the thread-level bridge to `Session::inject_if_running` for + /// callers that only hold a `CodexThread`. + /// It returns the unchanged items when this thread has no active turn. + pub async fn inject_if_running( + &self, + items: Vec, + ) -> Result<(), Vec> { + self.codex.session.inject_if_running(items).await + } + + /// Starts a regular turn with model-visible items only if the thread is idle. + pub async fn try_start_turn_if_idle( + &self, + items: Vec, + ) -> Result<(), Vec> { + self.codex.session.try_start_turn_if_idle(items).await + } + pub async fn set_app_server_client_info( &self, app_server_client_name: Option, @@ -251,13 +301,23 @@ impl CodexThread { .await } - /// Validate persistent turn context overrides without committing them. - pub async fn validate_turn_context_overrides( + /// Preview persistent thread settings overrides without committing them. + pub async fn preview_thread_settings_overrides( &self, - overrides: CodexThreadTurnContextOverrides, - ) -> ConstraintResult<()> { - let CodexThreadTurnContextOverrides { + overrides: CodexThreadSettingsOverrides, + ) -> ConstraintResult { + let updates = self.thread_settings_update(overrides).await; + self.codex.session.preview_settings(&updates).await + } + + async fn thread_settings_update( + &self, + overrides: CodexThreadSettingsOverrides, + ) -> SessionSettingsUpdate { + let CodexThreadSettingsOverrides { cwd, + workspace_roots, + profile_workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, @@ -281,8 +341,10 @@ impl CodexThread { .with_updates(model, effort, /*developer_instructions*/ None) }; - let updates = SessionSettingsUpdate { + SessionSettingsUpdate { cwd, + workspace_roots, + profile_workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, @@ -294,8 +356,7 @@ impl CodexThread { service_tier, personality, ..Default::default() - }; - self.codex.session.validate_settings(&updates).await + } } /// Use sparingly: this is intended to be removed soon. @@ -328,60 +389,19 @@ impl CodexThread { /// Records a user-role session-prefix message without creating a new user turn boundary. pub(crate) async fn inject_user_message_without_turn(&self, message: String) { - let message = ResponseItem::Message { + let item = ResponseItem::Message { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { text: message }], phase: None, }; - let pending_item = match pending_message_input_item(&message) { - Ok(pending_item) => pending_item, - Err(err) => { - debug_assert!(false, "session-prefix message append should succeed: {err}"); - return; - } - }; - if self - .codex + self.codex .session - .inject_response_items(vec![pending_item]) - .await - .is_err() - { - let turn_context = self.codex.session.new_default_turn().await; - self.codex - .session - .record_conversation_items(turn_context.as_ref(), &[message]) - .await; - } + .inject_no_new_turn(vec![item], /*current_turn_context*/ None) + .await; } - /// Append a prebuilt message to the thread history without treating it as a user turn. - /// - /// If the thread already has an active turn, the message is queued as pending input for that - /// turn. Otherwise it is queued at session scope and a regular turn is started so the agent - /// can consume that pending input through the normal turn pipeline. - #[cfg(test)] - pub(crate) async fn append_message(&self, message: ResponseItem) -> CodexResult { - let submission_id = uuid::Uuid::new_v4().to_string(); - let pending_item = pending_message_input_item(&message)?; - if let Err(items) = self - .codex - .session - .inject_response_items(vec![pending_item]) - .await - { - self.codex - .session - .queue_response_items_for_next_turn(items) - .await; - self.codex.session.maybe_start_turn_for_pending_work().await; - } - - Ok(submission_id) - } - - /// Append raw Responses API items to the thread's model-visible history. + /// Record raw Responses API items without starting a new turn. pub async fn inject_response_items(&self, items: Vec) -> CodexResult<()> { if items.is_empty() { return Err(CodexErr::InvalidRequest( @@ -398,7 +418,7 @@ impl CodexThread { } self.codex .session - .record_conversation_items(turn_context.as_ref(), &items) + .inject_no_new_turn(items, Some(turn_context.as_ref())) .await; self.codex.session.flush_rollout().await?; Ok(()) @@ -501,13 +521,7 @@ impl CodexThread { let result = self .codex .session - .read_resource( - server, - ReadResourceRequestParams { - meta: None, - uri: uri.to_string(), - }, - ) + .read_resource(server, ReadResourceRequestParams::new(uri)) .await?; Ok(serde_json::to_value(result)?) @@ -565,21 +579,3 @@ impl CodexThread { Ok(*guard) } } - -fn pending_message_input_item(message: &ResponseItem) -> CodexResult { - match message { - ResponseItem::Message { - role, - content, - phase, - .. - } => Ok(ResponseInputItem::Message { - role: role.clone(), - content: content.clone(), - phase: phase.clone(), - }), - _ => Err(CodexErr::InvalidRequest( - "append_message only supports ResponseItem::Message".to_string(), - )), - } -} diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 9d1c82eb65..11386e6bcd 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -13,6 +13,7 @@ use crate::session::PreviousTurnSettings; use crate::session::session::Session; use crate::session::turn::get_last_assistant_message_from_turn; use crate::session::turn_context::TurnContext; +use crate::turn_metadata::CompactionTurnMetadata; use crate::util::backoff; use codex_analytics::CodexCompactionEvent; use codex_analytics::CompactionImplementation; @@ -43,8 +44,8 @@ use tracing::error; use codex_model_provider_info::ModelProviderInfo; -pub const SUMMARIZATION_PROMPT: &str = include_str!("../templates/compact/prompt.md"); -pub const SUMMARY_PREFIX: &str = include_str!("../templates/compact/summary_prefix.md"); +pub use codex_prompts::SUMMARIZATION_PROMPT; +pub use codex_prompts::SUMMARY_PREFIX; const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000; /// Controls whether compaction replacement history must include initial context. @@ -100,6 +101,7 @@ pub(crate) async fn run_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + trace_id: turn_context.trace_id.clone(), started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, @@ -127,6 +129,8 @@ async fn run_compact_task_inner( reason: CompactionReason, phase: CompactionPhase, ) -> CodexResult<()> { + let compaction_metadata = + CompactionTurnMetadata::new(trigger, reason, CompactionImplementation::Responses, phase); let attempt = CompactionAnalyticsAttempt::begin( sess.as_ref(), turn_context.as_ref(), @@ -152,6 +156,7 @@ async fn run_compact_task_inner( Arc::clone(&turn_context), input, initial_context_injection, + compaction_metadata, ) .await; let status = compaction_status_from_result(&result); @@ -172,6 +177,7 @@ async fn run_compact_task_inner_impl( turn_context: Arc, input: Vec, initial_context_injection: InitialContextInjection, + compaction_metadata: CompactionTurnMetadata, ) -> CodexResult { let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new()); sess.emit_turn_item_started(&turn_context, &compaction_item) @@ -203,7 +209,10 @@ async fn run_compact_task_inner_impl( personality: turn_context.personality, ..Default::default() }; - let turn_metadata_header = turn_context.turn_metadata_state.current_header_value(); + let window_id = sess.services.model_client.current_window_id(); + let turn_metadata_header = turn_context + .turn_metadata_state + .current_header_value_for_compaction(&window_id, compaction_metadata); let attempt_result = drain_to_completed( &sess, turn_context.as_ref(), @@ -282,7 +291,6 @@ async fn run_compact_task_inner_impl( }; sess.replace_compacted_history(new_history, reference_context_item, compacted_item) .await; - client_session.reset_websocket_session(); sess.recompute_token_usage(&turn_context).await; sess.emit_turn_item_completed(&turn_context, compaction_item) @@ -560,7 +568,7 @@ async fn drain_to_completed( }; match event { Ok(ResponseEvent::OutputItemDone(item)) => { - sess.record_into_history(std::slice::from_ref(&item), turn_context) + sess.record_conversation_items(turn_context, std::slice::from_ref(&item)) .await; } Ok(ResponseEvent::ServerReasoningIncluded(included)) => { diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index cc31d50b13..b4456dc717 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -1,4 +1,3 @@ -use std::collections::HashSet; use std::sync::Arc; use crate::Prompt; @@ -18,6 +17,7 @@ use crate::hook_runtime::run_pre_compact_hooks; use crate::session::session::Session; use crate::session::turn::built_tools; use crate::session::turn_context::TurnContext; +use crate::turn_metadata::CompactionTurnMetadata; use codex_analytics::CompactionImplementation; use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; @@ -63,6 +63,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + trace_id: turn_context.trace_id.clone(), started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, @@ -89,6 +90,12 @@ async fn run_remote_compact_task_inner( reason: CompactionReason, phase: CompactionPhase, ) -> CodexResult<()> { + let compaction_metadata = CompactionTurnMetadata::new( + trigger, + reason, + CompactionImplementation::ResponsesCompact, + phase, + ); let attempt = CompactionAnalyticsAttempt::begin( sess.as_ref(), turn_context.as_ref(), @@ -113,8 +120,13 @@ async fn run_remote_compact_task_inner( return Err(CodexErr::TurnAborted); } } - let result = - run_remote_compact_task_inner_impl(sess, turn_context, initial_context_injection).await; + let result = run_remote_compact_task_inner_impl( + sess, + turn_context, + initial_context_injection, + compaction_metadata, + ) + .await; let status = compaction_status_from_result(&result); let error = result.as_ref().err().map(ToString::to_string); if result.is_ok() { @@ -139,6 +151,7 @@ async fn run_remote_compact_task_inner_impl( sess: &Arc, turn_context: &Arc, initial_context_injection: InitialContextInjection, + compaction_metadata: CompactionTurnMetadata, ) -> CodexResult<()> { let context_compaction_item = ContextCompactionItem::new(); // Use the UI compaction item ID as the trace compaction ID so protocol lifecycle events, @@ -174,9 +187,6 @@ async fn run_remote_compact_task_inner_impl( let tool_router = built_tools( sess.as_ref(), turn_context.as_ref(), - &prompt_input, - &HashSet::new(), - /*skills_outcome*/ None, &CancellationToken::new(), ) .await?; @@ -189,6 +199,10 @@ async fn run_remote_compact_task_inner_impl( output_schema: None, output_schema_strict: true, }; + let window_id = sess.services.model_client.current_window_id(); + let turn_metadata_header = turn_context + .turn_metadata_state + .current_header_value_for_compaction(&window_id, compaction_metadata); let mut new_history = sess .services .model_client @@ -206,6 +220,7 @@ async fn run_remote_compact_task_inner_impl( }, &turn_context.session_telemetry, &compaction_trace, + turn_metadata_header.as_deref(), ) .or_else(|err| async { let total_usage_breakdown = sess.get_total_token_usage_breakdown().await; @@ -290,7 +305,7 @@ pub(crate) async fn process_compacted_history( /// - `user`-role warnings that parse as `TurnItem::UserMessage` and compaction-generated summary /// messages. Legacy warning fragments are filtered by `parse_turn_item` before they reach this /// check. -fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { +pub(crate) fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { match item { ResponseItem::Message { role, .. } if role == "developer" => false, ResponseItem::Message { role, .. } if role == "user" => { @@ -302,6 +317,7 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { ResponseItem::Message { role, .. } if role == "assistant" => true, ResponseItem::Message { .. } => false, ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, + ResponseItem::CompactionTrigger => false, ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 4dafb95f8e..0da3017f7d 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -1,4 +1,3 @@ -use std::collections::HashSet; use std::sync::Arc; use crate::Prompt; @@ -11,10 +10,18 @@ use crate::compact::compaction_status_from_result; use crate::compact_remote::build_compact_request_log_data; use crate::compact_remote::log_remote_compact_failure; use crate::compact_remote::process_compacted_history; +use crate::compact_remote::should_keep_compacted_history_item; use crate::compact_remote::trim_function_call_history_to_fit_context_window; +use crate::hook_runtime::PostCompactHookOutcome; +use crate::hook_runtime::PreCompactHookOutcome; +use crate::hook_runtime::run_post_compact_hooks; +use crate::hook_runtime::run_pre_compact_hooks; +use crate::responses_retry::ResponsesStreamRequest; +use crate::responses_retry::handle_retryable_response_stream_error; use crate::session::session::Session; use crate::session::turn::built_tools; use crate::session::turn_context::TurnContext; +use crate::turn_metadata::CompactionTurnMetadata; use codex_analytics::CompactionImplementation; use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; @@ -24,17 +31,27 @@ use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::items::ContextCompactionItem; use codex_protocol::items::TurnItem; +use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::TruncationPolicy; use codex_protocol::protocol::TurnStartedEvent; use codex_rollout_trace::CompactionCheckpointTracePayload; use codex_rollout_trace::InferenceTraceContext; +use codex_utils_output_truncation::approx_token_count; +use codex_utils_output_truncation::truncate_text; use futures::StreamExt; -use futures::TryFutureExt; use tokio_util::sync::CancellationToken; use tracing::info; +// Mirror the current /responses/compact retained-message default while the +// server-side path remains the reference implementation. +const RETAINED_MESSAGE_TOKEN_BUDGET: usize = 64_000; +// Compact attempts can run much longer than normal turns, so keep the per-transport +// retry budget smaller than the general Responses stream retry budget. +const MAX_REMOTE_COMPACTION_V2_STREAM_RETRIES: u64 = 2; + pub(crate) async fn run_inline_remote_auto_compact_task( sess: Arc, turn_context: Arc, @@ -61,6 +78,7 @@ pub(crate) async fn run_remote_compact_task( ) -> CodexResult<()> { let start_event = EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_context.sub_id.clone(), + trace_id: turn_context.trace_id.clone(), started_at: turn_context.turn_timing_state.started_at_unix_secs().await, model_context_window: turn_context.model_context_window(), collaboration_mode_kind: turn_context.collaboration_mode.mode, @@ -88,29 +106,54 @@ async fn run_remote_compact_task_inner( reason: CompactionReason, phase: CompactionPhase, ) -> CodexResult<()> { + let compaction_metadata = CompactionTurnMetadata::new( + trigger, + reason, + CompactionImplementation::ResponsesCompactionV2, + phase, + ); let attempt = CompactionAnalyticsAttempt::begin( sess.as_ref(), turn_context.as_ref(), trigger, reason, - CompactionImplementation::Responses, + CompactionImplementation::ResponsesCompactionV2, phase, ) .await; + let pre_compact_outcome = run_pre_compact_hooks(sess, turn_context, trigger).await; + match pre_compact_outcome { + PreCompactHookOutcome::Continue => {} + PreCompactHookOutcome::Stopped { reason } => { + let error = reason.unwrap_or_else(|| "PreCompact hook stopped execution".to_string()); + attempt + .track( + sess.as_ref(), + codex_analytics::CompactionStatus::Interrupted, + Some(error), + ) + .await; + return Err(CodexErr::TurnAborted); + } + } let result = run_remote_compact_task_inner_impl( sess, turn_context, client_session, initial_context_injection, + compaction_metadata, ) .await; - attempt - .track( - sess.as_ref(), - compaction_status_from_result(&result), - result.as_ref().err().map(ToString::to_string), - ) - .await; + let status = compaction_status_from_result(&result); + let error = result.as_ref().err().map(ToString::to_string); + if result.is_ok() { + let post_compact_outcome = run_post_compact_hooks(sess, turn_context, trigger).await; + if let PostCompactHookOutcome::Stopped = post_compact_outcome { + attempt.track(sess.as_ref(), status, error).await; + return Err(CodexErr::TurnAborted); + } + } + attempt.track(sess.as_ref(), status, error.clone()).await; if let Err(err) = result { let event = EventMsg::Error( err.to_error_event(Some("Error running remote compact task".to_string())), @@ -126,6 +169,7 @@ async fn run_remote_compact_task_inner_impl( turn_context: &Arc, client_session: Option<&mut ModelClientSession>, initial_context_injection: InitialContextInjection, + compaction_metadata: CompactionTurnMetadata, ) -> CodexResult<()> { let context_compaction_item = ContextCompactionItem::new(); let compaction_trace = sess.services.rollout_thread_trace.compaction_trace_context( @@ -158,16 +202,11 @@ async fn run_remote_compact_task_inner_impl( let tool_router = built_tools( sess.as_ref(), turn_context.as_ref(), - &prompt_input, - &HashSet::new(), - /*skills_outcome*/ None, &CancellationToken::new(), ) .await?; let mut input = prompt_input.clone(); - input.push(ResponseItem::ContextCompaction { - encrypted_content: None, - }); + input.push(ResponseItem::CompactionTrigger); let prompt = Prompt { input, tools: tool_router.model_visible_specs(), @@ -178,7 +217,10 @@ async fn run_remote_compact_task_inner_impl( output_schema_strict: true, }; - let turn_metadata_header = turn_context.turn_metadata_state.current_header_value(); + let window_id = sess.services.model_client.current_window_id(); + let turn_metadata_header = turn_context + .turn_metadata_state + .current_header_value_for_compaction(&window_id, compaction_metadata); let trace_attempt = compaction_trace.start_attempt(&serde_json::json!({ "model": turn_context.model_info.slug.as_str(), "instructions": prompt.base_instructions.text.as_str(), @@ -252,62 +294,89 @@ async fn run_remote_compaction_request_v2( prompt: &Prompt, turn_metadata_header: Option<&str>, ) -> CodexResult<(ResponseItem, String)> { - let stream = client_session - .stream( - prompt, - &turn_context.model_info, - &turn_context.session_telemetry, - turn_context.reasoning_effort, - turn_context.reasoning_summary, - turn_context.config.service_tier.clone(), - turn_metadata_header, - &InferenceTraceContext::disabled(), - ) - .or_else(|err| async { - let total_usage_breakdown = sess.get_total_token_usage_breakdown().await; - let compact_request_log_data = - build_compact_request_log_data(&prompt.input, &prompt.base_instructions.text); - log_remote_compact_failure( - turn_context, - &compact_request_log_data, - total_usage_breakdown, - &err, - ); - Err(err) - }) - .await?; - collect_context_compaction_output(stream).await + let max_retries = turn_context + .provider + .info() + .stream_max_retries() + .min(MAX_REMOTE_COMPACTION_V2_STREAM_RETRIES); + let mut retries = 0; + loop { + let result = match client_session + .stream( + prompt, + &turn_context.model_info, + &turn_context.session_telemetry, + turn_context.reasoning_effort, + turn_context.reasoning_summary, + turn_context.config.service_tier.clone(), + turn_metadata_header, + &InferenceTraceContext::disabled(), + ) + .await + { + Ok(stream) => collect_compaction_output(stream).await, + Err(err) => Err(err), + }; + + match result { + Ok(compaction_output) => return Ok(compaction_output), + Err(err) if !err.is_retryable() => { + log_remote_compaction_request_failure(sess, turn_context, prompt, &err).await; + return Err(err); + } + Err(err) => { + if let Err(err) = handle_retryable_response_stream_error( + &mut retries, + max_retries, + err, + client_session, + sess, + turn_context, + ResponsesStreamRequest::RemoteCompactionV2, + ) + .await + { + log_remote_compaction_request_failure(sess, turn_context, prompt, &err).await; + return Err(err); + } + } + } + } } -async fn collect_context_compaction_output( +async fn log_remote_compaction_request_failure( + sess: &Session, + turn_context: &TurnContext, + prompt: &Prompt, + err: &CodexErr, +) { + let total_usage_breakdown = sess.get_total_token_usage_breakdown().await; + let compact_request_log_data = + build_compact_request_log_data(&prompt.input, &prompt.base_instructions.text); + log_remote_compact_failure( + turn_context, + &compact_request_log_data, + total_usage_breakdown, + err, + ); +} + +async fn collect_compaction_output( mut stream: ResponseStream, ) -> CodexResult<(ResponseItem, String)> { let mut output_item_count = 0usize; - let mut context_compaction_count = 0usize; - let mut context_compaction_output = None; + let mut compaction_count = 0usize; + let mut compaction_output = None; let mut completed_response_id = None; while let Some(event) = stream.next().await { match event? { ResponseEvent::OutputItemDone(item) => { output_item_count += 1; - match item { - ResponseItem::ContextCompaction { - encrypted_content: Some(_), - } => { - context_compaction_count += 1; - if context_compaction_output.is_none() { - context_compaction_output = Some(item); - } + if let ResponseItem::Compaction { .. } = item { + compaction_count += 1; + if compaction_output.is_none() { + compaction_output = Some(item); } - ResponseItem::ContextCompaction { - encrypted_content: None, - } => { - return Err(CodexErr::Fatal( - "remote compaction v2 returned context_compaction without encrypted_content" - .to_string(), - )); - } - _ => {} } } ResponseEvent::Completed { response_id, .. } => { @@ -319,32 +388,36 @@ async fn collect_context_compaction_output( } let Some(response_id) = completed_response_id else { - return Err(CodexErr::Fatal( + return Err(CodexErr::Stream( "remote compaction v2 stream closed before response.completed".to_string(), + None, )); }; - if context_compaction_count != 1 { + if compaction_count != 1 { return Err(CodexErr::Fatal(format!( - "remote compaction v2 expected exactly one context_compaction output item, got {context_compaction_count} from {output_item_count} output items" + "remote compaction v2 expected exactly one compaction output item, got {compaction_count} from {output_item_count} output items" ))); } - let Some(context_compaction_output) = context_compaction_output else { - unreachable!("context compaction output must exist when count is exactly one"); + let Some(compaction_output) = compaction_output else { + unreachable!("compaction output must exist when count is exactly one"); }; - Ok((context_compaction_output, response_id)) + Ok((compaction_output, response_id)) } fn build_v2_compacted_history( prompt_input: &[ResponseItem], compaction_output: ResponseItem, ) -> Vec { - let mut retained = prompt_input + let retained = prompt_input .iter() .filter(|item| is_retained_for_remote_compaction_v2(item)) + .filter(|item| should_keep_compacted_history_item(item)) .cloned() .collect::>(); + let mut retained = + truncate_retained_messages_for_remote_compaction(retained, RETAINED_MESSAGE_TOKEN_BUDGET); retained.push(compaction_output); retained } @@ -357,6 +430,98 @@ fn is_retained_for_remote_compaction_v2(item: &ResponseItem) -> bool { matches!(role.as_str(), "user" | "developer" | "system") } +fn truncate_retained_messages_for_remote_compaction( + items: Vec, + max_tokens: usize, +) -> Vec { + let mut remaining = max_tokens; + let mut truncated_reversed = Vec::with_capacity(items.len()); + for item in items.into_iter().rev() { + if remaining == 0 { + continue; + } + + let token_count = message_text_token_count(&item).max(1); + if token_count <= remaining { + truncated_reversed.push(item); + remaining = remaining.saturating_sub(token_count); + } else if let Some(truncated_item) = + truncate_message_text_to_token_budget(item, /*max_tokens*/ remaining) + { + truncated_reversed.push(truncated_item); + remaining = 0; + } + } + truncated_reversed.reverse(); + truncated_reversed +} + +fn message_text_token_count(item: &ResponseItem) -> usize { + let ResponseItem::Message { content, .. } = item else { + return 0; + }; + + content + .iter() + .map(|item| match item { + ContentItem::InputText { text } | ContentItem::OutputText { text } => { + approx_token_count(text) + } + ContentItem::InputImage { .. } => 0, + }) + .sum() +} + +fn truncate_message_text_to_token_budget( + item: ResponseItem, + max_tokens: usize, +) -> Option { + let ResponseItem::Message { + id, + role, + content, + phase, + } = item + else { + return Some(item); + }; + + let mut remaining = max_tokens; + let mut truncated_content = Vec::with_capacity(content.len()); + for mut content_item in content { + match &mut content_item { + ContentItem::InputText { text } | ContentItem::OutputText { text } => { + if remaining == 0 { + continue; + } + + let token_count = approx_token_count(text); + if token_count <= remaining { + remaining = remaining.saturating_sub(token_count); + } else { + *text = truncate_text(text, TruncationPolicy::Tokens(remaining)); + remaining = 0; + } + if !text.is_empty() { + truncated_content.push(content_item); + } + } + ContentItem::InputImage { .. } => truncated_content.push(content_item), + } + } + + if truncated_content.is_empty() { + return None; + } + + Some(ResponseItem::Message { + id, + role, + content: truncated_content, + phase, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -392,7 +557,7 @@ mod tests { } #[test] - fn build_v2_compacted_history_matches_prod_retention_shape() { + fn build_v2_compacted_history_filters_to_installed_retention_shape() { let input = vec![ message("developer", "dev", /*phase*/ None), message("system", "sys", /*phase*/ None), @@ -410,27 +575,157 @@ mod tests { encrypted_content: "old".to_string(), }, ]; - let output = ResponseItem::ContextCompaction { - encrypted_content: Some("new".to_string()), + let output = ResponseItem::Compaction { + encrypted_content: "new".to_string(), }; let history = build_v2_compacted_history(&input, output.clone()); assert_eq!( history, + vec![message("user", "user", /*phase*/ None), output] + ); + } + + #[test] + fn build_v2_compacted_history_discards_messages_before_truncating() { + let old = message("user", "old", /*phase*/ None); + let new = message("user", "new", /*phase*/ None); + let huge_developer_message = "d".repeat((RETAINED_MESSAGE_TOKEN_BUDGET + 1) * 4); + let huge_contextual_message = format!( + "\n{}\n", + "c".repeat((RETAINED_MESSAGE_TOKEN_BUDGET + 1) * 4) + ); + let input = vec![ + old.clone(), + message("developer", &huge_developer_message, /*phase*/ None), + message("user", &huge_contextual_message, /*phase*/ None), + new.clone(), + ]; + let output = ResponseItem::Compaction { + encrypted_content: "new".to_string(), + }; + + let history = build_v2_compacted_history(&input, output.clone()); + + assert_eq!(history, vec![old, new, output]); + } + + #[test] + fn retained_history_truncation_keeps_newest_messages_first() { + let middle = message("user", "middle1234", /*phase*/ None); + let new = message("user", "new", /*phase*/ None); + let retained = vec![ + message("user", "old-old", /*phase*/ None), + middle, + new.clone(), + ]; + + let truncated = + truncate_retained_messages_for_remote_compaction(retained, /*max_tokens*/ 3); + + assert_eq!( + truncated, vec![ - message("developer", "dev", /*phase*/ None), - message("system", "sys", /*phase*/ None), - message("user", "user", /*phase*/ None), - output, + message("user", "midd…1 tokens truncated…1234", /*phase*/ None), + new, ] ); } + #[test] + fn retained_history_truncation_preserves_images_and_truncates_later_text_parts() { + let item = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ + ContentItem::InputText { + text: "abcdef".to_string(), + }, + ContentItem::InputImage { + image_url: "data:image/png;base64,abc".to_string(), + detail: None, + }, + ContentItem::OutputText { + text: "uvwxyz".to_string(), + }, + ], + phase: None, + }; + + let truncated = + truncate_retained_messages_for_remote_compaction(vec![item], /*max_tokens*/ 3); + + assert_eq!( + truncated, + vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ + ContentItem::InputText { + text: "abcdef".to_string(), + }, + ContentItem::InputImage { + image_url: "data:image/png;base64,abc".to_string(), + detail: None, + }, + ContentItem::OutputText { + text: "uv…1 tokens truncated…yz".to_string(), + }, + ], + phase: None, + }] + ); + } + + #[test] + fn retained_history_truncation_charges_image_only_messages() { + let image_only_message = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputImage { + image_url: "data:image/png;base64,abc".to_string(), + detail: None, + }], + phase: None, + }; + let newest = message("user", "new", /*phase*/ None); + let retained = vec![ + message("user", "old", /*phase*/ None), + image_only_message.clone(), + newest.clone(), + ]; + + let truncated = + truncate_retained_messages_for_remote_compaction(retained, /*max_tokens*/ 2); + + assert_eq!(truncated, vec![image_only_message, newest]); + } + + #[test] + fn retained_history_truncation_drops_image_only_messages_after_budget_is_spent() { + let image_only_message = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputImage { + image_url: "data:image/png;base64,abc".to_string(), + detail: None, + }], + phase: None, + }; + let newest = message("user", "new", /*phase*/ None); + let retained = vec![image_only_message, newest.clone()]; + + let truncated = + truncate_retained_messages_for_remote_compaction(retained, /*max_tokens*/ 1); + + assert_eq!(truncated, vec![newest]); + } + #[tokio::test] - async fn collect_context_compaction_output_accepts_additional_output_items() { - let context_compaction = ResponseItem::ContextCompaction { - encrypted_content: Some("encrypted".to_string()), + async fn collect_compaction_output_accepts_additional_output_items() { + let compaction = ResponseItem::Compaction { + encrypted_content: "encrypted".to_string(), }; let stream = response_stream(vec![ Ok(ResponseEvent::OutputItemDone(message( @@ -438,7 +733,7 @@ mod tests { "IGNORED_COMPACT_REPLY", Some(MessagePhase::FinalAnswer), ))), - Ok(ResponseEvent::OutputItemDone(context_compaction.clone())), + Ok(ResponseEvent::OutputItemDone(compaction.clone())), Ok(ResponseEvent::Completed { response_id: "resp-compact".to_string(), token_usage: None, @@ -446,11 +741,11 @@ mod tests { }), ]); - let (output, response_id) = collect_context_compaction_output(stream) + let (output, response_id) = collect_compaction_output(stream) .await - .expect("context compaction should be collected"); + .expect("compaction should be collected"); - assert_eq!(output, context_compaction); + assert_eq!(output, compaction); assert_eq!(response_id, "resp-compact"); } } diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index b5c5c2ec18..d0097d7537 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -27,9 +27,10 @@ use codex_config::loader::load_requirements_toml; use codex_exec_server::LOCAL_FS; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::WebSearchMode; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::collections::BTreeMap; @@ -821,6 +822,7 @@ flag = false async fn managed_preferences_expand_home_directory_in_workspace_write_roots() -> anyhow::Result<()> { use base64::Engine; + use codex_protocol::protocol::SandboxPolicy; let Some(home) = dirs::home_dir() else { return Ok(()); @@ -913,14 +915,7 @@ allowed_sandbox_modes = ["read-only"] state .requirements() .permission_profile - .can_set(&PermissionProfile::from_legacy_sandbox_policy( - &SandboxPolicy::WorkspaceWrite { - writable_roots: Vec::new(), - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }, - )) + .can_set(&PermissionProfile::workspace_write()) .is_err() ); @@ -1102,9 +1097,13 @@ allowed_approval_policies = ["on-request"] allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, + computer_use: None, + windows: None, feature_requirements: None, hooks: None, mcp_servers: None, @@ -1160,9 +1159,13 @@ allowed_approval_policies = ["on-request"] allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, + computer_use: None, + windows: None, feature_requirements: None, hooks: None, mcp_servers: None, @@ -1233,11 +1236,9 @@ allowed_sandbox_modes = ["read-only"] let config_requirements: ConfigRequirements = config_requirements_toml.try_into()?; assert_eq!( - config_requirements.permission_profile.can_set( - &PermissionProfile::from_legacy_sandbox_policy( - &SandboxPolicy::new_workspace_write_policy() - ) - ), + config_requirements + .permission_profile + .can_set(&PermissionProfile::workspace_write()), Err(ConstraintError::InvalidValue { field_name: "sandbox_mode", candidate: "WorkspaceWrite".into(), @@ -1369,9 +1370,13 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> allowed_approval_policies: Some(vec![AskForApproval::Never]), allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, + computer_use: None, + windows: None, feature_requirements: None, hooks: None, mcp_servers: None, @@ -1417,6 +1422,264 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> Ok(()) } +#[tokio::test] +async fn system_requirements_define_managed_permission_profiles() -> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + tokio::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#" +default_permissions = "managed-standard" +"#, + ) + .await?; + let requirements_path = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_path, + r#" +allowed_permissions = ["managed-standard"] + +[permissions.managed-standard] +extends = ":workspace" +"#, + ) + .await?; + + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_requirements_path = Some(requirements_path); + let config = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(cwd.to_path_buf())) + .loader_overrides(overrides) + .build() + .await?; + + assert_eq!( + config + .config_layer_stack + .requirements_toml() + .allowed_permissions, + Some(vec!["managed-standard".to_string()]) + ); + assert_eq!( + config + .permissions + .active_permission_profile() + .map(|profile| profile.id), + Some("managed-standard".to_string()) + ); + Ok(()) +} + +#[tokio::test] +async fn system_allowed_permissions_keep_builtin_permission_fallbacks() -> anyhow::Result<()> { + for (trust_level, expected_profile) in [ + ( + Some(TrustLevel::Trusted), + if cfg!(target_os = "windows") { + BUILT_IN_PERMISSION_PROFILE_READ_ONLY + } else { + BUILT_IN_PERMISSION_PROFILE_WORKSPACE + }, + ), + ( + Some(TrustLevel::Untrusted), + if cfg!(target_os = "windows") { + BUILT_IN_PERMISSION_PROFILE_READ_ONLY + } else { + BUILT_IN_PERMISSION_PROFILE_WORKSPACE + }, + ), + (None, BUILT_IN_PERMISSION_PROFILE_READ_ONLY), + ] { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + if let Some(trust_level) = trust_level { + make_config_for_test( + &codex_home, + tmp.path(), + trust_level, + /*project_root_markers*/ None, + ) + .await?; + } + let requirements_path = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_path, + r#" +allowed_permissions = ["managed-standard"] + +[permissions.managed-standard.filesystem] +":workspace_roots" = "read" +"#, + ) + .await?; + + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_requirements_path = Some(requirements_path); + let config = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(cwd.to_path_buf())) + .loader_overrides(overrides) + .build() + .await?; + + assert_eq!( + config + .permissions + .active_permission_profile() + .map(|profile| profile.id), + Some(expected_profile.to_string()), + "trust level {trust_level:?}", + ); + } + Ok(()) +} + +#[tokio::test] +async fn system_allowed_permissions_keep_explicit_builtin_defaults() -> anyhow::Result<()> { + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + tokio::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#" +default_permissions = ":workspace" +"#, + ) + .await?; + let requirements_path = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_path, + r#" +allowed_permissions = ["managed-standard"] + +[permissions.managed-standard.filesystem] +":workspace_roots" = "read" +"#, + ) + .await?; + + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_requirements_path = Some(requirements_path); + let config = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(cwd.to_path_buf())) + .loader_overrides(overrides) + .build() + .await?; + + assert_eq!( + config + .permissions + .active_permission_profile() + .map(|profile| profile.id), + Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()) + ); + Ok(()) +} + +#[tokio::test] +async fn system_requirements_preserve_allowed_configured_permission_default() -> anyhow::Result<()> +{ + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + tokio::fs::write( + codex_home.join(CONFIG_TOML_FILE), + r#" +default_permissions = "managed-build" +"#, + ) + .await?; + let requirements_path = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_path, + r#" +allowed_permissions = ["managed-standard", "managed-build"] + +[permissions.managed-standard] +extends = ":read-only" + +[permissions.managed-build] +extends = ":workspace" +"#, + ) + .await?; + + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_requirements_path = Some(requirements_path); + let config = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(cwd.to_path_buf())) + .loader_overrides(overrides) + .build() + .await?; + + assert_eq!( + config + .permissions + .active_permission_profile() + .map(|profile| profile.id), + Some("managed-build".to_string()) + ); + Ok(()) +} + +#[tokio::test] +async fn system_requirements_warn_for_disallowed_explicit_permission_override() -> anyhow::Result<()> +{ + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + let requirements_path = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_path, + r#" +allowed_permissions = ["managed-standard"] + +[permissions.managed-standard] +extends = ":workspace" +"#, + ) + .await?; + + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_requirements_path = Some(requirements_path); + let config = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(cwd.to_path_buf())) + .harness_overrides(ConfigOverrides { + default_permissions: Some("managed-build".to_string()), + ..ConfigOverrides::default() + }) + .loader_overrides(overrides) + .build() + .await?; + + assert_eq!( + config + .permissions + .active_permission_profile() + .map(|profile| profile.id), + Some("managed-standard".to_string()) + ); + assert!( + config.startup_warnings.iter().any(|warning| warning + .contains("Configured value for `permission_profile` is disallowed by requirements")), + "{:?}", + config.startup_warnings + ); + Ok(()) +} + #[tokio::test] async fn load_config_layers_can_ignore_managed_requirements() -> anyhow::Result<()> { let tmp = tempdir()?; @@ -1570,9 +1833,7 @@ async fn load_config_layers_applies_matching_remote_sandbox_config() -> anyhow:: layers .requirements() .permission_profile - .can_set(&PermissionProfile::from_legacy_sandbox_policy( - &SandboxPolicy::new_workspace_write_policy() - )) + .can_set(&PermissionProfile::workspace_write()) .is_ok() ); @@ -2353,6 +2614,7 @@ model = "project-model" model_instructions_file = "instructions.md" openai_base_url = "https://attacker.example/v1" chatgpt_base_url = "https://attacker.example/backend-api" +apps_mcp_product_sku = "attacker" model_provider = "attacker" notify = ["sh", "-c", "echo attacker"] profile = "attacker" @@ -2404,6 +2666,7 @@ wire_api = "responses" let ignored_project_config_keys = vec![ "openai_base_url", "chatgpt_base_url", + "apps_mcp_product_sku", "model_provider", "model_providers", "notify", diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 7760c2d32d..70719843ba 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1,6 +1,5 @@ use crate::agents_md::DEFAULT_AGENTS_MD_FILENAME; use crate::agents_md::LOCAL_AGENTS_MD_FILENAME; -use crate::config::ThreadStoreConfig; use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; use crate::config::edit::apply_blocking; @@ -13,8 +12,8 @@ use codex_config::config_toml::AgentRoleToml; use codex_config::config_toml::AgentsToml; use codex_config::config_toml::AutoReviewToml; use codex_config::config_toml::ConfigToml; +use codex_config::config_toml::ExperimentalRequestUserInput; use codex_config::config_toml::ProjectConfig; -use codex_config::config_toml::RealtimeAudioConfig; use codex_config::config_toml::RealtimeConfig; use codex_config::config_toml::RealtimeToml; use codex_config::config_toml::RealtimeTransport; @@ -26,9 +25,13 @@ use codex_config::permissions_toml::FilesystemPermissionToml; use codex_config::permissions_toml::FilesystemPermissionsToml; use codex_config::permissions_toml::NetworkDomainPermissionToml; use codex_config::permissions_toml::NetworkDomainPermissionsToml; +use codex_config::permissions_toml::NetworkMitmActionToml; +use codex_config::permissions_toml::NetworkMitmHookToml; +use codex_config::permissions_toml::NetworkMitmToml; use codex_config::permissions_toml::NetworkToml; use codex_config::permissions_toml::PermissionProfileToml; use codex_config::permissions_toml::PermissionsToml; +use codex_config::permissions_toml::WorkspaceRootsToml; use codex_config::profile_toml::ConfigProfile; use codex_config::types::AppToolApproval; use codex_config::types::ApprovalsReviewer; @@ -46,7 +49,6 @@ use codex_config::types::Notice; use codex_config::types::NotificationCondition; use codex_config::types::NotificationMethod; use codex_config::types::Notifications; -use codex_config::types::OtelConfig; use codex_config::types::OtelConfigToml; use codex_config::types::OtelExporterKind; use codex_config::types::SandboxWorkspaceWrite; @@ -68,9 +70,10 @@ use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_model_provider_info::WireApi; use codex_models_manager::bundled_models_response; +use codex_network_proxy::NetworkMode; +use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::ServiceTier; use codex_protocol::models::ActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification; 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; @@ -94,6 +97,7 @@ use core_test_support::PathBufExt; use core_test_support::PathExt; use core_test_support::TempDirExt; use core_test_support::test_absolute_path; +use indexmap::IndexMap; use pretty_assertions::assert_eq; use rmcp::model::ElicitationCapability; use rmcp::model::FormElicitationCapability; @@ -114,7 +118,7 @@ fn stdio_mcp(command: &str) -> McpServerConfig { env_vars: Vec::new(), cwd: None, }, - experimental_environment: None, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, @@ -139,7 +143,7 @@ fn http_mcp(url: &str) -> McpServerConfig { http_headers: None, env_http_headers: None, }, - experimental_environment: None, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, @@ -285,6 +289,7 @@ persistence = "none" disable_on_external_context = true generate_memories = false use_memories = false +dedicated_tools = true max_raw_memories_for_consolidation = 512 max_unused_days = 21 max_rollout_age_days = 42 @@ -301,6 +306,7 @@ consolidation_model = "gpt-5.2" disable_on_external_context: Some(true), generate_memories: Some(false), use_memories: Some(false), + dedicated_tools: Some(true), max_raw_memories_for_consolidation: Some(512), max_unused_days: Some(21), max_rollout_age_days: Some(42), @@ -326,6 +332,7 @@ consolidation_model = "gpt-5.2" disable_on_external_context: true, generate_memories: false, use_memories: false, + dedicated_tools: true, max_raw_memories_for_consolidation: 512, max_unused_days: 21, max_rollout_age_days: 42, @@ -383,7 +390,13 @@ web_search = true ) .expect("TOML deserialization should succeed"); - assert_eq!(cfg.tools, Some(ToolsToml { web_search: None })); + assert_eq!( + cfg.tools, + Some(ToolsToml { + web_search: None, + experimental_request_user_input: None, + }) + ); } #[test] @@ -396,7 +409,72 @@ web_search = false ) .expect("TOML deserialization should succeed"); - assert_eq!(cfg.tools, Some(ToolsToml { web_search: None })); + assert_eq!( + cfg.tools, + Some(ToolsToml { + web_search: None, + experimental_request_user_input: None, + }) + ); +} + +#[test] +fn tools_experimental_request_user_input_defaults_to_enabled() { + let cfg: ConfigToml = toml::from_str( + r#" +[tools.experimental_request_user_input] +"#, + ) + .expect("TOML deserialization should succeed"); + + assert_eq!( + cfg.tools, + Some(ToolsToml { + web_search: None, + experimental_request_user_input: Some(ExperimentalRequestUserInput { enabled: true }), + }) + ); +} + +#[test] +fn tools_experimental_request_user_input_can_be_disabled() { + let cfg: ConfigToml = toml::from_str( + r#" +[tools.experimental_request_user_input] +enabled = false +"#, + ) + .expect("TOML deserialization should succeed"); + + assert_eq!( + cfg.tools, + Some(ToolsToml { + web_search: None, + experimental_request_user_input: Some(ExperimentalRequestUserInput { enabled: false }), + }) + ); +} + +#[tokio::test] +async fn load_config_resolves_experimental_request_user_input_enabled() -> std::io::Result<()> { + let codex_home = tempdir()?; + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + tools: Some(ToolsToml { + web_search: None, + experimental_request_user_input: Some(ExperimentalRequestUserInput { + enabled: false, + }), + }), + ..ConfigToml::default() + }, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert!(!config.experimental_request_user_input_enabled); + Ok(()) } #[test] @@ -720,34 +798,60 @@ async fn runtime_config_uses_tui_raw_output_mode() { #[test] fn config_toml_deserializes_permission_profiles() { let toml = r#" -default_permissions = "workspace" +default_permissions = "dev" -[permissions.workspace.filesystem] +[permissions.dev] +description = "Day-to-day workspace access." + +[permissions.dev.workspace_roots] +"~/code/openai" = true +"~/code/ignored" = false + +[permissions.dev.filesystem] ":minimal" = "read" +"/tmp/secret.env" = "deny" -[permissions.workspace.filesystem.":workspace_roots"] +[permissions.dev.filesystem.":workspace_roots"] "." = "write" "docs" = "read" -[permissions.workspace.network] +[permissions.dev.network] enabled = true proxy_url = "http://127.0.0.1:43128" enable_socks5 = false allow_upstream_proxy = false +mode = "full" -[permissions.workspace.network.domains] +[permissions.dev.network.domains] "openai.com" = "allow" + +[permissions.dev.network.mitm.hooks.github_write] +host = "api.github.com" +methods = ["POST", "PUT"] +path_prefixes = ["/repos/openai/"] +action = ["strip_auth"] + +[permissions.dev.network.mitm.actions.strip_auth] +strip_request_headers = ["authorization"] "#; let cfg: ConfigToml = toml::from_str(toml).expect("TOML deserialization should succeed for permissions profiles"); - assert_eq!(cfg.default_permissions.as_deref(), Some("workspace")); + assert_eq!(cfg.default_permissions.as_deref(), Some("dev")); assert_eq!( cfg.permissions.expect("[permissions] should deserialize"), PermissionsToml { entries: BTreeMap::from([( - "workspace".to_string(), + "dev".to_string(), PermissionProfileToml { + description: Some("Day-to-day workspace access.".to_string()), + extends: None, + workspace_roots: Some(WorkspaceRootsToml { + entries: BTreeMap::from([ + ("~/code/ignored".to_string(), false), + ("~/code/openai".to_string(), true), + ]), + }), filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([ @@ -755,6 +859,10 @@ allow_upstream_proxy = false ":minimal".to_string(), FilesystemPermissionToml::Access(FileSystemAccessMode::Read), ), + ( + "/tmp/secret.env".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Deny), + ), ( ":workspace_roots".to_string(), FilesystemPermissionToml::Scoped(BTreeMap::from([ @@ -773,7 +881,7 @@ allow_upstream_proxy = false allow_upstream_proxy: Some(false), dangerously_allow_non_loopback_proxy: None, dangerously_allow_all_unix_sockets: None, - mode: None, + mode: Some(NetworkMode::Full), domains: Some(NetworkDomainPermissionsToml { entries: BTreeMap::from([( "openai.com".to_string(), @@ -782,6 +890,27 @@ allow_upstream_proxy = false }), unix_sockets: None, allow_local_binding: None, + mitm: Some(NetworkMitmToml { + hooks: Some(IndexMap::from([( + "github_write".to_string(), + NetworkMitmHookToml { + host: "api.github.com".to_string(), + methods: vec!["POST".to_string(), "PUT".to_string()], + path_prefixes: vec!["/repos/openai/".to_string()], + query: BTreeMap::new(), + headers: BTreeMap::new(), + body: None, + action: vec!["strip_auth".to_string()], + }, + )])), + actions: Some(IndexMap::from([( + "strip_auth".to_string(), + NetworkMitmActionToml { + strip_request_headers: vec!["authorization".to_string()], + inject_request_headers: Vec::new(), + }, + )])), + }), }), }, )]), @@ -789,6 +918,140 @@ allow_upstream_proxy = false ); } +#[test] +fn config_toml_rejects_empty_mitm_action_reference_list() { + let toml = r#" +default_permissions = "workspace" + +[permissions.workspace.network.mitm.hooks.github_write] +host = "api.github.com" +methods = ["POST"] +path_prefixes = ["/repos/openai/"] +action = [] + +[permissions.workspace.network.mitm.actions.strip_auth] +strip_request_headers = ["authorization"] +"#; + + let err = + toml::from_str::(toml).expect_err("empty MITM action refs should fail closed"); + + assert!( + err.to_string() + .contains("network.mitm.hooks.github_write.action must not be empty"), + "{err}" + ); +} + +#[test] +fn config_toml_rejects_empty_mitm_action_definition() { + let toml = r#" +default_permissions = "workspace" + +[permissions.workspace.network.mitm.hooks.github_write] +host = "api.github.com" +methods = ["POST"] +path_prefixes = ["/repos/openai/"] +action = ["strip_auth"] + +[permissions.workspace.network.mitm.actions.strip_auth] +"#; + + let err = toml::from_str::(toml) + .expect_err("empty MITM action definitions should fail closed"); + + assert!( + err.to_string() + .contains("network.mitm.actions.strip_auth must define at least one operation"), + "{err}" + ); +} + +#[test] +fn permissions_profile_network_to_proxy_config_preserves_mitm_hooks() { + let network = NetworkToml { + mode: Some(NetworkMode::Full), + mitm: Some(NetworkMitmToml { + hooks: Some(IndexMap::from([( + "github_write".to_string(), + NetworkMitmHookToml { + host: "api.github.com".to_string(), + methods: vec!["POST".to_string()], + path_prefixes: vec!["/repos/openai/".to_string()], + action: vec!["strip_auth".to_string()], + ..NetworkMitmHookToml::default() + }, + )])), + actions: Some(IndexMap::from([( + "strip_auth".to_string(), + NetworkMitmActionToml { + strip_request_headers: vec!["authorization".to_string()], + inject_request_headers: Vec::new(), + }, + )])), + }), + ..NetworkToml::default() + }; + + let config = network.to_network_proxy_config(); + + assert_eq!(config.network.mode, NetworkMode::Full); + assert!(config.network.mitm); + assert_eq!(config.network.mitm_hooks.len(), 1); + assert_eq!(config.network.mitm_hooks[0].host, "api.github.com"); + assert_eq!( + config.network.mitm_hooks[0].matcher.methods, + vec!["POST".to_string()] + ); + assert_eq!( + config.network.mitm_hooks[0].actions.strip_request_headers, + vec!["authorization".to_string()] + ); +} + +#[test] +fn permissions_profile_network_to_proxy_config_preserves_mitm_hook_declaration_order() { + let toml = r#" +default_permissions = "workspace" + +[permissions.workspace.network.mitm.actions.noop] +strip_request_headers = ["authorization"] + +[permissions.workspace.network.mitm.hooks.z_first] +host = "api.github.com" +methods = ["POST"] +path_prefixes = ["/repos/openai/"] +action = ["noop"] + +[permissions.workspace.network.mitm.hooks.a_second] +host = "api.github.com" +methods = ["POST"] +path_prefixes = ["/repos/"] +action = ["noop"] +"#; + let cfg: ConfigToml = toml::from_str(toml).expect("permissions profile should deserialize"); + let permissions = cfg.permissions.expect("permissions should deserialize"); + let network = permissions + .entries + .get("workspace") + .expect("workspace profile should exist") + .network + .as_ref() + .expect("network profile should exist"); + + let config = network.to_network_proxy_config(); + + assert_eq!(config.network.mitm_hooks.len(), 2); + assert_eq!( + config.network.mitm_hooks[0].matcher.path_prefixes, + vec!["/repos/openai/".to_string()] + ); + assert_eq!( + config.network.mitm_hooks[1].matcher.path_prefixes, + vec!["/repos/".to_string()] + ); +} + #[tokio::test] async fn permissions_profiles_proxy_policy_does_not_start_managed_network_proxy_without_feature() -> std::io::Result<()> { @@ -798,11 +1061,64 @@ async fn permissions_profiles_proxy_policy_does_not_start_managed_network_proxy_ let config = Config::load_from_base_config_with_overrides( ConfigToml { - default_permissions: Some("workspace".to_string()), + default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( - "workspace".to_string(), + "dev".to_string(), PermissionProfileToml { + description: None, + extends: None, + workspace_roots: None, + filesystem: Some(FilesystemPermissionsToml { + glob_scan_max_depth: None, + entries: BTreeMap::from([( + ":minimal".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + )]), + }), + network: Some(NetworkToml { + enabled: Some(true), + ..Default::default() + }), + }, + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + assert_eq!( + config.permissions.network_sandbox_policy(), + NetworkSandboxPolicy::Enabled + ); + assert!( + config.permissions.network.is_none(), + "bare profile network.enabled should not start the managed network proxy" + ); + Ok(()) +} + +#[tokio::test] +async fn permissions_profiles_proxy_policy_starts_managed_network_proxy() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some("dev".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "dev".to_string(), + PermissionProfileToml { + description: None, + extends: None, + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -951,11 +1267,14 @@ async fn network_proxy_feature_matrix_preserves_sandbox_network_semantics() -> s .then(|| toml::from_str("network_proxy = true").expect("valid features")); let base_config = match case.surface { Surface::PermissionProfile => ConfigToml { - default_permissions: Some("workspace".to_string()), + default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( - "workspace".to_string(), + "dev".to_string(), PermissionProfileToml { + description: None, + extends: None, + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -1101,11 +1420,14 @@ async fn network_proxy_feature_uses_profile_network_proxy_settings() -> std::io: let config = Config::load_from_base_config_with_overrides( ConfigToml { features: Some(toml::from_str("network_proxy = true").expect("valid features")), - default_permissions: Some("workspace".to_string()), + default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( - "workspace".to_string(), + "dev".to_string(), PermissionProfileToml { + description: None, + extends: None, + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -1146,47 +1468,6 @@ async fn network_proxy_feature_uses_profile_network_proxy_settings() -> std::io: Ok(()) } -#[tokio::test] -async fn profile_network_proxy_disable_ignores_base_feature_config() -> std::io::Result<()> { - let codex_home = TempDir::new()?; - let cwd = TempDir::new()?; - let config = Config::load_from_base_config_with_overrides( - ConfigToml { - features: Some( - toml::from_str( - r#" -[network_proxy] -enabled = true -proxy_url = "http://127.0.0.1:43128" -"#, - ) - .expect("valid base features"), - ), - profiles: HashMap::from([( - "no_proxy".to_string(), - ConfigProfile { - features: Some( - toml::from_str("network_proxy = false").expect("valid profile features"), - ), - ..Default::default() - }, - )]), - profile: Some("no_proxy".to_string()), - ..Default::default() - }, - ConfigOverrides { - cwd: Some(cwd.path().to_path_buf()), - ..Default::default() - }, - codex_home.abs(), - ) - .await?; - - assert!(!config.features.enabled(Feature::NetworkProxy)); - assert!(config.permissions.network.is_none()); - Ok(()) -} - #[tokio::test] async fn disabled_network_proxy_feature_does_not_start_profile_proxy_policy() -> std::io::Result<()> { @@ -1203,11 +1484,14 @@ enabled = false ) .expect("valid features"), ), - default_permissions: Some("workspace".to_string()), + default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( - "workspace".to_string(), + "dev".to_string(), PermissionProfileToml { + description: None, + extends: None, + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -1251,11 +1535,14 @@ async fn permissions_profiles_network_disabled_by_default_does_not_start_proxy() let config = Config::load_from_base_config_with_overrides( ConfigToml { - default_permissions: Some("workspace".to_string()), + default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( - "workspace".to_string(), + "dev".to_string(), PermissionProfileToml { + description: None, + extends: None, + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -1297,11 +1584,14 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; let cfg = ConfigToml { - default_permissions: Some("workspace".to_string()), + default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( - "workspace".to_string(), + "dev".to_string(), PermissionProfileToml { + description: None, + extends: None, + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([ @@ -1335,7 +1625,7 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: ) .await?; - let memories_root = codex_home.path().join("memories").abs(); + let cwd_root = cwd.path().abs(); assert_eq!( config.permissions.file_system_sandbox_policy(), FileSystemSandboxPolicy::restricted(vec![ @@ -1346,34 +1636,34 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: access: FileSystemAccessMode::Read, }, FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + path: FileSystemPath::Path { + path: cwd_root.clone(), }, access: FileSystemAccessMode::Write, }, - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(Some("docs".into())), - }, - access: FileSystemAccessMode::Read, - }, FileSystemSandboxEntry { path: FileSystemPath::Path { - path: memories_root.clone(), + path: cwd_root.join("docs"), }, - access: FileSystemAccessMode::Write, + access: FileSystemAccessMode::Read, }, ]), ); assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::WorkspaceWrite { - writable_roots: vec![memories_root], + writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, } ); + assert!( + !config + .permissions + .file_system_sandbox_policy() + .can_write_path_with_cwd(&cwd.path().join(".git"), cwd.path()) + ); assert_eq!( config.permissions.network_sandbox_policy(), NetworkSandboxPolicy::Restricted @@ -1384,7 +1674,66 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: .active_permission_profile() .as_ref() .map(|active| active.id.as_str()), - Some("workspace") + Some("dev") + ); + Ok(()) +} + +#[tokio::test] +async fn default_permissions_extended_profile_preserves_parent_metadata() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some("dev".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([ + ( + "base".to_string(), + PermissionProfileToml { + description: None, + extends: None, + workspace_roots: None, + filesystem: Some(FilesystemPermissionsToml { + glob_scan_max_depth: None, + entries: BTreeMap::from([( + ":minimal".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + )]), + }), + network: None, + }, + ), + ( + "dev".to_string(), + PermissionProfileToml { + description: None, + extends: Some("base".to_string()), + workspace_roots: None, + filesystem: None, + network: None, + }, + ), + ]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + assert_eq!( + config.permissions.active_permission_profile(), + Some(ActivePermissionProfile { + id: "dev".to_string(), + extends: Some("base".to_string()), + }) ); Ok(()) } @@ -1406,7 +1755,10 @@ async fn permission_profile_override_populates_runtime_permissions() -> std::io: ) .await?; - assert_eq!(config.permissions.permission_profile(), permission_profile); + assert_eq!( + config.permissions.effective_permission_profile(), + permission_profile + ); assert_eq!(config.permissions.active_permission_profile(), None); assert_eq!( &config.legacy_sandbox_policy(), @@ -1415,6 +1767,30 @@ async fn permission_profile_override_populates_runtime_permissions() -> std::io: Ok(()) } +#[test] +fn permission_snapshot_setter_preserves_permission_constraints() { + let initial_profile = PermissionProfile::read_only(); + let mut permissions = Permissions::from_approval_and_profile( + Constrained::allow_any(AskForApproval::Never), + Constrained::allow_only(initial_profile.clone()), + ) + .expect("initial permissions should satisfy constraints"); + + let err = permissions + .set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::active( + PermissionProfile::workspace_write(), + ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_WORKSPACE), + )) + .expect_err("workspace profile should violate read-only constraint"); + + assert_eq!(permissions.permission_profile(), &initial_profile); + assert_eq!(permissions.active_permission_profile(), None); + assert!( + matches!(err, ConstraintError::InvalidValue { .. }), + "expected invalid value constraint error, got {err:?}" + ); +} + #[tokio::test] async fn permission_profile_override_preserves_managed_unrestricted_filesystem() -> std::io::Result<()> { @@ -1436,7 +1812,10 @@ async fn permission_profile_override_preserves_managed_unrestricted_filesystem() ) .await?; - assert_eq!(config.permissions.permission_profile(), permission_profile); + assert_eq!( + config.permissions.effective_permission_profile(), + permission_profile + ); assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::ExternalSandbox { @@ -1502,7 +1881,7 @@ async fn managed_unrestricted_permission_profile_still_enables_network_requireme } #[tokio::test] -async fn permission_profile_override_applies_runtime_roots_to_legacy_projection() +async fn permission_profile_override_keeps_memories_root_out_of_legacy_projection() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; @@ -1537,7 +1916,7 @@ async fn permission_profile_override_applies_runtime_roots_to_legacy_projection( let memories_root = codex_home.path().join("memories").abs(); assert!( - config + !config .permissions .file_system_sandbox_policy() .can_write_path_with_cwd(memories_root.as_path(), cwd.path()) @@ -1545,7 +1924,7 @@ async fn permission_profile_override_applies_runtime_roots_to_legacy_projection( assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::WorkspaceWrite { - writable_roots: vec![memories_root], + writable_roots: vec![], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -1563,11 +1942,14 @@ async fn permission_profile_override_preserves_configured_network_policy_without let config = Config::load_from_base_config_with_overrides( ConfigToml { - default_permissions: Some("workspace".to_string()), + default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( - "workspace".to_string(), + "dev".to_string(), PermissionProfileToml { + description: None, + extends: None, + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -1605,7 +1987,10 @@ async fn permission_profile_override_preserves_configured_network_policy_without config.permissions.network.is_none(), "profile network.enabled should not start the managed network proxy" ); - assert_eq!(config.permissions.permission_profile(), permission_profile); + assert_eq!( + config.permissions.effective_permission_profile(), + permission_profile + ); Ok(()) } @@ -1613,22 +1998,27 @@ async fn permission_profile_override_preserves_configured_network_policy_without async fn workspace_root_glob_none_compiles_to_filesystem_pattern_entry() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; + let extra_root = TempDir::new()?; tokio::fs::write(cwd.path().join(".git"), "gitdir: nowhere").await?; + tokio::fs::write(extra_root.path().join(".git"), "gitdir: nowhere").await?; let config = Config::load_from_base_config_with_overrides( ConfigToml { - default_permissions: Some("workspace".to_string()), + default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( - "workspace".to_string(), + "dev".to_string(), PermissionProfileToml { + description: None, + extends: None, + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: Some(2), entries: BTreeMap::from([( ":workspace_roots".to_string(), FilesystemPermissionToml::Scoped(BTreeMap::from([ (".".to_string(), FileSystemAccessMode::Write), - ("**/*.env".to_string(), FileSystemAccessMode::None), + ("**/*.env".to_string(), FileSystemAccessMode::Deny), ])), )]), }), @@ -1640,6 +2030,7 @@ async fn workspace_root_glob_none_compiles_to_filesystem_pattern_entry() -> std: }, ConfigOverrides { cwd: Some(cwd.path().to_path_buf()), + additional_writable_roots: vec![extra_root.path().to_path_buf()], ..Default::default() }, codex_home.abs(), @@ -1653,21 +2044,23 @@ async fn workspace_root_glob_none_compiles_to_filesystem_pattern_entry() -> std: .glob_scan_max_depth, Some(2) ); - let expected_pattern = AbsolutePathBuf::resolve_path_against_base("**/*.env", cwd.path()) - .to_string_lossy() - .into_owned(); - assert!( - config - .permissions - .file_system_sandbox_policy() - .entries - .contains(&FileSystemSandboxEntry { - path: FileSystemPath::GlobPattern { - pattern: expected_pattern, - }, - access: FileSystemAccessMode::None, - }) - ); + for root in [cwd.path(), extra_root.path()] { + let expected_pattern = AbsolutePathBuf::resolve_path_against_base("**/*.env", root) + .to_string_lossy() + .into_owned(); + assert!( + config + .permissions + .file_system_sandbox_policy() + .entries + .contains(&FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: expected_pattern, + }, + access: FileSystemAccessMode::Deny, + }) + ); + } assert!( !config .permissions @@ -1695,8 +2088,11 @@ async fn permissions_profiles_require_default_permissions() -> std::io::Result<( ConfigToml { permissions: Some(PermissionsToml { entries: BTreeMap::from([( - "workspace".to_string(), + "dev".to_string(), PermissionProfileToml { + description: None, + extends: None, + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -1746,6 +2142,8 @@ async fn default_permissions_can_select_builtin_profile_without_permissions_tabl ) .await?; + assert!(config.explicit_permission_profile_mode); + assert!(config.custom_permission_profiles.is_empty()); let policy = config.permissions.file_system_sandbox_policy(); assert_eq!( config @@ -1767,8 +2165,7 @@ async fn default_permissions_can_select_builtin_profile_without_permissions_tabl } #[tokio::test] -async fn default_permissions_read_only_applies_additional_writable_roots_as_modifications() --> std::io::Result<()> { +async fn default_permissions_read_only_keeps_add_dir_read_only() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; let extra_root = TempDir::new()?; @@ -1790,20 +2187,114 @@ async fn default_permissions_read_only_applies_additional_writable_roots_as_modi let policy = config.permissions.file_system_sandbox_policy(); assert!( - policy.can_write_path_with_cwd(extra_root.as_path(), cwd.path()), - "expected additional writable root to modify :read-only, policy: {policy:?}" + !policy.can_write_path_with_cwd(extra_root.as_path(), cwd.path()), + "expected :read-only to stay read-only for runtime workspace roots, policy: {policy:?}" ); assert_eq!( config.permissions.active_permission_profile(), - Some( - ActivePermissionProfile::new(BUILT_IN_PERMISSION_PROFILE_READ_ONLY).with_modifications( - vec![ - ActivePermissionProfileModification::AdditionalWritableRoot { - path: extra_root, + Some(ActivePermissionProfile::new( + BUILT_IN_PERMISSION_PROFILE_READ_ONLY, + )) + ); + Ok(()) +} + +#[tokio::test] +async fn workspace_profile_applies_rules_to_runtime_and_profile_workspace_roots() +-> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let codex_home = temp_dir.path().join("codex-home"); + let cwd = temp_dir.path().join("frontend"); + let runtime_root = temp_dir.path().join("backend"); + let profile_root = temp_dir.path().join("shared"); + for root in [&cwd, &runtime_root, &profile_root] { + std::fs::create_dir_all(root.join(".git"))?; + std::fs::create_dir_all(root.join(".codex"))?; + } + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some("dev".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "dev".to_string(), + PermissionProfileToml { + description: None, + extends: None, + workspace_roots: Some(WorkspaceRootsToml { + entries: BTreeMap::from([( + profile_root.to_string_lossy().into_owned(), + true, + )]), + }), + filesystem: Some(FilesystemPermissionsToml { + glob_scan_max_depth: None, + entries: BTreeMap::from([( + ":workspace_roots".to_string(), + FilesystemPermissionToml::Scoped(BTreeMap::from([ + (".".to_string(), FileSystemAccessMode::Write), + (".git".to_string(), FileSystemAccessMode::Read), + (".codex".to_string(), FileSystemAccessMode::Read), + ])), + )]), + }), + network: None, }, - ] - ) - ) + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.clone()), + additional_writable_roots: vec![runtime_root.clone()], + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + let cwd_abs = cwd.abs(); + let runtime_root_abs = runtime_root.abs(); + let profile_root_abs = profile_root.abs(); + assert_eq!( + config.workspace_roots, + vec![cwd_abs.clone(), runtime_root_abs.clone()] + ); + assert_eq!( + config.permissions.workspace_roots(), + &[cwd_abs.clone(), runtime_root_abs.clone()] + ); + assert_eq!( + config.effective_workspace_roots(), + vec![ + cwd_abs.clone(), + runtime_root_abs.clone(), + profile_root_abs.clone() + ] + ); + + let policy = config.permissions.file_system_sandbox_policy(); + for root in [cwd_abs, runtime_root_abs, profile_root_abs.clone()] { + assert!( + policy.can_write_path_with_cwd(root.as_path(), cwd.as_path()), + "expected workspace root to be writable, policy: {policy:?}" + ); + assert!( + !policy.can_write_path_with_cwd(&root.join(".git"), cwd.as_path()), + "expected .git carveout under {root:?}, policy: {policy:?}" + ); + assert!( + !policy.can_write_path_with_cwd(&root.join(".codex"), cwd.as_path()), + "expected .codex carveout under {root:?}, policy: {policy:?}" + ); + } + assert_eq!( + config.permissions.profile_workspace_roots(), + std::slice::from_ref(&profile_root_abs) + ); + assert_eq!( + config.permissions.active_permission_profile(), + Some(ActivePermissionProfile::new("dev")) ); Ok(()) } @@ -1850,6 +2341,118 @@ async fn explicit_builtin_workspace_profile_ignores_legacy_workspace_write_setti Ok(()) } +#[tokio::test] +async fn default_permissions_profile_can_extend_builtin_workspace() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some("workspace-with-network".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "workspace-with-network".to_string(), + PermissionProfileToml { + description: None, + extends: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()), + workspace_roots: None, + filesystem: None, + network: Some(NetworkToml { + enabled: Some(true), + ..Default::default() + }), + }, + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + let policy = config.permissions.file_system_sandbox_policy(); + assert!( + policy.can_write_path_with_cwd(cwd.path(), cwd.path()), + "expected profile extending :workspace to keep project-root writes, policy: {policy:?}" + ); + assert!( + !policy.can_write_path_with_cwd(&cwd.path().join(".git"), cwd.path()), + "expected profile extending :workspace to keep metadata carveouts, policy: {policy:?}" + ); + assert_eq!( + config.permissions.network_sandbox_policy(), + NetworkSandboxPolicy::Enabled + ); + assert_eq!( + config.permissions.active_permission_profile(), + Some(ActivePermissionProfile { + id: "workspace-with-network".to_string(), + extends: Some(BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string()), + }) + ); + Ok(()) +} + +#[tokio::test] +async fn default_permissions_profile_can_extend_builtin_read_only() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + default_permissions: Some("read-only-with-network".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "read-only-with-network".to_string(), + PermissionProfileToml { + description: None, + extends: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()), + workspace_roots: None, + filesystem: None, + network: Some(NetworkToml { + enabled: Some(true), + ..Default::default() + }), + }, + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + let policy = config.permissions.file_system_sandbox_policy(); + assert!( + policy.can_read_path_with_cwd(cwd.path(), cwd.path()), + "expected profile extending :read-only to keep read access, policy: {policy:?}" + ); + assert!( + !policy.can_write_path_with_cwd(cwd.path(), cwd.path()), + "expected profile extending :read-only to stay non-writable, policy: {policy:?}" + ); + assert_eq!( + config.permissions.network_sandbox_policy(), + NetworkSandboxPolicy::Enabled + ); + assert_eq!( + config.permissions.active_permission_profile(), + Some(ActivePermissionProfile { + id: "read-only-with-network".to_string(), + extends: Some(BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string()), + }) + ); + Ok(()) +} + #[tokio::test] async fn empty_config_defaults_to_builtin_profile_for_trusted_project() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -2071,7 +2674,7 @@ async fn default_permissions_can_select_builtin_full_access_profile() -> std::io .await?; assert_eq!( - config.permissions.permission_profile(), + config.permissions.effective_permission_profile(), PermissionProfile::Disabled ); assert_eq!( @@ -2183,11 +2786,14 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() let config = Config::load_from_base_config_with_overrides( ConfigToml { - default_permissions: Some("workspace".to_string()), + default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( - "workspace".to_string(), + "dev".to_string(), PermissionProfileToml { + description: Some("Workspace access.".to_string()), + extends: None, + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -2209,9 +2815,13 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() ) .await?; - let memories_root = AbsolutePathBuf::from_absolute_path(std::fs::canonicalize( - codex_home.path().join("memories"), - )?)?; + assert_eq!( + config.custom_permission_profiles, + vec![CustomPermissionProfileSummary { + id: "dev".to_string(), + description: Some("Workspace access.".to_string()), + }] + ); assert!( config .permissions @@ -2221,7 +2831,7 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::WorkspaceWrite { - writable_roots: vec![external_write_path, memories_root], + writable_roots: vec![external_write_path], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -2239,11 +2849,14 @@ async fn permissions_profiles_reject_nested_entries_for_non_workspace_roots() -> let err = Config::load_from_base_config_with_overrides( ConfigToml { - default_permissions: Some("workspace".to_string()), + default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( - "workspace".to_string(), + "dev".to_string(), PermissionProfileToml { + description: None, + extends: None, + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -2286,9 +2899,9 @@ async fn load_workspace_permission_profile( Config::load_from_base_config_with_overrides( ConfigToml { - default_permissions: Some("workspace".to_string()), + default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { - entries: BTreeMap::from([("workspace".to_string(), profile)]), + entries: BTreeMap::from([("dev".to_string(), profile)]), }), ..Default::default() }, @@ -2304,6 +2917,9 @@ 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, + extends: None, + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -2347,6 +2963,9 @@ 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, + extends: None, + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -2383,6 +3002,9 @@ 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, + extends: None, + workspace_roots: None, filesystem: None, network: None, }) @@ -2400,7 +3022,7 @@ async fn permissions_profiles_allow_missing_filesystem_with_warning() -> std::io ); assert!( config.startup_warnings.iter().any(|warning| warning.contains( - "Permissions profile `workspace` does not define any recognized filesystem entries for this version of Codex." + "Permissions profile `dev` does not define any recognized filesystem entries for this version of Codex." )), "{:?}", config.startup_warnings @@ -2411,6 +3033,9 @@ 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, + extends: None, + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::new(), @@ -2425,7 +3050,7 @@ async fn permissions_profiles_allow_empty_filesystem_with_warning() -> std::io:: ); assert!( config.startup_warnings.iter().any(|warning| warning.contains( - "Permissions profile `workspace` does not define any recognized filesystem entries for this version of Codex." + "Permissions profile `dev` does not define any recognized filesystem entries for this version of Codex." )), "{:?}", config.startup_warnings @@ -2441,11 +3066,14 @@ async fn permissions_profiles_reject_workspace_root_parent_traversal() -> std::i let err = Config::load_from_base_config_with_overrides( ConfigToml { - default_permissions: Some("workspace".to_string()), + default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( - "workspace".to_string(), + "dev".to_string(), PermissionProfileToml { + description: None, + extends: None, + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -2487,11 +3115,14 @@ async fn permissions_profiles_allow_network_enablement() -> std::io::Result<()> let config = Config::load_from_base_config_with_overrides( ConfigToml { - default_permissions: Some("workspace".to_string()), + default_permissions: Some("dev".to_string()), permissions: Some(PermissionsToml { entries: BTreeMap::from([( - "workspace".to_string(), + "dev".to_string(), PermissionProfileToml { + description: None, + extends: None, + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -2840,31 +3471,6 @@ async fn runtime_config_resolves_session_picker_view_default_and_override() { cfg.tui_session_picker_view, SessionPickerViewMode::Comfortable ); - - let cfg_toml = toml::from_str::( - r#"profile = "work" - -[tui] -session_picker_view = "dense" - -[profiles.work.tui] -session_picker_view = "comfortable" -"#, - ) - .expect("parse profile scoped tui config"); - - let cfg = Config::load_from_base_config_with_overrides( - cfg_toml, - ConfigOverrides::default(), - tempdir().expect("tempdir").abs(), - ) - .await - .expect("load profile override config"); - - assert_eq!( - cfg.tui_session_picker_view, - SessionPickerViewMode::Comfortable - ); } #[tokio::test] @@ -3087,13 +3693,15 @@ exclude_slash_tmp = true ); continue; } + assert_eq!( + config.permissions.workspace_roots(), + &[cwd.abs(), extra_root.clone()] + ); assert!( file_system_policy .entries .contains(&FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(/*subpath*/ None), - }, + path: FileSystemPath::Path { path: cwd.abs() }, access: FileSystemAccessMode::Write, }) ); @@ -3112,15 +3720,16 @@ exclude_slash_tmp = true file_system_policy .entries .contains(&FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::project_roots(Some( - subpath.into() - )), + path: FileSystemPath::Path { + path: AbsolutePathBuf::resolve_path_against_base( + subpath, + cwd.path() + ), }, access: FileSystemAccessMode::Read, }), - "case `{name}` should preserve `{subpath}` as a symbolic project-root \ - metadata carveout" + "case `{name}` should materialize `{subpath}` for the runtime workspace \ + root" ); } } @@ -3671,6 +4280,63 @@ async fn rebuild_preserving_session_layers_refreshes_plugin_derived_mcp_config() mcp_config.configured_mcp_servers.get("sample"), Some(&http_mcp("https://sample.example/mcp")) ); + assert_eq!( + mcp_config.plugin_ids_by_mcp_server_name, + HashMap::from([("sample".to_string(), "sample@test".to_string())]) + ); + + Ok(()) +} + +#[tokio::test] +async fn to_mcp_config_omits_plugin_id_when_user_server_shadows_plugin_mcp() -> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + )?; + std::fs::write( + plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "sample": { + "type": "http", + "url": "https://plugin.example/mcp" + } + } +}"#, + )?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +[features] +plugins = true + +[mcp_servers.sample] +url = "https://user.example/mcp" + +[plugins."sample@test"] +enabled = true +"#, + )?; + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await?; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + let mcp_config = config.to_mcp_config(&plugins_manager).await; + + assert_eq!( + mcp_config.configured_mcp_servers.get("sample"), + Some(&http_mcp("https://user.example/mcp")) + ); + assert!(mcp_config.plugin_ids_by_mcp_server_name.is_empty()); Ok(()) } @@ -3872,6 +4538,25 @@ async fn add_dir_override_extends_workspace_writable_roots() -> std::io::Result< Ok(()) } +#[tokio::test] +async fn default_zsh_path_sets_runtime_zsh_path() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let default_zsh_path = codex_home.path().join("packaged-zsh"); + + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides { + default_zsh_path: Some(default_zsh_path.abs()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + assert_eq!(config.zsh_path, Some(default_zsh_path)); + + Ok(()) +} + #[tokio::test] async fn sqlite_home_defaults_to_codex_home_for_workspace_write() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -3891,13 +4576,15 @@ async fn sqlite_home_defaults_to_codex_home_for_workspace_write() -> std::io::Re } #[tokio::test] -async fn workspace_write_always_includes_memories_root_once() -> std::io::Result<()> { +async fn workspace_write_includes_configured_writable_root_once_without_memories_root() +-> std::io::Result<()> { let codex_home = TempDir::new()?; let memories_root = codex_home.path().join("memories"); + let writable_root = codex_home.path().join("writable").abs(); let config = Config::load_from_base_config_with_overrides( ConfigToml { sandbox_workspace_write: Some(SandboxWorkspaceWrite { - writable_roots: vec![memories_root.abs()], + writable_roots: vec![writable_root.clone(), writable_root.clone()], ..Default::default() }), ..Default::default() @@ -3917,21 +4604,22 @@ async fn workspace_write_always_includes_memories_root_once() -> std::io::Result } } else { assert!( - memories_root.is_dir(), - "expected memories root directory to exist at {}", + !memories_root.exists(), + "expected config load not to create memories root at {}", memories_root.display() ); let expected_memories_root = memories_root.abs(); match &config.legacy_sandbox_policy() { SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + assert!(!writable_roots.contains(&expected_memories_root)); assert_eq!( writable_roots .iter() - .filter(|root| **root == expected_memories_root) + .filter(|root| **root == writable_root) .count(), 1, "expected single writable root entry for {}", - expected_memories_root.display() + writable_root.display() ); } other => panic!("expected workspace-write policy, got {other:?}"), @@ -3941,6 +4629,62 @@ async fn workspace_write_always_includes_memories_root_once() -> std::io::Result Ok(()) } +#[tokio::test] +async fn memory_tool_makes_memories_root_readable_without_creating_or_widening_writes() +-> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let memories_root = codex_home.path().join("memories"); + let memories_root_abs = memories_root.abs(); + + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + features: Some(FeaturesToml::from(BTreeMap::from([( + "memories".to_string(), + true, + )]))), + sandbox_workspace_write: Some(SandboxWorkspaceWrite { + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + ..Default::default() + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + sandbox_mode: Some(SandboxMode::WorkspaceWrite), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + assert!( + !memories_root.exists(), + "expected config load not to create memories root at {}", + memories_root.display() + ); + let file_system_policy = config.permissions.file_system_sandbox_policy(); + assert!(file_system_policy.can_read_path_with_cwd(memories_root_abs.as_path(), cwd.path())); + assert!(!file_system_policy.can_write_path_with_cwd(memories_root_abs.as_path(), cwd.path())); + + if cfg!(target_os = "windows") { + match &config.legacy_sandbox_policy() { + SandboxPolicy::ReadOnly { .. } => {} + other => panic!("expected read-only policy on Windows, got {other:?}"), + } + } else { + match &config.legacy_sandbox_policy() { + SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + assert!(!writable_roots.contains(&memories_root_abs)); + } + other => panic!("expected workspace-write policy, got {other:?}"), + } + } + + Ok(()) +} + #[tokio::test] async fn config_defaults_to_file_cli_auth_store_mode() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -4084,16 +4828,14 @@ async fn feedback_enabled_defaults_to_true() -> std::io::Result<()> { #[test] fn web_search_mode_defaults_to_none_if_unset() { let cfg = ConfigToml::default(); - let profile = ConfigProfile::default(); let features = Features::with_defaults(); - assert_eq!(resolve_web_search_mode(&cfg, &profile, &features), None); + assert_eq!(resolve_web_search_mode(&cfg, &features), None); } #[test] -fn web_search_mode_prefers_profile_over_legacy_flags() { - let cfg = ConfigToml::default(); - let profile = ConfigProfile { +fn web_search_mode_prefers_config_over_legacy_flags() { + let cfg = ConfigToml { web_search: Some(WebSearchMode::Live), ..Default::default() }; @@ -4101,7 +4843,7 @@ fn web_search_mode_prefers_profile_over_legacy_flags() { features.enable(Feature::WebSearchCached); assert_eq!( - resolve_web_search_mode(&cfg, &profile, &features), + resolve_web_search_mode(&cfg, &features), Some(WebSearchMode::Live) ); } @@ -4112,12 +4854,11 @@ fn web_search_mode_disabled_overrides_legacy_request() { web_search: Some(WebSearchMode::Disabled), ..Default::default() }; - let profile = ConfigProfile::default(); let mut features = Features::with_defaults(); features.enable(Feature::WebSearchRequest); assert_eq!( - resolve_web_search_mode(&cfg, &profile, &features), + resolve_web_search_mode(&cfg, &features), Some(WebSearchMode::Disabled) ); } @@ -4125,8 +4866,7 @@ fn web_search_mode_disabled_overrides_legacy_request() { #[test] fn web_search_mode_for_turn_uses_preference_for_read_only() { let web_search_mode = Constrained::allow_any(WebSearchMode::Cached); - let permission_profile = - PermissionProfile::from_legacy_sandbox_policy(&SandboxPolicy::new_read_only_policy()); + let permission_profile = PermissionProfile::read_only(); let mode = resolve_web_search_mode_for_turn(&web_search_mode, &permission_profile); assert_eq!(mode, WebSearchMode::Cached); @@ -4178,14 +4918,6 @@ async fn project_profiles_are_ignored() -> std::io::Result<()> { codex_home.path().join(CONFIG_TOML_FILE), format!( r#" -profile = "global" - -[profiles.global] -model = "gpt-global" - -[profiles.project] -model = "gpt-project" - [projects."{workspace_key}"] trust_level = "trusted" "#, @@ -4212,8 +4944,7 @@ model = "gpt-project-local" .build() .await?; - assert_eq!(config.active_profile.as_deref(), Some("global")); - assert_eq!(config.model.as_deref(), Some("gpt-global")); + assert_eq!(config.model, None); assert!( config.startup_warnings.iter().any(|warning| { warning.contains("profile") @@ -4230,7 +4961,7 @@ model = "gpt-project-local" } #[tokio::test] -async fn profile_sandbox_mode_overrides_base() -> std::io::Result<()> { +async fn unselected_profile_sandbox_mode_is_ignored() -> std::io::Result<()> { let codex_home = TempDir::new()?; let mut profiles = HashMap::new(); profiles.insert( @@ -4242,7 +4973,6 @@ async fn profile_sandbox_mode_overrides_base() -> std::io::Result<()> { ); let cfg = ConfigToml { profiles, - profile: Some("work".to_string()), sandbox_mode: Some(SandboxMode::ReadOnly), ..Default::default() }; @@ -4254,50 +4984,10 @@ async fn profile_sandbox_mode_overrides_base() -> std::io::Result<()> { ) .await?; - assert!(matches!( - &config.legacy_sandbox_policy(), - &SandboxPolicy::DangerFullAccess - )); - - Ok(()) -} - -#[tokio::test] -async fn cli_override_takes_precedence_over_profile_sandbox_mode() -> std::io::Result<()> { - let codex_home = TempDir::new()?; - let mut profiles = HashMap::new(); - profiles.insert( - "work".to_string(), - ConfigProfile { - sandbox_mode: Some(SandboxMode::DangerFullAccess), - ..Default::default() - }, + assert_eq!( + config.legacy_sandbox_policy(), + SandboxPolicy::new_read_only_policy() ); - let cfg = ConfigToml { - profiles, - profile: Some("work".to_string()), - ..Default::default() - }; - - let overrides = ConfigOverrides { - sandbox_mode: Some(SandboxMode::WorkspaceWrite), - ..Default::default() - }; - - let config = - Config::load_from_base_config_with_overrides(cfg, overrides, codex_home.abs()).await?; - - if cfg!(target_os = "windows") { - assert!(matches!( - &config.legacy_sandbox_policy(), - SandboxPolicy::ReadOnly { .. } - )); - } else { - assert!(matches!( - &config.legacy_sandbox_policy(), - SandboxPolicy::WorkspaceWrite { .. } - )); - } Ok(()) } @@ -4466,9 +5156,9 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> { args: vec!["hello".to_string()], env: None, env_vars: Vec::new(), - cwd: None, + cwd: Some(codex_home.path().to_path_buf()), }, - experimental_environment: Some("remote".to_string()), + environment_id: "remote".to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, @@ -4487,7 +5177,6 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> { apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -4506,19 +5195,18 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> { assert_eq!(args, &vec!["hello".to_string()]); assert!(env.is_none()); assert!(env_vars.is_empty()); - assert!(cwd.is_none()); + assert_eq!(cwd, &Some(codex_home.path().to_path_buf())); } other => panic!("unexpected transport {other:?}"), } assert_eq!(docs.startup_timeout_sec, Some(Duration::from_secs(3))); assert_eq!(docs.tool_timeout_sec, Some(Duration::from_secs(5))); - assert_eq!(docs.experimental_environment.as_deref(), Some("remote")); + assert_eq!(docs.environment_id, "remote"); assert!(docs.enabled); let empty = BTreeMap::new(); apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(empty.clone())], )?; let loaded = load_global_mcp_servers(codex_home.path()).await?; @@ -4660,6 +5348,57 @@ approval_mode = "approve" ); } +#[test] +fn desktop_toml_round_trips_opaque_nested_values() -> anyhow::Result<()> { + let parsed = toml::from_str::( + r#" +[desktop] +appearanceTheme = "dark" +selected-avatar-id = "codex" +recentViews = ["threads", "settings"] + +[desktop.workspace] +collapsed = true +width = 320 +pane = { selected = "console", expanded = false } +"#, + )?; + + let desktop = parsed + .desktop + .as_ref() + .expect("desktop settings should deserialize"); + assert_eq!( + desktop.get("appearanceTheme"), + Some(&serde_json::json!("dark")) + ); + assert_eq!( + desktop.get("selected-avatar-id"), + Some(&serde_json::json!("codex")) + ); + assert_eq!( + desktop.get("recentViews"), + Some(&serde_json::json!(["threads", "settings"])) + ); + assert_eq!( + desktop.get("workspace"), + Some(&serde_json::json!({ + "collapsed": true, + "width": 320, + "pane": { + "selected": "console", + "expanded": false, + }, + })) + ); + + let serialized = toml::to_string(&parsed)?; + let reparsed = toml::from_str::(&serialized)?; + assert_eq!(reparsed.desktop, parsed.desktop); + + Ok(()) +} + #[tokio::test] async fn to_mcp_config_preserves_apps_feature_from_config() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -4672,12 +5411,14 @@ async fn to_mcp_config_preserves_apps_feature_from_config() -> std::io::Result<( let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); config.apps_mcp_path_override = Some("/custom/mcp".to_string()); + config.apps_mcp_product_sku = Some("tpp".to_string()); let mcp_config = config.to_mcp_config(&plugins_manager).await; assert!(mcp_config.apps_enabled); assert_eq!( mcp_config.apps_mcp_path_override.as_deref(), Some("/custom/mcp") ); + assert_eq!(mcp_config.apps_mcp_product_sku.as_deref(), Some("tpp")); let _ = config.features.disable(Feature::Apps); let mcp_config = config.to_mcp_config(&plugins_manager).await; @@ -4690,6 +5431,27 @@ async fn to_mcp_config_preserves_apps_feature_from_config() -> std::io::Result<( Ok(()) } +#[tokio::test] +async fn to_mcp_config_flows_mcp_tool_prefix_from_feature() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + + let mcp_config = config.to_mcp_config(&plugins_manager).await; + assert!(mcp_config.prefix_mcp_tool_names); + + let _ = config.features.enable(Feature::NonPrefixedMcpToolNames); + let mcp_config = config.to_mcp_config(&plugins_manager).await; + assert!(!mcp_config.prefix_mcp_tool_names); + + Ok(()) +} + #[tokio::test] async fn to_mcp_config_preserves_auth_elicitation_feature_from_config() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -4762,7 +5524,7 @@ async fn replace_mcp_servers_serializes_env_sorted() -> anyhow::Result<()> { env_vars: Vec::new(), cwd: None, }, - experimental_environment: None, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, @@ -4781,7 +5543,6 @@ async fn replace_mcp_servers_serializes_env_sorted() -> anyhow::Result<()> { apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -4839,7 +5600,7 @@ async fn replace_mcp_servers_serializes_env_vars() -> anyhow::Result<()> { env_vars: vec!["ALPHA".into(), "BETA".into()], cwd: None, }, - experimental_environment: None, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, @@ -4858,7 +5619,6 @@ async fn replace_mcp_servers_serializes_env_vars() -> anyhow::Result<()> { apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -4901,7 +5661,7 @@ async fn replace_mcp_servers_serializes_sourced_env_vars() -> anyhow::Result<()> ], cwd: None, }, - experimental_environment: None, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, @@ -4920,7 +5680,6 @@ async fn replace_mcp_servers_serializes_sourced_env_vars() -> anyhow::Result<()> apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -4953,7 +5712,7 @@ async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> { env_vars: Vec::new(), cwd: Some(cwd_path.clone()), }, - experimental_environment: None, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, @@ -4972,7 +5731,6 @@ async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> { apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -5008,7 +5766,7 @@ async fn replace_mcp_servers_streamable_http_serializes_bearer_token() -> anyhow http_headers: None, env_http_headers: None, }, - experimental_environment: None, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, @@ -5027,7 +5785,6 @@ async fn replace_mcp_servers_streamable_http_serializes_bearer_token() -> anyhow apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -5079,7 +5836,7 @@ async fn replace_mcp_servers_streamable_http_serializes_custom_headers() -> anyh "DOCS_AUTH".to_string(), )])), }, - experimental_environment: None, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, @@ -5097,7 +5854,6 @@ async fn replace_mcp_servers_streamable_http_serializes_custom_headers() -> anyh )]); apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -5162,7 +5918,7 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh "DOCS_AUTH".to_string(), )])), }, - experimental_environment: None, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, @@ -5181,7 +5937,6 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; let serialized_with_optional = std::fs::read_to_string(&config_path)?; @@ -5198,7 +5953,7 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh http_headers: None, env_http_headers: None, }, - experimental_environment: None, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, @@ -5216,7 +5971,6 @@ async fn replace_mcp_servers_streamable_http_removes_optional_sections() -> anyh ); apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -5269,7 +6023,7 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers() "DOCS_AUTH".to_string(), )])), }, - experimental_environment: None, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, @@ -5295,7 +6049,7 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers() env_vars: Vec::new(), cwd: None, }, - experimental_environment: None, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, @@ -5315,7 +6069,6 @@ async fn replace_mcp_servers_streamable_http_isolates_headers_between_servers() apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -5384,7 +6137,7 @@ async fn replace_mcp_servers_serializes_disabled_flag() -> anyhow::Result<()> { env_vars: Vec::new(), cwd: None, }, - experimental_environment: None, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: false, required: false, supports_parallel_tool_calls: false, @@ -5403,7 +6156,6 @@ async fn replace_mcp_servers_serializes_disabled_flag() -> anyhow::Result<()> { apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -5435,7 +6187,7 @@ async fn replace_mcp_servers_serializes_required_flag() -> anyhow::Result<()> { env_vars: Vec::new(), cwd: None, }, - experimental_environment: None, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: true, supports_parallel_tool_calls: false, @@ -5454,7 +6206,6 @@ async fn replace_mcp_servers_serializes_required_flag() -> anyhow::Result<()> { apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -5486,7 +6237,7 @@ async fn replace_mcp_servers_serializes_tool_filters() -> anyhow::Result<()> { env_vars: Vec::new(), cwd: None, }, - experimental_environment: None, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, @@ -5505,7 +6256,6 @@ async fn replace_mcp_servers_serializes_tool_filters() -> anyhow::Result<()> { apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -5541,7 +6291,7 @@ async fn replace_mcp_servers_streamable_http_serializes_oauth_resource() -> anyh http_headers: None, env_http_headers: None, }, - experimental_environment: None, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, @@ -5562,7 +6312,6 @@ async fn replace_mcp_servers_streamable_http_serializes_oauth_resource() -> anyh apply_blocking( codex_home.path(), - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], )?; @@ -5685,210 +6434,13 @@ model = "gpt-4.1" Ok(()) } -#[tokio::test] -async fn set_model_updates_profile() -> anyhow::Result<()> { - let codex_home = TempDir::new()?; - - ConfigEditsBuilder::new(codex_home.path()) - .with_profile(Some("dev")) - .set_model(Some("gpt-5.4"), Some(ReasoningEffort::Medium)) - .apply() - .await?; - - let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; - let parsed: ConfigToml = toml::from_str(&serialized)?; - let profile = parsed - .profiles - .get("dev") - .expect("profile should be created"); - - assert_eq!(profile.model.as_deref(), Some("gpt-5.4")); - assert_eq!( - profile.model_reasoning_effort, - Some(ReasoningEffort::Medium) - ); - - Ok(()) -} - -#[tokio::test] -async fn set_model_updates_existing_profile() -> anyhow::Result<()> { - let codex_home = TempDir::new()?; - let config_path = codex_home.path().join(CONFIG_TOML_FILE); - - tokio::fs::write( - &config_path, - r#" -[profiles.dev] -model = "gpt-4" -model_reasoning_effort = "medium" - -[profiles.prod] -model = "gpt-5.4" -"#, - ) - .await?; - - ConfigEditsBuilder::new(codex_home.path()) - .with_profile(Some("dev")) - .set_model(Some("o4-high"), Some(ReasoningEffort::Medium)) - .apply() - .await?; - - let serialized = tokio::fs::read_to_string(config_path).await?; - let parsed: ConfigToml = toml::from_str(&serialized)?; - - let dev_profile = parsed - .profiles - .get("dev") - .expect("dev profile should survive updates"); - assert_eq!(dev_profile.model.as_deref(), Some("o4-high")); - assert_eq!( - dev_profile.model_reasoning_effort, - Some(ReasoningEffort::Medium) - ); - - assert_eq!( - parsed - .profiles - .get("prod") - .and_then(|profile| profile.model.as_deref()), - Some("gpt-5.4"), - ); - - Ok(()) -} - -#[tokio::test] -async fn set_feature_enabled_updates_profile() -> anyhow::Result<()> { - let codex_home = TempDir::new()?; - - ConfigEditsBuilder::new(codex_home.path()) - .with_profile(Some("dev")) - .set_feature_enabled("guardian_approval", /*enabled*/ true) - .apply() - .await?; - - let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; - let parsed: ConfigToml = toml::from_str(&serialized)?; - let profile = parsed - .profiles - .get("dev") - .expect("profile should be created"); - - assert_eq!( - profile - .features - .as_ref() - .and_then(|features| features.entries().get("guardian_approval").copied()), - Some(true), - ); - assert_eq!( - parsed - .features - .as_ref() - .and_then(|features| features.entries().get("guardian_approval").copied()), - None, - ); - - Ok(()) -} - -#[tokio::test] -async fn set_feature_enabled_persists_feature_disable_in_profile() -> anyhow::Result<()> { - let codex_home = TempDir::new()?; - - ConfigEditsBuilder::new(codex_home.path()) - .with_profile(Some("dev")) - .set_feature_enabled("guardian_approval", /*enabled*/ true) - .apply() - .await?; - - ConfigEditsBuilder::new(codex_home.path()) - .with_profile(Some("dev")) - .set_feature_enabled("guardian_approval", /*enabled*/ false) - .apply() - .await?; - - let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; - let parsed: ConfigToml = toml::from_str(&serialized)?; - let profile = parsed - .profiles - .get("dev") - .expect("profile should be created"); - - assert_eq!( - profile - .features - .as_ref() - .and_then(|features| features.entries().get("guardian_approval").copied()), - Some(false), - ); - assert_eq!( - parsed - .features - .as_ref() - .and_then(|features| features.entries().get("guardian_approval").copied()), - None, - ); - - Ok(()) -} - -#[tokio::test] -async fn set_feature_enabled_profile_disable_overrides_root_enable() -> anyhow::Result<()> { - let codex_home = TempDir::new()?; - - ConfigEditsBuilder::new(codex_home.path()) - .set_feature_enabled("guardian_approval", /*enabled*/ true) - .apply() - .await?; - - ConfigEditsBuilder::new(codex_home.path()) - .with_profile(Some("dev")) - .set_feature_enabled("guardian_approval", /*enabled*/ false) - .apply() - .await?; - - let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; - let parsed: ConfigToml = toml::from_str(&serialized)?; - let profile = parsed - .profiles - .get("dev") - .expect("profile should be created"); - - assert_eq!( - parsed - .features - .as_ref() - .and_then(|features| features.entries().get("guardian_approval").copied()), - Some(true), - ); - assert_eq!( - profile - .features - .as_ref() - .and_then(|features| features.entries().get("guardian_approval").copied()), - Some(false), - ); - - Ok(()) -} - struct PrecedenceTestFixture { cwd: TempDir, codex_home: TempDir, cfg: ConfigToml, - model_provider_map: HashMap, - openai_provider: ModelProviderInfo, - openai_custom_provider: ModelProviderInfo, } impl PrecedenceTestFixture { - fn cwd(&self) -> AbsolutePathBuf { - self.cwd.abs() - } - fn cwd_path(&self) -> PathBuf { self.cwd.path().to_path_buf() } @@ -7288,10 +7840,6 @@ fn create_test_fixture() -> std::io::Result { model = "o3" approval_policy = "untrusted" -# Can be used to determine which profile to use if not specified by -# `ConfigOverrides`. -profile = "gpt3" - [analytics] enabled = true @@ -7345,200 +7893,34 @@ model_verbosity = "high" let codex_home_temp_dir = TempDir::new().unwrap(); - let openai_custom_provider = ModelProviderInfo { - name: "OpenAI custom".to_string(), - base_url: Some("https://api.openai.com/v1".to_string()), - env_key: Some("OPENAI_API_KEY".to_string()), - wire_api: WireApi::Responses, - env_key_instructions: None, - experimental_bearer_token: None, - auth: None, - aws: None, - query_params: None, - http_headers: None, - env_http_headers: None, - request_max_retries: Some(4), - stream_max_retries: Some(10), - stream_idle_timeout_ms: Some(300_000), - websocket_connect_timeout_ms: Some(15_000), - requires_openai_auth: false, - supports_websockets: false, - }; - let model_provider_map = { - let mut model_provider_map = - built_in_model_providers(/* openai_base_url */ /*openai_base_url*/ None); - model_provider_map.insert("openai-custom".to_string(), openai_custom_provider.clone()); - model_provider_map - }; - - let openai_provider = model_provider_map - .get("openai") - .expect("openai provider should exist") - .clone(); - Ok(PrecedenceTestFixture { cwd: cwd_temp_dir, codex_home: codex_home_temp_dir, cfg, - model_provider_map, - openai_provider, - openai_custom_provider, }) } -/// Users can specify config values at multiple levels that have the -/// following precedence: -/// -/// 1. custom command-line argument, e.g. `--model o3` -/// 2. as part of a profile, where the `--profile` is specified via a CLI -/// (or in the config file itself) -/// 3. as an entry in `config.toml`, e.g. `model = "o3"` -/// 4. the default value for a required field defined in code. -/// -/// Note that profiles are the recommended way to specify a group of -/// configuration options together. #[tokio::test] -async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { - let fixture = create_test_fixture()?; +async fn legacy_profile_selection_is_rejected() -> std::io::Result<()> { + let mut fixture = create_test_fixture()?; + fixture.cfg.profile = Some("gpt3".to_string()); - let o3_profile_overrides = ConfigOverrides { - config_profile: Some("o3".to_string()), - cwd: Some(fixture.cwd_path()), - ..Default::default() - }; - let o3_profile_config: Config = Config::load_from_base_config_with_overrides( + let err = Config::load_from_base_config_with_overrides( fixture.cfg.clone(), - o3_profile_overrides, + ConfigOverrides { + cwd: Some(fixture.cwd_path()), + ..Default::default() + }, fixture.codex_home(), ) - .await?; - assert_eq!( - Config { - model: Some("o3".to_string()), - review_model: None, - model_context_window: None, - model_auto_compact_token_limit: None, - service_tier: None, - model_provider_id: "openai".to_string(), - model_provider: fixture.openai_provider.clone(), - permissions: Permissions { - approval_policy: Constrained::allow_any(AskForApproval::Never), - permission_profile: Constrained::allow_any(PermissionProfile::read_only()), - active_permission_profile: Some(ActivePermissionProfile::new( - BUILT_IN_PERMISSION_PROFILE_READ_ONLY, - )), - network: None, - allow_login_shell: true, - shell_environment_policy: ShellEnvironmentPolicy::default(), - windows_sandbox_mode: None, - windows_sandbox_private_desktop: true, - }, - approvals_reviewer: ApprovalsReviewer::User, - enforce_residency: Constrained::allow_any(/*initial_value*/ None), - user_instructions: None, - notify: None, - cwd: fixture.cwd(), - cli_auth_credentials_store_mode: Default::default(), - mcp_servers: Constrained::allow_any(HashMap::new()), - mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( - Default::default(), - LOCAL_DEV_BUILD_VERSION, - ), - mcp_oauth_callback_port: None, - mcp_oauth_callback_url: None, - model_providers: fixture.model_provider_map.clone(), - project_doc_max_bytes: AGENTS_MD_MAX_BYTES, - project_doc_fallback_filenames: Vec::new(), - tool_output_token_limit: None, - agent_max_threads: DEFAULT_AGENT_MAX_THREADS, - agent_max_depth: DEFAULT_AGENT_MAX_DEPTH, - agent_roles: BTreeMap::new(), - memories: MemoriesConfig::default(), - agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS, - agent_interrupt_message_enabled: true, - codex_home: fixture.codex_home(), - sqlite_home: fixture.codex_home().to_path_buf(), - log_dir: fixture.codex_home().join("log").to_path_buf(), - config_lock_export_dir: None, - config_lock_allow_codex_version_mismatch: false, - config_lock_save_fields_resolved_from_model_catalog: true, - config_lock_toml: None, - config_layer_stack: Default::default(), - startup_warnings: Vec::new(), - history: History::default(), - ephemeral: false, - bypass_hook_trust: false, - file_opener: UriBasedFileOpener::VsCode, - codex_self_exe: None, - codex_linux_sandbox_exe: None, - main_execve_wrapper_exe: None, - zsh_path: None, - hide_agent_reasoning: false, - show_raw_agent_reasoning: false, - model_reasoning_effort: Some(ReasoningEffort::High), - plan_mode_reasoning_effort: None, - model_reasoning_summary: Some(ReasoningSummary::Detailed), - model_supports_reasoning_summaries: None, - model_catalog: None, - model_verbosity: None, - personality: Some(Personality::Pragmatic), - chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), - apps_mcp_path_override: None, - realtime_audio: RealtimeAudioConfig::default(), - experimental_realtime_start_instructions: None, - experimental_realtime_ws_base_url: None, - experimental_realtime_ws_model: None, - realtime: RealtimeConfig::default(), - experimental_realtime_ws_backend_prompt: None, - experimental_realtime_ws_startup_context: None, - experimental_thread_config_endpoint: None, - experimental_thread_store: ThreadStoreConfig::Local, - base_instructions: None, - developer_instructions: None, - guardian_policy_config: None, - include_permissions_instructions: true, - include_apps_instructions: true, - include_collaboration_mode_instructions: true, - include_skill_instructions: true, - include_environment_context: true, - compact_prompt: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search_mode: Constrained::allow_any(WebSearchMode::Cached), - web_search_config: None, - use_experimental_unified_exec_tool: !cfg!(windows), - background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS, - ghost_snapshot: GhostSnapshotConfig::default(), - multi_agent_v2: MultiAgentV2Config::default(), - features: Features::with_defaults().into(), - suppress_unstable_features_warning: false, - active_profile: Some("o3".to_string()), - active_project: ProjectConfig { trust_level: None }, - notices: Default::default(), - check_for_update_on_startup: true, - disable_paste_burst: false, - tui_notifications: Default::default(), - animations: true, - show_tooltips: true, - tui_vim_mode_default: false, - tui_raw_output_mode: false, - tui_keymap: TuiKeymap::default(), - model_availability_nux: ModelAvailabilityNuxConfig::default(), - terminal_resize_reflow: TerminalResizeReflowConfig::default(), - analytics_enabled: Some(true), - feedback_enabled: true, - tool_suggest: ToolSuggestConfig::default(), - tui_alternate_screen: AltScreenMode::Auto, - tui_status_line: None, - tui_status_line_use_colors: true, - tui_terminal_title: None, - tui_theme: None, - tui_pet: None, - tui_pet_anchor: TuiPetAnchor::Composer, - tui_session_picker_view: SessionPickerViewMode::Dense, - otel: OtelConfig::default(), - }, - o3_profile_config + .await + .expect_err("legacy profile selection should be rejected"); + + assert_eq!(err.kind(), ErrorKind::InvalidData); + assert!( + err.to_string() + .contains("legacy `profile = \"gpt3\"` config is no longer supported"), + "unexpected error: {err}" ); Ok(()) } @@ -7708,7 +8090,7 @@ alpha = "one\ntwo" } #[tokio::test] -async fn explicit_null_service_tier_override_sets_fast_default_opt_out() -> std::io::Result<()> { +async fn explicit_null_service_tier_override_maps_to_default_service_tier() -> std::io::Result<()> { let fixture = create_test_fixture()?; let config = Config::load_from_base_config_with_overrides( @@ -7722,8 +8104,33 @@ async fn explicit_null_service_tier_override_sets_fast_default_opt_out() -> std: ) .await?; - assert_eq!(config.service_tier, None); - assert_eq!(config.notices.fast_default_opt_out, Some(true)); + assert_eq!( + config.service_tier, + Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()) + ); + assert_eq!(config.notices.fast_default_opt_out, None); + Ok(()) +} + +#[tokio::test] +async fn default_service_tier_override_uses_default_request_value() -> std::io::Result<()> { + let fixture = create_test_fixture()?; + + let config = Config::load_from_base_config_with_overrides( + fixture.cfg.clone(), + ConfigOverrides { + cwd: Some(fixture.cwd_path()), + service_tier: Some(Some("default".to_string())), + ..Default::default() + }, + fixture.codex_home(), + ) + .await?; + + assert_eq!( + config.service_tier, + Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()) + ); Ok(()) } @@ -7845,459 +8252,6 @@ async fn fast_default_opt_out_notice_config_is_respected() -> std::io::Result<() Ok(()) } -#[tokio::test] -async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { - let fixture = create_test_fixture()?; - - let gpt3_profile_overrides = ConfigOverrides { - config_profile: Some("gpt3".to_string()), - cwd: Some(fixture.cwd_path()), - ..Default::default() - }; - let gpt3_profile_config = Config::load_from_base_config_with_overrides( - fixture.cfg.clone(), - gpt3_profile_overrides, - fixture.codex_home(), - ) - .await?; - let expected_gpt3_profile_config = Config { - model: Some("gpt-3.5-turbo".to_string()), - review_model: None, - model_context_window: None, - model_auto_compact_token_limit: None, - service_tier: None, - model_provider_id: "openai-custom".to_string(), - model_provider: fixture.openai_custom_provider.clone(), - permissions: Permissions { - approval_policy: Constrained::allow_any(AskForApproval::UnlessTrusted), - permission_profile: Constrained::allow_any(PermissionProfile::read_only()), - active_permission_profile: Some(ActivePermissionProfile::new( - BUILT_IN_PERMISSION_PROFILE_READ_ONLY, - )), - network: None, - allow_login_shell: true, - shell_environment_policy: ShellEnvironmentPolicy::default(), - windows_sandbox_mode: None, - windows_sandbox_private_desktop: true, - }, - approvals_reviewer: ApprovalsReviewer::User, - enforce_residency: Constrained::allow_any(/*initial_value*/ None), - user_instructions: None, - notify: None, - cwd: fixture.cwd(), - cli_auth_credentials_store_mode: Default::default(), - mcp_servers: Constrained::allow_any(HashMap::new()), - mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( - Default::default(), - LOCAL_DEV_BUILD_VERSION, - ), - mcp_oauth_callback_port: None, - mcp_oauth_callback_url: None, - model_providers: fixture.model_provider_map.clone(), - project_doc_max_bytes: AGENTS_MD_MAX_BYTES, - project_doc_fallback_filenames: Vec::new(), - tool_output_token_limit: None, - agent_max_threads: DEFAULT_AGENT_MAX_THREADS, - agent_max_depth: DEFAULT_AGENT_MAX_DEPTH, - agent_roles: BTreeMap::new(), - memories: MemoriesConfig::default(), - agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS, - agent_interrupt_message_enabled: true, - codex_home: fixture.codex_home(), - sqlite_home: fixture.codex_home().to_path_buf(), - log_dir: fixture.codex_home().join("log").to_path_buf(), - config_lock_export_dir: None, - config_lock_allow_codex_version_mismatch: false, - config_lock_save_fields_resolved_from_model_catalog: true, - config_lock_toml: None, - config_layer_stack: Default::default(), - startup_warnings: Vec::new(), - history: History::default(), - ephemeral: false, - bypass_hook_trust: false, - file_opener: UriBasedFileOpener::VsCode, - codex_self_exe: None, - codex_linux_sandbox_exe: None, - main_execve_wrapper_exe: None, - zsh_path: None, - hide_agent_reasoning: false, - show_raw_agent_reasoning: false, - model_reasoning_effort: None, - plan_mode_reasoning_effort: None, - model_reasoning_summary: None, - model_supports_reasoning_summaries: None, - model_catalog: None, - model_verbosity: None, - personality: Some(Personality::Pragmatic), - chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), - apps_mcp_path_override: None, - realtime_audio: RealtimeAudioConfig::default(), - experimental_realtime_start_instructions: None, - experimental_realtime_ws_base_url: None, - experimental_realtime_ws_model: None, - realtime: RealtimeConfig::default(), - experimental_realtime_ws_backend_prompt: None, - experimental_realtime_ws_startup_context: None, - experimental_thread_config_endpoint: None, - experimental_thread_store: ThreadStoreConfig::Local, - base_instructions: None, - developer_instructions: None, - guardian_policy_config: None, - include_permissions_instructions: true, - include_apps_instructions: true, - include_collaboration_mode_instructions: true, - include_skill_instructions: true, - include_environment_context: true, - compact_prompt: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search_mode: Constrained::allow_any(WebSearchMode::Cached), - web_search_config: None, - use_experimental_unified_exec_tool: !cfg!(windows), - background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS, - ghost_snapshot: GhostSnapshotConfig::default(), - multi_agent_v2: MultiAgentV2Config::default(), - features: Features::with_defaults().into(), - suppress_unstable_features_warning: false, - active_profile: Some("gpt3".to_string()), - active_project: ProjectConfig { trust_level: None }, - notices: Default::default(), - check_for_update_on_startup: true, - disable_paste_burst: false, - tui_notifications: Default::default(), - animations: true, - show_tooltips: true, - tui_vim_mode_default: false, - tui_raw_output_mode: false, - tui_keymap: TuiKeymap::default(), - model_availability_nux: ModelAvailabilityNuxConfig::default(), - terminal_resize_reflow: TerminalResizeReflowConfig::default(), - analytics_enabled: Some(true), - feedback_enabled: true, - tool_suggest: ToolSuggestConfig::default(), - tui_alternate_screen: AltScreenMode::Auto, - tui_status_line: None, - tui_status_line_use_colors: true, - tui_terminal_title: None, - tui_theme: None, - tui_pet: None, - tui_pet_anchor: TuiPetAnchor::Composer, - tui_session_picker_view: SessionPickerViewMode::Dense, - otel: OtelConfig::default(), - }; - - assert_eq!(expected_gpt3_profile_config, gpt3_profile_config); - - // Verify that loading without specifying a profile in ConfigOverrides - // uses the default profile from the config file (which is "gpt3"). - let default_profile_overrides = ConfigOverrides { - cwd: Some(fixture.cwd_path()), - ..Default::default() - }; - - let default_profile_config = Config::load_from_base_config_with_overrides( - fixture.cfg.clone(), - default_profile_overrides, - fixture.codex_home(), - ) - .await?; - - assert_eq!(expected_gpt3_profile_config, default_profile_config); - Ok(()) -} - -#[tokio::test] -async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { - let fixture = create_test_fixture()?; - - let zdr_profile_overrides = ConfigOverrides { - config_profile: Some("zdr".to_string()), - cwd: Some(fixture.cwd_path()), - ..Default::default() - }; - let zdr_profile_config = Config::load_from_base_config_with_overrides( - fixture.cfg.clone(), - zdr_profile_overrides, - fixture.codex_home(), - ) - .await?; - let expected_zdr_profile_config = Config { - model: Some("o3".to_string()), - review_model: None, - model_context_window: None, - model_auto_compact_token_limit: None, - service_tier: None, - model_provider_id: "openai".to_string(), - model_provider: fixture.openai_provider.clone(), - permissions: Permissions { - approval_policy: Constrained::allow_any(AskForApproval::OnFailure), - permission_profile: Constrained::allow_any(PermissionProfile::read_only()), - active_permission_profile: Some(ActivePermissionProfile::new( - BUILT_IN_PERMISSION_PROFILE_READ_ONLY, - )), - network: None, - allow_login_shell: true, - shell_environment_policy: ShellEnvironmentPolicy::default(), - windows_sandbox_mode: None, - windows_sandbox_private_desktop: true, - }, - approvals_reviewer: ApprovalsReviewer::User, - enforce_residency: Constrained::allow_any(/*initial_value*/ None), - user_instructions: None, - notify: None, - cwd: fixture.cwd(), - cli_auth_credentials_store_mode: Default::default(), - mcp_servers: Constrained::allow_any(HashMap::new()), - mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( - Default::default(), - LOCAL_DEV_BUILD_VERSION, - ), - mcp_oauth_callback_port: None, - mcp_oauth_callback_url: None, - model_providers: fixture.model_provider_map.clone(), - project_doc_max_bytes: AGENTS_MD_MAX_BYTES, - project_doc_fallback_filenames: Vec::new(), - tool_output_token_limit: None, - agent_max_threads: DEFAULT_AGENT_MAX_THREADS, - agent_max_depth: DEFAULT_AGENT_MAX_DEPTH, - agent_roles: BTreeMap::new(), - memories: MemoriesConfig::default(), - agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS, - agent_interrupt_message_enabled: true, - codex_home: fixture.codex_home(), - sqlite_home: fixture.codex_home().to_path_buf(), - log_dir: fixture.codex_home().join("log").to_path_buf(), - config_lock_export_dir: None, - config_lock_allow_codex_version_mismatch: false, - config_lock_save_fields_resolved_from_model_catalog: true, - config_lock_toml: None, - config_layer_stack: Default::default(), - startup_warnings: Vec::new(), - history: History::default(), - ephemeral: false, - bypass_hook_trust: false, - file_opener: UriBasedFileOpener::VsCode, - codex_self_exe: None, - codex_linux_sandbox_exe: None, - main_execve_wrapper_exe: None, - zsh_path: None, - hide_agent_reasoning: false, - show_raw_agent_reasoning: false, - model_reasoning_effort: None, - plan_mode_reasoning_effort: None, - model_reasoning_summary: None, - model_supports_reasoning_summaries: None, - model_catalog: None, - model_verbosity: None, - personality: Some(Personality::Pragmatic), - chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), - apps_mcp_path_override: None, - realtime_audio: RealtimeAudioConfig::default(), - experimental_realtime_start_instructions: None, - experimental_realtime_ws_base_url: None, - experimental_realtime_ws_model: None, - realtime: RealtimeConfig::default(), - experimental_realtime_ws_backend_prompt: None, - experimental_realtime_ws_startup_context: None, - experimental_thread_config_endpoint: None, - experimental_thread_store: ThreadStoreConfig::Local, - base_instructions: None, - developer_instructions: None, - guardian_policy_config: None, - include_permissions_instructions: true, - include_apps_instructions: true, - include_collaboration_mode_instructions: true, - include_skill_instructions: true, - include_environment_context: true, - compact_prompt: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search_mode: Constrained::allow_any(WebSearchMode::Cached), - web_search_config: None, - use_experimental_unified_exec_tool: !cfg!(windows), - background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS, - ghost_snapshot: GhostSnapshotConfig::default(), - multi_agent_v2: MultiAgentV2Config::default(), - features: Features::with_defaults().into(), - suppress_unstable_features_warning: false, - active_profile: Some("zdr".to_string()), - active_project: ProjectConfig { trust_level: None }, - notices: Default::default(), - check_for_update_on_startup: true, - disable_paste_burst: false, - tui_notifications: Default::default(), - animations: true, - show_tooltips: true, - tui_vim_mode_default: false, - tui_raw_output_mode: false, - tui_keymap: TuiKeymap::default(), - model_availability_nux: ModelAvailabilityNuxConfig::default(), - terminal_resize_reflow: TerminalResizeReflowConfig::default(), - analytics_enabled: Some(false), - feedback_enabled: true, - tool_suggest: ToolSuggestConfig::default(), - tui_alternate_screen: AltScreenMode::Auto, - tui_status_line: None, - tui_status_line_use_colors: true, - tui_terminal_title: None, - tui_theme: None, - tui_pet: None, - tui_pet_anchor: TuiPetAnchor::Composer, - tui_session_picker_view: SessionPickerViewMode::Dense, - otel: OtelConfig::default(), - }; - - assert_eq!(expected_zdr_profile_config, zdr_profile_config); - - Ok(()) -} - -#[tokio::test] -async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { - let fixture = create_test_fixture()?; - - let gpt5_profile_overrides = ConfigOverrides { - config_profile: Some("gpt5".to_string()), - cwd: Some(fixture.cwd_path()), - ..Default::default() - }; - let gpt5_profile_config = Config::load_from_base_config_with_overrides( - fixture.cfg.clone(), - gpt5_profile_overrides, - fixture.codex_home(), - ) - .await?; - let expected_gpt5_profile_config = Config { - model: Some("gpt-5.4".to_string()), - review_model: None, - model_context_window: None, - model_auto_compact_token_limit: None, - service_tier: None, - model_provider_id: "openai".to_string(), - model_provider: fixture.openai_provider.clone(), - permissions: Permissions { - approval_policy: Constrained::allow_any(AskForApproval::OnFailure), - permission_profile: Constrained::allow_any(PermissionProfile::read_only()), - active_permission_profile: Some(ActivePermissionProfile::new( - BUILT_IN_PERMISSION_PROFILE_READ_ONLY, - )), - network: None, - allow_login_shell: true, - shell_environment_policy: ShellEnvironmentPolicy::default(), - windows_sandbox_mode: None, - windows_sandbox_private_desktop: true, - }, - approvals_reviewer: ApprovalsReviewer::User, - enforce_residency: Constrained::allow_any(/*initial_value*/ None), - user_instructions: None, - notify: None, - cwd: fixture.cwd(), - cli_auth_credentials_store_mode: Default::default(), - mcp_servers: Constrained::allow_any(HashMap::new()), - mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( - Default::default(), - LOCAL_DEV_BUILD_VERSION, - ), - mcp_oauth_callback_port: None, - mcp_oauth_callback_url: None, - model_providers: fixture.model_provider_map.clone(), - project_doc_max_bytes: AGENTS_MD_MAX_BYTES, - project_doc_fallback_filenames: Vec::new(), - tool_output_token_limit: None, - agent_max_threads: DEFAULT_AGENT_MAX_THREADS, - agent_max_depth: DEFAULT_AGENT_MAX_DEPTH, - agent_roles: BTreeMap::new(), - memories: MemoriesConfig::default(), - agent_job_max_runtime_seconds: DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS, - agent_interrupt_message_enabled: true, - codex_home: fixture.codex_home(), - sqlite_home: fixture.codex_home().to_path_buf(), - log_dir: fixture.codex_home().join("log").to_path_buf(), - config_lock_export_dir: None, - config_lock_allow_codex_version_mismatch: false, - config_lock_save_fields_resolved_from_model_catalog: true, - config_lock_toml: None, - config_layer_stack: Default::default(), - startup_warnings: Vec::new(), - history: History::default(), - ephemeral: false, - bypass_hook_trust: false, - file_opener: UriBasedFileOpener::VsCode, - codex_self_exe: None, - codex_linux_sandbox_exe: None, - main_execve_wrapper_exe: None, - zsh_path: None, - hide_agent_reasoning: false, - show_raw_agent_reasoning: false, - model_reasoning_effort: Some(ReasoningEffort::High), - plan_mode_reasoning_effort: None, - model_reasoning_summary: Some(ReasoningSummary::Detailed), - model_supports_reasoning_summaries: None, - model_catalog: None, - model_verbosity: Some(Verbosity::High), - personality: Some(Personality::Pragmatic), - chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), - apps_mcp_path_override: None, - realtime_audio: RealtimeAudioConfig::default(), - experimental_realtime_start_instructions: None, - experimental_realtime_ws_base_url: None, - experimental_realtime_ws_model: None, - realtime: RealtimeConfig::default(), - experimental_realtime_ws_backend_prompt: None, - experimental_realtime_ws_startup_context: None, - experimental_thread_config_endpoint: None, - experimental_thread_store: ThreadStoreConfig::Local, - base_instructions: None, - developer_instructions: None, - guardian_policy_config: None, - include_permissions_instructions: true, - include_apps_instructions: true, - include_collaboration_mode_instructions: true, - include_skill_instructions: true, - include_environment_context: true, - compact_prompt: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search_mode: Constrained::allow_any(WebSearchMode::Cached), - web_search_config: None, - use_experimental_unified_exec_tool: !cfg!(windows), - background_terminal_max_timeout: DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS, - ghost_snapshot: GhostSnapshotConfig::default(), - multi_agent_v2: MultiAgentV2Config::default(), - features: Features::with_defaults().into(), - suppress_unstable_features_warning: false, - active_profile: Some("gpt5".to_string()), - active_project: ProjectConfig { trust_level: None }, - notices: Default::default(), - check_for_update_on_startup: true, - disable_paste_burst: false, - tui_notifications: Default::default(), - animations: true, - show_tooltips: true, - tui_vim_mode_default: false, - tui_raw_output_mode: false, - tui_keymap: TuiKeymap::default(), - model_availability_nux: ModelAvailabilityNuxConfig::default(), - terminal_resize_reflow: TerminalResizeReflowConfig::default(), - analytics_enabled: Some(true), - feedback_enabled: true, - tool_suggest: ToolSuggestConfig::default(), - tui_alternate_screen: AltScreenMode::Auto, - tui_status_line: None, - tui_status_line_use_colors: true, - tui_terminal_title: None, - tui_theme: None, - tui_pet: None, - tui_pet_anchor: TuiPetAnchor::Composer, - tui_session_picker_view: SessionPickerViewMode::Dense, - otel: OtelConfig::default(), - }; - - assert_eq!(expected_gpt5_profile_config, gpt5_profile_config); - - Ok(()) -} - #[tokio::test] async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() -> anyhow::Result<()> { @@ -8307,9 +8261,13 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() allowed_approval_policies: None, allowed_approvals_reviewers: None, allowed_sandbox_modes: None, + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: Some(vec![codex_config::WebSearchModeRequirement::Cached]), allow_managed_hooks_only: None, + allow_appshots: None, + computer_use: None, + windows: None, feature_requirements: None, hooks: None, mcp_servers: None, @@ -8679,29 +8637,26 @@ async fn derive_sandbox_policy_preserves_windows_downgrade_for_unsupported_fallb let active_project = ProjectConfig { trust_level: Some(TrustLevel::Trusted), }; - let constrained = Constrained::new( - PermissionProfile::from_legacy_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), - |candidate| { - if matches!( - candidate, - PermissionProfile::Managed { - file_system: ManagedFileSystemPermissions::Restricted { entries, .. }, - .. - } if entries - .iter() - .any(|entry| entry.access.can_write()) - ) { - Ok(()) - } else { - Err(ConstraintError::InvalidValue { - field_name: "sandbox_mode", - candidate: format!("{candidate:?}"), - allowed: "[WorkspaceWrite]".to_string(), - requirement_source: RequirementSource::Unknown, - }) - } - }, - )?; + let constrained = Constrained::new(PermissionProfile::workspace_write(), |candidate| { + if matches!( + candidate, + PermissionProfile::Managed { + file_system: ManagedFileSystemPermissions::Restricted { entries, .. }, + .. + } if entries + .iter() + .any(|entry| entry.access.can_write()) + ) { + Ok(()) + } else { + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: format!("{candidate:?}"), + allowed: "[WorkspaceWrite]".to_string(), + requirement_source: RequirementSource::Unknown, + }) + } + })?; let resolution = derive_legacy_sandbox_policy_for_test( &cfg, @@ -8724,35 +8679,10 @@ async fn derive_sandbox_policy_preserves_windows_downgrade_for_unsupported_fallb #[test] fn test_resolve_oss_provider_explicit_override() { let config_toml = ConfigToml::default(); - let result = resolve_oss_provider( - Some("custom-provider"), - &config_toml, - /*config_profile*/ None, - ); + let result = resolve_oss_provider(Some("custom-provider"), &config_toml); assert_eq!(result, Some("custom-provider".to_string())); } -#[test] -fn test_resolve_oss_provider_from_profile() { - let mut profiles = std::collections::HashMap::new(); - let profile = ConfigProfile { - oss_provider: Some("profile-provider".to_string()), - ..Default::default() - }; - profiles.insert("test-profile".to_string(), profile); - let config_toml = ConfigToml { - profiles, - ..Default::default() - }; - - let result = resolve_oss_provider( - /*explicit_provider*/ None, - &config_toml, - Some("test-profile".to_string()), - ); - assert_eq!(result, Some("profile-provider".to_string())); -} - #[test] fn test_resolve_oss_provider_from_global_config() { let config_toml = ConfigToml { @@ -8760,63 +8690,25 @@ fn test_resolve_oss_provider_from_global_config() { ..Default::default() }; - let result = resolve_oss_provider( - /*explicit_provider*/ None, - &config_toml, - /*config_profile*/ None, - ); - assert_eq!(result, Some("global-provider".to_string())); -} - -#[test] -fn test_resolve_oss_provider_profile_fallback_to_global() { - let mut profiles = std::collections::HashMap::new(); - let profile = ConfigProfile::default(); // No oss_provider set - profiles.insert("test-profile".to_string(), profile); - let config_toml = ConfigToml { - oss_provider: Some("global-provider".to_string()), - profiles, - ..Default::default() - }; - - let result = resolve_oss_provider( - /*explicit_provider*/ None, - &config_toml, - Some("test-profile".to_string()), - ); + let result = resolve_oss_provider(/*explicit_provider*/ None, &config_toml); assert_eq!(result, Some("global-provider".to_string())); } #[test] fn test_resolve_oss_provider_none_when_not_configured() { let config_toml = ConfigToml::default(); - let result = resolve_oss_provider( - /*explicit_provider*/ None, - &config_toml, - /*config_profile*/ None, - ); + let result = resolve_oss_provider(/*explicit_provider*/ None, &config_toml); assert_eq!(result, None); } #[test] -fn test_resolve_oss_provider_explicit_overrides_all() { - let mut profiles = std::collections::HashMap::new(); - let profile = ConfigProfile { - oss_provider: Some("profile-provider".to_string()), - ..Default::default() - }; - profiles.insert("test-profile".to_string(), profile); +fn test_resolve_oss_provider_explicit_overrides_global() { let config_toml = ConfigToml { oss_provider: Some("global-provider".to_string()), - profiles, ..Default::default() }; - let result = resolve_oss_provider( - Some("explicit-provider"), - &config_toml, - Some("test-profile".to_string()), - ); + let result = resolve_oss_provider(Some("explicit-provider"), &config_toml); assert_eq!(result, Some("explicit-provider".to_string())); } @@ -8960,6 +8852,27 @@ path = "/custom/mcp" Ok(()) } +#[tokio::test] +async fn config_loads_apps_mcp_product_sku_from_toml() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let toml = r#" +model = "gpt-5.4" +apps_mcp_product_sku = "tpp" +"#; + let cfg: ConfigToml = + toml::from_str(toml).expect("TOML deserialization should succeed for apps MCP SKU"); + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert_eq!(config.apps_mcp_product_sku.as_deref(), Some("tpp")); + Ok(()) +} + #[tokio::test] async fn config_loads_mcp_oauth_callback_url_from_toml() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -9072,9 +8985,13 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s allowed_approval_policies: None, allowed_approvals_reviewers: None, allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), + allowed_permissions: None, remote_sandbox_config: None, allowed_web_search_modes: None, allow_managed_hooks_only: None, + allow_appshots: None, + computer_use: None, + windows: None, feature_requirements: None, hooks: None, mcp_servers: None, @@ -9102,6 +9019,114 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s Ok(()) } +#[tokio::test] +async fn windows_sandbox_mode_falls_back_when_disallowed_by_requirements() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"[windows] +sandbox = "unelevated" +"#, + )?; + + let requirements = codex_config::ConfigRequirementsToml { + windows: Some(codex_config::WindowsRequirementsToml { + allowed_sandbox_implementations: Some(vec![ + codex_config::types::WindowsSandboxModeToml::Elevated, + ]), + }), + ..Default::default() + }; + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cloud_requirements(CloudRequirementsLoader::new(async move { + Ok(Some(requirements)) + })) + .build() + .await?; + + assert_eq!( + config.permissions.windows_sandbox_mode, + Some(codex_config::types::WindowsSandboxModeToml::Elevated) + ); + assert!( + config.startup_warnings.iter().any(|warning| warning + .contains("Configured value for `windows.sandbox` is disallowed by requirements")), + "{:?}", + config.startup_warnings + ); + Ok(()) +} + +#[tokio::test] +async fn danger_full_access_with_never_is_rejected_when_requirements_force_read_only() +-> std::io::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"approval_policy = "never" +sandbox_mode = "danger-full-access" +"#, + )?; + + let err = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cloud_requirements(CloudRequirementsLoader::new(async { + Ok(Some(codex_config::ConfigRequirementsToml { + allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), + ..Default::default() + })) + })) + .build() + .await + .expect_err("requirements-constrained yolo should require sandbox approval"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "`approval_policy = \"never\"` cannot be used because requirements do not allow `sandbox_mode = \"danger-full-access\"`; Codex would fall back to read-only permissions with approvals disabled. Choose an `approval_policy` based on what you need, such as `on-request`, or choose an allowed sandbox mode." + ); + Ok(()) +} + +#[tokio::test] +async fn named_full_access_profile_with_never_is_rejected_when_requirements_force_read_only() +-> std::io::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"approval_policy = "never" +default_permissions = "dev" + +[permissions.dev.filesystem] +":root" = "write" +"#, + )?; + + let err = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cloud_requirements(CloudRequirementsLoader::new(async { + Ok(Some(codex_config::ConfigRequirementsToml { + allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), + ..Default::default() + })) + })) + .build() + .await + .expect_err("requirements-constrained full-access profile should require sandbox approval"); + + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + err.to_string(), + "`approval_policy = \"never\"` cannot be used because requirements do not allow `sandbox_mode = \"danger-full-access\"`; Codex would fall back to read-only permissions with approvals disabled. Choose an `approval_policy` based on what you need, such as `on-request`, or choose an allowed sandbox mode." + ); + Ok(()) +} + #[tokio::test] async fn permission_profile_override_falls_back_when_disallowed_by_requirements() -> std::io::Result<()> { @@ -9127,7 +9152,7 @@ async fn permission_profile_override_falls_back_when_disallowed_by_requirements( let expected_sandbox_policy = SandboxPolicy::new_read_only_policy(); assert_eq!(config.legacy_sandbox_policy(), expected_sandbox_policy); assert_eq!( - config.permissions.permission_profile(), + config.permissions.effective_permission_profile(), PermissionProfile::read_only() ); Ok(()) @@ -9155,7 +9180,7 @@ async fn active_profile_is_cleared_when_requirements_force_fallback() -> std::io .await?; assert_eq!( - config.permissions.permission_profile(), + config.permissions.effective_permission_profile(), PermissionProfile::read_only() ); assert_eq!(config.permissions.active_permission_profile(), None); @@ -9275,7 +9300,7 @@ async fn requirements_web_search_mode_overrides_danger_full_access_default() -> assert_eq!( resolve_web_search_mode_for_turn( &config.web_search_mode, - &config.permissions.permission_profile(), + &config.permissions.effective_permission_profile(), ), WebSearchMode::Cached, ); @@ -9557,8 +9582,7 @@ async fn approvals_reviewer_defaults_to_manual_only_without_guardian_feature() - } #[tokio::test] -async fn prompt_instruction_blocks_can_be_disabled_from_config_and_profiles() -> std::io::Result<()> -{ +async fn prompt_instruction_blocks_can_be_disabled_from_config() -> std::io::Result<()> { let codex_home = TempDir::new()?; std::fs::write( codex_home.path().join(CONFIG_TOML_FILE), @@ -9566,15 +9590,9 @@ async fn prompt_instruction_blocks_can_be_disabled_from_config_and_profiles() -> include_apps_instructions = false include_collaboration_mode_instructions = false include_environment_context = false -profile = "chatty" [skills] include_instructions = false - -[profiles.chatty] -include_permissions_instructions = true -include_collaboration_mode_instructions = true -include_environment_context = true "#, )?; @@ -9584,11 +9602,11 @@ include_environment_context = true .build() .await?; - assert!(config.include_permissions_instructions); + assert!(!config.include_permissions_instructions); assert!(!config.include_apps_instructions); - assert!(config.include_collaboration_mode_instructions); + assert!(!config.include_collaboration_mode_instructions); assert!(!config.include_skill_instructions); - assert!(config.include_environment_context); + assert!(!config.include_environment_context); Ok(()) } @@ -9633,29 +9651,6 @@ async fn approvals_reviewer_can_be_set_in_config_without_guardian_approval() -> Ok(()) } -#[tokio::test] -async fn approvals_reviewer_can_be_set_in_profile_without_guardian_approval() -> std::io::Result<()> -{ - let codex_home = TempDir::new()?; - std::fs::write( - codex_home.path().join(CONFIG_TOML_FILE), - r#"profile = "guardian" - -[profiles.guardian] -approvals_reviewer = "guardian_subagent" -"#, - )?; - - let config = ConfigBuilder::without_managed_config_for_tests() - .codex_home(codex_home.path().to_path_buf()) - .fallback_cwd(Some(codex_home.path().to_path_buf())) - .build() - .await?; - - assert_eq!(config.approvals_reviewer, ApprovalsReviewer::AutoReview); - Ok(()) -} - #[tokio::test] async fn requirements_disallowing_default_approvals_reviewer_falls_back_to_required_default() -> std::io::Result<()> { @@ -9710,35 +9705,6 @@ async fn root_approvals_reviewer_falls_back_when_disallowed_by_requirements() -> Ok(()) } -#[tokio::test] -async fn profile_approvals_reviewer_falls_back_when_disallowed_by_requirements() --> std::io::Result<()> { - let codex_home = TempDir::new()?; - std::fs::write( - codex_home.path().join(CONFIG_TOML_FILE), - r#"profile = "default" - -[profiles.default] -approvals_reviewer = "user" -"#, - )?; - - let config = ConfigBuilder::without_managed_config_for_tests() - .codex_home(codex_home.path().to_path_buf()) - .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(codex_config::ConfigRequirementsToml { - allowed_approvals_reviewers: Some(vec![ApprovalsReviewer::AutoReview]), - ..Default::default() - })) - })) - .build() - .await?; - - assert_eq!(config.approvals_reviewer, ApprovalsReviewer::AutoReview); - Ok(()) -} - #[tokio::test] async fn approvals_reviewer_preserves_valid_user_choice_when_allowed_by_requirements() -> std::io::Result<()> { @@ -9803,36 +9769,6 @@ smart_approvals = true Ok(()) } -#[tokio::test] -async fn smart_approvals_alias_is_ignored_in_profiles() -> std::io::Result<()> { - let codex_home = TempDir::new()?; - std::fs::write( - codex_home.path().join(CONFIG_TOML_FILE), - r#"profile = "guardian" - -[profiles.guardian.features] -smart_approvals = true -"#, - )?; - - let config = ConfigBuilder::without_managed_config_for_tests() - .codex_home(codex_home.path().to_path_buf()) - .fallback_cwd(Some(codex_home.path().to_path_buf())) - .build() - .await?; - - assert!(config.features.enabled(Feature::GuardianApproval)); - assert_eq!(config.approvals_reviewer, ApprovalsReviewer::User); - - let serialized = tokio::fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).await?; - assert!(serialized.contains("[profiles.guardian.features]")); - assert!(serialized.contains("smart_approvals = true")); - assert!(!serialized.contains("guardian_approval")); - assert!(!serialized.contains("approvals_reviewer")); - - Ok(()) -} - #[tokio::test] async fn multi_agent_v2_config_from_feature_table() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -9848,6 +9784,7 @@ usage_hint_enabled = false usage_hint_text = "Custom delegation guidance." root_agent_usage_hint_text = "Root guidance." subagent_usage_hint_text = "Subagent guidance." +tool_namespace = "agents" hide_spawn_agent_metadata = true non_code_mode_only = true "#, @@ -9878,74 +9815,16 @@ non_code_mode_only = true config.multi_agent_v2.subagent_usage_hint_text.as_deref(), Some("Subagent guidance.") ); + assert_eq!( + config.multi_agent_v2.tool_namespace.as_deref(), + Some("agents") + ); assert!(config.multi_agent_v2.hide_spawn_agent_metadata); assert!(config.multi_agent_v2.non_code_mode_only); Ok(()) } -#[tokio::test] -async fn profile_multi_agent_v2_config_overrides_base() -> std::io::Result<()> { - let codex_home = TempDir::new()?; - std::fs::write( - codex_home.path().join(CONFIG_TOML_FILE), - r#"profile = "no_hint" - -[features.multi_agent_v2] -max_concurrent_threads_per_session = 4 -min_wait_timeout_ms = 3000 -max_wait_timeout_ms = 120000 -default_wait_timeout_ms = 30000 -usage_hint_enabled = true -usage_hint_text = "base hint" -root_agent_usage_hint_text = "base root hint" -subagent_usage_hint_text = "base subagent hint" -hide_spawn_agent_metadata = true -non_code_mode_only = false - -[profiles.no_hint.features.multi_agent_v2] -max_concurrent_threads_per_session = 6 -min_wait_timeout_ms = 1500 -max_wait_timeout_ms = 90000 -default_wait_timeout_ms = 15000 -usage_hint_enabled = false -usage_hint_text = "profile hint" -root_agent_usage_hint_text = "profile root hint" -subagent_usage_hint_text = "profile subagent hint" -hide_spawn_agent_metadata = false -non_code_mode_only = true -"#, - )?; - - let config = ConfigBuilder::without_managed_config_for_tests() - .codex_home(codex_home.path().to_path_buf()) - .fallback_cwd(Some(codex_home.path().to_path_buf())) - .build() - .await?; - - assert_eq!(config.multi_agent_v2.max_concurrent_threads_per_session, 6); - assert_eq!(config.multi_agent_v2.min_wait_timeout_ms, 1500); - assert_eq!(config.multi_agent_v2.max_wait_timeout_ms, 90000); - assert_eq!(config.multi_agent_v2.default_wait_timeout_ms, 15000); - assert!(!config.multi_agent_v2.usage_hint_enabled); - assert_eq!( - config.multi_agent_v2.usage_hint_text.as_deref(), - Some("profile hint") - ); - assert_eq!( - config.multi_agent_v2.root_agent_usage_hint_text.as_deref(), - Some("profile root hint") - ); - assert_eq!( - config.multi_agent_v2.subagent_usage_hint_text.as_deref(), - Some("profile subagent hint") - ); - assert!(!config.multi_agent_v2.hide_spawn_agent_metadata); - assert!(config.multi_agent_v2.non_code_mode_only); - - Ok(()) -} - #[tokio::test] async fn multi_agent_v2_default_session_thread_cap_counts_root() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -9967,7 +9846,55 @@ enabled = true assert_eq!(config.multi_agent_v2.max_wait_timeout_ms, 3_600_000); assert_eq!(config.multi_agent_v2.default_wait_timeout_ms, 30_000); assert_eq!(config.agent_max_threads, Some(3)); - assert!(!config.multi_agent_v2.non_code_mode_only); + assert_eq!( + config.multi_agent_v2.root_agent_usage_hint_text.as_deref(), + Some(DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT) + ); + assert!( + !config + .multi_agent_v2 + .root_agent_usage_hint_text + .as_deref() + .unwrap_or_default() + .contains("maximum concurrency"), + ); + assert_eq!( + config.multi_agent_v2.subagent_usage_hint_text.as_deref(), + Some(DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT) + ); + assert!( + !config + .multi_agent_v2 + .subagent_usage_hint_text + .as_deref() + .unwrap_or_default() + .contains("maximum concurrency"), + ); + assert!(config.multi_agent_v2.non_code_mode_only); + + Ok(()) +} + +#[tokio::test] +async fn multi_agent_v2_empty_usage_hint_overrides_clear_default_hints() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#"[features.multi_agent_v2] +enabled = true +root_agent_usage_hint_text = "" +subagent_usage_hint_text = "" +"#, + )?; + + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await?; + + assert_eq!(config.multi_agent_v2.root_agent_usage_hint_text, None); + assert_eq!(config.multi_agent_v2.subagent_usage_hint_text, None); Ok(()) } @@ -10200,6 +10127,42 @@ default_wait_timeout_ms = 2500 Ok(()) } +#[tokio::test] +async fn multi_agent_v2_rejects_invalid_tool_namespace() -> std::io::Result<()> { + for (namespace, expected_message) in [ + ( + "bad namespace", + "features.multi_agent_v2.tool_namespace must match ^[a-zA-Z0-9_-]+$", + ), + ( + "functions", + "features.multi_agent_v2.tool_namespace uses a reserved namespace: functions", + ), + ] { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + format!( + r#"[features.multi_agent_v2] +enabled = true +tool_namespace = "{namespace}" +"# + ), + )?; + + let err = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await + .expect_err("invalid multi_agent_v2 tool namespace should fail"); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!(err.to_string(), expected_message); + } + + Ok(()) +} + #[tokio::test] async fn multi_agent_v2_session_thread_cap_one_disallows_subagents() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 5418d3b268..862660ba2e 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -24,6 +24,8 @@ use toml_edit::value; const NOTICE_TABLE_KEY: &str = "notice"; +mod document_helpers; + /// Discrete config mutations supported by the persistence engine. #[derive(Clone, Debug)] pub enum ConfigEdit { @@ -40,8 +42,6 @@ pub enum ConfigEdit { SetNoticeHideFullAccessWarning(bool), /// Toggle the Windows world-writable directories warning acknowledgement flag. SetNoticeHideWorldWritableWarning(bool), - /// Toggle the opt-out marker for Codex-managed fast defaults. - SetNoticeFastDefaultOptOut(bool), /// Toggle the rate limit model nudge acknowledgement flag. SetNoticeHideRateLimitModelNudge(bool), /// Toggle the model migration prompt acknowledgement flag. @@ -181,7 +181,7 @@ pub fn keymap_binding_clear_edit(context: &str, action: &str) -> ConfigEdit { pub fn model_availability_nux_count_edits(shown_count: &HashMap) -> Vec { let mut shown_count_entries: Vec<_> = shown_count.iter().collect(); - shown_count_entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right)); + shown_count_entries.sort_unstable_by_key(|(left, _)| *left); let mut edits = vec![ConfigEdit::ClearPath { segments: vec!["tui".to_string(), "model_availability_nux".to_string()], @@ -200,328 +200,8 @@ pub fn model_availability_nux_count_edits(shown_count: &HashMap) -> edits } -// TODO(jif) move to a dedicated file -mod document_helpers { - use codex_config::types::AppToolApproval; - use codex_config::types::McpServerConfig; - use codex_config::types::McpServerEnvVar; - use codex_config::types::McpServerToolConfig; - use codex_config::types::McpServerTransportConfig; - use codex_config::types::ToolSuggestDisabledTool; - use codex_config::types::ToolSuggestDiscoverableType; - use toml_edit::Array as TomlArray; - use toml_edit::InlineTable; - use toml_edit::Item as TomlItem; - use toml_edit::Table as TomlTable; - use toml_edit::Value as TomlValue; - use toml_edit::value; - - pub(super) fn ensure_table_for_write(item: &mut TomlItem) -> Option<&mut TomlTable> { - match item { - TomlItem::Table(table) => Some(table), - TomlItem::Value(value) => { - if let Some(inline) = value.as_inline_table() { - *item = TomlItem::Table(table_from_inline(inline)); - item.as_table_mut() - } else { - *item = TomlItem::Table(new_implicit_table()); - item.as_table_mut() - } - } - TomlItem::None => { - *item = TomlItem::Table(new_implicit_table()); - item.as_table_mut() - } - _ => None, - } - } - - pub(super) fn ensure_table_for_read(item: &mut TomlItem) -> Option<&mut TomlTable> { - match item { - TomlItem::Table(table) => Some(table), - TomlItem::Value(value) => { - let inline = value.as_inline_table()?; - *item = TomlItem::Table(table_from_inline(inline)); - item.as_table_mut() - } - _ => None, - } - } - - fn serialize_mcp_server_table(config: &McpServerConfig) -> TomlTable { - let mut entry = TomlTable::new(); - entry.set_implicit(false); - - match &config.transport { - McpServerTransportConfig::Stdio { - command, - args, - env, - env_vars, - cwd, - } => { - entry["command"] = value(command.clone()); - if !args.is_empty() { - entry["args"] = array_from_iter(args.iter().cloned()); - } - if let Some(env) = env - && !env.is_empty() - { - entry["env"] = table_from_pairs(env.iter()); - } - if !env_vars.is_empty() { - entry["env_vars"] = array_from_env_vars(env_vars); - } - if let Some(cwd) = cwd { - entry["cwd"] = value(cwd.to_string_lossy().to_string()); - } - } - McpServerTransportConfig::StreamableHttp { - url, - bearer_token_env_var, - http_headers, - env_http_headers, - } => { - entry["url"] = value(url.clone()); - if let Some(env_var) = bearer_token_env_var { - entry["bearer_token_env_var"] = value(env_var.clone()); - } - if let Some(headers) = http_headers - && !headers.is_empty() - { - entry["http_headers"] = table_from_pairs(headers.iter()); - } - if let Some(headers) = env_http_headers - && !headers.is_empty() - { - entry["env_http_headers"] = table_from_pairs(headers.iter()); - } - } - } - - if !config.enabled { - entry["enabled"] = value(false); - } - if let Some(environment) = &config.experimental_environment { - entry["experimental_environment"] = value(environment.clone()); - } - if config.required { - entry["required"] = value(true); - } - if config.supports_parallel_tool_calls { - entry["supports_parallel_tool_calls"] = value(true); - } - if let Some(timeout) = config.startup_timeout_sec { - entry["startup_timeout_sec"] = value(timeout.as_secs_f64()); - } - if let Some(timeout) = config.tool_timeout_sec { - entry["tool_timeout_sec"] = value(timeout.as_secs_f64()); - } - if let Some(approval_mode) = config.default_tools_approval_mode { - entry["default_tools_approval_mode"] = value(match approval_mode { - AppToolApproval::Auto => "auto", - AppToolApproval::Prompt => "prompt", - AppToolApproval::Approve => "approve", - }); - } - if let Some(enabled_tools) = &config.enabled_tools - && !enabled_tools.is_empty() - { - entry["enabled_tools"] = array_from_iter(enabled_tools.iter().cloned()); - } - if let Some(disabled_tools) = &config.disabled_tools - && !disabled_tools.is_empty() - { - entry["disabled_tools"] = array_from_iter(disabled_tools.iter().cloned()); - } - if let Some(scopes) = &config.scopes - && !scopes.is_empty() - { - entry["scopes"] = array_from_iter(scopes.iter().cloned()); - } - if let Some(oauth) = &config.oauth - && let Some(client_id) = &oauth.client_id - && !client_id.is_empty() - { - let mut oauth_table = TomlTable::new(); - oauth_table.set_implicit(false); - oauth_table["client_id"] = value(client_id.clone()); - entry["oauth"] = TomlItem::Table(oauth_table); - } - if let Some(resource) = &config.oauth_resource - && !resource.is_empty() - { - entry["oauth_resource"] = value(resource.clone()); - } - if !config.tools.is_empty() { - let mut tools = new_implicit_table(); - let mut tool_entries: Vec<_> = config.tools.iter().collect(); - tool_entries.sort_by(|(left, _), (right, _)| left.cmp(right)); - for (name, tool_config) in tool_entries { - tools.insert(name, serialize_mcp_server_tool(tool_config)); - } - entry.insert("tools", TomlItem::Table(tools)); - } - - entry - } - - fn serialize_mcp_server_tool(config: &McpServerToolConfig) -> TomlItem { - let mut entry = TomlTable::new(); - entry.set_implicit(false); - if let Some(approval_mode) = config.approval_mode { - entry["approval_mode"] = value(match approval_mode { - AppToolApproval::Auto => "auto", - AppToolApproval::Prompt => "prompt", - AppToolApproval::Approve => "approve", - }); - } - TomlItem::Table(entry) - } - - pub(super) fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem { - TomlItem::Table(serialize_mcp_server_table(config)) - } - - pub(super) fn serialize_mcp_server_inline(config: &McpServerConfig) -> InlineTable { - serialize_mcp_server_table(config).into_inline_table() - } - - pub(super) fn merge_inline_table(existing: &mut InlineTable, replacement: InlineTable) { - existing.retain(|key, _| replacement.get(key).is_some()); - - for (key, value) in replacement.iter() { - if let Some(existing_value) = existing.get_mut(key) { - let mut updated_value = value.clone(); - *updated_value.decor_mut() = existing_value.decor().clone(); - *existing_value = updated_value; - } else { - existing.insert(key.to_string(), value.clone()); - } - } - } - - fn table_from_inline(inline: &InlineTable) -> TomlTable { - let mut table = new_implicit_table(); - for (key, value) in inline.iter() { - let mut value = value.clone(); - let decor = value.decor_mut(); - decor.set_suffix(""); - table.insert(key, TomlItem::Value(value)); - } - table - } - - pub(super) fn new_implicit_table() -> TomlTable { - let mut table = TomlTable::new(); - table.set_implicit(true); - table - } - - pub(super) fn parse_tool_suggest_disabled_tool( - value: &TomlValue, - ) -> Option { - let table = value.as_inline_table()?; - let kind = match table.get("type").and_then(TomlValue::as_str) { - Some("connector") => ToolSuggestDiscoverableType::Connector, - Some("plugin") => ToolSuggestDiscoverableType::Plugin, - _ => return None, - }; - let id = table.get("id").and_then(TomlValue::as_str)?; - Some(ToolSuggestDisabledTool { - kind, - id: id.to_string(), - }) - } - - pub(super) fn parse_tool_suggest_disabled_tool_table( - table: &TomlTable, - ) -> Option { - let kind = match table.get("type").and_then(TomlItem::as_str) { - Some("connector") => ToolSuggestDiscoverableType::Connector, - Some("plugin") => ToolSuggestDiscoverableType::Plugin, - _ => return None, - }; - let id = table.get("id").and_then(TomlItem::as_str)?; - Some(ToolSuggestDisabledTool { - kind, - id: id.to_string(), - }) - } - - pub(super) fn tool_suggest_disabled_tools_value( - disabled_tools: &[ToolSuggestDisabledTool], - ) -> TomlItem { - let mut array = TomlArray::new(); - for disabled_tool in disabled_tools { - let mut table = InlineTable::new(); - table.insert( - "type", - match disabled_tool.kind { - ToolSuggestDiscoverableType::Connector => "connector", - ToolSuggestDiscoverableType::Plugin => "plugin", - } - .into(), - ); - table.insert("id", disabled_tool.id.clone().into()); - array.push(table); - } - TomlItem::Value(array.into()) - } - - fn array_from_iter(iter: I) -> TomlItem - where - I: Iterator, - { - let mut array = TomlArray::new(); - for value in iter { - array.push(value); - } - TomlItem::Value(array.into()) - } - - fn array_from_env_vars(env_vars: &[McpServerEnvVar]) -> TomlItem { - let mut array = TomlArray::new(); - for env_var in env_vars { - match env_var { - McpServerEnvVar::Name(name) => array.push(name.clone()), - McpServerEnvVar::Config { name, source } => { - let mut table = InlineTable::new(); - table.insert("name", name.clone().into()); - if let Some(source) = source { - table.insert("source", source.clone().into()); - } - array.push(table); - } - } - } - TomlItem::Value(array.into()) - } - - fn table_from_pairs<'a, I>(pairs: I) -> TomlItem - where - I: IntoIterator, - { - let mut entries: Vec<_> = pairs.into_iter().collect(); - entries.sort_by(|(a, _), (b, _)| a.cmp(b)); - let mut table = TomlTable::new(); - table.set_implicit(false); - for (key, val) in entries { - table.insert(key, value(val.clone())); - } - TomlItem::Table(table) - } -} - struct ConfigDocument { doc: DocumentMut, - profile: Option, -} - -#[derive(Copy, Clone)] -enum Scope { - Global, - Profile, } #[derive(Copy, Clone)] @@ -531,27 +211,29 @@ enum TraversalMode { } impl ConfigDocument { - fn new(doc: DocumentMut, profile: Option) -> Self { - Self { doc, profile } + fn new(doc: DocumentMut) -> Self { + Self { doc } } fn apply(&mut self, edit: &ConfigEdit) -> anyhow::Result { match edit { ConfigEdit::SetModel { model, effort } => Ok({ let mut mutated = false; - mutated |= self.write_profile_value( + mutated |= self.write_optional_value( &["model"], model.as_ref().map(|model_value| value(model_value.clone())), ); - mutated |= self.write_profile_value( + mutated |= self.write_optional_value( &["model_reasoning_effort"], effort.map(|effort| value(effort.to_string())), ); mutated }), - ConfigEdit::SetServiceTier { service_tier } => Ok(self.write_profile_value( + ConfigEdit::SetServiceTier { service_tier } => Ok(self.write_optional_value( &["service_tier"], service_tier.as_ref().map(|service_tier| { + // Keep the legacy config spelling stable. Runtime values use + // `priority`, but config.toml continues to store it as `fast`. let config_value = match ServiceTier::from_request_value(service_tier) { Some(ServiceTier::Fast) => "fast", Some(ServiceTier::Flex) => "flex", @@ -560,40 +242,30 @@ impl ConfigDocument { value(config_value) }), )), - ConfigEdit::SetModelPersonality { personality } => Ok(self.write_profile_value( + ConfigEdit::SetModelPersonality { personality } => Ok(self.write_optional_value( &["personality"], personality.map(|personality| value(personality.to_string())), )), ConfigEdit::SetNoticeHideFullAccessWarning(acknowledged) => Ok(self.write_value( - Scope::Global, &[NOTICE_TABLE_KEY, "hide_full_access_warning"], value(*acknowledged), )), ConfigEdit::SetNoticeHideWorldWritableWarning(acknowledged) => Ok(self.write_value( - Scope::Global, &[NOTICE_TABLE_KEY, "hide_world_writable_warning"], value(*acknowledged), )), - ConfigEdit::SetNoticeFastDefaultOptOut(opted_out) => Ok(self.write_value( - Scope::Global, - &[NOTICE_TABLE_KEY, "fast_default_opt_out"], - value(*opted_out), - )), ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged) => Ok(self.write_value( - Scope::Global, &[NOTICE_TABLE_KEY, "hide_rate_limit_model_nudge"], value(*acknowledged), )), ConfigEdit::SetNoticeHideModelMigrationPrompt(migration_config, acknowledged) => { Ok(self.write_value( - Scope::Global, &[NOTICE_TABLE_KEY, migration_config.as_str()], value(*acknowledged), )) } ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome(acknowledged) => Ok(self .write_value( - Scope::Global, &[ NOTICE_TABLE_KEY, "external_config_migration_prompts", @@ -603,7 +275,6 @@ impl ConfigDocument { )), ConfigEdit::SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(timestamp) => { Ok(self.write_value( - Scope::Global, &[ NOTICE_TABLE_KEY, "external_config_migration_prompts", @@ -616,7 +287,6 @@ impl ConfigDocument { project, acknowledged, ) => Ok(self.write_value( - Scope::Global, &[ NOTICE_TABLE_KEY, "external_config_migration_prompts", @@ -629,7 +299,6 @@ impl ConfigDocument { project, timestamp, ) => Ok(self.write_value( - Scope::Global, &[ NOTICE_TABLE_KEY, "external_config_migration_prompts", @@ -639,7 +308,6 @@ impl ConfigDocument { value(*timestamp), )), ConfigEdit::RecordModelMigrationSeen { from, to } => Ok(self.write_value( - Scope::Global, &[NOTICE_TABLE_KEY, "model_migrations", from.as_str()], value(to.clone()), )), @@ -668,20 +336,26 @@ impl ConfigDocument { } } - fn write_profile_value(&mut self, segments: &[&str], value: Option) -> bool { + fn write_optional_value(&mut self, segments: &[&str], value: Option) -> bool { match value { - Some(item) => self.write_value(Scope::Profile, segments, item), - None => self.clear(Scope::Profile, segments), + Some(item) => self.write_value(segments, item), + None => self.clear(segments), } } - fn write_value(&mut self, scope: Scope, segments: &[&str], value: TomlItem) -> bool { - let resolved = self.scoped_segments(scope, segments); + fn write_value(&mut self, segments: &[&str], value: TomlItem) -> bool { + let resolved = segments + .iter() + .map(|segment| (*segment).to_string()) + .collect::>(); self.insert(&resolved, value) } - fn clear(&mut self, scope: Scope, segments: &[&str]) -> bool { - let resolved = self.scoped_segments(scope, segments); + fn clear(&mut self, segments: &[&str]) -> bool { + let resolved = segments + .iter() + .map(|segment| (*segment).to_string()) + .collect::>(); self.remove(&resolved) } @@ -714,7 +388,6 @@ impl ConfigDocument { .filter(|disabled_tool| seen.insert(disabled_tool.clone())) .collect::>(); self.write_value( - Scope::Global, &["tool_suggest", "disabled_tools"], document_helpers::tool_suggest_disabled_tools_value(&disabled_tools), ) @@ -726,7 +399,7 @@ impl ConfigDocument { fn replace_mcp_servers(&mut self, servers: &BTreeMap) -> bool { if servers.is_empty() { - return self.clear(Scope::Global, &["mcp_servers"]); + return self.clear(&["mcp_servers"]); } let root = self.doc.as_table_mut(); @@ -888,26 +561,6 @@ impl ConfigDocument { mutated } - fn scoped_segments(&self, scope: Scope, segments: &[&str]) -> Vec { - let resolved: Vec = segments - .iter() - .map(|segment| (*segment).to_string()) - .collect(); - - if matches!(scope, Scope::Profile) - && resolved.first().is_none_or(|segment| segment != "profiles") - && let Some(profile) = self.profile.as_deref() - { - let mut scoped = Vec::with_capacity(resolved.len() + 2); - scoped.push("profiles".to_string()); - scoped.push(profile.to_string()); - scoped.extend(resolved); - return scoped; - } - - resolved - } - fn insert(&mut self, segments: &[String], value: TomlItem) -> bool { let Some((last, parents)) = segments.split_last() else { return false; @@ -1035,18 +688,13 @@ fn write_skill_config_selector(table: &mut TomlTable, selector: &SkillConfigSele } /// Persist edits using a blocking strategy. -pub fn apply_blocking( - codex_home: &Path, - profile: Option<&str>, - edits: &[ConfigEdit], -) -> anyhow::Result<()> { +pub fn apply_blocking(codex_home: &Path, edits: &[ConfigEdit]) -> anyhow::Result<()> { let config_path = codex_home.join(CONFIG_TOML_FILE); - apply_blocking_to_resolved_file(&config_path, profile, edits) + apply_blocking_to_resolved_file(&config_path, edits) } fn apply_blocking_to_resolved_file( resolved_config_file: &Path, - legacy_profile: Option<&str>, edits: &[ConfigEdit], ) -> anyhow::Result<()> { if edits.is_empty() { @@ -1069,13 +717,7 @@ fn apply_blocking_to_resolved_file( serialized.parse::()? }; - let profile = legacy_profile.map(ToOwned::to_owned).or_else(|| { - doc.get("profile") - .and_then(|item| item.as_str()) - .map(ToOwned::to_owned) - }); - - let mut document = ConfigDocument::new(doc, profile); + let mut document = ConfigDocument::new(doc); let mut mutated = false; for edit in edits { @@ -1098,29 +740,18 @@ fn apply_blocking_to_resolved_file( /// Persist edits asynchronously by offloading the blocking writer. /// -/// `profile` selects a legacy `[profiles.]` section inside -/// `$CODEX_HOME/config.toml`; profile-v2 callers should resolve their target -/// file before constructing a [ConfigEditsBuilder]. -pub async fn apply( - codex_home: &Path, - profile: Option<&str>, - edits: Vec, -) -> anyhow::Result<()> { +pub async fn apply(codex_home: &Path, edits: Vec) -> anyhow::Result<()> { let codex_home = codex_home.to_path_buf(); let config_path = codex_home.join(CONFIG_TOML_FILE); - let profile = profile.map(ToOwned::to_owned); - task::spawn_blocking(move || { - apply_blocking_to_resolved_file(&config_path, profile.as_deref(), &edits) - }) - .await - .context("config persistence task panicked")? + task::spawn_blocking(move || apply_blocking_to_resolved_file(&config_path, &edits)) + .await + .context("config persistence task panicked")? } /// Fluent builder to batch config edits and apply them atomically. #[derive(Default)] pub struct ConfigEditsBuilder { config_path: PathBuf, - profile: Option, edits: Vec, } @@ -1141,16 +772,10 @@ impl ConfigEditsBuilder { pub fn for_config_path(config_path: &Path) -> Self { Self { config_path: config_path.to_path_buf(), - profile: None, edits: Vec::new(), } } - pub fn with_profile(mut self, profile: Option<&str>) -> Self { - self.profile = profile.map(ToOwned::to_owned); - self - } - pub fn set_model(mut self, model: Option<&str>, effort: Option) -> Self { self.edits.push(ConfigEdit::SetModel { model: model.map(ToOwned::to_owned), @@ -1182,12 +807,6 @@ impl ConfigEditsBuilder { self } - pub fn set_fast_default_opt_out(mut self, opted_out: bool) -> Self { - self.edits - .push(ConfigEdit::SetNoticeFastDefaultOptOut(opted_out)); - self - } - pub fn set_hide_rate_limit_model_nudge(mut self, acknowledged: bool) -> Self { self.edits .push(ConfigEdit::SetNoticeHideRateLimitModelNudge(acknowledged)); @@ -1259,27 +878,16 @@ impl ConfigEditsBuilder { /// Enable or disable a feature flag by key under the `[features]` table. /// - /// Disabling a default-false feature clears the root-scoped key instead of + /// Disabling a default-false feature clears the key instead of /// persisting `false`, so the config does not pin the feature once it - /// graduates to globally enabled. Profile-scoped disables still persist - /// `false` so they can override an inherited root enable. + /// graduates to globally enabled. pub fn set_feature_enabled(mut self, key: &str, enabled: bool) -> Self { - let profile_scoped = self.profile.is_some(); - let segments = if let Some(profile) = self.profile.as_ref() { - vec![ - "profiles".to_string(), - profile.clone(), - "features".to_string(), - key.to_string(), - ] - } else { - vec!["features".to_string(), key.to_string()] - }; + let segments = vec!["features".to_string(), key.to_string()]; let is_default_false_feature = FEATURES .iter() .find(|spec| spec.key == key) .is_some_and(|spec| !spec.default_enabled); - if enabled || profile_scoped || !is_default_false_feature { + if enabled || !is_default_false_feature { self.edits.push(ConfigEdit::SetPath { segments, value: value(enabled), @@ -1291,18 +899,8 @@ impl ConfigEditsBuilder { } pub fn set_windows_sandbox_mode(mut self, mode: &str) -> Self { - let segments = if let Some(profile) = self.profile.as_ref() { - vec![ - "profiles".to_string(), - profile.clone(), - "windows".to_string(), - "sandbox".to_string(), - ] - } else { - vec!["windows".to_string(), "sandbox".to_string()] - }; self.edits.push(ConfigEdit::SetPath { - segments, + segments: vec!["windows".to_string(), "sandbox".to_string()], value: value(mode), }); self @@ -1350,34 +948,15 @@ impl ConfigEditsBuilder { "elevated_windows_sandbox", "enable_experimental_windows_sandbox", ] { - let mut segments = vec!["features".to_string(), key.to_string()]; - if let Some(profile) = self.profile.as_ref() { - segments = vec![ - "profiles".to_string(), - profile.clone(), - "features".to_string(), - key.to_string(), - ]; - } + let segments = vec!["features".to_string(), key.to_string()]; self.edits.push(ConfigEdit::ClearPath { segments }); } self } pub fn set_session_picker_view(mut self, mode: SessionPickerViewMode) -> Self { - let segments = if let Some(profile) = self.profile.as_ref() { - vec![ - "profiles".to_string(), - profile.clone(), - "tui".to_string(), - "session_picker_view".to_string(), - ] - } else { - vec!["tui".to_string(), "session_picker_view".to_string()] - }; - self.edits.push(ConfigEdit::SetPath { - segments, + segments: vec!["tui".to_string(), "session_picker_view".to_string()], value: value(mode.to_string()), }); self @@ -1393,13 +972,13 @@ impl ConfigEditsBuilder { /// Apply edits on a blocking thread. pub fn apply_blocking(self) -> anyhow::Result<()> { - apply_blocking_to_resolved_file(&self.config_path, self.profile.as_deref(), &self.edits) + apply_blocking_to_resolved_file(&self.config_path, &self.edits) } /// Apply edits asynchronously via a blocking offload. pub async fn apply(self) -> anyhow::Result<()> { task::spawn_blocking(move || { - apply_blocking_to_resolved_file(&self.config_path, self.profile.as_deref(), &self.edits) + apply_blocking_to_resolved_file(&self.config_path, &self.edits) }) .await .context("config persistence task panicked")? diff --git a/codex-rs/core/src/config/edit/document_helpers.rs b/codex-rs/core/src/config/edit/document_helpers.rs new file mode 100644 index 0000000000..5b99306eb9 --- /dev/null +++ b/codex-rs/core/src/config/edit/document_helpers.rs @@ -0,0 +1,309 @@ +use codex_config::types::AppToolApproval; +use codex_config::types::McpServerConfig; +use codex_config::types::McpServerEnvVar; +use codex_config::types::McpServerToolConfig; +use codex_config::types::McpServerTransportConfig; +use codex_config::types::ToolSuggestDisabledTool; +use codex_config::types::ToolSuggestDiscoverableType; +use toml_edit::Array as TomlArray; +use toml_edit::InlineTable; +use toml_edit::Item as TomlItem; +use toml_edit::Table as TomlTable; +use toml_edit::Value as TomlValue; +use toml_edit::value; + +pub(super) fn ensure_table_for_write(item: &mut TomlItem) -> Option<&mut TomlTable> { + match item { + TomlItem::Table(table) => Some(table), + TomlItem::Value(value) => { + if let Some(inline) = value.as_inline_table() { + *item = TomlItem::Table(table_from_inline(inline)); + item.as_table_mut() + } else { + *item = TomlItem::Table(new_implicit_table()); + item.as_table_mut() + } + } + TomlItem::None => { + *item = TomlItem::Table(new_implicit_table()); + item.as_table_mut() + } + _ => None, + } +} + +pub(super) fn ensure_table_for_read(item: &mut TomlItem) -> Option<&mut TomlTable> { + match item { + TomlItem::Table(table) => Some(table), + TomlItem::Value(value) => { + let inline = value.as_inline_table()?; + *item = TomlItem::Table(table_from_inline(inline)); + item.as_table_mut() + } + _ => None, + } +} + +fn serialize_mcp_server_table(config: &McpServerConfig) -> TomlTable { + let mut entry = TomlTable::new(); + entry.set_implicit(false); + + match &config.transport { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { + entry["command"] = value(command.clone()); + if !args.is_empty() { + entry["args"] = array_from_iter(args.iter().cloned()); + } + if let Some(env) = env + && !env.is_empty() + { + entry["env"] = table_from_pairs(env.iter()); + } + if !env_vars.is_empty() { + entry["env_vars"] = array_from_env_vars(env_vars); + } + if let Some(cwd) = cwd { + entry["cwd"] = value(cwd.to_string_lossy().to_string()); + } + } + McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var, + http_headers, + env_http_headers, + } => { + entry["url"] = value(url.clone()); + if let Some(env_var) = bearer_token_env_var { + entry["bearer_token_env_var"] = value(env_var.clone()); + } + if let Some(headers) = http_headers + && !headers.is_empty() + { + entry["http_headers"] = table_from_pairs(headers.iter()); + } + if let Some(headers) = env_http_headers + && !headers.is_empty() + { + entry["env_http_headers"] = table_from_pairs(headers.iter()); + } + } + } + + if !config.enabled { + entry["enabled"] = value(false); + } + if !config.is_local_environment() { + entry["environment_id"] = value(config.environment_id.clone()); + } + if config.required { + entry["required"] = value(true); + } + if config.supports_parallel_tool_calls { + entry["supports_parallel_tool_calls"] = value(true); + } + if let Some(timeout) = config.startup_timeout_sec { + entry["startup_timeout_sec"] = value(timeout.as_secs_f64()); + } + if let Some(timeout) = config.tool_timeout_sec { + entry["tool_timeout_sec"] = value(timeout.as_secs_f64()); + } + if let Some(approval_mode) = config.default_tools_approval_mode { + entry["default_tools_approval_mode"] = value(match approval_mode { + AppToolApproval::Auto => "auto", + AppToolApproval::Prompt => "prompt", + AppToolApproval::Approve => "approve", + }); + } + if let Some(enabled_tools) = &config.enabled_tools + && !enabled_tools.is_empty() + { + entry["enabled_tools"] = array_from_iter(enabled_tools.iter().cloned()); + } + if let Some(disabled_tools) = &config.disabled_tools + && !disabled_tools.is_empty() + { + entry["disabled_tools"] = array_from_iter(disabled_tools.iter().cloned()); + } + if let Some(scopes) = &config.scopes + && !scopes.is_empty() + { + entry["scopes"] = array_from_iter(scopes.iter().cloned()); + } + if let Some(oauth) = &config.oauth + && let Some(client_id) = &oauth.client_id + && !client_id.is_empty() + { + let mut oauth_table = TomlTable::new(); + oauth_table.set_implicit(false); + oauth_table["client_id"] = value(client_id.clone()); + entry["oauth"] = TomlItem::Table(oauth_table); + } + if let Some(resource) = &config.oauth_resource + && !resource.is_empty() + { + entry["oauth_resource"] = value(resource.clone()); + } + if !config.tools.is_empty() { + let mut tools = new_implicit_table(); + let mut tool_entries: Vec<_> = config.tools.iter().collect(); + tool_entries.sort_by_key(|(name, _)| *name); + for (name, tool_config) in tool_entries { + tools.insert(name, serialize_mcp_server_tool(tool_config)); + } + entry.insert("tools", TomlItem::Table(tools)); + } + + entry +} + +fn serialize_mcp_server_tool(config: &McpServerToolConfig) -> TomlItem { + let mut entry = TomlTable::new(); + entry.set_implicit(false); + if let Some(approval_mode) = config.approval_mode { + entry["approval_mode"] = value(match approval_mode { + AppToolApproval::Auto => "auto", + AppToolApproval::Prompt => "prompt", + AppToolApproval::Approve => "approve", + }); + } + TomlItem::Table(entry) +} + +pub(super) fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem { + TomlItem::Table(serialize_mcp_server_table(config)) +} + +pub(super) fn serialize_mcp_server_inline(config: &McpServerConfig) -> InlineTable { + serialize_mcp_server_table(config).into_inline_table() +} + +pub(super) fn merge_inline_table(existing: &mut InlineTable, replacement: InlineTable) { + existing.retain(|key, _| replacement.get(key).is_some()); + + for (key, value) in replacement.iter() { + if let Some(existing_value) = existing.get_mut(key) { + let mut updated_value = value.clone(); + *updated_value.decor_mut() = existing_value.decor().clone(); + *existing_value = updated_value; + } else { + existing.insert(key.to_string(), value.clone()); + } + } +} + +fn table_from_inline(inline: &InlineTable) -> TomlTable { + let mut table = new_implicit_table(); + for (key, value) in inline.iter() { + let mut value = value.clone(); + let decor = value.decor_mut(); + decor.set_suffix(""); + table.insert(key, TomlItem::Value(value)); + } + table +} + +pub(super) fn new_implicit_table() -> TomlTable { + let mut table = TomlTable::new(); + table.set_implicit(true); + table +} + +pub(super) fn parse_tool_suggest_disabled_tool( + value: &TomlValue, +) -> Option { + let table = value.as_inline_table()?; + let kind = match table.get("type").and_then(TomlValue::as_str) { + Some("connector") => ToolSuggestDiscoverableType::Connector, + Some("plugin") => ToolSuggestDiscoverableType::Plugin, + _ => return None, + }; + let id = table.get("id").and_then(TomlValue::as_str)?; + Some(ToolSuggestDisabledTool { + kind, + id: id.to_string(), + }) +} + +pub(super) fn parse_tool_suggest_disabled_tool_table( + table: &TomlTable, +) -> Option { + let kind = match table.get("type").and_then(TomlItem::as_str) { + Some("connector") => ToolSuggestDiscoverableType::Connector, + Some("plugin") => ToolSuggestDiscoverableType::Plugin, + _ => return None, + }; + let id = table.get("id").and_then(TomlItem::as_str)?; + Some(ToolSuggestDisabledTool { + kind, + id: id.to_string(), + }) +} + +pub(super) fn tool_suggest_disabled_tools_value( + disabled_tools: &[ToolSuggestDisabledTool], +) -> TomlItem { + let mut array = TomlArray::new(); + for disabled_tool in disabled_tools { + let mut table = InlineTable::new(); + table.insert( + "type", + match disabled_tool.kind { + ToolSuggestDiscoverableType::Connector => "connector", + ToolSuggestDiscoverableType::Plugin => "plugin", + } + .into(), + ); + table.insert("id", disabled_tool.id.clone().into()); + array.push(table); + } + TomlItem::Value(array.into()) +} + +fn array_from_iter(iter: I) -> TomlItem +where + I: Iterator, +{ + let mut array = TomlArray::new(); + for value in iter { + array.push(value); + } + TomlItem::Value(array.into()) +} + +fn array_from_env_vars(env_vars: &[McpServerEnvVar]) -> TomlItem { + let mut array = TomlArray::new(); + for env_var in env_vars { + match env_var { + McpServerEnvVar::Name(name) => array.push(name.clone()), + McpServerEnvVar::Config { name, source } => { + let mut table = InlineTable::new(); + table.insert("name", name.clone().into()); + if let Some(source) = source { + table.insert("source", source.clone().into()); + } + array.push(table); + } + } + } + TomlItem::Value(array.into()) +} + +fn table_from_pairs<'a, I>(pairs: I) -> TomlItem +where + I: IntoIterator, +{ + let mut entries: Vec<_> = pairs.into_iter().collect(); + entries.sort_by_key(|(key, _)| *key); + let mut table = TomlTable::new(); + table.set_implicit(false); + for (key, val) in entries { + table.insert(key, value(val.clone())); + } + TomlItem::Table(table) +} diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index 751411afdf..dce192831b 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -4,6 +4,7 @@ use codex_config::types::McpServerOAuthConfig; use codex_config::types::McpServerToolConfig; use codex_config::types::McpServerTransportConfig; use codex_config::types::SessionPickerViewMode; +use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; @@ -19,7 +20,6 @@ fn blocking_set_model_top_level() { apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetModel { model: Some("gpt-5.4".to_string()), effort: Some(ReasoningEffort::High), @@ -34,6 +34,20 @@ model_reasoning_effort = "high" assert_eq!(contents, expected); } +#[test] +fn set_service_tier_saves_default_as_default() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .set_service_tier(Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())) + .apply_blocking() + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + assert_eq!(contents, "service_tier = \"default\"\n"); +} + #[test] fn set_service_tier_saves_priority_as_fast() { let tmp = tempdir().expect("tmpdir"); @@ -96,24 +110,6 @@ session_picker_view = "dense" assert_eq!(contents, expected); } -#[test] -fn session_picker_view_builder_respects_active_profile() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - - ConfigEditsBuilder::new(codex_home) - .with_profile(Some("work")) - .set_session_picker_view(SessionPickerViewMode::Dense) - .apply_blocking() - .expect("persist"); - - let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let expected = r#"[profiles.work.tui] -session_picker_view = "dense" -"#; - assert_eq!(contents, expected); -} - #[test] fn keymap_binding_edit_writes_root_action_binding() { let tmp = tempdir().expect("tmpdir"); @@ -365,7 +361,7 @@ enabled = false } #[test] -fn blocking_set_model_preserves_inline_table_contents() { +fn blocking_set_model_ignores_inline_legacy_profile_contents() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); @@ -381,7 +377,6 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } } apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetModel { model: Some("o4-mini".to_string()), effort: None, @@ -392,7 +387,12 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } } let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let value: TomlValue = toml::from_str(&raw).expect("parse config"); - // Ensure sandbox_mode is preserved under profiles.fast and model updated. + assert_eq!( + value.get("model").and_then(TomlValue::as_str), + Some("o4-mini") + ); + + // Legacy profile values stay untouched when root settings are updated. let profiles_tbl = value .get("profiles") .and_then(|v| v.as_table()) @@ -407,7 +407,7 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } } ); assert_eq!( fast_tbl.get("model").and_then(|v| v.as_str()), - Some("o4-mini") + Some("gpt-4o") ); } @@ -426,7 +426,6 @@ fn blocking_set_model_writes_through_symlink_chain() { apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetModel { model: Some("gpt-5.4".to_string()), effort: Some(ReasoningEffort::High), @@ -459,7 +458,6 @@ fn blocking_set_model_replaces_symlink_on_cycle() { apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetModel { model: Some("gpt-5.4".to_string()), effort: None, @@ -498,7 +496,6 @@ network_access = false apply_blocking( codex_home, - /*profile*/ None, &[ ConfigEdit::SetPath { segments: vec![ @@ -538,7 +535,7 @@ network_access = true } #[test] -fn blocking_clear_model_removes_inline_table_entry() { +fn blocking_clear_model_does_not_follow_legacy_active_profile() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); @@ -553,7 +550,6 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } } apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetModel { model: None, effort: Some(ReasoningEffort::High), @@ -564,15 +560,14 @@ profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } } let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"profile = "fast" -[profiles.fast] -sandbox_mode = "strict" +profiles = { fast = { model = "gpt-4o", sandbox_mode = "strict" } } model_reasoning_effort = "high" "#; assert_eq!(contents, expected); } #[test] -fn blocking_set_model_scopes_to_active_profile() { +fn blocking_set_model_does_not_follow_legacy_active_profile() { let tmp = tempdir().expect("tmpdir"); let codex_home = tmp.path(); std::fs::write( @@ -587,7 +582,6 @@ model_reasoning_effort = "low" apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetModel { model: Some("o5-preview".to_string()), effort: Some(ReasoningEffort::Minimal), @@ -597,39 +591,11 @@ model_reasoning_effort = "low" let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"profile = "team" +model = "o5-preview" +model_reasoning_effort = "minimal" [profiles.team] -model_reasoning_effort = "minimal" -model = "o5-preview" -"#; - assert_eq!(contents, expected); -} - -#[test] -fn blocking_set_model_with_explicit_profile() { - let tmp = tempdir().expect("tmpdir"); - let codex_home = tmp.path(); - std::fs::write( - codex_home.join(CONFIG_TOML_FILE), - r#"[profiles."team a"] -model = "gpt-5.4" -"#, - ) - .expect("seed"); - - apply_blocking( - codex_home, - Some("team a"), - &[ConfigEdit::SetModel { - model: Some("o4-mini".to_string()), - effort: None, - }], - ) - .expect("persist"); - - let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); - let expected = r#"[profiles."team a"] -model = "o4-mini" +model_reasoning_effort = "low" "#; assert_eq!(contents, expected); } @@ -651,7 +617,6 @@ existing = "value" apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetNoticeHideFullAccessWarning(true)], ) .expect("persist"); @@ -681,7 +646,6 @@ existing = "value" apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetNoticeHideRateLimitModelNudge(true)], ) .expect("persist"); @@ -707,7 +671,6 @@ existing = "value" .expect("seed"); apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetNoticeHideModelMigrationPrompt( "hide_gpt5_1_migration_prompt".to_string(), true, @@ -736,7 +699,6 @@ existing = "value" .expect("seed"); apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetNoticeHideModelMigrationPrompt( "hide_gpt-5.1-codex-max_migration_prompt".to_string(), true, @@ -765,7 +727,6 @@ existing = "value" .expect("seed"); apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::RecordModelMigrationSeen { from: "gpt-5.2".to_string(), to: "gpt-5.4".to_string(), @@ -796,7 +757,6 @@ existing = "value" .expect("seed"); apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome( true, )], @@ -826,7 +786,6 @@ existing = "value" .expect("seed"); apply_blocking( codex_home, - /*profile*/ None, &[ ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject( "/Users/alexsong/code/skills".to_string(), @@ -859,7 +818,6 @@ existing = "value" .expect("seed"); apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(1_760_000_000)], ) .expect("persist"); @@ -887,7 +845,6 @@ existing = "value" .expect("seed"); apply_blocking( codex_home, - /*profile*/ None, &[ ConfigEdit::SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt( "/Users/alexsong/code/skills".to_string(), @@ -930,7 +887,7 @@ fn blocking_replace_mcp_servers_round_trips() { env_vars: vec!["FOO".into()], cwd: None, }, - experimental_environment: None, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: true, @@ -960,7 +917,7 @@ fn blocking_replace_mcp_servers_round_trips() { ), env_http_headers: None, }, - experimental_environment: None, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: false, required: false, supports_parallel_tool_calls: false, @@ -981,7 +938,6 @@ fn blocking_replace_mcp_servers_round_trips() { apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(servers.clone())], ) .expect("persist"); @@ -1032,7 +988,7 @@ fn blocking_replace_mcp_servers_serializes_tool_approval_overrides() { env_vars: Vec::new(), cwd: None, }, - experimental_environment: None, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, @@ -1054,12 +1010,7 @@ fn blocking_replace_mcp_servers_serializes_tool_approval_overrides() { }, ); - apply_blocking( - codex_home, - /*profile*/ None, - &[ConfigEdit::ReplaceMcpServers(servers)], - ) - .expect("persist"); + apply_blocking(codex_home, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist"); let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = "\ @@ -1097,7 +1048,7 @@ foo = { command = "cmd" } env_vars: Vec::new(), cwd: None, }, - experimental_environment: None, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, @@ -1114,12 +1065,7 @@ foo = { command = "cmd" } }, ); - apply_blocking( - codex_home, - /*profile*/ None, - &[ConfigEdit::ReplaceMcpServers(servers)], - ) - .expect("persist"); + apply_blocking(codex_home, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[mcp_servers] @@ -1152,7 +1098,7 @@ foo = { command = "cmd" } # keep me env_vars: Vec::new(), cwd: None, }, - experimental_environment: None, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: false, required: false, supports_parallel_tool_calls: false, @@ -1169,12 +1115,7 @@ foo = { command = "cmd" } # keep me }, ); - apply_blocking( - codex_home, - /*profile*/ None, - &[ConfigEdit::ReplaceMcpServers(servers)], - ) - .expect("persist"); + apply_blocking(codex_home, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[mcp_servers] @@ -1206,7 +1147,7 @@ foo = { command = "cmd", args = ["--flag"] } # keep me env_vars: Vec::new(), cwd: None, }, - experimental_environment: None, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, required: false, supports_parallel_tool_calls: false, @@ -1223,12 +1164,7 @@ foo = { command = "cmd", args = ["--flag"] } # keep me }, ); - apply_blocking( - codex_home, - /*profile*/ None, - &[ConfigEdit::ReplaceMcpServers(servers)], - ) - .expect("persist"); + apply_blocking(codex_home, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[mcp_servers] @@ -1261,7 +1197,7 @@ foo = { command = "cmd" } env_vars: Vec::new(), cwd: None, }, - experimental_environment: None, + environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: false, required: false, supports_parallel_tool_calls: false, @@ -1278,12 +1214,7 @@ foo = { command = "cmd" } }, ); - apply_blocking( - codex_home, - /*profile*/ None, - &[ConfigEdit::ReplaceMcpServers(servers)], - ) - .expect("persist"); + apply_blocking(codex_home, &[ConfigEdit::ReplaceMcpServers(servers)]).expect("persist"); let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); let expected = r#"[mcp_servers] @@ -1300,7 +1231,6 @@ fn blocking_clear_path_noop_when_missing() { apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::ClearPath { segments: vec!["missing".to_string()], }], @@ -1321,7 +1251,6 @@ fn blocking_set_path_updates_notifications() { let item = value(false); apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::SetPath { segments: vec!["tui".to_string(), "notifications".to_string()], value: item, @@ -1503,7 +1432,6 @@ fn replace_mcp_servers_blocking_clears_table_when_empty() { apply_blocking( codex_home, - /*profile*/ None, &[ConfigEdit::ReplaceMcpServers(BTreeMap::new())], ) .expect("persist"); diff --git a/codex-rs/core/src/config/managed_features.rs b/codex-rs/core/src/config/managed_features.rs index 480225fb99..234a2835c6 100644 --- a/codex-rs/core/src/config/managed_features.rs +++ b/codex-rs/core/src/config/managed_features.rs @@ -9,7 +9,6 @@ use codex_config::RequirementSource; use codex_config::Sourced; use codex_config::config_toml::ConfigToml; -use codex_config::profile_toml::ConfigProfile; use codex_features::Feature; use codex_features::FeatureConfigSource; use codex_features::FeatureOverrides; @@ -269,27 +268,6 @@ fn explicit_feature_settings_in_config(cfg: &ConfigToml) -> Vec<(String, Feature enabled, )); } - for (profile_name, profile) in &cfg.profiles { - if let Some(features) = profile.features.as_ref() { - for (key, enabled) in features.entries() { - if let Some(feature) = feature_for_key(&key) { - explicit_settings.push(( - format!("profiles.{profile_name}.features.{key}"), - feature, - enabled, - )); - } - } - } - if let Some(enabled) = profile.experimental_use_unified_exec_tool { - explicit_settings.push(( - format!("profiles.{profile_name}.experimental_use_unified_exec_tool"), - Feature::UnifiedExec, - enabled, - )); - } - } - explicit_settings } @@ -339,47 +317,13 @@ pub(crate) fn validate_feature_requirements_in_config_toml( cfg: &ConfigToml, feature_requirements: Option<&Sourced>, ) -> std::io::Result<()> { - fn validate_profile( - cfg: &ConfigToml, - profile_name: Option<&str>, - profile: &ConfigProfile, - feature_requirements: Option<&Sourced>, - ) -> std::io::Result<()> { - let configured_features = Features::from_sources( - FeatureConfigSource { - features: cfg.features.as_ref(), - experimental_use_unified_exec_tool: cfg.experimental_use_unified_exec_tool, - }, - FeatureConfigSource { - features: profile.features.as_ref(), - experimental_use_unified_exec_tool: profile.experimental_use_unified_exec_tool, - }, - FeatureOverrides::default(), - ); - ManagedFeatures::from_configured(configured_features, feature_requirements.cloned()) - .map(|_| ()) - .map_err(|err| { - if let Some(profile_name) = profile_name { - std::io::Error::new( - err.kind(), - format!( - "invalid feature configuration for profile `{profile_name}`: {err}" - ), - ) - } else { - err - } - }) - } - - validate_profile( - cfg, - /*profile_name*/ None, - &ConfigProfile::default(), - feature_requirements, - )?; - for (profile_name, profile) in &cfg.profiles { - validate_profile(cfg, Some(profile_name), profile, feature_requirements)?; - } - Ok(()) + let configured_features = Features::from_sources( + FeatureConfigSource { + features: cfg.features.as_ref(), + experimental_use_unified_exec_tool: cfg.experimental_use_unified_exec_tool, + }, + FeatureConfigSource::default(), + FeatureOverrides::default(), + ); + ManagedFeatures::from_configured(configured_features, feature_requirements.cloned()).map(|_| ()) } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index c6b35e7a01..ff666b51e9 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -33,7 +33,7 @@ use codex_config::config_toml::ThreadStoreToml; use codex_config::config_toml::validate_model_providers; use codex_config::loader::load_config_layers_state; use codex_config::loader::project_trust_key; -use codex_config::profile_toml::ConfigProfile; +use codex_config::permissions_toml::PermissionsToml; use codex_config::sandbox_mode_requirement_for_permission_profile; use codex_config::types::ApprovalsReviewer; use codex_config::types::AuthCredentialsStoreMode; @@ -67,6 +67,7 @@ use codex_features::FeaturesToml; use codex_features::MultiAgentV2ConfigToml; use codex_features::NetworkProxyConfigToml; use codex_git_utils::resolve_root_git_project_for_trust; +use codex_install_context::InstallContext; use codex_login::AuthManagerConfig; use codex_mcp::McpConfig; use codex_memories_read::memory_root; @@ -77,9 +78,11 @@ use codex_model_provider_info::built_in_model_providers; use codex_model_provider_info::merge_configured_model_providers; use codex_models_manager::ModelsManagerConfig; use codex_protocol::config_types::AltScreenMode; +use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::ShellEnvironmentPolicy; @@ -89,7 +92,6 @@ use codex_protocol::config_types::WebSearchConfig; use codex_protocol::config_types::WebSearchMode; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::ActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxEnforcement; use codex_protocol::openai_models::ModelsResponse; @@ -108,6 +110,7 @@ use serde::Serialize; use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; +use std::collections::hash_map::Entry; use std::io::ErrorKind; use std::path::Path; use std::path::PathBuf; @@ -117,6 +120,7 @@ use crate::config::permissions::BUILT_IN_WORKSPACE_PROFILE; use crate::config::permissions::apply_network_proxy_feature_config; use crate::config::permissions::builtin_permission_profile; use crate::config::permissions::compile_permission_profile_selection; +use crate::config::permissions::compile_permission_profile_workspace_roots; use crate::config::permissions::default_builtin_permission_profile_name; use crate::config::permissions::get_readable_roots_required_for_codex_runtime; use crate::config::permissions::network_proxy_config_for_profile_selection; @@ -134,6 +138,7 @@ mod managed_features; mod network_proxy_spec; mod otel; mod permissions; +mod resolved_permission_profile; #[cfg(test)] mod schema; pub use codex_config::ConfigLoadOptions; @@ -147,7 +152,11 @@ pub use codex_sandboxing::system_bwrap_warning; pub use managed_features::ManagedFeatures; pub use network_proxy_spec::NetworkProxySpec; pub use network_proxy_spec::StartedNetworkProxy; +pub(crate) use permissions::is_builtin_permission_profile_name; +pub(crate) use permissions::reject_unknown_builtin_permission_profile; pub(crate) use permissions::resolve_permission_profile; +pub use resolved_permission_profile::PermissionProfileSnapshot; +pub(crate) use resolved_permission_profile::PermissionProfileState; const DEFAULT_IGNORE_LARGE_UNTRACKED_DIRS: i64 = 200; const DEFAULT_IGNORE_LARGE_UNTRACKED_FILES: i64 = 10 * 1024 * 1024; @@ -180,6 +189,44 @@ pub(crate) const DEFAULT_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION: usiz pub(crate) const DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS: i64 = 10_000; pub(crate) const DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS: i64 = 3600 * 1000; pub(crate) const DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS: i64 = 30_000; +const DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT: &str = r#"You are `/root`, the primary agent in a team of agents collaborating to fulfill the user's goals. + +At the start of your turn, you are the active agent. +You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents. +All agents in the team, including the agents that you can assign tasks to, are equally intelligent and capable, and have access to the same set of tools. + +You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task and trigger a turn, and `send_message` to pass a message to a running agent without triggering a turn. +Child agents can also spawn their own sub-agents. +You can decide how much context you want to propagate to your sub-agents with the `fork_turns` parameter. + +You will receive messages in the analysis channel in the form: +``` +Message Type: MESSAGE | FINAL_ANSWER +Sender: +Payload: + +``` +They may be addressed as to=/root +"#; +const DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT: &str = r#"You are an agent in a team of agents collaborating to complete a task. + +You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents. All agents in the team, including the agents that you can assign tasks to, are equally intelligent and capable, and have access to the same set of tools. + +You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task and trigger a turn, and `send_message` to pass a message to a running agent. +Child agents can also spawn their own sub-agents. + +When you provide a response in the final channel, that content is immediately delivered back to your parent agent. + +You will receive messages in the analysis channel in the form: +``` +Message Type: NEW_TASK | MESSAGE | FINAL_ANSWER +Task name: # only for NEW_TASK -- this determines your identity +Sender: +Payload: + +``` +You may also see them addressed as to=/root/..., which indicates your identity is /root/... +"#; pub(crate) const HARD_MIN_MULTI_AGENT_V2_TIMEOUT_MS: i64 = 0; pub(crate) const HARD_MAX_MULTI_AGENT_V2_TIMEOUT_MS: i64 = DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS; @@ -247,12 +294,12 @@ pub(crate) async fn test_config() -> Config { pub struct Permissions { /// Approval policy for executing commands. pub approval_policy: Constrained, - /// Canonical effective runtime permissions after config requirements and - /// runtime readable-root additions have been applied. - pub permission_profile: Constrained, - /// Named or implicit built-in profile selected by config, rather than an - /// ad-hoc override. - pub active_permission_profile: Option, + /// Constrained permission profile plus its selected profile identity, if + /// the profile came from a built-in or named config profile. + permission_profile_state: PermissionProfileState, + /// Thread-scoped runtime workspace roots. Symbolic `:workspace_roots` + /// entries in the permission profile are materialized against these roots. + workspace_roots: Vec, /// Effective network configuration applied to all spawned processes. pub network: Option, /// Whether the model may request a login shell for shell-based tools. @@ -274,33 +321,131 @@ pub struct Permissions { } impl Permissions { + /// Build permissions from the constrained values required for a minimal + /// in-process configuration. + pub fn from_approval_and_profile( + approval_policy: Constrained, + permission_profile: Constrained, + ) -> ConstraintResult { + Ok(Self { + approval_policy, + permission_profile_state: PermissionProfileState::from_constrained_legacy( + permission_profile, + )?, + workspace_roots: Vec::new(), + network: None, + allow_login_shell: true, + shell_environment_policy: ShellEnvironmentPolicy::default(), + windows_sandbox_mode: None, + windows_sandbox_private_desktop: true, + }) + } + + pub(crate) fn permission_profile_state(&self) -> &PermissionProfileState { + &self.permission_profile_state + } + + pub(crate) fn set_permission_profile_state( + &mut self, + permission_profile_state: PermissionProfileState, + ) { + self.permission_profile_state = permission_profile_state; + } + + /// Apply a permission profile snapshot emitted by core session state. + /// + /// This is a trusted-state bridge for consumers of `SessionConfigured`. + /// Config loading and app-server selection should resolve named profiles + /// through config instead of constructing a snapshot directly. + pub fn set_permission_profile_from_session_snapshot( + &mut self, + snapshot: PermissionProfileSnapshot, + ) -> ConstraintResult<()> { + self.permission_profile_state + .set_permission_profile_snapshot(snapshot) + } + + /// Replace the current permission constraints with a trusted session + /// snapshot. This is only for clients that must mirror core session state + /// after their local config constraints reject the snapshot. + pub fn replace_permission_profile_from_session_snapshot( + &mut self, + snapshot: PermissionProfileSnapshot, + ) -> ConstraintResult<()> { + let permission_profile = Constrained::allow_only(snapshot.permission_profile().clone()); + self.permission_profile_state = PermissionProfileState::from_constrained_resolved( + permission_profile, + snapshot.into_resolved_permission_profile(), + )?; + Ok(()) + } + + /// Borrow the canonical profile before runtime workspace-root + /// materialization has been applied. + pub fn permission_profile(&self) -> &PermissionProfile { + self.permission_profile_state.permission_profile() + } + + pub fn can_set_permission_profile( + &self, + permission_profile: &PermissionProfile, + ) -> ConstraintResult<()> { + self.permission_profile_state + .can_set_legacy_permission_profile(permission_profile) + } + + pub fn set_workspace_roots(&mut self, workspace_roots: Vec) { + self.workspace_roots = workspace_roots; + } + + pub fn workspace_roots(&self) -> &[AbsolutePathBuf] { + &self.workspace_roots + } + + /// Workspace roots that came from user-visible configuration or runtime + /// selection. Internal Codex-only writable roots are intentionally excluded. + pub fn user_visible_workspace_roots(&self) -> &[AbsolutePathBuf] { + &self.workspace_roots + } + + pub fn profile_workspace_roots(&self) -> &[AbsolutePathBuf] { + self.permission_profile_state.profile_workspace_roots() + } + + fn materialized_permission_profile(&self) -> PermissionProfile { + self.permission_profile() + .clone() + .materialize_project_roots_with_workspace_roots(&self.workspace_roots) + } + /// Effective runtime permissions after config requirements and runtime - /// readable-root additions have been applied. - pub fn permission_profile(&self) -> PermissionProfile { - self.permission_profile.get().clone() + /// workspace-root materialization have been applied. + pub fn effective_permission_profile(&self) -> PermissionProfile { + self.materialized_permission_profile() } /// Named profile selected by config, if the current profile has one. pub fn active_permission_profile(&self) -> Option { - self.active_permission_profile.clone() + self.permission_profile_state.active_permission_profile() } /// Effective filesystem sandbox policy derived from the canonical profile. pub fn file_system_sandbox_policy(&self) -> FileSystemSandboxPolicy { - self.permission_profile.get().file_system_sandbox_policy() + self.materialized_permission_profile() + .file_system_sandbox_policy() } /// Effective network sandbox policy derived from the canonical profile. pub fn network_sandbox_policy(&self) -> NetworkSandboxPolicy { - self.permission_profile.get().network_sandbox_policy() + self.permission_profile().network_sandbox_policy() } /// Legacy compatibility projection derived from the canonical profile. pub fn legacy_sandbox_policy(&self, cwd: &Path) -> SandboxPolicy { - let permission_profile = self.permission_profile.get(); + let permission_profile = self.materialized_permission_profile(); let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); compatibility_sandbox_policy_for_permission_profile( - permission_profile, + &permission_profile, &file_system_sandbox_policy, permission_profile.network_sandbox_policy(), cwd, @@ -322,11 +467,12 @@ impl Permissions { &file_system_sandbox_policy, network_sandbox_policy, ); - self.permission_profile.can_set(&permission_profile) + self.permission_profile_state + .can_set_legacy_permission_profile(&permission_profile) } - /// Replace permissions from a legacy sandbox policy and keep every - /// permission projection in sync. + /// Set permissions from a legacy sandbox policy and keep every permission + /// projection in sync. pub fn set_legacy_sandbox_policy( &mut self, sandbox_policy: SandboxPolicy, @@ -341,35 +487,39 @@ impl Permissions { &file_system_sandbox_policy, network_sandbox_policy, ); + self.workspace_roots = match &sandbox_policy { + SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + let mut workspace_roots = vec![ + AbsolutePathBuf::from_absolute_path(cwd) + .unwrap_or_else(|_| AbsolutePathBuf::resolve_path_against_base(cwd, "/")), + ]; + for root in writable_roots { + if !workspace_roots.iter().any(|existing| existing == root) { + workspace_roots.push(root.clone()); + } + } + workspace_roots + } + SandboxPolicy::DangerFullAccess + | SandboxPolicy::ExternalSandbox { .. } + | SandboxPolicy::ReadOnly { .. } => vec![ + AbsolutePathBuf::from_absolute_path(cwd) + .unwrap_or_else(|_| AbsolutePathBuf::resolve_path_against_base(cwd, "/")), + ], + }; - self.permission_profile.set(permission_profile)?; - self.active_permission_profile = None; + self.permission_profile_state + .set_legacy_permission_profile(permission_profile)?; Ok(()) } - /// Replace permissions from the canonical profile. + /// Set permissions from the canonical profile. pub fn set_permission_profile( &mut self, permission_profile: PermissionProfile, ) -> ConstraintResult<()> { - self.set_permission_profile_with_active_profile( - permission_profile, - /*active_permission_profile*/ None, - ) - } - - /// Replace permissions from the canonical profile and record the named - /// source profile, if one is known. - pub fn set_permission_profile_with_active_profile( - &mut self, - permission_profile: PermissionProfile, - active_permission_profile: Option, - ) -> ConstraintResult<()> { - self.permission_profile.can_set(&permission_profile)?; - - self.permission_profile.set(permission_profile)?; - self.active_permission_profile = active_permission_profile; - Ok(()) + self.permission_profile_state + .set_legacy_permission_profile(permission_profile) } } @@ -385,6 +535,39 @@ fn profile_allows_configured_network_proxy(permission_profile: &PermissionProfil } } +fn build_network_proxy_spec( + configured_network_proxy_config: NetworkProxyConfig, + network_requirements: Option>, + permission_profile: &PermissionProfile, +) -> std::io::Result> { + let (network_requirements, network_requirements_source) = match network_requirements { + Some(Sourced { value, source }) => (Some(value), Some(source)), + None => (None, None), + }; + let has_network_requirements = network_requirements.is_some(); + let network = NetworkProxySpec::from_config_and_constraints( + configured_network_proxy_config, + network_requirements, + permission_profile, + ) + .map_err(|err| { + if let Some(source) = network_requirements_source.as_ref() { + std::io::Error::new( + err.kind(), + format!("failed to build managed network proxy from {source}: {err}"), + ) + } else { + err + } + })?; + + Ok(if has_network_requirements { + Some(network) + } else { + network.enabled().then_some(network) + }) +} + /// Configured thread persistence backend. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum ThreadStoreConfig { @@ -409,6 +592,7 @@ pub struct Config { pub model: Option, /// Effective service tier request id preference for new turns. + /// `default` means the user explicitly selected standard routing. pub service_tier: Option, /// Model used specifically for review sessions. @@ -420,6 +604,10 @@ pub struct Config { /// Token usage threshold triggering auto-compaction of conversation history. pub model_auto_compact_token_limit: Option, + /// Controls whether `model_auto_compact_token_limit` applies to the full + /// active context or only tokens after the carried compaction-window prefix. + pub model_auto_compact_token_limit_scope: AutoCompactTokenLimitScope, + /// Key into the model_providers map that specifies which provider to use. pub model_provider_id: String, @@ -432,6 +620,13 @@ pub struct Config { /// Effective permission configuration for shell tool execution. pub permissions: Permissions, + /// Whether config explicitly selected named permissions profiles instead + /// of the legacy `sandbox_mode` syntax. + pub explicit_permission_profile_mode: bool, + + /// User-defined permission profiles available from effective config. + pub custom_permission_profiles: Vec, + /// Configures who approval requests are routed to for review once they have /// been escalated. This does not disable separate safety checks such as /// ARC. @@ -577,6 +772,15 @@ pub struct Config { /// layer are resolved against this path. pub cwd: AbsolutePathBuf, + /// Absolute runtime workspace roots for the session. Symbolic + /// `:workspace_roots` permission entries are materialized against these + /// roots while profile-defined workspace roots remain encoded directly in + /// the permission profile. + pub workspace_roots: Vec, + /// Whether runtime workspace roots were supplied explicitly by the caller + /// or legacy config, rather than defaulting to `cwd`. + pub workspace_roots_explicit: bool, + /// Preferred store for CLI auth credentials. /// file (default): Use a file in the Codex home directory. /// keyring: Use an OS-specific keyring service. @@ -726,6 +930,9 @@ pub struct Config { /// Optional path override for the host-owned apps MCP server. pub apps_mcp_path_override: Option, + /// Optional product SKU forwarded to the host-owned apps MCP server. + pub apps_mcp_product_sku: Option, + /// Machine-local realtime audio device preferences used by realtime voice. pub realtime_audio: RealtimeAudioConfig, @@ -770,6 +977,9 @@ pub struct Config { /// Additional parameters for the web search tool when it is enabled. pub web_search_config: Option, + /// Whether to register the experimental request_user_input tool. + pub experimental_request_user_input_enabled: bool, + /// If set to `true`, used only the experimental unified exec tool. pub use_experimental_unified_exec_tool: bool, @@ -790,9 +1000,6 @@ pub struct Config { /// When `true`, suppress warnings about unstable (under development) features. pub suppress_unstable_features_warning: bool, - /// The active profile name used to derive this `Config` (if any). - pub active_profile: Option, - /// The currently active project config, resolved by checking if cwd: /// is (1) part of a git repo, (2) a git worktree, or (3) just using the cwd pub active_project: ProjectConfig, @@ -835,6 +1042,7 @@ pub struct MultiAgentV2Config { pub usage_hint_text: Option, pub root_agent_usage_hint_text: Option, pub subagent_usage_hint_text: Option, + pub tool_namespace: Option, pub hide_spawn_agent_metadata: bool, pub non_code_mode_only: bool, } @@ -849,10 +1057,15 @@ impl Default for MultiAgentV2Config { default_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS, usage_hint_enabled: true, usage_hint_text: None, - root_agent_usage_hint_text: None, - subagent_usage_hint_text: None, + root_agent_usage_hint_text: Some( + DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT.to_string(), + ), + subagent_usage_hint_text: Some( + DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT.to_string(), + ), + tool_namespace: None, hide_spawn_agent_metadata: false, - non_code_mode_only: false, + non_code_mode_only: true, } } } @@ -1076,8 +1289,21 @@ impl Config { &mut self, sandbox_policy: SandboxPolicy, ) -> ConstraintResult<()> { + self.workspace_roots_explicit = matches!( + &sandbox_policy, + SandboxPolicy::WorkspaceWrite { writable_roots, .. } if !writable_roots.is_empty() + ); self.permissions - .set_legacy_sandbox_policy(sandbox_policy, self.cwd.as_path()) + .set_legacy_sandbox_policy(sandbox_policy, self.cwd.as_path())?; + self.workspace_roots = self.permissions.workspace_roots().to_vec(); + Ok(()) + } + + pub fn effective_workspace_roots(&self) -> Vec { + let mut workspace_roots = self.workspace_roots.clone(); + workspace_roots.extend(self.permissions.profile_workspace_roots().iter().cloned()); + dedupe_absolute_paths(&mut workspace_roots); + workspace_roots } pub fn to_models_manager_config(&self) -> ModelsManagerConfig { @@ -1098,7 +1324,6 @@ impl Config { self.config_layer_stack.clone(), self.features.enabled(Feature::Plugins), self.features.enabled(Feature::RemotePlugin), - self.features.enabled(Feature::PluginHooks), self.chatgpt_base_url.clone(), ) } @@ -1110,6 +1335,7 @@ impl Config { let plugins_input = self.plugins_config_input(); let loaded_plugins = plugins_manager.plugins_for_config(&plugins_input).await; let mut configured_mcp_servers = self.mcp_servers.get().clone(); + let mut plugin_ids_by_mcp_server_name = HashMap::new(); for plugin in loaded_plugins .plugins() .iter() @@ -1122,7 +1348,10 @@ impl Config { self.config_layer_stack.requirements().plugins.as_ref(), ); for (name, plugin_server) in plugin_mcp_servers { - configured_mcp_servers.entry(name).or_insert(plugin_server); + if let Entry::Vacant(entry) = configured_mcp_servers.entry(name.clone()) { + entry.insert(plugin_server); + plugin_ids_by_mcp_server_name.insert(name, plugin.config_name.clone()); + } } } if let Some(mcp_requirements) = self.config_layer_stack.requirements().mcp_servers.as_ref() @@ -1132,10 +1361,13 @@ impl Config { // above. filter_mcp_servers_by_requirements(&mut configured_mcp_servers, Some(mcp_requirements)); } + plugin_ids_by_mcp_server_name + .retain(|server_name, _| configured_mcp_servers.contains_key(server_name)); McpConfig { chatgpt_base_url: self.chatgpt_base_url.clone(), apps_mcp_path_override: self.apps_mcp_path_override.clone(), + apps_mcp_product_sku: self.apps_mcp_product_sku.clone(), codex_home: self.codex_home.to_path_buf(), mcp_oauth_credentials_store_mode: self.mcp_oauth_credentials_store_mode, mcp_oauth_callback_port: self.mcp_oauth_callback_port, @@ -1147,6 +1379,7 @@ impl Config { codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), use_legacy_landlock: self.features.use_legacy_landlock(), apps_enabled: self.features.enabled(Feature::Apps), + prefix_mcp_tool_names: self.prefix_mcp_tool_names(), client_elicitation_capability: if self.features.enabled(Feature::AuthElicitation) { ElicitationCapability { form: Some(FormElicitationCapability::default()), @@ -1158,10 +1391,15 @@ impl Config { ElicitationCapability::default() }, configured_mcp_servers, + plugin_ids_by_mcp_server_name, plugin_capability_summaries: loaded_plugins.capability_summaries().to_vec(), } } + pub(crate) fn prefix_mcp_tool_names(&self) -> bool { + !self.features.enabled(Feature::NonPrefixedMcpToolNames) + } + pub async fn rebuild_preserving_session_layers( &self, refreshed_config: &Config, @@ -1205,11 +1443,18 @@ impl Config { .effective_config() .try_into() .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; + let default_zsh_path = refreshed_config + .zsh_path + .clone() + .map(AbsolutePathBuf::try_from) + .transpose()?; + Self::load_config_with_layer_stack( LOCAL_FS.as_ref(), cfg, ConfigOverrides { cwd: Some(self.cwd.to_path_buf()), + default_zsh_path, ..Default::default() }, refreshed_config.codex_home.clone(), @@ -1702,6 +1947,12 @@ pub struct AgentRoleConfig { pub nickname_candidates: Option>, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CustomPermissionProfileSummary { + pub id: String, + pub description: Option, +} + fn resolve_tool_suggest_config( config_toml: &ConfigToml, config_layer_stack: &ConfigLayerStack, @@ -1806,11 +2057,42 @@ struct PermissionSelectionToml { sandbox_mode: Option, } +// Resolve the named-profile catalog and selected profile id together. Runtime +// profile constraints are applied later after this selection compiles into a +// concrete `PermissionProfile`. +#[derive(Debug)] +struct EffectivePermissionSelection<'a> { + profiles: Option, + selected_profile_id: Option<&'a str>, + requirements_force_profile_selection: bool, +} + +impl EffectivePermissionSelection<'_> { + fn has_profiles(&self) -> bool { + self.profiles + .as_ref() + .is_some_and(|profiles| !profiles.is_empty()) + } + + fn profiles_are_active( + &self, + default_permissions_override: Option<&str>, + permission_config_syntax: Option, + ) -> bool { + self.requirements_force_profile_selection + || default_permissions_override.is_some() + || matches!( + permission_config_syntax, + Some(PermissionConfigSyntax::Profiles) + ) + || permission_config_syntax.is_none() + } +} + fn resolve_permission_config_syntax( config_layer_stack: &ConfigLayerStack, cfg: &ConfigToml, sandbox_mode_override: Option, - profile_sandbox_mode: Option, ) -> Option { if sandbox_mode_override.is_some() { return Some(PermissionConfigSyntax::Legacy); @@ -1835,10 +2117,6 @@ fn resolve_permission_config_syntax( return Some(PermissionConfigSyntax::Profiles); } - if profile_sandbox_mode.is_some() { - return Some(PermissionConfigSyntax::Legacy); - } - let mut selection = None; for layer in config_layer_stack.get_layers( ConfigLayerStackOrdering::LowestPrecedenceFirst, @@ -1877,7 +2155,7 @@ fn apply_managed_filesystem_constraints( path: codex_protocol::permissions::FileSystemPath::GlobPattern { pattern: deny_read.as_str().to_string(), }, - access: codex_protocol::permissions::FileSystemAccessMode::None, + access: codex_protocol::permissions::FileSystemAccessMode::Deny, } } else { let Ok(path) = AbsolutePathBuf::try_from(deny_read.as_str()) else { @@ -1885,7 +2163,7 @@ fn apply_managed_filesystem_constraints( }; codex_protocol::permissions::FileSystemSandboxEntry { path: codex_protocol::permissions::FileSystemPath::Path { path }, - access: codex_protocol::permissions::FileSystemAccessMode::None, + access: codex_protocol::permissions::FileSystemAccessMode::Deny, } }; if !file_system_sandbox_policy @@ -1911,11 +2189,10 @@ pub struct ConfigOverrides { pub default_permissions: Option, pub model_provider: Option, pub service_tier: Option>, - pub config_profile: Option, pub codex_self_exe: Option, pub codex_linux_sandbox_exe: Option, pub main_execve_wrapper_exe: Option, - pub zsh_path: Option, + pub default_zsh_path: Option, pub base_instructions: Option, pub developer_instructions: Option, pub personality: Option, @@ -1926,43 +2203,33 @@ pub struct ConfigOverrides { pub bypass_hook_trust: Option, /// Additional directories that should be treated as writable roots for this session. pub additional_writable_roots: Vec, + /// Explicit runtime workspace roots for this session. When set, this is + /// the full runtime root list rather than an additive override. + pub workspace_roots: Option>, } -/// Resolves the OSS provider from CLI override, profile config, or global config. +fn dedupe_absolute_paths(paths: &mut Vec) { + let mut seen = HashSet::new(); + paths.retain(|path| seen.insert(path.clone())); +} + +/// Resolves the OSS provider from CLI override or global config. /// Returns `None` if no provider is configured at any level. pub fn resolve_oss_provider( explicit_provider: Option<&str>, config_toml: &ConfigToml, - config_profile: Option, ) -> Option { if let Some(provider) = explicit_provider { // Explicit provider specified (e.g., via --local-provider) Some(provider.to_string()) } else { - // Check profile config first, then global config - let profile = config_toml.get_config_profile(config_profile).ok(); - if let Some(profile) = &profile { - // Check if profile has an oss provider - if let Some(profile_oss_provider) = &profile.oss_provider { - Some(profile_oss_provider.clone()) - } - // If not then check if the toml has an oss provider - else { - config_toml.oss_provider.clone() - } - } else { - config_toml.oss_provider.clone() - } + config_toml.oss_provider.clone() } } /// Resolve the web search mode from explicit config and feature flags. -fn resolve_web_search_mode( - config_toml: &ConfigToml, - config_profile: &ConfigProfile, - features: &Features, -) -> Option { - if let Some(mode) = config_profile.web_search.or(config_toml.web_search) { +fn resolve_web_search_mode(config_toml: &ConfigToml, features: &Features) -> Option { + if let Some(mode) = config_toml.web_search { return Some(mode); } if features.enabled(Feature::WebSearchCached) { @@ -1974,77 +2241,63 @@ fn resolve_web_search_mode( None } -fn resolve_web_search_config( - config_toml: &ConfigToml, - config_profile: &ConfigProfile, -) -> Option { - let base = config_toml +fn resolve_web_search_config(config_toml: &ConfigToml) -> Option { + config_toml .tools .as_ref() - .and_then(|tools| tools.web_search.as_ref()); - let profile = config_profile - .tools - .as_ref() - .and_then(|tools| tools.web_search.as_ref()); - - match (base, profile) { - (None, None) => None, - (Some(base), None) => Some(base.clone().into()), - (None, Some(profile)) => Some(profile.clone().into()), - (Some(base), Some(profile)) => Some(base.merge(profile).into()), - } + .and_then(|tools| tools.web_search.as_ref()) + .cloned() + .map(Into::into) } -fn resolve_multi_agent_v2_config( - config_toml: &ConfigToml, - config_profile: &ConfigProfile, -) -> MultiAgentV2Config { +fn resolve_experimental_request_user_input_enabled(config_toml: &ConfigToml) -> bool { + config_toml + .tools + .as_ref() + .and_then(|tools| tools.experimental_request_user_input.as_ref()) + .is_none_or(|config| config.enabled) +} + +fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config { let base = multi_agent_v2_toml_config(config_toml.features.as_ref()); - let profile = multi_agent_v2_toml_config(config_profile.features.as_ref()); let default = MultiAgentV2Config::default(); - let max_concurrent_threads_per_session = profile + let max_concurrent_threads_per_session = base .and_then(|config| config.max_concurrent_threads_per_session) - .or_else(|| base.and_then(|config| config.max_concurrent_threads_per_session)) .unwrap_or(default.max_concurrent_threads_per_session); - let min_wait_timeout_ms = profile + let min_wait_timeout_ms = base .and_then(|config| config.min_wait_timeout_ms) - .or_else(|| base.and_then(|config| config.min_wait_timeout_ms)) .unwrap_or(default.min_wait_timeout_ms); - let max_wait_timeout_ms = profile + let max_wait_timeout_ms = base .and_then(|config| config.max_wait_timeout_ms) - .or_else(|| base.and_then(|config| config.max_wait_timeout_ms)) .unwrap_or(default.max_wait_timeout_ms); - let default_wait_timeout_ms = profile + let default_wait_timeout_ms = base .and_then(|config| config.default_wait_timeout_ms) - .or_else(|| base.and_then(|config| config.default_wait_timeout_ms)) .unwrap_or(default.default_wait_timeout_ms); - let usage_hint_enabled = profile + let usage_hint_enabled = base .and_then(|config| config.usage_hint_enabled) - .or_else(|| base.and_then(|config| config.usage_hint_enabled)) .unwrap_or(default.usage_hint_enabled); - let usage_hint_text = profile + let usage_hint_text = base .and_then(|config| config.usage_hint_text.as_ref()) - .or_else(|| base.and_then(|config| config.usage_hint_text.as_ref())) .cloned() .or(default.usage_hint_text); - let root_agent_usage_hint_text = profile - .and_then(|config| config.root_agent_usage_hint_text.as_ref()) - .or_else(|| base.and_then(|config| config.root_agent_usage_hint_text.as_ref())) + let root_agent_usage_hint_text = resolve_optional_prompt_text( + base.map(|config| &config.root_agent_usage_hint_text), + default.root_agent_usage_hint_text, + ); + let subagent_usage_hint_text = resolve_optional_prompt_text( + base.map(|config| &config.subagent_usage_hint_text), + default.subagent_usage_hint_text, + ); + let tool_namespace = base + .and_then(|config| config.tool_namespace.as_ref()) .cloned() - .or(default.root_agent_usage_hint_text); - let subagent_usage_hint_text = profile - .and_then(|config| config.subagent_usage_hint_text.as_ref()) - .or_else(|| base.and_then(|config| config.subagent_usage_hint_text.as_ref())) - .cloned() - .or(default.subagent_usage_hint_text); - let hide_spawn_agent_metadata = profile + .or(default.tool_namespace); + let hide_spawn_agent_metadata = base .and_then(|config| config.hide_spawn_agent_metadata) - .or_else(|| base.and_then(|config| config.hide_spawn_agent_metadata)) .unwrap_or(default.hide_spawn_agent_metadata); - let non_code_mode_only = profile + let non_code_mode_only = base .and_then(|config| config.non_code_mode_only) - .or_else(|| base.and_then(|config| config.non_code_mode_only)) .unwrap_or(default.non_code_mode_only); MultiAgentV2Config { @@ -2056,6 +2309,7 @@ fn resolve_multi_agent_v2_config( usage_hint_text, root_agent_usage_hint_text, subagent_usage_hint_text, + tool_namespace, hide_spawn_agent_metadata, non_code_mode_only, } @@ -2075,6 +2329,17 @@ fn resolve_terminal_resize_reflow_config(config_toml: &ConfigToml) -> TerminalRe } } +fn resolve_optional_prompt_text( + configured: Option<&Option>, + default: Option, +) -> Option { + match configured { + Some(Some(value)) if value.is_empty() => None, + Some(Some(value)) => Some(value.clone()), + Some(None) | None => default, + } +} + fn multi_agent_v2_toml_config(features: Option<&FeaturesToml>) -> Option<&MultiAgentV2ConfigToml> { match features?.multi_agent_v2.as_ref()? { FeatureToml::Enabled(_) => None, @@ -2150,6 +2415,69 @@ fn validate_multi_agent_v2_wait_timeout(label: &str, value: i64) -> std::io::Res Ok(()) } +fn validate_multi_agent_v2_tool_namespace(namespace: Option<&str>) -> std::io::Result<()> { + const LABEL: &str = "features.multi_agent_v2.tool_namespace"; + const MAX_LEN: usize = 64; + const RESERVED_RESPONSES_NAMESPACES: &[&str] = &[ + "api_tool", + "browser", + "computer", + "container", + "file_search", + "functions", + "image_gen", + "multi_tool_use", + "python", + "python_user_visible", + "submodel_delegator", + "terminal", + "tool_search", + "web", + ]; + + let Some(namespace) = namespace else { + return Ok(()); + }; + if namespace.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{LABEL} must not be empty"), + )); + } + if namespace.trim() != namespace { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{LABEL} must not have leading or trailing whitespace"), + )); + } + if !namespace + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{LABEL} must match ^[a-zA-Z0-9_-]+$"), + )); + } + if namespace.chars().count() > MAX_LEN { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{LABEL} must be at most {MAX_LEN} characters"), + )); + } + if namespace == "mcp" + || namespace.starts_with("mcp__") + || RESERVED_RESPONSES_NAMESPACES.contains(&namespace) + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("{LABEL} uses a reserved namespace: {namespace}"), + )); + } + + Ok(()) +} + impl Config { #[cfg(test)] async fn load_from_base_config_with_overrides( @@ -2193,8 +2521,11 @@ impl Config { approval_policy: mut constrained_approval_policy, approvals_reviewer: mut constrained_approvals_reviewer, permission_profile: mut constrained_permission_profile, + windows_sandbox_mode: mut constrained_windows_sandbox_mode, web_search_mode: mut constrained_web_search_mode, allow_managed_hooks_only: _, + allow_appshots: _, + computer_use: _, feature_requirements, managed_hooks: _, mcp_servers, @@ -2206,12 +2537,17 @@ impl Config { guardian_policy_config_source: _, } = config_layer_stack.requirements().clone(); - let user_instructions = AgentsMdManager::load_global_instructions(Some(&codex_home)) - .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 { @@ -2225,11 +2561,10 @@ impl Config { default_permissions: default_permissions_override, model_provider, service_tier: service_tier_override, - config_profile: config_profile_key, codex_self_exe, codex_linux_sandbox_exe, main_execve_wrapper_exe, - zsh_path: zsh_path_override, + default_zsh_path, base_instructions, developer_instructions, personality, @@ -2239,6 +2574,7 @@ impl Config { ephemeral, bypass_hook_trust, additional_writable_roots, + workspace_roots: workspace_roots_override, } = overrides; let bypass_hook_trust = bypass_hook_trust.unwrap_or_default(); @@ -2267,24 +2603,15 @@ impl Config { "`permission_profile` and `default_permissions` overrides cannot both be set", )); } + if let Some(profile) = cfg.profile.as_deref() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!( + "legacy `profile = \"{profile}\"` config is no longer supported; use `--profile {profile}` with `{profile}.config.toml` instead" + ), + )); + } - let active_profile_name = config_profile_key - .as_ref() - .or(cfg.profile.as_ref()) - .cloned(); - let config_profile = match active_profile_name.as_ref() { - Some(key) => cfg - .profiles - .get(key) - .ok_or_else(|| { - std::io::Error::new( - std::io::ErrorKind::NotFound, - format!("config profile `{key}` not found"), - ) - })? - .clone(), - None => ConfigProfile::default(), - }; let tool_suggest = resolve_tool_suggest_config(&cfg, &config_layer_stack); let feature_overrides = FeatureOverrides { web_search_request: override_tools_web_search_request, @@ -2296,9 +2623,7 @@ impl Config { experimental_use_unified_exec_tool: cfg.experimental_use_unified_exec_tool, }, FeatureConfigSource { - features: config_profile.features.as_ref(), - experimental_use_unified_exec_tool: config_profile - .experimental_use_unified_exec_tool, + ..Default::default() }, feature_overrides, ); @@ -2308,9 +2633,29 @@ impl Config { &mut startup_warnings, )?; let enable_network_proxy = features.enabled(Feature::NetworkProxy); - let windows_sandbox_mode = resolve_windows_sandbox_mode(&cfg, &config_profile); - let windows_sandbox_private_desktop = - resolve_windows_sandbox_private_desktop(&cfg, &config_profile); + let configured_windows_sandbox_mode = resolve_windows_sandbox_mode(&cfg); + // Keep the configured mode separate so a requirement-constrained mode + // does not look like it was explicitly selected in config. + let selected_windows_sandbox_mode = configured_windows_sandbox_mode.or_else(|| { + match WindowsSandboxLevel::from_features(&features) { + WindowsSandboxLevel::Elevated => Some(WindowsSandboxModeToml::Elevated), + WindowsSandboxLevel::RestrictedToken => Some(WindowsSandboxModeToml::Unelevated), + WindowsSandboxLevel::Disabled => None, + } + }); + apply_requirement_constrained_value( + "windows.sandbox", + selected_windows_sandbox_mode, + &mut constrained_windows_sandbox_mode, + &mut startup_warnings, + )?; + let effective_windows_sandbox_mode = *constrained_windows_sandbox_mode.get(); + let windows_sandbox_mode = if constrained_windows_sandbox_mode.source.is_some() { + effective_windows_sandbox_mode + } else { + configured_windows_sandbox_mode + }; + let windows_sandbox_private_desktop = resolve_windows_sandbox_private_desktop(&cfg); let resolved_cwd = AbsolutePathBuf::try_from(normalize_for_native_workdir({ use std::env; @@ -2329,11 +2674,10 @@ impl Config { } } }))?; - let mut additional_writable_roots: Vec = additional_writable_roots + let requested_additional_writable_roots: Vec = additional_writable_roots .into_iter() .map(|path| AbsolutePathBuf::resolve_path_against_base(path, resolved_cwd.as_path())) .collect(); - let requested_additional_writable_roots = additional_writable_roots.clone(); let repo_root = resolve_root_git_project_for_trust(fs, &resolved_cwd).await; let active_project = cfg .get_active_project( @@ -2345,22 +2689,22 @@ impl Config { &config_layer_stack, &cfg, sandbox_mode, - config_profile.sandbox_mode, ); - let has_permission_profiles = cfg - .permissions - .as_ref() - .is_some_and(|profiles| !profiles.is_empty()); - let default_permissions = default_permissions_override - .as_deref() - .or(cfg.default_permissions.as_deref()); - validate_user_permission_profile_names(cfg.permissions.as_ref())?; - if has_permission_profiles + let requirements_toml = config_layer_stack.requirements_toml(); + let effective_permission_selection = resolve_effective_permission_selection( + cfg.permissions.as_ref(), + default_permissions_override.as_deref(), + cfg.default_permissions.as_deref(), + requirements_toml, + &mut startup_warnings, + )?; + if effective_permission_selection.has_profiles() && !matches!( permission_config_syntax, Some(PermissionConfigSyntax::Legacy) ) - && default_permissions.is_none() + && effective_permission_selection.selected_profile_id.is_none() + && !effective_permission_selection.requirements_force_profile_selection { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, @@ -2368,35 +2712,82 @@ impl Config { )); } - let windows_sandbox_level = match windows_sandbox_mode { + let windows_sandbox_level = match effective_windows_sandbox_mode { Some(WindowsSandboxModeToml::Elevated) => WindowsSandboxLevel::Elevated, Some(WindowsSandboxModeToml::Unelevated) => WindowsSandboxLevel::RestrictedToken, - None => WindowsSandboxLevel::from_features(&features), + None => WindowsSandboxLevel::Disabled, }; + let memories_config: MemoriesConfig = cfg.memories.clone().unwrap_or_default().into(); let memories_root = memory_root(&codex_home); - std::fs::create_dir_all(&memories_root)?; - if !additional_writable_roots - .iter() - .any(|existing| existing == &memories_root) - { - additional_writable_roots.push(memories_root); - } - let profiles_are_active = default_permissions_override.is_some() + let profiles_are_active = effective_permission_selection.profiles_are_active( + default_permissions_override.as_deref(), + permission_config_syntax, + ); + let explicit_permission_profile_mode = default_permissions_override.is_some() || matches!( permission_config_syntax, Some(PermissionConfigSyntax::Profiles) - ) - || permission_config_syntax.is_none(); - let using_implicit_builtin_profile = - permission_config_syntax.is_none() && default_permissions.is_none(); + ); + let custom_permission_profiles = cfg + .permissions + .as_ref() + .map_or_else(Vec::new, |permissions| { + permissions + .entries + .iter() + .map(|(id, profile)| CustomPermissionProfileSummary { + id: id.clone(), + description: profile.description.clone(), + }) + .collect() + }); + let using_implicit_builtin_profile = permission_config_syntax.is_none() + && effective_permission_selection.selected_profile_id.is_none(); + let should_seed_legacy_workspace_roots = effective_permission_selection + .selected_profile_id + .is_none() + && matches!( + permission_config_syntax, + None | Some(PermissionConfigSyntax::Legacy) + ); + let legacy_workspace_roots_explicit = should_seed_legacy_workspace_roots + && cfg + .sandbox_workspace_write + .as_ref() + .is_some_and(|sandbox_workspace_write| { + !sandbox_workspace_write.writable_roots.is_empty() + }); + let workspace_roots_explicit = workspace_roots_override.is_some() + || !requested_additional_writable_roots.is_empty() + || legacy_workspace_roots_explicit; + let mut workspace_roots = match workspace_roots_override { + Some(workspace_roots) => workspace_roots + .into_iter() + .map(|path| { + AbsolutePathBuf::resolve_path_against_base(path, resolved_cwd.as_path()) + }) + .collect(), + None => { + let mut workspace_roots = vec![resolved_cwd.clone()]; + workspace_roots.extend(requested_additional_writable_roots.clone()); + if should_seed_legacy_workspace_roots + && let Some(sandbox_workspace_write) = cfg.sandbox_workspace_write.as_ref() + { + workspace_roots.extend(sandbox_workspace_write.writable_roots.clone()); + } + workspace_roots + } + }; + dedupe_absolute_paths(&mut workspace_roots); let ( mut configured_network_proxy_config, permission_profile, file_system_sandbox_policy, mut active_permission_profile, - ) = if let Some(mut permission_profile) = permission_profile { - let (mut file_system_sandbox_policy, network_sandbox_policy) = + mut profile_workspace_roots, + ) = if let Some(permission_profile) = permission_profile { + let (file_system_sandbox_policy, _network_sandbox_policy) = permission_profile.to_runtime_permissions(); let configured_network_proxy_config = if profile_allows_configured_network_proxy(&permission_profile) @@ -2405,65 +2796,66 @@ impl Config { // PermissionProfile carries the active network sandbox bit, not the configured // proxy/allowlist policy. Keep that config so active profiles can round-trip // without broadening network behavior. - let default_permissions = default_permissions.unwrap_or_else(|| { - default_builtin_permission_profile_name( - &active_project, - windows_sandbox_level, - ) - }); + let default_permissions = effective_permission_selection + .selected_profile_id + .unwrap_or_else(|| { + default_builtin_permission_profile_name( + &active_project, + windows_sandbox_level, + ) + }); network_proxy_config_for_profile_selection( - cfg.permissions.as_ref(), + effective_permission_selection.profiles.as_ref(), default_permissions, )? } else { NetworkProxyConfig::default() }; - let sandbox_policy = compatibility_sandbox_policy_for_permission_profile( - &permission_profile, - &file_system_sandbox_policy, - network_sandbox_policy, - resolved_cwd.as_path(), - ); - if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { - file_system_sandbox_policy = file_system_sandbox_policy - .with_additional_writable_roots( - resolved_cwd.as_path(), - &additional_writable_roots, - ); - permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( - permission_profile.enforcement(), - &file_system_sandbox_policy, - network_sandbox_policy, - ); - } ( configured_network_proxy_config, permission_profile, file_system_sandbox_policy, None, + Vec::new(), ) } else if profiles_are_active { - let default_permissions = default_permissions.unwrap_or_else(|| { - default_builtin_permission_profile_name(&active_project, windows_sandbox_level) - }); + let default_permissions = effective_permission_selection + .selected_profile_id + .unwrap_or_else(|| { + default_builtin_permission_profile_name(&active_project, windows_sandbox_level) + }); let builtin_workspace_write_settings = if using_implicit_builtin_profile { cfg.sandbox_workspace_write.as_ref() } else { None }; let configured_network_proxy_config = network_proxy_config_for_profile_selection( - cfg.permissions.as_ref(), + effective_permission_selection.profiles.as_ref(), default_permissions, )?; let (mut file_system_sandbox_policy, network_sandbox_policy) = compile_permission_profile_selection( - cfg.permissions.as_ref(), + effective_permission_selection.profiles.as_ref(), default_permissions, builtin_workspace_write_settings, resolved_cwd.as_path(), &mut startup_warnings, )?; - let mut permission_profile = if let Some(permission_profile) = + let mut configured_workspace_roots = compile_permission_profile_workspace_roots( + effective_permission_selection.profiles.as_ref(), + default_permissions, + resolved_cwd.as_path(), + )?; + if using_implicit_builtin_profile + && default_permissions == BUILT_IN_WORKSPACE_PROFILE + && let Some(sandbox_workspace_write) = cfg.sandbox_workspace_write.as_ref() + { + configured_workspace_roots.extend(sandbox_workspace_write.writable_roots.clone()); + } + dedupe_absolute_paths(&mut configured_workspace_roots); + file_system_sandbox_policy = file_system_sandbox_policy + .with_materialized_project_roots_for_workspace_roots(&configured_workspace_roots); + let permission_profile = if let Some(permission_profile) = builtin_permission_profile(default_permissions, builtin_workspace_write_settings) { permission_profile @@ -2473,40 +2865,6 @@ impl Config { network_sandbox_policy, ) }; - let sandbox_policy = compatibility_sandbox_policy_for_permission_profile( - &permission_profile, - &file_system_sandbox_policy, - network_sandbox_policy, - resolved_cwd.as_path(), - ); - if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { - file_system_sandbox_policy = if using_implicit_builtin_profile { - file_system_sandbox_policy - .with_additional_legacy_workspace_writable_roots( - &additional_writable_roots, - ) - } else { - file_system_sandbox_policy.with_additional_writable_roots( - resolved_cwd.as_path(), - &additional_writable_roots, - ) - }; - permission_profile = PermissionProfile::from_runtime_permissions( - &file_system_sandbox_policy, - network_sandbox_policy, - ); - } else if matches!(permission_profile, PermissionProfile::Managed { .. }) - && !requested_additional_writable_roots.is_empty() - { - file_system_sandbox_policy = file_system_sandbox_policy.with_additional_writable_roots( - resolved_cwd.as_path(), - &requested_additional_writable_roots, - ); - permission_profile = PermissionProfile::from_runtime_permissions( - &file_system_sandbox_policy, - network_sandbox_policy, - ); - } let active_permission_profile = if using_implicit_builtin_profile && default_permissions == BUILT_IN_WORKSPACE_PROFILE && cfg.sandbox_workspace_write.is_some() @@ -2518,28 +2876,22 @@ impl Config { // when doing so would lose roots, network, or tmp settings. None } else { - let active_permission_profile = if !requested_additional_writable_roots.is_empty() - && matches!(permission_profile, PermissionProfile::Managed { .. }) - { - ActivePermissionProfile::new(default_permissions).with_modifications( - requested_additional_writable_roots - .iter() - .cloned() - .map(|path| { - ActivePermissionProfileModification::AdditionalWritableRoot { path } - }) - .collect(), - ) - } else { - ActivePermissionProfile::new(default_permissions) - }; - Some(active_permission_profile) + let selected_profile_extends = cfg + .permissions + .as_ref() + .and_then(|permissions| permissions.entries.get(default_permissions)) + .and_then(|profile| profile.extends.clone()); + Some(ActivePermissionProfile { + id: default_permissions.to_string(), + extends: selected_profile_extends, + }) }; ( configured_network_proxy_config, permission_profile, file_system_sandbox_policy, active_permission_profile, + configured_workspace_roots, ) } else { let configured_network_proxy_config = NetworkProxyConfig::default(); @@ -2551,7 +2903,7 @@ impl Config { let mut permission_profile = cfg .derive_permission_profile( sandbox_mode, - config_profile.sandbox_mode, + /*profile_sandbox_mode*/ None, windows_sandbox_level, Some(&active_project), Some(&constrained_permission_profile), @@ -2570,38 +2922,14 @@ impl Config { ); permission_profile = PermissionProfile::read_only(); } - let (mut file_system_sandbox_policy, network_sandbox_policy) = + let (file_system_sandbox_policy, _network_sandbox_policy) = permission_profile.to_runtime_permissions(); - // `additional_writable_roots` is a legacy workspace-write knob. It - // only applies when the derived managed profile has workspace-style - // write access to the project roots; read-only, disabled, external, - // and future non-workspace profiles must not silently grow extra - // write access. - if matches!(permission_profile.enforcement(), SandboxEnforcement::Managed) - && file_system_sandbox_policy.can_write_path_with_cwd( - resolved_cwd.as_path(), - resolved_cwd.as_path(), - ) - && !file_system_sandbox_policy.has_full_disk_write_access() - { - // Keep legacy behavior for extra writable roots while storing - // the result as the canonical permission profile. Explicit - // extra roots are concrete paths, so their metadata carveouts - // are also concrete rather than symbolic `:workspace_roots` - // entries. - file_system_sandbox_policy = file_system_sandbox_policy - .with_additional_legacy_workspace_writable_roots(&additional_writable_roots); - permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( - permission_profile.enforcement(), - &file_system_sandbox_policy, - network_sandbox_policy, - ); - } ( configured_network_proxy_config, permission_profile, file_system_sandbox_policy, None, + Vec::new(), ) }; if enable_network_proxy && permission_profile.network_sandbox_policy().is_enabled() { @@ -2611,20 +2939,11 @@ impl Config { network_proxy, ); } - if let Some(network_proxy) = network_proxy_toml_config(config_profile.features.as_ref()) - { - apply_network_proxy_feature_config( - &mut configured_network_proxy_config, - network_proxy, - ); - } configured_network_proxy_config.network.enabled = true; } - let approval_policy_was_explicit = approval_policy_override.is_some() - || config_profile.approval_policy.is_some() - || cfg.approval_policy.is_some(); + let approval_policy_was_explicit = + approval_policy_override.is_some() || cfg.approval_policy.is_some(); let mut approval_policy = approval_policy_override - .or(config_profile.approval_policy) .or(cfg.approval_policy) .unwrap_or_else(|| { if active_project.is_trusted() { @@ -2644,11 +2963,9 @@ impl Config { ); approval_policy = constrained_approval_policy.value(); } - let approvals_reviewer_was_explicit = approvals_reviewer_override.is_some() - || config_profile.approvals_reviewer.is_some() - || cfg.approvals_reviewer.is_some(); + let approvals_reviewer_was_explicit = + approvals_reviewer_override.is_some() || cfg.approvals_reviewer.is_some(); let mut approvals_reviewer = approvals_reviewer_override - .or(config_profile.approvals_reviewer) .or(cfg.approvals_reviewer) .unwrap_or(ApprovalsReviewer::User); if !approvals_reviewer_was_explicit @@ -2660,16 +2977,15 @@ impl Config { ); approvals_reviewer = constrained_approvals_reviewer.value(); } - let web_search_mode = resolve_web_search_mode(&cfg, &config_profile, &features) - .unwrap_or(WebSearchMode::Cached); - let web_search_config = resolve_web_search_config(&cfg, &config_profile); - let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg, &config_profile); + let web_search_mode = + resolve_web_search_mode(&cfg, &features).unwrap_or(WebSearchMode::Cached); + let web_search_config = resolve_web_search_config(&cfg); + let experimental_request_user_input_enabled = + resolve_experimental_request_user_input_enabled(&cfg); + let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg); let apps_mcp_path_override = if features.enabled(Feature::AppsMcpPathOverride) { let base = apps_mcp_path_override_toml_config(cfg.features.as_ref()); - let profile = apps_mcp_path_override_toml_config(config_profile.features.as_ref()); - profile - .and_then(|config| config.path.as_ref()) - .or_else(|| base.and_then(|config| config.path.as_ref())) + base.and_then(|config| config.path.as_ref()) .cloned() .or_else(|| Some("/ps/mcp".to_string())) } else { @@ -2691,7 +3007,6 @@ impl Config { .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidData, message))?; let model_provider_id = model_provider - .or(config_profile.model_provider) .or(cfg.model_provider) .unwrap_or_else(|| "openai".to_string()); let model_provider = model_providers @@ -2747,6 +3062,7 @@ impl Config { "features.multi_agent_v2.default_wait_timeout_ms must be at most features.multi_agent_v2.max_wait_timeout_ms", )); } + validate_multi_agent_v2_tool_namespace(multi_agent_v2.tool_namespace.as_deref())?; let agent_max_threads_from_config = cfg.agents.as_ref().and_then(|agents| agents.max_threads); let agent_max_threads = if features.enabled(Feature::MultiAgentV2) { if agent_max_threads_from_config.is_some() { @@ -2852,17 +3168,12 @@ impl Config { let forced_login_method = cfg.forced_login_method; - let model = model.or(config_profile.model).or(cfg.model); - let mut notices = cfg.notice.unwrap_or_default(); + let model = model.or(cfg.model); + let notices = cfg.notice.unwrap_or_default(); let service_tier = match service_tier_override { Some(Some(service_tier)) => Some(service_tier), - Some(None) => { - // Preserve explicit standard/clear intent after the nested override - // collapses into `Config.service_tier = None`. - notices.fast_default_opt_out = Some(true); - None - } - None => config_profile.service_tier.or(cfg.service_tier), + Some(None) => Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()), + None => cfg.service_tier, }; let service_tier = service_tier.and_then(|service_tier| { match ServiceTier::from_request_value(&service_tier) { @@ -2886,10 +3197,7 @@ impl Config { // Load base instructions override from a file if specified. If the // path is relative, resolve it against the effective cwd so the // behaviour matches other path-like config values. - let model_instructions_path = config_profile - .model_instructions_file - .as_ref() - .or(cfg.model_instructions_file.as_ref()); + let model_instructions_path = cfg.model_instructions_file.as_ref(); let file_base_instructions = Self::try_read_non_empty_file( fs, model_instructions_path, @@ -2900,27 +3208,16 @@ impl Config { .or(file_base_instructions) .or(cfg.instructions.clone()); let developer_instructions = developer_instructions.or(cfg.developer_instructions); - let include_permissions_instructions = config_profile - .include_permissions_instructions - .or(cfg.include_permissions_instructions) - .unwrap_or(true); - let include_apps_instructions = config_profile - .include_apps_instructions - .or(cfg.include_apps_instructions) - .unwrap_or(true); - let include_collaboration_mode_instructions = config_profile - .include_collaboration_mode_instructions - .or(cfg.include_collaboration_mode_instructions) - .unwrap_or(true); + let include_permissions_instructions = cfg.include_permissions_instructions.unwrap_or(true); + let include_apps_instructions = cfg.include_apps_instructions.unwrap_or(true); + let include_collaboration_mode_instructions = + cfg.include_collaboration_mode_instructions.unwrap_or(true); let include_skill_instructions = cfg .skills .as_ref() .and_then(|skills| skills.include_instructions) .unwrap_or(true); - let include_environment_context = config_profile - .include_environment_context - .or(cfg.include_environment_context) - .unwrap_or(true); + let include_environment_context = cfg.include_environment_context.unwrap_or(true); let guardian_policy_config = guardian_policy_config_from_requirements(config_layer_stack.requirements_toml()) .or_else(|| { @@ -2931,7 +3228,6 @@ impl Config { )) }); let personality = personality - .or(config_profile.personality) .or(cfg.personality) .or_else(|| { features @@ -2939,10 +3235,7 @@ impl Config { .then_some(Personality::Pragmatic) }); - let experimental_compact_prompt_path = config_profile - .experimental_compact_prompt_file - .as_ref() - .or(cfg.experimental_compact_prompt_file.as_ref()); + let experimental_compact_prompt_path = cfg.experimental_compact_prompt_file.as_ref(); let file_compact_prompt = Self::try_read_non_empty_file( fs, experimental_compact_prompt_path, @@ -2950,19 +3243,14 @@ impl Config { ) .await?; let compact_prompt = compact_prompt.or(file_compact_prompt); - let zsh_path = zsh_path_override - .or(config_profile.zsh_path.map(Into::into)) - .or(cfg.zsh_path.map(Into::into)); + let zsh_path = default_zsh_path + .or_else(|| InstallContext::current().bundled_zsh_path()) + .map(AbsolutePathBuf::into_path_buf); let review_model = override_review_model.or(cfg.review_model); let check_for_update_on_startup = cfg.check_for_update_on_startup.unwrap_or(true); - let model_catalog = load_model_catalog( - config_profile - .model_catalog_json - .clone() - .or(cfg.model_catalog_json.clone()), - )?; + let model_catalog = load_model_catalog(cfg.model_catalog_json.clone())?; let log_dir = cfg .log_dir @@ -3021,10 +3309,22 @@ impl Config { &mut constrained_permission_profile, &mut startup_warnings, )?; + if permission_profile_was_constrained + && sandbox_mode_requirement_for_permission_profile(&original_permission_profile) + == SandboxModeRequirement::DangerFullAccess + && constrained_permission_profile.get() == &PermissionProfile::read_only() + && constrained_approval_policy.value() == AskForApproval::Never + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "`approval_policy = \"never\"` cannot be used because requirements do not allow `sandbox_mode = \"danger-full-access\"`; Codex would fall back to read-only permissions with approvals disabled. Choose an `approval_policy` based on what you need, such as `on-request`, or choose an allowed sandbox mode.", + )); + } if permission_profile_was_constrained { // The selected profile no longer describes the effective // permissions after requirements forced a fallback. active_permission_profile = None; + profile_workspace_roots.clear(); } apply_requirement_constrained_value( "web_search_mode", @@ -3036,37 +3336,20 @@ impl Config { let mcp_servers = constrain_mcp_servers(cfg.mcp_servers.clone(), mcp_servers.as_ref()) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{e}")))?; - let (network_requirements, network_requirements_source) = match network_requirements { - Some(Sourced { value, source }) => (Some(value), Some(source)), - None => (None, None), - }; - let has_network_requirements = network_requirements.is_some(); let network_permission_profile = constrained_permission_profile.get().clone(); - let network = NetworkProxySpec::from_config_and_constraints( + let network = build_network_proxy_spec( configured_network_proxy_config, network_requirements, &network_permission_profile, - ) - .map_err(|err| { - if let Some(source) = network_requirements_source.as_ref() { - std::io::Error::new( - err.kind(), - format!("failed to build managed network proxy from {source}: {err}"), - ) - } else { - err - } - })?; - let network = if has_network_requirements { - Some(network) - } else { - network.enabled().then_some(network) - }; - let helper_readable_roots = get_readable_roots_required_for_codex_runtime( + )?; + let mut helper_readable_roots = get_readable_roots_required_for_codex_runtime( &codex_home, zsh_path.as_ref(), main_execve_wrapper_exe.as_ref(), ); + if features.enabled(Feature::MemoryTool) && memories_config.use_memories { + helper_readable_roots.push(memories_root); + } let effective_permission_profile = constrained_permission_profile.value.get().clone(); let (mut effective_file_system_sandbox_policy, effective_network_sandbox_policy) = effective_permission_profile.to_runtime_permissions(); @@ -3095,6 +3378,12 @@ impl Config { .value .set(effective_permission_profile) .map_err(std::io::Error::from)?; + let permission_profile_state = PermissionProfileState::from_constrained_active_profile( + constrained_permission_profile.value, + active_permission_profile, + profile_workspace_roots, + ) + .map_err(std::io::Error::from)?; let otel = otel::resolve_config(cfg.otel.unwrap_or_default(), &mut startup_warnings); let config = Self { model, @@ -3102,20 +3391,27 @@ impl Config { review_model, model_context_window: cfg.model_context_window, model_auto_compact_token_limit: cfg.model_auto_compact_token_limit, + model_auto_compact_token_limit_scope: cfg + .model_auto_compact_token_limit_scope + .unwrap_or_default(), model_provider_id, model_provider, cwd: resolved_cwd, + workspace_roots: workspace_roots.clone(), + workspace_roots_explicit, startup_warnings, permissions: Permissions { approval_policy: constrained_approval_policy.value, - permission_profile: constrained_permission_profile.value, - active_permission_profile, + permission_profile_state, + workspace_roots, network, allow_login_shell, shell_environment_policy, windows_sandbox_mode, windows_sandbox_private_desktop, }, + explicit_permission_profile_mode, + custom_permission_profiles, approvals_reviewer: constrained_approvals_reviewer.value(), enforce_residency: enforce_residency.value, notify: cfg.notify, @@ -3163,7 +3459,7 @@ impl Config { agent_max_threads, agent_max_depth, agent_roles, - memories: cfg.memories.unwrap_or_default().into(), + memories: memories_config, agent_job_max_runtime_seconds, agent_interrupt_message_enabled, codex_home, @@ -3203,23 +3499,17 @@ impl Config { .or(show_raw_agent_reasoning) .unwrap_or(false), guardian_policy_config, - model_reasoning_effort: config_profile - .model_reasoning_effort - .or(cfg.model_reasoning_effort), - plan_mode_reasoning_effort: config_profile - .plan_mode_reasoning_effort - .or(cfg.plan_mode_reasoning_effort), - model_reasoning_summary: config_profile - .model_reasoning_summary - .or(cfg.model_reasoning_summary), + model_reasoning_effort: cfg.model_reasoning_effort, + plan_mode_reasoning_effort: cfg.plan_mode_reasoning_effort, + model_reasoning_summary: cfg.model_reasoning_summary, model_supports_reasoning_summaries: cfg.model_supports_reasoning_summaries, model_catalog, - model_verbosity: config_profile.model_verbosity.or(cfg.model_verbosity), - chatgpt_base_url: config_profile + model_verbosity: cfg.model_verbosity, + chatgpt_base_url: cfg .chatgpt_base_url - .or(cfg.chatgpt_base_url) .unwrap_or("https://chatgpt.com/backend-api/".to_string()), apps_mcp_path_override, + apps_mcp_product_sku: cfg.apps_mcp_product_sku.clone(), realtime_audio: cfg .audio .map_or_else(RealtimeAudioConfig::default, |audio| RealtimeAudioConfig { @@ -3248,6 +3538,7 @@ impl Config { forced_login_method, web_search_mode: constrained_web_search_mode.value, web_search_config, + experimental_request_user_input_enabled, use_experimental_unified_exec_tool, background_terminal_max_timeout, ghost_snapshot, @@ -3256,16 +3547,11 @@ impl Config { suppress_unstable_features_warning: cfg .suppress_unstable_features_warning .unwrap_or(false), - active_profile: active_profile_name, active_project, notices, check_for_update_on_startup, disable_paste_burst: cfg.disable_paste_burst.unwrap_or(false), - analytics_enabled: config_profile - .analytics - .as_ref() - .and_then(|a| a.enabled) - .or(cfg.analytics.as_ref().and_then(|a| a.enabled)), + analytics_enabled: cfg.analytics.as_ref().and_then(|a| a.enabled), feedback_enabled: cfg .feedback .as_ref() @@ -3313,11 +3599,10 @@ impl Config { .as_ref() .map(|t| t.pet_anchor) .unwrap_or_default(), - tui_session_picker_view: config_profile + tui_session_picker_view: cfg .tui .as_ref() .and_then(|t| t.session_picker_view) - .or_else(|| cfg.tui.as_ref().and_then(|t| t.session_picker_view)) .unwrap_or_default(), terminal_resize_reflow, tui_keymap: cfg @@ -3393,7 +3678,7 @@ impl Config { pub fn managed_network_requirements_enabled(&self) -> bool { !matches!( - self.permissions.permission_profile.get(), + self.permissions.permission_profile(), PermissionProfile::Disabled ) && self .config_layer_stack @@ -3402,24 +3687,184 @@ impl Config { .is_some() } + pub(crate) fn network_proxy_spec_for_active_permission_profile( + &self, + active_permission_profile: &ActivePermissionProfile, + permission_profile: &PermissionProfile, + ) -> std::io::Result> { + let profile_allows_network_proxy = + profile_allows_configured_network_proxy(permission_profile); + let configured_network_proxy_config = if profile_allows_network_proxy { + let cfg: ConfigToml = self + .config_layer_stack + .effective_config() + .try_into() + .map_err(|err| { + std::io::Error::new( + ErrorKind::InvalidInput, + format!( + "failed to read effective config for selected permission profile: {err}" + ), + ) + })?; + let mut configured_network_proxy_config = network_proxy_config_for_profile_selection( + cfg.permissions.as_ref(), + active_permission_profile.id.as_str(), + )?; + if self.features.enabled(Feature::NetworkProxy) + && permission_profile.network_sandbox_policy().is_enabled() + { + if let Some(network_proxy) = network_proxy_toml_config(cfg.features.as_ref()) { + apply_network_proxy_feature_config( + &mut configured_network_proxy_config, + network_proxy, + ); + } + configured_network_proxy_config.network.enabled = true; + } + configured_network_proxy_config + } else { + NetworkProxyConfig::default() + }; + + build_network_proxy_spec( + configured_network_proxy_config, + self.config_layer_stack.requirements().network.clone(), + permission_profile, + ) + } + pub fn bundled_skills_enabled(&self) -> bool { crate::manager::bundled_skills_enabled_from_stack(&self.config_layer_stack) } } -pub(crate) fn uses_deprecated_instructions_file(config_layer_stack: &ConfigLayerStack) -> bool { - config_layer_stack - .layers_high_to_low() - .into_iter() - .any(|layer| toml_uses_deprecated_instructions_file(&layer.config)) -} - fn guardian_policy_config_from_requirements( requirements_toml: &ConfigRequirementsToml, ) -> Option { normalize_guardian_policy_config(requirements_toml.guardian_policy_config.as_deref()) } +fn merge_managed_permission_profiles( + configured_permissions: Option<&PermissionsToml>, + requirements_toml: &ConfigRequirementsToml, +) -> std::io::Result> { + let managed_profiles = requirements_toml + .permissions + .as_ref() + .map(|permissions| &permissions.profiles) + .filter(|profiles| !profiles.is_empty()); + let Some(managed_profiles) = managed_profiles else { + return Ok(configured_permissions.cloned()); + }; + + let mut merged_permissions = configured_permissions.cloned().unwrap_or_default(); + for (profile_id, managed_profile) in managed_profiles { + if merged_permissions.entries.contains_key(profile_id) { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + format!( + "requirements.toml permissions profile `{profile_id}` conflicts with a config-defined profile of the same name" + ), + )); + } + merged_permissions + .entries + .insert(profile_id.clone(), managed_profile.clone()); + } + + Ok(Some(merged_permissions)) +} + +fn resolve_effective_permission_selection<'a>( + configured_permissions: Option<&PermissionsToml>, + default_permissions_override: Option<&'a str>, + configured_default_permissions: Option<&'a str>, + requirements_toml: &'a ConfigRequirementsToml, + startup_warnings: &mut Vec, +) -> std::io::Result> { + let profiles = merge_managed_permission_profiles(configured_permissions, requirements_toml)?; + validate_user_permission_profile_names(profiles.as_ref())?; + validate_required_permission_profile_catalog(requirements_toml, profiles.as_ref())?; + let selected_profile_id = resolve_default_permissions( + default_permissions_override, + configured_default_permissions, + requirements_toml, + startup_warnings, + )?; + + Ok(EffectivePermissionSelection { + profiles, + selected_profile_id, + requirements_force_profile_selection: requirements_toml.allowed_permissions.is_some(), + }) +} + +fn resolve_default_permissions<'a>( + default_permissions_override: Option<&'a str>, + configured_default_permissions: Option<&'a str>, + requirements_toml: &'a ConfigRequirementsToml, + startup_warnings: &mut Vec, +) -> std::io::Result> { + let allowed_permissions = requirements_toml.allowed_permissions.as_ref(); + let mut default_permissions = default_permissions_override.or(configured_default_permissions); + if let (Some(selected_permissions), Some(allowed_permissions)) = + (default_permissions, allowed_permissions) + && !is_builtin_permission_profile_name(selected_permissions) + && !allowed_permissions + .iter() + .any(|allowed_permission| allowed_permission == selected_permissions) + { + let Some(fallback_permissions) = allowed_permissions.first().map(String::as_str) else { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + "requirements.toml allowed_permissions must include at least one profile", + )); + }; + startup_warnings.push(format!( + "Configured value for `permission_profile` is disallowed by requirements; falling back from `{selected_permissions}` to required value `{fallback_permissions}`." + )); + default_permissions = Some(fallback_permissions); + } + + Ok(default_permissions) +} + +fn validate_required_permission_profile_catalog( + requirements_toml: &ConfigRequirementsToml, + available_permissions: Option<&PermissionsToml>, +) -> std::io::Result<()> { + let is_known_profile = |profile_id: &str| { + is_builtin_permission_profile_name(profile_id) + || available_permissions + .as_ref() + .is_some_and(|permissions| permissions.entries.contains_key(profile_id)) + }; + + let Some(allowed_permissions) = requirements_toml.allowed_permissions.as_ref() else { + return Ok(()); + }; + if allowed_permissions.is_empty() { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + "requirements.toml allowed_permissions must include at least one profile", + )); + } + + for profile_id in allowed_permissions { + if !is_known_profile(profile_id) { + return Err(std::io::Error::new( + ErrorKind::InvalidInput, + format!( + "requirements.toml allowed_permissions refers to undefined profile `{profile_id}`" + ), + )); + } + } + + Ok(()) +} + fn normalize_guardian_policy_config(value: Option<&str>) -> Option { value.and_then(|value| { let trimmed = value.trim(); @@ -3427,23 +3872,6 @@ fn normalize_guardian_policy_config(value: Option<&str>) -> Option { }) } -fn toml_uses_deprecated_instructions_file(value: &TomlValue) -> bool { - let Some(table) = value.as_table() else { - return false; - }; - if table.contains_key("experimental_instructions_file") { - return true; - } - let Some(profiles) = table.get("profiles").and_then(TomlValue::as_table) else { - return false; - }; - profiles.values().any(|profile| { - profile.as_table().is_some_and(|profile_table| { - profile_table.contains_key("experimental_instructions_file") - }) - }) -} - /// Returns the path to the Codex configuration directory, which can be /// specified by the `CODEX_HOME` environment variable. If not set, defaults to /// `~/.codex`. diff --git a/codex-rs/core/src/config/network_proxy_spec_tests.rs b/codex-rs/core/src/config/network_proxy_spec_tests.rs index 14b7c1c330..6dfb1e3a25 100644 --- a/codex-rs/core/src/config/network_proxy_spec_tests.rs +++ b/codex-rs/core/src/config/network_proxy_spec_tests.rs @@ -5,13 +5,8 @@ use codex_network_proxy::NetworkDomainPermission; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::NetworkSandboxPolicy; -use codex_protocol::protocol::SandboxPolicy; use pretty_assertions::assert_eq; -fn permission_profile_for_sandbox_policy(sandbox_policy: &SandboxPolicy) -> PermissionProfile { - PermissionProfile::from_legacy_sandbox_policy(sandbox_policy) -} - fn domain_permissions( entries: impl IntoIterator, ) -> NetworkDomainPermissionsToml { @@ -62,7 +57,7 @@ fn requirements_allowed_domains_are_a_baseline_for_user_allowlist() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_read_only_policy()), + &PermissionProfile::read_only(), ) .expect("config should stay within the managed allowlist"); @@ -97,7 +92,7 @@ fn requirements_allowed_domains_do_not_override_user_denies_for_same_pattern() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + &PermissionProfile::workspace_write(), ) .expect("managed allowlist should not erase a user deny"); @@ -129,7 +124,7 @@ fn requirements_allowlist_expansion_keeps_user_entries_mutable() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + &PermissionProfile::workspace_write(), ) .expect("managed baseline should still allow user edits"); @@ -207,7 +202,7 @@ fn danger_full_access_keeps_managed_allowlist_and_denylist_fixed() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess), + &PermissionProfile::Disabled, ) .expect("yolo mode should pin the effective policy to the managed baseline"); @@ -241,7 +236,7 @@ fn managed_allowed_domains_only_disables_default_mode_allowlist_expansion() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + &PermissionProfile::workspace_write(), ) .expect("managed baseline should still load"); @@ -270,7 +265,7 @@ fn managed_allowed_domains_only_ignores_user_allowlist_and_hard_denies_misses() let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + &PermissionProfile::workspace_write(), ) .expect("managed-only allowlist should still load"); @@ -300,7 +295,7 @@ fn managed_allowed_domains_only_without_managed_allowlist_blocks_all_user_domain let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + &PermissionProfile::workspace_write(), ) .expect("managed-only mode should treat missing managed allowlist as empty"); @@ -324,7 +319,7 @@ fn managed_allowed_domains_only_blocks_all_user_domains_in_full_access_without_m let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess), + &PermissionProfile::Disabled, ) .expect("managed-only mode should treat missing managed allowlist as empty"); @@ -351,7 +346,7 @@ fn deny_only_requirements_do_not_create_allow_constraints_in_full_access() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess), + &PermissionProfile::Disabled, ) .expect("deny-only requirements should not constrain the allowlist"); @@ -384,7 +379,7 @@ fn allow_only_requirements_do_not_create_deny_constraints_in_full_access() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::DangerFullAccess), + &PermissionProfile::Disabled, ) .expect("allow-only requirements should not constrain the denylist"); @@ -417,7 +412,7 @@ fn requirements_denied_domains_are_a_baseline_for_default_mode() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + &PermissionProfile::workspace_write(), ) .expect("default mode should merge managed and user deny entries"); @@ -452,7 +447,7 @@ fn requirements_denylist_expansion_keeps_user_entries_mutable() { let spec = NetworkProxySpec::from_config_and_constraints( config, Some(requirements), - &permission_profile_for_sandbox_policy(&SandboxPolicy::new_workspace_write_policy()), + &PermissionProfile::workspace_write(), ) .expect("managed baseline should still allow user edits"); diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index b93b8745d7..a8546858f1 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -13,6 +13,8 @@ use codex_config::permissions_toml::NetworkUnixSocketPermissionToml; use codex_config::permissions_toml::NetworkUnixSocketPermissionsToml; use codex_config::permissions_toml::PermissionProfileToml; use codex_config::permissions_toml::PermissionsToml; +use codex_config::permissions_toml::ResolvedPermissionProfileToml; +use codex_config::permissions_toml::WorkspaceRootsToml; use codex_config::types::SandboxWorkspaceWrite; use codex_features::NetworkProxyConfigToml; use codex_features::NetworkProxyDomainPermissionToml; @@ -33,6 +35,7 @@ use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::permissions::project_roots_glob_pattern; use codex_utils_absolute_path::AbsolutePathBuf; use super::ProjectConfig; @@ -72,12 +75,12 @@ pub(crate) fn builtin_permission_profile( BUILT_IN_READ_ONLY_PROFILE => Some(PermissionProfile::read_only()), BUILT_IN_WORKSPACE_PROFILE => Some(match workspace_write { Some(SandboxWorkspaceWrite { - writable_roots, + writable_roots: _, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, }) => PermissionProfile::workspace_write_with( - writable_roots, + &[], if *network_access { NetworkSandboxPolicy::Enabled } else { @@ -173,8 +176,8 @@ pub(crate) fn apply_network_proxy_feature_config( NetworkProxyUnixSocketPermissionToml::Allow => { NetworkUnixSocketPermissionToml::Allow } - NetworkProxyUnixSocketPermissionToml::None => { - NetworkUnixSocketPermissionToml::None + NetworkProxyUnixSocketPermissionToml::Deny => { + NetworkUnixSocketPermissionToml::Deny } }; (path.clone(), permission) @@ -183,19 +186,34 @@ pub(crate) fn apply_network_proxy_feature_config( } }), allow_local_binding: feature_config.allow_local_binding, + mitm: None, } .apply_to_network_proxy_config(config); } -pub(crate) fn resolve_permission_profile<'a>( - permissions: &'a PermissionsToml, +pub(crate) fn resolve_permission_profile( + permissions: &PermissionsToml, profile_name: &str, -) -> io::Result<&'a PermissionProfileToml> { - permissions.entries.get(profile_name).ok_or_else(|| { - io::Error::new( - io::ErrorKind::InvalidInput, - format!("default_permissions refers to undefined profile `{profile_name}`"), - ) +) -> io::Result { + permissions + .resolve_profile(profile_name, extensible_builtin_parent_profile_marker) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err.to_string())) +} + +/// Built-in parents provide their runtime permissions below. Resolution only +/// needs an empty profile marker so inheritance can terminate while preserving +/// the built-in parent id in `inherited_profile_names`. +fn extensible_builtin_parent_profile_marker(profile_name: &str) -> Option { + matches!( + profile_name, + BUILT_IN_READ_ONLY_PROFILE | BUILT_IN_WORKSPACE_PROFILE + ) + .then_some(PermissionProfileToml { + description: None, + extends: None, + workspace_roots: None, + filesystem: None, + network: None, }) } @@ -214,7 +232,7 @@ pub(crate) fn network_proxy_config_for_profile_selection( "default_permissions requires a `[permissions]` table", ) })?; - let profile = resolve_permission_profile(permissions, profile_name)?; + let profile = resolve_permission_profile(permissions, profile_name)?.profile; Ok(network_proxy_config_from_profile_network( profile.network.as_ref(), )) @@ -226,11 +244,27 @@ pub(crate) fn compile_permission_profile( policy_cwd: &Path, startup_warnings: &mut Vec, ) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> { - let profile = resolve_permission_profile(permissions, profile_name)?; - - let mut entries = Vec::new(); + let ResolvedPermissionProfileToml { + profile, + inherited_profile_names, + } = resolve_permission_profile(permissions, profile_name)?; + let base_permissions = inherited_profile_names.iter().find_map(|name| { + match name.as_str() { + BUILT_IN_READ_ONLY_PROFILE => Some(PermissionProfile::read_only()), + BUILT_IN_WORKSPACE_PROFILE => Some(PermissionProfile::workspace_write()), + _ => None, + } + .map(|profile| profile.to_runtime_permissions()) + }); + let (mut file_system_sandbox_policy, base_network_sandbox_policy) = base_permissions + .unwrap_or_else(|| { + ( + FileSystemSandboxPolicy::restricted(Vec::new()), + NetworkSandboxPolicy::Restricted, + ) + }); if let Some(filesystem) = profile.filesystem.as_ref() { - if filesystem.is_empty() { + if filesystem.is_empty() && file_system_sandbox_policy.entries.is_empty() { push_warning( startup_warnings, missing_filesystem_entries_warning(profile_name), @@ -241,7 +275,7 @@ pub(crate) fn compile_permission_profile( push_warning( startup_warnings, format!( - "Filesystem glob `{pattern}` uses `read` or `write` access, which is not fully supported by this platform's sandboxing. Use an exact path or trailing `/**` subtree rule instead. `none` deny-read globs are supported." + "Filesystem glob `{pattern}` uses `read` or `write` access, which is not fully supported by this platform's sandboxing. Use an exact path or trailing `/**` subtree rule instead. `deny` globs are supported." ), ); } @@ -255,15 +289,17 @@ pub(crate) fn compile_permission_profile( } } for (path, permission) in &filesystem.entries { - entries.extend(compile_filesystem_permission( - path, - permission, - policy_cwd, - startup_warnings, - )?); + file_system_sandbox_policy + .entries + .extend(compile_filesystem_permission( + path, + permission, + policy_cwd, + startup_warnings, + )?); } } - } else { + } else if file_system_sandbox_policy.entries.is_empty() { push_warning( startup_warnings, missing_filesystem_entries_warning(profile_name), @@ -275,10 +311,11 @@ pub(crate) fn compile_permission_profile( .as_ref() .and_then(|filesystem| filesystem.glob_scan_max_depth), )?; - - let network_sandbox_policy = compile_network_sandbox_policy(profile.network.as_ref()); - let mut file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(entries); - file_system_sandbox_policy.glob_scan_max_depth = glob_scan_max_depth; + if let Some(glob_scan_max_depth) = glob_scan_max_depth { + file_system_sandbox_policy.glob_scan_max_depth = Some(glob_scan_max_depth); + } + let network_sandbox_policy = + compile_network_sandbox_policy(profile.network.as_ref(), base_network_sandbox_policy); Ok((file_system_sandbox_policy, network_sandbox_policy)) } @@ -303,7 +340,42 @@ pub(crate) fn compile_permission_profile_selection( compile_permission_profile(permissions, profile_name, policy_cwd, startup_warnings) } -fn reject_unknown_builtin_permission_profile(profile_name: &str) -> io::Result<()> { +pub(crate) fn compile_permission_profile_workspace_roots( + permissions: Option<&PermissionsToml>, + profile_name: &str, + policy_cwd: &Path, +) -> io::Result> { + if is_builtin_permission_profile_name(profile_name) { + return Ok(Vec::new()); + } + reject_unknown_builtin_permission_profile(profile_name)?; + + let permissions = permissions.ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "default_permissions requires a `[permissions]` table", + ) + })?; + let profile = resolve_permission_profile(permissions, profile_name)?; + Ok(compile_workspace_roots( + profile.profile.workspace_roots.as_ref(), + policy_cwd, + )) +} + +fn compile_workspace_roots( + workspace_roots: Option<&WorkspaceRootsToml>, + policy_cwd: &Path, +) -> Vec { + workspace_roots.map_or_else(Vec::new, |workspace_roots| { + workspace_roots + .enabled_roots() + .map(|path| AbsolutePathBuf::resolve_path_against_base(path, policy_cwd)) + .collect() + }) +} + +pub(crate) fn reject_unknown_builtin_permission_profile(profile_name: &str) -> io::Result<()> { if profile_name.starts_with(':') { return Err(io::Error::new( io::ErrorKind::InvalidInput, @@ -345,14 +417,18 @@ pub(crate) fn get_readable_roots_required_for_codex_runtime( readable_roots } -fn compile_network_sandbox_policy(network: Option<&NetworkToml>) -> NetworkSandboxPolicy { +fn compile_network_sandbox_policy( + network: Option<&NetworkToml>, + base_network_sandbox_policy: NetworkSandboxPolicy, +) -> NetworkSandboxPolicy { let Some(network) = network else { - return NetworkSandboxPolicy::Restricted; + return base_network_sandbox_policy; }; match network.enabled { Some(true) => NetworkSandboxPolicy::Enabled, - _ => NetworkSandboxPolicy::Restricted, + Some(false) => NetworkSandboxPolicy::Restricted, + None => base_network_sandbox_policy, } } @@ -377,7 +453,7 @@ fn compile_filesystem_permission( Some(FileSystemSpecialPath::ProjectRoots { .. }) | None => true, Some(_) => false, }; - if has_glob && *access == FileSystemAccessMode::None && can_compile_as_pattern { + if has_glob && *access == FileSystemAccessMode::Deny && can_compile_as_pattern { // Scoped glob syntax is a first-class filesystem policy // pattern entry. Literal scoped paths continue through the // exact-path parser so existing path semantics stay intact. @@ -412,7 +488,7 @@ fn compile_filesystem_access_path( return compile_filesystem_path(path, startup_warnings); } - if access == FileSystemAccessMode::None { + if access == FileSystemAccessMode::Deny { // At this point `path` is an unscoped filesystem table key. Top-level // glob deny entries still go through the absolute-path parser before // becoming policy patterns; relative project-root glob syntax is @@ -478,30 +554,25 @@ fn compile_scoped_filesystem_pattern( path: &str, subpath: &str, access: FileSystemAccessMode, - policy_cwd: &Path, + _policy_cwd: &Path, ) -> io::Result { // Pattern entries currently mean deny-read only. Supporting broader access // modes here would imply glob-based read/write allow semantics that the // sandbox policy does not express yet. - if access != FileSystemAccessMode::None { + if access != FileSystemAccessMode::Deny { return Err(io::Error::new( io::ErrorKind::InvalidInput, - format!("filesystem glob subpath `{subpath}` only supports `none` access"), + format!("filesystem glob subpath `{subpath}` only supports `deny` access"), )); } let subpath = parse_relative_subpath(subpath)?; match parse_special_path(path) { Some(FileSystemSpecialPath::ProjectRoots { .. }) => { - // `:workspace_roots` is represented as a special path, but current - // filesystem-policy resolution defines it relative to the session - // cwd. Use the same policy cwd here so glob entries and exact - // scoped entries resolve consistently. - Ok( - AbsolutePathBuf::resolve_path_against_base(&subpath, policy_cwd) - .to_string_lossy() - .to_string(), - ) + // Keep `:workspace_roots` glob patterns symbolic until the active + // workspace roots are known, then materialize them for cwd and any + // runtime/profile-added workspace roots together. + Ok(project_roots_glob_pattern(&subpath)) } Some(_) => Err(io::Error::new( io::ErrorKind::InvalidInput, @@ -527,7 +598,7 @@ fn compile_read_write_glob_path(path: &str, access: FileSystemAccessMode) -> io: Err(io::Error::new( io::ErrorKind::InvalidInput, format!( - "filesystem glob path `{path}` only supports `none` access; use an exact path or trailing `/**` for `{access}` subtree access" + "filesystem glob path `{path}` only supports `deny` access; use an exact path or trailing `/**` for `{access}` subtree access" ), )) } @@ -537,7 +608,7 @@ fn unsupported_read_write_glob_paths(filesystem: &FilesystemPermissionsToml) -> for (path, permission) in &filesystem.entries { match permission { FilesystemPermissionToml::Access(access) => { - if *access != FileSystemAccessMode::None + if *access != FileSystemAccessMode::Deny && contains_glob_chars(remove_trailing_glob_suffix(path)) { patterns.push(path.clone()); @@ -545,7 +616,7 @@ fn unsupported_read_write_glob_paths(filesystem: &FilesystemPermissionsToml) -> } FilesystemPermissionToml::Scoped(scoped_entries) => { for (subpath, access) in scoped_entries { - if *access != FileSystemAccessMode::None + if *access != FileSystemAccessMode::Deny && contains_glob_chars(remove_trailing_glob_suffix(subpath)) { patterns.push(format!("{path}/{subpath}")); @@ -565,7 +636,7 @@ fn unbounded_unreadable_globstar_paths(filesystem: &FilesystemPermissionsToml) - let mut patterns = Vec::new(); for (path, permission) in &filesystem.entries { match permission { - FilesystemPermissionToml::Access(FileSystemAccessMode::None) => { + FilesystemPermissionToml::Access(FileSystemAccessMode::Deny) => { if path.contains("**") { patterns.push(path.clone()); } @@ -573,7 +644,7 @@ fn unbounded_unreadable_globstar_paths(filesystem: &FilesystemPermissionsToml) - FilesystemPermissionToml::Access(_) => {} FilesystemPermissionToml::Scoped(scoped_entries) => { for (subpath, access) in scoped_entries { - if *access == FileSystemAccessMode::None && subpath.contains("**") { + if *access == FileSystemAccessMode::Deny && subpath.contains("**") { patterns.push(format!("{path}/{subpath}")); } } diff --git a/codex-rs/core/src/config/permissions_tests.rs b/codex-rs/core/src/config/permissions_tests.rs index 86a3c604dd..41f4179ba8 100644 --- a/codex-rs/core/src/config/permissions_tests.rs +++ b/codex-rs/core/src/config/permissions_tests.rs @@ -11,6 +11,7 @@ use codex_config::permissions_toml::NetworkUnixSocketPermissionToml; use codex_config::permissions_toml::NetworkUnixSocketPermissionsToml; use codex_config::permissions_toml::PermissionProfileToml; use codex_config::permissions_toml::PermissionsToml; +use codex_config::permissions_toml::WorkspaceRootsToml; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; @@ -66,6 +67,9 @@ async fn restricted_read_implicitly_allows_helper_executables() -> std::io::Resu entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, + extends: None, + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::new(), @@ -78,7 +82,7 @@ async fn restricted_read_implicitly_allows_helper_executables() -> std::io::Resu }, ConfigOverrides { cwd: Some(cwd.clone()), - zsh_path: Some(zsh_path.clone()), + default_zsh_path: Some(AbsolutePathBuf::try_from(zsh_path.clone())?), main_execve_wrapper_exe: Some(execve_wrapper), ..Default::default() }, @@ -145,7 +149,7 @@ fn network_permission_containers_project_allowed_and_denied_entries() { ), ( "/tmp/ignored.sock".to_string(), - NetworkUnixSocketPermissionToml::None, + NetworkUnixSocketPermissionToml::Deny, ), ]), }; @@ -207,7 +211,7 @@ fn network_toml_overlays_unix_socket_permissions_by_path() { ), ( "/tmp/override.sock".to_string(), - NetworkUnixSocketPermissionToml::None, + NetworkUnixSocketPermissionToml::Deny, ), ]), }), @@ -229,13 +233,166 @@ fn network_toml_overlays_unix_socket_permissions_by_path() { ), ( "/tmp/override.sock".to_string(), - ProxyNetworkUnixSocketPermission::None, + ProxyNetworkUnixSocketPermission::Deny, ), ]), }) ); } +#[test] +fn permissions_profiles_resolve_extends_parent_first_with_child_overrides() { + let permissions = toml::from_str::( + r#" +[base] +description = "Base profile" + +[base.filesystem] +glob_scan_max_depth = 1 +"/tmp/base" = "read" +"/tmp/shared" = "read" + +[base.filesystem.":project_roots"] +"**/*.env" = "deny" +docs = "read" + +[base.network] +enabled = true + +[base.network.domains] +"base.example.com" = "allow" +"SHARED.EXAMPLE.COM." = "deny" + +[base.network.unix_sockets] +"/tmp/base.sock" = "allow" +"/tmp/blocked.sock" = "deny" + +[child] +extends = "base" + +[child.filesystem] +glob_scan_max_depth = 3 +"/tmp/shared" = "write" + +[child.filesystem.":project_roots"] +docs = "write" +src = "read" + +[child.network] +enabled = false +allow_local_binding = true + +[child.network.domains] +"child.example.com" = "allow" +"shared.example.com" = "allow" + +[child.network.unix_sockets] +"/tmp/child.sock" = "allow" +"#, + ) + .expect("permissions should deserialize"); + + let resolved = permissions + .resolve_profile("child", |_| None) + .expect("child profile should resolve"); + let expected_profile = toml::from_str::( + r#" +extends = "base" + +[filesystem] +glob_scan_max_depth = 3 +"/tmp/base" = "read" +"/tmp/shared" = "write" + +[filesystem.":project_roots"] +"**/*.env" = "deny" +docs = "write" +src = "read" + +[network] +enabled = false +allow_local_binding = true + +[network.domains] +"base.example.com" = "allow" +"child.example.com" = "allow" +"shared.example.com" = "allow" + +[network.unix_sockets] +"/tmp/base.sock" = "allow" +"/tmp/blocked.sock" = "deny" +"/tmp/child.sock" = "allow" +"#, + ) + .expect("expected profile should deserialize"); + + assert_eq!(resolved.profile, expected_profile); + assert_eq!(resolved.inherited_profile_names, vec!["base".to_string()]); +} + +#[test] +fn permissions_profiles_reject_undefined_extends_parent() { + let permissions = toml::from_str::( + r#" +[child] +extends = "base" +"#, + ) + .expect("permissions should deserialize"); + + let err = permissions + .resolve_profile("child", |_| None) + .expect_err("missing parent should be rejected"); + + assert_eq!( + err.to_string(), + "permissions profile `child` extends undefined profile `base`" + ); +} + +#[test] +fn permissions_profiles_reject_unsupported_builtin_extends_parent() { + let permissions = toml::from_str::( + r#" +[child] +extends = ":danger-full-access" +"#, + ) + .expect("permissions should deserialize"); + + let err = permissions + .resolve_profile("child", |_| None) + .expect_err("unsupported built-in parent should be rejected"); + + assert_eq!( + err.to_string(), + "permissions profile `child` cannot extend unsupported built-in profile `:danger-full-access`" + ); +} + +#[test] +fn permissions_profiles_reject_extends_cycles() { + let permissions = toml::from_str::( + r#" +[alpha] +extends = "beta" + +[beta] +extends = "alpha" +"#, + ) + .expect("permissions should deserialize"); + + let err = permissions + .resolve_profile("alpha", |_| None) + .expect_err("cycle should be rejected"); + + assert_eq!( + err.to_string(), + "permissions profile inheritance cycle detected: alpha -> beta -> alpha" + ); +} + #[test] fn profile_network_proxy_config_keeps_proxy_disabled_for_bare_network_access() { let config = network_proxy_config_from_profile_network(Some(&NetworkToml { @@ -275,6 +432,41 @@ fn profile_network_proxy_config_keeps_proxy_disabled_for_proxy_policy() { ); } +#[test] +fn compile_permission_profile_workspace_roots_resolves_enabled_entries() -> std::io::Result<()> { + let cwd = TempDir::new()?; + let workspace_roots = compile_permission_profile_workspace_roots( + Some(&PermissionsToml { + entries: BTreeMap::from([( + "workspace".to_string(), + PermissionProfileToml { + description: None, + extends: None, + workspace_roots: Some(WorkspaceRootsToml { + entries: BTreeMap::from([ + ("backend".to_string(), true), + ("disabled".to_string(), false), + ]), + }), + filesystem: None, + network: None, + }, + )]), + }), + "workspace", + cwd.path(), + )?; + + assert_eq!( + workspace_roots, + vec![AbsolutePathBuf::resolve_path_against_base( + "backend", + cwd.path() + )] + ); + Ok(()) +} + #[test] fn read_write_glob_warnings_skip_supported_deny_read_globs_and_trailing_subpaths() { let filesystem = FilesystemPermissionsToml { @@ -291,7 +483,7 @@ fn read_write_glob_warnings_skip_supported_deny_read_globs_and_trailing_subpaths ( ":workspace_roots".to_string(), FilesystemPermissionToml::Scoped(BTreeMap::from([ - ("**/*.env".to_string(), FileSystemAccessMode::None), + ("**/*.env".to_string(), FileSystemAccessMode::Deny), ("docs/**".to_string(), FileSystemAccessMode::Read), ("src/**/*.rs".to_string(), FileSystemAccessMode::Write), ])), @@ -305,7 +497,7 @@ fn read_write_glob_warnings_skip_supported_deny_read_globs_and_trailing_subpaths "/tmp/**/*.log".to_string(), ":workspace_roots/src/**/*.rs".to_string() ], - "`none` glob patterns are supported as deny-read rules; only `read`/`write` globs should warn" + "`deny` glob patterns are supported as deny-read rules; only `read`/`write` globs should warn" ); } @@ -316,8 +508,8 @@ fn unreadable_globstar_warning_is_suppressed_when_scan_depth_is_configured() { entries: BTreeMap::from([( ":workspace_roots".to_string(), FilesystemPermissionToml::Scoped(BTreeMap::from([ - ("**/*.env".to_string(), FileSystemAccessMode::None), - ("*.pem".to_string(), FileSystemAccessMode::None), + ("**/*.env".to_string(), FileSystemAccessMode::Deny), + ("*.pem".to_string(), FileSystemAccessMode::Deny), ])), )]), }; @@ -359,6 +551,9 @@ fn read_write_trailing_glob_suffix_compiles_as_subpath() -> std::io::Result<()> entries: BTreeMap::from([( "workspace".to_string(), PermissionProfileToml { + description: None, + extends: None, + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: BTreeMap::from([( @@ -399,7 +594,7 @@ fn read_write_glob_patterns_still_reject_non_subpath_globs() { assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); assert!( err.to_string() - .contains("filesystem glob path `src/**/*.rs` only supports `none` access"), + .contains("filesystem glob path `src/**/*.rs` only supports `deny` access"), "{err}" ); } diff --git a/codex-rs/core/src/config/resolved_permission_profile.rs b/codex-rs/core/src/config/resolved_permission_profile.rs new file mode 100644 index 0000000000..f0fff66bcc --- /dev/null +++ b/codex-rs/core/src/config/resolved_permission_profile.rs @@ -0,0 +1,309 @@ +use codex_config::Constrained; +use codex_config::ConstraintResult; +use codex_protocol::models::ActivePermissionProfile; +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::PermissionProfile; +use codex_utils_absolute_path::AbsolutePathBuf; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum BuiltInPermissionProfileId { + ReadOnly, + Workspace, + DangerFullAccess, +} + +impl BuiltInPermissionProfileId { + fn from_str(id: &str) -> Option { + match id { + BUILT_IN_PERMISSION_PROFILE_READ_ONLY => Some(Self::ReadOnly), + BUILT_IN_PERMISSION_PROFILE_WORKSPACE => Some(Self::Workspace), + BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS => Some(Self::DangerFullAccess), + _ => None, + } + } + + fn as_str(self) -> &'static str { + match self { + Self::ReadOnly => BUILT_IN_PERMISSION_PROFILE_READ_ONLY, + Self::Workspace => BUILT_IN_PERMISSION_PROFILE_WORKSPACE, + Self::DangerFullAccess => BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ResolvedPermissionProfile { + Legacy(LegacyPermissionProfile), + BuiltIn(BuiltInPermissionProfile), + Named(NamedPermissionProfile), +} + +/// Trusted snapshot of a resolved permission profile. +/// +/// This is a bridge for already-resolved session/config state. It keeps the +/// concrete `PermissionProfile`, optional active profile id, and +/// profile-defined workspace roots together so `Permissions` can validate and +/// install them atomically. It is not a resolver: callers that are handling +/// user-selected profile ids should resolve those ids through config instead +/// of constructing this type directly. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PermissionProfileSnapshot { + resolved_permission_profile: ResolvedPermissionProfile, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LegacyPermissionProfile { + permission_profile: PermissionProfile, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BuiltInPermissionProfile { + id: BuiltInPermissionProfileId, + extends: Option, + permission_profile: PermissionProfile, + profile_workspace_roots: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct NamedPermissionProfile { + id: String, + extends: Option, + permission_profile: PermissionProfile, + profile_workspace_roots: Vec, +} + +impl ResolvedPermissionProfile { + pub(crate) fn from_active_profile( + permission_profile: PermissionProfile, + active_permission_profile: Option, + profile_workspace_roots: Vec, + ) -> Self { + let Some(active_permission_profile) = active_permission_profile else { + return Self::legacy(permission_profile); + }; + + let ActivePermissionProfile { id, extends } = active_permission_profile; + if let Some(built_in_id) = BuiltInPermissionProfileId::from_str(&id) { + Self::BuiltIn(BuiltInPermissionProfile { + id: built_in_id, + extends, + permission_profile, + profile_workspace_roots, + }) + } else { + Self::Named(NamedPermissionProfile { + id, + extends, + permission_profile, + profile_workspace_roots, + }) + } + } + + pub(crate) fn legacy(permission_profile: PermissionProfile) -> Self { + Self::Legacy(LegacyPermissionProfile { permission_profile }) + } + + pub(crate) fn permission_profile(&self) -> &PermissionProfile { + match self { + Self::Legacy(profile) => &profile.permission_profile, + Self::BuiltIn(profile) => &profile.permission_profile, + Self::Named(profile) => &profile.permission_profile, + } + } + + pub(crate) fn active_permission_profile(&self) -> Option { + match self { + Self::Legacy(_) => None, + Self::BuiltIn(profile) => Some(ActivePermissionProfile { + id: profile.id.as_str().to_string(), + extends: profile.extends.clone(), + }), + Self::Named(profile) => Some(ActivePermissionProfile { + id: profile.id.clone(), + extends: profile.extends.clone(), + }), + } + } + + pub(crate) fn profile_workspace_roots(&self) -> &[AbsolutePathBuf] { + match self { + Self::Legacy(_) => &[], + Self::BuiltIn(profile) => &profile.profile_workspace_roots, + Self::Named(profile) => &profile.profile_workspace_roots, + } + } +} + +impl PermissionProfileSnapshot { + /// Create a snapshot with no active profile id. + /// + /// Prefer this only for legacy data or local overrides that genuinely do + /// not have a named/built-in profile identity. Using this for a built-in or + /// named profile will intentionally clear the active profile metadata. + pub fn legacy(permission_profile: PermissionProfile) -> Self { + Self { + resolved_permission_profile: ResolvedPermissionProfile::legacy(permission_profile), + } + } + + /// Create a snapshot for a known active profile id. + /// + /// Use this only after a trusted caller has already resolved the active id + /// to the supplied concrete `PermissionProfile`. This constructor does not + /// verify that the id and profile match; `Permissions` will still enforce + /// configured permission constraints when the snapshot is installed. + pub fn active( + permission_profile: PermissionProfile, + active_permission_profile: ActivePermissionProfile, + ) -> Self { + Self::active_with_profile_workspace_roots( + permission_profile, + active_permission_profile, + Vec::new(), + ) + } + + /// Create a snapshot for a known active profile id with profile roots. + /// + /// As with `active`, the caller is responsible for passing the concrete + /// profile and active id that were resolved together. Use this variant when + /// the selected profile declared workspace roots that should remain + /// distinct from turn-scoped runtime workspace roots. + pub fn active_with_profile_workspace_roots( + permission_profile: PermissionProfile, + active_permission_profile: ActivePermissionProfile, + profile_workspace_roots: Vec, + ) -> Self { + Self { + resolved_permission_profile: ResolvedPermissionProfile::from_active_profile( + permission_profile, + Some(active_permission_profile), + profile_workspace_roots, + ), + } + } + + /// Reconstruct a trusted snapshot from session state. + /// + /// This is intended for session responses emitted by core, where the + /// concrete profile and active profile id were captured together. Avoid + /// using this as a shortcut for arbitrary user input because mismatched + /// arguments can still misrepresent the active profile identity. + pub fn from_session_snapshot( + permission_profile: PermissionProfile, + active_permission_profile: Option, + ) -> Self { + match active_permission_profile { + Some(active_permission_profile) => { + Self::active(permission_profile, active_permission_profile) + } + None => Self::legacy(permission_profile), + } + } + + /// Borrow the concrete permission profile captured in this snapshot. + pub fn permission_profile(&self) -> &PermissionProfile { + self.resolved_permission_profile.permission_profile() + } + + /// Return the active profile id captured in this snapshot, if any. + pub fn active_permission_profile(&self) -> Option { + self.resolved_permission_profile.active_permission_profile() + } + + /// Borrow profile-declared workspace roots captured in this snapshot. + pub fn profile_workspace_roots(&self) -> &[AbsolutePathBuf] { + self.resolved_permission_profile.profile_workspace_roots() + } + + pub(crate) fn into_resolved_permission_profile(self) -> ResolvedPermissionProfile { + self.resolved_permission_profile + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct PermissionProfileState { + resolved_permission_profile: Constrained, +} + +impl PermissionProfileState { + pub(crate) fn from_constrained_legacy( + constrained_permission_profile: Constrained, + ) -> ConstraintResult { + let resolved = + ResolvedPermissionProfile::legacy(constrained_permission_profile.get().clone()); + Self::from_constrained_resolved(constrained_permission_profile, resolved) + } + + pub(crate) fn from_constrained_active_profile( + constrained_permission_profile: Constrained, + active_permission_profile: Option, + profile_workspace_roots: Vec, + ) -> ConstraintResult { + let resolved = ResolvedPermissionProfile::from_active_profile( + constrained_permission_profile.get().clone(), + active_permission_profile, + profile_workspace_roots, + ); + Self::from_constrained_resolved(constrained_permission_profile, resolved) + } + + pub(crate) fn from_constrained_resolved( + constrained_permission_profile: Constrained, + resolved_permission_profile: ResolvedPermissionProfile, + ) -> ConstraintResult { + let permission_profile_constraint = constrained_permission_profile; + let resolved_permission_profile = Constrained::new( + resolved_permission_profile, + move |candidate: &ResolvedPermissionProfile| { + permission_profile_constraint.can_set(candidate.permission_profile()) + }, + )?; + Ok(Self { + resolved_permission_profile, + }) + } + + pub(crate) fn permission_profile(&self) -> &PermissionProfile { + self.resolved_permission_profile.get().permission_profile() + } + + pub(crate) fn active_permission_profile(&self) -> Option { + self.resolved_permission_profile + .get() + .active_permission_profile() + } + + pub(crate) fn profile_workspace_roots(&self) -> &[AbsolutePathBuf] { + self.resolved_permission_profile + .get() + .profile_workspace_roots() + } + + pub(crate) fn can_set_legacy_permission_profile( + &self, + permission_profile: &PermissionProfile, + ) -> ConstraintResult<()> { + let candidate = ResolvedPermissionProfile::legacy(permission_profile.clone()); + self.resolved_permission_profile.can_set(&candidate) + } + + pub(crate) fn set_legacy_permission_profile( + &mut self, + permission_profile: PermissionProfile, + ) -> ConstraintResult<()> { + self.resolved_permission_profile + .set(ResolvedPermissionProfile::legacy(permission_profile)) + } + + pub(crate) fn set_permission_profile_snapshot( + &mut self, + snapshot: PermissionProfileSnapshot, + ) -> ConstraintResult<()> { + self.resolved_permission_profile + .set(snapshot.into_resolved_permission_profile()) + } +} diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index f4ee42c821..55a941e8c4 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -35,7 +35,7 @@ use codex_login::CodexAuth; use codex_login::default_client::originator; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_mcp::McpConnectionManager; -use codex_mcp::McpRuntimeEnvironment; +use codex_mcp::McpRuntimeContext; use codex_mcp::ToolInfo; use codex_mcp::ToolPluginProvenance; use codex_mcp::codex_apps_tools_cache_key; @@ -113,8 +113,9 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( config: &Config, auth: Option<&CodexAuth>, accessible_connectors: &[AppInfo], + loaded_plugin_app_connector_ids: &[String], ) -> anyhow::Result> { - let connector_ids = tool_suggest_connector_ids(config).await; + let connector_ids = tool_suggest_connector_ids(config, loaded_plugin_app_connector_ids); let directory_connectors = codex_connectors::merge::merge_plugin_connectors( cached_directory_connectors_for_tool_suggest_with_auth(config, auth).await, connector_ids.iter().cloned(), @@ -128,10 +129,11 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( ) .into_iter() .map(DiscoverableTool::from); - let discoverable_plugins = list_tool_suggest_discoverable_plugins(config) - .await? - .into_iter() - .map(DiscoverableTool::from); + let discoverable_plugins = + list_tool_suggest_discoverable_plugins(config, loaded_plugin_app_connector_ids) + .await? + .into_iter() + .map(DiscoverableTool::from); Ok(discoverable_connectors .chain(discoverable_plugins) .collect()) @@ -198,11 +200,12 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( config.codex_linux_sandbox_exe.clone(), )?; let environment_manager = - EnvironmentManager::from_codex_home(config.codex_home.clone(), local_runtime_paths).await?; + EnvironmentManager::from_codex_home(config.codex_home.clone(), Some(local_runtime_paths)) + .await?; list_accessible_connectors_from_mcp_tools_with_environment_manager( config, force_refetch, - &environment_manager, + Arc::new(environment_manager), ) .await } @@ -210,7 +213,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager( config: &Config, force_refetch: bool, - environment_manager: &EnvironmentManager, + environment_manager: Arc, ) -> anyhow::Result { let auth_manager = AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ false).await; @@ -261,10 +264,6 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager( let (tx_event, rx_event) = unbounded(); drop(rx_event); - let environment = environment_manager - .default_environment() - .unwrap_or_else(|| environment_manager.local_environment()); - let (mut mcp_connection_manager, cancel_token) = McpConnectionManager::new( &mcp_servers, config.mcp_oauth_credentials_store_mode, @@ -273,10 +272,13 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager( INITIAL_SUBMIT_ID.to_owned(), tx_event, PermissionProfile::default(), - McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()), + // Connector discovery is threadless. Use an actually configured env if + // one exists, but do not reintroduce the old hidden-local fallback. + McpRuntimeContext::new(environment_manager, config.cwd.to_path_buf()), config.codex_home.to_path_buf(), codex_apps_tools_cache_key(auth.as_ref()), host_owned_codex_apps_enabled, + mcp_config.prefix_mcp_tool_names, mcp_config.client_elicitation_capability, ToolPluginProvenance::default(), auth.as_ref(), @@ -404,15 +406,13 @@ fn write_cached_accessible_connectors( }); } -async fn tool_suggest_connector_ids(config: &Config) -> HashSet { - let plugins_input = config.plugins_config_input(); - let mut connector_ids = PluginsManager::new(config.codex_home.to_path_buf()) - .plugins_for_config(&plugins_input) - .await - .capability_summaries() +fn tool_suggest_connector_ids( + config: &Config, + loaded_plugin_app_connector_ids: &[String], +) -> HashSet { + let mut connector_ids = loaded_plugin_app_connector_ids .iter() - .flat_map(|plugin| plugin.app_connector_ids.iter()) - .map(|connector_id| connector_id.0.clone()) + .cloned() .collect::>(); connector_ids.extend( config diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index cce2491679..285df9a29a 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -31,13 +31,13 @@ use std::sync::Arc; use tempfile::tempdir; fn annotations(destructive_hint: Option, open_world_hint: Option) -> ToolAnnotations { - ToolAnnotations { + ToolAnnotations::from_raw( + /*title*/ None, + /*read_only_hint*/ None, destructive_hint, - idempotent_hint: None, + /*idempotent_hint*/ None, open_world_hint, - read_only_hint: None, - title: None, - } + ) } fn app(id: &str) -> AppInfo { @@ -63,17 +63,7 @@ fn plugin_names(names: &[&str]) -> Vec { } fn test_tool_definition(tool_name: &str) -> Tool { - Tool { - name: tool_name.to_string().into(), - title: None, - description: None, - input_schema: Arc::new(JsonObject::default()), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - } + Tool::new_with_raw(tool_name.to_string(), None, Arc::new(JsonObject::default())) } fn codex_app_tool( @@ -243,17 +233,11 @@ fn accessible_connectors_from_mcp_tools_preserves_description() { callable_name: "calendar_create_event".to_string(), callable_namespace: "mcp__codex_apps__calendar".to_string(), namespace_description: Some("Plan events".to_string()), - tool: Tool { - name: "calendar_create_event".to_string().into(), - title: None, - description: Some("Create a calendar event".into()), - input_schema: Arc::new(JsonObject::default()), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }, + tool: Tool::new( + "calendar_create_event", + "Create a calendar event", + Arc::new(JsonObject::default()), + ), connector_id: Some("calendar".to_string()), connector_name: Some("Calendar".to_string()), plugin_display_names: Vec::new(), @@ -1196,7 +1180,7 @@ discoverables = [ .expect("config should load"); assert_eq!( - tool_suggest_connector_ids(&config).await, + tool_suggest_connector_ids(&config, &[]), HashSet::from(["connector_2128aebfecb84f64a069897515042a44".to_string()]) ); } @@ -1225,7 +1209,7 @@ disabled_tools = [ .expect("config should load"); assert_eq!( - tool_suggest_connector_ids(&config).await, + tool_suggest_connector_ids(&config, &[]), HashSet::from(["connector_gmail".to_string()]) ); } @@ -1254,7 +1238,7 @@ discoverables = [ let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); let discoverable_tools = - list_tool_suggest_discoverable_tools_with_auth(&config, Some(&auth), &[]) + list_tool_suggest_discoverable_tools_with_auth(&config, Some(&auth), &[], &[]) .await .expect("discoverable tools should load"); @@ -1265,3 +1249,39 @@ discoverables = [ ))] ); } + +#[tokio::test] +async fn tool_suggest_includes_connectors_from_loaded_plugin_apps() { + let codex_home = tempdir().expect("tempdir should succeed"); + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +[features] +apps = true +"#, + ) + .expect("write config"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config should load"); + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + let loaded_plugin_app_connector_ids = vec!["asdk_app_databricks_workspace".to_string()]; + + let discoverable_tools = list_tool_suggest_discoverable_tools_with_auth( + &config, + Some(&auth), + &[], + &loaded_plugin_app_connector_ids, + ) + .await + .expect("discoverable tools should load"); + + assert_eq!( + discoverable_tools, + vec![DiscoverableTool::from(plugin_connector_to_app_info( + "asdk_app_databricks_workspace".to_string(), + ))] + ); +} diff --git a/codex-rs/core/src/context/approved_command_prefix_saved.rs b/codex-rs/core/src/context/approved_command_prefix_saved.rs index 3aac17712a..532a2494c3 100644 --- a/codex-rs/core/src/context/approved_command_prefix_saved.rs +++ b/codex-rs/core/src/context/approved_command_prefix_saved.rs @@ -14,9 +14,17 @@ impl ApprovedCommandPrefixSaved { } impl ContextualUserFragment for ApprovedCommandPrefixSaved { - const ROLE: &'static str = "developer"; - const START_MARKER: &'static str = ""; - const END_MARKER: &'static str = ""; + fn role() -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } fn body(&self) -> String { format!("Approved command prefix saved:\n{}", self.prefixes) diff --git a/codex-rs/core/src/context/apps_instructions.rs b/codex-rs/core/src/context/apps_instructions.rs index 55865b429a..9a475dc311 100644 --- a/codex-rs/core/src/context/apps_instructions.rs +++ b/codex-rs/core/src/context/apps_instructions.rs @@ -18,9 +18,17 @@ impl AppsInstructions { } impl ContextualUserFragment for AppsInstructions { - const ROLE: &'static str = "developer"; - const START_MARKER: &'static str = APPS_INSTRUCTIONS_OPEN_TAG; - const END_MARKER: &'static str = APPS_INSTRUCTIONS_CLOSE_TAG; + fn role() -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + (APPS_INSTRUCTIONS_OPEN_TAG, APPS_INSTRUCTIONS_CLOSE_TAG) + } fn body(&self) -> String { format!( diff --git a/codex-rs/core/src/context/available_plugins_instructions.rs b/codex-rs/core/src/context/available_plugins_instructions.rs index 52f9c8df48..e7f560f6a1 100644 --- a/codex-rs/core/src/context/available_plugins_instructions.rs +++ b/codex-rs/core/src/context/available_plugins_instructions.rs @@ -22,9 +22,20 @@ impl AvailablePluginsInstructions { } impl ContextualUserFragment for AvailablePluginsInstructions { - const ROLE: &'static str = "developer"; - const START_MARKER: &'static str = PLUGINS_INSTRUCTIONS_OPEN_TAG; - const END_MARKER: &'static str = PLUGINS_INSTRUCTIONS_CLOSE_TAG; + fn role() -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ( + PLUGINS_INSTRUCTIONS_OPEN_TAG, + PLUGINS_INSTRUCTIONS_CLOSE_TAG, + ) + } fn body(&self) -> String { let mut lines = vec![ diff --git a/codex-rs/core/src/context/available_skills_instructions.rs b/codex-rs/core/src/context/available_skills_instructions.rs index 0a99bf62e6..e63d637677 100644 --- a/codex-rs/core/src/context/available_skills_instructions.rs +++ b/codex-rs/core/src/context/available_skills_instructions.rs @@ -21,9 +21,17 @@ impl From for AvailableSkillsInstructions { } impl ContextualUserFragment for AvailableSkillsInstructions { - const ROLE: &'static str = "developer"; - const START_MARKER: &'static str = SKILLS_INSTRUCTIONS_OPEN_TAG; - const END_MARKER: &'static str = SKILLS_INSTRUCTIONS_CLOSE_TAG; + fn role() -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + (SKILLS_INSTRUCTIONS_OPEN_TAG, SKILLS_INSTRUCTIONS_CLOSE_TAG) + } fn body(&self) -> String { render_available_skills_body(&self.skill_root_lines, &self.skill_lines) diff --git a/codex-rs/core/src/context/collaboration_mode_instructions.rs b/codex-rs/core/src/context/collaboration_mode_instructions.rs index dcf3bac1de..16e3d38198 100644 --- a/codex-rs/core/src/context/collaboration_mode_instructions.rs +++ b/codex-rs/core/src/context/collaboration_mode_instructions.rs @@ -22,9 +22,17 @@ impl CollaborationModeInstructions { } impl ContextualUserFragment for CollaborationModeInstructions { - const ROLE: &'static str = "developer"; - const START_MARKER: &'static str = COLLABORATION_MODE_OPEN_TAG; - const END_MARKER: &'static str = COLLABORATION_MODE_CLOSE_TAG; + fn role() -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + (COLLABORATION_MODE_OPEN_TAG, COLLABORATION_MODE_CLOSE_TAG) + } fn body(&self) -> String { self.instructions.clone() diff --git a/codex-rs/core/src/context/contextual_user_message.rs b/codex-rs/core/src/context/contextual_user_message.rs index c7ada2317f..79768dd3a3 100644 --- a/codex-rs/core/src/context/contextual_user_message.rs +++ b/codex-rs/core/src/context/contextual_user_message.rs @@ -2,10 +2,11 @@ use codex_protocol::items::HookPromptItem; use codex_protocol::items::parse_hook_prompt_fragment; use codex_protocol::models::ContentItem; +use super::AdditionalContextUserFragment; use super::EnvironmentContext; use super::FragmentRegistration; use super::FragmentRegistrationProxy; -use super::GoalContext; +use super::InternalModelContextFragment; use super::LegacyApplyPatchExecCommandWarning; use super::LegacyModelMismatchWarning; use super::LegacyUnifiedExecProcessLimitWarning; @@ -19,6 +20,8 @@ static USER_INSTRUCTIONS_REGISTRATION: FragmentRegistrationProxy = FragmentRegistrationProxy::new(); +static ADDITIONAL_CONTEXT_REGISTRATION: FragmentRegistrationProxy = + FragmentRegistrationProxy::new(); static SKILL_INSTRUCTIONS_REGISTRATION: FragmentRegistrationProxy = FragmentRegistrationProxy::new(); static USER_SHELL_COMMAND_REGISTRATION: FragmentRegistrationProxy = @@ -27,8 +30,9 @@ static TURN_ABORTED_REGISTRATION: FragmentRegistrationProxy = FragmentRegistrationProxy::new(); static SUBAGENT_NOTIFICATION_REGISTRATION: FragmentRegistrationProxy = FragmentRegistrationProxy::new(); -static GOAL_CONTEXT_REGISTRATION: FragmentRegistrationProxy = - FragmentRegistrationProxy::new(); +static INTERNAL_MODEL_CONTEXT_REGISTRATION: FragmentRegistrationProxy< + InternalModelContextFragment, +> = FragmentRegistrationProxy::new(); static LEGACY_UNIFIED_EXEC_PROCESS_LIMIT_WARNING_REGISTRATION: FragmentRegistrationProxy< LegacyUnifiedExecProcessLimitWarning, > = FragmentRegistrationProxy::new(); @@ -42,11 +46,12 @@ static LEGACY_MODEL_MISMATCH_WARNING_REGISTRATION: FragmentRegistrationProxy< static CONTEXTUAL_USER_FRAGMENTS: &[&dyn FragmentRegistration] = &[ &USER_INSTRUCTIONS_REGISTRATION, &ENVIRONMENT_CONTEXT_REGISTRATION, + &ADDITIONAL_CONTEXT_REGISTRATION, &SKILL_INSTRUCTIONS_REGISTRATION, &USER_SHELL_COMMAND_REGISTRATION, &TURN_ABORTED_REGISTRATION, &SUBAGENT_NOTIFICATION_REGISTRATION, - &GOAL_CONTEXT_REGISTRATION, + &INTERNAL_MODEL_CONTEXT_REGISTRATION, &LEGACY_UNIFIED_EXEC_PROCESS_LIMIT_WARNING_REGISTRATION, &LEGACY_APPLY_PATCH_EXEC_COMMAND_WARNING_REGISTRATION, &LEGACY_MODEL_MISMATCH_WARNING_REGISTRATION, diff --git a/codex-rs/core/src/context/contextual_user_message_tests.rs b/codex-rs/core/src/context/contextual_user_message_tests.rs index ee134cc685..dc7cb0f534 100644 --- a/codex-rs/core/src/context/contextual_user_message_tests.rs +++ b/codex-rs/core/src/context/contextual_user_message_tests.rs @@ -1,8 +1,12 @@ use super::*; use crate::context::ContextualUserFragment; +use crate::context::InternalContextSource; +use crate::context::InternalModelContextFragment; +use crate::context::SubagentNotification; use codex_protocol::items::HookPromptFragment; use codex_protocol::items::build_hook_prompt_message; use codex_protocol::models::ResponseItem; +use pretty_assertions::assert_eq; #[test] fn detects_environment_context_fragment() { @@ -27,17 +31,58 @@ fn detects_subagent_notification_fragment_case_insensitively() { } #[test] -fn detects_goal_context_fragment() { - let text = GoalContext { - prompt: "Continue working toward the active thread goal.".to_string(), - } +fn detects_internal_model_context_fragment() { + let text = InternalModelContextFragment::new( + InternalContextSource::from_static("goal"), + "Continue working toward the active thread goal.", + ) .render(); + assert_eq!( + text, + "\nContinue working toward the active thread goal.\n" + ); assert!(is_contextual_user_fragment(&ContentItem::InputText { text })); } +#[test] +fn detects_legacy_goal_context_fragment() { + assert!(is_contextual_user_fragment(&ContentItem::InputText { + text: "\nContinue working toward the active thread goal.\n" + .to_string(), + })); +} + +#[test] +fn does_not_hide_arbitrary_context_tags() { + assert!(!is_contextual_user_fragment(&ContentItem::InputText { + text: "\nbody\n".to_string(), + })); +} + +#[test] +fn rejects_invalid_internal_model_context_source() { + assert!(!is_contextual_user_fragment(&ContentItem::InputText { + text: "\nbody\n" + .to_string(), + })); +} + +#[test] +fn contextual_user_fragment_is_dyn_compatible() { + let fragment: Box = Box::new(InternalModelContextFragment::new( + InternalContextSource::from_static("goal"), + "Continue working toward the active thread goal.", + )); + + assert_eq!( + fragment.render(), + "\nContinue working toward the active thread goal.\n" + ); +} + #[test] fn ignores_regular_user_text() { assert!(!is_contextual_user_fragment(&ContentItem::InputText { diff --git a/codex-rs/core/src/context/environment_context.rs b/codex-rs/core/src/context/environment_context.rs index 272e3c617d..051147b7cd 100644 --- a/codex-rs/core/src/context/environment_context.rs +++ b/codex-rs/core/src/context/environment_context.rs @@ -1,9 +1,17 @@ use crate::session::turn_context::TurnContext; use crate::session::turn_context::TurnEnvironment; use crate::shell::Shell; +use codex_protocol::models::ManagedFileSystemPermissions; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; use codex_utils_absolute_path::AbsolutePathBuf; +use std::collections::HashSet; +use std::path::PathBuf; use super::ContextualUserFragment; @@ -13,6 +21,7 @@ pub(crate) struct EnvironmentContext { pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) network: Option, + pub(crate) filesystem: Option, pub(crate) subagents: Option, } @@ -83,6 +92,208 @@ impl EnvironmentContextEnvironments { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FileSystemContext { + workspace_roots: Vec, + permission_profile: FileSystemPermissionProfileContext, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum FileSystemPermissionProfileContext { + Managed(ManagedFileSystemContext), + Disabled, + External, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ManagedFileSystemContext { + Restricted { + entries: Vec, + glob_scan_max_depth: Option, + }, + Unrestricted, +} + +impl FileSystemContext { + fn from_permission_profile( + permission_profile: &PermissionProfile, + workspace_roots: &[AbsolutePathBuf], + ) -> Self { + let permission_profile = permission_profile + .clone() + .materialize_project_roots_with_workspace_roots(workspace_roots); + let workspace_roots = workspace_roots + .iter() + .map(|root| root.to_string_lossy().into_owned()) + .collect(); + let permission_profile = match permission_profile { + PermissionProfile::Managed { file_system, .. } => { + FileSystemPermissionProfileContext::Managed(ManagedFileSystemContext::from( + file_system, + )) + } + PermissionProfile::Disabled => FileSystemPermissionProfileContext::Disabled, + PermissionProfile::External { .. } => FileSystemPermissionProfileContext::External, + }; + Self { + workspace_roots, + permission_profile, + } + } + + fn render(&self) -> String { + let mut rendered = "".to_string(); + if !self.workspace_roots.is_empty() { + rendered.push_str(""); + for root in &self.workspace_roots { + push_text_element(&mut rendered, "root", root); + } + rendered.push_str(""); + } + self.permission_profile.render(&mut rendered); + rendered.push_str(""); + rendered + } +} + +impl From for ManagedFileSystemContext { + fn from(file_system: ManagedFileSystemPermissions) -> Self { + match file_system { + ManagedFileSystemPermissions::Restricted { + mut entries, + glob_scan_max_depth, + } => { + dedupe_file_system_entries(&mut entries); + Self::Restricted { + entries, + glob_scan_max_depth: glob_scan_max_depth.map(usize::from), + } + } + ManagedFileSystemPermissions::Unrestricted => Self::Unrestricted, + } + } +} + +impl FileSystemPermissionProfileContext { + fn render(&self, rendered: &mut String) { + match self { + Self::Managed(file_system) => { + rendered.push_str(""); + file_system.render(rendered); + rendered.push_str(""); + } + Self::Disabled => { + rendered.push_str( + "", + ); + } + Self::External => { + rendered.push_str( + "", + ); + } + } + } +} + +impl ManagedFileSystemContext { + fn render(&self, rendered: &mut String) { + match self { + Self::Restricted { + entries, + glob_scan_max_depth, + } => { + if entries.is_empty() && glob_scan_max_depth.is_none() { + rendered.push_str(""); + return; + } + + rendered.push_str("'); + for entry in entries { + render_file_system_entry(rendered, entry); + } + rendered.push_str(""); + } + Self::Unrestricted => { + rendered.push_str(""); + } + } + } +} + +fn render_file_system_entry(rendered: &mut String, entry: &FileSystemSandboxEntry) { + rendered.push_str(""); + match &entry.path { + FileSystemPath::Path { path } => { + push_text_element(rendered, "path", path.to_string_lossy().as_ref()); + } + FileSystemPath::GlobPattern { pattern } => { + push_text_element(rendered, "glob", pattern); + } + FileSystemPath::Special { value } => { + let value = render_special_path(value); + push_text_element(rendered, "special", &value); + } + } + rendered.push_str(""); +} + +fn render_special_path(value: &FileSystemSpecialPath) -> String { + match value { + FileSystemSpecialPath::Root => ":root".to_string(), + FileSystemSpecialPath::Minimal => ":minimal".to_string(), + FileSystemSpecialPath::ProjectRoots { subpath } => { + render_special_path_with_subpath(":workspace_roots", subpath) + } + FileSystemSpecialPath::Tmpdir => ":tmpdir".to_string(), + FileSystemSpecialPath::SlashTmp => ":slash_tmp".to_string(), + FileSystemSpecialPath::Unknown { path, subpath } => { + render_special_path_with_subpath(path, subpath) + } + } +} + +fn render_special_path_with_subpath(base: &str, subpath: &Option) -> String { + match subpath { + Some(subpath) => format!("{base}/{}", subpath.display()), + None => base.to_string(), + } +} + +fn dedupe_file_system_entries(entries: &mut Vec) { + let mut seen = HashSet::new(); + entries.retain(|entry| seen.insert(entry.clone())); +} + +fn push_text_element(rendered: &mut String, name: &str, value: &str) { + rendered.push_str(&format!("<{name}>")); + push_xml_escaped_text(rendered, value); + rendered.push_str(&format!("")); +} + +fn push_xml_escaped_text(rendered: &mut String, value: &str) { + for ch in value.chars() { + match ch { + '&' => rendered.push_str("&"), + '<' => rendered.push_str("<"), + '>' => rendered.push_str(">"), + '"' => rendered.push_str("""), + '\'' => rendered.push_str("'"), + _ => rendered.push(ch), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Default)] pub(crate) struct NetworkContext { allowed_domains: Vec, @@ -129,6 +340,7 @@ impl EnvironmentContext { current_date, timezone, network, + filesystem: None, subagents, } } @@ -138,6 +350,7 @@ impl EnvironmentContext { current_date: Option, timezone: Option, network: Option, + filesystem: Option, subagents: Option, ) -> Self { Self { @@ -145,6 +358,7 @@ impl EnvironmentContext { current_date, timezone, network, + filesystem, subagents, } } @@ -157,6 +371,7 @@ impl EnvironmentContext { && self.current_date == other.current_date && self.timezone == other.timezone && self.network == other.network + && self.filesystem == other.filesystem && self.subagents == other.subagents } @@ -165,6 +380,7 @@ impl EnvironmentContext { after: &EnvironmentContext, ) -> Self { let before_network = Self::network_from_turn_context_item(before); + let before_filesystem = Self::filesystem_from_turn_context_item(before); let environments = match &after.environments { EnvironmentContextEnvironments::Single(environment) => { if before.cwd.as_path() != environment.cwd.as_path() { @@ -186,17 +402,23 @@ impl EnvironmentContext { } else { before_network }; + let filesystem = if before_filesystem != after.filesystem { + after.filesystem.clone() + } else { + before_filesystem + }; EnvironmentContext::new_with_environments( environments, after.current_date.clone(), after.timezone.clone(), network, + filesystem, /*subagents*/ None, ) } pub(crate) fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self { - Self::new( + let mut context = Self::new( EnvironmentContextEnvironment::from_turn_environments( &turn_context.environments.turn_environments, shell, @@ -205,7 +427,12 @@ impl EnvironmentContext { turn_context.timezone.clone(), Self::network_from_turn_context(turn_context), /*subagents*/ None, - ) + ); + context.filesystem = Some(FileSystemContext::from_permission_profile( + &turn_context.permission_profile, + &turn_context.config.effective_workspace_roots(), + )); + context } pub(crate) fn from_turn_context_item( @@ -216,11 +443,14 @@ impl EnvironmentContext { Ok(cwd) => cwd, Err(_) => AbsolutePathBuf::resolve_path_against_base(&turn_context_item.cwd, "/"), }; - Self::new( - vec![EnvironmentContextEnvironment::legacy(cwd, shell)], + Self::new_with_environments( + EnvironmentContextEnvironments::from_vec(vec![EnvironmentContextEnvironment::legacy( + cwd, shell, + )]), turn_context_item.current_date.clone(), turn_context_item.timezone.clone(), Self::network_from_turn_context_item(turn_context_item), + Self::filesystem_from_turn_context_item(turn_context_item), /*subagents*/ None, ) } @@ -266,12 +496,47 @@ impl EnvironmentContext { denied_domains.clone(), )) } + + fn filesystem_from_turn_context_item( + turn_context_item: &TurnContextItem, + ) -> Option { + Some(FileSystemContext::from_permission_profile( + &turn_context_item.permission_profile(), + &workspace_roots_from_turn_context_item(turn_context_item), + )) + } +} + +fn workspace_roots_from_turn_context_item( + turn_context_item: &TurnContextItem, +) -> Vec { + if let Some(workspace_roots) = turn_context_item.workspace_roots.as_ref() { + return workspace_roots.clone(); + } + + // Older rollout items did not persist workspace roots. Fall back to the + // legacy cwd binding only when reconstructing that historical context. + match AbsolutePathBuf::try_from(turn_context_item.cwd.clone()) { + Ok(cwd) => vec![cwd], + Err(_) => Vec::new(), + } } impl ContextualUserFragment for EnvironmentContext { - const ROLE: &'static str = "user"; - const START_MARKER: &'static str = codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; - const END_MARKER: &'static str = codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG; + fn role() -> &'static str { + "user" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ( + codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG, + codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG, + ) + } fn body(&self) -> String { let mut lines = Vec::new(); @@ -313,6 +578,9 @@ impl ContextualUserFragment for EnvironmentContext { // lines.push(" ".to_string()); } } + if let Some(filesystem) = &self.filesystem { + lines.push(format!(" {}", filesystem.render())); + } if let Some(subagents) = &self.subagents { lines.push(" ".to_string()); lines.extend(subagents.lines().map(|line| format!(" {line}"))); diff --git a/codex-rs/core/src/context/environment_context_tests.rs b/codex-rs/core/src/context/environment_context_tests.rs index 68ff7c9d44..875ce20c09 100644 --- a/codex-rs/core/src/context/environment_context_tests.rs +++ b/codex-rs/core/src/context/environment_context_tests.rs @@ -1,9 +1,21 @@ use crate::shell::ShellType; use super::*; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::FileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy; +use codex_protocol::permissions::project_roots_glob_pattern; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::TurnContextItem; use codex_utils_absolute_path::test_support::PathBufExt; use core_test_support::test_path_buf; use pretty_assertions::assert_eq; +use std::path::Path; use std::path::PathBuf; fn fake_shell_name() -> String { @@ -79,6 +91,125 @@ fn serialize_environment_context_with_network() { assert_eq!(context.render(), expected); } +fn workspace_write_permission_profile_with_private_denials() -> PermissionProfile { + PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(/*subpath*/ None), + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::project_roots(Some(PathBuf::from("private"))), + }, + access: FileSystemAccessMode::Deny, + }, + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: project_roots_glob_pattern(Path::new("private/**")), + }, + access: FileSystemAccessMode::Deny, + }, + ]), + NetworkSandboxPolicy::Restricted, + ) +} + +#[test] +fn serialize_environment_context_with_full_filesystem_profile() { + let repo = test_abs_path("/repo"); + let other_repo = test_abs_path("/other-repo"); + let repo_private = repo.join("private"); + let other_repo_private = other_repo.join("private"); + let repo_private_glob = + AbsolutePathBuf::resolve_path_against_base(Path::new("private/**"), repo.as_path()); + let other_repo_private_glob = + AbsolutePathBuf::resolve_path_against_base(Path::new("private/**"), other_repo.as_path()); + let mut context = EnvironmentContext::new( + vec![EnvironmentContextEnvironment { + id: "local".to_string(), + cwd: test_path_buf("/repo").abs(), + shell: fake_shell_name(), + }], + /*current_date*/ None, + /*timezone*/ None, + /*network*/ None, + /*subagents*/ None, + ); + context.filesystem = Some(FileSystemContext::from_permission_profile( + &workspace_write_permission_profile_with_private_denials(), + &[repo.clone(), other_repo.clone()], + )); + + let expected = format!( + r#" + {} + bash + {repo}{other_repo}{repo}{other_repo}{repo_private}{other_repo_private}{repo_private_glob}{other_repo_private_glob} +"#, + test_path_buf("/repo").display(), + repo = repo.to_string_lossy(), + other_repo = other_repo.to_string_lossy(), + repo_private = repo_private.to_string_lossy(), + other_repo_private = other_repo_private.to_string_lossy(), + repo_private_glob = repo_private_glob.to_string_lossy(), + other_repo_private_glob = other_repo_private_glob.to_string_lossy(), + ); + + assert_eq!(context.render(), expected); +} + +#[test] +fn turn_context_item_filesystem_uses_workspace_roots_instead_of_cwd() { + let repo = test_abs_path("/repo"); + let other_repo = test_abs_path("/other-repo"); + let repo_private = repo.join("private"); + let item = TurnContextItem { + turn_id: None, + cwd: test_path_buf("/not-the-workspace"), + workspace_roots: Some(vec![repo.clone(), other_repo.clone()]), + current_date: None, + timezone: None, + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + permission_profile: Some(workspace_write_permission_profile_with_private_denials()), + network: None, + file_system_sandbox_policy: None, + model: "gpt-5".to_string(), + personality: None, + collaboration_mode: None, + realtime_active: None, + effort: None, + summary: codex_protocol::config_types::ReasoningSummary::Auto, + }; + + let context = EnvironmentContext::from_turn_context_item(&item, fake_shell_name()).render(); + + assert!( + context.contains(&format!( + "{}{}", + repo.to_string_lossy(), + other_repo.to_string_lossy() + )), + "{context}" + ); + assert!( + context.contains(&format!("{}", repo_private.to_string_lossy())), + "{context}" + ); + assert!( + !context.contains( + test_abs_path("/not-the-workspace") + .join("private") + .to_string_lossy() + .as_ref() + ), + "{context}" + ); +} + #[test] fn serialize_read_only_environment_context() { let context = EnvironmentContext::new( diff --git a/codex-rs/core/src/context/fragment.rs b/codex-rs/core/src/context/fragment.rs index 1cc8f6d9b8..5c500fe8ef 100644 --- a/codex-rs/core/src/context/fragment.rs +++ b/codex-rs/core/src/context/fragment.rs @@ -1,4 +1,5 @@ use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use std::marker::PhantomData; @@ -38,37 +39,34 @@ impl FragmentRegistration for FragmentRegistrationPro /// in which case the default helpers render only the body and never match /// arbitrary text. pub trait ContextualUserFragment { - const ROLE: &'static str; - const START_MARKER: &'static str; - const END_MARKER: &'static str; + fn role() -> &'static str + where + Self: Sized; + + fn markers(&self) -> (&'static str, &'static str); fn body(&self) -> String; + fn type_markers() -> (&'static str, &'static str) + where + Self: Sized; + fn matches_text(text: &str) -> bool where Self: Sized, { - if Self::START_MARKER.is_empty() || Self::END_MARKER.is_empty() { - return false; - } - - let trimmed = text.trim_start(); - let starts_with_marker = trimmed - .get(..Self::START_MARKER.len()) - .is_some_and(|candidate| candidate.eq_ignore_ascii_case(Self::START_MARKER)); - let trimmed = trimmed.trim_end(); - let ends_with_marker = trimmed - .get(trimmed.len().saturating_sub(Self::END_MARKER.len())..) - .is_some_and(|candidate| candidate.eq_ignore_ascii_case(Self::END_MARKER)); - starts_with_marker && ends_with_marker + let (start_marker, end_marker) = Self::type_markers(); + matches_marked_text(start_marker, end_marker, text) } fn render(&self) -> String { - if Self::START_MARKER.is_empty() && Self::END_MARKER.is_empty() { - return self.body(); + let (start_marker, end_marker) = self.markers(); + let body = self.body(); + if start_marker.is_empty() && end_marker.is_empty() { + return body; } - format!("{}{}{}", Self::START_MARKER, self.body(), Self::END_MARKER) + format!("{start_marker}{body}{end_marker}") } fn into(self) -> ResponseItem @@ -77,7 +75,20 @@ pub trait ContextualUserFragment { { ResponseItem::Message { id: None, - role: Self::ROLE.to_string(), + role: Self::role().to_string(), + content: vec![ContentItem::InputText { + text: self.render(), + }], + phase: None, + } + } + + fn into_response_input_item(self) -> ResponseInputItem + where + Self: Sized, + { + ResponseInputItem::Message { + role: Self::role().to_string(), content: vec![ContentItem::InputText { text: self.render(), }], @@ -85,3 +96,19 @@ pub trait ContextualUserFragment { } } } + +fn matches_marked_text(start_marker: &str, end_marker: &str, text: &str) -> bool { + if start_marker.is_empty() || end_marker.is_empty() { + return false; + } + + let trimmed = text.trim_start(); + let starts_with_marker = trimmed + .get(..start_marker.len()) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(start_marker)); + let trimmed = trimmed.trim_end(); + let ends_with_marker = trimmed + .get(trimmed.len().saturating_sub(end_marker.len())..) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(end_marker)); + starts_with_marker && ends_with_marker +} diff --git a/codex-rs/core/src/context/fragments.rs b/codex-rs/core/src/context/fragments.rs new file mode 100644 index 0000000000..e23f96f59f --- /dev/null +++ b/codex-rs/core/src/context/fragments.rs @@ -0,0 +1,92 @@ +use super::ContextualUserFragment; +use codex_utils_string::truncate_middle_with_token_budget; + +const MAX_ADDITIONAL_CONTEXT_VALUE_TOKENS: usize = 1_000; +const ADDITIONAL_CONTEXT_END_MARKER_SUFFIX: &str = ">"; +const ADDITIONAL_CONTEXT_START_MARKER_PREFIX: &str = " Self { + Self { key, value } + } +} + +impl ContextualUserFragment for AdditionalContextUserFragment { + fn role() -> &'static str { + "user" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ( + ADDITIONAL_CONTEXT_START_MARKER_PREFIX, + ADDITIONAL_CONTEXT_END_MARKER_SUFFIX, + ) + } + + fn matches_text(text: &str) -> bool { + let trimmed = text.trim(); + let Some(rest) = trimmed.strip_prefix(ADDITIONAL_CONTEXT_START_MARKER_PREFIX) else { + return false; + }; + let Some((key, value_and_close)) = rest.split_once(ADDITIONAL_CONTEXT_END_MARKER_SUFFIX) + else { + return false; + }; + + value_and_close.ends_with(&format!("")) + } + + fn body(&self) -> String { + additional_context_body(&self.key, &self.value) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AdditionalContextDeveloperFragment { + key: String, + value: String, +} + +impl AdditionalContextDeveloperFragment { + pub(crate) fn new(key: String, value: String) -> Self { + Self { key, value } + } +} + +impl ContextualUserFragment for AdditionalContextDeveloperFragment { + fn role() -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } + + fn body(&self) -> String { + additional_context_developer_body(&self.key, &self.value) + } +} + +fn additional_context_body(key: &str, value: &str) -> String { + let value = truncate_middle_with_token_budget(value, MAX_ADDITIONAL_CONTEXT_VALUE_TOKENS).0; + format!("{key}>{value} String { + let value = truncate_middle_with_token_budget(value, MAX_ADDITIONAL_CONTEXT_VALUE_TOKENS).0; + format!("<{key}>{value}") +} diff --git a/codex-rs/core/src/context/goal_context.rs b/codex-rs/core/src/context/goal_context.rs deleted file mode 100644 index d259273324..0000000000 --- a/codex-rs/core/src/context/goal_context.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! Hidden user-context fragment for runtime-owned goal steering prompts. - -use super::ContextualUserFragment; - -#[derive(Debug, Clone, PartialEq)] -pub(crate) struct GoalContext { - pub(crate) prompt: String, -} - -impl ContextualUserFragment for GoalContext { - const ROLE: &'static str = "user"; - const START_MARKER: &'static str = ""; - const END_MARKER: &'static str = ""; - - fn body(&self) -> String { - format!("\n{}\n", self.prompt) - } -} diff --git a/codex-rs/core/src/context/guardian_followup_review_reminder.rs b/codex-rs/core/src/context/guardian_followup_review_reminder.rs index 972355ba95..4de750629c 100644 --- a/codex-rs/core/src/context/guardian_followup_review_reminder.rs +++ b/codex-rs/core/src/context/guardian_followup_review_reminder.rs @@ -4,9 +4,17 @@ use super::ContextualUserFragment; pub(crate) struct GuardianFollowupReviewReminder; impl ContextualUserFragment for GuardianFollowupReviewReminder { - const ROLE: &'static str = "developer"; - const START_MARKER: &'static str = ""; - const END_MARKER: &'static str = ""; + fn role() -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } fn body(&self) -> String { concat!( diff --git a/codex-rs/core/src/context/hook_additional_context.rs b/codex-rs/core/src/context/hook_additional_context.rs index 01c95a6524..1094261665 100644 --- a/codex-rs/core/src/context/hook_additional_context.rs +++ b/codex-rs/core/src/context/hook_additional_context.rs @@ -12,9 +12,17 @@ impl HookAdditionalContext { } impl ContextualUserFragment for HookAdditionalContext { - const ROLE: &'static str = "developer"; - const START_MARKER: &'static str = ""; - const END_MARKER: &'static str = ""; + fn role() -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } fn body(&self) -> String { self.text.clone() diff --git a/codex-rs/core/src/context/image_generation_instructions.rs b/codex-rs/core/src/context/image_generation_instructions.rs index 01abee9096..7ddb231b8f 100644 --- a/codex-rs/core/src/context/image_generation_instructions.rs +++ b/codex-rs/core/src/context/image_generation_instructions.rs @@ -17,9 +17,17 @@ impl ImageGenerationInstructions { } impl ContextualUserFragment for ImageGenerationInstructions { - const ROLE: &'static str = "developer"; - const START_MARKER: &'static str = ""; - const END_MARKER: &'static str = ""; + fn role() -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } fn body(&self) -> String { format!( diff --git a/codex-rs/core/src/context/internal_model_context.rs b/codex-rs/core/src/context/internal_model_context.rs new file mode 100644 index 0000000000..a25e77a82a --- /dev/null +++ b/codex-rs/core/src/context/internal_model_context.rs @@ -0,0 +1,129 @@ +//! Hidden user-context fragment for extension-owned model steering. + +use super::ContextualUserFragment; +use std::error::Error; +use std::fmt; + +const CONTEXT_START_MARKER: &str = ""; + +/// Source label for hidden internal model context. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InternalContextSource(String); + +impl InternalContextSource { + /// Creates a source label for an internal model-context fragment. + /// + /// Sources are intentionally constrained so the value can be embedded in the + /// wrapper without escaping and still remain easy to audit in stored history. + pub fn new(source: impl Into) -> Result { + let source = source.into(); + if is_valid_source(&source) { + Ok(Self(source)) + } else { + Err(InvalidInternalContextSource { source }) + } + } + + /// Creates a source label from a trusted static string. + pub fn from_static(source: &'static str) -> Self { + Self::new(source) + .unwrap_or_else(|_| panic!("invalid static internal context source: {source}")) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +/// Error returned when an internal model-context source is invalid. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InvalidInternalContextSource { + source: String, +} + +impl fmt::Display for InvalidInternalContextSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let source = &self.source; + write!( + f, + "invalid internal model context source {source:?}; expected [a-z][a-z0-9_]*" + ) + } +} + +impl Error for InvalidInternalContextSource {} + +/// Hidden runtime-owned context injected into model input. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InternalModelContextFragment { + source: InternalContextSource, + body: String, +} + +impl InternalModelContextFragment { + /// Creates hidden model context with an extension-owned source label. + pub fn new(source: InternalContextSource, body: impl Into) -> Self { + Self { + source, + body: body.into(), + } + } +} + +impl ContextualUserFragment for InternalModelContextFragment { + fn role() -> &'static str { + "user" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + (CONTEXT_START_MARKER, CONTEXT_END_MARKER) + } + + fn matches_text(text: &str) -> bool { + let trimmed = text.trim(); + if matches_legacy_goal_context(trimmed) { + return true; + } + + let Some(rest) = trimmed.strip_prefix(CONTEXT_START_MARKER) else { + return false; + }; + let Some(rest) = rest.strip_prefix(SOURCE_ATTR_START) else { + return false; + }; + let Some((source, body_and_close)) = rest.split_once(SOURCE_ATTR_END) else { + return false; + }; + + is_valid_source(source) && body_and_close.ends_with(CONTEXT_END_MARKER) + } + + fn body(&self) -> String { + let source = self.source.as_str(); + let body = &self.body; + format!(" source=\"{source}\">\n{body}\n") + } +} + +fn matches_legacy_goal_context(text: &str) -> bool { + text.starts_with(LEGACY_GOAL_CONTEXT_START_MARKER) + && text.ends_with(LEGACY_GOAL_CONTEXT_END_MARKER) +} + +fn is_valid_source(source: &str) -> bool { + let mut chars = source.chars(); + let Some(first) = chars.next() else { + return false; + }; + first.is_ascii_lowercase() + && chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_') +} diff --git a/codex-rs/core/src/context/legacy_apply_patch_exec_command_warning.rs b/codex-rs/core/src/context/legacy_apply_patch_exec_command_warning.rs index d8fd7da8e9..b9ecbf6170 100644 --- a/codex-rs/core/src/context/legacy_apply_patch_exec_command_warning.rs +++ b/codex-rs/core/src/context/legacy_apply_patch_exec_command_warning.rs @@ -5,9 +5,17 @@ use super::ContextualUserFragment; pub(crate) struct LegacyApplyPatchExecCommandWarning; impl ContextualUserFragment for LegacyApplyPatchExecCommandWarning { - const ROLE: &'static str = "user"; - const START_MARKER: &'static str = ""; - const END_MARKER: &'static str = ""; + fn role() -> &'static str { + "user" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } fn matches_text(text: &str) -> bool { let trimmed = text.trim(); diff --git a/codex-rs/core/src/context/legacy_model_mismatch_warning.rs b/codex-rs/core/src/context/legacy_model_mismatch_warning.rs index a6afa9d7d1..9b769e11e9 100644 --- a/codex-rs/core/src/context/legacy_model_mismatch_warning.rs +++ b/codex-rs/core/src/context/legacy_model_mismatch_warning.rs @@ -5,9 +5,17 @@ use super::ContextualUserFragment; pub(crate) struct LegacyModelMismatchWarning; impl ContextualUserFragment for LegacyModelMismatchWarning { - const ROLE: &'static str = "user"; - const START_MARKER: &'static str = ""; - const END_MARKER: &'static str = ""; + fn role() -> &'static str { + "user" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } fn matches_text(text: &str) -> bool { text.trim().starts_with( diff --git a/codex-rs/core/src/context/legacy_unified_exec_process_limit_warning.rs b/codex-rs/core/src/context/legacy_unified_exec_process_limit_warning.rs index 463aeaa8aa..54f1a40e3e 100644 --- a/codex-rs/core/src/context/legacy_unified_exec_process_limit_warning.rs +++ b/codex-rs/core/src/context/legacy_unified_exec_process_limit_warning.rs @@ -5,9 +5,17 @@ use super::ContextualUserFragment; pub(crate) struct LegacyUnifiedExecProcessLimitWarning; impl ContextualUserFragment for LegacyUnifiedExecProcessLimitWarning { - const ROLE: &'static str = "user"; - const START_MARKER: &'static str = ""; - const END_MARKER: &'static str = ""; + fn role() -> &'static str { + "user" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } fn matches_text(text: &str) -> bool { text.trim().starts_with( diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index bd89025368..3e01d5212e 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -8,10 +8,11 @@ mod collaboration_mode_instructions; mod contextual_user_message; mod environment_context; mod fragment; -mod goal_context; +mod fragments; mod guardian_followup_review_reminder; mod hook_additional_context; mod image_generation_instructions; +mod internal_model_context; mod legacy_apply_patch_exec_command_warning; mod legacy_model_mismatch_warning; mod legacy_unified_exec_process_limit_warning; @@ -40,10 +41,14 @@ pub(crate) use environment_context::EnvironmentContext; pub use fragment::ContextualUserFragment; pub(crate) use fragment::FragmentRegistration; pub(crate) use fragment::FragmentRegistrationProxy; -pub(crate) use goal_context::GoalContext; +pub(crate) use fragments::AdditionalContextDeveloperFragment; +pub(crate) use fragments::AdditionalContextUserFragment; pub(crate) use guardian_followup_review_reminder::GuardianFollowupReviewReminder; pub(crate) use hook_additional_context::HookAdditionalContext; pub(crate) use image_generation_instructions::ImageGenerationInstructions; +pub use internal_model_context::InternalContextSource; +pub use internal_model_context::InternalModelContextFragment; +pub use internal_model_context::InvalidInternalContextSource; pub(crate) use legacy_apply_patch_exec_command_warning::LegacyApplyPatchExecCommandWarning; pub(crate) use legacy_model_mismatch_warning::LegacyModelMismatchWarning; pub(crate) use legacy_unified_exec_process_limit_warning::LegacyUnifiedExecProcessLimitWarning; diff --git a/codex-rs/core/src/context/model_switch_instructions.rs b/codex-rs/core/src/context/model_switch_instructions.rs index 0b75afb61a..f15dcd22e0 100644 --- a/codex-rs/core/src/context/model_switch_instructions.rs +++ b/codex-rs/core/src/context/model_switch_instructions.rs @@ -14,9 +14,17 @@ impl ModelSwitchInstructions { } impl ContextualUserFragment for ModelSwitchInstructions { - const ROLE: &'static str = "developer"; - const START_MARKER: &'static str = ""; - const END_MARKER: &'static str = ""; + fn role() -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } fn body(&self) -> String { format!( diff --git a/codex-rs/core/src/context/network_rule_saved.rs b/codex-rs/core/src/context/network_rule_saved.rs index 8162e6bda2..5b45dbb560 100644 --- a/codex-rs/core/src/context/network_rule_saved.rs +++ b/codex-rs/core/src/context/network_rule_saved.rs @@ -18,9 +18,17 @@ impl NetworkRuleSaved { } impl ContextualUserFragment for NetworkRuleSaved { - const ROLE: &'static str = "developer"; - const START_MARKER: &'static str = ""; - const END_MARKER: &'static str = ""; + fn role() -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } fn body(&self) -> String { let (action, list_name) = match self.action { diff --git a/codex-rs/core/src/context/permissions_instructions.rs b/codex-rs/core/src/context/permissions_instructions.rs index 0ccd6c33a7..513390d592 100644 --- a/codex-rs/core/src/context/permissions_instructions.rs +++ b/codex-rs/core/src/context/permissions_instructions.rs @@ -1,357 +1,21 @@ use super::ContextualUserFragment; -use codex_execpolicy::Policy; -use codex_protocol::config_types::ApprovalsReviewer; -use codex_protocol::config_types::SandboxMode; -use codex_protocol::models::PermissionProfile; -use codex_protocol::models::format_allow_prefixes; -use codex_protocol::permissions::NetworkSandboxPolicy; -use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::GranularApprovalConfig; -use codex_protocol::protocol::NetworkAccess; -use codex_protocol::protocol::SandboxPolicy; -use codex_protocol::protocol::WritableRoot; -use codex_utils_template::Template; -use std::path::Path; -use std::sync::LazyLock; -const APPROVAL_POLICY_NEVER: &str = include_str!("prompts/permissions/approval_policy/never.md"); -const APPROVAL_POLICY_UNLESS_TRUSTED: &str = - include_str!("prompts/permissions/approval_policy/unless_trusted.md"); -const APPROVAL_POLICY_ON_FAILURE: &str = - include_str!("prompts/permissions/approval_policy/on_failure.md"); -const APPROVAL_POLICY_ON_REQUEST_RULE: &str = - include_str!("prompts/permissions/approval_policy/on_request.md"); -const APPROVAL_POLICY_ON_REQUEST_RULE_REQUEST_PERMISSION: &str = - include_str!("prompts/permissions/approval_policy/on_request_rule_request_permission.md"); -const AUTO_REVIEW_APPROVAL_SUFFIX: &str = "`approvals_reviewer` is `auto_review`: Sandbox escalations with require_escalated will be reviewed for compliance with the policy. If a rejection happens, you should proceed only with a materially safer alternative, or inform the user of the risk and send a final message to ask for approval."; - -const SANDBOX_MODE_DANGER_FULL_ACCESS: &str = - include_str!("prompts/permissions/sandbox_mode/danger_full_access.md"); -const SANDBOX_MODE_WORKSPACE_WRITE: &str = - include_str!("prompts/permissions/sandbox_mode/workspace_write.md"); -const SANDBOX_MODE_READ_ONLY: &str = include_str!("prompts/permissions/sandbox_mode/read_only.md"); - -static SANDBOX_MODE_DANGER_FULL_ACCESS_TEMPLATE: LazyLock